A community based topic aggregation platform built on atproto

feat(oauth): add confidential client support for extended session lifetime

Implements OAuth confidential client authentication to enable 1-year session
lifetimes (vs 14 days for public clients). Auth servers enforce shorter limits
for public clients, so this upgrade is necessary for improved UX.

Changes:
- Add OAUTH_CLIENT_PRIVATE_KEY and OAUTH_CLIENT_KEY_ID env configuration
- Add /oauth-client-keys.json JWKS endpoint for public key discovery
- Expand OAuth scopes to include all Coves record types and blob uploads
- Add LogoURI and PolicyURI to client metadata for auth screen branding
- Add cmd/tools/generate-oauth-key utility for P-256 key generation
- Update session TTL defaults to 1 year / 18 months for confidential clients
- Add scope validation with warnings in callback handler
- Fix WebSocket timeout handling in integration tests to prevent panics
- Increase unique name entropy in tests to reduce collision probability

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+466 -26
+10
.env.dev
··· 120 # This must be 32 bytes when base64-decoded for AES-256 121 # OAUTH_SEAL_SECRET=ryW6xNVxYhP6hCDA90NGCmK58Q2ONnkYXbHL0oZN2no= 122 123 # AppView public URL (used for OAuth callback and client metadata) 124 # Dev: http://127.0.0.1:8081 (use 127.0.0.1 instead of localhost per RFC 8252) 125 # Prod: https://coves.social
··· 120 # This must be 32 bytes when base64-decoded for AES-256 121 # OAUTH_SEAL_SECRET=ryW6xNVxYhP6hCDA90NGCmK58Q2ONnkYXbHL0oZN2no= 122 123 + # OAuth Confidential Client Configuration (optional, for testing) 124 + # If both are set, Coves becomes a confidential OAuth client with 90-day session lifetime 125 + # (Public clients are limited to 14 days by the auth server) 126 + # Generate keys with: go run ./cmd/tools/generate-oauth-key 127 + 128 + # Generated: 2026-02-05T22:49:41-08:00 129 + # WARNING: Keep this private key secure! Never commit to version control. 130 + OAUTH_CLIENT_PRIVATE_KEY=z42ti3ZG2JDj4RvFKdovxbgHB9Q4uvwsPvew8NQaxKxdfTLY 131 + OAUTH_CLIENT_KEY_ID=coves-key-1770360581 132 + 133 # AppView public URL (used for OAuth callback and client metadata) 134 # Dev: http://127.0.0.1:8081 (use 127.0.0.1 instead of localhost per RFC 8252) 135 # Prod: https://coves.social
+11
.env.dev.example
··· 78 # Generate with: openssl rand -hex 32 79 OAUTH_COOKIE_SECRET=<generate-your-own-secret> 80 81 # ============================================================================= 82 # Development Settings 83 # =============================================================================
··· 78 # Generate with: openssl rand -hex 32 79 OAUTH_COOKIE_SECRET=<generate-your-own-secret> 80 81 + # OAuth Confidential Client Configuration (optional, for testing) 82 + # If both are set, Coves becomes a confidential OAuth client with 90-day session lifetime 83 + # (Public clients are limited to 14 days by the auth server) 84 + # Generate keys with: go run ./cmd/tools/generate-oauth-key 85 + 86 + # P-256 private key in multibase format (z-prefixed base58btc) 87 + # OAUTH_CLIENT_PRIVATE_KEY=z... 88 + 89 + # Key identifier (arbitrary string, used in JWT header) 90 + # OAUTH_CLIENT_KEY_ID=coves-dev-key-1 91 + 92 # ============================================================================= 93 # Development Settings 94 # =============================================================================
+10 -4
.env.prod.example
··· 85 # REQUIRED - Generate with: openssl rand -base64 32 86 OAUTH_SEAL_SECRET=CHANGE_ME_BASE64_32_BYTES 87 88 - # Optional: OAuth client secret and key ID (for confidential clients only) 89 - # Most deployments use public clients and don't need these 90 - # OAUTH_CLIENT_SECRET= 91 - # OAUTH_CLIENT_KID= 92 93 # ============================================================================= 94 # Mobile Universal Links & App Links
··· 85 # REQUIRED - Generate with: openssl rand -base64 32 86 OAUTH_SEAL_SECRET=CHANGE_ME_BASE64_32_BYTES 87 88 + # OAuth Confidential Client Configuration (optional) 89 + # If both are set, Coves becomes a confidential OAuth client with 90-day session lifetime 90 + # (Public clients are limited to 14 days by the auth server) 91 + # Generate keys with: go run ./cmd/tools/generate-oauth-key 92 + 93 + # P-256 private key in multibase format (z-prefixed base58btc) 94 + # OAUTH_CLIENT_PRIVATE_KEY=z... 95 + 96 + # Key identifier (arbitrary string, used in JWT header) 97 + # OAUTH_CLIENT_KEY_ID=coves-prod-key-1 98 99 # ============================================================================= 100 # Mobile Universal Links & App Links
+24 -4
cmd/server/main.go
··· 202 isDevMode := os.Getenv("IS_DEV_ENV") == "true" 203 pdsURL := os.Getenv("PDS_URL") // For dev mode: resolve handles via local PDS 204 oauthConfig := &oauth.OAuthConfig{ 205 - PublicURL: os.Getenv("APPVIEW_PUBLIC_URL"), 206 - SealSecret: oauthSealSecret, 207 - Scopes: []string{"atproto"}, 208 DevMode: isDevMode, 209 AllowPrivateIPs: isDevMode, // Allow private IPs only in dev mode 210 PLCURL: plcURL, 211 PDSURL: pdsURL, // For dev mode handle resolution 212 - // SessionTTL and SealedTokenTTL will use defaults if not set (7 days and 14 days) 213 } 214 215 // Create PostgreSQL-backed OAuth session store (using default 7-day TTL)
··· 202 isDevMode := os.Getenv("IS_DEV_ENV") == "true" 203 pdsURL := os.Getenv("PDS_URL") // For dev mode: resolve handles via local PDS 204 oauthConfig := &oauth.OAuthConfig{ 205 + PublicURL: os.Getenv("APPVIEW_PUBLIC_URL"), 206 + SealSecret: oauthSealSecret, 207 + Scopes: []string{ 208 + "atproto", 209 + "blob:*/*", // For avatar/image uploads 210 + // Posts 211 + "repo:social.coves.community.post?action=create&action=update&action=delete", 212 + // Comments 213 + "repo:social.coves.community.comment?action=create&action=update&action=delete", 214 + // Communities 215 + "repo:social.coves.community.profile?action=create&action=update&action=delete", 216 + // Subscriptions 217 + "repo:social.coves.community.subscription?action=create&action=update&action=delete", 218 + // User profile 219 + "repo:social.coves.actor.profile?action=create&action=update&action=delete", 220 + // Votes 221 + "repo:social.coves.feed.vote?action=create&action=delete", 222 + }, 223 DevMode: isDevMode, 224 AllowPrivateIPs: isDevMode, // Allow private IPs only in dev mode 225 PLCURL: plcURL, 226 PDSURL: pdsURL, // For dev mode handle resolution 227 + // Confidential client keys (optional - if set, upgrades to confidential client) 228 + // Confidential clients: 90-day session TTL, 180-day sealed token TTL 229 + // Public clients: Limited to 14 days by auth server regardless of config 230 + ClientPrivateKeyMultibase: os.Getenv("OAUTH_CLIENT_PRIVATE_KEY"), 231 + ClientKeyID: os.Getenv("OAUTH_CLIENT_KEY_ID"), 232 + // SessionTTL and SealedTokenTTL use defaults if not set (90 days and 180 days) 233 } 234 235 // Create PostgreSQL-backed OAuth session store (using default 7-day TTL)
+26
cmd/tools/generate-oauth-key/main.go
···
··· 1 + // Package main provides a utility to generate P-256 private keys for OAuth confidential clients. 2 + // Run with: go run ./cmd/tools/generate-oauth-key 3 + package main 4 + 5 + import ( 6 + "fmt" 7 + "os" 8 + "time" 9 + 10 + "github.com/bluesky-social/indigo/atproto/atcrypto" 11 + ) 12 + 13 + func main() { 14 + priv, err := atcrypto.GeneratePrivateKeyP256() 15 + if err != nil { 16 + fmt.Fprintf(os.Stderr, "Error generating key: %v\n", err) 17 + os.Exit(1) 18 + } 19 + 20 + // Output in format suitable for .env file 21 + fmt.Printf("# OAuth Confidential Client Key\n") 22 + fmt.Printf("# Generated: %s\n", time.Now().Format(time.RFC3339)) 23 + fmt.Printf("# WARNING: Keep this private key secure! Never commit to version control.\n") 24 + fmt.Printf("OAUTH_CLIENT_PRIVATE_KEY=%s\n", priv.Multibase()) 25 + fmt.Printf("OAUTH_CLIENT_KEY_ID=coves-key-%d\n", time.Now().Unix()) 26 + }
+1
internal/api/routes/oauth.go
··· 29 // OAuth metadata endpoints - public, no extra rate limiting (use global limit) 30 // Serve at root /oauth-client-metadata.json so OAuth screens show clean brand domain 31 r.Get("/oauth-client-metadata.json", handler.HandleClientMetadata) 32 r.Get("/.well-known/oauth-protected-resource", handler.HandleProtectedResourceMetadata) 33 34 // OAuth flow endpoints - stricter rate limiting for authentication attempts
··· 29 // OAuth metadata endpoints - public, no extra rate limiting (use global limit) 30 // Serve at root /oauth-client-metadata.json so OAuth screens show clean brand domain 31 r.Get("/oauth-client-metadata.json", handler.HandleClientMetadata) 32 + r.Get("/oauth-client-keys.json", handler.HandleClientJWKS) 33 r.Get("/.well-known/oauth-protected-resource", handler.HandleProtectedResourceMetadata) 34 35 // OAuth flow endpoints - stricter rate limiting for authentication attempts
+38 -4
internal/atproto/oauth/client.go
··· 7 "net/url" 8 "time" 9 10 "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 "github.com/bluesky-social/indigo/atproto/identity" 12 ) ··· 29 SealedTokenTTL time.Duration 30 DevMode bool 31 AllowPrivateIPs bool 32 } 33 34 // NewOAuthClient creates a new OAuth client for Coves ··· 67 68 // Set default TTL values if not specified 69 // Per atproto OAuth spec: 70 - // - Public clients: 2-week (14 day) maximum session lifetime 71 - // - Confidential clients: 180-day maximum session lifetime 72 if config.SessionTTL == 0 { 73 - config.SessionTTL = 7 * 24 * time.Hour // 7 days default 74 } 75 if config.SealedTokenTTL == 0 { 76 - config.SealedTokenTTL = 14 * 24 * time.Hour // 14 days (public client limit) 77 } 78 79 // Create indigo client config ··· 95 clientConfig = oauth.NewPublicConfig(clientID, callbackURL, config.Scopes) 96 } 97 98 // Set user agent 99 clientConfig.UserAgent = "Coves/1.0" 100 ··· 138 139 // Add additional metadata for Coves 140 metadata.ClientName = strPtr("Coves") 141 if !c.Config.DevMode { 142 metadata.ClientURI = strPtr(c.Config.PublicURL) 143 } 144 145 return metadata
··· 7 "net/url" 8 "time" 9 10 + "github.com/bluesky-social/indigo/atproto/atcrypto" 11 "github.com/bluesky-social/indigo/atproto/auth/oauth" 12 "github.com/bluesky-social/indigo/atproto/identity" 13 ) ··· 30 SealedTokenTTL time.Duration 31 DevMode bool 32 AllowPrivateIPs bool 33 + 34 + // Confidential client (optional - if set, upgrades to confidential) 35 + ClientPrivateKeyMultibase string 36 + ClientKeyID string 37 } 38 39 // NewOAuthClient creates a new OAuth client for Coves ··· 72 73 // Set default TTL values if not specified 74 // Per atproto OAuth spec: 75 + // - Public clients: 2-week (14 day) maximum session lifetime (enforced by auth server) 76 + // - Confidential clients: up to 2 years maximum session lifetime 77 + // Note: The auth server ultimately enforces these limits. Public clients with longer TTLs 78 + // configured here will still be limited to 14 days by the auth server. 79 if config.SessionTTL == 0 { 80 + config.SessionTTL = 365 * 24 * time.Hour // 1 year (confidential clients only) 81 } 82 if config.SealedTokenTTL == 0 { 83 + config.SealedTokenTTL = 548 * 24 * time.Hour // 18 months (confidential clients only) 84 } 85 86 // Create indigo client config ··· 102 clientConfig = oauth.NewPublicConfig(clientID, callbackURL, config.Scopes) 103 } 104 105 + // Upgrade to confidential client if private key is configured 106 + // Confidential clients get longer session lifetimes (up to 90/180 days vs 14 days for public) 107 + if config.ClientPrivateKeyMultibase != "" && config.ClientKeyID != "" { 108 + priv, err := atcrypto.ParsePrivateMultibase(config.ClientPrivateKeyMultibase) 109 + if err != nil { 110 + return nil, fmt.Errorf("parsing OAuth client private key: %w", err) 111 + } 112 + if err := clientConfig.SetClientSecret(priv, config.ClientKeyID); err != nil { 113 + return nil, fmt.Errorf("setting OAuth client secret: %w", err) 114 + } 115 + slog.Info("OAuth client configured as confidential", "key_id", config.ClientKeyID) 116 + } else if config.ClientPrivateKeyMultibase != "" || config.ClientKeyID != "" { 117 + // Partial configuration - warn operator that both fields are required 118 + // Without both, we fall back to public client with 14-day session limit 119 + slog.Warn("OAuth confidential client partially configured - both OAUTH_CLIENT_PRIVATE_KEY and OAUTH_CLIENT_KEY_ID are required", 120 + "has_private_key", config.ClientPrivateKeyMultibase != "", 121 + "has_key_id", config.ClientKeyID != "") 122 + } 123 + 124 // Set user agent 125 clientConfig.UserAgent = "Coves/1.0" 126 ··· 164 165 // Add additional metadata for Coves 166 metadata.ClientName = strPtr("Coves") 167 + metadata.LogoURI = strPtr(c.Config.PublicURL + "/static/images/lil_dude.png") 168 + metadata.PolicyURI = strPtr(c.Config.PublicURL + "/privacy") 169 + 170 if !c.Config.DevMode { 171 metadata.ClientURI = strPtr(c.Config.PublicURL) 172 + // For confidential clients, include the JWKS URI for the public key 173 + if c.ClientApp.Config.IsConfidential() { 174 + jwksURI := c.Config.PublicURL + "/oauth-client-keys.json" 175 + metadata.JWKSURI = &jwksURI 176 + } 177 } 178 179 return metadata
+47
internal/atproto/oauth/handlers.go
··· 231 } 232 } 233 234 // HandleLogin starts the OAuth flow (web version) 235 // GET /oauth/login?handle=user.bsky.social 236 func (h *OAuthHandler) HandleLogin(w http.ResponseWriter, r *http.Request) { ··· 471 slog.Error("OAuth callback returned nil session data") 472 http.Error(w, "OAuth callback failed: no session data", http.StatusInternalServerError) 473 return 474 } 475 476 // Bidirectional handle verification: ensure the DID actually controls a valid handle ··· 1027 http.Error(w, "Universal Link not intercepted: The mobile app should have opened this URL. "+ 1028 "Check that Universal Links (iOS) or App Links (Android) are properly configured.", http.StatusBadRequest) 1029 }
··· 231 } 232 } 233 234 + // HandleClientJWKS serves the OAuth client public keys (JWKS) 235 + // GET /oauth-client-keys.json 236 + // This endpoint is only relevant for confidential clients; public clients don't have keys. 237 + func (h *OAuthHandler) HandleClientJWKS(w http.ResponseWriter, r *http.Request) { 238 + jwks := h.client.ClientApp.Config.PublicJWKS() 239 + 240 + // Encode to buffer first to avoid setting headers on a response that may fail mid-write. 241 + // If encoding fails after headers are set, clients receive Content-Type: application/json 242 + // but an HTML error body, causing parsing failures. 243 + data, err := json.Marshal(jwks) 244 + if err != nil { 245 + slog.Error("failed to encode client JWKS", "error", err) 246 + http.Error(w, "internal server error", http.StatusInternalServerError) 247 + return 248 + } 249 + 250 + w.Header().Set("Content-Type", "application/json") 251 + w.Header().Set("Cache-Control", "public, max-age=3600") 252 + if _, err := w.Write(data); err != nil { 253 + slog.Error("failed to write client JWKS response", "error", err) 254 + // Headers already sent, can't change response at this point 255 + } 256 + } 257 + 258 // HandleLogin starts the OAuth flow (web version) 259 // GET /oauth/login?handle=user.bsky.social 260 func (h *OAuthHandler) HandleLogin(w http.ResponseWriter, r *http.Request) { ··· 495 slog.Error("OAuth callback returned nil session data") 496 http.Error(w, "OAuth callback failed: no session data", http.StatusInternalServerError) 497 return 498 + } 499 + 500 + // Validate that critical scopes were granted by the authorization server. 501 + // Log warnings for missing scopes but don't fail auth - users can still use limited functionality. 502 + criticalScopes := []string{"atproto", "blob:*/*"} 503 + for _, required := range criticalScopes { 504 + if !scopesContain(sessData.Scopes, required) { 505 + slog.Warn("OAuth callback: critical scope not granted", 506 + "did", sessData.AccountDID, 507 + "missing_scope", required, 508 + "granted_scopes", sessData.Scopes) 509 + } 510 } 511 512 // Bidirectional handle verification: ensure the DID actually controls a valid handle ··· 1063 http.Error(w, "Universal Link not intercepted: The mobile app should have opened this URL. "+ 1064 "Check that Universal Links (iOS) or App Links (Android) are properly configured.", http.StatusBadRequest) 1065 } 1066 + 1067 + // scopesContain checks if a required scope is present in the granted scopes list. 1068 + // It performs an exact match comparison. 1069 + func scopesContain(granted []string, required string) bool { 1070 + for _, scope := range granted { 1071 + if scope == required { 1072 + return true 1073 + } 1074 + } 1075 + return false 1076 + }
+227
internal/atproto/oauth/handlers_test.go
··· 248 } 249 return &did, nil 250 }
··· 248 } 249 return &did, nil 250 } 251 + 252 + // TestConfidentialClientTransition validates the seamless transition from public to confidential client 253 + func TestConfidentialClientTransition(t *testing.T) { 254 + baseConfig := func() *OAuthConfig { 255 + return &OAuthConfig{ 256 + PublicURL: "https://coves.social", 257 + Scopes: []string{"atproto"}, 258 + DevMode: false, 259 + AllowPrivateIPs: false, 260 + SealSecret: "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=", 261 + } 262 + } 263 + 264 + t.Run("public client without keys", func(t *testing.T) { 265 + config := baseConfig() 266 + client, err := NewOAuthClient(config, oauth.NewMemStore()) 267 + require.NoError(t, err) 268 + 269 + // Should NOT be confidential 270 + assert.False(t, client.ClientApp.Config.IsConfidential()) 271 + 272 + // Metadata should NOT have JWKS URI 273 + metadata := client.ClientMetadata() 274 + assert.Nil(t, metadata.JWKSURI) 275 + assert.Equal(t, "none", metadata.TokenEndpointAuthMethod) 276 + }) 277 + 278 + t.Run("confidential client with keys", func(t *testing.T) { 279 + config := baseConfig() 280 + // TEST-ONLY: P-256 private key in multibase format - DO NOT use in production 281 + // This is a publicly known test key that provides NO security 282 + config.ClientPrivateKeyMultibase = "z42tn7PHdrLvcwoYGtY71n4g56NcQ3vJn3W5NNJV9mmqDL68" 283 + config.ClientKeyID = "test-key-1" 284 + 285 + client, err := NewOAuthClient(config, oauth.NewMemStore()) 286 + require.NoError(t, err) 287 + 288 + // Should be confidential 289 + assert.True(t, client.ClientApp.Config.IsConfidential()) 290 + 291 + // Metadata SHOULD have JWKS URI 292 + metadata := client.ClientMetadata() 293 + require.NotNil(t, metadata.JWKSURI) 294 + assert.Equal(t, "https://coves.social/oauth-client-keys.json", *metadata.JWKSURI) 295 + assert.Equal(t, "private_key_jwt", metadata.TokenEndpointAuthMethod) 296 + }) 297 + 298 + t.Run("partial keys rejected", func(t *testing.T) { 299 + // Only private key, no key ID 300 + config := baseConfig() 301 + // TEST-ONLY key - DO NOT use in production 302 + config.ClientPrivateKeyMultibase = "z42tn7PHdrLvcwoYGtY71n4g56NcQ3vJn3W5NNJV9mmqDL68" 303 + // No ClientKeyID 304 + 305 + client, err := NewOAuthClient(config, oauth.NewMemStore()) 306 + require.NoError(t, err) 307 + 308 + // Should NOT be confidential (both fields required) 309 + assert.False(t, client.ClientApp.Config.IsConfidential()) 310 + }) 311 + 312 + t.Run("invalid private key rejected", func(t *testing.T) { 313 + config := baseConfig() 314 + config.ClientPrivateKeyMultibase = "invalid-key" 315 + config.ClientKeyID = "test-key-1" 316 + 317 + _, err := NewOAuthClient(config, oauth.NewMemStore()) 318 + assert.Error(t, err) 319 + assert.Contains(t, err.Error(), "parsing OAuth client private key") 320 + }) 321 + } 322 + 323 + // TestHandleClientJWKS tests the JWKS endpoint 324 + func TestHandleClientJWKS(t *testing.T) { 325 + t.Run("public client returns empty JWKS", func(t *testing.T) { 326 + config := &OAuthConfig{ 327 + PublicURL: "https://coves.social", 328 + Scopes: []string{"atproto"}, 329 + DevMode: false, 330 + AllowPrivateIPs: false, 331 + SealSecret: "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=", 332 + } 333 + 334 + client, err := NewOAuthClient(config, oauth.NewMemStore()) 335 + require.NoError(t, err) 336 + 337 + handler := NewOAuthHandler(client, oauth.NewMemStore()) 338 + 339 + req := httptest.NewRequest(http.MethodGet, "/oauth-client-keys.json", nil) 340 + rec := httptest.NewRecorder() 341 + 342 + handler.HandleClientJWKS(rec, req) 343 + 344 + assert.Equal(t, http.StatusOK, rec.Code) 345 + assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) 346 + assert.Equal(t, "public, max-age=3600", rec.Header().Get("Cache-Control")) 347 + 348 + // Parse response - should be empty keys array for public client 349 + var jwks oauth.JWKS 350 + err = json.NewDecoder(rec.Body).Decode(&jwks) 351 + require.NoError(t, err) 352 + assert.Empty(t, jwks.Keys) 353 + }) 354 + 355 + t.Run("confidential client returns JWKS with public key", func(t *testing.T) { 356 + config := &OAuthConfig{ 357 + PublicURL: "https://coves.social", 358 + Scopes: []string{"atproto"}, 359 + DevMode: false, 360 + AllowPrivateIPs: false, 361 + SealSecret: "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=", 362 + // TEST-ONLY key - DO NOT use in production 363 + ClientPrivateKeyMultibase: "z42tn7PHdrLvcwoYGtY71n4g56NcQ3vJn3W5NNJV9mmqDL68", 364 + ClientKeyID: "test-key-1", 365 + } 366 + 367 + client, err := NewOAuthClient(config, oauth.NewMemStore()) 368 + require.NoError(t, err) 369 + 370 + handler := NewOAuthHandler(client, oauth.NewMemStore()) 371 + 372 + req := httptest.NewRequest(http.MethodGet, "/oauth-client-keys.json", nil) 373 + rec := httptest.NewRecorder() 374 + 375 + handler.HandleClientJWKS(rec, req) 376 + 377 + assert.Equal(t, http.StatusOK, rec.Code) 378 + assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) 379 + 380 + // Parse response - should have one key 381 + var jwks oauth.JWKS 382 + err = json.NewDecoder(rec.Body).Decode(&jwks) 383 + require.NoError(t, err) 384 + require.Len(t, jwks.Keys, 1) 385 + 386 + // Validate key properties 387 + key := jwks.Keys[0] 388 + assert.Equal(t, "EC", key.KeyType) 389 + assert.Equal(t, "P-256", key.Curve) 390 + assert.NotNil(t, key.KeyID) 391 + assert.Equal(t, "test-key-1", *key.KeyID) 392 + // Should have public key coordinates (X, Y) 393 + // The JWK struct only contains public key data - no private key field 394 + assert.NotEmpty(t, key.X) 395 + assert.NotEmpty(t, key.Y) 396 + }) 397 + } 398 + 399 + // TestOAuthEndpointsNoConflict ensures new endpoints don't conflict with existing routes 400 + func TestOAuthEndpointsNoConflict(t *testing.T) { 401 + // This test validates that the route paths are distinct and don't overlap 402 + routes := map[string]string{ 403 + "/oauth-client-metadata.json": "Client identity document", 404 + "/oauth-client-keys.json": "JWKS public keys (new)", 405 + "/oauth/callback": "OAuth callback after auth", 406 + "/oauth/login": "Start web OAuth flow", 407 + "/oauth/mobile/login": "Start mobile OAuth flow", 408 + "/.well-known/oauth-protected-resource": "Resource server metadata", 409 + } 410 + 411 + // All routes should be unique 412 + seen := make(map[string]bool) 413 + for route := range routes { 414 + assert.False(t, seen[route], "Duplicate route: %s", route) 415 + seen[route] = true 416 + } 417 + 418 + // Verify none of the OAuth routes conflict with DID routes 419 + didRoutes := []string{ 420 + "/.well-known/did.json", 421 + "/.well-known/atproto-did", 422 + } 423 + 424 + for _, didRoute := range didRoutes { 425 + for oauthRoute := range routes { 426 + assert.NotEqual(t, didRoute, oauthRoute, 427 + "OAuth route %s conflicts with DID route %s", oauthRoute, didRoute) 428 + } 429 + } 430 + } 431 + 432 + // TestConfidentialClientWithDevMode verifies confidential client works in dev mode 433 + func TestConfidentialClientWithDevMode(t *testing.T) { 434 + config := &OAuthConfig{ 435 + PublicURL: "http://127.0.0.1:8081", 436 + Scopes: []string{"atproto"}, 437 + DevMode: true, // Dev mode enabled 438 + AllowPrivateIPs: true, 439 + SealSecret: "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=", 440 + // TEST-ONLY key - DO NOT use in production 441 + ClientPrivateKeyMultibase: "z42tn7PHdrLvcwoYGtY71n4g56NcQ3vJn3W5NNJV9mmqDL68", 442 + ClientKeyID: "test-key-1", 443 + } 444 + 445 + client, err := NewOAuthClient(config, oauth.NewMemStore()) 446 + require.NoError(t, err) 447 + 448 + // Should be confidential even in dev mode 449 + assert.True(t, client.ClientApp.Config.IsConfidential()) 450 + assert.True(t, client.Config.DevMode) 451 + 452 + // In dev mode, loopback config is used but still becomes confidential 453 + assert.Equal(t, "private_key_jwt", client.ClientApp.Config.ClientMetadata().TokenEndpointAuthMethod) 454 + 455 + // Dev mode uses loopback client_id format 456 + // Loopback clients use http://localhost format 457 + assert.Contains(t, client.ClientApp.Config.ClientID, "http://") 458 + } 459 + 460 + // TestSessionTTLsForConfidentialClient verifies TTLs are set appropriately 461 + func TestSessionTTLsForConfidentialClient(t *testing.T) { 462 + config := &OAuthConfig{ 463 + PublicURL: "https://coves.social", 464 + Scopes: []string{"atproto"}, 465 + DevMode: false, 466 + AllowPrivateIPs: false, 467 + SealSecret: "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=", 468 + // No explicit TTL set - should use defaults 469 + } 470 + 471 + client, err := NewOAuthClient(config, oauth.NewMemStore()) 472 + require.NoError(t, err) 473 + 474 + // Default TTLs for confidential clients (1 year session, 18 months sealed token) 475 + assert.Equal(t, 365*24*time.Hour, client.Config.SessionTTL, "SessionTTL should be 1 year") 476 + assert.Equal(t, 548*24*time.Hour, client.Config.SealedTokenTTL, "SealedTokenTTL should be 18 months") 477 + }
+35 -6
tests/integration/community_avatar_e2e_test.go
··· 9 "bytes" 10 "context" 11 "database/sql" 12 "fmt" 13 "image" 14 "image/color" 15 "image/png" 16 "net/http" 17 "os" 18 "strings" ··· 123 consumer := jetstream.NewCommunityEventConsumer(communityRepo, instanceDID, true, identityResolver) 124 125 t.Run("create community with avatar via real Jetstream", func(t *testing.T) { 126 - uniqueName := fmt.Sprintf("avt%d", time.Now().UnixNano()%1000000) 127 creatorDID := "did:plc:avatar-create-test" 128 129 // Create a test PNG image (100x100 red square) ··· 146 } 147 defer func() { _ = conn.Close() }() 148 149 for { 150 select { 151 case <-done: ··· 159 160 var event jetstream.JetstreamEvent 161 if readErr := conn.ReadJSON(&event); readErr != nil { 162 - continue // Timeout or error, keep trying 163 } 164 165 // Only process community profile create events 166 if event.Kind == "commit" && event.Commit != nil && ··· 378 } 379 defer func() { _ = conn.Close() }() 380 381 for { 382 select { 383 case <-done: ··· 391 392 var event jetstream.JetstreamEvent 393 if readErr := conn.ReadJSON(&event); readErr != nil { 394 continue 395 } 396 397 if event.Kind == "commit" && event.Commit != nil && 398 event.Commit.Collection == "social.coves.community.profile" && ··· 415 } 416 417 t.Run("add avatar to community without one", func(t *testing.T) { 418 - uniqueName := fmt.Sprintf("upav%d", time.Now().UnixNano()%1000000) 419 creatorDID := "did:plc:avatar-update-test" 420 421 // Create a community WITHOUT an avatar ··· 528 }) 529 530 t.Run("replace existing avatar with new one", func(t *testing.T) { 531 - uniqueName := fmt.Sprintf("rpav%d", time.Now().UnixNano()%1000000) 532 creatorDID := "did:plc:avatar-replace-test" 533 534 // Create a community WITH an initial avatar (red square) ··· 747 } 748 defer func() { _ = conn.Close() }() 749 750 for { 751 select { 752 case <-done: ··· 760 761 var event jetstream.JetstreamEvent 762 if readErr := conn.ReadJSON(&event); readErr != nil { 763 continue 764 } 765 766 if event.Kind == "commit" && event.Commit != nil && 767 event.Commit.Collection == "social.coves.community.profile" && ··· 784 } 785 786 t.Run("add banner to community without one", func(t *testing.T) { 787 - uniqueName := fmt.Sprintf("ban%d", time.Now().UnixNano()%1000000) 788 creatorDID := "did:plc:banner-add-test" 789 790 // Create a community WITHOUT a banner ··· 897 }) 898 899 t.Run("replace existing banner with new one", func(t *testing.T) { 900 - uniqueName := fmt.Sprintf("rpban%d", time.Now().UnixNano()%1000000) 901 creatorDID := "did:plc:banner-replace-test" 902 903 // Create a community WITH an initial banner (red rectangle)
··· 9 "bytes" 10 "context" 11 "database/sql" 12 + "errors" 13 "fmt" 14 "image" 15 "image/color" 16 "image/png" 17 + "net" 18 "net/http" 19 "os" 20 "strings" ··· 125 consumer := jetstream.NewCommunityEventConsumer(communityRepo, instanceDID, true, identityResolver) 126 127 t.Run("create community with avatar via real Jetstream", func(t *testing.T) { 128 + uniqueName := fmt.Sprintf("avt%d", time.Now().UnixNano()%100000000) 129 creatorDID := "did:plc:avatar-create-test" 130 131 // Create a test PNG image (100x100 red square) ··· 148 } 149 defer func() { _ = conn.Close() }() 150 151 + consecutiveTimeouts := 0 152 for { 153 select { 154 case <-done: ··· 162 163 var event jetstream.JetstreamEvent 164 if readErr := conn.ReadJSON(&event); readErr != nil { 165 + var netErr net.Error 166 + if errors.As(readErr, &netErr) && netErr.Timeout() { 167 + consecutiveTimeouts++ 168 + if consecutiveTimeouts >= 10 { 169 + return // Connection stale, exit to prevent panic 170 + } 171 + } 172 + continue 173 } 174 + consecutiveTimeouts = 0 175 176 // Only process community profile create events 177 if event.Kind == "commit" && event.Commit != nil && ··· 389 } 390 defer func() { _ = conn.Close() }() 391 392 + consecutiveTimeouts := 0 393 for { 394 select { 395 case <-done: ··· 403 404 var event jetstream.JetstreamEvent 405 if readErr := conn.ReadJSON(&event); readErr != nil { 406 + var netErr net.Error 407 + if errors.As(readErr, &netErr) && netErr.Timeout() { 408 + consecutiveTimeouts++ 409 + if consecutiveTimeouts >= 10 { 410 + return // Connection stale, exit to prevent panic 411 + } 412 + } 413 continue 414 } 415 + consecutiveTimeouts = 0 416 417 if event.Kind == "commit" && event.Commit != nil && 418 event.Commit.Collection == "social.coves.community.profile" && ··· 435 } 436 437 t.Run("add avatar to community without one", func(t *testing.T) { 438 + uniqueName := fmt.Sprintf("upav%d", time.Now().UnixNano()%100000000) 439 creatorDID := "did:plc:avatar-update-test" 440 441 // Create a community WITHOUT an avatar ··· 548 }) 549 550 t.Run("replace existing avatar with new one", func(t *testing.T) { 551 + uniqueName := fmt.Sprintf("rpav%d", time.Now().UnixNano()%100000000) 552 creatorDID := "did:plc:avatar-replace-test" 553 554 // Create a community WITH an initial avatar (red square) ··· 767 } 768 defer func() { _ = conn.Close() }() 769 770 + consecutiveTimeouts := 0 771 for { 772 select { 773 case <-done: ··· 781 782 var event jetstream.JetstreamEvent 783 if readErr := conn.ReadJSON(&event); readErr != nil { 784 + var netErr net.Error 785 + if errors.As(readErr, &netErr) && netErr.Timeout() { 786 + consecutiveTimeouts++ 787 + if consecutiveTimeouts >= 10 { 788 + return // Connection stale, exit to prevent panic 789 + } 790 + } 791 continue 792 } 793 + consecutiveTimeouts = 0 794 795 if event.Kind == "commit" && event.Commit != nil && 796 event.Commit.Collection == "social.coves.community.profile" && ··· 813 } 814 815 t.Run("add banner to community without one", func(t *testing.T) { 816 + uniqueName := fmt.Sprintf("ban%d", time.Now().UnixNano()%100000000) 817 creatorDID := "did:plc:banner-add-test" 818 819 // Create a community WITHOUT a banner ··· 926 }) 927 928 t.Run("replace existing banner with new one", func(t *testing.T) { 929 + uniqueName := fmt.Sprintf("rpban%d", time.Now().UnixNano()%100000000) 930 creatorDID := "did:plc:banner-replace-test" 931 932 // Create a community WITH an initial banner (red rectangle)
+2 -2
tests/integration/community_update_e2e_test.go
··· 106 107 t.Run("update community with real Jetstream indexing", func(t *testing.T) { 108 // First, create a community 109 - uniqueName := fmt.Sprintf("upd%d", time.Now().UnixNano()%1000000) 110 creatorDID := "did:plc:jetstream-update-test" 111 112 t.Logf("\n📝 Creating community on PDS...") ··· 227 228 t.Run("multiple updates with real Jetstream", func(t *testing.T) { 229 // This tests that consecutive updates all flow through Jetstream correctly 230 - uniqueName := fmt.Sprintf("multi%d", time.Now().UnixNano()%1000000) 231 creatorDID := "did:plc:multi-update-test" 232 233 t.Logf("\n📝 Creating community for multi-update test...")
··· 106 107 t.Run("update community with real Jetstream indexing", func(t *testing.T) { 108 // First, create a community 109 + uniqueName := fmt.Sprintf("upd%d", time.Now().UnixNano()%100000000) 110 creatorDID := "did:plc:jetstream-update-test" 111 112 t.Logf("\n📝 Creating community on PDS...") ··· 227 228 t.Run("multiple updates with real Jetstream", func(t *testing.T) { 229 // This tests that consecutive updates all flow through Jetstream correctly 230 + uniqueName := fmt.Sprintf("multi%d", time.Now().UnixNano()%100000000) 231 creatorDID := "did:plc:multi-update-test" 232 233 t.Logf("\n📝 Creating community for multi-update test...")
+35 -6
tests/integration/user_profile_avatar_e2e_test.go
··· 11 "context" 12 "database/sql" 13 "encoding/json" 14 "fmt" 15 "image" 16 "image/color" ··· 177 } 178 defer func() { _ = conn.Close() }() 179 180 for { 181 select { 182 case <-done: ··· 191 var event jetstream.JetstreamEvent 192 if readErr := conn.ReadJSON(&event); readErr != nil { 193 var netErr net.Error 194 - if nErr, ok := readErr.(net.Error); ok && nErr.Timeout() { 195 - continue 196 - } 197 - // Check using errors.As as well 198 - if netErr != nil && netErr.Timeout() { 199 - continue 200 } 201 continue 202 } 203 204 // Only process profile update events for our user 205 if event.Kind == "commit" && event.Commit != nil && ··· 466 } 467 defer func() { _ = conn.Close() }() 468 469 for { 470 select { 471 case <-done: ··· 479 480 var event jetstream.JetstreamEvent 481 if err := conn.ReadJSON(&event); err != nil { 482 continue 483 } 484 485 if event.Kind == "commit" && event.Commit != nil && 486 event.Commit.Collection == "social.coves.actor.profile" && ··· 688 } 689 defer func() { _ = conn.Close() }() 690 691 for { 692 select { 693 case <-done: ··· 701 702 var event jetstream.JetstreamEvent 703 if err := conn.ReadJSON(&event); err != nil { 704 continue 705 } 706 707 if event.Kind == "commit" && event.Commit != nil && 708 event.Commit.Collection == "social.coves.actor.profile" && ··· 870 } 871 defer func() { _ = conn.Close() }() 872 873 for { 874 select { 875 case <-done: ··· 883 884 var event jetstream.JetstreamEvent 885 if err := conn.ReadJSON(&event); err != nil { 886 continue 887 } 888 889 if event.Kind == "commit" && event.Commit != nil && 890 event.Commit.Collection == "social.coves.actor.profile" &&
··· 11 "context" 12 "database/sql" 13 "encoding/json" 14 + "errors" 15 "fmt" 16 "image" 17 "image/color" ··· 178 } 179 defer func() { _ = conn.Close() }() 180 181 + consecutiveTimeouts := 0 182 for { 183 select { 184 case <-done: ··· 193 var event jetstream.JetstreamEvent 194 if readErr := conn.ReadJSON(&event); readErr != nil { 195 var netErr net.Error 196 + if errors.As(readErr, &netErr) && netErr.Timeout() { 197 + consecutiveTimeouts++ 198 + if consecutiveTimeouts >= 10 { 199 + return // Connection stale, exit to prevent panic 200 + } 201 } 202 continue 203 } 204 + consecutiveTimeouts = 0 205 206 // Only process profile update events for our user 207 if event.Kind == "commit" && event.Commit != nil && ··· 468 } 469 defer func() { _ = conn.Close() }() 470 471 + consecutiveTimeouts := 0 472 for { 473 select { 474 case <-done: ··· 482 483 var event jetstream.JetstreamEvent 484 if err := conn.ReadJSON(&event); err != nil { 485 + var netErr net.Error 486 + if errors.As(err, &netErr) && netErr.Timeout() { 487 + consecutiveTimeouts++ 488 + if consecutiveTimeouts >= 10 { 489 + return // Connection stale, exit to prevent panic 490 + } 491 + } 492 continue 493 } 494 + consecutiveTimeouts = 0 495 496 if event.Kind == "commit" && event.Commit != nil && 497 event.Commit.Collection == "social.coves.actor.profile" && ··· 699 } 700 defer func() { _ = conn.Close() }() 701 702 + consecutiveTimeouts := 0 703 for { 704 select { 705 case <-done: ··· 713 714 var event jetstream.JetstreamEvent 715 if err := conn.ReadJSON(&event); err != nil { 716 + var netErr net.Error 717 + if errors.As(err, &netErr) && netErr.Timeout() { 718 + consecutiveTimeouts++ 719 + if consecutiveTimeouts >= 10 { 720 + return // Connection stale, exit to prevent panic 721 + } 722 + } 723 continue 724 } 725 + consecutiveTimeouts = 0 726 727 if event.Kind == "commit" && event.Commit != nil && 728 event.Commit.Collection == "social.coves.actor.profile" && ··· 890 } 891 defer func() { _ = conn.Close() }() 892 893 + consecutiveTimeouts := 0 894 for { 895 select { 896 case <-done: ··· 904 905 var event jetstream.JetstreamEvent 906 if err := conn.ReadJSON(&event); err != nil { 907 + var netErr net.Error 908 + if errors.As(err, &netErr) && netErr.Timeout() { 909 + consecutiveTimeouts++ 910 + if consecutiveTimeouts >= 10 { 911 + return // Connection stale, exit to prevent panic 912 + } 913 + } 914 continue 915 } 916 + consecutiveTimeouts = 0 917 918 if event.Kind == "commit" && event.Commit != nil && 919 event.Commit.Collection == "social.coves.actor.profile" &&