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 Bearer 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:
- Users authenticate with their PDS, not with Coves
- Third-party apps (mobile apps, desktop clients, browser extensions) obtain tokens from the user's PDS
- 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)
- Login flows (
- 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.createPOST /xrpc/social.coves.community.updatePOST /xrpc/social.coves.community.subscribePOST /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 communityGET /xrpc/social.coves.community.list- List communitiesGET /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 userPOST /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 repoPOST /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>
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
scopeincludesatproto - Check
exphasn't passed
3. Fetch PDS Public Keys#
- Extract PDS URL from
issclaim - Fetch
/.well-known/oauth-authorization-servermetadata - Get
jwks_urifrom 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
kidfrom 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
jwkclaim - Check
htmmatches request HTTP method - Check
htumatches request URL (without query params) - Check
athmatches hash of access token - Verify
jktin access token matches hash of DPoP public key - Check
iatis recent (prevent replay attacks)
6. Handle Nonces (Replay Prevention)#
- First request: no nonce required
- Return
DPoP-Nonceheader 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-oauthlibrary 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
atprotoscope - ✅ DID is extracted from
subclaim
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-DIDheader (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-
RequireAuthmiddleware -
OptionalAuthmiddleware -
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 inuser_test.go - Updated
community_e2e_test.goto use JWT auth
- Created
- Delete orphaned OAuth files
- Removed
dpop_transport.go(referenced deleted packages) - Removed
oauth_test.go(tested deleted first-party OAuth)
- Removed
- Documentation complete (README.md in internal/atproto/auth/)
Phase 2 (Beta) - ✅ COMPLETED (2025-10-16)#
- Implement JWT signature verification (
VerifyJWTinjwt.go) - Implement
CachedJWKSFetcherwith 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
JWKSFetcherinterface to returninterface{}
- 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
-
htmvalidation -
htuvalidation -
athvalidation -
cnf/jktbinding 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)
- Configurable expected audience from
- Fix thundering herd issue in JWKS cache
- Implement singleflight pattern (
golang.org/x/sync/singleflight) - Add tests for concurrent cache misses
- Implement singleflight pattern (
- 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 Bearer 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#
-
PDS key caching: What TTL is appropriate?
- Proposal: 1 hour (balance freshness vs performance)
- Allow PDS to hint with
Cache-Controlheaders
-
Nonce storage: Where to store DPoP nonces?
- Phase 1: Not needed
- Phase 3: Redis or in-memory with TTL
-
Error messages: How detailed should auth errors be?
- Proposal: Generic "Unauthorized" to prevent enumeration
- Log detailed errors server-side for debugging
-
Token audience: Should we validate
audclaim?- Proposal: Optional validation, log if present but mismatched
- Some PDSes may not include
aud
-
Backward compatibility: Support legacy auth during transition?
- Proposal: No. Clean break at alpha launch
- X-User-DID was never documented/public
References#
- atProto OAuth Spec
- RFC 9449 - OAuth 2.0 Demonstrating Proof of Possession (DPoP)
- RFC 7519 - JSON Web Token (JWT)
- Indigo OAuth Client Implementation
- Tangled codebase analysis:
/home/bretton/Code/tangled/core
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.