A community based topic aggregation platform built on atproto

feat(oauth): add branded intermediate page for mobile callback

Instead of redirecting directly to the custom scheme (social.coves:/),
serve an intermediate HTML page that:
- Shows "Login Complete" with Coves branding
- Displays the user's handle
- Redirects to the app via JavaScript + meta refresh
- Attempts to close the browser tab
- Shows friendly fallback message if app doesn't open

This prevents users from seeing stale PDS error pages when the
Custom Tab doesn't close immediately after the OAuth redirect.

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

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

+143 -2
+143 -2
internal/atproto/oauth/handlers.go
··· 4 4 "context" 5 5 "encoding/json" 6 6 "fmt" 7 + "html/template" 7 8 "log/slog" 8 9 "net/http" 9 10 "net/url" ··· 11 12 "github.com/bluesky-social/indigo/atproto/auth/oauth" 12 13 "github.com/bluesky-social/indigo/atproto/syntax" 13 14 ) 15 + 16 + // mobileCallbackTemplate is the intermediate page shown after OAuth completes 17 + // before redirecting to the mobile app via custom scheme. 18 + // This prevents the browser from showing a stale PDS page after the redirect. 19 + var mobileCallbackTemplate = template.Must(template.New("mobile_callback").Parse(`<!DOCTYPE html> 20 + <html lang="en"> 21 + <head> 22 + <meta charset="utf-8"> 23 + <meta name="viewport" content="width=device-width, initial-scale=1"> 24 + <title>Login Complete - Coves</title> 25 + <meta http-equiv="refresh" content="1;url={{.DeepLink}}"> 26 + <style> 27 + * { box-sizing: border-box; margin: 0; padding: 0; } 28 + body { 29 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 30 + background: #0B0F14; 31 + color: #e4e6e7; 32 + min-height: 100vh; 33 + display: flex; 34 + justify-content: center; 35 + align-items: center; 36 + padding: 24px; 37 + } 38 + .card { 39 + text-align: center; 40 + max-width: 320px; 41 + } 42 + .logo { 43 + width: 80px; 44 + height: 80px; 45 + margin: 0 auto 16px; 46 + } 47 + .checkmark { 48 + width: 64px; 49 + height: 64px; 50 + margin: 0 auto 24px; 51 + background: #FF6B35; 52 + border-radius: 50%; 53 + display: flex; 54 + align-items: center; 55 + justify-content: center; 56 + animation: scale-in 0.3s ease-out; 57 + } 58 + .checkmark svg { 59 + width: 32px; 60 + height: 32px; 61 + stroke: white; 62 + stroke-width: 3; 63 + fill: none; 64 + } 65 + @keyframes scale-in { 66 + 0% { transform: scale(0); } 67 + 50% { transform: scale(1.1); } 68 + 100% { transform: scale(1); } 69 + } 70 + h1 { 71 + font-size: 24px; 72 + font-weight: 600; 73 + margin-bottom: 8px; 74 + color: #e4e6e7; 75 + } 76 + .subtitle { 77 + font-size: 16px; 78 + color: #B6C2D2; 79 + margin-bottom: 24px; 80 + } 81 + .handle { 82 + font-size: 14px; 83 + color: #7CB9E8; 84 + background: #1A1F26; 85 + padding: 8px 16px; 86 + border-radius: 8px; 87 + margin-bottom: 24px; 88 + display: inline-block; 89 + } 90 + .hint { 91 + font-size: 13px; 92 + color: #6B7280; 93 + line-height: 1.5; 94 + } 95 + .spinner { 96 + width: 20px; 97 + height: 20px; 98 + border: 2px solid #2A2F36; 99 + border-top-color: #FF6B35; 100 + border-radius: 50%; 101 + animation: spin 1s linear infinite; 102 + display: inline-block; 103 + vertical-align: middle; 104 + margin-right: 8px; 105 + } 106 + @keyframes spin { 107 + to { transform: rotate(360deg); } 108 + } 109 + </style> 110 + </head> 111 + <body> 112 + <div class="card"> 113 + <div class="checkmark"> 114 + <svg viewBox="0 0 24 24"> 115 + <polyline points="20 6 9 17 4 12"></polyline> 116 + </svg> 117 + </div> 118 + <h1>Login Complete</h1> 119 + <p class="subtitle"> 120 + <span class="spinner"></span> 121 + Returning to Coves... 122 + </p> 123 + {{if .Handle}} 124 + <div class="handle">@{{.Handle}}</div> 125 + {{end}} 126 + <p class="hint">If the app doesn't open automatically,<br>you can close this window.</p> 127 + </div> 128 + <script> 129 + // Redirect to app immediately 130 + window.location.href = {{.DeepLink}}; 131 + // Try to close window after a delay 132 + setTimeout(function() { 133 + window.close(); 134 + }, 1500); 135 + </script> 136 + </body> 137 + </html> 138 + `)) 14 139 15 140 // MobileOAuthStore interface for mobile-specific OAuth operations 16 141 // This extends the base OAuth store with mobile CSRF tracking ··· 483 608 // Log mobile redirect (sanitized - no token or session ID to avoid leaking credentials) 484 609 slog.Info("redirecting to mobile app", "did", sessData.AccountDID, "handle", handle) 485 610 486 - // Redirect to mobile app deep link 487 - http.Redirect(w, r, deepLink, http.StatusFound) 611 + // Serve intermediate page that redirects to the app 612 + // This prevents the browser from showing a stale PDS page after the custom scheme redirect 613 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 614 + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate") 615 + 616 + data := struct { 617 + DeepLink string 618 + Handle string 619 + }{ 620 + DeepLink: deepLink, 621 + Handle: handle, 622 + } 623 + 624 + if err := mobileCallbackTemplate.Execute(w, data); err != nil { 625 + slog.Error("failed to render mobile callback template", "error", err) 626 + // Fallback to direct redirect if template fails 627 + http.Redirect(w, r, deepLink, http.StatusFound) 628 + } 488 629 } 489 630 490 631 // HandleLogout revokes the session and clears cookies