Microservice to bring 2FA to self hosted PDSes
1# PDS Gatekeeper Authentication Middleware 2 3This document describes the authentication middleware system for pds-gatekeeper, which provides flexible authorization rules based on DIDs, handles, and OAuth scopes. 4 5## Overview 6 7The auth middleware validates incoming requests by: 8 91. **Extracting** the DID and scopes from a JWT Bearer token 102. **Resolving** the DID to a handle using jacquard-identity 113. **Validating** against configured authorization rules 124. **Returning** appropriate HTTP errors (401/403) on failure 13 14## Quick Start 15 16```rust 17use axum::middleware::from_fn_with_state; 18use crate::auth::{auth_middleware, handle_ends_with, scope_equals, with_rules, AuthRules}; 19 20let app = Router::new() 21 // Simple: require handle from specific domain 22 .route("/xrpc/community.blacksky.feed.get", 23 get(handler).layer(from_fn_with_state( 24 handle_ends_with(".blacksky.team", &state), 25 auth_middleware 26 ))) 27 28 // Simple: require specific OAuth scope 29 .route("/xrpc/com.atproto.repo.createRecord", 30 post(handler).layer(from_fn_with_state( 31 scope_equals("repo:app.bsky.feed.post", &state), 32 auth_middleware 33 ))) 34 35 .with_state(state); 36``` 37 38## ATProto OAuth Scopes Reference 39 40| Scope | Description | 41|-------|-------------| 42| `atproto` | Base scope, required for all OAuth clients | 43| `transition:generic` | Full repository access (equivalent to app passwords) | 44| `repo:<collection>` | Access to specific collection (e.g., `repo:app.bsky.feed.post`) | 45| `identity:handle` | Permits handle changes | 46| `identity:*` | Full DID document control | 47| `account:email` | Read email addresses | 48| `account:repo?action=manage` | Import repository data | 49| `blob:*/*` | Upload any blob type | 50| `blob?accept=image/*` | Upload only images | 51 52See [Marvin's Guide to OAuth Scopes](https://marvins-guide.leaflet.pub/3mbfvey7sok26) for complete details. 53 54## Helper Functions 55 56### Identity Helpers 57 58| Function | Description | 59|----------|-------------| 60| `handle_ends_with(suffix, state)` | Handle must end with suffix | 61| `handle_ends_with_any(suffixes, state)` | Handle must end with any suffix (OR) | 62| `did_equals(did, state)` | DID must match exactly | 63| `did_equals_any(dids, state)` | DID must match any value (OR) | 64 65### Scope Helpers 66 67| Function | Description | 68|----------|-------------| 69| `scope_equals(scope, state)` | Must have specific scope | 70| `scope_any(scopes, state)` | Must have any of the scopes (OR) | 71| `scope_all(scopes, state)` | Must have all scopes (AND) | 72 73### Combined Helpers (Identity + Scope) 74 75| Function | Description | 76|----------|-------------| 77| `handle_ends_with_and_scope(suffix, scope, state)` | Handle suffix AND scope | 78| `handle_ends_with_and_scopes(suffix, scopes, state)` | Handle suffix AND all scopes | 79| `did_with_scope(did, scope, state)` | DID match AND scope | 80| `did_with_scopes(did, scopes, state)` | DID match AND all scopes | 81 82### Custom Rules 83 84For complex authorization logic, use `with_rules()`: 85 86```rust 87with_rules(AuthRules::Any(vec![ 88 AuthRules::DidEquals("did:plc:rnpkyqnmsw4ipey6eotbdnnf".into()), 89 AuthRules::All(vec![ 90 AuthRules::HandleEndsWith(".mod.team".into()), 91 AuthRules::ScopeEquals("account:email".into()), 92 ]), 93]), &state) 94``` 95 96## Realistic PDS Endpoint Examples 97 98### Admin Endpoints 99 100Based on `com.atproto.admin.*` endpoints from the ATProto PDS: 101 102```rust 103// com.atproto.admin.deleteAccount 104// Admin-only: specific DID with full access scope 105.route("/xrpc/com.atproto.admin.deleteAccount", 106 post(delete_account).layer(from_fn_with_state( 107 did_with_scope("did:plc:rnpkyqnmsw4ipey6eotbdnnf", "transition:generic", &state), 108 auth_middleware 109 ))) 110 111// com.atproto.admin.getAccountInfo 112// Either admin DID OR (moderator handle + account scope) 113.route("/xrpc/com.atproto.admin.getAccountInfo", 114 get(get_account_info).layer(from_fn_with_state( 115 with_rules(AuthRules::Any(vec![ 116 AuthRules::DidEquals("did:plc:rnpkyqnmsw4ipey6eotbdnnf".into()), 117 AuthRules::All(vec![ 118 AuthRules::HandleEndsWith(".mod.team".into()), 119 AuthRules::ScopeEquals("account:email".into()), 120 ]), 121 ]), &state), 122 auth_middleware 123 ))) 124 125// com.atproto.admin.updateAccountEmail 126// Admin DID with account management scope 127.route("/xrpc/com.atproto.admin.updateAccountEmail", 128 post(update_email).layer(from_fn_with_state( 129 did_with_scopes( 130 "did:plc:rnpkyqnmsw4ipey6eotbdnnf", 131 ["account:email", "account:repo?action=manage"], 132 &state 133 ), 134 auth_middleware 135 ))) 136 137// com.atproto.admin.updateAccountHandle 138// Admin with identity control 139.route("/xrpc/com.atproto.admin.updateAccountHandle", 140 post(update_handle).layer(from_fn_with_state( 141 did_with_scope("did:plc:rnpkyqnmsw4ipey6eotbdnnf", "identity:*", &state), 142 auth_middleware 143 ))) 144``` 145 146### Repository Endpoints 147 148```rust 149// com.atproto.repo.createRecord 150// Scoped write access to specific collection 151.route("/xrpc/com.atproto.repo.createRecord", 152 post(create_record).layer(from_fn_with_state( 153 scope_equals("repo:app.bsky.feed.post", &state), 154 auth_middleware 155 ))) 156 157// com.atproto.repo.putRecord 158// Either specific collection scope OR full access 159.route("/xrpc/com.atproto.repo.putRecord", 160 post(put_record).layer(from_fn_with_state( 161 scope_any(["repo:app.bsky.feed.post", "transition:generic"], &state), 162 auth_middleware 163 ))) 164 165// com.atproto.repo.uploadBlob 166// Blob upload with media type restriction (scope-based) 167.route("/xrpc/com.atproto.repo.uploadBlob", 168 post(upload_blob).layer(from_fn_with_state( 169 scope_any(["blob:*/*", "blob?accept=image/*", "transition:generic"], &state), 170 auth_middleware 171 ))) 172``` 173 174### Community/Custom Endpoints 175 176```rust 177// Community feed generator - restricted to team members with full access 178.route("/xrpc/community.blacksky.feed.generator", 179 post(generator).layer(from_fn_with_state( 180 handle_ends_with_and_scope(".blacksky.team", "transition:generic", &state), 181 auth_middleware 182 ))) 183 184// Multi-community endpoint 185.route("/xrpc/community.shared.moderation.report", 186 post(report).layer(from_fn_with_state( 187 with_rules(AuthRules::All(vec![ 188 AuthRules::HandleEndsWithAny(vec![ 189 ".blacksky.team".into(), 190 ".bsky.team".into(), 191 ".mod.social".into(), 192 ]), 193 AuthRules::ScopeEquals("atproto".into()), 194 ]), &state), 195 auth_middleware 196 ))) 197 198// VIP access - specific DIDs only 199.route("/xrpc/community.blacksky.vip.access", 200 get(vip_handler).layer(from_fn_with_state( 201 did_equals_any([ 202 "did:plc:rnpkyqnmsw4ipey6eotbdnnf", 203 "did:plc:abc123def456ghi789jklmno", 204 "did:plc:xyz987uvw654rst321qponml", 205 ], &state), 206 auth_middleware 207 ))) 208``` 209 210## Building Complex Authorization Rules 211 212The `AuthRules` enum supports arbitrary nesting: 213 214```rust 215// Complex: Admin OR (Team member with write scope) OR (Moderator with read-only) 216let rules = AuthRules::Any(vec![ 217 // Admin bypass 218 AuthRules::DidEquals("did:plc:rnpkyqnmsw4ipey6eotbdnnf".into()), 219 220 // Team member with write access 221 AuthRules::All(vec![ 222 AuthRules::HandleEndsWith(".blacksky.team".into()), 223 AuthRules::ScopeEquals("transition:generic".into()), 224 ]), 225 226 // Moderator with limited scope 227 AuthRules::All(vec![ 228 AuthRules::HandleEndsWith(".mod.team".into()), 229 AuthRules::ScopeEqualsAny(vec![ 230 "account:email".into(), 231 "atproto".into(), 232 ]), 233 ]), 234]); 235``` 236 237## Error Responses 238 239| Status | Error Code | Description | 240|--------|------------|-------------| 241| `401` | `AuthRequired` | No Authorization header provided | 242| `401` | `InvalidToken` | JWT validation failed (expired, invalid signature, malformed) | 243| `403` | `AccessDenied` | Valid authentication but authorization rules rejected | 244| `500` | `ResolutionError` | Failed to resolve DID to handle | 245 246Response format: 247```json 248{ 249 "error": "AccessDenied", 250 "message": "Access denied by authorization rules" 251} 252``` 253 254## JWT Token Format 255 256The middleware expects JWT tokens with these claims: 257 258```json 259{ 260 "sub": "did:plc:rnpkyqnmsw4ipey6eotbdnnf", 261 "scope": "atproto transition:generic repo:app.bsky.feed.post", 262 "iat": 1704067200, 263 "exp": 1704153600 264} 265``` 266 267- `sub` (required): The user's DID 268- `scope` (optional): Space-separated OAuth scopes per [RFC 6749](https://tools.ietf.org/html/rfc6749) 269 270## Handle Resolution 271 272DIDs are resolved to handles using the jacquard-identity `PublicResolver`: 273 2741. Check the `HandleCache` for a cached result 2752. If miss, resolve the DID document via PLC directory 2763. Extract handle from `alsoKnownAs` field (format: `at://handle.example.com`) 2774. Cache the result (1 hour TTL default) 278 279This allows rules like `HandleEndsWith(".blacksky.team")` to work even though the JWT only contains the DID.