Files
2025-10-26 23:08:03 -05:00

296 lines
9.6 KiB
Go

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 = `
<!DOCTYPE html>
<html>
<head>
<title>Authorization Successful - ATCR</title>
<meta http-equiv="refresh" content="3;url=/settings">
<style>
body { font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
.success { background: #d4edda; border: 1px solid #c3e6cb; padding: 20px; border-radius: 5px; }
.info { background: #d1ecf1; border: 1px solid #bee5eb; padding: 15px; border-radius: 5px; margin-top: 15px; }
a { color: #007bff; text-decoration: none; }
a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="success">
<h1>✓ Authorization Successful!</h1>
<p>You have successfully authorized ATCR to access your ATProto account: <strong>{{.Handle}}</strong></p>
<p>Redirecting to settings page to generate your API key...</p>
<p>If not redirected, <a href="/settings">click here</a>.</p>
</div>
<div class="info">
<h3>Next Steps:</h3>
<ol>
<li>Generate an API key on the settings page</li>
<li>Copy the API key (shown once!)</li>
<li>Use it with: <code>docker login atcr.io -u {{.Handle}} -p [your-api-key]</code></li>
</ol>
</div>
</body>
</html>
`
const errorTemplate = `
<!DOCTYPE html>
<html>
<head>
<title>Authorization Failed - ATCR</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
.error { background: #f8d7da; border: 1px solid #f5c6cb; padding: 20px; border-radius: 5px; }
</style>
</head>
<body>
<div class="error">
<h1>✗ Authorization Failed</h1>
<p>{{.Message}}</p>
<p><a href="/">Return to home</a></p>
</div>
</body>
</html>
`