Microservice to bring 2FA to self hosted PDSes

PDS Gatekeeper Authentication Middleware#

This document describes the authentication middleware system for pds-gatekeeper, which provides flexible authorization rules based on DIDs, handles, and OAuth scopes.

Overview#

The auth middleware validates incoming requests by:

  1. Extracting the DID and scopes from a JWT Bearer token
  2. Resolving the DID to a handle using jacquard-identity
  3. Validating against configured authorization rules
  4. Returning appropriate HTTP errors (401/403) on failure

Quick Start#

use axum::middleware::from_fn_with_state;
use crate::auth::{auth_middleware, handle_ends_with, scope_equals, with_rules, AuthRules};

let app = Router::new()
    // Simple: require handle from specific domain
    .route("/xrpc/community.blacksky.feed.get",
        get(handler).layer(from_fn_with_state(
            handle_ends_with(".blacksky.team", &state),
            auth_middleware
        )))

    // Simple: require specific OAuth scope
    .route("/xrpc/com.atproto.repo.createRecord",
        post(handler).layer(from_fn_with_state(
            scope_equals("repo:app.bsky.feed.post", &state),
            auth_middleware
        )))

    .with_state(state);

ATProto OAuth Scopes Reference#

Scope Description
atproto Base scope, required for all OAuth clients
transition:generic Full repository access (equivalent to app passwords)
repo:<collection> Access to specific collection (e.g., repo:app.bsky.feed.post)
identity:handle Permits handle changes
identity:* Full DID document control
account:email Read email addresses
account:repo?action=manage Import repository data
blob:*/* Upload any blob type
blob?accept=image/* Upload only images

See Marvin's Guide to OAuth Scopes for complete details.

Helper Functions#

Identity Helpers#

Function Description
handle_ends_with(suffix, state) Handle must end with suffix
handle_ends_with_any(suffixes, state) Handle must end with any suffix (OR)
did_equals(did, state) DID must match exactly
did_equals_any(dids, state) DID must match any value (OR)

Scope Helpers#

Function Description
scope_equals(scope, state) Must have specific scope
scope_any(scopes, state) Must have any of the scopes (OR)
scope_all(scopes, state) Must have all scopes (AND)

Combined Helpers (Identity + Scope)#

Function Description
handle_ends_with_and_scope(suffix, scope, state) Handle suffix AND scope
handle_ends_with_and_scopes(suffix, scopes, state) Handle suffix AND all scopes
did_with_scope(did, scope, state) DID match AND scope
did_with_scopes(did, scopes, state) DID match AND all scopes

Custom Rules#

For complex authorization logic, use with_rules():

with_rules(AuthRules::Any(vec![
    AuthRules::DidEquals("did:plc:rnpkyqnmsw4ipey6eotbdnnf".into()),
    AuthRules::All(vec![
        AuthRules::HandleEndsWith(".mod.team".into()),
        AuthRules::ScopeEquals("account:email".into()),
    ]),
]), &state)

Realistic PDS Endpoint Examples#

Admin Endpoints#

Based on com.atproto.admin.* endpoints from the ATProto PDS:

// com.atproto.admin.deleteAccount
// Admin-only: specific DID with full access scope
.route("/xrpc/com.atproto.admin.deleteAccount",
    post(delete_account).layer(from_fn_with_state(
        did_with_scope("did:plc:rnpkyqnmsw4ipey6eotbdnnf", "transition:generic", &state),
        auth_middleware
    )))

// com.atproto.admin.getAccountInfo
// Either admin DID OR (moderator handle + account scope)
.route("/xrpc/com.atproto.admin.getAccountInfo",
    get(get_account_info).layer(from_fn_with_state(
        with_rules(AuthRules::Any(vec![
            AuthRules::DidEquals("did:plc:rnpkyqnmsw4ipey6eotbdnnf".into()),
            AuthRules::All(vec![
                AuthRules::HandleEndsWith(".mod.team".into()),
                AuthRules::ScopeEquals("account:email".into()),
            ]),
        ]), &state),
        auth_middleware
    )))

// com.atproto.admin.updateAccountEmail
// Admin DID with account management scope
.route("/xrpc/com.atproto.admin.updateAccountEmail",
    post(update_email).layer(from_fn_with_state(
        did_with_scopes(
            "did:plc:rnpkyqnmsw4ipey6eotbdnnf",
            ["account:email", "account:repo?action=manage"],
            &state
        ),
        auth_middleware
    )))

// com.atproto.admin.updateAccountHandle
// Admin with identity control
.route("/xrpc/com.atproto.admin.updateAccountHandle",
    post(update_handle).layer(from_fn_with_state(
        did_with_scope("did:plc:rnpkyqnmsw4ipey6eotbdnnf", "identity:*", &state),
        auth_middleware
    )))

Repository Endpoints#

// com.atproto.repo.createRecord
// Scoped write access to specific collection
.route("/xrpc/com.atproto.repo.createRecord",
    post(create_record).layer(from_fn_with_state(
        scope_equals("repo:app.bsky.feed.post", &state),
        auth_middleware
    )))

// com.atproto.repo.putRecord
// Either specific collection scope OR full access
.route("/xrpc/com.atproto.repo.putRecord",
    post(put_record).layer(from_fn_with_state(
        scope_any(["repo:app.bsky.feed.post", "transition:generic"], &state),
        auth_middleware
    )))

// com.atproto.repo.uploadBlob
// Blob upload with media type restriction (scope-based)
.route("/xrpc/com.atproto.repo.uploadBlob",
    post(upload_blob).layer(from_fn_with_state(
        scope_any(["blob:*/*", "blob?accept=image/*", "transition:generic"], &state),
        auth_middleware
    )))

Community/Custom Endpoints#

// Community feed generator - restricted to team members with full access
.route("/xrpc/community.blacksky.feed.generator",
    post(generator).layer(from_fn_with_state(
        handle_ends_with_and_scope(".blacksky.team", "transition:generic", &state),
        auth_middleware
    )))

// Multi-community endpoint
.route("/xrpc/community.shared.moderation.report",
    post(report).layer(from_fn_with_state(
        with_rules(AuthRules::All(vec![
            AuthRules::HandleEndsWithAny(vec![
                ".blacksky.team".into(),
                ".bsky.team".into(),
                ".mod.social".into(),
            ]),
            AuthRules::ScopeEquals("atproto".into()),
        ]), &state),
        auth_middleware
    )))

// VIP access - specific DIDs only
.route("/xrpc/community.blacksky.vip.access",
    get(vip_handler).layer(from_fn_with_state(
        did_equals_any([
            "did:plc:rnpkyqnmsw4ipey6eotbdnnf",
            "did:plc:abc123def456ghi789jklmno",
            "did:plc:xyz987uvw654rst321qponml",
        ], &state),
        auth_middleware
    )))

Building Complex Authorization Rules#

The AuthRules enum supports arbitrary nesting:

// Complex: Admin OR (Team member with write scope) OR (Moderator with read-only)
let rules = AuthRules::Any(vec![
    // Admin bypass
    AuthRules::DidEquals("did:plc:rnpkyqnmsw4ipey6eotbdnnf".into()),

    // Team member with write access
    AuthRules::All(vec![
        AuthRules::HandleEndsWith(".blacksky.team".into()),
        AuthRules::ScopeEquals("transition:generic".into()),
    ]),

    // Moderator with limited scope
    AuthRules::All(vec![
        AuthRules::HandleEndsWith(".mod.team".into()),
        AuthRules::ScopeEqualsAny(vec![
            "account:email".into(),
            "atproto".into(),
        ]),
    ]),
]);

Error Responses#

Status Error Code Description
401 AuthRequired No Authorization header provided
401 InvalidToken JWT validation failed (expired, invalid signature, malformed)
403 AccessDenied Valid authentication but authorization rules rejected
500 ResolutionError Failed to resolve DID to handle

Response format:

{
  "error": "AccessDenied",
  "message": "Access denied by authorization rules"
}

JWT Token Format#

The middleware expects JWT tokens with these claims:

{
  "sub": "did:plc:rnpkyqnmsw4ipey6eotbdnnf",
  "scope": "atproto transition:generic repo:app.bsky.feed.post",
  "iat": 1704067200,
  "exp": 1704153600
}
  • sub (required): The user's DID
  • scope (optional): Space-separated OAuth scopes per RFC 6749

Handle Resolution#

DIDs are resolved to handles using the jacquard-identity PublicResolver:

  1. Check the HandleCache for a cached result
  2. If miss, resolve the DID document via PLC directory
  3. Extract handle from alsoKnownAs field (format: at://handle.example.com)
  4. Cache the result (1 hour TTL default)

This allows rules like HandleEndsWith(".blacksky.team") to work even though the JWT only contains the DID.