357 lines
12 KiB
Go
357 lines
12 KiB
Go
package oauth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"atcr.io/pkg/atproto"
|
|
"github.com/bluesky-social/indigo/atproto/atclient"
|
|
"github.com/bluesky-social/indigo/atproto/auth/oauth"
|
|
)
|
|
|
|
// UISessionStore is the interface for UI session management
|
|
// UISessionStore is defined in client.go (session management section)
|
|
|
|
// getOAuthErrorHint provides troubleshooting hints for OAuth errors during token exchange
|
|
func getOAuthErrorHint(apiErr *atclient.APIError) string {
|
|
switch apiErr.Name {
|
|
case "invalid_client":
|
|
if strings.Contains(apiErr.Message, "iat") && strings.Contains(apiErr.Message, "timestamp") {
|
|
return "JWT timestamp validation failed - AppView system clock may be ahead of PDS clock. Check NTP sync: timedatectl status. Typical tolerance is ±30 seconds."
|
|
}
|
|
return "OAuth client authentication failed during token exchange - check client key and PDS OAuth configuration"
|
|
case "invalid_grant":
|
|
return "Authorization code is invalid, expired, or already used - user should retry OAuth flow from beginning"
|
|
case "use_dpop_nonce":
|
|
return "DPoP nonce challenge during token exchange - indigo should retry automatically, persistent failures indicate PDS issue"
|
|
case "invalid_dpop_proof":
|
|
return "DPoP proof validation failed - check system clock sync between AppView and PDS"
|
|
case "unauthorized_client":
|
|
return "PDS rejected the client - check client metadata URL is accessible and scopes are supported"
|
|
case "invalid_request":
|
|
return "Malformed token request - check OAuth flow parameters (code, redirect_uri, state)"
|
|
case "server_error":
|
|
return "PDS internal error during token exchange - check PDS logs for root cause"
|
|
default:
|
|
if apiErr.StatusCode == 400 {
|
|
return "Bad request during OAuth token exchange - check error details and PDS logs"
|
|
}
|
|
return "OAuth token exchange failed - see errorName and errorMessage for PDS response"
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
clientApp *oauth.ClientApp
|
|
refresher *Refresher
|
|
uiSessionStore UISessionStore
|
|
postAuthCallback PostAuthCallback
|
|
}
|
|
|
|
// NewServer creates a new OAuth server
|
|
func NewServer(clientApp *oauth.ClientApp) *Server {
|
|
return &Server{
|
|
clientApp: clientApp,
|
|
}
|
|
}
|
|
|
|
// 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.clientApp.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)
|
|
// This performs token exchange with the PDS using authorization code
|
|
sessionData, err := s.clientApp.ProcessCallback(r.Context(), r.URL.Query())
|
|
if err != nil {
|
|
// Detailed error logging for token exchange failures
|
|
var apiErr *atclient.APIError
|
|
if errors.As(err, &apiErr) {
|
|
slog.Error("OAuth callback failed - token exchange error",
|
|
"component", "oauth/server",
|
|
"error", err,
|
|
"httpStatus", apiErr.StatusCode,
|
|
"errorName", apiErr.Name,
|
|
"errorMessage", apiErr.Message,
|
|
"hint", getOAuthErrorHint(apiErr),
|
|
"queryParams", r.URL.Query().Encode())
|
|
} else {
|
|
slog.Error("OAuth callback failed - unknown error",
|
|
"component", "oauth/server",
|
|
"error", err,
|
|
"errorType", fmt.Sprintf("%T", err),
|
|
"queryParams", r.URL.Query().Encode())
|
|
}
|
|
|
|
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)
|
|
|
|
// Clean up old OAuth sessions for this DID BEFORE invalidating cache
|
|
// This prevents accumulation of stale sessions with expired refresh tokens
|
|
// Order matters: delete from DB first, then invalidate cache, so when cache reloads
|
|
// it will only find the new session
|
|
type sessionCleaner interface {
|
|
DeleteOldSessionsForDID(ctx context.Context, did string, keepSessionID string) error
|
|
}
|
|
if cleaner, ok := s.clientApp.Store.(sessionCleaner); ok {
|
|
if err := cleaner.DeleteOldSessionsForDID(r.Context(), did, sessionID); err != nil {
|
|
slog.Warn("Failed to clean up old OAuth sessions", "did", did, "error", err)
|
|
// Non-fatal - log and continue
|
|
} else {
|
|
slog.Debug("Cleaned up old OAuth sessions", "did", did, "kept", sessionID)
|
|
}
|
|
}
|
|
|
|
// 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>
|
|
`
|