A community based topic aggregation platform built on atproto

OAuth Authentication PRD: Third-Party Client Support#

✅ Implementation Status#

Phase 1 & 2: COMPLETED (2025-10-16)

  • ✅ JWT parsing and validation implemented
  • ✅ JWT signature verification with PDS public keys (RSA + ECDSA/ES256)
  • ✅ JWKS fetching and caching (1 hour TTL)
  • ✅ Auth middleware protecting community endpoints
  • ✅ Handlers updated to use GetUserDID(r)
  • ✅ Comprehensive middleware auth tests (11 test cases)
  • ✅ E2E tests updated to use DPoP-bound tokens
  • ✅ Security logging with IP, method, path, issuer
  • ✅ Scope validation (atproto required)
  • ✅ Issuer HTTPS validation
  • ✅ CreatedByDID validation in handlers
  • ✅ All tests passing
  • ✅ Documentation complete

Implementation Location: internal/atproto/auth/, internal/api/middleware/auth.go

Configuration: Set AUTH_SKIP_VERIFY=false for full signature verification (recommended for production).

Security Notes:

  • Phase 1 (skipVerify=true): Parses and validates JWT claims without signature verification - suitable for alpha with trusted users
  • Phase 2 (skipVerify=false): Full cryptographic signature verification with PDS public keys - production-ready

Next Steps: Phase 3 (DPoP validation, audience validation, JWKS fetcher tests) can be implemented when needed for production hardening.


Overview#

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).

Why This Is Needed for Coves#

The Problem#

Currently, Coves community endpoints accept an X-User-DID header that anyone can forge. This is fundamentally insecure and allows:

  • Impersonation attacks (claiming to be any DID)
  • Unauthorized community creation
  • Fake subscriptions
  • Malicious updates to communities

Example of current vulnerability:

# Anyone can pretend to be alice by setting a header
curl -X POST https://coves.social/xrpc/social.coves.community.create \
  -H "X-User-DID: did:plc:alice123" \
  -d '{"name": "fake-community", ...}'

Why Third-Party OAuth?#

Unlike traditional APIs where you control the auth flow, atProto is federated:

  1. Users authenticate with their PDS, not with Coves
  2. Third-party apps (mobile apps, desktop clients, browser extensions) obtain tokens from the user's PDS
  3. Coves must validate these tokens when clients make requests on behalf of users

This is fundamentally different from traditional OAuth where you're the authorization server. In atProto:

  • You are NOT the auth server - Each PDS is its own authorization server
  • You are a resource server - You validate tokens issued by arbitrary PDSes
  • You cannot control token issuance - Only validation

Why This Differs From First-Party OAuth#

Coves has two separate OAuth systems that serve different purposes:

System Purpose Location Token Source
First-Party OAuth Authenticate users for Coves web UI internal/core/oauth/ Coves issues tokens
Third-Party OAuth Validate tokens from external apps To be implemented User's PDS issues tokens

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.).

Third-party OAuth validation is for the server side - validating incoming tokens from arbitrary clients you didn't build.

Current State#

Existing Infrastructure#

1. First-Party OAuth (Client-Side)#

  • Location: internal/core/oauth/, internal/api/handlers/oauth/
  • Purpose: For a potential Coves web frontend
  • What it does:
    • Login flows (/oauth/login, /oauth/callback)
    • Session management (cookie + database)
    • Token refresh
    • Client metadata (/oauth/client-metadata.json)
  • What it does NOT do: Validate incoming tokens from third-party apps

2. Placeholder Auth (INSECURE)#

  • Location: Community handlers (internal/api/handlers/community/*.go)
  • Current implementation:
    // INSECURE - allows impersonation
    userDID := r.Header.Get("X-User-DID")
    
  • Used by:
    • POST /xrpc/social.coves.community.create
    • POST /xrpc/social.coves.community.update
    • POST /xrpc/social.coves.community.subscribe
    • POST /xrpc/social.coves.community.unsubscribe

Protected vs Public Endpoints#

✅ Public Endpoints (No auth required)#

These are read-only endpoints that anyone can access:

  • GET /xrpc/social.coves.community.get - View a community
  • GET /xrpc/social.coves.community.list - List communities
  • GET /xrpc/social.coves.community.search - Search communities

Rationale: Public discovery is essential for network effects and user experience.

🔒 Protected Endpoints (Require authentication)#

These modify state and must verify the user's identity:

  • POST /xrpc/social.coves.community.create - Creates a community owned by the authenticated user
  • POST /xrpc/social.coves.community.update - Updates a community (must be owner/moderator)
  • POST /xrpc/social.coves.community.subscribe - Creates a subscription record in the user's repo
  • POST /xrpc/social.coves.community.unsubscribe - Deletes a subscription from the user's repo

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.

atProto OAuth Requirements#

How Third-Party Clients Work#

When a third-party app (e.g., a mobile client for Coves) wants to make authenticated requests:

┌─────────────┐                                ┌─────────────┐
│   User's    │                                │    User's   │
│  Mobile App │                                │     PDS     │
│             │  1. Initiate OAuth flow        │             │
│             │──────────────────────────────>│             │
│             │                                │             │
│             │  2. User authorizes            │             │
│             │  3. Receive access token       │             │
│             │<──────────────────────────────│             │
└─────────────┘                                └─────────────┘
       │
       │ 4. Make authenticated request
       │    Authorization: DPoP <token>
       │    DPoP: <proof-jwt>
       ↓
┌─────────────┐
│   Coves     │
│  AppView    │  5. Validate token & DPoP
│             │  6. Extract user DID
│             │  7. Process request
└─────────────┘

Token Format#

Third-party clients send two headers:

1. Authorization Header

Authorization: DPoP eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6ImRpZDpwbGM6YWxpY2UjYXRwcm90by1wZHMifQ...

Format: DPoP <access_token> (note: uses "DPoP" scheme, not "Bearer")

The access token is a JWT containing:

{
  "iss": "https://user-pds.example.com",    // PDS that issued token
  "sub": "did:plc:alice123",                // User's DID
  "aud": "https://coves.social",            // Target resource server (optional)
  "scope": "atproto",                       // Required scope
  "exp": 1698765432,                        // Expiration timestamp
  "iat": 1698761832,                        // Issued at timestamp
  "jti": "unique-token-id",                 // Unique token identifier
  "cnf": {                                   // Confirmation claim (DPoP binding)
    "jkt": "hash-of-dpop-public-key"
  }
}

2. DPoP Header

DPoP: eyJhbGciOiJFUzI1NiIsInR5cCI6ImRwb3Arand0In0...

The DPoP proof is a JWT proving possession of the private key bound to the access token:

{
  "typ": "dpop+jwt",
  "alg": "ES256",
  "jwk": {                                   // Public key (ephemeral)
    "kty": "EC",
    "crv": "P-256",
    "x": "...",
    "y": "..."
  }
}
// Payload:
{
  "jti": "unique-proof-id",                 // Unique proof identifier
  "htm": "POST",                            // HTTP method
  "htu": "https://coves.social/xrpc/...",   // Target URL (without query params)
  "iat": 1698761832,                        // Issued at
  "ath": "hash-of-access-token",            // Hash of access token (SHA-256)
  "nonce": "server-provided-nonce"          // Server nonce (after first request)
}

Validation Requirements#

To properly validate incoming requests, Coves must:

1. Extract and Parse Tokens#

  • Extract Authorization: DPoP <token> header
  • Extract DPoP: <proof> header
  • Parse both as JWTs

2. Validate Access Token Structure#

  • Check token is a valid JWT
  • Verify required claims exist (iss, sub, exp, scope, cnf)
  • Check scope includes atproto
  • Check exp hasn't passed

3. Fetch PDS Public Keys#

  • Extract PDS URL from iss claim
  • Fetch /.well-known/oauth-authorization-server metadata
  • Get jwks_uri from metadata
  • Fetch public keys from jwks_uri
  • Cache keys with appropriate TTL (critical for performance)

4. Verify Access Token Signature#

  • Find correct public key (match kid from JWT header)
  • Verify JWT signature using PDS public key
  • Cryptographically proves token was issued by claimed PDS

5. Validate DPoP Proof#

  • Parse DPoP JWT
  • Verify DPoP signature using public key in jwk claim
  • Check htm matches request HTTP method
  • Check htu matches request URL (without query params)
  • Check ath matches hash of access token
  • Verify jkt in access token matches hash of DPoP public key
  • Check iat is recent (prevent replay attacks)

6. Handle Nonces (Replay Prevention)#

  • First request: no nonce required
  • Return DPoP-Nonce header in response
  • Subsequent requests: verify nonce in DPoP proof
  • Rotate nonces periodically

Why Alternative Solutions Aren't Feasible#

Option 1: Use Indigo's OAuth Package ❌#

What we investigated: github.com/bluesky-social/indigo/atproto/auth/oauth

Why it doesn't work:

  • Indigo's OAuth package is client-side only
  • Designed for apps that make requests, not receive requests
  • No token validation for resource servers
  • No DPoP proof verification utilities

What it provides:

// Client-side only:
- ClientApp.StartAuthFlow()      // Initiate login
- ClientApp.ProcessCallback()    // Handle OAuth callback
- ClientSession.RefreshToken()   // Refresh tokens

What it does NOT provide:

// Server-side validation (missing):
- ValidateAccessToken()           // ❌ Not available
- ValidateDPoPProof()             // ❌ Not available
- FetchPDSKeys()                  // ❌ Not available

Option 2: Use Indigo's Service Auth ❌#

What we investigated: github.com/bluesky-social/indigo/atproto/auth.ServiceAuthValidator

Why it doesn't work:

  • Service auth is for service-to-service communication, not user auth
  • Different token format (short-lived JWTs, 60s TTL)
  • Different validation logic (no DPoP, different audience)
  • Used when PDS calls AppView on behalf of user, not when user's app calls AppView

Service Auth vs User OAuth:

Service Auth:           User OAuth:
PDS → AppView          Third-party App → AppView
Short-lived (60s)      Long-lived (hours)
No DPoP                DPoP required
Service DID            User DID

Option 3: Use Tangled's Implementation ❌#

What we investigated: Tangled's codebase at /home/bretton/Code/tangled/core

Why it doesn't work:

  • Tangled uses first-party OAuth only (their own web UI)
  • No third-party token validation implemented
  • Uses same indigo service auth we already ruled out
  • Custom icyphox.sh/atproto-oauth library is also client-side only

What Tangled has:

// First-party OAuth (client-side):
oauth.SaveSession()        // For their web UI
oauth.GetSession()         // For their web UI
oauth.AuthorizedClient()   // Making requests TO PDS

// Service-to-service:
ServiceAuth.VerifyServiceAuth()  // Same as indigo

What Tangled does NOT have:

  • Third-party OAuth token validation
  • DPoP proof verification for user tokens
  • PDS public key fetching/caching

Option 4: Trust X-User-DID Header ❌#

Current implementation - fundamentally insecure

Why it doesn't work:

  • Anyone can set HTTP headers
  • No cryptographic verification
  • Trivial to impersonate any user
  • Violates basic security principles

Attack example:

# Attacker creates community as victim
curl -X POST https://coves.social/xrpc/social.coves.community.create \
  -H "X-User-DID: did:plc:victim123" \
  -d '{"name": "impersonated-community", ...}'

Option 5: Proxy All Requests Through PDS ❌#

Idea: Only accept requests from PDSes, not clients

Why it doesn't work:

  • Breaks standard atProto architecture
  • Forces PDS to implement Coves-specific logic
  • Prevents third-party app development
  • Centralization defeats purpose of federation
  • No other AppView works this way

Option 6: Require Users to Register API Keys ❌#

Idea: Issue our own API keys to users

Why it doesn't work:

  • Defeats purpose of decentralized identity (DID)
  • Users already have cryptographic identity via PDS
  • Creates vendor lock-in (keys only work with Coves)
  • Incompatible with atProto federation model
  • No other AppView requires this

Implementation Approach#

Phased Rollout Strategy#

We'll implement OAuth validation in three phases to balance security, complexity, and time-to-alpha.

Phase 1: Alpha - Basic JWT Validation (MVP)#

Goal: Unblock alpha launch with basic security

Implementation:

func (m *AuthMiddleware) RequireAtProtoAuth(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // 1. Extract Authorization header
        authHeader := r.Header.Get("Authorization")
        if !strings.HasPrefix(authHeader, "DPoP ") {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return
        }

        token := strings.TrimPrefix(authHeader, "DPoP ")

        // 2. Parse JWT (unverified)
        claims, err := parseJWTClaims(token)
        if err != nil {
            http.Error(w, "Invalid token", http.StatusUnauthorized)
            return
        }

        // 3. Basic validation
        if time.Now().Unix() > claims.Expiry {
            http.Error(w, "Token expired", http.StatusUnauthorized)
            return
        }

        if !strings.Contains(claims.Scope, "atproto") {
            http.Error(w, "Invalid scope", http.StatusUnauthorized)
            return
        }

        // 4. Inject DID into context
        ctx := context.WithValue(r.Context(), UserDIDKey, claims.Subject)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

What Phase 1 Validates:

  • ✅ Token is a valid JWT structure
  • ✅ Token hasn't expired
  • ✅ Token has atproto scope
  • ✅ DID is extracted from sub claim

What Phase 1 Does NOT Validate:

  • ❌ JWT signature (anyone can mint valid-looking JWTs)
  • ❌ Token was actually issued by claimed PDS
  • ❌ DPoP proof

Security Posture:

  • Better than X-User-DID header (requires valid JWT structure)
  • Not production-ready (no signature verification)
  • Acceptable for alpha with trusted early users

Documentation Requirements:

// TODO(OAuth-Phase2): Add JWT signature verification before beta
//
// Current implementation parses JWT claims but does not verify signatures.
// This means tokens are not cryptographically validated against the PDS.
//
// Alpha security rationale:
// - Better than X-User-DID (requires JWT structure, expiry)
// - Acceptable risk for trusted early users
// - Must be replaced before public beta
//
// See docs/PRD_OAUTH.md for Phase 2 implementation plan.

Phase 2: Beta - JWT Signature Verification#

Goal: Cryptographically verify tokens

Implementation:

type TokenValidator struct {
    keyCache *PDSKeyCache  // Caches PDS public keys
    idResolver *identity.Resolver
}

func (v *TokenValidator) ValidateAccessToken(ctx context.Context, token string) (*Claims, error) {
    // 1. Parse JWT with claims
    jwt, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) {
        // 2. Extract issuer (PDS URL)
        claims := token.Claims.(jwt.MapClaims)
        issuer := claims["iss"].(string)

        // 3. Fetch PDS public keys (cached)
        keys, err := v.keyCache.GetKeys(ctx, issuer)
        if err != nil {
            return nil, err
        }

        // 4. Find matching key by kid
        kid := token.Header["kid"].(string)
        return keys.FindKey(kid)
    })

    if err != nil {
        return nil, fmt.Errorf("invalid signature: %w", err)
    }

    // 5. Validate claims
    claims := jwt.Claims.(Claims)
    if !claims.HasScope("atproto") {
        return nil, errors.New("missing atproto scope")
    }

    return &claims, nil
}

type PDSKeyCache struct {
    cache *ttlcache.Cache
}

func (c *PDSKeyCache) GetKeys(ctx context.Context, pdsURL string) (*jwk.Set, error) {
    // Check cache
    if keys, ok := c.cache.Get(pdsURL); ok {
        return keys.(*jwk.Set), nil
    }

    // Fetch metadata
    metadata, err := fetchAuthServerMetadata(ctx, pdsURL)
    if err != nil {
        return nil, err
    }

    // Fetch JWKS
    keys, err := fetchJWKS(ctx, metadata.JWKSURI)
    if err != nil {
        return nil, err
    }

    // Cache with TTL (1 hour)
    c.cache.Set(pdsURL, keys, time.Hour)

    return keys, nil
}

What Phase 2 Adds:

  • ✅ JWT signature verification
  • ✅ PDS public key fetching
  • ✅ Key caching (performance)
  • ✅ Cryptographic proof of token authenticity

Security Posture:

  • Production-grade token validation
  • Cryptographically verifies token issued by claimed PDS
  • Acceptable for public beta

Phase 3: Production - Full DPoP Validation#

Goal: Complete OAuth security compliance

Implementation:

func (v *TokenValidator) ValidateDPoPBoundToken(ctx context.Context, r *http.Request) (*Claims, error) {
    // 1. Extract tokens
    accessToken := extractAccessToken(r)
    dpopProof := r.Header.Get("DPoP")

    // 2. Validate access token (Phase 2 logic)
    claims, err := v.ValidateAccessToken(ctx, accessToken)
    if err != nil {
        return nil, err
    }

    // 3. Parse DPoP proof
    dpop, err := jwt.Parse(dpopProof, func(token *jwt.Token) (interface{}, error) {
        // Public key is in the JWT itself (jwk claim)
        jwkClaim := token.Header["jwk"]
        return parseJWK(jwkClaim)
    })
    if err != nil {
        return nil, fmt.Errorf("invalid DPoP proof: %w", err)
    }

    // 4. Validate DPoP proof
    dpopClaims := dpop.Claims.(DPoPClaims)

    // Check HTTP method matches
    if dpopClaims.HTM != r.Method {
        return nil, errors.New("DPoP htm mismatch")
    }

    // Check URL matches (without query params)
    expectedHTU := fmt.Sprintf("%s://%s%s", r.URL.Scheme, r.URL.Host, r.URL.Path)
    if dpopClaims.HTU != expectedHTU {
        return nil, errors.New("DPoP htu mismatch")
    }

    // Check access token hash
    tokenHash := sha256Hash(accessToken)
    if dpopClaims.ATH != tokenHash {
        return nil, errors.New("DPoP ath mismatch")
    }

    // 5. Verify DPoP key matches access token cnf
    dpopKeyThumbprint := computeJWKThumbprint(dpop.Header["jwk"])
    if claims.Confirmation.JKT != dpopKeyThumbprint {
        return nil, errors.New("DPoP key binding mismatch")
    }

    // 6. Check and update nonce
    if err := v.validateAndRotateNonce(r, dpopClaims.Nonce); err != nil {
        // Return 401 with new nonce header
        return nil, &NonceError{NewNonce: generateNonce()}
    }

    return claims, nil
}

What Phase 3 Adds:

  • ✅ DPoP proof verification
  • ✅ Token binding validation
  • ✅ Nonce handling (replay prevention)
  • ✅ Full OAuth/DPoP spec compliance

Security Posture:

  • Full production security
  • Prevents token theft/replay attacks
  • Industry-standard OAuth 2.0 + DPoP

Middleware Integration#

// In cmd/server/main.go

// Initialize auth middleware
authMiddleware, err := middleware.NewAuthMiddleware(sessionStore, identityResolver)
if err != nil {
    log.Fatal("Failed to initialize auth middleware:", err)
}

// Apply to community routes
routes.RegisterCommunityRoutes(r, communityService, authMiddleware)
// In internal/api/routes/community.go

func RegisterCommunityRoutes(r chi.Router, service communities.Service, auth *middleware.AuthMiddleware) {
    // ... handlers initialization ...

    // Public endpoints (no auth)
    r.Get("/xrpc/social.coves.community.get", getHandler.HandleGet)
    r.Get("/xrpc/social.coves.community.list", listHandler.HandleList)
    r.Get("/xrpc/social.coves.community.search", searchHandler.HandleSearch)

    // Protected endpoints (require auth)
    r.Group(func(r chi.Router) {
        r.Use(auth.RequireAtProtoAuth)  // Apply middleware

        r.Post("/xrpc/social.coves.community.create", createHandler.HandleCreate)
        r.Post("/xrpc/social.coves.community.update", updateHandler.HandleUpdate)
        r.Post("/xrpc/social.coves.community.subscribe", subscribeHandler.HandleSubscribe)
        r.Post("/xrpc/social.coves.community.unsubscribe", subscribeHandler.HandleUnsubscribe)
    })
}

Handler Updates#

Replace placeholder auth with context extraction:

// OLD (Phase 0 - Insecure)
userDID := r.Header.Get("X-User-DID")  // ❌ Anyone can forge

// NEW (Phase 1+)
userDID := middleware.GetUserDID(r)  // ✅ From validated token
if userDID == "" {
    // Should never happen (middleware validates)
    writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
    return
}

Implementation Checklist#

Phase 1 (Alpha) - ✅ COMPLETED (2025-10-16)#

  • Create internal/api/middleware/auth.go
    • RequireAuth middleware
    • OptionalAuth middleware
    • GetUserDID(r) helper
    • GetJWTClaims(r) helper
    • Basic JWT parsing (no signature verification)
    • Expiry validation
    • Scope validation (lenient: allows empty, rejects wrong scopes)
    • Issuer HTTPS validation
    • DID format validation
    • Security logging (IP, method, path, issuer, error type)
  • Update community handlers to use GetUserDID(r)
    • create.go (with CreatedByDID validation)
    • update.go
    • subscribe.go
  • Update route registration in routes/community.go
  • Add comprehensive middleware tests (auth_test.go)
    • Valid token acceptance
    • Missing/invalid header rejection
    • Malformed token rejection
    • Expired token rejection
    • Missing DID rejection
    • Optional auth scenarios
    • Context helper functions
  • Update E2E tests to use Bearer tokens
    • Created createTestJWT() helper in user_test.go
    • Updated community_e2e_test.go to use JWT auth
  • Delete orphaned OAuth files
    • Removed dpop_transport.go (referenced deleted packages)
    • Removed oauth_test.go (tested deleted first-party OAuth)
  • Documentation complete (README.md in internal/atproto/auth/)

Phase 2 (Beta) - ✅ COMPLETED (2025-10-16)#

  • Implement JWT signature verification (VerifyJWT in jwt.go)
  • Implement CachedJWKSFetcher with TTL (1 hour default)
  • Add PDS metadata fetching
    • /.well-known/oauth-authorization-server
    • JWKS fetching from jwks_uri
  • Add key caching layer (in-memory with TTL)
  • Add ECDSA (ES256) support for atProto tokens
    • Support for P-256, P-384, P-521 curves
    • toECPublicKey() method in JWK
    • Updated JWKSFetcher interface to return interface{}
  • Add comprehensive error handling
  • Add detailed security logging for validation failures
  • JWT tests passing (jwt_test.go)
  • Middleware tests passing (11/11 tests)
  • Build verification successful
  • Integration tests with real PDS (deferred - requires live PDS)
  • Security audit (recommended before production)

Phase 3 (Production) - Future Work#

Status: Not started (deferred to post-alpha)

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.

  • Implement DPoP proof parsing
  • Add DPoP validation logic
    • htm validation
    • htu validation
    • ath validation
    • cnf/jkt binding validation
  • Implement nonce management
    • Nonce generation
    • Nonce storage (per-user, per-server)
    • Nonce rotation
  • Add replay attack prevention
  • Add comprehensive JWKS fetcher tests ⚠️ HIGH PRIORITY
    • Cache hit/miss scenarios
    • Cache expiration behavior
    • JWKS endpoint failures
    • Malformed JWKS responses
    • Key rotation (kid mismatch)
    • Concurrent fetch handling (thundering herd - known limitation)
  • Add optional audience (aud) claim validation
    • Configurable expected audience from APPVIEW_PUBLIC_URL
    • Lenient mode (allow missing audience)
    • Strict mode (reject if audience doesn't match)
  • Fix thundering herd issue in JWKS cache
    • Implement singleflight pattern (golang.org/x/sync/singleflight)
    • Add tests for concurrent cache misses
  • Performance optimization
    • Profile JWKS fetch performance
    • Consider Redis for JWKS cache in multi-instance deployments
  • Complete security audit
  • Load testing

Success Metrics#

Phase 1 (Alpha) - ✅ ACHIEVED#

  • All community endpoints reject requests without valid JWT structure
  • Integration tests pass with mock tokens (11/11 middleware tests passing)
  • Zero security regressions from X-User-DID (JWT validation is strictly better)
  • E2E tests updated to use proper DPoP token authentication
  • Build succeeds without compilation errors

Phase 2 (Beta) - ✅ READY FOR TESTING#

  • 100% of tokens cryptographically verified (when AUTH_SKIP_VERIFY=false)
  • ECDSA (ES256) token support for atProto ecosystem
  • PDS key cache hit rate >90% (requires production metrics)
  • Token validation <50ms p99 latency (requires production benchmarking)
  • Zero successful token forgery attempts in testing (ready for security audit)

Phase 3 (Production)#

  • Full DPoP spec compliance
  • Zero replay attacks in production
  • Token validation <100ms p99 latency
  • Security audit passed

Security Considerations#

Phase 1 Limitations (MUST DOCUMENT)#

Warning: Phase 1 implementation does NOT verify JWT signatures. This means:

  • ❌ Anyone with JWT knowledge can mint "valid" tokens
  • ❌ No cryptographic proof of PDS issuance
  • ❌ Not suitable for untrusted users

Acceptable because:

  • ✅ Alpha users are trusted early adopters
  • ✅ Better than X-User-DID header
  • ✅ Clear upgrade path to Phase 2

Mitigation:

  • Document limitations in README
  • Add warning to API documentation
  • Include TODO comments in code
  • Set clear deadline for Phase 2 (before public beta)

Phase 2+ Security#

Once signature verification is implemented:

  • ✅ Cryptographic proof of token authenticity
  • ✅ Cannot forge tokens without PDS private key
  • ✅ Production-grade security

Additional Hardening#

  • Rate limiting: Prevent brute force token guessing
  • Token revocation: Check against revocation list (future)
  • Audit logging: Log all authentication attempts
  • Monitoring: Alert on validation failure spikes

Open Questions#

  1. PDS key caching: What TTL is appropriate?

    • Proposal: 1 hour (balance freshness vs performance)
    • Allow PDS to hint with Cache-Control headers
  2. Nonce storage: Where to store DPoP nonces?

    • Phase 1: Not needed
    • Phase 3: Redis or in-memory with TTL
  3. Error messages: How detailed should auth errors be?

    • Proposal: Generic "Unauthorized" to prevent enumeration
    • Log detailed errors server-side for debugging
  4. Token audience: Should we validate aud claim?

    • Proposal: Optional validation, log if present but mismatched
    • Some PDSes may not include aud
  5. Backward compatibility: Support legacy auth during transition?

    • Proposal: No. Clean break at alpha launch
    • X-User-DID was never documented/public

References#

Appendix: Token Examples#

Valid Access Token (Decoded)#

Header:

{
  "alg": "ES256",
  "typ": "at+jwt",
  "kid": "did:plc:alice#atproto-pds"
}

Payload:

{
  "iss": "https://pds.alice.com",
  "sub": "did:plc:alice123",
  "aud": "https://coves.social",
  "scope": "atproto",
  "exp": 1698765432,
  "iat": 1698761832,
  "jti": "token-unique-id-123",
  "cnf": {
    "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I"
  }
}

Valid DPoP Proof (Decoded)#

Header:

{
  "typ": "dpop+jwt",
  "alg": "ES256",
  "jwk": {
    "kty": "EC",
    "crv": "P-256",
    "x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs",
    "y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA"
  }
}

Payload:

{
  "jti": "proof-unique-id-456",
  "htm": "POST",
  "htu": "https://coves.social/xrpc/social.coves.community.create",
  "iat": 1698761832,
  "ath": "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo",
  "nonce": "server-nonce-abc123"
}

Appendix: Comparison with Other Systems#

Feature Coves (Phase 1) Coves (Phase 3) Tangled Bluesky AppView
User OAuth Validation Basic JWT parse Full DPoP ❌ None ✅ Full
Signature Verification
DPoP Proof Validation
Service Auth
First-Party OAuth
Third-Party Support Partial

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.