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 120 # This must be 32 bytes when base64-decoded for AES-256 121 121 # OAUTH_SEAL_SECRET=ryW6xNVxYhP6hCDA90NGCmK58Q2ONnkYXbHL0oZN2no= 122 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 + 123 133 # AppView public URL (used for OAuth callback and client metadata) 124 134 # Dev: http://127.0.0.1:8081 (use 127.0.0.1 instead of localhost per RFC 8252) 125 135 # Prod: https://coves.social
+11
.env.dev.example
··· 78 78 # Generate with: openssl rand -hex 32 79 79 OAUTH_COOKIE_SECRET=<generate-your-own-secret> 80 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 + 81 92 # ============================================================================= 82 93 # Development Settings 83 94 # =============================================================================
+10 -4
.env.prod.example
··· 85 85 # REQUIRED - Generate with: openssl rand -base64 32 86 86 OAUTH_SEAL_SECRET=CHANGE_ME_BASE64_32_BYTES 87 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= 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 92 98 93 99 # ============================================================================= 94 100 # Mobile Universal Links & App Links
+24 -4
cmd/server/main.go
··· 202 202 isDevMode := os.Getenv("IS_DEV_ENV") == "true" 203 203 pdsURL := os.Getenv("PDS_URL") // For dev mode: resolve handles via local PDS 204 204 oauthConfig := &oauth.OAuthConfig{ 205 - PublicURL: os.Getenv("APPVIEW_PUBLIC_URL"), 206 - SealSecret: oauthSealSecret, 207 - Scopes: []string{"atproto"}, 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 + }, 208 223 DevMode: isDevMode, 209 224 AllowPrivateIPs: isDevMode, // Allow private IPs only in dev mode 210 225 PLCURL: plcURL, 211 226 PDSURL: pdsURL, // For dev mode handle resolution 212 - // SessionTTL and SealedTokenTTL will use defaults if not set (7 days and 14 days) 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) 213 233 } 214 234 215 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 29 // OAuth metadata endpoints - public, no extra rate limiting (use global limit) 30 30 // Serve at root /oauth-client-metadata.json so OAuth screens show clean brand domain 31 31 r.Get("/oauth-client-metadata.json", handler.HandleClientMetadata) 32 + r.Get("/oauth-client-keys.json", handler.HandleClientJWKS) 32 33 r.Get("/.well-known/oauth-protected-resource", handler.HandleProtectedResourceMetadata) 33 34 34 35 // OAuth flow endpoints - stricter rate limiting for authentication attempts
+38 -4
internal/atproto/oauth/client.go
··· 7 7 "net/url" 8 8 "time" 9 9 10 + "github.com/bluesky-social/indigo/atproto/atcrypto" 10 11 "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 12 "github.com/bluesky-social/indigo/atproto/identity" 12 13 ) ··· 29 30 SealedTokenTTL time.Duration 30 31 DevMode bool 31 32 AllowPrivateIPs bool 33 + 34 + // Confidential client (optional - if set, upgrades to confidential) 35 + ClientPrivateKeyMultibase string 36 + ClientKeyID string 32 37 } 33 38 34 39 // NewOAuthClient creates a new OAuth client for Coves ··· 67 72 68 73 // Set default TTL values if not specified 69 74 // Per atproto OAuth spec: 70 - // - Public clients: 2-week (14 day) maximum session lifetime 71 - // - Confidential clients: 180-day maximum session lifetime 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. 72 79 if config.SessionTTL == 0 { 73 - config.SessionTTL = 7 * 24 * time.Hour // 7 days default 80 + config.SessionTTL = 365 * 24 * time.Hour // 1 year (confidential clients only) 74 81 } 75 82 if config.SealedTokenTTL == 0 { 76 - config.SealedTokenTTL = 14 * 24 * time.Hour // 14 days (public client limit) 83 + config.SealedTokenTTL = 548 * 24 * time.Hour // 18 months (confidential clients only) 77 84 } 78 85 79 86 // Create indigo client config ··· 95 102 clientConfig = oauth.NewPublicConfig(clientID, callbackURL, config.Scopes) 96 103 } 97 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 + 98 124 // Set user agent 99 125 clientConfig.UserAgent = "Coves/1.0" 100 126 ··· 138 164 139 165 // Add additional metadata for Coves 140 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 + 141 170 if !c.Config.DevMode { 142 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 + } 143 177 } 144 178 145 179 return metadata
+47
internal/atproto/oauth/handlers.go
··· 231 231 } 232 232 } 233 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 + 234 258 // HandleLogin starts the OAuth flow (web version) 235 259 // GET /oauth/login?handle=user.bsky.social 236 260 func (h *OAuthHandler) HandleLogin(w http.ResponseWriter, r *http.Request) { ··· 471 495 slog.Error("OAuth callback returned nil session data") 472 496 http.Error(w, "OAuth callback failed: no session data", http.StatusInternalServerError) 473 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 + } 474 510 } 475 511 476 512 // Bidirectional handle verification: ensure the DID actually controls a valid handle ··· 1027 1063 http.Error(w, "Universal Link not intercepted: The mobile app should have opened this URL. "+ 1028 1064 "Check that Universal Links (iOS) or App Links (Android) are properly configured.", http.StatusBadRequest) 1029 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 248 } 249 249 return &did, nil 250 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 9 "bytes" 10 10 "context" 11 11 "database/sql" 12 + "errors" 12 13 "fmt" 13 14 "image" 14 15 "image/color" 15 16 "image/png" 17 + "net" 16 18 "net/http" 17 19 "os" 18 20 "strings" ··· 123 125 consumer := jetstream.NewCommunityEventConsumer(communityRepo, instanceDID, true, identityResolver) 124 126 125 127 t.Run("create community with avatar via real Jetstream", func(t *testing.T) { 126 - uniqueName := fmt.Sprintf("avt%d", time.Now().UnixNano()%1000000) 128 + uniqueName := fmt.Sprintf("avt%d", time.Now().UnixNano()%100000000) 127 129 creatorDID := "did:plc:avatar-create-test" 128 130 129 131 // Create a test PNG image (100x100 red square) ··· 146 148 } 147 149 defer func() { _ = conn.Close() }() 148 150 151 + consecutiveTimeouts := 0 149 152 for { 150 153 select { 151 154 case <-done: ··· 159 162 160 163 var event jetstream.JetstreamEvent 161 164 if readErr := conn.ReadJSON(&event); readErr != nil { 162 - continue // Timeout or error, keep trying 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 163 173 } 174 + consecutiveTimeouts = 0 164 175 165 176 // Only process community profile create events 166 177 if event.Kind == "commit" && event.Commit != nil && ··· 378 389 } 379 390 defer func() { _ = conn.Close() }() 380 391 392 + consecutiveTimeouts := 0 381 393 for { 382 394 select { 383 395 case <-done: ··· 391 403 392 404 var event jetstream.JetstreamEvent 393 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 + } 394 413 continue 395 414 } 415 + consecutiveTimeouts = 0 396 416 397 417 if event.Kind == "commit" && event.Commit != nil && 398 418 event.Commit.Collection == "social.coves.community.profile" && ··· 415 435 } 416 436 417 437 t.Run("add avatar to community without one", func(t *testing.T) { 418 - uniqueName := fmt.Sprintf("upav%d", time.Now().UnixNano()%1000000) 438 + uniqueName := fmt.Sprintf("upav%d", time.Now().UnixNano()%100000000) 419 439 creatorDID := "did:plc:avatar-update-test" 420 440 421 441 // Create a community WITHOUT an avatar ··· 528 548 }) 529 549 530 550 t.Run("replace existing avatar with new one", func(t *testing.T) { 531 - uniqueName := fmt.Sprintf("rpav%d", time.Now().UnixNano()%1000000) 551 + uniqueName := fmt.Sprintf("rpav%d", time.Now().UnixNano()%100000000) 532 552 creatorDID := "did:plc:avatar-replace-test" 533 553 534 554 // Create a community WITH an initial avatar (red square) ··· 747 767 } 748 768 defer func() { _ = conn.Close() }() 749 769 770 + consecutiveTimeouts := 0 750 771 for { 751 772 select { 752 773 case <-done: ··· 760 781 761 782 var event jetstream.JetstreamEvent 762 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 + } 763 791 continue 764 792 } 793 + consecutiveTimeouts = 0 765 794 766 795 if event.Kind == "commit" && event.Commit != nil && 767 796 event.Commit.Collection == "social.coves.community.profile" && ··· 784 813 } 785 814 786 815 t.Run("add banner to community without one", func(t *testing.T) { 787 - uniqueName := fmt.Sprintf("ban%d", time.Now().UnixNano()%1000000) 816 + uniqueName := fmt.Sprintf("ban%d", time.Now().UnixNano()%100000000) 788 817 creatorDID := "did:plc:banner-add-test" 789 818 790 819 // Create a community WITHOUT a banner ··· 897 926 }) 898 927 899 928 t.Run("replace existing banner with new one", func(t *testing.T) { 900 - uniqueName := fmt.Sprintf("rpban%d", time.Now().UnixNano()%1000000) 929 + uniqueName := fmt.Sprintf("rpban%d", time.Now().UnixNano()%100000000) 901 930 creatorDID := "did:plc:banner-replace-test" 902 931 903 932 // Create a community WITH an initial banner (red rectangle)
+2 -2
tests/integration/community_update_e2e_test.go
··· 106 106 107 107 t.Run("update community with real Jetstream indexing", func(t *testing.T) { 108 108 // First, create a community 109 - uniqueName := fmt.Sprintf("upd%d", time.Now().UnixNano()%1000000) 109 + uniqueName := fmt.Sprintf("upd%d", time.Now().UnixNano()%100000000) 110 110 creatorDID := "did:plc:jetstream-update-test" 111 111 112 112 t.Logf("\n📝 Creating community on PDS...") ··· 227 227 228 228 t.Run("multiple updates with real Jetstream", func(t *testing.T) { 229 229 // This tests that consecutive updates all flow through Jetstream correctly 230 - uniqueName := fmt.Sprintf("multi%d", time.Now().UnixNano()%1000000) 230 + uniqueName := fmt.Sprintf("multi%d", time.Now().UnixNano()%100000000) 231 231 creatorDID := "did:plc:multi-update-test" 232 232 233 233 t.Logf("\n📝 Creating community for multi-update test...")
+35 -6
tests/integration/user_profile_avatar_e2e_test.go
··· 11 11 "context" 12 12 "database/sql" 13 13 "encoding/json" 14 + "errors" 14 15 "fmt" 15 16 "image" 16 17 "image/color" ··· 177 178 } 178 179 defer func() { _ = conn.Close() }() 179 180 181 + consecutiveTimeouts := 0 180 182 for { 181 183 select { 182 184 case <-done: ··· 191 193 var event jetstream.JetstreamEvent 192 194 if readErr := conn.ReadJSON(&event); readErr != nil { 193 195 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 196 + if errors.As(readErr, &netErr) && netErr.Timeout() { 197 + consecutiveTimeouts++ 198 + if consecutiveTimeouts >= 10 { 199 + return // Connection stale, exit to prevent panic 200 + } 200 201 } 201 202 continue 202 203 } 204 + consecutiveTimeouts = 0 203 205 204 206 // Only process profile update events for our user 205 207 if event.Kind == "commit" && event.Commit != nil && ··· 466 468 } 467 469 defer func() { _ = conn.Close() }() 468 470 471 + consecutiveTimeouts := 0 469 472 for { 470 473 select { 471 474 case <-done: ··· 479 482 480 483 var event jetstream.JetstreamEvent 481 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 + } 482 492 continue 483 493 } 494 + consecutiveTimeouts = 0 484 495 485 496 if event.Kind == "commit" && event.Commit != nil && 486 497 event.Commit.Collection == "social.coves.actor.profile" && ··· 688 699 } 689 700 defer func() { _ = conn.Close() }() 690 701 702 + consecutiveTimeouts := 0 691 703 for { 692 704 select { 693 705 case <-done: ··· 701 713 702 714 var event jetstream.JetstreamEvent 703 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 + } 704 723 continue 705 724 } 725 + consecutiveTimeouts = 0 706 726 707 727 if event.Kind == "commit" && event.Commit != nil && 708 728 event.Commit.Collection == "social.coves.actor.profile" && ··· 870 890 } 871 891 defer func() { _ = conn.Close() }() 872 892 893 + consecutiveTimeouts := 0 873 894 for { 874 895 select { 875 896 case <-done: ··· 883 904 884 905 var event jetstream.JetstreamEvent 885 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 + } 886 914 continue 887 915 } 916 + consecutiveTimeouts = 0 888 917 889 918 if event.Kind == "commit" && event.Commit != nil && 890 919 event.Commit.Collection == "social.coves.actor.profile" &&