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 DPoP-bound 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└─────────────┘ └─────────────┘ 144145 │ 4. Make authenticated request 146 │ Authorization: DPoP <token> 147 │ DPoP: <proof-jwt> 148149┌─────────────┐ 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>` (note: uses "DPoP" scheme, not "Bearer") 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 DPoP 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.