Microservice to bring 2FA to self hosted PDSes
91
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 279 lines 9.0 kB view raw view rendered
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.