A community based topic aggregation platform built on atproto

feat(oauth): add web frontend dev proxy and generalize redirect URI handling

Refactors the mobile-only OAuth redirect URI system into a configurable
allowlist that supports both mobile apps (custom schemes + Universal Links)
and web clients (HTTPS redirects). Adds Caddy-based reverse proxy for web
frontend development, enabling same-origin cookie sharing between Vite
frontend and Coves backend.

Changes:
- Refactor isAllowedMobileRedirectURI() into OAuthHandler.isAllowedRedirectURI()
with BuildAllowedRedirectURIs() builder for the configurable allowlist
- Add smart redirect: HTTPS clients get direct HTTP redirect, custom scheme
clients get the intermediate redirect page
- Add Caddyfile.dev reverse proxy config (Vite :5173 + Coves :8081 on :8080)
- Add scripts/web-dev-run.sh for combined backend+frontend dev startup
- Add Makefile targets: run-web, web-proxy, web-proxy-bg, web-proxy-stop
- Auto-run db-migrate before server start in make run and make run-web
- Update OAuth client comments to clarify ATProto loopback client_id spec
- Add PAR request debug logging in dev auth resolver
- Update all security tests to use OAuthHandler instance methods

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

+415 -192
+41
Caddyfile.dev
··· 1 + # Coves Web Development Reverse Proxy 2 + # Combines Vite frontend (5173) and Coves backend (8081) on single origin (8080) 3 + # This enables OAuth cookies to work correctly across frontend/backend 4 + # 5 + # Usage: 6 + # make web-dev # Starts proxy (also starts dev stack if needed) 7 + # Or manually: caddy run --config Caddyfile.dev 8 + # 9 + # Access at: http://localhost:8080 10 + 11 + :8080 { 12 + # OAuth routes -> Coves backend 13 + handle /oauth/* { 14 + reverse_proxy 127.0.0.1:8081 15 + } 16 + 17 + # XRPC API routes -> Coves backend 18 + handle /xrpc/* { 19 + reverse_proxy 127.0.0.1:8081 20 + } 21 + 22 + # OAuth client metadata -> Coves backend 23 + handle /oauth-client-metadata.json { 24 + reverse_proxy 127.0.0.1:8081 25 + } 26 + 27 + # Image proxy routes -> Coves backend 28 + handle /img/* { 29 + reverse_proxy 127.0.0.1:8081 30 + } 31 + 32 + # API routes (if any) -> Coves backend 33 + handle /api/* { 34 + reverse_proxy 127.0.0.1:8081 35 + } 36 + 37 + # Everything else -> Vite dev server (frontend) 38 + handle { 39 + reverse_proxy localhost:5173 40 + } 41 + }
+37
Makefile
··· 265 265 @echo "$(GREEN)✓ Build complete: ./server (with dev tags)$(RESET)" 266 266 267 267 run: ## Run the Coves server with dev environment (requires database running) 268 + @make db-migrate 268 269 @./scripts/dev-run.sh 269 270 270 271 ##@ Cleanup ··· 332 333 333 334 ngrok-down: ## Stop all ngrok tunnels 334 335 @./scripts/stop-ngrok.sh 336 + 337 + ##@ Web Frontend Development 338 + 339 + run-web: ## Run Coves backend configured for web frontend dev (OAuth via :8080 proxy) 340 + @make db-migrate 341 + @./scripts/web-dev-run.sh 342 + 343 + web-proxy: ## Start Caddy reverse proxy for web frontend dev (combines Vite + Coves on :8080) 344 + @echo "$(CYAN)Starting web development proxy...$(RESET)" 345 + @echo "" 346 + @echo "$(YELLOW)Prerequisites:$(RESET)" 347 + @echo " 1. Coves backend running on :8081 (make run)" 348 + @echo " 2. Vite frontend running on :5173 (cd frontend && npm run dev)" 349 + @echo "" 350 + @command -v caddy >/dev/null 2>&1 || { echo "$(RED)Error: Caddy not installed. Install with:$(RESET)"; \ 351 + echo " Ubuntu/Debian: sudo apt install caddy"; \ 352 + echo " macOS: brew install caddy"; \ 353 + echo " Or see: https://caddyserver.com/docs/install"; \ 354 + exit 1; } 355 + @echo "$(GREEN)Starting Caddy on http://localhost:8080$(RESET)" 356 + @echo " Backend routes (/oauth/*, /xrpc/*, /api/*) -> 127.0.0.1:8081" 357 + @echo " Frontend routes (everything else) -> localhost:5173" 358 + @echo "" 359 + @echo "$(CYAN)Access your app at: http://localhost:8080$(RESET)" 360 + @echo "$(CYAN)Press Ctrl+C to stop$(RESET)" 361 + @echo "" 362 + @caddy run --config Caddyfile.dev 363 + 364 + web-proxy-bg: ## Start Caddy proxy in background 365 + @command -v caddy >/dev/null 2>&1 || { echo "$(RED)Error: Caddy not installed$(RESET)"; exit 1; } 366 + @caddy start --config Caddyfile.dev 367 + @echo "$(GREEN)✓ Caddy proxy started in background on http://localhost:8080$(RESET)" 368 + 369 + web-proxy-stop: ## Stop background Caddy proxy 370 + @caddy stop 2>/dev/null || echo "$(YELLOW)Caddy not running$(RESET)" 371 + @echo "$(GREEN)✓ Caddy proxy stopped$(RESET)" 335 372 336 373 ##@ Utilities 337 374
+1
cmd/server/main.go
··· 202 202 203 203 isDevMode := os.Getenv("IS_DEV_ENV") == "true" 204 204 pdsURL := os.Getenv("PDS_URL") // For dev mode: resolve handles via local PDS 205 + 205 206 oauthConfig := &oauth.OAuthConfig{ 206 207 PublicURL: os.Getenv("APPVIEW_PUBLIC_URL"), 207 208 SealSecret: oauthSealSecret,
+4 -3
internal/atproto/oauth/client.go
··· 86 86 // Create indigo client config 87 87 var clientConfig oauth.ClientConfig 88 88 if config.DevMode { 89 - // Dev mode: loopback with HTTP 90 - // IMPORTANT: Use 127.0.0.1 instead of localhost per RFC 8252 - PDS rejects localhost 91 - // The callback URL must match the APPVIEW_PUBLIC_URL from .env.dev 89 + // Dev mode: loopback OAuth client 90 + // Per ATProto OAuth spec: client_id base MUST be "http://localhost" (not 127.0.0.1) 91 + // The redirect_uri in the query params CAN use 127.0.0.1 with port 92 + // Format: http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%3A8081%2Foauth%2Fcallback&scope=atproto 92 93 callbackURL := config.PublicURL + "/oauth/callback" 93 94 clientConfig = oauth.NewLocalhostConfig(callbackURL, config.Scopes) 94 95 slog.Info("dev mode: OAuth client configured",
+10 -1
internal/atproto/oauth/dev_auth_resolver.go
··· 257 257 slog.Debug("dev mode: got auth server metadata", 258 258 "issuer", authMeta.Issuer, 259 259 "authorization_endpoint", authMeta.AuthorizationEndpoint, 260 - "token_endpoint", authMeta.TokenEndpoint) 260 + "token_endpoint", authMeta.TokenEndpoint, 261 + "par_endpoint", authMeta.PushedAuthorizationRequestEndpoint) 262 + 263 + slog.Debug("dev mode: PAR request details", 264 + "client_id", client.ClientApp.Config.ClientID, 265 + "callback_url", client.ClientApp.Config.CallbackURL, 266 + "scopes", client.Config.Scopes) 261 267 262 268 // Send auth request (PAR) using indigo's method 263 269 info, err := client.ClientApp.SendAuthRequest(ctx, authMeta, client.Config.Scopes, identifier) 264 270 if err != nil { 271 + slog.Error("dev mode: PAR request failed", 272 + "error", err, 273 + "client_id", client.ClientApp.Config.ClientID) 265 274 return "", fmt.Errorf("auth request failed: %w", err) 266 275 } 267 276
+55 -28
internal/atproto/oauth/handlers.go
··· 156 156 157 157 // OAuthHandler handles OAuth-related HTTP endpoints 158 158 type OAuthHandler struct { 159 - client *OAuthClient 160 - store oauth.ClientAuthStore 161 - mobileStore MobileOAuthStore // For server-side CSRF validation 162 - userIndexer UserIndexer // For indexing users after OAuth login 163 - devResolver *DevHandleResolver // For dev mode: resolve handles via local PDS 164 - devAuthResolver *DevAuthResolver // For dev mode: bypass HTTPS validation for localhost OAuth 159 + client *OAuthClient 160 + store oauth.ClientAuthStore 161 + mobileStore MobileOAuthStore // For server-side CSRF validation 162 + userIndexer UserIndexer // For indexing users after OAuth login 163 + devResolver *DevHandleResolver // For dev mode: resolve handles via local PDS 164 + devAuthResolver *DevAuthResolver // For dev mode: bypass HTTPS validation for localhost OAuth 165 + allowedRedirectURIs map[string]bool // Combined allowlist for mobile + external OAuth clients 165 166 } 166 167 167 168 // OAuthHandlerOption is a functional option for configuring OAuthHandler ··· 178 179 // NewOAuthHandler creates a new OAuth handler 179 180 func NewOAuthHandler(client *OAuthClient, store oauth.ClientAuthStore, opts ...OAuthHandlerOption) *OAuthHandler { 180 181 handler := &OAuthHandler{ 181 - client: client, 182 - store: store, 182 + client: client, 183 + store: store, 184 + allowedRedirectURIs: BuildAllowedRedirectURIs(), 183 185 } 184 186 185 187 // Apply functional options ··· 207 209 } 208 210 209 211 return handler 212 + } 213 + 214 + // isAllowedRedirectURI checks if a redirect URI is in the configured allowlist. 215 + // This includes both the base mobile redirect URIs and any configured external client URIs. 216 + // 217 + // SECURITY: Uses exact string matching - no wildcards or pattern matching. 218 + // The URI must match exactly as configured in the allowlist. 219 + func (h *OAuthHandler) isAllowedRedirectURI(redirectURI string) bool { 220 + return h.allowedRedirectURIs[redirectURI] 210 221 } 211 222 212 223 // HandleClientMetadata serves the OAuth client metadata document ··· 354 365 } 355 366 356 367 // SECURITY FIX 1: Validate redirect_uri against allowlist 357 - if !isAllowedMobileRedirectURI(mobileRedirectURI) { 358 - slog.Warn("rejected unauthorized mobile redirect URI", "scheme", extractScheme(mobileRedirectURI)) 359 - http.Error(w, "invalid redirect_uri: scheme not allowed", http.StatusBadRequest) 368 + // Uses configurable allowlist that includes both mobile deep links and external client URIs 369 + if !h.isAllowedRedirectURI(mobileRedirectURI) { 370 + slog.Warn("rejected unauthorized redirect URI", "scheme", extractScheme(mobileRedirectURI)) 371 + http.Error(w, "invalid redirect_uri: not in allowlist", http.StatusBadRequest) 360 372 return 361 373 } 362 374 ··· 769 781 http.Redirect(w, r, redirectURL, http.StatusFound) 770 782 } 771 783 772 - // handleMobileCallback handles the mobile OAuth callback flow 784 + // handleMobileCallback handles the mobile OAuth callback flow. 785 + // This handles both mobile deep links (custom schemes like social.coves://) and 786 + // Universal Links (https:// URLs verified via .well-known). 773 787 func (h *OAuthHandler) handleMobileCallback(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData, mobileRedirectURIEncoded, csrfToken, verifiedHandle string) { 774 - // Decode the mobile redirect URI 775 - mobileRedirectURI, err := url.QueryUnescape(mobileRedirectURIEncoded) 788 + // Decode the redirect URI 789 + redirectURI, err := url.QueryUnescape(mobileRedirectURIEncoded) 776 790 if err != nil { 777 - slog.Error("failed to decode mobile redirect URI", "error", err) 778 - http.Error(w, "invalid mobile redirect URI", http.StatusBadRequest) 791 + slog.Error("failed to decode redirect URI", "error", err) 792 + http.Error(w, "invalid redirect URI", http.StatusBadRequest) 779 793 return 780 794 } 781 795 782 796 // SECURITY FIX 1: Re-validate redirect URI against allowlist 783 - if !isAllowedMobileRedirectURI(mobileRedirectURI) { 784 - slog.Error("mobile callback attempted with unauthorized redirect URI", "scheme", extractScheme(mobileRedirectURI)) 797 + // Uses configurable allowlist that includes both mobile deep links and external client URIs 798 + if !h.isAllowedRedirectURI(redirectURI) { 799 + slog.Error("callback attempted with unauthorized redirect URI", "scheme", extractScheme(redirectURI)) 785 800 http.Error(w, "invalid redirect URI", http.StatusBadRequest) 786 801 return 787 802 } 788 803 789 - // Seal the session data for mobile 804 + // Seal the session data 790 805 sealedToken, err := h.client.SealSession( 791 806 sessData.AccountDID.String(), 792 807 sessData.SessionID, ··· 807 822 } 808 823 } 809 824 810 - // Clear all mobile cookies to prevent reuse (defense in depth) 825 + // Clear all mobile/external cookies to prevent reuse (defense in depth) 811 826 clearMobileCookies(w) 812 827 813 - // Build deep link with sealed token 814 - deepLink := fmt.Sprintf("%s?token=%s&did=%s&session_id=%s", 815 - mobileRedirectURI, 828 + // Build redirect URL with sealed token 829 + callbackURL := fmt.Sprintf("%s?token=%s&did=%s&session_id=%s", 830 + redirectURI, 816 831 url.QueryEscape(sealedToken), 817 832 url.QueryEscape(sessData.AccountDID.String()), 818 833 url.QueryEscape(sessData.SessionID), 819 834 ) 820 835 if handle != "" { 821 - deepLink += "&handle=" + url.QueryEscape(handle) 836 + callbackURL += "&handle=" + url.QueryEscape(handle) 822 837 } 823 838 824 - // Log mobile redirect (sanitized - no token or session ID to avoid leaking credentials) 825 - slog.Info("redirecting to mobile app", "did", sessData.AccountDID, "handle", handle) 839 + // Determine redirect type based on scheme 840 + parsedURI, parseErr := url.Parse(redirectURI) 841 + isWebClient := parseErr == nil && (parsedURI.Scheme == "http" || parsedURI.Scheme == "https") 826 842 843 + if isWebClient { 844 + // HTTPS Universal Links or web clients get a direct HTTP redirect. 845 + // The OS intercepts Universal Links and opens the app; no intermediate page needed. 846 + slog.Info("redirecting via HTTP", "did", sessData.AccountDID, "handle", handle, "host", parsedURI.Host) 847 + http.Redirect(w, r, callbackURL, http.StatusFound) 848 + return 849 + } 850 + 851 + // Mobile app with custom scheme (e.g., social.coves://) 827 852 // Serve intermediate page that redirects to the app 828 853 // This prevents the browser from showing a stale PDS page after the custom scheme redirect 854 + slog.Info("redirecting to mobile app", "did", sessData.AccountDID, "handle", handle) 855 + 829 856 w.Header().Set("Content-Type", "text/html; charset=utf-8") 830 857 w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate") 831 858 ··· 833 860 DeepLink string 834 861 Handle string 835 862 }{ 836 - DeepLink: deepLink, 863 + DeepLink: callbackURL, 837 864 Handle: handle, 838 865 } 839 866 840 867 if err := mobileCallbackTemplate.Execute(w, data); err != nil { 841 868 slog.Error("failed to render mobile callback template", "error", err) 842 869 // Fallback to direct redirect if template fails 843 - http.Redirect(w, r, deepLink, http.StatusFound) 870 + http.Redirect(w, r, callbackURL, http.StatusFound) 844 871 } 845 872 } 846 873
+14 -13
internal/atproto/oauth/handlers_security.go
··· 9 9 "net/url" 10 10 ) 11 11 12 - // allowedMobileRedirectURIs contains the EXACT allowed redirect URIs for mobile apps. 12 + // baseMobileRedirectURIs contains the EXACT allowed redirect URIs for mobile apps. 13 + // These are always allowed regardless of configuration. 13 14 // 14 15 // Per atproto OAuth spec (https://atproto.com/specs/oauth#mobile-clients): 15 16 // - Custom URL schemes are allowed for native mobile apps ··· 23 24 // Universal Links provide stronger security guarantees but require: 24 25 // - iOS: Verified via /.well-known/apple-app-site-association 25 26 // - Android: Verified via /.well-known/assetlinks.json 26 - var allowedMobileRedirectURIs = map[string]bool{ 27 + var baseMobileRedirectURIs = map[string]bool{ 27 28 // Custom scheme per atproto spec (reverse-domain of coves.social) 28 29 "social.coves:/callback": true, 29 30 "social.coves://callback": true, // Some platforms add double slash ··· 33 34 "https://coves.social/app/oauth/callback": true, 34 35 } 35 36 36 - // isAllowedMobileRedirectURI validates that the redirect URI is in the exact allowlist. 37 - // SECURITY: Exact URI matching prevents token theft by rogue apps. 37 + // BuildAllowedRedirectURIs returns a copy of the allowed mobile redirect URIs. 38 + // The allowlist uses exact URI matching to prevent token theft. 38 39 // 39 - // Per atproto OAuth spec, custom schemes must match the client_id hostname 40 - // in reverse-domain order (social.coves for coves.social), which provides 41 - // some protection as malicious apps would need to know the specific scheme. 42 - // 43 - // Universal Links (https://) provide stronger security as they're cryptographically 44 - // bound to the app via .well-known verification files. 45 - func isAllowedMobileRedirectURI(redirectURI string) bool { 46 - // Normalize and check exact match 47 - return allowedMobileRedirectURIs[redirectURI] 40 + // For web frontends like Kelp, use a reverse proxy (Vite proxy in dev, nginx in prod) 41 + // to serve from the same origin as Coves, allowing HTTP-only cookies to work. 42 + func BuildAllowedRedirectURIs() map[string]bool { 43 + // Return a copy of base mobile URIs 44 + allowed := make(map[string]bool, len(baseMobileRedirectURIs)) 45 + for uri := range baseMobileRedirectURIs { 46 + allowed[uri] = true 47 + } 48 + return allowed 48 49 } 49 50 50 51 // extractScheme extracts the scheme from a URI for logging purposes
+173 -146
internal/atproto/oauth/handlers_security_test.go
··· 5 5 "net/http/httptest" 6 6 "testing" 7 7 8 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 8 9 "github.com/stretchr/testify/assert" 9 10 "github.com/stretchr/testify/require" 10 11 ) 11 12 12 - // TestIsAllowedMobileRedirectURI tests the mobile redirect URI allowlist with EXACT URI matching 13 - // Only Universal Links (HTTPS) are allowed - custom schemes are blocked for security 14 - func TestIsAllowedMobileRedirectURI(t *testing.T) { 15 - tests := []struct { 16 - name string 17 - uri string 18 - expected bool 19 - }{ 20 - { 21 - name: "allowed - Universal Link", 22 - uri: "https://coves.social/app/oauth/callback", 23 - expected: true, 24 - }, 25 - { 26 - name: "rejected - custom scheme coves-app (vulnerable to interception)", 27 - uri: "coves-app://oauth/callback", 28 - expected: false, 29 - }, 30 - { 31 - name: "rejected - custom scheme coves (vulnerable to interception)", 32 - uri: "coves://oauth/callback", 33 - expected: false, 34 - }, 35 - { 36 - name: "rejected - evil scheme", 37 - uri: "evil://callback", 38 - expected: false, 39 - }, 40 - { 41 - name: "rejected - http (not secure)", 42 - uri: "http://example.com/callback", 43 - expected: false, 44 - }, 45 - { 46 - name: "rejected - https different domain", 47 - uri: "https://example.com/callback", 48 - expected: false, 49 - }, 50 - { 51 - name: "rejected - https coves.social wrong path", 52 - uri: "https://coves.social/wrong/path", 53 - expected: false, 54 - }, 55 - { 56 - name: "rejected - invalid URI", 57 - uri: "not a uri", 58 - expected: false, 59 - }, 60 - { 61 - name: "rejected - empty string", 62 - uri: "", 63 - expected: false, 64 - }, 65 - } 66 - 67 - for _, tt := range tests { 68 - t.Run(tt.name, func(t *testing.T) { 69 - result := isAllowedMobileRedirectURI(tt.uri) 70 - assert.Equal(t, tt.expected, result, 71 - "isAllowedMobileRedirectURI(%q) = %v, want %v", tt.uri, result, tt.expected) 72 - }) 73 - } 74 - } 75 - 76 13 // TestExtractScheme tests the scheme extraction function 77 14 func TestExtractScheme(t *testing.T) { 78 15 tests := []struct { ··· 122 59 assert.Greater(t, len(token1), 40, "CSRF token should be reasonably long (32 bytes base64 encoded)") 123 60 } 124 61 125 - // TestHandleMobileLogin_RedirectURIValidation tests that HandleMobileLogin validates redirect URIs 126 - func TestHandleMobileLogin_RedirectURIValidation(t *testing.T) { 127 - // Note: This is a unit test for the validation logic only. 128 - // Full integration tests with OAuth flow are in tests/integration/oauth_e2e_test.go 129 - 130 - tests := []struct { 131 - name string 132 - redirectURI string 133 - expectedLog string 134 - expectedStatus int 135 - }{ 136 - { 137 - name: "allowed - Universal Link", 138 - redirectURI: "https://coves.social/app/oauth/callback", 139 - expectedStatus: http.StatusBadRequest, // Will fail at StartAuthFlow (no OAuth client setup) 140 - }, 141 - { 142 - name: "rejected - custom scheme coves-app (insecure)", 143 - redirectURI: "coves-app://oauth/callback", 144 - expectedStatus: http.StatusBadRequest, 145 - expectedLog: "rejected unauthorized mobile redirect URI", 146 - }, 147 - { 148 - name: "rejected evil scheme", 149 - redirectURI: "evil://callback", 150 - expectedStatus: http.StatusBadRequest, 151 - expectedLog: "rejected unauthorized mobile redirect URI", 152 - }, 153 - { 154 - name: "rejected http", 155 - redirectURI: "http://evil.com/callback", 156 - expectedStatus: http.StatusBadRequest, 157 - expectedLog: "scheme not allowed", 158 - }, 159 - } 160 - 161 - for _, tt := range tests { 162 - t.Run(tt.name, func(t *testing.T) { 163 - // Test the validation function directly 164 - result := isAllowedMobileRedirectURI(tt.redirectURI) 165 - if tt.expectedLog != "" { 166 - assert.False(t, result, "Should reject %s", tt.redirectURI) 167 - } 168 - }) 169 - } 170 - } 171 - 172 62 // TestHandleCallback_CSRFValidation tests that HandleCallback validates CSRF tokens for mobile flow 173 63 func TestHandleCallback_CSRFValidation(t *testing.T) { 174 64 // This is a conceptual test structure. Full implementation would require: ··· 208 98 209 99 assert.NotNil(t, req) // Placeholder assertion 210 100 }) 211 - } 212 - 213 - // TestHandleMobileCallback_RevalidatesRedirectURI tests that handleMobileCallback re-validates the redirect URI 214 - func TestHandleMobileCallback_RevalidatesRedirectURI(t *testing.T) { 215 - // This is a critical security test: even if an attacker somehow bypasses the initial check, 216 - // the callback handler should re-validate the redirect URI before redirecting. 217 - 218 - tests := []struct { 219 - name string 220 - redirectURI string 221 - shouldPass bool 222 - }{ 223 - { 224 - name: "allowed - Universal Link", 225 - redirectURI: "https://coves.social/app/oauth/callback", 226 - shouldPass: true, 227 - }, 228 - { 229 - name: "blocked - custom scheme (insecure)", 230 - redirectURI: "coves-app://oauth/callback", 231 - shouldPass: false, 232 - }, 233 - { 234 - name: "blocked - evil scheme", 235 - redirectURI: "evil://callback", 236 - shouldPass: false, 237 - }, 238 - } 239 - 240 - for _, tt := range tests { 241 - t.Run(tt.name, func(t *testing.T) { 242 - result := isAllowedMobileRedirectURI(tt.redirectURI) 243 - assert.Equal(t, tt.shouldPass, result) 244 - }) 245 - } 246 101 } 247 102 248 103 // TestGenerateMobileRedirectBinding tests the binding token generation ··· 475 330 }) 476 331 } 477 332 } 333 + 334 + // TestBuildAllowedRedirectURIs tests the mobile redirect URI allowlist builder 335 + func TestBuildAllowedRedirectURIs(t *testing.T) { 336 + t.Run("includes all base mobile URIs", func(t *testing.T) { 337 + allowed := BuildAllowedRedirectURIs() 338 + 339 + // All base mobile URIs should be included 340 + assert.True(t, allowed["social.coves:/callback"], "should include social.coves:/callback") 341 + assert.True(t, allowed["social.coves://callback"], "should include social.coves://callback") 342 + assert.True(t, allowed["social.coves:/oauth/callback"], "should include social.coves:/oauth/callback") 343 + assert.True(t, allowed["social.coves://oauth/callback"], "should include social.coves://oauth/callback") 344 + assert.True(t, allowed["https://coves.social/app/oauth/callback"], "should include Universal Link") 345 + 346 + // Should have exactly 5 base mobile URIs 347 + assert.Len(t, allowed, 5, "should have exactly 5 base mobile URIs") 348 + }) 349 + 350 + t.Run("rejects URIs not in allowlist", func(t *testing.T) { 351 + allowed := BuildAllowedRedirectURIs() 352 + 353 + // Should reject URIs not in the list 354 + assert.False(t, allowed["http://evil.com/callback"], "should reject evil.com") 355 + assert.False(t, allowed["http://localhost:5173/callback"], "should reject localhost") 356 + assert.False(t, allowed["evil://steal"], "should reject evil scheme") 357 + }) 358 + 359 + t.Run("returns copy not reference to base URIs", func(t *testing.T) { 360 + allowed1 := BuildAllowedRedirectURIs() 361 + allowed2 := BuildAllowedRedirectURIs() 362 + 363 + // Modifying one should not affect the other 364 + allowed1["test://modified"] = true 365 + assert.False(t, allowed2["test://modified"], "modifications should not affect other copies") 366 + }) 367 + } 368 + 369 + // ============================================================================= 370 + // Mobile OAuth Redirect URI Integration Tests 371 + // ============================================================================= 372 + 373 + // createTestOAuthHandler creates a minimal OAuthHandler for testing. 374 + // This uses a memory store and minimal configuration suitable for unit tests. 375 + func createTestOAuthHandler(t *testing.T) *OAuthHandler { 376 + t.Helper() 377 + 378 + config := &OAuthConfig{ 379 + PublicURL: "https://coves.social", 380 + Scopes: []string{"atproto"}, 381 + DevMode: true, // Dev mode to avoid real PDS calls 382 + AllowPrivateIPs: true, 383 + SealSecret: "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=", // base64 encoded 32 bytes 384 + } 385 + 386 + client, err := NewOAuthClient(config, oauth.NewMemStore()) 387 + require.NoError(t, err) 388 + 389 + handler := NewOAuthHandler(client, oauth.NewMemStore()) 390 + return handler 391 + } 392 + 393 + // TestOAuthHandler_isAllowedRedirectURI tests the OAuthHandler.isAllowedRedirectURI() method. 394 + // This is a critical security test (severity 9/10). 395 + func TestOAuthHandler_isAllowedRedirectURI(t *testing.T) { 396 + t.Run("accepts base mobile URIs", func(t *testing.T) { 397 + handler := createTestOAuthHandler(t) 398 + 399 + // Base mobile URIs should be accepted 400 + baseMobileURIs := []string{ 401 + "social.coves:/callback", 402 + "social.coves://callback", 403 + "social.coves:/oauth/callback", 404 + "social.coves://oauth/callback", 405 + "https://coves.social/app/oauth/callback", 406 + } 407 + 408 + for _, uri := range baseMobileURIs { 409 + assert.True(t, handler.isAllowedRedirectURI(uri), 410 + "should accept base mobile URI: %s", uri) 411 + } 412 + }) 413 + 414 + t.Run("rejects URIs not in allowlist", func(t *testing.T) { 415 + handler := createTestOAuthHandler(t) 416 + 417 + // These URIs should be rejected 418 + rejectedURIs := []string{ 419 + "http://localhost:5173/callback", // Localhost (use Vite proxy instead) 420 + "http://localhost:3000/callback", // Localhost 421 + "http://evil.com/callback", // Evil domain 422 + "https://example.com/oauth", // Random HTTPS 423 + "https://coves.social/wrong/path", // Right domain, wrong path 424 + "evil://steal", // Evil custom scheme 425 + "coves-app://callback", // Old/wrong custom scheme 426 + "coves://oauth/callback", // Wrong custom scheme (not reverse-domain) 427 + "", // Empty 428 + "not-a-uri", // Invalid URI 429 + } 430 + 431 + for _, uri := range rejectedURIs { 432 + assert.False(t, handler.isAllowedRedirectURI(uri), 433 + "should reject URI not in allowlist: %s", uri) 434 + } 435 + }) 436 + } 437 + 438 + // TestHandleMobileLogin_MobileURIs tests that HandleMobileLogin properly 439 + // accepts/rejects mobile redirect URIs. (severity 9/10) 440 + func TestHandleMobileLogin_MobileURIs(t *testing.T) { 441 + t.Run("accepts mobile Universal Link", func(t *testing.T) { 442 + handler := createTestOAuthHandler(t) 443 + 444 + req := httptest.NewRequest(http.MethodGet, 445 + "/oauth/mobile/login?handle=test.user&redirect_uri=https://coves.social/app/oauth/callback", nil) 446 + rec := httptest.NewRecorder() 447 + 448 + handler.HandleMobileLogin(rec, req) 449 + 450 + // Should NOT get "invalid redirect_uri" error 451 + body := rec.Body.String() 452 + assert.NotContains(t, body, "invalid redirect_uri", 453 + "mobile Universal Link should be accepted") 454 + }) 455 + 456 + t.Run("rejects localhost URI", func(t *testing.T) { 457 + handler := createTestOAuthHandler(t) 458 + 459 + req := httptest.NewRequest(http.MethodGet, 460 + "/oauth/mobile/login?handle=test.user&redirect_uri=http://localhost:5173/callback", nil) 461 + rec := httptest.NewRecorder() 462 + 463 + handler.HandleMobileLogin(rec, req) 464 + 465 + // Should get "invalid redirect_uri" error 466 + assert.Equal(t, http.StatusBadRequest, rec.Code) 467 + assert.Contains(t, rec.Body.String(), "invalid redirect_uri", 468 + "localhost URI should be rejected (use Vite proxy for dev)") 469 + }) 470 + 471 + t.Run("rejects evil URI", func(t *testing.T) { 472 + handler := createTestOAuthHandler(t) 473 + 474 + req := httptest.NewRequest(http.MethodGet, 475 + "/oauth/mobile/login?handle=test.user&redirect_uri=http://evil.com/callback", nil) 476 + rec := httptest.NewRecorder() 477 + 478 + handler.HandleMobileLogin(rec, req) 479 + 480 + // Should get "invalid redirect_uri" error 481 + assert.Equal(t, http.StatusBadRequest, rec.Code) 482 + assert.Contains(t, rec.Body.String(), "invalid redirect_uri", 483 + "evil URI should be rejected") 484 + }) 485 + } 486 + 487 + // TestMobileURIs_OnlyMobileAllowed tests that only mobile URIs are allowed. 488 + func TestMobileURIs_OnlyMobileAllowed(t *testing.T) { 489 + t.Run("only mobile URIs work", func(t *testing.T) { 490 + handler := createTestOAuthHandler(t) 491 + 492 + // Mobile URIs should work 493 + assert.True(t, handler.isAllowedRedirectURI("social.coves:/callback"), 494 + "mobile custom scheme should work") 495 + assert.True(t, handler.isAllowedRedirectURI("https://coves.social/app/oauth/callback"), 496 + "mobile Universal Link should work") 497 + 498 + // Localhost URIs should NOT work (use Vite proxy for dev) 499 + assert.False(t, handler.isAllowedRedirectURI("http://localhost:5173/callback"), 500 + "localhost should be rejected") 501 + assert.False(t, handler.isAllowedRedirectURI("http://127.0.0.1:3000/callback"), 502 + "127.0.0.1 should be rejected") 503 + }) 504 + }
+3 -1
internal/atproto/oauth/handlers_test.go
··· 174 174 // TestIsMobileRedirectURI tests mobile redirect URI validation with EXACT URI matching 175 175 // Per atproto spec, custom schemes must match client_id hostname in reverse-domain order 176 176 func TestIsMobileRedirectURI(t *testing.T) { 177 + handler := createTestOAuthHandler(t) 178 + 177 179 tests := []struct { 178 180 uri string 179 181 expected bool ··· 199 201 200 202 for _, tt := range tests { 201 203 t.Run(tt.uri, func(t *testing.T) { 202 - result := isAllowedMobileRedirectURI(tt.uri) 204 + result := handler.isAllowedRedirectURI(tt.uri) 203 205 assert.Equal(t, tt.expected, result) 204 206 }) 205 207 }
+77
scripts/web-dev-run.sh
··· 1 + #!/bin/bash 2 + # Web frontend development server runner 3 + # Starts both Coves backend AND Vite frontend, uses Caddy proxy on port 8080 4 + # 5 + # Usage: make run-web (or ./scripts/web-dev-run.sh) 6 + 7 + set -a # automatically export all variables 8 + source .env.dev 9 + set +a 10 + 11 + # Override for web frontend development 12 + # OAuth callback needs to use the proxy port (8080) for cookie sharing 13 + # MUST use 127.0.0.1 (not localhost) per RFC 8252 - PDS rejects localhost in redirect_uri 14 + export APPVIEW_PUBLIC_URL="http://127.0.0.1:8080" 15 + 16 + # Resolve paths relative to this script (no hardcoded absolute paths) 17 + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 18 + COVES_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" 19 + 20 + # Frontend location (override with KELP_DIR env var) 21 + KELP_DIR="${KELP_DIR:-$COVES_DIR/../kelp}" 22 + 23 + # Cleanup function 24 + cleanup() { 25 + echo "" 26 + echo "🛑 Shutting down..." 27 + # Kill the Vite process if it's running 28 + if [ -n "$VITE_PID" ]; then 29 + kill $VITE_PID 2>/dev/null 30 + wait $VITE_PID 2>/dev/null 31 + fi 32 + exit 0 33 + } 34 + 35 + # Set up trap for clean shutdown 36 + trap cleanup SIGINT SIGTERM 37 + 38 + echo "🌐 Starting Coves WEB FRONTEND development environment..." 39 + echo "" 40 + echo " IS_DEV_ENV: $IS_DEV_ENV" 41 + echo " PLC_DIRECTORY_URL: $PLC_DIRECTORY_URL" 42 + echo " APPVIEW_PUBLIC_URL: $APPVIEW_PUBLIC_URL (via Caddy proxy)" 43 + echo " PDS_URL: $PDS_URL" 44 + echo " Build tags: dev" 45 + echo "" 46 + 47 + # Check if kelp directory exists 48 + if [ ! -d "$KELP_DIR" ]; then 49 + echo "❌ Frontend directory not found: $KELP_DIR" 50 + exit 1 51 + fi 52 + 53 + # Start Vite in background 54 + echo "🚀 Starting Vite frontend (kelp) on :5173..." 55 + cd "$KELP_DIR" && npm run dev & 56 + VITE_PID=$! 57 + 58 + # Give Vite a moment to start 59 + sleep 2 60 + 61 + # Return to Coves directory 62 + cd "$COVES_DIR" 63 + 64 + echo "" 65 + echo "🚀 Starting Coves backend on :8081..." 66 + echo "" 67 + echo " ⚠️ Make sure Caddy proxy is running: make web-proxy" 68 + echo " 📍 Access your app at: http://localhost:8080" 69 + echo "" 70 + echo " Press Ctrl+C to stop both services" 71 + echo "" 72 + 73 + # Run Go server (foreground - will block) 74 + go run -tags dev ./cmd/server 75 + 76 + # If Go server exits, clean up Vite 77 + cleanup