A community based topic aggregation platform built on atproto
1# atProto OAuth Authentication
2
3This package implements third-party OAuth authentication for Coves, validating JWT Bearer tokens from mobile apps and other atProto clients.
4
5## Architecture
6
7This is **third-party authentication** (validating incoming requests), not first-party authentication (logging users into Coves web frontend).
8
9### Components
10
111. **JWT Parser** (`jwt.go`) - Parses and validates JWT tokens
122. **JWKS Fetcher** (`jwks_fetcher.go`) - Fetches and caches public keys from PDS authorization servers
133. **Auth Middleware** (`internal/api/middleware/auth.go`) - HTTP middleware that protects endpoints
14
15### Flow
16
17```
18Client Request
19 ↓
20Authorization: Bearer <jwt>
21 ↓
22Auth Middleware
23 ↓
24Extract JWT → Parse Claims → Verify Signature (via JWKS)
25 ↓
26Inject DID into Context → Call Handler
27```
28
29## Usage
30
31### Phase 1: Parse-Only Mode (Testing)
32
33Set `AUTH_SKIP_VERIFY=true` to only parse JWTs without signature verification:
34
35```bash
36export AUTH_SKIP_VERIFY=true
37```
38
39This is useful for:
40- Initial integration testing
41- Testing with mock tokens
42- Debugging JWT structure
43
44### Phase 2: Full Verification (Production)
45
46Set `AUTH_SKIP_VERIFY=false` (or unset) to enable full JWT signature verification:
47
48```bash
49export AUTH_SKIP_VERIFY=false
50# or just unset it
51```
52
53This is **required for production** and validates:
54- JWT signature using PDS public key
55- Token expiration
56- Required claims (sub, iss)
57- DID format
58
59## Protected Endpoints
60
61The following endpoints require authentication:
62
63- `POST /xrpc/social.coves.community.create`
64- `POST /xrpc/social.coves.community.update`
65- `POST /xrpc/social.coves.community.subscribe`
66- `POST /xrpc/social.coves.community.unsubscribe`
67
68### Making Authenticated Requests
69
70Include the JWT in the `Authorization` header:
71
72```bash
73curl -X POST https://coves.social/xrpc/social.coves.community.create \
74 -H "Authorization: Bearer eyJhbGc..." \
75 -H "Content-Type: application/json" \
76 -d '{"name":"Gaming","hostedByDid":"did:plc:..."}'
77```
78
79### Getting User DID in Handlers
80
81The middleware injects the authenticated user's DID into the request context:
82
83```go
84import "Coves/internal/api/middleware"
85
86func (h *Handler) HandleCreate(w http.ResponseWriter, r *http.Request) {
87 // Extract authenticated user DID
88 userDID := middleware.GetUserDID(r)
89 if userDID == "" {
90 // Not authenticated (should never happen with RequireAuth middleware)
91 http.Error(w, "Unauthorized", http.StatusUnauthorized)
92 return
93 }
94
95 // Use userDID for authorization checks
96 // ...
97}
98```
99
100## Key Caching
101
102Public keys are fetched from PDS authorization servers and cached for 1 hour. The cache is automatically cleaned up hourly to remove expired entries.
103
104### JWKS Discovery Flow
105
1061. Extract `iss` claim from JWT (e.g., `https://pds.example.com`)
1072. Fetch `https://pds.example.com/.well-known/oauth-authorization-server`
1083. Extract `jwks_uri` from metadata
1094. Fetch JWKS from `jwks_uri`
1105. Find matching key by `kid` from JWT header
1116. Cache the JWKS for 1 hour
112
113## DPoP Token Binding
114
115DPoP (Demonstrating Proof-of-Possession) binds access tokens to client-controlled cryptographic keys, preventing token theft and replay attacks.
116
117### What is DPoP?
118
119DPoP is an OAuth extension (RFC 9449) that adds proof-of-possession semantics to bearer tokens. When a PDS issues a DPoP-bound access token:
120
1211. Access token contains `cnf.jkt` claim (JWK thumbprint of client's public key)
1222. Client creates a DPoP proof JWT signed with their private key
1233. Server verifies the proof signature and checks it matches the token's `cnf.jkt`
124
125### CRITICAL: DPoP Security Model
126
127> ⚠️ **DPoP is an ADDITIONAL security layer, NOT a replacement for token signature verification.**
128
129The correct verification order is:
1301. **ALWAYS verify the access token signature first** (via JWKS, HS256 shared secret, or DID resolution)
1312. **If the verified token has `cnf.jkt`, REQUIRE valid DPoP proof**
1323. **NEVER use DPoP as a fallback when signature verification fails**
133
134**Why This Matters**: An attacker could create a fake token with `sub: "did:plc:victim"` and their own `cnf.jkt`, then present a valid DPoP proof signed with their key. If we accept DPoP as a fallback, the attacker can impersonate any user.
135
136### How DPoP Works
137
138```
139┌─────────────┐ ┌─────────────┐
140│ Client │ │ Server │
141│ │ │ (Coves) │
142└─────────────┘ └─────────────┘
143 │ │
144 │ 1. Authorization: Bearer <token> │
145 │ DPoP: <proof-jwt> │
146 │───────────────────────────────────────>│
147 │ │
148 │ │ 2. VERIFY token signature
149 │ │ (REQUIRED - no fallback!)
150 │ │
151 │ │ 3. If token has cnf.jkt:
152 │ │ - Verify DPoP proof
153 │ │ - Check thumbprint match
154 │ │
155 │ 200 OK │
156 │<───────────────────────────────────────│
157```
158
159### When DPoP is Required
160
161DPoP verification is **REQUIRED** when:
162- Access token signature has been verified AND
163- Access token contains `cnf.jkt` claim (DPoP-bound)
164
165If the token has `cnf.jkt` but no DPoP header is present, the request is **REJECTED**.
166
167### Replay Protection
168
169DPoP proofs include a unique `jti` (JWT ID) claim. The server tracks seen `jti` values to prevent replay attacks:
170
171```go
172// Create a verifier with replay protection (default)
173verifier := auth.NewDPoPVerifier()
174defer verifier.Stop() // Stop cleanup goroutine on shutdown
175
176// The verifier automatically rejects reused jti values within the proof validity window (5 minutes)
177```
178
179### DPoP Implementation
180
181The `dpop.go` module provides:
182
183```go
184// Create a verifier with replay protection
185verifier := auth.NewDPoPVerifier()
186defer verifier.Stop()
187
188// Verify the DPoP proof
189proof, err := verifier.VerifyDPoPProof(dpopHeader, "POST", "https://coves.social/xrpc/...")
190if err != nil {
191 // Invalid proof (includes replay detection)
192}
193
194// Verify it binds to the VERIFIED access token
195expectedThumbprint, err := auth.ExtractCnfJkt(claims)
196if err != nil {
197 // Token not DPoP-bound
198}
199
200if err := verifier.VerifyTokenBinding(proof, expectedThumbprint); err != nil {
201 // Proof doesn't match token
202}
203```
204
205### DPoP Proof Format
206
207The DPoP header contains a JWT with:
208
209**Header**:
210- `typ`: `"dpop+jwt"` (required)
211- `alg`: `"ES256"` (or other supported algorithm)
212- `jwk`: Client's public key (JWK format)
213
214**Claims**:
215- `jti`: Unique proof identifier (tracked for replay protection)
216- `htm`: HTTP method (e.g., `"POST"`)
217- `htu`: HTTP URI (without query/fragment)
218- `iat`: Timestamp (must be recent, within 5 minutes)
219
220**Example**:
221```json
222{
223 "typ": "dpop+jwt",
224 "alg": "ES256",
225 "jwk": {
226 "kty": "EC",
227 "crv": "P-256",
228 "x": "...",
229 "y": "..."
230 }
231}
232{
233 "jti": "unique-id-123",
234 "htm": "POST",
235 "htu": "https://coves.social/xrpc/social.coves.community.create",
236 "iat": 1700000000
237}
238```
239
240## Security Considerations
241
242### ✅ Implemented
243
244- JWT signature verification with PDS public keys
245- Token expiration validation
246- DID format validation
247- Required claims validation (sub, iss)
248- Key caching with TTL
249- Secure error messages (no internal details leaked)
250- **DPoP proof verification** (proof-of-possession for token binding)
251- **DPoP thumbprint validation** (prevents token theft attacks)
252- **DPoP freshness checks** (5-minute proof validity window)
253- **DPoP replay protection** (jti tracking with in-memory cache)
254- **Secure DPoP model** (DPoP required AFTER signature verification, never as fallback)
255
256### ⚠️ Not Yet Implemented
257
258- Server-issued DPoP nonces (additional replay protection)
259- Scope validation (checking `scope` claim)
260- Audience validation (checking `aud` claim)
261- Rate limiting per DID
262- Token revocation checking
263
264## Testing
265
266Run the test suite:
267
268```bash
269go test ./internal/atproto/auth/... -v
270```
271
272### Manual Testing
273
2741. **Phase 1 (Parse Only)**:
275 ```bash
276 # Create a test JWT (use jwt.io or a tool)
277 export AUTH_SKIP_VERIFY=true
278 curl -X POST http://localhost:8081/xrpc/social.coves.community.create \
279 -H "Authorization: Bearer <test-jwt>" \
280 -d '{"name":"Test","hostedByDid":"did:plc:test"}'
281 ```
282
2832. **Phase 2 (Full Verification)**:
284 ```bash
285 # Use a real JWT from a PDS
286 export AUTH_SKIP_VERIFY=false
287 curl -X POST http://localhost:8081/xrpc/social.coves.community.create \
288 -H "Authorization: Bearer <real-jwt>" \
289 -d '{"name":"Test","hostedByDid":"did:plc:test"}'
290 ```
291
292## Error Responses
293
294### 401 Unauthorized
295
296Missing or invalid token:
297
298```json
299{
300 "error": "AuthenticationRequired",
301 "message": "Missing Authorization header"
302}
303```
304
305```json
306{
307 "error": "AuthenticationRequired",
308 "message": "Invalid or expired token"
309}
310```
311
312### Common Issues
313
3141. **Missing Authorization header** → Add `Authorization: Bearer <token>`
3152. **Token expired** → Get a new token from PDS
3163. **Invalid signature** → Ensure token is from a valid PDS
3174. **JWKS fetch fails** → Check PDS availability and network connectivity
318
319## Future Enhancements
320
321- [ ] DPoP nonce validation (server-managed nonce for additional replay protection)
322- [ ] Scope-based authorization
323- [ ] Audience claim validation
324- [ ] Token revocation support
325- [ ] Rate limiting per DID
326- [ ] Metrics and monitoring