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.