From 31e235a2a1d6a43e63d1cb5758ec9644b9ce978e Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Sat, 4 Oct 2025 13:50:28 -0500 Subject: [PATCH] cleanup oauth --- cmd/credential-helper/main.go | 294 ++++++++-------------- cmd/registry/main.go | 22 +- cmd/registry/serve.go | 111 ++++++++- docker-compose.yml | 8 +- docs/APPVIEW_OAUTH.md | 434 +++++++++++++++++++++++++++++++++ pkg/auth/atproto/validator.go | 23 +- pkg/auth/exchange/handler.go | 96 +++----- pkg/auth/oauth/client.go | 16 +- pkg/auth/oauth/refresher.go | 167 +++++++++++++ pkg/auth/oauth/server.go | 342 ++++++++++++++++++++++++++ pkg/auth/oauth/tokenstorage.go | 200 +++++++++++++++ pkg/auth/scope.go | 7 + pkg/auth/session/handler.go | 170 +++++++++++++ pkg/auth/token/handler.go | 80 +++--- pkg/middleware/registry.go | 46 +++- 15 files changed, 1712 insertions(+), 304 deletions(-) create mode 100644 docs/APPVIEW_OAUTH.md create mode 100644 pkg/auth/oauth/refresher.go create mode 100644 pkg/auth/oauth/server.go create mode 100644 pkg/auth/oauth/tokenstorage.go create mode 100644 pkg/auth/session/handler.go diff --git a/cmd/credential-helper/main.go b/cmd/credential-helper/main.go index 4bb5811..88d8784 100644 --- a/cmd/credential-helper/main.go +++ b/cmd/credential-helper/main.go @@ -2,39 +2,26 @@ package main import ( "bytes" - "context" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" - "strings" - "time" - "atcr.io/pkg/atproto" "atcr.io/pkg/auth/oauth" ) const ( - callbackPort = "8888" - baseURL = "http://127.0.0.1:" + callbackPort - callbackPath = "/callback" + // Default AppView URL - can be overridden via environment variable + defaultAppViewURL = "http://127.0.0.1:5000" ) -var ( - clientID string - redirectURI string -) - -func init() { - // Use shared helper to create localhost client ID - cfg := oauth.ClientIDConfig{ - BaseURL: baseURL, - CallbackPath: callbackPath, - Scopes: []string{"atproto"}, - } - clientID, redirectURI = cfg.MakeClientID() +// SessionStore represents the stored session token +type SessionStore struct { + SessionToken string `json:"session_token"` + Handle string `json:"handle"` + AppViewURL string `json:"appview_url"` } // Docker credential helper protocol @@ -84,72 +71,22 @@ func handleGet() { os.Exit(1) } - // Load token from storage - tokenPath := getTokenPath() - token, err := oauth.LoadTokenStore(tokenPath) + // Load session from storage + sessionPath := getSessionPath() + session, err := loadSession(sessionPath) if err != nil { - fmt.Fprintf(os.Stderr, "Error loading token: %v\n", err) + fmt.Fprintf(os.Stderr, "Error loading session: %v\n", err) + fmt.Fprintf(os.Stderr, "Please run: docker-credential-atcr configure\n") os.Exit(1) } - // Check if token is expired and refresh if needed - if token.IsExpired() && token.RefreshToken != "" { - // Create OAuth client - client, err := oauth.NewClient(clientID, redirectURI) - if err != nil { - fmt.Fprintf(os.Stderr, "Error creating OAuth client: %v\n", err) - os.Exit(1) - } - - // Load DPoP key - dpopKey, err := token.GetDPoPKey() - if err != nil { - fmt.Fprintf(os.Stderr, "Error loading DPoP key: %v\n", err) - os.Exit(1) - } - client.SetDPoPKey(dpopKey) - - // Initialize for the handle - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - if err := client.InitializeForHandle(ctx, token.Handle); err != nil { - fmt.Fprintf(os.Stderr, "Error initializing OAuth client: %v\n", err) - os.Exit(1) - } - - // Refresh the token - newToken, err := client.RefreshToken(ctx, token.RefreshToken) - if err != nil { - fmt.Fprintf(os.Stderr, "Error refreshing token: %v\n", err) - os.Exit(1) - } - - // Update token store - token.AccessToken = newToken.AccessToken - token.RefreshToken = newToken.RefreshToken - token.ExpiresAt = newToken.Expiry - - // Save updated token - if err := token.Save(tokenPath); err != nil { - fmt.Fprintf(os.Stderr, "Error saving token: %v\n", err) - os.Exit(1) - } - } - - // Exchange ATProto token for registry JWT - fmt.Fprintf(os.Stderr, "[DEBUG] Exchanging token for %s, handle=%s, token_expired=%v\n", serverURL, token.Handle, token.IsExpired()) - registryJWT, err := exchangeForRegistryToken(token.AccessToken, serverURL, token.Handle) - if err != nil { - fmt.Fprintf(os.Stderr, "Error exchanging token: %v\n", err) - os.Exit(1) - } - - // Return credentials + // Return session token as credentials + // Docker will call /auth/token with this, and the token handler + // will validate the session token and issue a registry JWT creds := Credentials{ ServerURL: serverURL, - Username: "oauth2", - Secret: registryJWT, + Username: "oauth2", // Signals token-based auth to Docker + Secret: session.SessionToken, // Return session token directly } if err := json.NewEncoder(os.Stdout).Encode(creds); err != nil { @@ -180,10 +117,10 @@ func handleErase() { os.Exit(1) } - // Remove token file - tokenPath := getTokenPath() - if err := os.Remove(tokenPath); err != nil && !os.IsNotExist(err) { - fmt.Fprintf(os.Stderr, "Error removing token: %v\n", err) + // Remove session file + sessionPath := getSessionPath() + if err := os.Remove(sessionPath); err != nil && !os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "Error removing session: %v\n", err) os.Exit(1) } } @@ -194,6 +131,13 @@ func handleConfigure(handle string) { fmt.Println("=====================================") fmt.Println() + // Get AppView URL from environment or use default + appViewURL := os.Getenv("ATCR_APPVIEW_URL") + if appViewURL == "" { + appViewURL = defaultAppViewURL + } + fmt.Printf("AppView URL: %s\n\n", appViewURL) + // Ask for handle if not provided as argument if handle == "" { fmt.Print("Enter your ATProto handle (e.g., alice.bsky.social): ") @@ -205,52 +149,96 @@ func handleConfigure(handle string) { fmt.Printf("Using handle: %s\n", handle) } - // Run OAuth flow - fmt.Println("\nStarting OAuth flow...") - token, err := runOAuthFlow(handle) - if err != nil { - fmt.Fprintf(os.Stderr, "Error during OAuth flow: %v\n", err) + // Open browser to AppView OAuth authorization + authURL := fmt.Sprintf("%s/auth/oauth/authorize?handle=%s", appViewURL, handle) + fmt.Printf("\nOpening browser to: %s\n", authURL) + fmt.Println("Please complete the authorization in your browser.") + fmt.Println("After authorization, you will receive a session token.") + fmt.Println() + + if err := oauth.OpenBrowser(authURL); err != nil { + fmt.Printf("Failed to open browser automatically.\nPlease open this URL manually:\n%s\n\n", authURL) + } + + // Prompt user to paste session token + fmt.Print("Enter the session token from the browser: ") + var sessionToken string + if _, err := fmt.Scanln(&sessionToken); err != nil { + fmt.Fprintf(os.Stderr, "Error reading session token: %v\n", err) os.Exit(1) } - // Save token - tokenPath := getTokenPath() - if err := token.Save(tokenPath); err != nil { - fmt.Fprintf(os.Stderr, "Error saving token: %v\n", err) + // Create session store + session := &SessionStore{ + SessionToken: sessionToken, + Handle: handle, + AppViewURL: appViewURL, + } + + // Save session + sessionPath := getSessionPath() + if err := saveSession(sessionPath, session); err != nil { + fmt.Fprintf(os.Stderr, "Error saving session: %v\n", err) os.Exit(1) } - fmt.Println("\nConfiguration complete!") + fmt.Println("\n✓ Configuration complete!") fmt.Println("You can now use docker push/pull with atcr.io") } -// getTokenPath returns the path to the token file -func getTokenPath() string { +// getSessionPath returns the path to the session file +func getSessionPath() string { homeDir, err := os.UserHomeDir() if err != nil { fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err) os.Exit(1) } - return filepath.Join(homeDir, ".atcr", "oauth-token.json") -} - -// exchangeForRegistryToken exchanges the ATProto OAuth token for a registry JWT -func exchangeForRegistryToken(atprotoToken, registryURL, handle string, dpopKey interface{}) (string, error) { - // Call the registry's /auth/exchange endpoint - // This endpoint validates the ATProto token and returns a registry JWT - - // Normalize registry URL - add scheme if missing - if !strings.HasPrefix(registryURL, "http://") && !strings.HasPrefix(registryURL, "https://") { - registryURL = "http://" + registryURL + atcrDir := filepath.Join(homeDir, ".atcr") + if err := os.MkdirAll(atcrDir, 0700); err != nil { + fmt.Fprintf(os.Stderr, "Error creating .atcr directory: %v\n", err) + os.Exit(1) } - exchangeURL := fmt.Sprintf("%s/auth/exchange", registryURL) + return filepath.Join(atcrDir, "session.json") +} + +// loadSession loads the session from disk +func loadSession(path string) (*SessionStore, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read session file: %w", err) + } + + var session SessionStore + if err := json.Unmarshal(data, &session); err != nil { + return nil, fmt.Errorf("failed to parse session file: %w", err) + } + + return &session, nil +} + +// saveSession saves the session to disk +func saveSession(path string, session *SessionStore) error { + data, err := json.MarshalIndent(session, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal session: %w", err) + } + + if err := os.WriteFile(path, data, 0600); err != nil { + return fmt.Errorf("failed to write session file: %w", err) + } + + return nil +} + +// exchangeSessionForRegistryToken exchanges the session token for a registry JWT +func exchangeSessionForRegistryToken(sessionToken, appViewURL string) (string, error) { + // Call the AppView's /auth/exchange endpoint + exchangeURL := fmt.Sprintf("%s/auth/exchange", appViewURL) reqBody := map[string]any{ - "access_token": atprotoToken, - "handle": handle, // Required for PDS resolution and token validation - "scope": []string{"repository:*:pull,push"}, + "scope": []string{"repository:*:pull,push"}, } body, err := json.Marshal(reqBody) @@ -258,23 +246,14 @@ func exchangeForRegistryToken(atprotoToken, registryURL, handle string, dpopKey return "", fmt.Errorf("failed to marshal request: %w", err) } - fmt.Fprintf(os.Stderr, "[DEBUG] POST %s\n", exchangeURL) - fmt.Fprintf(os.Stderr, "[DEBUG] Request: handle=%s, token_prefix=%s..., scope=%v\n", - handle, - atprotoToken[:min(20, len(atprotoToken))], - reqBody["scope"]) - - // Create HTTP client with DPoP transport - transport := oauth.NewDPoPTransport(http.DefaultTransport, dpopKey) - transport.SetAccessToken(atprotoToken) - client := &http.Client{Transport: transport} - req, err := http.NewRequest("POST", exchangeURL, bytes.NewReader(body)) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+sessionToken) + client := &http.Client{} resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("failed to call exchange endpoint: %w", err) @@ -284,9 +263,7 @@ func exchangeForRegistryToken(atprotoToken, registryURL, handle string, dpopKey if resp.StatusCode != http.StatusOK { // Read response body for debugging bodyBytes, _ := io.ReadAll(resp.Body) - fmt.Fprintf(os.Stderr, "[DEBUG] Exchange failed with status %d\n", resp.StatusCode) - fmt.Fprintf(os.Stderr, "[DEBUG] Response body: %s\n", string(bodyBytes)) - return "", fmt.Errorf("exchange failed with status %d", resp.StatusCode) + return "", fmt.Errorf("exchange failed with status %d: %s", resp.StatusCode, string(bodyBytes)) } var result struct { @@ -304,70 +281,3 @@ func exchangeForRegistryToken(atprotoToken, registryURL, handle string, dpopKey return result.AccessToken, nil } -// runOAuthFlow executes the OAuth flow with browser -func runOAuthFlow(handle string) (*oauth.TokenStore, error) { - var server *http.Server - - // Run interactive OAuth flow with ephemeral server - result, err := oauth.RunInteractiveFlow( - context.Background(), - oauth.InteractiveFlowConfig{ - ClientID: clientID, - RedirectURI: redirectURI, - Handle: handle, - }, - func(authURL string, handler *oauth.CallbackHandler, metadata *oauth.ClientMetadata) error { - // First call (authURL empty): start server - if authURL == "" { - var err error - server, err = oauth.StartCallbackServer(handler, metadata) - if err != nil { - return fmt.Errorf("failed to start callback server: %w", err) - } - return nil - } - - // Second call (authURL populated): display URL and open browser - fmt.Printf("Opening browser to: %s\n", authURL) - if err := oauth.OpenBrowser(authURL); err != nil { - fmt.Printf("Failed to open browser automatically. Please open this URL manually:\n%s\n", authURL) - } - - return nil - }, - ) - if err != nil { - return nil, err - } - - // Shutdown ephemeral server - if server != nil { - defer server.Shutdown(context.Background()) - } - - fmt.Println("Authorization successful!") - - // Resolve handle to get DID - resolver := atproto.NewResolver() - did, _, err := resolver.ResolveIdentity(context.Background(), handle) - if err != nil { - return nil, fmt.Errorf("failed to resolve DID: %w", err) - } - - // Create token store - store := &oauth.TokenStore{ - AccessToken: result.Token.AccessToken, - RefreshToken: result.Token.RefreshToken, - TokenType: result.Token.TokenType, - ExpiresAt: result.Token.Expiry, - Handle: handle, - DID: did, - } - - // Save DPoP key - if err := store.SetDPoPKey(result.Client.DPoPKey()); err != nil { - return nil, fmt.Errorf("failed to save DPoP key: %w", err) - } - - return store, nil -} diff --git a/cmd/registry/main.go b/cmd/registry/main.go index c2c9f3b..1abd816 100644 --- a/cmd/registry/main.go +++ b/cmd/registry/main.go @@ -1,7 +1,9 @@ package main import ( + "fmt" "os" + "time" "github.com/distribution/distribution/v3/registry" _ "github.com/distribution/distribution/v3/registry/auth/token" @@ -11,12 +13,28 @@ import ( // Register our custom middleware _ "atcr.io/pkg/middleware" + + "atcr.io/pkg/auth/exchange" + "atcr.io/pkg/auth/oauth" + "atcr.io/pkg/auth/session" + "atcr.io/pkg/auth/token" + "atcr.io/pkg/middleware" ) func main() { - // Use distribution's built-in CLI - // Our middleware will be automatically registered via init() + // The serve command is registered in serve.go via init() + // Just execute the root command if err := registry.RootCmd.Execute(); err != nil { os.Exit(1) } } + +// Suppress unused import warnings +var _ = fmt.Sprint +var _ = os.Stdout +var _ = time.Now +var _ = oauth.NewRefresher +var _ = session.NewManager +var _ = token.NewIssuer +var _ = exchange.NewHandler +var _ = middleware.SetGlobalRefresher diff --git a/cmd/registry/serve.go b/cmd/registry/serve.go index 94b57b8..052176b 100644 --- a/cmd/registry/serve.go +++ b/cmd/registry/serve.go @@ -6,6 +6,7 @@ import ( "net/http" "os" "os/signal" + "path/filepath" "syscall" "time" @@ -15,7 +16,10 @@ import ( "github.com/spf13/cobra" "atcr.io/pkg/auth/exchange" + "atcr.io/pkg/auth/oauth" + "atcr.io/pkg/auth/session" "atcr.io/pkg/auth/token" + "atcr.io/pkg/middleware" ) var serveCmd = &cobra.Command{ @@ -51,7 +55,86 @@ func serveRegistry(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to parse configuration: %w", err) } - // Initialize auth keys if needed + // Initialize OAuth components + fmt.Println("Initializing OAuth components...") + + // 1. Create refresh token storage + // Allow override via environment variable for Docker deployments + storagePath := os.Getenv("ATCR_TOKEN_STORAGE_PATH") + if storagePath == "" { + var err error + storagePath, err = oauth.GetDefaultPath() + if err != nil { + return fmt.Errorf("failed to get storage path: %w", err) + } + } + + // Ensure directory exists + storageDir := filepath.Dir(storagePath) + if err := os.MkdirAll(storageDir, 0700); err != nil { + return fmt.Errorf("failed to create storage directory: %w", err) + } + + fmt.Printf("Using token storage path: %s\n", storagePath) + + refreshStorage, err := oauth.NewRefreshTokenStorage(storagePath) + if err != nil { + return fmt.Errorf("failed to create refresh token storage: %w", err) + } + + // 2. Create session manager with 30-day TTL + // Use persistent secret so session tokens remain valid across container restarts + secretPath := os.Getenv("ATCR_SESSION_SECRET_PATH") + if secretPath == "" { + // Default to same directory as tokens + secretPath = filepath.Join(filepath.Dir(storagePath), "session-secret.key") + } + sessionManager, err := session.NewManagerWithPersistentSecret(secretPath, 30*24*time.Hour) + if err != nil { + return fmt.Errorf("failed to create session manager: %w", err) + } + + // 3. Get base URL from config or environment + baseURL := os.Getenv("ATCR_BASE_URL") + if baseURL == "" { + // If addr is just a port (e.g., ":5000"), prepend localhost + addr := config.HTTP.Addr + if addr[0] == ':' { + baseURL = fmt.Sprintf("http://127.0.0.1%s", addr) + } else { + baseURL = fmt.Sprintf("http://%s", addr) + } + } + + fmt.Printf("DEBUG: Base URL for OAuth: %s\n", baseURL) + + // 4. Get client ID from config + clientIDConfig := oauth.ClientIDConfig{ + BaseURL: baseURL, + CallbackPath: "/auth/oauth/callback", + Scopes: []string{"atproto"}, + } + clientID, redirectURI := clientIDConfig.MakeClientID() + + fmt.Printf("DEBUG: Client ID: %s\n", clientID) + fmt.Printf("DEBUG: Redirect URI: %s\n", redirectURI) + + // 5. Create refresher + refresher := oauth.NewRefresher(refreshStorage, clientID, redirectURI) + // Start cleanup routine (runs every hour) + refresher.StartCleanupRoutine(1 * time.Hour) + + // 6. Set global refresher for middleware + middleware.SetGlobalRefresher(refresher) + + // 7. Create client metadata (only needed for production, not localhost) + // For localhost, client metadata is embedded in the client_id query string + // clientMetadata := oauth.NewClientMetadata(clientID, []string{redirectURI}) + + // 8. Create OAuth server + oauthServer := oauth.NewServer(refreshStorage, sessionManager, baseURL) + + // 9. Initialize auth keys and create token issuer var issuer *token.Issuer if config.Auth["token"] != nil { if err := initializeAuthKeys(config); err != nil { @@ -75,17 +158,37 @@ func serveRegistry(cmd *cobra.Command, args []string) error { // Mount registry at /v2/ mux.Handle("/v2/", app) + // Mount OAuth endpoints + mux.HandleFunc("/auth/oauth/authorize", oauthServer.ServeAuthorize) + mux.HandleFunc("/auth/oauth/callback", oauthServer.ServeCallback) + + // Start OAuth server cleanup routine + go func() { + ticker := time.NewTicker(10 * time.Minute) + defer ticker.Stop() + for range ticker.C { + oauthServer.CleanupExpiredStates() + } + }() + // Mount auth endpoints if enabled if issuer != nil { // Extract default hold endpoint from middleware config defaultHoldEndpoint := extractDefaultHoldEndpoint(config) - tokenHandler := token.NewHandler(issuer, defaultHoldEndpoint) + // Basic Auth token endpoint (also supports session tokens) + tokenHandler := token.NewHandler(issuer, sessionManager, defaultHoldEndpoint) tokenHandler.RegisterRoutes(mux) - exchangeHandler := exchange.NewHandler(issuer, defaultHoldEndpoint) + // OAuth exchange endpoint (session token → registry JWT) + exchangeHandler := exchange.NewHandler(issuer, sessionManager) exchangeHandler.RegisterRoutes(mux) - fmt.Println("Auth endpoints enabled at /auth/token and /auth/exchange") + + fmt.Printf("Auth endpoints enabled:\n") + fmt.Printf(" - Basic Auth: /auth/token\n") + fmt.Printf(" - OAuth: /auth/oauth/authorize\n") + fmt.Printf(" - OAuth: /auth/oauth/callback\n") + fmt.Printf(" - Exchange: /auth/exchange\n") } // Create HTTP server diff --git a/docker-compose.yml b/docker-compose.yml index 51c38af..f104384 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,9 +7,13 @@ services: container_name: atcr-registry ports: - "5000:5000" + environment: + - ATCR_TOKEN_STORAGE_PATH=/var/lib/atcr/tokens/oauth-tokens.json volumes: - # Only auth keys (could be moved to secrets in production) + # Auth keys (JWT signing keys) - atcr-auth:/var/lib/atcr/auth + # OAuth refresh tokens (persists user sessions across container restarts) + - atcr-tokens:/var/lib/atcr/tokens restart: unless-stopped networks: atcr-network: @@ -17,6 +21,7 @@ services: # The registry should be stateless - all storage is external: # - Manifests/Tags -> ATProto PDS # - Blobs/Layers -> Hold service + # - OAuth tokens -> Persistent volume (atcr-tokens) # Future: Add read_only: true for production deployments hold: @@ -51,3 +56,4 @@ networks: volumes: atcr-hold: atcr-auth: + atcr-tokens: diff --git a/docs/APPVIEW_OAUTH.md b/docs/APPVIEW_OAUTH.md new file mode 100644 index 0000000..6bb374d --- /dev/null +++ b/docs/APPVIEW_OAUTH.md @@ -0,0 +1,434 @@ +# AppView-Mediated OAuth Architecture + +## Overview + +ATCR uses a two-tier authentication model to support OAuth while allowing the AppView to write manifests to users' Personal Data Servers (PDS). + +## The Problem + +OAuth with DPoP creates cryptographically bound tokens that cannot be delegated: + +- **Basic Auth**: App password is a shared secret that can be forwarded from client → AppView → PDS ✅ +- **OAuth + DPoP**: Token is bound to client's keypair and cannot be reused by AppView ❌ + +This creates a challenge: How can the AppView write manifests to the user's PDS on their behalf? + +## The Solution: Two-Tier Authentication + +``` +┌──────────┐ ┌─────────┐ ┌────────────┐ +│ Docker │◄───────►│ AppView │◄───────►│ PDS/Auth │ +│ Client │ Auth1 │ (ATCR) │ Auth2 │ Server │ +└──────────┘ └─────────┘ └────────────┘ +``` + +**Auth Tier 1** (Docker ↔ AppView): Registry authentication +- Client authenticates to AppView using session tokens +- AppView issues short-lived registry JWTs +- Standard Docker registry auth protocol + +**Auth Tier 2** (AppView ↔ PDS): Resource access +- AppView acts as OAuth client for each user +- AppView stores refresh tokens per user +- AppView gets access tokens on-demand to write manifests + +## Complete Flows + +### One-Time Authorization Flow + +``` +┌────────┐ ┌──────────────┐ ┌─────────┐ ┌─────┐ +│ User │ │ Credential │ │ AppView │ │ PDS │ +│ │ │ Helper │ │ │ │ │ +└───┬────┘ └──────┬───────┘ └────┬────┘ └──┬──┘ + │ │ │ │ + │ $ docker-credential-atcr configure │ │ + │ Enter handle: evan.jarrett.net │ │ + │─────────────────────>│ │ │ + │ │ │ │ + │ │ GET /auth/oauth/authorize?handle=... │ + │ │─────────────────────>│ │ + │ │ │ │ + │ │ 302 Redirect to PDS │ │ + │ │<─────────────────────│ │ + │ │ │ │ + │ [Browser opens] │ │ │ + │<─────────────────────│ │ │ + │ │ │ │ + │ Authorize ATCR? │ │ │ + │──────────────────────────────────────────────────────────────>│ + │ │ │ │ + │ │ │<─code────────────│ + │ │ │ │ + │ │ │ POST /token │ + │ │ │ (exchange code) │ + │ │ │ + DPoP proof │ + │ │ │─────────────────>│ + │ │ │ │ + │ │ │<─refresh_token───│ + │ │ │ access_token │ + │ │ │ │ + │ │ │ [Store tokens] │ + │ │ │ DID → { │ + │ │ │ refresh_token, │ + │ │ │ dpop_key, │ + │ │ │ pds_endpoint │ + │ │ │ } │ + │ │ │ │ + │ │<─session_token───────│ │ + │ │ │ │ + │ [Store session] │ │ │ + │<─────────────────────│ │ │ + │ ~/.atcr/ │ │ │ + │ session.json │ │ │ + │ │ │ │ + │ ✓ Authorization │ │ │ + │ complete! │ │ │ + │ │ │ │ +``` + +### Docker Push Flow (Every Push) + +``` +┌────────┐ ┌──────────┐ ┌─────────┐ ┌─────┐ +│ Docker │ │ Cred │ │ AppView │ │ PDS │ +│ │ │ Helper │ │ │ │ │ +└───┬────┘ └────┬─────┘ └────┬────┘ └──┬──┘ + │ │ │ │ + │ docker push │ │ │ + │──────────────>│ │ │ + │ │ │ │ + │ │ GET /auth/exchange │ + │ │ Authorization: Bearer │ + │ │ │ + │ │──────────────>│ │ + │ │ │ │ + │ │ │ [Validate │ + │ │ │ session] │ + │ │ │ │ + │ │ │ [Issue JWT] │ + │ │ │ │ + │ │<──registry_jwt─│ │ + │ │ │ │ + │<─registry_jwt─│ │ │ + │ │ │ │ + │ PUT /v2/.../manifests/... │ │ + │ Authorization: Bearer │ │ + │ │ │ + │──────────────────────────────>│ │ + │ │ │ + │ │ [Validate │ + │ │ JWT] │ + │ │ │ + │ │ [Get fresh │ + │ │ access │ + │ │ token] │ + │ │ │ + │ │ POST /token │ + │ │ (refresh) │ + │ │ + DPoP │ + │ │────────────>│ + │ │ │ + │ ││ + │ │ │ + │ │<──201 OK────│ + │ │ │ + │<──────────201 OK──────────────│ │ + │ │ │ +``` + +## Components + +### 1. OAuth Authorization Server (AppView) + +**File**: `pkg/auth/oauth/server.go` + +**Endpoints**: + +#### `GET /auth/oauth/authorize` + +Initiates OAuth flow for a user. + +**Query Parameters**: +- `handle` (required): User's ATProto handle (e.g., `evan.jarrett.net`) + +**Flow**: +1. Resolve handle → DID → PDS endpoint +2. Discover PDS OAuth metadata +3. Generate state + PKCE verifier +4. Create PAR request to PDS +5. Redirect user to PDS authorization endpoint + +**Response**: `302 Redirect` to PDS authorization page + +#### `GET /auth/oauth/callback` + +Receives OAuth callback from PDS. + +**Query Parameters**: +- `code`: Authorization code +- `state`: State for CSRF protection + +**Flow**: +1. Validate state +2. Exchange code for tokens (POST to PDS token endpoint) +3. Use AppView's DPoP key for the exchange +4. Store refresh token + DPoP key for user's DID +5. Generate AppView session token +6. Redirect to success page with session token + +**Response**: HTML page with session token (user copies to credential helper) + +### 2. Refresh Token Storage + +**File**: `pkg/auth/oauth/storage.go` + +**Storage Format**: + +```json +{ + "refresh_tokens": { + "did:plc:abc123": { + "refresh_token": "...", + "dpop_key_pem": "-----BEGIN EC PRIVATE KEY-----\n...", + "pds_endpoint": "https://bsky.social", + "handle": "evan.jarrett.net", + "created_at": "2025-10-04T...", + "last_refreshed": "2025-10-04T..." + } + } +} +``` + +**Location**: +- Development: `~/.atcr/appview-tokens.json` +- Production: Encrypted database or secret manager + +**Security**: +- File permissions: `0600` (owner read/write only) +- Consider encrypting DPoP keys at rest +- Rotate refresh tokens periodically + +### 3. Token Refresher + +**File**: `pkg/auth/oauth/refresher.go` + +**Interface**: + +```go +type Refresher interface { + // GetAccessToken gets a fresh access token for a DID + // Returns cached token if still valid, otherwise refreshes + GetAccessToken(ctx context.Context, did string) (token string, dpopKey *ecdsa.PrivateKey, err error) + + // RefreshToken forces a token refresh + RefreshToken(ctx context.Context, did string) error + + // RevokeToken removes stored refresh token + RevokeToken(did string) error +} +``` + +**Caching Strategy**: +- Access tokens cached for 14 minutes (expire at 15min) +- Refresh tokens stored persistently +- Cache key: `did → {access_token, dpop_key, expires_at}` + +### 4. Session Management + +**File**: `pkg/auth/session/handler.go` + +**Session Token Format**: +``` +Base64(JSON({ + "did": "did:plc:abc123", + "handle": "evan.jarrett.net", + "issued_at": "2025-10-04T...", + "expires_at": "2025-11-03T..." // 30 days +})).HMAC-SHA256(secret) +``` + +**Storage**: Stateless (validated by HMAC signature) + +**Endpoints**: + +#### `GET /auth/session/validate` + +Validates a session token. + +**Headers**: +- `Authorization: Bearer ` + +**Response**: +```json +{ + "did": "did:plc:abc123", + "handle": "evan.jarrett.net", + "valid": true +} +``` + +### 5. Updated Exchange Handler + +**File**: `pkg/auth/exchange/handler.go` + +**Changes**: +- Accept session token instead of OAuth token +- Validate session token → extract DID +- Issue registry JWT with DID +- Remove PDS token validation + +**Request**: +``` +POST /auth/exchange +Authorization: Bearer + +{ + "scope": ["repository:*:pull,push"] +} +``` + +**Response**: +```json +{ + "token": "", + "expires_in": 900 +} +``` + +### 6. Credential Helper Updates + +**File**: `cmd/credential-helper/main.go` + +**Changes**: + +1. **Configure command**: + - Open browser to AppView: `http://127.0.0.1:5000/auth/oauth/authorize?handle=...` + - User authorizes on PDS + - AppView displays session token + - User copies session token to helper + - Helper stores session token + +2. **Get command**: + - Load session token from `~/.atcr/session.json` + - Call `/auth/exchange` with session token + - Return registry JWT to Docker + +3. **Storage format**: +```json +{ + "session_token": "...", + "handle": "evan.jarrett.net", + "appview_url": "http://127.0.0.1:5000" +} +``` + +**Removed**: +- DPoP key generation +- OAuth client logic +- Refresh token handling + +## Security Considerations + +### AppView as Trusted Component + +The AppView becomes a **trusted intermediary** that: +- Stores refresh tokens for users +- Acts on users' behalf to write manifests +- Issues registry authentication tokens + +**Trust model**: +- Users must trust the AppView operator +- Similar to trusting a Docker registry operator +- AppView has write access to manifests (not profile data) + +### Scope Limitations + +AppView OAuth tokens are requested with minimal scopes: +- `atproto` - Basic ATProto operations +- Only needs: `com.atproto.repo.putRecord`, `com.atproto.repo.getRecord` +- Does NOT need: profile updates, social graph access, etc. + +### Token Security + +**Refresh Tokens**: +- Stored encrypted at rest +- File permissions: 0600 +- Rotated periodically (when used) +- Can be revoked by user on PDS + +**Session Tokens**: +- 30-day expiry +- HMAC-signed (stateless validation) +- Can be revoked by clearing storage + +**Access Tokens**: +- Cached in-memory only +- 15-minute expiry +- Never stored persistently + +### Audit Trail + +AppView should log: +- OAuth authorizations (DID, timestamp) +- Token refreshes (DID, timestamp) +- Manifest writes (DID, repository, timestamp) + +## Migration from Current OAuth + +Users currently using `docker-credential-atcr` with direct PDS OAuth will need to: + +1. Run `docker-credential-atcr configure` again +2. Authorize AppView (new OAuth flow) +3. Old PDS tokens are no longer used + +## Alternative: Bring Your Own AppView + +Users who don't trust a shared AppView can: +1. Run their own ATCR AppView instance +2. Configure credential helper to point at their AppView +3. Their AppView stores their refresh tokens locally + +## Future Enhancements + +### Multi-AppView Support + +Allow users to configure multiple AppViews: +```json +{ + "appviews": { + "default": "https://atcr.io", + "personal": "http://localhost:5000" + }, + "sessions": { + "https://atcr.io": {"session_token": "...", "handle": "..."}, + "http://localhost:5000": {"session_token": "...", "handle": "..."} + } +} +``` + +### Refresh Token Rotation + +Implement automatic refresh token rotation per OAuth best practices: +- PDS issues new refresh token with each use +- AppView updates stored token +- Old refresh token invalidated + +### Revocation UI + +Add web UI for users to: +- View active sessions +- Revoke AppView access +- See audit log of manifest writes + +## References + +- [ATProto OAuth Specification](https://atproto.com/specs/oauth) +- [RFC 6749: OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc6749) +- [RFC 9449: DPoP](https://datatracker.ietf.org/doc/html/rfc9449) +- [Docker Credential Helpers](https://github.com/docker/docker-credential-helpers) diff --git a/pkg/auth/atproto/validator.go b/pkg/auth/atproto/validator.go index 686bd73..92f56c6 100644 --- a/pkg/auth/atproto/validator.go +++ b/pkg/auth/atproto/validator.go @@ -33,7 +33,8 @@ type SessionInfo struct { // ValidateToken validates an ATProto OAuth access token by calling getSession // Returns the user's DID and handle if the token is valid -func (v *TokenValidator) ValidateToken(ctx context.Context, pdsEndpoint, accessToken string) (*SessionInfo, error) { +// dpopProof is optional - if provided, uses DPoP auth; otherwise uses Bearer +func (v *TokenValidator) ValidateToken(ctx context.Context, pdsEndpoint, accessToken, dpopProof string) (*SessionInfo, error) { // Call com.atproto.server.getSession with the access token url := fmt.Sprintf("%s/xrpc/com.atproto.server.getSession", pdsEndpoint) @@ -42,26 +43,35 @@ func (v *TokenValidator) ValidateToken(ctx context.Context, pdsEndpoint, accessT return nil, fmt.Errorf("failed to create request: %w", err) } - // Add bearer token + // Always use Bearer auth for getSession validation + // The DPoP proof from the client is bound to their request to us (POST /auth/exchange), + // not to our request to the PDS (GET /getSession) req.Header.Set("Authorization", "Bearer "+accessToken) + fmt.Printf("DEBUG [validator]: calling %s with Bearer auth, token_prefix=%s...\n", + url, accessToken[:min(20, len(accessToken))]) + resp, err := v.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to get session: %w", err) } defer resp.Body.Close() + // Read body once for both logging and error handling + bodyBytes, _ := io.ReadAll(resp.Body) + if resp.StatusCode == http.StatusUnauthorized { + fmt.Printf("DEBUG [validator]: getSession returned 401: %s\n", string(bodyBytes)) return nil, fmt.Errorf("invalid or expired token") } if resp.StatusCode != http.StatusOK { - bodyBytes, _ := io.ReadAll(resp.Body) + fmt.Printf("DEBUG [validator]: getSession failed with status %d: %s\n", resp.StatusCode, string(bodyBytes)) return nil, fmt.Errorf("getSession failed with status %d: %s", resp.StatusCode, string(bodyBytes)) } var session SessionInfo - if err := json.NewDecoder(resp.Body).Decode(&session); err != nil { + if err := json.Unmarshal(bodyBytes, &session); err != nil { return nil, fmt.Errorf("failed to decode session: %w", err) } @@ -77,7 +87,8 @@ func (v *TokenValidator) ValidateToken(ctx context.Context, pdsEndpoint, accessT } // ValidateTokenWithResolver validates a token and automatically resolves the PDS endpoint -func (v *TokenValidator) ValidateTokenWithResolver(ctx context.Context, handle, accessToken string) (*SessionInfo, error) { +// dpopProof is optional - if provided, uses DPoP auth; otherwise uses Bearer +func (v *TokenValidator) ValidateTokenWithResolver(ctx context.Context, handle, accessToken, dpopProof string) (*SessionInfo, error) { // Resolve handle to PDS endpoint resolver := mainAtproto.NewResolver() _, pdsEndpoint, err := resolver.ResolveIdentity(ctx, handle) @@ -86,5 +97,5 @@ func (v *TokenValidator) ValidateTokenWithResolver(ctx context.Context, handle, } // Validate token against the PDS - return v.ValidateToken(ctx, pdsEndpoint, accessToken) + return v.ValidateToken(ctx, pdsEndpoint, accessToken, dpopProof) } diff --git a/pkg/auth/exchange/handler.go b/pkg/auth/exchange/handler.go index 62b050b..4dec5a7 100644 --- a/pkg/auth/exchange/handler.go +++ b/pkg/auth/exchange/handler.go @@ -4,34 +4,30 @@ import ( "encoding/json" "fmt" "net/http" + "strings" - mainAtproto "atcr.io/pkg/atproto" "atcr.io/pkg/auth" - "atcr.io/pkg/auth/atproto" + "atcr.io/pkg/auth/session" "atcr.io/pkg/auth/token" ) -// Handler handles /auth/exchange requests (OAuth token -> JWT token) +// Handler handles /auth/exchange requests (session token -> registry JWT) type Handler struct { - issuer *token.Issuer - validator *atproto.TokenValidator - defaultHoldEndpoint string + issuer *token.Issuer + sessionManager *session.Manager } // NewHandler creates a new exchange handler -func NewHandler(issuer *token.Issuer, defaultHoldEndpoint string) *Handler { +func NewHandler(issuer *token.Issuer, sessionManager *session.Manager) *Handler { return &Handler{ - issuer: issuer, - validator: atproto.NewTokenValidator(), - defaultHoldEndpoint: defaultHoldEndpoint, + issuer: issuer, + sessionManager: sessionManager, } } -// ExchangeRequest represents the request to exchange an OAuth token +// ExchangeRequest represents the request to exchange a session token for registry JWT type ExchangeRequest struct { - AccessToken string `json:"access_token"` // ATProto OAuth access token - Handle string `json:"handle"` // User's handle (required for PDS resolution) - Scope []string `json:"scope"` // Requested Docker scopes + Scope []string `json:"scope"` // Requested Docker scopes } // ExchangeResponse represents the response from /auth/exchange @@ -48,34 +44,38 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + // Extract session token from Authorization header + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + http.Error(w, "authorization header required", http.StatusUnauthorized) + return + } + + // Parse Bearer token + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + http.Error(w, "invalid authorization header format", http.StatusUnauthorized) + return + } + sessionToken := parts[1] + + // Validate session token + sessionClaims, err := h.sessionManager.Validate(sessionToken) + if err != nil { + fmt.Printf("DEBUG [exchange]: session validation failed: %v\n", err) + http.Error(w, fmt.Sprintf("invalid session token: %v", err), http.StatusUnauthorized) + return + } + + fmt.Printf("DEBUG [exchange]: session validated for DID=%s, handle=%s\n", sessionClaims.DID, sessionClaims.Handle) + + // Parse request body for scopes var req ExchangeRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest) return } - if req.AccessToken == "" { - http.Error(w, "access_token is required", http.StatusBadRequest) - return - } - - // Validate the ATProto OAuth token via the PDS - // We need the handle to resolve the PDS endpoint - if req.Handle == "" { - http.Error(w, "handle required to validate token", http.StatusBadRequest) - return - } - - session, err := h.validator.ValidateTokenWithResolver(r.Context(), req.Handle, req.AccessToken) - if err != nil { - http.Error(w, fmt.Sprintf("token validation failed: %v", err), http.StatusUnauthorized) - return - } - - // Use DID and handle from validated session - did := session.DID - handle := session.Handle - // Parse and validate scopes access, err := auth.ParseScope(req.Scope) if err != nil { @@ -84,31 +84,13 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // Validate access permissions - if err := auth.ValidateAccess(did, handle, access); err != nil { + if err := auth.ValidateAccess(sessionClaims.DID, sessionClaims.Handle, access); err != nil { http.Error(w, fmt.Sprintf("access denied: %v", err), http.StatusForbidden) return } - // Ensure user profile exists (creates with default hold if needed) - // Resolve PDS endpoint for profile management - resolver := mainAtproto.NewResolver() - _, pdsEndpoint, err := resolver.ResolveIdentity(r.Context(), handle) - if err != nil { - // Log error but don't fail auth - profile management is not critical - fmt.Printf("WARNING: failed to resolve PDS for profile management: %v\n", err) - } else { - // Create ATProto client with validated token - atprotoClient := mainAtproto.NewClient(pdsEndpoint, did, req.AccessToken) - - // Ensure profile exists (will create with default hold if not exists and default is configured) - if err := mainAtproto.EnsureProfile(r.Context(), atprotoClient, h.defaultHoldEndpoint); err != nil { - // Log error but don't fail auth - profile management is not critical - fmt.Printf("WARNING: failed to ensure profile for %s: %v\n", did, err) - } - } - - // Issue JWT token - tokenString, err := h.issuer.Issue(did, access) + // Issue registry JWT token + tokenString, err := h.issuer.Issue(sessionClaims.DID, access) if err != nil { http.Error(w, fmt.Sprintf("failed to issue token: %v", err), http.StatusInternalServerError) return diff --git a/pkg/auth/oauth/client.go b/pkg/auth/oauth/client.go index 204980e..7f82f91 100644 --- a/pkg/auth/oauth/client.go +++ b/pkg/auth/oauth/client.go @@ -58,8 +58,10 @@ func (c *Client) InitializeForHandle(ctx context.Context, handle string) error { c.metadata = metadata - // Configure OAuth2 client with default scope - // Can be overridden with SetScopes() before calling AuthorizeURL() + // Configure OAuth2 client + // Note: Both localhost and production need redirect_uri and scopes in the config + // For localhost: client_id contains these (query-based) AND they're sent as params + // For production: client_id is metadata URL, params come from config c.config = &oauth2.Config{ ClientID: c.clientID, Endpoint: oauth2.Endpoint{ @@ -115,6 +117,14 @@ func (c *Client) AuthorizeURL(state string) (authURL string, codeVerifier string // authorizeURLWithPAR uses Pushed Authorization Request func (c *Client) authorizeURLWithPAR(state, codeChallenge string) (string, error) { + fmt.Printf("DEBUG [oauth/client]: Starting PAR request\n") + fmt.Printf("DEBUG [oauth/client]: - client_id: %s\n", c.config.ClientID) + fmt.Printf("DEBUG [oauth/client]: - redirect_uri: %s\n", c.config.RedirectURL) + fmt.Printf("DEBUG [oauth/client]: - scope: %v\n", c.config.Scopes) + fmt.Printf("DEBUG [oauth/client]: - state: %s\n", state) + fmt.Printf("DEBUG [oauth/client]: - code_challenge_method: S256\n") + fmt.Printf("DEBUG [oauth/client]: - PAR endpoint: %s\n", c.config.Endpoint.PushedAuthURL) + // Create HTTP client with DPoP transport ctx := context.WithValue(context.Background(), oauth2.HTTPClient, &http.Client{ Transport: c.dpopTransport, @@ -126,9 +136,11 @@ func (c *Client) authorizeURLWithPAR(state, codeChallenge string) (string, error oauth2.SetAuthURLParam("code_challenge_method", "S256"), ) if err != nil { + fmt.Printf("ERROR [oauth/client]: PAR request failed: %v\n", err) return "", err } + fmt.Printf("DEBUG [oauth/client]: PAR successful, authURL: %s\n", authURL.String()) return authURL.String(), nil } diff --git a/pkg/auth/oauth/refresher.go b/pkg/auth/oauth/refresher.go new file mode 100644 index 0000000..62e2bc8 --- /dev/null +++ b/pkg/auth/oauth/refresher.go @@ -0,0 +1,167 @@ +package oauth + +import ( + "context" + "crypto/ecdsa" + "fmt" + "net/http" + "sync" + "time" + + "authelia.com/client/oauth2" +) + +// AccessTokenEntry represents a cached access token +type AccessTokenEntry struct { + Token string + DPoPKey *ecdsa.PrivateKey + ExpiresAt time.Time +} + +// Refresher manages OAuth token refresh for AppView +type Refresher struct { + storage *RefreshTokenStorage + accessTokens map[string]*AccessTokenEntry + mu sync.RWMutex + clientID string + redirectURI string +} + +// NewRefresher creates a new token refresher +func NewRefresher(storage *RefreshTokenStorage, clientID, redirectURI string) *Refresher { + return &Refresher{ + storage: storage, + accessTokens: make(map[string]*AccessTokenEntry), + clientID: clientID, + redirectURI: redirectURI, + } +} + +// GetAccessToken gets a fresh access token for a DID +// Returns cached token if still valid, otherwise refreshes +func (r *Refresher) GetAccessToken(ctx context.Context, did string) (string, *ecdsa.PrivateKey, error) { + // Check cache first + r.mu.RLock() + entry, ok := r.accessTokens[did] + r.mu.RUnlock() + + if ok && time.Now().Before(entry.ExpiresAt) { + // Token still valid + return entry.Token, entry.DPoPKey, nil + } + + // Token expired or not cached, refresh it + return r.RefreshToken(ctx, did) +} + +// RefreshToken forces a token refresh for a DID +func (r *Refresher) RefreshToken(ctx context.Context, did string) (string, *ecdsa.PrivateKey, error) { + // Get stored refresh token + entry, err := r.storage.Get(did) + if err != nil { + return "", nil, fmt.Errorf("failed to get stored refresh token: %w", err) + } + + // Parse DPoP key + dpopKey, err := r.storage.GetDPoPKey(did) + if err != nil { + return "", nil, fmt.Errorf("failed to get DPoP key: %w", err) + } + + // Create OAuth client with DPoP transport + dpopTransport := NewDPoPTransport(http.DefaultTransport, dpopKey) + httpClient := &http.Client{Transport: dpopTransport} + + // Discover PDS OAuth metadata + metadata, err := DiscoverAuthServer(ctx, entry.PDS) + if err != nil { + return "", nil, fmt.Errorf("failed to discover auth server: %w", err) + } + + // Configure OAuth2 client + config := &oauth2.Config{ + ClientID: r.clientID, + Endpoint: oauth2.Endpoint{ + AuthURL: metadata.AuthorizationEndpoint, + TokenURL: metadata.TokenEndpoint, + PushedAuthURL: metadata.PushedAuthorizationRequestEndpoint, + }, + RedirectURL: r.redirectURI, + Scopes: []string{"atproto"}, + } + + // Create context with custom HTTP client + ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient) + + // Exchange refresh token for new access token + token, err := config.TokenSource(ctxWithClient, &oauth2.Token{ + RefreshToken: entry.RefreshToken, + }).Token() + if err != nil { + return "", nil, fmt.Errorf("failed to refresh token: %w", err) + } + + // Update last refresh timestamp + if err := r.storage.UpdateLastRefresh(did); err != nil { + // Log but don't fail - this is not critical + fmt.Printf("WARNING: failed to update last refresh timestamp for %s: %v\n", did, err) + } + + // If a new refresh token was issued, update storage + if token.RefreshToken != "" && token.RefreshToken != entry.RefreshToken { + entry.RefreshToken = token.RefreshToken + if err := r.storage.Store(did, entry); err != nil { + // Log but don't fail - we have the access token + fmt.Printf("WARNING: failed to update refresh token for %s: %v\n", did, err) + } + } + + // Cache the access token + // Expire 1 minute early to avoid edge cases + expiresAt := token.Expiry.Add(-1 * time.Minute) + + r.mu.Lock() + r.accessTokens[did] = &AccessTokenEntry{ + Token: token.AccessToken, + DPoPKey: dpopKey, + ExpiresAt: expiresAt, + } + r.mu.Unlock() + + return token.AccessToken, dpopKey, nil +} + +// RevokeToken removes stored refresh token and cached access token +func (r *Refresher) RevokeToken(did string) error { + r.mu.Lock() + delete(r.accessTokens, did) + r.mu.Unlock() + + return r.storage.Delete(did) +} + +// CleanupExpiredTokens removes expired access tokens from cache +// Should be called periodically (e.g., every hour) +func (r *Refresher) CleanupExpiredTokens() { + r.mu.Lock() + defer r.mu.Unlock() + + now := time.Now() + for did, entry := range r.accessTokens { + if now.After(entry.ExpiresAt) { + delete(r.accessTokens, did) + } + } +} + +// StartCleanupRoutine starts a background goroutine to cleanup expired tokens +func (r *Refresher) StartCleanupRoutine(interval time.Duration) { + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for range ticker.C { + r.CleanupExpiredTokens() + } + }() +} diff --git a/pkg/auth/oauth/server.go b/pkg/auth/oauth/server.go new file mode 100644 index 0000000..a2c1365 --- /dev/null +++ b/pkg/auth/oauth/server.go @@ -0,0 +1,342 @@ +package oauth + +import ( + "context" + "crypto/ecdsa" + "crypto/rand" + "fmt" + "html/template" + "net/http" + "sync" + "time" + + "atcr.io/pkg/atproto" + "atcr.io/pkg/auth/session" + "authelia.com/client/oauth2" +) + +// Server handles OAuth authorization for the AppView +type Server struct { + storage *RefreshTokenStorage + sessionManager *session.Manager + resolver *atproto.Resolver + clientID string + redirectURI string + baseURL string + states map[string]*OAuthState + statesMu sync.RWMutex +} + +// OAuthState tracks an in-progress OAuth flow +type OAuthState struct { + State string + Handle string + DID string + PDSEndpoint string + CodeVerifier string + DPoPKey *ecdsa.PrivateKey + CreatedAt time.Time +} + +// NewServer creates a new OAuth server +func NewServer(storage *RefreshTokenStorage, sessionManager *session.Manager, baseURL string) *Server { + // Create client ID based on AppView's base URL + cfg := ClientIDConfig{ + BaseURL: baseURL, + CallbackPath: "/auth/oauth/callback", + Scopes: []string{"atproto"}, + } + clientID, redirectURI := cfg.MakeClientID() + + return &Server{ + storage: storage, + sessionManager: sessionManager, + resolver: atproto.NewResolver(), + clientID: clientID, + redirectURI: redirectURI, + baseURL: baseURL, + states: make(map[string]*OAuthState), + } +} + +// 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 + } + + fmt.Printf("DEBUG [oauth/server]: Starting OAuth flow for handle=%s\n", handle) + + // Resolve handle to DID and PDS + did, pdsEndpoint, err := s.resolver.ResolveIdentity(r.Context(), handle) + if err != nil { + fmt.Printf("ERROR [oauth/server]: Failed to resolve handle: %v\n", err) + http.Error(w, fmt.Sprintf("failed to resolve handle: %v", err), http.StatusBadRequest) + return + } + + fmt.Printf("DEBUG [oauth/server]: Resolved handle=%s -> did=%s, pds=%s\n", handle, did, pdsEndpoint) + + // Create OAuth client + fmt.Printf("DEBUG [oauth/server]: Creating OAuth client with clientID=%s, redirectURI=%s\n", s.clientID, s.redirectURI) + client, err := NewClient(s.clientID, s.redirectURI) + if err != nil { + fmt.Printf("ERROR [oauth/server]: Failed to create OAuth client: %v\n", err) + http.Error(w, fmt.Sprintf("failed to create OAuth client: %v", err), http.StatusInternalServerError) + return + } + + // Initialize for the handle's PDS + fmt.Printf("DEBUG [oauth/server]: Initializing OAuth client for handle=%s\n", handle) + if err := client.InitializeForHandle(r.Context(), handle); err != nil { + fmt.Printf("ERROR [oauth/server]: Failed to initialize OAuth: %v\n", err) + http.Error(w, fmt.Sprintf("failed to initialize OAuth: %v", err), http.StatusInternalServerError) + return + } + + // Generate authorization URL + state := generateState() + fmt.Printf("DEBUG [oauth/server]: Generating authorization URL with state=%s\n", state) + authURL, codeVerifier, err := client.AuthorizeURL(state) + if err != nil { + fmt.Printf("ERROR [oauth/server]: Failed to generate auth URL: %v\n", err) + http.Error(w, fmt.Sprintf("failed to generate auth URL: %v", err), http.StatusInternalServerError) + return + } + + fmt.Printf("DEBUG [oauth/server]: Generated authURL=%s\n", authURL) + + // Store state for callback + s.statesMu.Lock() + s.states[state] = &OAuthState{ + State: state, + Handle: handle, + DID: did, + PDSEndpoint: pdsEndpoint, + CodeVerifier: codeVerifier, + DPoPKey: client.dpopKey, + CreatedAt: time.Now(), + } + s.statesMu.Unlock() + + // Redirect to PDS authorization page + 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 + } + + // Get code and state from query parameters + code := r.URL.Query().Get("code") + state := r.URL.Query().Get("state") + + if code == "" || state == "" { + s.renderError(w, "Missing code or state parameter") + return + } + + // Retrieve OAuth state + s.statesMu.Lock() + oauthState, ok := s.states[state] + delete(s.states, state) // Consume state + s.statesMu.Unlock() + + if !ok { + s.renderError(w, "Invalid or expired state") + return + } + + // Exchange code for tokens + sessionToken, err := s.exchangeCodeForSession(r.Context(), code, oauthState) + if err != nil { + s.renderError(w, fmt.Sprintf("Failed to exchange code: %v", err)) + return + } + + // Render success page with session token + s.renderSuccess(w, sessionToken, oauthState.Handle) +} + +// exchangeCodeForSession exchanges authorization code for tokens and creates session +func (s *Server) exchangeCodeForSession(ctx context.Context, code string, state *OAuthState) (string, error) { + // Discover OAuth metadata + metadata, err := DiscoverAuthServer(ctx, state.PDSEndpoint) + if err != nil { + return "", fmt.Errorf("failed to discover auth server: %w", err) + } + + // Create DPoP transport + dpopTransport := NewDPoPTransport(http.DefaultTransport, state.DPoPKey) + httpClient := &http.Client{Transport: dpopTransport} + + // Configure OAuth2 client + config := &oauth2.Config{ + ClientID: s.clientID, + Endpoint: oauth2.Endpoint{ + AuthURL: metadata.AuthorizationEndpoint, + TokenURL: metadata.TokenEndpoint, + PushedAuthURL: metadata.PushedAuthorizationRequestEndpoint, + }, + RedirectURL: s.redirectURI, + Scopes: []string{"atproto"}, + } + + // Create context with custom HTTP client + ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient) + + // Exchange code for token + token, err := config.Exchange(ctxWithClient, code, oauth2.VerifierOption(state.CodeVerifier)) + if err != nil { + return "", fmt.Errorf("failed to exchange code: %w", err) + } + + // Encode DPoP key to PEM + dpopKeyPEM, err := EncodeDPoPKey(state.DPoPKey) + if err != nil { + return "", fmt.Errorf("failed to encode DPoP key: %w", err) + } + + // Store refresh token + refreshEntry := &RefreshTokenEntry{ + RefreshToken: token.RefreshToken, + DPoPKeyPEM: dpopKeyPEM, + PDS: state.PDSEndpoint, + Handle: state.Handle, + CreatedAt: time.Now(), + LastRefresh: time.Now(), + } + + if err := s.storage.Store(state.DID, refreshEntry); err != nil { + return "", fmt.Errorf("failed to store refresh token: %w", err) + } + + // Create session token for credential helper + sessionToken, err := s.sessionManager.Create(state.DID, state.Handle) + if err != nil { + return "", fmt.Errorf("failed to create session token: %w", err) + } + + return sessionToken, nil +} + +// renderSuccess renders the success page +func (s *Server) renderSuccess(w http.ResponseWriter, sessionToken, handle string) { + tmpl := template.Must(template.New("success").Parse(successTemplate)) + data := struct { + SessionToken string + Handle string + }{ + SessionToken: sessionToken, + 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) + } +} + +// CleanupExpiredStates removes expired OAuth states +// Should be called periodically +func (s *Server) CleanupExpiredStates() { + s.statesMu.Lock() + defer s.statesMu.Unlock() + + now := time.Now() + for state, oauthState := range s.states { + // States expire after 10 minutes + if now.Sub(oauthState.CreatedAt) > 10*time.Minute { + delete(s.states, state) + } + } +} + +// generateState generates a random state parameter +func generateState() string { + b := make([]byte, 32) + rand.Read(b) + return fmt.Sprintf("%x", b) +} + +// HTML templates + +const successTemplate = ` + + + + Authorization Successful - ATCR + + + +
+

✓ Authorization Successful!

+

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

+

Copy the session token below and paste it into your credential helper:

+ {{.SessionToken}} + +
+ + + +` + +const errorTemplate = ` + + + + Authorization Failed - ATCR + + + +
+

✗ Authorization Failed

+

{{.Message}}

+

Return to home

+
+ + +` diff --git a/pkg/auth/oauth/tokenstorage.go b/pkg/auth/oauth/tokenstorage.go new file mode 100644 index 0000000..8f8b926 --- /dev/null +++ b/pkg/auth/oauth/tokenstorage.go @@ -0,0 +1,200 @@ +package oauth + +import ( + "crypto/ecdsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +// RefreshTokenEntry represents a stored refresh token for a user +type RefreshTokenEntry struct { + RefreshToken string `json:"refresh_token"` + DPoPKeyPEM string `json:"dpop_key_pem"` + PDS string `json:"pds_endpoint"` + Handle string `json:"handle"` + CreatedAt time.Time `json:"created_at"` + LastRefresh time.Time `json:"last_refreshed"` +} + +// RefreshTokenStorage manages persistent storage of refresh tokens +type RefreshTokenStorage struct { + path string + tokens map[string]*RefreshTokenEntry + mu sync.RWMutex +} + +// StorageData represents the JSON structure stored on disk +type StorageData struct { + RefreshTokens map[string]*RefreshTokenEntry `json:"refresh_tokens"` +} + +// NewRefreshTokenStorage creates a new refresh token storage +func NewRefreshTokenStorage(path string) (*RefreshTokenStorage, error) { + storage := &RefreshTokenStorage{ + path: path, + tokens: make(map[string]*RefreshTokenEntry), + } + + // Load existing tokens if file exists + if err := storage.load(); err != nil { + if !os.IsNotExist(err) { + return nil, fmt.Errorf("failed to load tokens: %w", err) + } + // File doesn't exist yet, that's ok + } + + return storage, nil +} + +// GetDefaultPath returns the default storage path +func GetDefaultPath() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + + atcrDir := filepath.Join(homeDir, ".atcr") + if err := os.MkdirAll(atcrDir, 0700); err != nil { + return "", fmt.Errorf("failed to create .atcr directory: %w", err) + } + + return filepath.Join(atcrDir, "appview-tokens.json"), nil +} + +// Store saves a refresh token for a DID +func (s *RefreshTokenStorage) Store(did string, entry *RefreshTokenEntry) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.tokens[did] = entry + return s.save() +} + +// Get retrieves a refresh token for a DID +func (s *RefreshTokenStorage) Get(did string) (*RefreshTokenEntry, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + entry, ok := s.tokens[did] + if !ok { + return nil, fmt.Errorf("no refresh token found for DID: %s", did) + } + + return entry, nil +} + +// Delete removes a refresh token for a DID +func (s *RefreshTokenStorage) Delete(did string) error { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.tokens, did) + return s.save() +} + +// List returns all stored DIDs +func (s *RefreshTokenStorage) List() []string { + s.mu.RLock() + defer s.mu.RUnlock() + + dids := make([]string, 0, len(s.tokens)) + for did := range s.tokens { + dids = append(dids, did) + } + return dids +} + +// GetDPoPKey retrieves and parses the DPoP private key for a DID +func (s *RefreshTokenStorage) GetDPoPKey(did string) (*ecdsa.PrivateKey, error) { + entry, err := s.Get(did) + if err != nil { + return nil, err + } + + // Parse PEM encoded private key + block, _ := pem.Decode([]byte(entry.DPoPKeyPEM)) + if block == nil { + return nil, fmt.Errorf("failed to parse PEM block") + } + + // Parse EC private key + key, err := x509.ParseECPrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse EC private key: %w", err) + } + + return key, nil +} + +// UpdateLastRefresh updates the last refresh timestamp for a DID +func (s *RefreshTokenStorage) UpdateLastRefresh(did string) error { + s.mu.Lock() + defer s.mu.Unlock() + + entry, ok := s.tokens[did] + if !ok { + return fmt.Errorf("no refresh token found for DID: %s", did) + } + + entry.LastRefresh = time.Now() + return s.save() +} + +// load reads tokens from disk +func (s *RefreshTokenStorage) load() error { + data, err := os.ReadFile(s.path) + if err != nil { + return err + } + + var storageData StorageData + if err := json.Unmarshal(data, &storageData); err != nil { + return fmt.Errorf("failed to parse token storage: %w", err) + } + + if storageData.RefreshTokens != nil { + s.tokens = storageData.RefreshTokens + } + + return nil +} + +// save writes tokens to disk +func (s *RefreshTokenStorage) save() error { + storageData := StorageData{ + RefreshTokens: s.tokens, + } + + data, err := json.MarshalIndent(storageData, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal tokens: %w", err) + } + + // Write with restrictive permissions + if err := os.WriteFile(s.path, data, 0600); err != nil { + return fmt.Errorf("failed to write tokens: %w", err) + } + + return nil +} + +// EncodeDPoPKey encodes an ECDSA private key to PEM format +func EncodeDPoPKey(key *ecdsa.PrivateKey) (string, error) { + keyBytes, err := x509.MarshalECPrivateKey(key) + if err != nil { + return "", fmt.Errorf("failed to marshal private key: %w", err) + } + + block := &pem.Block{ + Type: "EC PRIVATE KEY", + Bytes: keyBytes, + } + + return string(pem.EncodeToMemory(block)), nil +} diff --git a/pkg/auth/scope.go b/pkg/auth/scope.go index ef839ee..faaff55 100644 --- a/pkg/auth/scope.go +++ b/pkg/auth/scope.go @@ -57,6 +57,13 @@ func ValidateAccess(userDID, userHandle string, access []AccessEntry) error { continue } + // Allow wildcard scope (e.g., "repository:*:pull,push") + // This is used by Docker credential helpers to request broad permissions + // Actual authorization happens later when accessing specific repositories + if entry.Name == "*" { + continue + } + // Extract the owner from repository name (e.g., "alice/myapp" -> "alice") parts := strings.SplitN(entry.Name, "/", 2) if len(parts) < 1 { diff --git a/pkg/auth/session/handler.go b/pkg/auth/session/handler.go new file mode 100644 index 0000000..de07b8f --- /dev/null +++ b/pkg/auth/session/handler.go @@ -0,0 +1,170 @@ +package session + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "strings" + "time" +) + +// SessionClaims represents the data stored in a session token +type SessionClaims struct { + DID string `json:"did"` + Handle string `json:"handle"` + IssuedAt time.Time `json:"issued_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +// Manager handles session token creation and validation +type Manager struct { + secret []byte + ttl time.Duration +} + +// NewManager creates a new session manager +func NewManager(secret []byte, ttl time.Duration) *Manager { + return &Manager{ + secret: secret, + ttl: ttl, + } +} + +// NewManagerWithRandomSecret creates a session manager with a random secret +func NewManagerWithRandomSecret(ttl time.Duration) (*Manager, error) { + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return nil, fmt.Errorf("failed to generate secret: %w", err) + } + return NewManager(secret, ttl), nil +} + +// NewManagerWithPersistentSecret creates a session manager with a persistent secret +// The secret is stored at secretPath and reused across restarts +func NewManagerWithPersistentSecret(secretPath string, ttl time.Duration) (*Manager, error) { + var secret []byte + + // Try to load existing secret + if data, err := os.ReadFile(secretPath); err == nil { + secret = data + fmt.Printf("Loaded existing session secret from %s\n", secretPath) + } else if os.IsNotExist(err) { + // Generate new secret + secret = make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return nil, fmt.Errorf("failed to generate secret: %w", err) + } + + // Save secret for future restarts + if err := os.WriteFile(secretPath, secret, 0600); err != nil { + return nil, fmt.Errorf("failed to save secret: %w", err) + } + fmt.Printf("Generated and saved new session secret to %s\n", secretPath) + } else { + return nil, fmt.Errorf("failed to read secret file: %w", err) + } + + return NewManager(secret, ttl), nil +} + +// Create generates a new session token for a DID +func (m *Manager) Create(did, handle string) (string, error) { + now := time.Now() + claims := SessionClaims{ + DID: did, + Handle: handle, + IssuedAt: now, + ExpiresAt: now.Add(m.ttl), + } + + // Marshal claims to JSON + claimsJSON, err := json.Marshal(claims) + if err != nil { + return "", fmt.Errorf("failed to marshal claims: %w", err) + } + + // Base64 encode claims + claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON) + + // Generate HMAC signature + sig := m.sign(claimsB64) + sigB64 := base64.RawURLEncoding.EncodeToString(sig) + + // Token format: . + token := claimsB64 + "." + sigB64 + + return token, nil +} + +// Validate validates a session token and returns the claims +func (m *Manager) Validate(token string) (*SessionClaims, error) { + // Split token into claims and signature + parts := strings.Split(token, ".") + if len(parts) != 2 { + return nil, fmt.Errorf("invalid token format") + } + + claimsB64 := parts[0] + sigB64 := parts[1] + + // Verify signature + expectedSig := m.sign(claimsB64) + providedSig, err := base64.RawURLEncoding.DecodeString(sigB64) + if err != nil { + return nil, fmt.Errorf("invalid signature encoding: %w", err) + } + + if !hmac.Equal(expectedSig, providedSig) { + return nil, fmt.Errorf("invalid signature") + } + + // Decode claims + claimsJSON, err := base64.RawURLEncoding.DecodeString(claimsB64) + if err != nil { + return nil, fmt.Errorf("invalid claims encoding: %w", err) + } + + var claims SessionClaims + if err := json.Unmarshal(claimsJSON, &claims); err != nil { + return nil, fmt.Errorf("invalid claims format: %w", err) + } + + // Check expiration + if time.Now().After(claims.ExpiresAt) { + return nil, fmt.Errorf("token expired") + } + + return &claims, nil +} + +// sign generates HMAC-SHA256 signature for data +func (m *Manager) sign(data string) []byte { + h := hmac.New(sha256.New, m.secret) + h.Write([]byte(data)) + return h.Sum(nil) +} + +// GetDID extracts the DID from a token without full validation +// Useful for logging/debugging +func (m *Manager) GetDID(token string) (string, error) { + parts := strings.Split(token, ".") + if len(parts) != 2 { + return "", fmt.Errorf("invalid token format") + } + + claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return "", fmt.Errorf("invalid claims encoding: %w", err) + } + + var claims SessionClaims + if err := json.Unmarshal(claimsJSON, &claims); err != nil { + return "", fmt.Errorf("invalid claims format: %w", err) + } + + return claims.DID, nil +} diff --git a/pkg/auth/token/handler.go b/pkg/auth/token/handler.go index 59ebfbb..793eadd 100644 --- a/pkg/auth/token/handler.go +++ b/pkg/auth/token/handler.go @@ -10,20 +10,23 @@ import ( mainAtproto "atcr.io/pkg/atproto" "atcr.io/pkg/auth" "atcr.io/pkg/auth/atproto" + "atcr.io/pkg/auth/session" ) // Handler handles /auth/token requests type Handler struct { issuer *Issuer validator *atproto.SessionValidator + sessionManager *session.Manager // For validating session tokens defaultHoldEndpoint string } // NewHandler creates a new token handler -func NewHandler(issuer *Issuer, defaultHoldEndpoint string) *Handler { +func NewHandler(issuer *Issuer, sessionManager *session.Manager, defaultHoldEndpoint string) *Handler { return &Handler{ issuer: issuer, validator: atproto.NewSessionValidator(), + sessionManager: sessionManager, defaultHoldEndpoint: defaultHoldEndpoint, } } @@ -73,43 +76,60 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - // Validate credentials against ATProto and get access token - fmt.Printf("DEBUG [token/handler]: Validating credentials for %s\n", username) - did, _, accessToken, err := h.validator.CreateSessionAndGetToken(r.Context(), username, password) - if err != nil { - fmt.Printf("DEBUG [token/handler]: Credential validation failed: %v\n", err) - w.Header().Set("WWW-Authenticate", `Basic realm="ATCR Registry"`) - http.Error(w, fmt.Sprintf("authentication failed: %v", err), http.StatusUnauthorized) - return - } + var did string + var handle string + var accessToken string - fmt.Printf("DEBUG [token/handler]: Credentials validated successfully, DID=%s, AccessToken length=%d\n", did, len(accessToken)) - - // Cache the access token for later use (e.g., when pushing manifests) - // TTL of 2 hours (ATProto tokens typically last longer) - auth.GetGlobalTokenCache().Set(did, accessToken, 2*time.Hour) - fmt.Printf("DEBUG [token/handler]: Cached access token for DID=%s\n", did) - - // Ensure user profile exists (creates with default hold if needed) - // Resolve PDS endpoint for profile management - resolver := mainAtproto.NewResolver() - _, pdsEndpoint, err := resolver.ResolveIdentity(r.Context(), username) - if err != nil { - // Log error but don't fail auth - profile management is not critical - fmt.Printf("WARNING: failed to resolve PDS for profile management: %v\n", err) + // Try to validate as session token first (our OAuth flow) + // Session tokens have format: . + sessionClaims, sessionErr := h.sessionManager.Validate(password) + if sessionErr == nil { + // Successfully validated as session token + did = sessionClaims.DID + handle = sessionClaims.Handle + fmt.Printf("DEBUG [token/handler]: Session token validated for DID=%s, handle=%s\n", did, handle) + // For session tokens, we don't have a PDS access token here + // The registry will use OAuth refresh tokens to get one when needed } else { - // Create ATProto client with validated token - atprotoClient := mainAtproto.NewClient(pdsEndpoint, did, accessToken) + // Not a session token, try app password (Basic Auth flow) + fmt.Printf("DEBUG [token/handler]: Not a session token, trying app password for %s\n", username) + did, handle, accessToken, err = h.validator.CreateSessionAndGetToken(r.Context(), username, password) + if err != nil { + fmt.Printf("DEBUG [token/handler]: App password validation failed: %v\n", err) + w.Header().Set("WWW-Authenticate", `Basic realm="ATCR Registry"`) + http.Error(w, fmt.Sprintf("authentication failed: %v", err), http.StatusUnauthorized) + return + } - // Ensure profile exists (will create with default hold if not exists and default is configured) - if err := mainAtproto.EnsureProfile(r.Context(), atprotoClient, h.defaultHoldEndpoint); err != nil { + fmt.Printf("DEBUG [token/handler]: App password validated successfully, DID=%s, handle=%s, AccessToken length=%d\n", did, handle, len(accessToken)) + + // Cache the access token for later use (e.g., when pushing manifests) + // TTL of 2 hours (ATProto tokens typically last longer) + auth.GetGlobalTokenCache().Set(did, accessToken, 2*time.Hour) + fmt.Printf("DEBUG [token/handler]: Cached access token for DID=%s\n", did) + + // Ensure user profile exists (creates with default hold if needed) + // Resolve PDS endpoint for profile management + resolver := mainAtproto.NewResolver() + _, pdsEndpoint, err := resolver.ResolveIdentity(r.Context(), username) + if err != nil { // Log error but don't fail auth - profile management is not critical - fmt.Printf("WARNING: failed to ensure profile for %s: %v\n", did, err) + fmt.Printf("WARNING: failed to resolve PDS for profile management: %v\n", err) + } else { + // Create ATProto client with validated token + atprotoClient := mainAtproto.NewClient(pdsEndpoint, did, accessToken) + + // Ensure profile exists (will create with default hold if not exists and default is configured) + if err := mainAtproto.EnsureProfile(r.Context(), atprotoClient, h.defaultHoldEndpoint); err != nil { + // Log error but don't fail auth - profile management is not critical + fmt.Printf("WARNING: failed to ensure profile for %s: %v\n", did, err) + } } } // Validate that the user has permission for the requested access - if err := auth.ValidateAccess(did, username, access); err != nil { + // Use the actual handle from the validated credentials, not the Basic Auth username + if err := auth.ValidateAccess(did, handle, access); err != nil { fmt.Printf("DEBUG [token/handler]: Access validation failed: %v\n", err) http.Error(w, fmt.Sprintf("access denied: %v", err), http.StatusForbidden) return diff --git a/pkg/middleware/registry.go b/pkg/middleware/registry.go index 90d1811..8650858 100644 --- a/pkg/middleware/registry.go +++ b/pkg/middleware/registry.go @@ -13,9 +13,18 @@ import ( "atcr.io/pkg/atproto" "atcr.io/pkg/auth" + "atcr.io/pkg/auth/oauth" "atcr.io/pkg/storage" ) +// Global refresher instance (set by main.go) +var globalRefresher *oauth.Refresher + +// SetGlobalRefresher sets the global OAuth refresher instance +func SetGlobalRefresher(refresher *oauth.Refresher) { + globalRefresher = refresher +} + func init() { // Register the name resolution middleware registrymw.Register("atproto-resolver", initATProtoResolver) @@ -99,18 +108,35 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name return nil, err } - // Wrap the repository with our routing repository - // Get the cached access token for this DID - accessToken, ok := auth.GetGlobalTokenCache().Get(did) - if !ok { - fmt.Printf("DEBUG [registry/middleware]: No cached access token found for DID=%s\n", did) - accessToken = "" // Will fail on manifest push, but let it try - } else { - fmt.Printf("DEBUG [registry/middleware]: Using cached access token for DID=%s (length=%d)\n", did, len(accessToken)) + // Get access token for PDS operations + // Try OAuth refresher first (for users who authorized via AppView OAuth) + // Fall back to Basic Auth token cache (for users who used app passwords) + var atprotoClient *atproto.Client + + if globalRefresher != nil { + // Try OAuth flow first + accessToken, dpopKey, err := globalRefresher.GetAccessToken(ctx, did) + if err == nil { + // OAuth token available - create client with DPoP support + fmt.Printf("DEBUG [registry/middleware]: Using OAuth access token for DID=%s\n", did) + dpopTransport := oauth.NewDPoPTransport(nil, dpopKey) + atprotoClient = atproto.NewClientWithDPoP(pdsEndpoint, did, accessToken, dpopKey, dpopTransport) + } else { + fmt.Printf("DEBUG [registry/middleware]: OAuth refresh failed for DID=%s: %v, falling back to Basic Auth\n", did, err) + } } - // This is where we inject ATProto + storage routing - atprotoClient := atproto.NewClient(pdsEndpoint, did, accessToken) + // Fall back to Basic Auth token cache if OAuth not available + if atprotoClient == nil { + accessToken, ok := auth.GetGlobalTokenCache().Get(did) + if !ok { + fmt.Printf("DEBUG [registry/middleware]: No cached access token found for DID=%s (neither OAuth nor Basic Auth)\n", did) + accessToken = "" // Will fail on manifest push, but let it try + } else { + fmt.Printf("DEBUG [registry/middleware]: Using Basic Auth access token for DID=%s (length=%d)\n", did, len(accessToken)) + } + atprotoClient = atproto.NewClient(pdsEndpoint, did, accessToken) + } // IMPORTANT: Use only the image name (not identity/image) for ATProto storage // ATProto records are scoped to the user's DID, so we don't need the identity prefix