A community based topic aggregation platform built on atproto
1# OAuth Authentication PRD: Third-Party Client Support
2
3## ✅ Implementation Status
4
5**Phase 1 & 2: COMPLETED** (2025-10-16)
6
7- ✅ JWT parsing and validation implemented
8- ✅ JWT signature verification with PDS public keys (RSA + ECDSA/ES256)
9- ✅ JWKS fetching and caching (1 hour TTL)
10- ✅ Auth middleware protecting community endpoints
11- ✅ Handlers updated to use `GetUserDID(r)`
12- ✅ Comprehensive middleware auth tests (11 test cases)
13- ✅ E2E tests updated to use Bearer tokens
14- ✅ Security logging with IP, method, path, issuer
15- ✅ Scope validation (atproto required)
16- ✅ Issuer HTTPS validation
17- ✅ CreatedByDID validation in handlers
18- ✅ All tests passing
19- ✅ Documentation complete
20
21**Implementation Location**: `internal/atproto/auth/`, `internal/api/middleware/auth.go`
22
23**Configuration**: Set `AUTH_SKIP_VERIFY=false` for full signature verification (recommended for production).
24
25**Security Notes**:
26- Phase 1 (skipVerify=true): Parses and validates JWT claims without signature verification - suitable for alpha with trusted users
27- Phase 2 (skipVerify=false): Full cryptographic signature verification with PDS public keys - production-ready
28
29**Next Steps**: Phase 3 (DPoP validation, audience validation, JWKS fetcher tests) can be implemented when needed for production hardening.
30
31---
32
33## Overview
34
35Coves needs to validate OAuth tokens from third-party atProto clients to enable authenticated API access. This is critical for the community endpoints (create, update, subscribe, unsubscribe) which currently use an insecure placeholder (`X-User-DID` header).
36
37## Why This Is Needed for Coves
38
39### The Problem
40
41Currently, Coves community endpoints accept an `X-User-DID` header that **anyone can forge**. This is fundamentally insecure and allows:
42- Impersonation attacks (claiming to be any DID)
43- Unauthorized community creation
44- Fake subscriptions
45- Malicious updates to communities
46
47Example of current vulnerability:
48```bash
49# Anyone can pretend to be alice by setting a header
50curl -X POST https://coves.social/xrpc/social.coves.community.create \
51 -H "X-User-DID: did:plc:alice123" \
52 -d '{"name": "fake-community", ...}'
53```
54
55### Why Third-Party OAuth?
56
57Unlike traditional APIs where you control the auth flow, **atProto is federated**:
58
591. **Users authenticate with their PDS**, not with Coves
602. **Third-party apps** (mobile apps, desktop clients, browser extensions) obtain tokens from the user's PDS
613. **Coves must validate** these tokens when clients make requests on behalf of users
62
63This is fundamentally different from traditional OAuth where you're the authorization server. In atProto:
64- **You are NOT the auth server** - Each PDS is its own authorization server
65- **You are a resource server** - You validate tokens issued by arbitrary PDSes
66- **You cannot control token issuance** - Only validation
67
68### Why This Differs From First-Party OAuth
69
70Coves has two separate OAuth systems that serve different purposes:
71
72| System | Purpose | Location | Token Source |
73|--------|---------|----------|--------------|
74| **First-Party OAuth** | Authenticate users for Coves web UI | `internal/core/oauth/` | Coves issues tokens |
75| **Third-Party OAuth** | Validate tokens from external apps | *To be implemented* | User's PDS issues tokens |
76
77**First-party OAuth** is for if/when you build a Coves web frontend. It implements the **client side** of OAuth (login flows, token refresh, etc.).
78
79**Third-party OAuth validation** is for the **server side** - validating incoming tokens from arbitrary clients you didn't build.
80
81## Current State
82
83### Existing Infrastructure
84
85#### 1. First-Party OAuth (Client-Side)
86- **Location**: `internal/core/oauth/`, `internal/api/handlers/oauth/`
87- **Purpose**: For a potential Coves web frontend
88- **What it does**:
89 - Login flows (`/oauth/login`, `/oauth/callback`)
90 - Session management (cookie + database)
91 - Token refresh
92 - Client metadata (`/oauth/client-metadata.json`)
93- **What it does NOT do**: Validate incoming tokens from third-party apps
94
95#### 2. Placeholder Auth (INSECURE)
96- **Location**: Community handlers (`internal/api/handlers/community/*.go`)
97- **Current implementation**:
98 ```go
99 // INSECURE - allows impersonation
100 userDID := r.Header.Get("X-User-DID")
101 ```
102- **Used by**:
103 - `POST /xrpc/social.coves.community.create`
104 - `POST /xrpc/social.coves.community.update`
105 - `POST /xrpc/social.coves.community.subscribe`
106 - `POST /xrpc/social.coves.community.unsubscribe`
107
108### Protected vs Public Endpoints
109
110#### ✅ Public Endpoints (No auth required)
111These are read-only endpoints that anyone can access:
112- `GET /xrpc/social.coves.community.get` - View a community
113- `GET /xrpc/social.coves.community.list` - List communities
114- `GET /xrpc/social.coves.community.search` - Search communities
115
116**Rationale**: Public discovery is essential for network effects and user experience.
117
118#### 🔒 Protected Endpoints (Require authentication)
119These modify state and must verify the user's identity:
120- `POST /xrpc/social.coves.community.create` - Creates a community owned by the authenticated user
121- `POST /xrpc/social.coves.community.update` - Updates a community (must be owner/moderator)
122- `POST /xrpc/social.coves.community.subscribe` - Creates a subscription record in the user's repo
123- `POST /xrpc/social.coves.community.unsubscribe` - Deletes a subscription from the user's repo
124
125**Rationale**: These operations write to the user's repository or create resources owned by the user or the Coves instance (Communities), so we must cryptographically verify their identity.
126
127## atProto OAuth Requirements
128
129### How Third-Party Clients Work
130
131When a third-party app (e.g., a mobile client for Coves) wants to make authenticated requests:
132
133```
134┌─────────────┐ ┌─────────────┐
135│ User's │ │ User's │
136│ Mobile App │ │ PDS │
137│ │ 1. Initiate OAuth flow │ │
138│ │──────────────────────────────>│ │
139│ │ │ │
140│ │ 2. User authorizes │ │
141│ │ 3. Receive access token │ │
142│ │<──────────────────────────────│ │
143└─────────────┘ └─────────────┘
144 │
145 │ 4. Make authenticated request
146 │ Authorization: DPoP <token>
147 │ DPoP: <proof-jwt>
148 ↓
149┌─────────────┐
150│ Coves │
151│ AppView │ 5. Validate token & DPoP
152│ │ 6. Extract user DID
153│ │ 7. Process request
154└─────────────┘
155```
156
157### Token Format
158
159Third-party clients send two headers:
160
161**1. Authorization Header**
162```
163Authorization: DPoP eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6ImRpZDpwbGM6YWxpY2UjYXRwcm90by1wZHMifQ...
164```
165
166Format: `DPoP <access_token>`
167
168The access token is a JWT containing:
169```json
170{
171 "iss": "https://user-pds.example.com", // PDS that issued token
172 "sub": "did:plc:alice123", // User's DID
173 "aud": "https://coves.social", // Target resource server (optional)
174 "scope": "atproto", // Required scope
175 "exp": 1698765432, // Expiration timestamp
176 "iat": 1698761832, // Issued at timestamp
177 "jti": "unique-token-id", // Unique token identifier
178 "cnf": { // Confirmation claim (DPoP binding)
179 "jkt": "hash-of-dpop-public-key"
180 }
181}
182```
183
184**2. DPoP Header**
185```
186DPoP: eyJhbGciOiJFUzI1NiIsInR5cCI6ImRwb3Arand0In0...
187```
188
189The DPoP proof is a JWT proving possession of the private key bound to the access token:
190```json
191{
192 "typ": "dpop+jwt",
193 "alg": "ES256",
194 "jwk": { // Public key (ephemeral)
195 "kty": "EC",
196 "crv": "P-256",
197 "x": "...",
198 "y": "..."
199 }
200}
201// Payload:
202{
203 "jti": "unique-proof-id", // Unique proof identifier
204 "htm": "POST", // HTTP method
205 "htu": "https://coves.social/xrpc/...", // Target URL (without query params)
206 "iat": 1698761832, // Issued at
207 "ath": "hash-of-access-token", // Hash of access token (SHA-256)
208 "nonce": "server-provided-nonce" // Server nonce (after first request)
209}
210```
211
212### Validation Requirements
213
214To properly validate incoming requests, Coves must:
215
216#### 1. Extract and Parse Tokens
217- Extract `Authorization: DPoP <token>` header
218- Extract `DPoP: <proof>` header
219- Parse both as JWTs
220
221#### 2. Validate Access Token Structure
222- Check token is a valid JWT
223- Verify required claims exist (`iss`, `sub`, `exp`, `scope`, `cnf`)
224- Check `scope` includes `atproto`
225- Check `exp` hasn't passed
226
227#### 3. Fetch PDS Public Keys
228- Extract PDS URL from `iss` claim
229- Fetch `/.well-known/oauth-authorization-server` metadata
230- Get `jwks_uri` from metadata
231- Fetch public keys from `jwks_uri`
232- **Cache keys with appropriate TTL** (critical for performance)
233
234#### 4. Verify Access Token Signature
235- Find correct public key (match `kid` from JWT header)
236- Verify JWT signature using PDS public key
237- Cryptographically proves token was issued by claimed PDS
238
239#### 5. Validate DPoP Proof
240- Parse DPoP JWT
241- Verify DPoP signature using public key in `jwk` claim
242- Check `htm` matches request HTTP method
243- Check `htu` matches request URL (without query params)
244- Check `ath` matches hash of access token
245- Verify `jkt` in access token matches hash of DPoP public key
246- Check `iat` is recent (prevent replay attacks)
247
248#### 6. Handle Nonces (Replay Prevention)
249- First request: no nonce required
250- Return `DPoP-Nonce` header in response
251- Subsequent requests: verify nonce in DPoP proof
252- Rotate nonces periodically
253
254## Why Alternative Solutions Aren't Feasible
255
256### Option 1: Use Indigo's OAuth Package ❌
257
258**What we investigated**: `github.com/bluesky-social/indigo/atproto/auth/oauth`
259
260**Why it doesn't work**:
261- Indigo's OAuth package is **client-side only**
262- Designed for apps that **make requests**, not **receive requests**
263- No token validation for resource servers
264- No DPoP proof verification utilities
265
266**What it provides**:
267```go
268// Client-side only:
269- ClientApp.StartAuthFlow() // Initiate login
270- ClientApp.ProcessCallback() // Handle OAuth callback
271- ClientSession.RefreshToken() // Refresh tokens
272```
273
274**What it does NOT provide**:
275```go
276// Server-side validation (missing):
277- ValidateAccessToken() // ❌ Not available
278- ValidateDPoPProof() // ❌ Not available
279- FetchPDSKeys() // ❌ Not available
280```
281
282### Option 2: Use Indigo's Service Auth ❌
283
284**What we investigated**: `github.com/bluesky-social/indigo/atproto/auth.ServiceAuthValidator`
285
286**Why it doesn't work**:
287- Service auth is for **service-to-service** communication, not user auth
288- Different token format (short-lived JWTs, 60s TTL)
289- Different validation logic (no DPoP, different audience)
290- Used when PDS calls AppView **on behalf of user**, not when **user's app** calls AppView
291
292**Service Auth vs User OAuth**:
293```
294Service Auth: User OAuth:
295PDS → AppView Third-party App → AppView
296Short-lived (60s) Long-lived (hours)
297No DPoP DPoP required
298Service DID User DID
299```
300
301### Option 3: Use Tangled's Implementation ❌
302
303**What we investigated**: Tangled's codebase at `/home/bretton/Code/tangled/core`
304
305**Why it doesn't work**:
306- Tangled uses **first-party OAuth only** (their own web UI)
307- No third-party token validation implemented
308- Uses same indigo service auth we already ruled out
309- Custom `icyphox.sh/atproto-oauth` library is also client-side only
310
311**What Tangled has**:
312```go
313// First-party OAuth (client-side):
314oauth.SaveSession() // For their web UI
315oauth.GetSession() // For their web UI
316oauth.AuthorizedClient() // Making requests TO PDS
317
318// Service-to-service:
319ServiceAuth.VerifyServiceAuth() // Same as indigo
320```
321
322**What Tangled does NOT have**:
323- Third-party OAuth token validation
324- DPoP proof verification for user tokens
325- PDS public key fetching/caching
326
327### Option 4: Trust X-User-DID Header ❌
328
329**Current implementation** - fundamentally insecure
330
331**Why it doesn't work**:
332- Anyone can set HTTP headers
333- No cryptographic verification
334- Trivial to impersonate any user
335- Violates basic security principles
336
337**Attack example**:
338```bash
339# Attacker creates community as victim
340curl -X POST https://coves.social/xrpc/social.coves.community.create \
341 -H "X-User-DID: did:plc:victim123" \
342 -d '{"name": "impersonated-community", ...}'
343```
344
345### Option 5: Proxy All Requests Through PDS ❌
346
347**Idea**: Only accept requests from PDSes, not clients
348
349**Why it doesn't work**:
350- Breaks standard atProto architecture
351- Forces PDS to implement Coves-specific logic
352- Prevents third-party app development
353- Centralization defeats purpose of federation
354- No other AppView works this way
355
356### Option 6: Require Users to Register API Keys ❌
357
358**Idea**: Issue our own API keys to users
359
360**Why it doesn't work**:
361- Defeats purpose of decentralized identity (DID)
362- Users already have cryptographic identity via PDS
363- Creates vendor lock-in (keys only work with Coves)
364- Incompatible with atProto federation model
365- No other AppView requires this
366
367## Implementation Approach
368
369### Phased Rollout Strategy
370
371We'll implement OAuth validation in three phases to balance security, complexity, and time-to-alpha.
372
373#### Phase 1: Alpha - Basic JWT Validation (MVP)
374
375**Goal**: Unblock alpha launch with basic security
376
377**Implementation**:
378```go
379func (m *AuthMiddleware) RequireAtProtoAuth(next http.Handler) http.Handler {
380 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
381 // 1. Extract Authorization header
382 authHeader := r.Header.Get("Authorization")
383 if !strings.HasPrefix(authHeader, "DPoP ") {
384 http.Error(w, "Unauthorized", http.StatusUnauthorized)
385 return
386 }
387
388 token := strings.TrimPrefix(authHeader, "DPoP ")
389
390 // 2. Parse JWT (unverified)
391 claims, err := parseJWTClaims(token)
392 if err != nil {
393 http.Error(w, "Invalid token", http.StatusUnauthorized)
394 return
395 }
396
397 // 3. Basic validation
398 if time.Now().Unix() > claims.Expiry {
399 http.Error(w, "Token expired", http.StatusUnauthorized)
400 return
401 }
402
403 if !strings.Contains(claims.Scope, "atproto") {
404 http.Error(w, "Invalid scope", http.StatusUnauthorized)
405 return
406 }
407
408 // 4. Inject DID into context
409 ctx := context.WithValue(r.Context(), UserDIDKey, claims.Subject)
410 next.ServeHTTP(w, r.WithContext(ctx))
411 })
412}
413```
414
415**What Phase 1 Validates**:
416- ✅ Token is a valid JWT structure
417- ✅ Token hasn't expired
418- ✅ Token has `atproto` scope
419- ✅ DID is extracted from `sub` claim
420
421**What Phase 1 Does NOT Validate**:
422- ❌ JWT signature (anyone can mint valid-looking JWTs)
423- ❌ Token was actually issued by claimed PDS
424- ❌ DPoP proof
425
426**Security Posture**:
427- Better than `X-User-DID` header (requires valid JWT structure)
428- Not production-ready (no signature verification)
429- Acceptable for alpha with trusted early users
430
431**Documentation Requirements**:
432```go
433// TODO(OAuth-Phase2): Add JWT signature verification before beta
434//
435// Current implementation parses JWT claims but does not verify signatures.
436// This means tokens are not cryptographically validated against the PDS.
437//
438// Alpha security rationale:
439// - Better than X-User-DID (requires JWT structure, expiry)
440// - Acceptable risk for trusted early users
441// - Must be replaced before public beta
442//
443// See docs/PRD_OAUTH.md for Phase 2 implementation plan.
444```
445
446#### Phase 2: Beta - JWT Signature Verification
447
448**Goal**: Cryptographically verify tokens
449
450**Implementation**:
451```go
452type TokenValidator struct {
453 keyCache *PDSKeyCache // Caches PDS public keys
454 idResolver *identity.Resolver
455}
456
457func (v *TokenValidator) ValidateAccessToken(ctx context.Context, token string) (*Claims, error) {
458 // 1. Parse JWT with claims
459 jwt, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) {
460 // 2. Extract issuer (PDS URL)
461 claims := token.Claims.(jwt.MapClaims)
462 issuer := claims["iss"].(string)
463
464 // 3. Fetch PDS public keys (cached)
465 keys, err := v.keyCache.GetKeys(ctx, issuer)
466 if err != nil {
467 return nil, err
468 }
469
470 // 4. Find matching key by kid
471 kid := token.Header["kid"].(string)
472 return keys.FindKey(kid)
473 })
474
475 if err != nil {
476 return nil, fmt.Errorf("invalid signature: %w", err)
477 }
478
479 // 5. Validate claims
480 claims := jwt.Claims.(Claims)
481 if !claims.HasScope("atproto") {
482 return nil, errors.New("missing atproto scope")
483 }
484
485 return &claims, nil
486}
487
488type PDSKeyCache struct {
489 cache *ttlcache.Cache
490}
491
492func (c *PDSKeyCache) GetKeys(ctx context.Context, pdsURL string) (*jwk.Set, error) {
493 // Check cache
494 if keys, ok := c.cache.Get(pdsURL); ok {
495 return keys.(*jwk.Set), nil
496 }
497
498 // Fetch metadata
499 metadata, err := fetchAuthServerMetadata(ctx, pdsURL)
500 if err != nil {
501 return nil, err
502 }
503
504 // Fetch JWKS
505 keys, err := fetchJWKS(ctx, metadata.JWKSURI)
506 if err != nil {
507 return nil, err
508 }
509
510 // Cache with TTL (1 hour)
511 c.cache.Set(pdsURL, keys, time.Hour)
512
513 return keys, nil
514}
515```
516
517**What Phase 2 Adds**:
518- ✅ JWT signature verification
519- ✅ PDS public key fetching
520- ✅ Key caching (performance)
521- ✅ Cryptographic proof of token authenticity
522
523**Security Posture**:
524- Production-grade token validation
525- Cryptographically verifies token issued by claimed PDS
526- Acceptable for public beta
527
528#### Phase 3: Production - Full DPoP Validation
529
530**Goal**: Complete OAuth security compliance
531
532**Implementation**:
533```go
534func (v *TokenValidator) ValidateDPoPBoundToken(ctx context.Context, r *http.Request) (*Claims, error) {
535 // 1. Extract tokens
536 accessToken := extractAccessToken(r)
537 dpopProof := r.Header.Get("DPoP")
538
539 // 2. Validate access token (Phase 2 logic)
540 claims, err := v.ValidateAccessToken(ctx, accessToken)
541 if err != nil {
542 return nil, err
543 }
544
545 // 3. Parse DPoP proof
546 dpop, err := jwt.Parse(dpopProof, func(token *jwt.Token) (interface{}, error) {
547 // Public key is in the JWT itself (jwk claim)
548 jwkClaim := token.Header["jwk"]
549 return parseJWK(jwkClaim)
550 })
551 if err != nil {
552 return nil, fmt.Errorf("invalid DPoP proof: %w", err)
553 }
554
555 // 4. Validate DPoP proof
556 dpopClaims := dpop.Claims.(DPoPClaims)
557
558 // Check HTTP method matches
559 if dpopClaims.HTM != r.Method {
560 return nil, errors.New("DPoP htm mismatch")
561 }
562
563 // Check URL matches (without query params)
564 expectedHTU := fmt.Sprintf("%s://%s%s", r.URL.Scheme, r.URL.Host, r.URL.Path)
565 if dpopClaims.HTU != expectedHTU {
566 return nil, errors.New("DPoP htu mismatch")
567 }
568
569 // Check access token hash
570 tokenHash := sha256Hash(accessToken)
571 if dpopClaims.ATH != tokenHash {
572 return nil, errors.New("DPoP ath mismatch")
573 }
574
575 // 5. Verify DPoP key matches access token cnf
576 dpopKeyThumbprint := computeJWKThumbprint(dpop.Header["jwk"])
577 if claims.Confirmation.JKT != dpopKeyThumbprint {
578 return nil, errors.New("DPoP key binding mismatch")
579 }
580
581 // 6. Check and update nonce
582 if err := v.validateAndRotateNonce(r, dpopClaims.Nonce); err != nil {
583 // Return 401 with new nonce header
584 return nil, &NonceError{NewNonce: generateNonce()}
585 }
586
587 return claims, nil
588}
589```
590
591**What Phase 3 Adds**:
592- ✅ DPoP proof verification
593- ✅ Token binding validation
594- ✅ Nonce handling (replay prevention)
595- ✅ Full OAuth/DPoP spec compliance
596
597**Security Posture**:
598- Full production security
599- Prevents token theft/replay attacks
600- Industry-standard OAuth 2.0 + DPoP
601
602### Middleware Integration
603
604```go
605// In cmd/server/main.go
606
607// Initialize auth middleware
608authMiddleware, err := middleware.NewAuthMiddleware(sessionStore, identityResolver)
609if err != nil {
610 log.Fatal("Failed to initialize auth middleware:", err)
611}
612
613// Apply to community routes
614routes.RegisterCommunityRoutes(r, communityService, authMiddleware)
615```
616
617```go
618// In internal/api/routes/community.go
619
620func RegisterCommunityRoutes(r chi.Router, service communities.Service, auth *middleware.AuthMiddleware) {
621 // ... handlers initialization ...
622
623 // Public endpoints (no auth)
624 r.Get("/xrpc/social.coves.community.get", getHandler.HandleGet)
625 r.Get("/xrpc/social.coves.community.list", listHandler.HandleList)
626 r.Get("/xrpc/social.coves.community.search", searchHandler.HandleSearch)
627
628 // Protected endpoints (require auth)
629 r.Group(func(r chi.Router) {
630 r.Use(auth.RequireAtProtoAuth) // Apply middleware
631
632 r.Post("/xrpc/social.coves.community.create", createHandler.HandleCreate)
633 r.Post("/xrpc/social.coves.community.update", updateHandler.HandleUpdate)
634 r.Post("/xrpc/social.coves.community.subscribe", subscribeHandler.HandleSubscribe)
635 r.Post("/xrpc/social.coves.community.unsubscribe", subscribeHandler.HandleUnsubscribe)
636 })
637}
638```
639
640### Handler Updates
641
642Replace placeholder auth with context extraction:
643
644```go
645// OLD (Phase 0 - Insecure)
646userDID := r.Header.Get("X-User-DID") // ❌ Anyone can forge
647
648// NEW (Phase 1+)
649userDID := middleware.GetUserDID(r) // ✅ From validated token
650if userDID == "" {
651 // Should never happen (middleware validates)
652 writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
653 return
654}
655```
656
657## Implementation Checklist
658
659### Phase 1 (Alpha) - ✅ COMPLETED (2025-10-16)
660
661- [x] Create `internal/api/middleware/auth.go`
662 - [x] `RequireAuth` middleware
663 - [x] `OptionalAuth` middleware
664 - [x] `GetUserDID(r)` helper
665 - [x] `GetJWTClaims(r)` helper
666 - [x] Basic JWT parsing (no signature verification)
667 - [x] Expiry validation
668 - [x] Scope validation (lenient: allows empty, rejects wrong scopes)
669 - [x] Issuer HTTPS validation
670 - [x] DID format validation
671 - [x] Security logging (IP, method, path, issuer, error type)
672- [x] Update community handlers to use `GetUserDID(r)`
673 - [x] `create.go` (with CreatedByDID validation)
674 - [x] `update.go`
675 - [x] `subscribe.go`
676- [x] Update route registration in `routes/community.go`
677- [x] Add comprehensive middleware tests (`auth_test.go`)
678 - [x] Valid token acceptance
679 - [x] Missing/invalid header rejection
680 - [x] Malformed token rejection
681 - [x] Expired token rejection
682 - [x] Missing DID rejection
683 - [x] Optional auth scenarios
684 - [x] Context helper functions
685- [x] Update E2E tests to use Bearer tokens
686 - [x] Created `createTestJWT()` helper in `user_test.go`
687 - [x] Updated `community_e2e_test.go` to use JWT auth
688- [x] Delete orphaned OAuth files
689 - [x] Removed `dpop_transport.go` (referenced deleted packages)
690 - [x] Removed `oauth_test.go` (tested deleted first-party OAuth)
691- [x] Documentation complete (README.md in internal/atproto/auth/)
692
693### Phase 2 (Beta) - ✅ COMPLETED (2025-10-16)
694
695- [x] Implement JWT signature verification (`VerifyJWT` in `jwt.go`)
696- [x] Implement `CachedJWKSFetcher` with TTL (1 hour default)
697- [x] Add PDS metadata fetching
698 - [x] `/.well-known/oauth-authorization-server`
699 - [x] JWKS fetching from `jwks_uri`
700- [x] Add key caching layer (in-memory with TTL)
701- [x] Add ECDSA (ES256) support for atProto tokens
702 - [x] Support for P-256, P-384, P-521 curves
703 - [x] `toECPublicKey()` method in JWK
704 - [x] Updated `JWKSFetcher` interface to return `interface{}`
705- [x] Add comprehensive error handling
706- [x] Add detailed security logging for validation failures
707- [x] JWT tests passing (`jwt_test.go`)
708- [x] Middleware tests passing (11/11 tests)
709- [x] Build verification successful
710- [ ] Integration tests with real PDS (deferred - requires live PDS)
711- [ ] Security audit (recommended before production)
712
713### Phase 3 (Production) - Future Work
714
715**Status**: Not started (deferred to post-alpha)
716
717**Rationale**: Phase 2 provides production-grade JWT signature verification. DPoP adds defense-in-depth against token theft but is not critical for alpha/beta with proper HTTPS.
718
719- [ ] Implement DPoP proof parsing
720- [ ] Add DPoP validation logic
721 - [ ] `htm` validation
722 - [ ] `htu` validation
723 - [ ] `ath` validation
724 - [ ] `cnf`/`jkt` binding validation
725- [ ] Implement nonce management
726 - [ ] Nonce generation
727 - [ ] Nonce storage (per-user, per-server)
728 - [ ] Nonce rotation
729- [ ] Add replay attack prevention
730- [ ] Add comprehensive JWKS fetcher tests ⚠️ HIGH PRIORITY
731 - [ ] Cache hit/miss scenarios
732 - [ ] Cache expiration behavior
733 - [ ] JWKS endpoint failures
734 - [ ] Malformed JWKS responses
735 - [ ] Key rotation (kid mismatch)
736 - [ ] Concurrent fetch handling (thundering herd - known limitation)
737- [ ] Add optional audience (`aud`) claim validation
738 - [ ] Configurable expected audience from `APPVIEW_PUBLIC_URL`
739 - [ ] Lenient mode (allow missing audience)
740 - [ ] Strict mode (reject if audience doesn't match)
741- [ ] Fix thundering herd issue in JWKS cache
742 - [ ] Implement singleflight pattern (`golang.org/x/sync/singleflight`)
743 - [ ] Add tests for concurrent cache misses
744- [ ] Performance optimization
745 - [ ] Profile JWKS fetch performance
746 - [ ] Consider Redis for JWKS cache in multi-instance deployments
747- [ ] Complete security audit
748- [ ] Load testing
749
750## Success Metrics
751
752### Phase 1 (Alpha) - ✅ ACHIEVED
753- [x] All community endpoints reject requests without valid JWT structure
754- [x] Integration tests pass with mock tokens (11/11 middleware tests passing)
755- [x] Zero security regressions from X-User-DID (JWT validation is strictly better)
756- [x] E2E tests updated to use proper Bearer token authentication
757- [x] Build succeeds without compilation errors
758
759### Phase 2 (Beta) - ✅ READY FOR TESTING
760- [x] 100% of tokens cryptographically verified (when AUTH_SKIP_VERIFY=false)
761- [x] ECDSA (ES256) token support for atProto ecosystem
762- [ ] PDS key cache hit rate >90% (requires production metrics)
763- [ ] Token validation <50ms p99 latency (requires production benchmarking)
764- [ ] Zero successful token forgery attempts in testing (ready for security audit)
765
766### Phase 3 (Production)
767- [ ] Full DPoP spec compliance
768- [ ] Zero replay attacks in production
769- [ ] Token validation <100ms p99 latency
770- [ ] Security audit passed
771
772## Security Considerations
773
774### Phase 1 Limitations (MUST DOCUMENT)
775
776**Warning**: Phase 1 implementation does NOT verify JWT signatures. This means:
777
778- ❌ Anyone with JWT knowledge can mint "valid" tokens
779- ❌ No cryptographic proof of PDS issuance
780- ❌ Not suitable for untrusted users
781
782**Acceptable because**:
783- ✅ Alpha users are trusted early adopters
784- ✅ Better than X-User-DID header
785- ✅ Clear upgrade path to Phase 2
786
787**Mitigation**:
788- Document limitations in README
789- Add warning to API documentation
790- Include TODO comments in code
791- Set clear deadline for Phase 2 (before public beta)
792
793### Phase 2+ Security
794
795Once signature verification is implemented:
796- ✅ Cryptographic proof of token authenticity
797- ✅ Cannot forge tokens without PDS private key
798- ✅ Production-grade security
799
800### Additional Hardening
801
802- **Rate limiting**: Prevent brute force token guessing
803- **Token revocation**: Check against revocation list (future)
804- **Audit logging**: Log all authentication attempts
805- **Monitoring**: Alert on validation failure spikes
806
807## Open Questions
808
8091. **PDS key caching**: What TTL is appropriate?
810 - Proposal: 1 hour (balance freshness vs performance)
811 - Allow PDS to hint with `Cache-Control` headers
812
8132. **Nonce storage**: Where to store DPoP nonces?
814 - Phase 1: Not needed
815 - Phase 3: Redis or in-memory with TTL
816
8173. **Error messages**: How detailed should auth errors be?
818 - Proposal: Generic "Unauthorized" to prevent enumeration
819 - Log detailed errors server-side for debugging
820
8214. **Token audience**: Should we validate `aud` claim?
822 - Proposal: Optional validation, log if present but mismatched
823 - Some PDSes may not include `aud`
824
8255. **Backward compatibility**: Support legacy auth during transition?
826 - Proposal: No. Clean break at alpha launch
827 - X-User-DID was never documented/public
828
829## References
830
831- [atProto OAuth Spec](https://atproto.com/specs/oauth)
832- [RFC 9449 - OAuth 2.0 Demonstrating Proof of Possession (DPoP)](https://datatracker.ietf.org/doc/html/rfc9449)
833- [RFC 7519 - JSON Web Token (JWT)](https://datatracker.ietf.org/doc/html/rfc7519)
834- [Indigo OAuth Client Implementation](https://pkg.go.dev/github.com/bluesky-social/indigo/atproto/auth/oauth)
835- Tangled codebase analysis: `/home/bretton/Code/tangled/core`
836
837## Appendix: Token Examples
838
839### Valid Access Token (Decoded)
840
841**Header**:
842```json
843{
844 "alg": "ES256",
845 "typ": "at+jwt",
846 "kid": "did:plc:alice#atproto-pds"
847}
848```
849
850**Payload**:
851```json
852{
853 "iss": "https://pds.alice.com",
854 "sub": "did:plc:alice123",
855 "aud": "https://coves.social",
856 "scope": "atproto",
857 "exp": 1698765432,
858 "iat": 1698761832,
859 "jti": "token-unique-id-123",
860 "cnf": {
861 "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I"
862 }
863}
864```
865
866### Valid DPoP Proof (Decoded)
867
868**Header**:
869```json
870{
871 "typ": "dpop+jwt",
872 "alg": "ES256",
873 "jwk": {
874 "kty": "EC",
875 "crv": "P-256",
876 "x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs",
877 "y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA"
878 }
879}
880```
881
882**Payload**:
883```json
884{
885 "jti": "proof-unique-id-456",
886 "htm": "POST",
887 "htu": "https://coves.social/xrpc/social.coves.community.create",
888 "iat": 1698761832,
889 "ath": "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo",
890 "nonce": "server-nonce-abc123"
891}
892```
893
894## Appendix: Comparison with Other Systems
895
896| Feature | Coves (Phase 1) | Coves (Phase 3) | Tangled | Bluesky AppView |
897|---------|-----------------|-----------------|---------|-----------------|
898| User OAuth Validation | Basic JWT parse | Full DPoP | ❌ None | ✅ Full |
899| Signature Verification | ❌ | ✅ | ❌ | ✅ |
900| DPoP Proof Validation | ❌ | ✅ | ❌ | ✅ |
901| Service Auth | ❌ | ❌ | ✅ | ✅ |
902| First-Party OAuth | ✅ | ✅ | ✅ | ✅ |
903| Third-Party Support | Partial | ✅ | ❌ | ✅ |
904
905**Key Takeaway**: Most atProto projects (including Tangled) focus on first-party OAuth only. Coves needs third-party validation because communities are inherently multi-user and social.