package oauth import ( "context" "fmt" "html/template" "log/slog" "net/http" "strings" "time" "atcr.io/pkg/atproto" ) // UISessionStore is the interface for UI session management // UISessionStore is defined in refresher.go to avoid duplication // UserStore is the interface for user management type UserStore interface { UpsertUser(did, handle, pdsEndpoint, avatar string) error } // PostAuthCallback is called after successful OAuth authentication. // Parameters: ctx, did, handle, pdsEndpoint, sessionID // This allows AppView to perform business logic (profile creation, avatar fetch, etc.) // without coupling the OAuth package to AppView-specific dependencies. type PostAuthCallback func(ctx context.Context, did, handle, pdsEndpoint, sessionID string) error // Server handles OAuth authorization for the AppView type Server struct { app *App refresher *Refresher uiSessionStore UISessionStore postAuthCallback PostAuthCallback } // NewServer creates a new OAuth server func NewServer(app *App) *Server { return &Server{ app: app, } } // SetRefresher sets the refresher for invalidating session cache func (s *Server) SetRefresher(refresher *Refresher) { s.refresher = refresher } // SetUISessionStore sets the UI session store for web login func (s *Server) SetUISessionStore(store UISessionStore) { s.uiSessionStore = store } // SetPostAuthCallback sets the callback to be invoked after successful OAuth authentication // This allows AppView to inject business logic without coupling the OAuth package func (s *Server) SetPostAuthCallback(callback PostAuthCallback) { s.postAuthCallback = callback } // ServeAuthorize handles GET /auth/oauth/authorize func (s *Server) ServeAuthorize(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } // Get handle from query parameter handle := r.URL.Query().Get("handle") if handle == "" { http.Error(w, "handle parameter required", http.StatusBadRequest) return } slog.Debug("Starting OAuth flow", "handle", handle) // Start auth flow via indigo authURL, err := s.app.StartAuthFlow(r.Context(), handle) if err != nil { slog.Error("Failed to start auth flow", "error", err, "handle", handle) // Check if error is about invalid_client_metadata (usually means PDS doesn't support required scopes) errMsg := err.Error() if strings.Contains(errMsg, "invalid_client_metadata") { s.renderError(w, "OAuth authorization failed: Your PDS does not support one or more required OAuth scopes (likely the 'blob:' scope). Please update your PDS to the latest version and try again.") return } http.Error(w, fmt.Sprintf("failed to start auth flow: %v", err), http.StatusInternalServerError) return } slog.Debug("Generated OAuth authorization URL", "authURL", authURL) // Redirect to PDS authorization page // Note: indigo handles state internally via the auth store http.Redirect(w, r, authURL, http.StatusFound) } // ServeCallback handles GET /auth/oauth/callback func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } // Check for OAuth error if errorParam := r.URL.Query().Get("error"); errorParam != "" { errorDesc := r.URL.Query().Get("error_description") s.renderError(w, fmt.Sprintf("OAuth error: %s - %s", errorParam, errorDesc)) return } // Process OAuth callback via indigo (handles state validation internally) sessionData, err := s.app.ProcessCallback(r.Context(), r.URL.Query()) if err != nil { s.renderError(w, fmt.Sprintf("Failed to process OAuth callback: %v", err)) return } did := sessionData.AccountDID.String() sessionID := sessionData.SessionID slog.Debug("OAuth callback successful", "did", did, "sessionID", sessionID) // Invalidate cached session (if any) since we have a new session with new tokens if s.refresher != nil { s.refresher.InvalidateSession(did) slog.Debug("Invalidated cached session after creating new session", "did", did) } // Look up identity (resolve DID to handle) _, handle, _, err := atproto.ResolveIdentity(r.Context(), did) if err != nil { slog.Warn("Failed to resolve DID to handle, using DID as fallback", "error", err, "did", did) handle = did // Fallback to DID if resolution fails } // Call post-auth callback for AppView business logic (profile, avatar, etc.) if s.postAuthCallback != nil { if err := s.postAuthCallback(r.Context(), did, handle, sessionData.HostURL, sessionID); err != nil { // Log error but don't fail OAuth flow - business logic is non-critical slog.Warn("Post-auth callback failed", "error", err, "did", did) } } // Check if this is a UI login (has oauth_return_to cookie) if cookie, err := r.Cookie("oauth_return_to"); err == nil && s.uiSessionStore != nil { // Create UI session (30 days to match OAuth refresh token lifetime) // Store OAuth sessionID so we can resume it on next login if store, ok := s.uiSessionStore.(interface { CreateWithOAuth(did, handle, pdsEndpoint, oauthSessionID string, duration time.Duration) (string, error) }); ok { uiSessionID, err := store.CreateWithOAuth(did, handle, sessionData.HostURL, sessionID, 30*24*time.Hour) if err != nil { s.renderError(w, fmt.Sprintf("Failed to create UI session: %v", err)) return } // Set UI session cookie and redirect (code below) // Note: Secure flag depends on the request scheme (HTTP vs HTTPS) http.SetCookie(w, &http.Cookie{ Name: "atcr_session", Value: uiSessionID, Path: "/", MaxAge: 30 * 86400, // 30 days HttpOnly: true, Secure: r.URL.Scheme == "https" || r.Header.Get("X-Forwarded-Proto") == "https", SameSite: http.SameSiteLaxMode, }) } else { // Fallback for stores that don't support OAuth sessionID uiSessionID, err := s.uiSessionStore.Create(did, handle, sessionData.HostURL, 30*24*time.Hour) if err != nil { s.renderError(w, fmt.Sprintf("Failed to create UI session: %v", err)) return } // Set UI session cookie // Note: Secure flag depends on the request scheme (HTTP vs HTTPS) http.SetCookie(w, &http.Cookie{ Name: "atcr_session", Value: uiSessionID, Path: "/", MaxAge: 30 * 86400, // 30 days HttpOnly: true, Secure: r.URL.Scheme == "https" || r.Header.Get("X-Forwarded-Proto") == "https", SameSite: http.SameSiteLaxMode, }) } // Clear the return_to cookie http.SetCookie(w, &http.Cookie{ Name: "oauth_return_to", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, }) // Redirect to return URL returnTo := cookie.Value if returnTo == "" { returnTo = "/" } http.Redirect(w, r, returnTo, http.StatusFound) return } // Non-UI flow: redirect to settings to get API key s.renderRedirectToSettings(w, handle) } // renderRedirectToSettings redirects to the settings page to generate an API key func (s *Server) renderRedirectToSettings(w http.ResponseWriter, handle string) { tmpl := template.Must(template.New("redirect").Parse(redirectToSettingsTemplate)) data := struct { Handle string }{ Handle: handle, } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := tmpl.Execute(w, data); err != nil { http.Error(w, "failed to render template", http.StatusInternalServerError) } } // renderError renders an error page func (s *Server) renderError(w http.ResponseWriter, message string) { tmpl := template.Must(template.New("error").Parse(errorTemplate)) data := struct { Message string }{ Message: message, } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusBadRequest) if err := tmpl.Execute(w, data); err != nil { http.Error(w, "failed to render template", http.StatusInternalServerError) } } // HTML templates const redirectToSettingsTemplate = ` Authorization Successful - ATCR

✓ Authorization Successful!

You have successfully authorized ATCR to access your ATProto account: {{.Handle}}

Redirecting to settings page to generate your API key...

If not redirected, click here.

Next Steps:

  1. Generate an API key on the settings page
  2. Copy the API key (shown once!)
  3. Use it with: docker login atcr.io -u {{.Handle}} -p [your-api-key]
` const errorTemplate = ` Authorization Failed - ATCR

✗ Authorization Failed

{{.Message}}

Return to home

`