A community based topic aggregation platform built on atproto

feat(oauth): integrate OAuth routes and well-known endpoints

- Register OAuth handlers at /oauth/* endpoints
- Register well-known routes for mobile verification
- Add /app/oauth/callback for Universal Links

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

+195 -35
+124 -35
cmd/server/main.go
··· 3 3 import ( 4 4 "Coves/internal/api/middleware" 5 5 "Coves/internal/api/routes" 6 - "Coves/internal/atproto/auth" 7 6 "Coves/internal/atproto/identity" 8 7 "Coves/internal/atproto/jetstream" 8 + "Coves/internal/atproto/oauth" 9 9 "Coves/internal/core/aggregators" 10 10 "Coves/internal/core/blobs" 11 11 "Coves/internal/core/comments" ··· 18 18 "Coves/internal/core/users" 19 19 "bytes" 20 20 "context" 21 + "crypto/rand" 21 22 "database/sql" 23 + "encoding/base64" 22 24 "encoding/json" 23 25 "fmt" 24 26 "io" ··· 38 40 commentsAPI "Coves/internal/api/handlers/comments" 39 41 40 42 postgresRepo "Coves/internal/db/postgres" 41 - 42 - indigoIdentity "github.com/bluesky-social/indigo/atproto/identity" 43 43 ) 44 44 45 45 func main() { ··· 137 137 138 138 identityResolver := identity.NewResolver(db, identityConfig) 139 139 140 - // Initialize atProto auth middleware for JWT validation 141 - // Phase 1: Set skipVerify=true to test JWT parsing only 142 - // Phase 2: Set skipVerify=false to enable full signature verification 143 - skipVerify := os.Getenv("AUTH_SKIP_VERIFY") == "true" 144 - if skipVerify { 145 - log.Println("⚠️ WARNING: JWT signature verification is DISABLED (Phase 1 testing)") 146 - log.Println(" Set AUTH_SKIP_VERIFY=false for production") 147 - } 148 - 149 - // Initialize Indigo directory for DID resolution (used by auth) 140 + // Get PLC URL for OAuth and other services 150 141 plcURL := os.Getenv("PLC_DIRECTORY_URL") 151 142 if plcURL == "" { 152 143 plcURL = "https://plc.directory" 153 144 } 154 - indigoDir := &indigoIdentity.BaseDirectory{ 155 - PLCURL: plcURL, 156 - HTTPClient: http.Client{Timeout: 10 * time.Second}, 145 + 146 + // Initialize OAuth client for sealed session tokens 147 + // Mobile apps authenticate via OAuth flow and receive sealed session tokens 148 + // These tokens are encrypted references to OAuth sessions stored in the database 149 + oauthSealSecret := os.Getenv("OAUTH_SEAL_SECRET") 150 + if oauthSealSecret == "" { 151 + if os.Getenv("IS_DEV_ENV") != "true" { 152 + log.Fatal("OAUTH_SEAL_SECRET is required in production mode") 153 + } 154 + // Generate RANDOM secret for dev mode 155 + randomBytes := make([]byte, 32) 156 + if _, err := rand.Read(randomBytes); err != nil { 157 + log.Fatal("Failed to generate random seal secret: ", err) 158 + } 159 + oauthSealSecret = base64.StdEncoding.EncodeToString(randomBytes) 160 + log.Println("⚠️ DEV MODE: Generated random OAuth seal secret (won't persist across restarts)") 157 161 } 158 162 159 - // Initialize JWT config early to cache HS256_ISSUERS and PDS_JWT_SECRET 160 - // This avoids reading env vars on every request 161 - auth.InitJWTConfig() 163 + isDevMode := os.Getenv("IS_DEV_ENV") == "true" 164 + oauthConfig := &oauth.OAuthConfig{ 165 + PublicURL: os.Getenv("APPVIEW_PUBLIC_URL"), 166 + SealSecret: oauthSealSecret, 167 + Scopes: []string{"atproto", "transition:generic"}, 168 + DevMode: isDevMode, 169 + AllowPrivateIPs: isDevMode, // Allow private IPs only in dev mode 170 + PLCURL: plcURL, 171 + // SessionTTL and SealedTokenTTL will use defaults if not set (7 days and 14 days) 172 + } 162 173 163 - // Create combined key fetcher for both DID and URL issuers 164 - // - DID issuers (did:plc:, did:web:) → resolved via DID document keys (ES256) 165 - // - URL issuers → JWKS endpoint (fallback for legacy tokens) 166 - jwksCacheTTL := 1 * time.Hour 167 - jwksFetcher := auth.NewCachedJWKSFetcher(jwksCacheTTL) 168 - keyFetcher := auth.NewCombinedKeyFetcher(indigoDir, jwksFetcher) 174 + // Create PostgreSQL-backed OAuth session store (using default 7-day TTL) 175 + baseOAuthStore := oauth.NewPostgresOAuthStore(db, 0) 176 + // Wrap with MobileAwareStoreWrapper to capture OAuth state for mobile CSRF validation. 177 + // This intercepts SaveAuthRequestInfo to save mobile CSRF data when present in context. 178 + oauthStore := oauth.NewMobileAwareStoreWrapper(baseOAuthStore) 179 + 180 + if oauthConfig.PublicURL == "" { 181 + oauthConfig.PublicURL = "http://localhost:8080" 182 + oauthConfig.DevMode = true // Force dev mode for localhost 183 + } 184 + 185 + // Optional: confidential client secret for production 186 + oauthConfig.ClientSecret = os.Getenv("OAUTH_CLIENT_SECRET") 187 + oauthConfig.ClientKID = os.Getenv("OAUTH_CLIENT_KID") 169 188 170 - authMiddleware := middleware.NewAtProtoAuthMiddleware(keyFetcher, skipVerify) 171 - log.Println("✅ atProto auth middleware initialized (DID + JWKS key resolution)") 189 + oauthClient, err := oauth.NewOAuthClient(oauthConfig, oauthStore) 190 + if err != nil { 191 + log.Fatalf("Failed to initialize OAuth client: %v", err) 192 + } 193 + 194 + // Create OAuth handler for HTTP endpoints 195 + oauthHandler := oauth.NewOAuthHandler(oauthClient, oauthStore) 196 + 197 + // Create OAuth auth middleware 198 + // Validates sealed session tokens and loads OAuth sessions from database 199 + authMiddleware := middleware.NewOAuthAuthMiddleware(oauthClient, oauthStore) 200 + log.Println("✅ OAuth auth middleware initialized (sealed session tokens)") 172 201 173 202 // Initialize repositories and services 174 203 userRepo := postgresRepo.NewUserRepository(db) ··· 303 332 log.Println(" - Indexing: social.coves.community.profile (community profiles)") 304 333 log.Println(" - Indexing: social.coves.community.subscription (user subscriptions)") 305 334 306 - // Start JWKS cache cleanup background job 335 + // Start OAuth session cleanup background job with cancellable context 336 + cleanupCtx, cleanupCancel := context.WithCancel(context.Background()) 307 337 go func() { 308 338 ticker := time.NewTicker(1 * time.Hour) 309 339 defer ticker.Stop() 310 - for range ticker.C { 311 - jwksFetcher.CleanupExpiredCache() 312 - log.Println("JWKS cache cleanup completed") 340 + for { 341 + select { 342 + case <-cleanupCtx.Done(): 343 + log.Println("OAuth cleanup job stopped") 344 + return 345 + case <-ticker.C: 346 + // Check if store implements cleanup methods 347 + // Use UnwrapPostgresStore to get the underlying store from the wrapper 348 + if cleanupStore := oauthStore.UnwrapPostgresStore(); cleanupStore != nil { 349 + sessions, sessErr := cleanupStore.CleanupExpiredSessions(cleanupCtx) 350 + if sessErr != nil { 351 + log.Printf("Error cleaning up expired OAuth sessions: %v", sessErr) 352 + } 353 + requests, reqErr := cleanupStore.CleanupExpiredAuthRequests(cleanupCtx) 354 + if reqErr != nil { 355 + log.Printf("Error cleaning up expired OAuth auth requests: %v", reqErr) 356 + } 357 + if sessions > 0 || requests > 0 { 358 + log.Printf("OAuth cleanup: removed %d expired sessions, %d expired auth requests", sessions, requests) 359 + } 360 + } 361 + } 313 362 } 314 363 }() 315 364 316 - log.Println("Started JWKS cache cleanup background job (runs hourly)") 365 + log.Println("Started OAuth session cleanup background job (runs hourly)") 317 366 318 367 // Initialize aggregator service 319 368 aggregatorRepo := postgresRepo.NewAggregatorRepository(db) ··· 494 543 log.Println("✅ Comment query API registered (20 req/min rate limit)") 495 544 log.Println(" - GET /xrpc/social.coves.community.comment.getComments") 496 545 546 + // Configure allowed CORS origins for OAuth callback 547 + // SECURITY: Never use wildcard "*" with credentials - only allow specific origins 548 + var oauthAllowedOrigins []string 549 + appviewPublicURL := os.Getenv("APPVIEW_PUBLIC_URL") 550 + if appviewPublicURL == "" { 551 + appviewPublicURL = "http://localhost:8080" 552 + } 553 + oauthAllowedOrigins = append(oauthAllowedOrigins, appviewPublicURL) 554 + 555 + // In dev mode, also allow common localhost origins for testing 556 + if oauthConfig.DevMode { 557 + oauthAllowedOrigins = append(oauthAllowedOrigins, 558 + "http://localhost:3000", 559 + "http://localhost:3001", 560 + "http://localhost:5173", 561 + "http://127.0.0.1:8080", 562 + "http://127.0.0.1:3000", 563 + "http://127.0.0.1:3001", 564 + "http://127.0.0.1:5173", 565 + ) 566 + log.Printf("🧪 DEV MODE: OAuth CORS allows localhost origins for testing") 567 + } 568 + log.Printf("OAuth CORS allowed origins: %v", oauthAllowedOrigins) 569 + 570 + // Register OAuth routes for authentication flow 571 + routes.RegisterOAuthRoutes(r, oauthHandler, oauthAllowedOrigins) 572 + log.Println("✅ OAuth endpoints registered") 573 + log.Println(" - GET /oauth/client-metadata.json") 574 + log.Println(" - GET /oauth/jwks.json") 575 + log.Println(" - GET /oauth/login") 576 + log.Println(" - GET /oauth/mobile/login") 577 + log.Println(" - GET /oauth/callback") 578 + log.Println(" - POST /oauth/logout") 579 + log.Println(" - POST /oauth/refresh") 580 + 581 + // Register well-known routes for mobile app deep linking 582 + routes.RegisterWellKnownRoutes(r) 583 + log.Println("✅ Well-known endpoints registered (mobile Universal Links & App Links)") 584 + log.Println(" - GET /.well-known/apple-app-site-association (iOS Universal Links)") 585 + log.Println(" - GET /.well-known/assetlinks.json (Android App Links)") 586 + 497 587 // Health check endpoints 498 588 healthHandler := func(w http.ResponseWriter, r *http.Request) { 499 589 w.WriteHeader(http.StatusOK) ··· 540 630 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 541 631 defer cancel() 542 632 543 - // Stop auth middleware background goroutines (DPoP replay cache cleanup) 544 - authMiddleware.Stop() 545 - log.Println("Auth middleware stopped") 633 + // Stop OAuth cleanup background job 634 + cleanupCancel() 546 635 547 636 if err := server.Shutdown(ctx); err != nil { 548 637 log.Fatalf("Server shutdown error: %v", err)
+71
internal/api/routes/oauth.go
··· 1 + package routes 2 + 3 + import ( 4 + "Coves/internal/api/middleware" 5 + "Coves/internal/atproto/oauth" 6 + "net/http" 7 + "time" 8 + 9 + "github.com/go-chi/chi/v5" 10 + "github.com/go-chi/cors" 11 + ) 12 + 13 + // RegisterOAuthRoutes registers OAuth-related endpoints on the router with dedicated rate limiting 14 + // OAuth endpoints have stricter rate limits to prevent: 15 + // - Credential stuffing attacks on login endpoints 16 + // - OAuth state exhaustion 17 + // - Refresh token abuse 18 + func RegisterOAuthRoutes(r chi.Router, handler *oauth.OAuthHandler, allowedOrigins []string) { 19 + // Create stricter rate limiters for OAuth endpoints 20 + // Login endpoints: 10 req/min per IP (credential stuffing protection) 21 + loginLimiter := middleware.NewRateLimiter(10, 1*time.Minute) 22 + 23 + // Refresh endpoint: 20 req/min per IP (slightly higher for legitimate token refresh) 24 + refreshLimiter := middleware.NewRateLimiter(20, 1*time.Minute) 25 + 26 + // Logout endpoint: 10 req/min per IP 27 + logoutLimiter := middleware.NewRateLimiter(10, 1*time.Minute) 28 + 29 + // OAuth metadata endpoints - public, no extra rate limiting (use global limit) 30 + r.Get("/oauth/client-metadata.json", handler.HandleClientMetadata) 31 + r.Get("/oauth/jwks.json", handler.HandleJWKS) 32 + 33 + // Alternative well-known paths for OAuth metadata 34 + r.Get("/.well-known/oauth-jwks.json", handler.HandleJWKS) 35 + r.Get("/.well-known/oauth-protected-resource", handler.HandleProtectedResourceMetadata) 36 + 37 + // OAuth flow endpoints - stricter rate limiting for authentication attempts 38 + r.With(loginLimiter.Middleware).Get("/oauth/login", handler.HandleLogin) 39 + r.With(loginLimiter.Middleware).Get("/oauth/mobile/login", handler.HandleMobileLogin) 40 + 41 + // OAuth callback - needs CORS for potential cross-origin redirects from PDS 42 + // Use login limiter since callback completes the authentication flow 43 + r.With(corsMiddleware(allowedOrigins), loginLimiter.Middleware).Get("/oauth/callback", handler.HandleCallback) 44 + 45 + // Mobile Universal Link callback route 46 + // This route is used for iOS Universal Links and Android App Links 47 + // Path must match the path in .well-known/apple-app-site-association 48 + // Uses the same handler as web callback - the system routes it to the mobile app 49 + r.With(loginLimiter.Middleware).Get("/app/oauth/callback", handler.HandleCallback) 50 + 51 + // Session management - dedicated rate limits 52 + r.With(logoutLimiter.Middleware).Post("/oauth/logout", handler.HandleLogout) 53 + r.With(refreshLimiter.Middleware).Post("/oauth/refresh", handler.HandleRefresh) 54 + } 55 + 56 + // corsMiddleware creates a CORS middleware for OAuth callback with specific allowed origins 57 + func corsMiddleware(allowedOrigins []string) func(next http.Handler) http.Handler { 58 + return cors.Handler(cors.Options{ 59 + AllowedOrigins: allowedOrigins, // Only allow specific origins for OAuth callback 60 + AllowedMethods: []string{"GET", "POST", "OPTIONS"}, 61 + AllowedHeaders: []string{ 62 + "Accept", 63 + "Authorization", 64 + "Content-Type", 65 + "X-CSRF-Token", 66 + }, 67 + ExposedHeaders: []string{"Link"}, 68 + AllowCredentials: true, 69 + MaxAge: 300, // 5 minutes 70 + }) 71 + }