# 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 ```rust 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:` | 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](https://marvins-guide.leaflet.pub/3mbfvey7sok26) 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()`: ```rust 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: ```rust // 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 ```rust // 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 ```rust // 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: ```rust // 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: ```json { "error": "AccessDenied", "message": "Access denied by authorization rules" } ``` ## JWT Token Format The middleware expects JWT tokens with these claims: ```json { "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](https://tools.ietf.org/html/rfc6749) ## 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.