···
1
+
# OAuth Authentication PRD: Third-Party Client Support
3
+
## ✅ Implementation Status
5
+
**Phase 1 & 2: COMPLETED** (2025-10-16)
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
21
+
**Implementation Location**: `internal/atproto/auth/`, `internal/api/middleware/auth.go`
23
+
**Configuration**: Set `AUTH_SKIP_VERIFY=false` for full signature verification (recommended for production).
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
29
+
**Next Steps**: Phase 3 (DPoP validation, audience validation, JWKS fetcher tests) can be implemented when needed for production hardening.
35
+
Coves 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).
37
+
## Why This Is Needed for Coves
41
+
Currently, 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
47
+
Example of current vulnerability:
49
+
# Anyone can pretend to be alice by setting a header
50
+
curl -X POST https://coves.social/xrpc/social.coves.community.create \
51
+
-H "X-User-DID: did:plc:alice123" \
52
+
-d '{"name": "fake-community", ...}'
55
+
### Why Third-Party OAuth?
57
+
Unlike traditional APIs where you control the auth flow, **atProto is federated**:
59
+
1. **Users authenticate with their PDS**, not with Coves
60
+
2. **Third-party apps** (mobile apps, desktop clients, browser extensions) obtain tokens from the user's PDS
61
+
3. **Coves must validate** these tokens when clients make requests on behalf of users
63
+
This 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
68
+
### Why This Differs From First-Party OAuth
70
+
Coves has two separate OAuth systems that serve different purposes:
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 |
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.).
79
+
**Third-party OAuth validation** is for the **server side** - validating incoming tokens from arbitrary clients you didn't build.
83
+
### Existing Infrastructure
85
+
#### 1. First-Party OAuth (Client-Side)
86
+
- **Location**: `internal/core/oauth/`, `internal/api/handlers/oauth/`
87
+
- **Purpose**: For a potential Coves web frontend
89
+
- Login flows (`/oauth/login`, `/oauth/callback`)
90
+
- Session management (cookie + database)
92
+
- Client metadata (`/oauth/client-metadata.json`)
93
+
- **What it does NOT do**: Validate incoming tokens from third-party apps
95
+
#### 2. Placeholder Auth (INSECURE)
96
+
- **Location**: Community handlers (`internal/api/handlers/community/*.go`)
97
+
- **Current implementation**:
99
+
// INSECURE - allows impersonation
100
+
userDID := r.Header.Get("X-User-DID")
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`
108
+
### Protected vs Public Endpoints
110
+
#### ✅ Public Endpoints (No auth required)
111
+
These 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
116
+
**Rationale**: Public discovery is essential for network effects and user experience.
118
+
#### 🔒 Protected Endpoints (Require authentication)
119
+
These 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
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.
127
+
## atProto OAuth Requirements
129
+
### How Third-Party Clients Work
131
+
When a third-party app (e.g., a mobile client for Coves) wants to make authenticated requests:
134
+
┌─────────────┐ ┌─────────────┐
135
+
│ User's │ │ User's │
136
+
│ Mobile App │ │ PDS │
137
+
│ │ 1. Initiate OAuth flow │ │
138
+
│ │──────────────────────────────>│ │
140
+
│ │ 2. User authorizes │ │
141
+
│ │ 3. Receive access token │ │
142
+
│ │<──────────────────────────────│ │
143
+
└─────────────┘ └─────────────┘
145
+
│ 4. Make authenticated request
146
+
│ Authorization: DPoP <token>
147
+
│ DPoP: <proof-jwt>
151
+
│ AppView │ 5. Validate token & DPoP
152
+
│ │ 6. Extract user DID
153
+
│ │ 7. Process request
159
+
Third-party clients send two headers:
161
+
**1. Authorization Header**
163
+
Authorization: DPoP eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6ImRpZDpwbGM6YWxpY2UjYXRwcm90by1wZHMifQ...
166
+
Format: `DPoP <access_token>`
168
+
The access token is a JWT containing:
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"
186
+
DPoP: eyJhbGciOiJFUzI1NiIsInR5cCI6ImRwb3Arand0In0...
189
+
The DPoP proof is a JWT proving possession of the private key bound to the access token:
194
+
"jwk": { // Public key (ephemeral)
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)
212
+
### Validation Requirements
214
+
To properly validate incoming requests, Coves must:
216
+
#### 1. Extract and Parse Tokens
217
+
- Extract `Authorization: DPoP <token>` header
218
+
- Extract `DPoP: <proof>` header
219
+
- Parse both as JWTs
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
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)
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
239
+
#### 5. Validate DPoP Proof
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)
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
254
+
## Why Alternative Solutions Aren't Feasible
256
+
### Option 1: Use Indigo's OAuth Package ❌
258
+
**What we investigated**: `github.com/bluesky-social/indigo/atproto/auth/oauth`
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
266
+
**What it provides**:
268
+
// Client-side only:
269
+
- ClientApp.StartAuthFlow() // Initiate login
270
+
- ClientApp.ProcessCallback() // Handle OAuth callback
271
+
- ClientSession.RefreshToken() // Refresh tokens
274
+
**What it does NOT provide**:
276
+
// Server-side validation (missing):
277
+
- ValidateAccessToken() // ❌ Not available
278
+
- ValidateDPoPProof() // ❌ Not available
279
+
- FetchPDSKeys() // ❌ Not available
282
+
### Option 2: Use Indigo's Service Auth ❌
284
+
**What we investigated**: `github.com/bluesky-social/indigo/atproto/auth.ServiceAuthValidator`
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
292
+
**Service Auth vs User OAuth**:
294
+
Service Auth: User OAuth:
295
+
PDS → AppView Third-party App → AppView
296
+
Short-lived (60s) Long-lived (hours)
297
+
No DPoP DPoP required
298
+
Service DID User DID
301
+
### Option 3: Use Tangled's Implementation ❌
303
+
**What we investigated**: Tangled's codebase at `/home/bretton/Code/tangled/core`
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
311
+
**What Tangled has**:
313
+
// First-party OAuth (client-side):
314
+
oauth.SaveSession() // For their web UI
315
+
oauth.GetSession() // For their web UI
316
+
oauth.AuthorizedClient() // Making requests TO PDS
318
+
// Service-to-service:
319
+
ServiceAuth.VerifyServiceAuth() // Same as indigo
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
327
+
### Option 4: Trust X-User-DID Header ❌
329
+
**Current implementation** - fundamentally insecure
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
337
+
**Attack example**:
339
+
# Attacker creates community as victim
340
+
curl -X POST https://coves.social/xrpc/social.coves.community.create \
341
+
-H "X-User-DID: did:plc:victim123" \
342
+
-d '{"name": "impersonated-community", ...}'
345
+
### Option 5: Proxy All Requests Through PDS ❌
347
+
**Idea**: Only accept requests from PDSes, not clients
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
356
+
### Option 6: Require Users to Register API Keys ❌
358
+
**Idea**: Issue our own API keys to users
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
367
+
## Implementation Approach
369
+
### Phased Rollout Strategy
371
+
We'll implement OAuth validation in three phases to balance security, complexity, and time-to-alpha.
373
+
#### Phase 1: Alpha - Basic JWT Validation (MVP)
375
+
**Goal**: Unblock alpha launch with basic security
377
+
**Implementation**:
379
+
func (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)
388
+
token := strings.TrimPrefix(authHeader, "DPoP ")
390
+
// 2. Parse JWT (unverified)
391
+
claims, err := parseJWTClaims(token)
393
+
http.Error(w, "Invalid token", http.StatusUnauthorized)
397
+
// 3. Basic validation
398
+
if time.Now().Unix() > claims.Expiry {
399
+
http.Error(w, "Token expired", http.StatusUnauthorized)
403
+
if !strings.Contains(claims.Scope, "atproto") {
404
+
http.Error(w, "Invalid scope", http.StatusUnauthorized)
408
+
// 4. Inject DID into context
409
+
ctx := context.WithValue(r.Context(), UserDIDKey, claims.Subject)
410
+
next.ServeHTTP(w, r.WithContext(ctx))
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
421
+
**What Phase 1 Does NOT Validate**:
422
+
- ❌ JWT signature (anyone can mint valid-looking JWTs)
423
+
- ❌ Token was actually issued by claimed PDS
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
431
+
**Documentation Requirements**:
433
+
// TODO(OAuth-Phase2): Add JWT signature verification before beta
435
+
// Current implementation parses JWT claims but does not verify signatures.
436
+
// This means tokens are not cryptographically validated against the PDS.
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
443
+
// See docs/PRD_OAUTH.md for Phase 2 implementation plan.
446
+
#### Phase 2: Beta - JWT Signature Verification
448
+
**Goal**: Cryptographically verify tokens
450
+
**Implementation**:
452
+
type TokenValidator struct {
453
+
keyCache *PDSKeyCache // Caches PDS public keys
454
+
idResolver *identity.Resolver
457
+
func (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)
464
+
// 3. Fetch PDS public keys (cached)
465
+
keys, err := v.keyCache.GetKeys(ctx, issuer)
470
+
// 4. Find matching key by kid
471
+
kid := token.Header["kid"].(string)
472
+
return keys.FindKey(kid)
476
+
return nil, fmt.Errorf("invalid signature: %w", err)
479
+
// 5. Validate claims
480
+
claims := jwt.Claims.(Claims)
481
+
if !claims.HasScope("atproto") {
482
+
return nil, errors.New("missing atproto scope")
485
+
return &claims, nil
488
+
type PDSKeyCache struct {
489
+
cache *ttlcache.Cache
492
+
func (c *PDSKeyCache) GetKeys(ctx context.Context, pdsURL string) (*jwk.Set, error) {
494
+
if keys, ok := c.cache.Get(pdsURL); ok {
495
+
return keys.(*jwk.Set), nil
499
+
metadata, err := fetchAuthServerMetadata(ctx, pdsURL)
505
+
keys, err := fetchJWKS(ctx, metadata.JWKSURI)
510
+
// Cache with TTL (1 hour)
511
+
c.cache.Set(pdsURL, keys, time.Hour)
517
+
**What Phase 2 Adds**:
518
+
- ✅ JWT signature verification
519
+
- ✅ PDS public key fetching
520
+
- ✅ Key caching (performance)
521
+
- ✅ Cryptographic proof of token authenticity
523
+
**Security Posture**:
524
+
- Production-grade token validation
525
+
- Cryptographically verifies token issued by claimed PDS
526
+
- Acceptable for public beta
528
+
#### Phase 3: Production - Full DPoP Validation
530
+
**Goal**: Complete OAuth security compliance
532
+
**Implementation**:
534
+
func (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")
539
+
// 2. Validate access token (Phase 2 logic)
540
+
claims, err := v.ValidateAccessToken(ctx, accessToken)
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)
552
+
return nil, fmt.Errorf("invalid DPoP proof: %w", err)
555
+
// 4. Validate DPoP proof
556
+
dpopClaims := dpop.Claims.(DPoPClaims)
558
+
// Check HTTP method matches
559
+
if dpopClaims.HTM != r.Method {
560
+
return nil, errors.New("DPoP htm mismatch")
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")
569
+
// Check access token hash
570
+
tokenHash := sha256Hash(accessToken)
571
+
if dpopClaims.ATH != tokenHash {
572
+
return nil, errors.New("DPoP ath mismatch")
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")
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()}
591
+
**What Phase 3 Adds**:
592
+
- ✅ DPoP proof verification
593
+
- ✅ Token binding validation
594
+
- ✅ Nonce handling (replay prevention)
595
+
- ✅ Full OAuth/DPoP spec compliance
597
+
**Security Posture**:
598
+
- Full production security
599
+
- Prevents token theft/replay attacks
600
+
- Industry-standard OAuth 2.0 + DPoP
602
+
### Middleware Integration
605
+
// In cmd/server/main.go
607
+
// Initialize auth middleware
608
+
authMiddleware, err := middleware.NewAuthMiddleware(sessionStore, identityResolver)
610
+
log.Fatal("Failed to initialize auth middleware:", err)
613
+
// Apply to community routes
614
+
routes.RegisterCommunityRoutes(r, communityService, authMiddleware)
618
+
// In internal/api/routes/community.go
620
+
func RegisterCommunityRoutes(r chi.Router, service communities.Service, auth *middleware.AuthMiddleware) {
621
+
// ... handlers initialization ...
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)
628
+
// Protected endpoints (require auth)
629
+
r.Group(func(r chi.Router) {
630
+
r.Use(auth.RequireAtProtoAuth) // Apply middleware
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)
640
+
### Handler Updates
642
+
Replace placeholder auth with context extraction:
645
+
// OLD (Phase 0 - Insecure)
646
+
userDID := r.Header.Get("X-User-DID") // ❌ Anyone can forge
649
+
userDID := middleware.GetUserDID(r) // ✅ From validated token
651
+
// Should never happen (middleware validates)
652
+
writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
657
+
## Implementation Checklist
659
+
### Phase 1 (Alpha) - ✅ COMPLETED (2025-10-16)
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)
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/)
693
+
### Phase 2 (Beta) - ✅ COMPLETED (2025-10-16)
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)
713
+
### Phase 3 (Production) - Future Work
715
+
**Status**: Not started (deferred to post-alpha)
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.
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
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
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)
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
772
+
## Security Considerations
774
+
### Phase 1 Limitations (MUST DOCUMENT)
776
+
**Warning**: Phase 1 implementation does NOT verify JWT signatures. This means:
778
+
- ❌ Anyone with JWT knowledge can mint "valid" tokens
779
+
- ❌ No cryptographic proof of PDS issuance
780
+
- ❌ Not suitable for untrusted users
782
+
**Acceptable because**:
783
+
- ✅ Alpha users are trusted early adopters
784
+
- ✅ Better than X-User-DID header
785
+
- ✅ Clear upgrade path to Phase 2
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)
793
+
### Phase 2+ Security
795
+
Once signature verification is implemented:
796
+
- ✅ Cryptographic proof of token authenticity
797
+
- ✅ Cannot forge tokens without PDS private key
798
+
- ✅ Production-grade security
800
+
### Additional Hardening
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
809
+
1. **PDS key caching**: What TTL is appropriate?
810
+
- Proposal: 1 hour (balance freshness vs performance)
811
+
- Allow PDS to hint with `Cache-Control` headers
813
+
2. **Nonce storage**: Where to store DPoP nonces?
814
+
- Phase 1: Not needed
815
+
- Phase 3: Redis or in-memory with TTL
817
+
3. **Error messages**: How detailed should auth errors be?
818
+
- Proposal: Generic "Unauthorized" to prevent enumeration
819
+
- Log detailed errors server-side for debugging
821
+
4. **Token audience**: Should we validate `aud` claim?
822
+
- Proposal: Optional validation, log if present but mismatched
823
+
- Some PDSes may not include `aud`
825
+
5. **Backward compatibility**: Support legacy auth during transition?
826
+
- Proposal: No. Clean break at alpha launch
827
+
- X-User-DID was never documented/public
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`
837
+
## Appendix: Token Examples
839
+
### Valid Access Token (Decoded)
846
+
"kid": "did:plc:alice#atproto-pds"
853
+
"iss": "https://pds.alice.com",
854
+
"sub": "did:plc:alice123",
855
+
"aud": "https://coves.social",
856
+
"scope": "atproto",
859
+
"jti": "token-unique-id-123",
861
+
"jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I"
866
+
### Valid DPoP Proof (Decoded)
876
+
"x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs",
877
+
"y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA"
885
+
"jti": "proof-unique-id-456",
887
+
"htu": "https://coves.social/xrpc/social.coves.community.create",
889
+
"ath": "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo",
890
+
"nonce": "server-nonce-abc123"
894
+
## Appendix: Comparison with Other Systems
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 | ✅ | ❌ | ✅ |
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.