A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

cleanup oauth

+1702 -294
+99 -189
cmd/credential-helper/main.go
··· 2 2 3 3 import ( 4 4 "bytes" 5 - "context" 6 5 "encoding/json" 7 6 "fmt" 8 7 "io" 9 8 "net/http" 10 9 "os" 11 10 "path/filepath" 12 - "strings" 13 - "time" 14 11 15 - "atcr.io/pkg/atproto" 16 12 "atcr.io/pkg/auth/oauth" 17 13 ) 18 14 19 15 const ( 20 - callbackPort = "8888" 21 - baseURL = "http://127.0.0.1:" + callbackPort 22 - callbackPath = "/callback" 16 + // Default AppView URL - can be overridden via environment variable 17 + defaultAppViewURL = "http://127.0.0.1:5000" 23 18 ) 24 19 25 - var ( 26 - clientID string 27 - redirectURI string 28 - ) 29 - 30 - func init() { 31 - // Use shared helper to create localhost client ID 32 - cfg := oauth.ClientIDConfig{ 33 - BaseURL: baseURL, 34 - CallbackPath: callbackPath, 35 - Scopes: []string{"atproto"}, 36 - } 37 - clientID, redirectURI = cfg.MakeClientID() 20 + // SessionStore represents the stored session token 21 + type SessionStore struct { 22 + SessionToken string `json:"session_token"` 23 + Handle string `json:"handle"` 24 + AppViewURL string `json:"appview_url"` 38 25 } 39 26 40 27 // Docker credential helper protocol ··· 84 71 os.Exit(1) 85 72 } 86 73 87 - // Load token from storage 88 - tokenPath := getTokenPath() 89 - token, err := oauth.LoadTokenStore(tokenPath) 90 - if err != nil { 91 - fmt.Fprintf(os.Stderr, "Error loading token: %v\n", err) 92 - os.Exit(1) 93 - } 94 - 95 - // Check if token is expired and refresh if needed 96 - if token.IsExpired() && token.RefreshToken != "" { 97 - // Create OAuth client 98 - client, err := oauth.NewClient(clientID, redirectURI) 99 - if err != nil { 100 - fmt.Fprintf(os.Stderr, "Error creating OAuth client: %v\n", err) 101 - os.Exit(1) 102 - } 103 - 104 - // Load DPoP key 105 - dpopKey, err := token.GetDPoPKey() 106 - if err != nil { 107 - fmt.Fprintf(os.Stderr, "Error loading DPoP key: %v\n", err) 108 - os.Exit(1) 109 - } 110 - client.SetDPoPKey(dpopKey) 111 - 112 - // Initialize for the handle 113 - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 114 - defer cancel() 115 - 116 - if err := client.InitializeForHandle(ctx, token.Handle); err != nil { 117 - fmt.Fprintf(os.Stderr, "Error initializing OAuth client: %v\n", err) 118 - os.Exit(1) 119 - } 120 - 121 - // Refresh the token 122 - newToken, err := client.RefreshToken(ctx, token.RefreshToken) 123 - if err != nil { 124 - fmt.Fprintf(os.Stderr, "Error refreshing token: %v\n", err) 125 - os.Exit(1) 126 - } 127 - 128 - // Update token store 129 - token.AccessToken = newToken.AccessToken 130 - token.RefreshToken = newToken.RefreshToken 131 - token.ExpiresAt = newToken.Expiry 132 - 133 - // Save updated token 134 - if err := token.Save(tokenPath); err != nil { 135 - fmt.Fprintf(os.Stderr, "Error saving token: %v\n", err) 136 - os.Exit(1) 137 - } 138 - } 139 - 140 - // Exchange ATProto token for registry JWT 141 - fmt.Fprintf(os.Stderr, "[DEBUG] Exchanging token for %s, handle=%s, token_expired=%v\n", serverURL, token.Handle, token.IsExpired()) 142 - registryJWT, err := exchangeForRegistryToken(token.AccessToken, serverURL, token.Handle) 74 + // Load session from storage 75 + sessionPath := getSessionPath() 76 + session, err := loadSession(sessionPath) 143 77 if err != nil { 144 - fmt.Fprintf(os.Stderr, "Error exchanging token: %v\n", err) 78 + fmt.Fprintf(os.Stderr, "Error loading session: %v\n", err) 79 + fmt.Fprintf(os.Stderr, "Please run: docker-credential-atcr configure\n") 145 80 os.Exit(1) 146 81 } 147 82 148 - // Return credentials 83 + // Return session token as credentials 84 + // Docker will call /auth/token with this, and the token handler 85 + // will validate the session token and issue a registry JWT 149 86 creds := Credentials{ 150 87 ServerURL: serverURL, 151 - Username: "oauth2", 152 - Secret: registryJWT, 88 + Username: "oauth2", // Signals token-based auth to Docker 89 + Secret: session.SessionToken, // Return session token directly 153 90 } 154 91 155 92 if err := json.NewEncoder(os.Stdout).Encode(creds); err != nil { ··· 180 117 os.Exit(1) 181 118 } 182 119 183 - // Remove token file 184 - tokenPath := getTokenPath() 185 - if err := os.Remove(tokenPath); err != nil && !os.IsNotExist(err) { 186 - fmt.Fprintf(os.Stderr, "Error removing token: %v\n", err) 120 + // Remove session file 121 + sessionPath := getSessionPath() 122 + if err := os.Remove(sessionPath); err != nil && !os.IsNotExist(err) { 123 + fmt.Fprintf(os.Stderr, "Error removing session: %v\n", err) 187 124 os.Exit(1) 188 125 } 189 126 } ··· 194 131 fmt.Println("=====================================") 195 132 fmt.Println() 196 133 134 + // Get AppView URL from environment or use default 135 + appViewURL := os.Getenv("ATCR_APPVIEW_URL") 136 + if appViewURL == "" { 137 + appViewURL = defaultAppViewURL 138 + } 139 + fmt.Printf("AppView URL: %s\n\n", appViewURL) 140 + 197 141 // Ask for handle if not provided as argument 198 142 if handle == "" { 199 143 fmt.Print("Enter your ATProto handle (e.g., alice.bsky.social): ") ··· 205 149 fmt.Printf("Using handle: %s\n", handle) 206 150 } 207 151 208 - // Run OAuth flow 209 - fmt.Println("\nStarting OAuth flow...") 210 - token, err := runOAuthFlow(handle) 211 - if err != nil { 212 - fmt.Fprintf(os.Stderr, "Error during OAuth flow: %v\n", err) 152 + // Open browser to AppView OAuth authorization 153 + authURL := fmt.Sprintf("%s/auth/oauth/authorize?handle=%s", appViewURL, handle) 154 + fmt.Printf("\nOpening browser to: %s\n", authURL) 155 + fmt.Println("Please complete the authorization in your browser.") 156 + fmt.Println("After authorization, you will receive a session token.") 157 + fmt.Println() 158 + 159 + if err := oauth.OpenBrowser(authURL); err != nil { 160 + fmt.Printf("Failed to open browser automatically.\nPlease open this URL manually:\n%s\n\n", authURL) 161 + } 162 + 163 + // Prompt user to paste session token 164 + fmt.Print("Enter the session token from the browser: ") 165 + var sessionToken string 166 + if _, err := fmt.Scanln(&sessionToken); err != nil { 167 + fmt.Fprintf(os.Stderr, "Error reading session token: %v\n", err) 213 168 os.Exit(1) 214 169 } 215 170 216 - // Save token 217 - tokenPath := getTokenPath() 218 - if err := token.Save(tokenPath); err != nil { 219 - fmt.Fprintf(os.Stderr, "Error saving token: %v\n", err) 171 + // Create session store 172 + session := &SessionStore{ 173 + SessionToken: sessionToken, 174 + Handle: handle, 175 + AppViewURL: appViewURL, 176 + } 177 + 178 + // Save session 179 + sessionPath := getSessionPath() 180 + if err := saveSession(sessionPath, session); err != nil { 181 + fmt.Fprintf(os.Stderr, "Error saving session: %v\n", err) 220 182 os.Exit(1) 221 183 } 222 184 223 - fmt.Println("\nConfiguration complete!") 185 + fmt.Println("\n✓ Configuration complete!") 224 186 fmt.Println("You can now use docker push/pull with atcr.io") 225 187 } 226 188 227 - // getTokenPath returns the path to the token file 228 - func getTokenPath() string { 189 + // getSessionPath returns the path to the session file 190 + func getSessionPath() string { 229 191 homeDir, err := os.UserHomeDir() 230 192 if err != nil { 231 193 fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err) 232 194 os.Exit(1) 233 195 } 234 196 235 - return filepath.Join(homeDir, ".atcr", "oauth-token.json") 197 + atcrDir := filepath.Join(homeDir, ".atcr") 198 + if err := os.MkdirAll(atcrDir, 0700); err != nil { 199 + fmt.Fprintf(os.Stderr, "Error creating .atcr directory: %v\n", err) 200 + os.Exit(1) 201 + } 202 + 203 + return filepath.Join(atcrDir, "session.json") 236 204 } 237 205 238 - // exchangeForRegistryToken exchanges the ATProto OAuth token for a registry JWT 239 - func exchangeForRegistryToken(atprotoToken, registryURL, handle string, dpopKey interface{}) (string, error) { 240 - // Call the registry's /auth/exchange endpoint 241 - // This endpoint validates the ATProto token and returns a registry JWT 206 + // loadSession loads the session from disk 207 + func loadSession(path string) (*SessionStore, error) { 208 + data, err := os.ReadFile(path) 209 + if err != nil { 210 + return nil, fmt.Errorf("failed to read session file: %w", err) 211 + } 242 212 243 - // Normalize registry URL - add scheme if missing 244 - if !strings.HasPrefix(registryURL, "http://") && !strings.HasPrefix(registryURL, "https://") { 245 - registryURL = "http://" + registryURL 213 + var session SessionStore 214 + if err := json.Unmarshal(data, &session); err != nil { 215 + return nil, fmt.Errorf("failed to parse session file: %w", err) 246 216 } 247 217 248 - exchangeURL := fmt.Sprintf("%s/auth/exchange", registryURL) 218 + return &session, nil 219 + } 220 + 221 + // saveSession saves the session to disk 222 + func saveSession(path string, session *SessionStore) error { 223 + data, err := json.MarshalIndent(session, "", " ") 224 + if err != nil { 225 + return fmt.Errorf("failed to marshal session: %w", err) 226 + } 227 + 228 + if err := os.WriteFile(path, data, 0600); err != nil { 229 + return fmt.Errorf("failed to write session file: %w", err) 230 + } 231 + 232 + return nil 233 + } 234 + 235 + // exchangeSessionForRegistryToken exchanges the session token for a registry JWT 236 + func exchangeSessionForRegistryToken(sessionToken, appViewURL string) (string, error) { 237 + // Call the AppView's /auth/exchange endpoint 238 + exchangeURL := fmt.Sprintf("%s/auth/exchange", appViewURL) 249 239 250 240 reqBody := map[string]any{ 251 - "access_token": atprotoToken, 252 - "handle": handle, // Required for PDS resolution and token validation 253 - "scope": []string{"repository:*:pull,push"}, 241 + "scope": []string{"repository:*:pull,push"}, 254 242 } 255 243 256 244 body, err := json.Marshal(reqBody) ··· 258 246 return "", fmt.Errorf("failed to marshal request: %w", err) 259 247 } 260 248 261 - fmt.Fprintf(os.Stderr, "[DEBUG] POST %s\n", exchangeURL) 262 - fmt.Fprintf(os.Stderr, "[DEBUG] Request: handle=%s, token_prefix=%s..., scope=%v\n", 263 - handle, 264 - atprotoToken[:min(20, len(atprotoToken))], 265 - reqBody["scope"]) 266 - 267 - // Create HTTP client with DPoP transport 268 - transport := oauth.NewDPoPTransport(http.DefaultTransport, dpopKey) 269 - transport.SetAccessToken(atprotoToken) 270 - client := &http.Client{Transport: transport} 271 - 272 249 req, err := http.NewRequest("POST", exchangeURL, bytes.NewReader(body)) 273 250 if err != nil { 274 251 return "", fmt.Errorf("failed to create request: %w", err) 275 252 } 276 253 req.Header.Set("Content-Type", "application/json") 254 + req.Header.Set("Authorization", "Bearer "+sessionToken) 277 255 256 + client := &http.Client{} 278 257 resp, err := client.Do(req) 279 258 if err != nil { 280 259 return "", fmt.Errorf("failed to call exchange endpoint: %w", err) ··· 284 263 if resp.StatusCode != http.StatusOK { 285 264 // Read response body for debugging 286 265 bodyBytes, _ := io.ReadAll(resp.Body) 287 - fmt.Fprintf(os.Stderr, "[DEBUG] Exchange failed with status %d\n", resp.StatusCode) 288 - fmt.Fprintf(os.Stderr, "[DEBUG] Response body: %s\n", string(bodyBytes)) 289 - return "", fmt.Errorf("exchange failed with status %d", resp.StatusCode) 266 + return "", fmt.Errorf("exchange failed with status %d: %s", resp.StatusCode, string(bodyBytes)) 290 267 } 291 268 292 269 var result struct { ··· 304 281 return result.AccessToken, nil 305 282 } 306 283 307 - // runOAuthFlow executes the OAuth flow with browser 308 - func runOAuthFlow(handle string) (*oauth.TokenStore, error) { 309 - var server *http.Server 310 - 311 - // Run interactive OAuth flow with ephemeral server 312 - result, err := oauth.RunInteractiveFlow( 313 - context.Background(), 314 - oauth.InteractiveFlowConfig{ 315 - ClientID: clientID, 316 - RedirectURI: redirectURI, 317 - Handle: handle, 318 - }, 319 - func(authURL string, handler *oauth.CallbackHandler, metadata *oauth.ClientMetadata) error { 320 - // First call (authURL empty): start server 321 - if authURL == "" { 322 - var err error 323 - server, err = oauth.StartCallbackServer(handler, metadata) 324 - if err != nil { 325 - return fmt.Errorf("failed to start callback server: %w", err) 326 - } 327 - return nil 328 - } 329 - 330 - // Second call (authURL populated): display URL and open browser 331 - fmt.Printf("Opening browser to: %s\n", authURL) 332 - if err := oauth.OpenBrowser(authURL); err != nil { 333 - fmt.Printf("Failed to open browser automatically. Please open this URL manually:\n%s\n", authURL) 334 - } 335 - 336 - return nil 337 - }, 338 - ) 339 - if err != nil { 340 - return nil, err 341 - } 342 - 343 - // Shutdown ephemeral server 344 - if server != nil { 345 - defer server.Shutdown(context.Background()) 346 - } 347 - 348 - fmt.Println("Authorization successful!") 349 - 350 - // Resolve handle to get DID 351 - resolver := atproto.NewResolver() 352 - did, _, err := resolver.ResolveIdentity(context.Background(), handle) 353 - if err != nil { 354 - return nil, fmt.Errorf("failed to resolve DID: %w", err) 355 - } 356 - 357 - // Create token store 358 - store := &oauth.TokenStore{ 359 - AccessToken: result.Token.AccessToken, 360 - RefreshToken: result.Token.RefreshToken, 361 - TokenType: result.Token.TokenType, 362 - ExpiresAt: result.Token.Expiry, 363 - Handle: handle, 364 - DID: did, 365 - } 366 - 367 - // Save DPoP key 368 - if err := store.SetDPoPKey(result.Client.DPoPKey()); err != nil { 369 - return nil, fmt.Errorf("failed to save DPoP key: %w", err) 370 - } 371 - 372 - return store, nil 373 - }
+20 -2
cmd/registry/main.go
··· 1 1 package main 2 2 3 3 import ( 4 + "fmt" 4 5 "os" 6 + "time" 5 7 6 8 "github.com/distribution/distribution/v3/registry" 7 9 _ "github.com/distribution/distribution/v3/registry/auth/token" ··· 11 13 12 14 // Register our custom middleware 13 15 _ "atcr.io/pkg/middleware" 16 + 17 + "atcr.io/pkg/auth/exchange" 18 + "atcr.io/pkg/auth/oauth" 19 + "atcr.io/pkg/auth/session" 20 + "atcr.io/pkg/auth/token" 21 + "atcr.io/pkg/middleware" 14 22 ) 15 23 16 24 func main() { 17 - // Use distribution's built-in CLI 18 - // Our middleware will be automatically registered via init() 25 + // The serve command is registered in serve.go via init() 26 + // Just execute the root command 19 27 if err := registry.RootCmd.Execute(); err != nil { 20 28 os.Exit(1) 21 29 } 22 30 } 31 + 32 + // Suppress unused import warnings 33 + var _ = fmt.Sprint 34 + var _ = os.Stdout 35 + var _ = time.Now 36 + var _ = oauth.NewRefresher 37 + var _ = session.NewManager 38 + var _ = token.NewIssuer 39 + var _ = exchange.NewHandler 40 + var _ = middleware.SetGlobalRefresher
+107 -4
cmd/registry/serve.go
··· 6 6 "net/http" 7 7 "os" 8 8 "os/signal" 9 + "path/filepath" 9 10 "syscall" 10 11 "time" 11 12 ··· 15 16 "github.com/spf13/cobra" 16 17 17 18 "atcr.io/pkg/auth/exchange" 19 + "atcr.io/pkg/auth/oauth" 20 + "atcr.io/pkg/auth/session" 18 21 "atcr.io/pkg/auth/token" 22 + "atcr.io/pkg/middleware" 19 23 ) 20 24 21 25 var serveCmd = &cobra.Command{ ··· 51 55 return fmt.Errorf("failed to parse configuration: %w", err) 52 56 } 53 57 54 - // Initialize auth keys if needed 58 + // Initialize OAuth components 59 + fmt.Println("Initializing OAuth components...") 60 + 61 + // 1. Create refresh token storage 62 + // Allow override via environment variable for Docker deployments 63 + storagePath := os.Getenv("ATCR_TOKEN_STORAGE_PATH") 64 + if storagePath == "" { 65 + var err error 66 + storagePath, err = oauth.GetDefaultPath() 67 + if err != nil { 68 + return fmt.Errorf("failed to get storage path: %w", err) 69 + } 70 + } 71 + 72 + // Ensure directory exists 73 + storageDir := filepath.Dir(storagePath) 74 + if err := os.MkdirAll(storageDir, 0700); err != nil { 75 + return fmt.Errorf("failed to create storage directory: %w", err) 76 + } 77 + 78 + fmt.Printf("Using token storage path: %s\n", storagePath) 79 + 80 + refreshStorage, err := oauth.NewRefreshTokenStorage(storagePath) 81 + if err != nil { 82 + return fmt.Errorf("failed to create refresh token storage: %w", err) 83 + } 84 + 85 + // 2. Create session manager with 30-day TTL 86 + // Use persistent secret so session tokens remain valid across container restarts 87 + secretPath := os.Getenv("ATCR_SESSION_SECRET_PATH") 88 + if secretPath == "" { 89 + // Default to same directory as tokens 90 + secretPath = filepath.Join(filepath.Dir(storagePath), "session-secret.key") 91 + } 92 + sessionManager, err := session.NewManagerWithPersistentSecret(secretPath, 30*24*time.Hour) 93 + if err != nil { 94 + return fmt.Errorf("failed to create session manager: %w", err) 95 + } 96 + 97 + // 3. Get base URL from config or environment 98 + baseURL := os.Getenv("ATCR_BASE_URL") 99 + if baseURL == "" { 100 + // If addr is just a port (e.g., ":5000"), prepend localhost 101 + addr := config.HTTP.Addr 102 + if addr[0] == ':' { 103 + baseURL = fmt.Sprintf("http://127.0.0.1%s", addr) 104 + } else { 105 + baseURL = fmt.Sprintf("http://%s", addr) 106 + } 107 + } 108 + 109 + fmt.Printf("DEBUG: Base URL for OAuth: %s\n", baseURL) 110 + 111 + // 4. Get client ID from config 112 + clientIDConfig := oauth.ClientIDConfig{ 113 + BaseURL: baseURL, 114 + CallbackPath: "/auth/oauth/callback", 115 + Scopes: []string{"atproto"}, 116 + } 117 + clientID, redirectURI := clientIDConfig.MakeClientID() 118 + 119 + fmt.Printf("DEBUG: Client ID: %s\n", clientID) 120 + fmt.Printf("DEBUG: Redirect URI: %s\n", redirectURI) 121 + 122 + // 5. Create refresher 123 + refresher := oauth.NewRefresher(refreshStorage, clientID, redirectURI) 124 + // Start cleanup routine (runs every hour) 125 + refresher.StartCleanupRoutine(1 * time.Hour) 126 + 127 + // 6. Set global refresher for middleware 128 + middleware.SetGlobalRefresher(refresher) 129 + 130 + // 7. Create client metadata (only needed for production, not localhost) 131 + // For localhost, client metadata is embedded in the client_id query string 132 + // clientMetadata := oauth.NewClientMetadata(clientID, []string{redirectURI}) 133 + 134 + // 8. Create OAuth server 135 + oauthServer := oauth.NewServer(refreshStorage, sessionManager, baseURL) 136 + 137 + // 9. Initialize auth keys and create token issuer 55 138 var issuer *token.Issuer 56 139 if config.Auth["token"] != nil { 57 140 if err := initializeAuthKeys(config); err != nil { ··· 74 157 75 158 // Mount registry at /v2/ 76 159 mux.Handle("/v2/", app) 160 + 161 + // Mount OAuth endpoints 162 + mux.HandleFunc("/auth/oauth/authorize", oauthServer.ServeAuthorize) 163 + mux.HandleFunc("/auth/oauth/callback", oauthServer.ServeCallback) 164 + 165 + // Start OAuth server cleanup routine 166 + go func() { 167 + ticker := time.NewTicker(10 * time.Minute) 168 + defer ticker.Stop() 169 + for range ticker.C { 170 + oauthServer.CleanupExpiredStates() 171 + } 172 + }() 77 173 78 174 // Mount auth endpoints if enabled 79 175 if issuer != nil { 80 176 // Extract default hold endpoint from middleware config 81 177 defaultHoldEndpoint := extractDefaultHoldEndpoint(config) 82 178 83 - tokenHandler := token.NewHandler(issuer, defaultHoldEndpoint) 179 + // Basic Auth token endpoint (also supports session tokens) 180 + tokenHandler := token.NewHandler(issuer, sessionManager, defaultHoldEndpoint) 84 181 tokenHandler.RegisterRoutes(mux) 85 182 86 - exchangeHandler := exchange.NewHandler(issuer, defaultHoldEndpoint) 183 + // OAuth exchange endpoint (session token → registry JWT) 184 + exchangeHandler := exchange.NewHandler(issuer, sessionManager) 87 185 exchangeHandler.RegisterRoutes(mux) 88 - fmt.Println("Auth endpoints enabled at /auth/token and /auth/exchange") 186 + 187 + fmt.Printf("Auth endpoints enabled:\n") 188 + fmt.Printf(" - Basic Auth: /auth/token\n") 189 + fmt.Printf(" - OAuth: /auth/oauth/authorize\n") 190 + fmt.Printf(" - OAuth: /auth/oauth/callback\n") 191 + fmt.Printf(" - Exchange: /auth/exchange\n") 89 192 } 90 193 91 194 // Create HTTP server
+7 -1
docker-compose.yml
··· 7 7 container_name: atcr-registry 8 8 ports: 9 9 - "5000:5000" 10 + environment: 11 + - ATCR_TOKEN_STORAGE_PATH=/var/lib/atcr/tokens/oauth-tokens.json 10 12 volumes: 11 - # Only auth keys (could be moved to secrets in production) 13 + # Auth keys (JWT signing keys) 12 14 - atcr-auth:/var/lib/atcr/auth 15 + # OAuth refresh tokens (persists user sessions across container restarts) 16 + - atcr-tokens:/var/lib/atcr/tokens 13 17 restart: unless-stopped 14 18 networks: 15 19 atcr-network: ··· 17 21 # The registry should be stateless - all storage is external: 18 22 # - Manifests/Tags -> ATProto PDS 19 23 # - Blobs/Layers -> Hold service 24 + # - OAuth tokens -> Persistent volume (atcr-tokens) 20 25 # Future: Add read_only: true for production deployments 21 26 22 27 hold: ··· 51 56 volumes: 52 57 atcr-hold: 53 58 atcr-auth: 59 + atcr-tokens:
+434
docs/APPVIEW_OAUTH.md
··· 1 + # AppView-Mediated OAuth Architecture 2 + 3 + ## Overview 4 + 5 + ATCR uses a two-tier authentication model to support OAuth while allowing the AppView to write manifests to users' Personal Data Servers (PDS). 6 + 7 + ## The Problem 8 + 9 + OAuth with DPoP creates cryptographically bound tokens that cannot be delegated: 10 + 11 + - **Basic Auth**: App password is a shared secret that can be forwarded from client → AppView → PDS ✅ 12 + - **OAuth + DPoP**: Token is bound to client's keypair and cannot be reused by AppView ❌ 13 + 14 + This creates a challenge: How can the AppView write manifests to the user's PDS on their behalf? 15 + 16 + ## The Solution: Two-Tier Authentication 17 + 18 + ``` 19 + ┌──────────┐ ┌─────────┐ ┌────────────┐ 20 + │ Docker │◄───────►│ AppView │◄───────►│ PDS/Auth │ 21 + │ Client │ Auth1 │ (ATCR) │ Auth2 │ Server │ 22 + └──────────┘ └─────────┘ └────────────┘ 23 + ``` 24 + 25 + **Auth Tier 1** (Docker ↔ AppView): Registry authentication 26 + - Client authenticates to AppView using session tokens 27 + - AppView issues short-lived registry JWTs 28 + - Standard Docker registry auth protocol 29 + 30 + **Auth Tier 2** (AppView ↔ PDS): Resource access 31 + - AppView acts as OAuth client for each user 32 + - AppView stores refresh tokens per user 33 + - AppView gets access tokens on-demand to write manifests 34 + 35 + ## Complete Flows 36 + 37 + ### One-Time Authorization Flow 38 + 39 + ``` 40 + ┌────────┐ ┌──────────────┐ ┌─────────┐ ┌─────┐ 41 + │ User │ │ Credential │ │ AppView │ │ PDS │ 42 + │ │ │ Helper │ │ │ │ │ 43 + └───┬────┘ └──────┬───────┘ └────┬────┘ └──┬──┘ 44 + │ │ │ │ 45 + │ $ docker-credential-atcr configure │ │ 46 + │ Enter handle: evan.jarrett.net │ │ 47 + │─────────────────────>│ │ │ 48 + │ │ │ │ 49 + │ │ GET /auth/oauth/authorize?handle=... │ 50 + │ │─────────────────────>│ │ 51 + │ │ │ │ 52 + │ │ 302 Redirect to PDS │ │ 53 + │ │<─────────────────────│ │ 54 + │ │ │ │ 55 + │ [Browser opens] │ │ │ 56 + │<─────────────────────│ │ │ 57 + │ │ │ │ 58 + │ Authorize ATCR? │ │ │ 59 + │──────────────────────────────────────────────────────────────>│ 60 + │ │ │ │ 61 + │ │ │<─code────────────│ 62 + │ │ │ │ 63 + │ │ │ POST /token │ 64 + │ │ │ (exchange code) │ 65 + │ │ │ + DPoP proof │ 66 + │ │ │─────────────────>│ 67 + │ │ │ │ 68 + │ │ │<─refresh_token───│ 69 + │ │ │ access_token │ 70 + │ │ │ │ 71 + │ │ │ [Store tokens] │ 72 + │ │ │ DID → { │ 73 + │ │ │ refresh_token, │ 74 + │ │ │ dpop_key, │ 75 + │ │ │ pds_endpoint │ 76 + │ │ │ } │ 77 + │ │ │ │ 78 + │ │<─session_token───────│ │ 79 + │ │ │ │ 80 + │ [Store session] │ │ │ 81 + │<─────────────────────│ │ │ 82 + │ ~/.atcr/ │ │ │ 83 + │ session.json │ │ │ 84 + │ │ │ │ 85 + │ ✓ Authorization │ │ │ 86 + │ complete! │ │ │ 87 + │ │ │ │ 88 + ``` 89 + 90 + ### Docker Push Flow (Every Push) 91 + 92 + ``` 93 + ┌────────┐ ┌──────────┐ ┌─────────┐ ┌─────┐ 94 + │ Docker │ │ Cred │ │ AppView │ │ PDS │ 95 + │ │ │ Helper │ │ │ │ │ 96 + └───┬────┘ └────┬─────┘ └────┬────┘ └──┬──┘ 97 + │ │ │ │ 98 + │ docker push │ │ │ 99 + │──────────────>│ │ │ 100 + │ │ │ │ 101 + │ │ GET /auth/exchange │ 102 + │ │ Authorization: Bearer │ 103 + │ │ <session_token> │ 104 + │ │──────────────>│ │ 105 + │ │ │ │ 106 + │ │ │ [Validate │ 107 + │ │ │ session] │ 108 + │ │ │ │ 109 + │ │ │ [Issue JWT] │ 110 + │ │ │ │ 111 + │ │<──registry_jwt─│ │ 112 + │ │ │ │ 113 + │<─registry_jwt─│ │ │ 114 + │ │ │ │ 115 + │ PUT /v2/.../manifests/... │ │ 116 + │ Authorization: Bearer │ │ 117 + │ <registry_jwt> │ │ 118 + │──────────────────────────────>│ │ 119 + │ │ │ 120 + │ │ [Validate │ 121 + │ │ JWT] │ 122 + │ │ │ 123 + │ │ [Get fresh │ 124 + │ │ access │ 125 + │ │ token] │ 126 + │ │ │ 127 + │ │ POST /token │ 128 + │ │ (refresh) │ 129 + │ │ + DPoP │ 130 + │ │────────────>│ 131 + │ │ │ 132 + │ │<access_token│ 133 + │ │ │ 134 + │ │ PUT record │ 135 + │ │ (manifest) │ 136 + │ │ + DPoP │ 137 + │ │────────────>│ 138 + │ │ │ 139 + │ │<──201 OK────│ 140 + │ │ │ 141 + │<──────────201 OK──────────────│ │ 142 + │ │ │ 143 + ``` 144 + 145 + ## Components 146 + 147 + ### 1. OAuth Authorization Server (AppView) 148 + 149 + **File**: `pkg/auth/oauth/server.go` 150 + 151 + **Endpoints**: 152 + 153 + #### `GET /auth/oauth/authorize` 154 + 155 + Initiates OAuth flow for a user. 156 + 157 + **Query Parameters**: 158 + - `handle` (required): User's ATProto handle (e.g., `evan.jarrett.net`) 159 + 160 + **Flow**: 161 + 1. Resolve handle → DID → PDS endpoint 162 + 2. Discover PDS OAuth metadata 163 + 3. Generate state + PKCE verifier 164 + 4. Create PAR request to PDS 165 + 5. Redirect user to PDS authorization endpoint 166 + 167 + **Response**: `302 Redirect` to PDS authorization page 168 + 169 + #### `GET /auth/oauth/callback` 170 + 171 + Receives OAuth callback from PDS. 172 + 173 + **Query Parameters**: 174 + - `code`: Authorization code 175 + - `state`: State for CSRF protection 176 + 177 + **Flow**: 178 + 1. Validate state 179 + 2. Exchange code for tokens (POST to PDS token endpoint) 180 + 3. Use AppView's DPoP key for the exchange 181 + 4. Store refresh token + DPoP key for user's DID 182 + 5. Generate AppView session token 183 + 6. Redirect to success page with session token 184 + 185 + **Response**: HTML page with session token (user copies to credential helper) 186 + 187 + ### 2. Refresh Token Storage 188 + 189 + **File**: `pkg/auth/oauth/storage.go` 190 + 191 + **Storage Format**: 192 + 193 + ```json 194 + { 195 + "refresh_tokens": { 196 + "did:plc:abc123": { 197 + "refresh_token": "...", 198 + "dpop_key_pem": "-----BEGIN EC PRIVATE KEY-----\n...", 199 + "pds_endpoint": "https://bsky.social", 200 + "handle": "evan.jarrett.net", 201 + "created_at": "2025-10-04T...", 202 + "last_refreshed": "2025-10-04T..." 203 + } 204 + } 205 + } 206 + ``` 207 + 208 + **Location**: 209 + - Development: `~/.atcr/appview-tokens.json` 210 + - Production: Encrypted database or secret manager 211 + 212 + **Security**: 213 + - File permissions: `0600` (owner read/write only) 214 + - Consider encrypting DPoP keys at rest 215 + - Rotate refresh tokens periodically 216 + 217 + ### 3. Token Refresher 218 + 219 + **File**: `pkg/auth/oauth/refresher.go` 220 + 221 + **Interface**: 222 + 223 + ```go 224 + type Refresher interface { 225 + // GetAccessToken gets a fresh access token for a DID 226 + // Returns cached token if still valid, otherwise refreshes 227 + GetAccessToken(ctx context.Context, did string) (token string, dpopKey *ecdsa.PrivateKey, err error) 228 + 229 + // RefreshToken forces a token refresh 230 + RefreshToken(ctx context.Context, did string) error 231 + 232 + // RevokeToken removes stored refresh token 233 + RevokeToken(did string) error 234 + } 235 + ``` 236 + 237 + **Caching Strategy**: 238 + - Access tokens cached for 14 minutes (expire at 15min) 239 + - Refresh tokens stored persistently 240 + - Cache key: `did → {access_token, dpop_key, expires_at}` 241 + 242 + ### 4. Session Management 243 + 244 + **File**: `pkg/auth/session/handler.go` 245 + 246 + **Session Token Format**: 247 + ``` 248 + Base64(JSON({ 249 + "did": "did:plc:abc123", 250 + "handle": "evan.jarrett.net", 251 + "issued_at": "2025-10-04T...", 252 + "expires_at": "2025-11-03T..." // 30 days 253 + })).HMAC-SHA256(secret) 254 + ``` 255 + 256 + **Storage**: Stateless (validated by HMAC signature) 257 + 258 + **Endpoints**: 259 + 260 + #### `GET /auth/session/validate` 261 + 262 + Validates a session token. 263 + 264 + **Headers**: 265 + - `Authorization: Bearer <session_token>` 266 + 267 + **Response**: 268 + ```json 269 + { 270 + "did": "did:plc:abc123", 271 + "handle": "evan.jarrett.net", 272 + "valid": true 273 + } 274 + ``` 275 + 276 + ### 5. Updated Exchange Handler 277 + 278 + **File**: `pkg/auth/exchange/handler.go` 279 + 280 + **Changes**: 281 + - Accept session token instead of OAuth token 282 + - Validate session token → extract DID 283 + - Issue registry JWT with DID 284 + - Remove PDS token validation 285 + 286 + **Request**: 287 + ``` 288 + POST /auth/exchange 289 + Authorization: Bearer <session_token> 290 + 291 + { 292 + "scope": ["repository:*:pull,push"] 293 + } 294 + ``` 295 + 296 + **Response**: 297 + ```json 298 + { 299 + "token": "<registry-jwt>", 300 + "expires_in": 900 301 + } 302 + ``` 303 + 304 + ### 6. Credential Helper Updates 305 + 306 + **File**: `cmd/credential-helper/main.go` 307 + 308 + **Changes**: 309 + 310 + 1. **Configure command**: 311 + - Open browser to AppView: `http://127.0.0.1:5000/auth/oauth/authorize?handle=...` 312 + - User authorizes on PDS 313 + - AppView displays session token 314 + - User copies session token to helper 315 + - Helper stores session token 316 + 317 + 2. **Get command**: 318 + - Load session token from `~/.atcr/session.json` 319 + - Call `/auth/exchange` with session token 320 + - Return registry JWT to Docker 321 + 322 + 3. **Storage format**: 323 + ```json 324 + { 325 + "session_token": "...", 326 + "handle": "evan.jarrett.net", 327 + "appview_url": "http://127.0.0.1:5000" 328 + } 329 + ``` 330 + 331 + **Removed**: 332 + - DPoP key generation 333 + - OAuth client logic 334 + - Refresh token handling 335 + 336 + ## Security Considerations 337 + 338 + ### AppView as Trusted Component 339 + 340 + The AppView becomes a **trusted intermediary** that: 341 + - Stores refresh tokens for users 342 + - Acts on users' behalf to write manifests 343 + - Issues registry authentication tokens 344 + 345 + **Trust model**: 346 + - Users must trust the AppView operator 347 + - Similar to trusting a Docker registry operator 348 + - AppView has write access to manifests (not profile data) 349 + 350 + ### Scope Limitations 351 + 352 + AppView OAuth tokens are requested with minimal scopes: 353 + - `atproto` - Basic ATProto operations 354 + - Only needs: `com.atproto.repo.putRecord`, `com.atproto.repo.getRecord` 355 + - Does NOT need: profile updates, social graph access, etc. 356 + 357 + ### Token Security 358 + 359 + **Refresh Tokens**: 360 + - Stored encrypted at rest 361 + - File permissions: 0600 362 + - Rotated periodically (when used) 363 + - Can be revoked by user on PDS 364 + 365 + **Session Tokens**: 366 + - 30-day expiry 367 + - HMAC-signed (stateless validation) 368 + - Can be revoked by clearing storage 369 + 370 + **Access Tokens**: 371 + - Cached in-memory only 372 + - 15-minute expiry 373 + - Never stored persistently 374 + 375 + ### Audit Trail 376 + 377 + AppView should log: 378 + - OAuth authorizations (DID, timestamp) 379 + - Token refreshes (DID, timestamp) 380 + - Manifest writes (DID, repository, timestamp) 381 + 382 + ## Migration from Current OAuth 383 + 384 + Users currently using `docker-credential-atcr` with direct PDS OAuth will need to: 385 + 386 + 1. Run `docker-credential-atcr configure` again 387 + 2. Authorize AppView (new OAuth flow) 388 + 3. Old PDS tokens are no longer used 389 + 390 + ## Alternative: Bring Your Own AppView 391 + 392 + Users who don't trust a shared AppView can: 393 + 1. Run their own ATCR AppView instance 394 + 2. Configure credential helper to point at their AppView 395 + 3. Their AppView stores their refresh tokens locally 396 + 397 + ## Future Enhancements 398 + 399 + ### Multi-AppView Support 400 + 401 + Allow users to configure multiple AppViews: 402 + ```json 403 + { 404 + "appviews": { 405 + "default": "https://atcr.io", 406 + "personal": "http://localhost:5000" 407 + }, 408 + "sessions": { 409 + "https://atcr.io": {"session_token": "...", "handle": "..."}, 410 + "http://localhost:5000": {"session_token": "...", "handle": "..."} 411 + } 412 + } 413 + ``` 414 + 415 + ### Refresh Token Rotation 416 + 417 + Implement automatic refresh token rotation per OAuth best practices: 418 + - PDS issues new refresh token with each use 419 + - AppView updates stored token 420 + - Old refresh token invalidated 421 + 422 + ### Revocation UI 423 + 424 + Add web UI for users to: 425 + - View active sessions 426 + - Revoke AppView access 427 + - See audit log of manifest writes 428 + 429 + ## References 430 + 431 + - [ATProto OAuth Specification](https://atproto.com/specs/oauth) 432 + - [RFC 6749: OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc6749) 433 + - [RFC 9449: DPoP](https://datatracker.ietf.org/doc/html/rfc9449) 434 + - [Docker Credential Helpers](https://github.com/docker/docker-credential-helpers)
+17 -6
pkg/auth/atproto/validator.go
··· 33 33 34 34 // ValidateToken validates an ATProto OAuth access token by calling getSession 35 35 // Returns the user's DID and handle if the token is valid 36 - func (v *TokenValidator) ValidateToken(ctx context.Context, pdsEndpoint, accessToken string) (*SessionInfo, error) { 36 + // dpopProof is optional - if provided, uses DPoP auth; otherwise uses Bearer 37 + func (v *TokenValidator) ValidateToken(ctx context.Context, pdsEndpoint, accessToken, dpopProof string) (*SessionInfo, error) { 37 38 // Call com.atproto.server.getSession with the access token 38 39 url := fmt.Sprintf("%s/xrpc/com.atproto.server.getSession", pdsEndpoint) 39 40 ··· 42 43 return nil, fmt.Errorf("failed to create request: %w", err) 43 44 } 44 45 45 - // Add bearer token 46 + // Always use Bearer auth for getSession validation 47 + // The DPoP proof from the client is bound to their request to us (POST /auth/exchange), 48 + // not to our request to the PDS (GET /getSession) 46 49 req.Header.Set("Authorization", "Bearer "+accessToken) 50 + 51 + fmt.Printf("DEBUG [validator]: calling %s with Bearer auth, token_prefix=%s...\n", 52 + url, accessToken[:min(20, len(accessToken))]) 47 53 48 54 resp, err := v.httpClient.Do(req) 49 55 if err != nil { ··· 51 57 } 52 58 defer resp.Body.Close() 53 59 60 + // Read body once for both logging and error handling 61 + bodyBytes, _ := io.ReadAll(resp.Body) 62 + 54 63 if resp.StatusCode == http.StatusUnauthorized { 64 + fmt.Printf("DEBUG [validator]: getSession returned 401: %s\n", string(bodyBytes)) 55 65 return nil, fmt.Errorf("invalid or expired token") 56 66 } 57 67 58 68 if resp.StatusCode != http.StatusOK { 59 - bodyBytes, _ := io.ReadAll(resp.Body) 69 + fmt.Printf("DEBUG [validator]: getSession failed with status %d: %s\n", resp.StatusCode, string(bodyBytes)) 60 70 return nil, fmt.Errorf("getSession failed with status %d: %s", resp.StatusCode, string(bodyBytes)) 61 71 } 62 72 63 73 var session SessionInfo 64 - if err := json.NewDecoder(resp.Body).Decode(&session); err != nil { 74 + if err := json.Unmarshal(bodyBytes, &session); err != nil { 65 75 return nil, fmt.Errorf("failed to decode session: %w", err) 66 76 } 67 77 ··· 77 87 } 78 88 79 89 // ValidateTokenWithResolver validates a token and automatically resolves the PDS endpoint 80 - func (v *TokenValidator) ValidateTokenWithResolver(ctx context.Context, handle, accessToken string) (*SessionInfo, error) { 90 + // dpopProof is optional - if provided, uses DPoP auth; otherwise uses Bearer 91 + func (v *TokenValidator) ValidateTokenWithResolver(ctx context.Context, handle, accessToken, dpopProof string) (*SessionInfo, error) { 81 92 // Resolve handle to PDS endpoint 82 93 resolver := mainAtproto.NewResolver() 83 94 _, pdsEndpoint, err := resolver.ResolveIdentity(ctx, handle) ··· 86 97 } 87 98 88 99 // Validate token against the PDS 89 - return v.ValidateToken(ctx, pdsEndpoint, accessToken) 100 + return v.ValidateToken(ctx, pdsEndpoint, accessToken, dpopProof) 90 101 }
+33 -51
pkg/auth/exchange/handler.go
··· 4 4 "encoding/json" 5 5 "fmt" 6 6 "net/http" 7 + "strings" 7 8 8 - mainAtproto "atcr.io/pkg/atproto" 9 9 "atcr.io/pkg/auth" 10 - "atcr.io/pkg/auth/atproto" 10 + "atcr.io/pkg/auth/session" 11 11 "atcr.io/pkg/auth/token" 12 12 ) 13 13 14 - // Handler handles /auth/exchange requests (OAuth token -> JWT token) 14 + // Handler handles /auth/exchange requests (session token -> registry JWT) 15 15 type Handler struct { 16 - issuer *token.Issuer 17 - validator *atproto.TokenValidator 18 - defaultHoldEndpoint string 16 + issuer *token.Issuer 17 + sessionManager *session.Manager 19 18 } 20 19 21 20 // NewHandler creates a new exchange handler 22 - func NewHandler(issuer *token.Issuer, defaultHoldEndpoint string) *Handler { 21 + func NewHandler(issuer *token.Issuer, sessionManager *session.Manager) *Handler { 23 22 return &Handler{ 24 - issuer: issuer, 25 - validator: atproto.NewTokenValidator(), 26 - defaultHoldEndpoint: defaultHoldEndpoint, 23 + issuer: issuer, 24 + sessionManager: sessionManager, 27 25 } 28 26 } 29 27 30 - // ExchangeRequest represents the request to exchange an OAuth token 28 + // ExchangeRequest represents the request to exchange a session token for registry JWT 31 29 type ExchangeRequest struct { 32 - AccessToken string `json:"access_token"` // ATProto OAuth access token 33 - Handle string `json:"handle"` // User's handle (required for PDS resolution) 34 - Scope []string `json:"scope"` // Requested Docker scopes 30 + Scope []string `json:"scope"` // Requested Docker scopes 35 31 } 36 32 37 33 // ExchangeResponse represents the response from /auth/exchange ··· 48 44 return 49 45 } 50 46 51 - var req ExchangeRequest 52 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 53 - http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest) 47 + // Extract session token from Authorization header 48 + authHeader := r.Header.Get("Authorization") 49 + if authHeader == "" { 50 + http.Error(w, "authorization header required", http.StatusUnauthorized) 54 51 return 55 52 } 56 53 57 - if req.AccessToken == "" { 58 - http.Error(w, "access_token is required", http.StatusBadRequest) 54 + // Parse Bearer token 55 + parts := strings.SplitN(authHeader, " ", 2) 56 + if len(parts) != 2 || parts[0] != "Bearer" { 57 + http.Error(w, "invalid authorization header format", http.StatusUnauthorized) 59 58 return 60 59 } 60 + sessionToken := parts[1] 61 61 62 - // Validate the ATProto OAuth token via the PDS 63 - // We need the handle to resolve the PDS endpoint 64 - if req.Handle == "" { 65 - http.Error(w, "handle required to validate token", http.StatusBadRequest) 62 + // Validate session token 63 + sessionClaims, err := h.sessionManager.Validate(sessionToken) 64 + if err != nil { 65 + fmt.Printf("DEBUG [exchange]: session validation failed: %v\n", err) 66 + http.Error(w, fmt.Sprintf("invalid session token: %v", err), http.StatusUnauthorized) 66 67 return 67 68 } 68 69 69 - session, err := h.validator.ValidateTokenWithResolver(r.Context(), req.Handle, req.AccessToken) 70 - if err != nil { 71 - http.Error(w, fmt.Sprintf("token validation failed: %v", err), http.StatusUnauthorized) 70 + fmt.Printf("DEBUG [exchange]: session validated for DID=%s, handle=%s\n", sessionClaims.DID, sessionClaims.Handle) 71 + 72 + // Parse request body for scopes 73 + var req ExchangeRequest 74 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 75 + http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest) 72 76 return 73 77 } 74 78 75 - // Use DID and handle from validated session 76 - did := session.DID 77 - handle := session.Handle 78 - 79 79 // Parse and validate scopes 80 80 access, err := auth.ParseScope(req.Scope) 81 81 if err != nil { ··· 84 84 } 85 85 86 86 // Validate access permissions 87 - if err := auth.ValidateAccess(did, handle, access); err != nil { 87 + if err := auth.ValidateAccess(sessionClaims.DID, sessionClaims.Handle, access); err != nil { 88 88 http.Error(w, fmt.Sprintf("access denied: %v", err), http.StatusForbidden) 89 89 return 90 90 } 91 91 92 - // Ensure user profile exists (creates with default hold if needed) 93 - // Resolve PDS endpoint for profile management 94 - resolver := mainAtproto.NewResolver() 95 - _, pdsEndpoint, err := resolver.ResolveIdentity(r.Context(), handle) 96 - if err != nil { 97 - // Log error but don't fail auth - profile management is not critical 98 - fmt.Printf("WARNING: failed to resolve PDS for profile management: %v\n", err) 99 - } else { 100 - // Create ATProto client with validated token 101 - atprotoClient := mainAtproto.NewClient(pdsEndpoint, did, req.AccessToken) 102 - 103 - // Ensure profile exists (will create with default hold if not exists and default is configured) 104 - if err := mainAtproto.EnsureProfile(r.Context(), atprotoClient, h.defaultHoldEndpoint); err != nil { 105 - // Log error but don't fail auth - profile management is not critical 106 - fmt.Printf("WARNING: failed to ensure profile for %s: %v\n", did, err) 107 - } 108 - } 109 - 110 - // Issue JWT token 111 - tokenString, err := h.issuer.Issue(did, access) 92 + // Issue registry JWT token 93 + tokenString, err := h.issuer.Issue(sessionClaims.DID, access) 112 94 if err != nil { 113 95 http.Error(w, fmt.Sprintf("failed to issue token: %v", err), http.StatusInternalServerError) 114 96 return
+14 -2
pkg/auth/oauth/client.go
··· 58 58 59 59 c.metadata = metadata 60 60 61 - // Configure OAuth2 client with default scope 62 - // Can be overridden with SetScopes() before calling AuthorizeURL() 61 + // Configure OAuth2 client 62 + // Note: Both localhost and production need redirect_uri and scopes in the config 63 + // For localhost: client_id contains these (query-based) AND they're sent as params 64 + // For production: client_id is metadata URL, params come from config 63 65 c.config = &oauth2.Config{ 64 66 ClientID: c.clientID, 65 67 Endpoint: oauth2.Endpoint{ ··· 115 117 116 118 // authorizeURLWithPAR uses Pushed Authorization Request 117 119 func (c *Client) authorizeURLWithPAR(state, codeChallenge string) (string, error) { 120 + fmt.Printf("DEBUG [oauth/client]: Starting PAR request\n") 121 + fmt.Printf("DEBUG [oauth/client]: - client_id: %s\n", c.config.ClientID) 122 + fmt.Printf("DEBUG [oauth/client]: - redirect_uri: %s\n", c.config.RedirectURL) 123 + fmt.Printf("DEBUG [oauth/client]: - scope: %v\n", c.config.Scopes) 124 + fmt.Printf("DEBUG [oauth/client]: - state: %s\n", state) 125 + fmt.Printf("DEBUG [oauth/client]: - code_challenge_method: S256\n") 126 + fmt.Printf("DEBUG [oauth/client]: - PAR endpoint: %s\n", c.config.Endpoint.PushedAuthURL) 127 + 118 128 // Create HTTP client with DPoP transport 119 129 ctx := context.WithValue(context.Background(), oauth2.HTTPClient, &http.Client{ 120 130 Transport: c.dpopTransport, ··· 126 136 oauth2.SetAuthURLParam("code_challenge_method", "S256"), 127 137 ) 128 138 if err != nil { 139 + fmt.Printf("ERROR [oauth/client]: PAR request failed: %v\n", err) 129 140 return "", err 130 141 } 131 142 143 + fmt.Printf("DEBUG [oauth/client]: PAR successful, authURL: %s\n", authURL.String()) 132 144 return authURL.String(), nil 133 145 } 134 146
+167
pkg/auth/oauth/refresher.go
··· 1 + package oauth 2 + 3 + import ( 4 + "context" 5 + "crypto/ecdsa" 6 + "fmt" 7 + "net/http" 8 + "sync" 9 + "time" 10 + 11 + "authelia.com/client/oauth2" 12 + ) 13 + 14 + // AccessTokenEntry represents a cached access token 15 + type AccessTokenEntry struct { 16 + Token string 17 + DPoPKey *ecdsa.PrivateKey 18 + ExpiresAt time.Time 19 + } 20 + 21 + // Refresher manages OAuth token refresh for AppView 22 + type Refresher struct { 23 + storage *RefreshTokenStorage 24 + accessTokens map[string]*AccessTokenEntry 25 + mu sync.RWMutex 26 + clientID string 27 + redirectURI string 28 + } 29 + 30 + // NewRefresher creates a new token refresher 31 + func NewRefresher(storage *RefreshTokenStorage, clientID, redirectURI string) *Refresher { 32 + return &Refresher{ 33 + storage: storage, 34 + accessTokens: make(map[string]*AccessTokenEntry), 35 + clientID: clientID, 36 + redirectURI: redirectURI, 37 + } 38 + } 39 + 40 + // GetAccessToken gets a fresh access token for a DID 41 + // Returns cached token if still valid, otherwise refreshes 42 + func (r *Refresher) GetAccessToken(ctx context.Context, did string) (string, *ecdsa.PrivateKey, error) { 43 + // Check cache first 44 + r.mu.RLock() 45 + entry, ok := r.accessTokens[did] 46 + r.mu.RUnlock() 47 + 48 + if ok && time.Now().Before(entry.ExpiresAt) { 49 + // Token still valid 50 + return entry.Token, entry.DPoPKey, nil 51 + } 52 + 53 + // Token expired or not cached, refresh it 54 + return r.RefreshToken(ctx, did) 55 + } 56 + 57 + // RefreshToken forces a token refresh for a DID 58 + func (r *Refresher) RefreshToken(ctx context.Context, did string) (string, *ecdsa.PrivateKey, error) { 59 + // Get stored refresh token 60 + entry, err := r.storage.Get(did) 61 + if err != nil { 62 + return "", nil, fmt.Errorf("failed to get stored refresh token: %w", err) 63 + } 64 + 65 + // Parse DPoP key 66 + dpopKey, err := r.storage.GetDPoPKey(did) 67 + if err != nil { 68 + return "", nil, fmt.Errorf("failed to get DPoP key: %w", err) 69 + } 70 + 71 + // Create OAuth client with DPoP transport 72 + dpopTransport := NewDPoPTransport(http.DefaultTransport, dpopKey) 73 + httpClient := &http.Client{Transport: dpopTransport} 74 + 75 + // Discover PDS OAuth metadata 76 + metadata, err := DiscoverAuthServer(ctx, entry.PDS) 77 + if err != nil { 78 + return "", nil, fmt.Errorf("failed to discover auth server: %w", err) 79 + } 80 + 81 + // Configure OAuth2 client 82 + config := &oauth2.Config{ 83 + ClientID: r.clientID, 84 + Endpoint: oauth2.Endpoint{ 85 + AuthURL: metadata.AuthorizationEndpoint, 86 + TokenURL: metadata.TokenEndpoint, 87 + PushedAuthURL: metadata.PushedAuthorizationRequestEndpoint, 88 + }, 89 + RedirectURL: r.redirectURI, 90 + Scopes: []string{"atproto"}, 91 + } 92 + 93 + // Create context with custom HTTP client 94 + ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient) 95 + 96 + // Exchange refresh token for new access token 97 + token, err := config.TokenSource(ctxWithClient, &oauth2.Token{ 98 + RefreshToken: entry.RefreshToken, 99 + }).Token() 100 + if err != nil { 101 + return "", nil, fmt.Errorf("failed to refresh token: %w", err) 102 + } 103 + 104 + // Update last refresh timestamp 105 + if err := r.storage.UpdateLastRefresh(did); err != nil { 106 + // Log but don't fail - this is not critical 107 + fmt.Printf("WARNING: failed to update last refresh timestamp for %s: %v\n", did, err) 108 + } 109 + 110 + // If a new refresh token was issued, update storage 111 + if token.RefreshToken != "" && token.RefreshToken != entry.RefreshToken { 112 + entry.RefreshToken = token.RefreshToken 113 + if err := r.storage.Store(did, entry); err != nil { 114 + // Log but don't fail - we have the access token 115 + fmt.Printf("WARNING: failed to update refresh token for %s: %v\n", did, err) 116 + } 117 + } 118 + 119 + // Cache the access token 120 + // Expire 1 minute early to avoid edge cases 121 + expiresAt := token.Expiry.Add(-1 * time.Minute) 122 + 123 + r.mu.Lock() 124 + r.accessTokens[did] = &AccessTokenEntry{ 125 + Token: token.AccessToken, 126 + DPoPKey: dpopKey, 127 + ExpiresAt: expiresAt, 128 + } 129 + r.mu.Unlock() 130 + 131 + return token.AccessToken, dpopKey, nil 132 + } 133 + 134 + // RevokeToken removes stored refresh token and cached access token 135 + func (r *Refresher) RevokeToken(did string) error { 136 + r.mu.Lock() 137 + delete(r.accessTokens, did) 138 + r.mu.Unlock() 139 + 140 + return r.storage.Delete(did) 141 + } 142 + 143 + // CleanupExpiredTokens removes expired access tokens from cache 144 + // Should be called periodically (e.g., every hour) 145 + func (r *Refresher) CleanupExpiredTokens() { 146 + r.mu.Lock() 147 + defer r.mu.Unlock() 148 + 149 + now := time.Now() 150 + for did, entry := range r.accessTokens { 151 + if now.After(entry.ExpiresAt) { 152 + delete(r.accessTokens, did) 153 + } 154 + } 155 + } 156 + 157 + // StartCleanupRoutine starts a background goroutine to cleanup expired tokens 158 + func (r *Refresher) StartCleanupRoutine(interval time.Duration) { 159 + go func() { 160 + ticker := time.NewTicker(interval) 161 + defer ticker.Stop() 162 + 163 + for range ticker.C { 164 + r.CleanupExpiredTokens() 165 + } 166 + }() 167 + }
+342
pkg/auth/oauth/server.go
··· 1 + package oauth 2 + 3 + import ( 4 + "context" 5 + "crypto/ecdsa" 6 + "crypto/rand" 7 + "fmt" 8 + "html/template" 9 + "net/http" 10 + "sync" 11 + "time" 12 + 13 + "atcr.io/pkg/atproto" 14 + "atcr.io/pkg/auth/session" 15 + "authelia.com/client/oauth2" 16 + ) 17 + 18 + // Server handles OAuth authorization for the AppView 19 + type Server struct { 20 + storage *RefreshTokenStorage 21 + sessionManager *session.Manager 22 + resolver *atproto.Resolver 23 + clientID string 24 + redirectURI string 25 + baseURL string 26 + states map[string]*OAuthState 27 + statesMu sync.RWMutex 28 + } 29 + 30 + // OAuthState tracks an in-progress OAuth flow 31 + type OAuthState struct { 32 + State string 33 + Handle string 34 + DID string 35 + PDSEndpoint string 36 + CodeVerifier string 37 + DPoPKey *ecdsa.PrivateKey 38 + CreatedAt time.Time 39 + } 40 + 41 + // NewServer creates a new OAuth server 42 + func NewServer(storage *RefreshTokenStorage, sessionManager *session.Manager, baseURL string) *Server { 43 + // Create client ID based on AppView's base URL 44 + cfg := ClientIDConfig{ 45 + BaseURL: baseURL, 46 + CallbackPath: "/auth/oauth/callback", 47 + Scopes: []string{"atproto"}, 48 + } 49 + clientID, redirectURI := cfg.MakeClientID() 50 + 51 + return &Server{ 52 + storage: storage, 53 + sessionManager: sessionManager, 54 + resolver: atproto.NewResolver(), 55 + clientID: clientID, 56 + redirectURI: redirectURI, 57 + baseURL: baseURL, 58 + states: make(map[string]*OAuthState), 59 + } 60 + } 61 + 62 + // ServeAuthorize handles GET /auth/oauth/authorize 63 + func (s *Server) ServeAuthorize(w http.ResponseWriter, r *http.Request) { 64 + if r.Method != http.MethodGet { 65 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 66 + return 67 + } 68 + 69 + // Get handle from query parameter 70 + handle := r.URL.Query().Get("handle") 71 + if handle == "" { 72 + http.Error(w, "handle parameter required", http.StatusBadRequest) 73 + return 74 + } 75 + 76 + fmt.Printf("DEBUG [oauth/server]: Starting OAuth flow for handle=%s\n", handle) 77 + 78 + // Resolve handle to DID and PDS 79 + did, pdsEndpoint, err := s.resolver.ResolveIdentity(r.Context(), handle) 80 + if err != nil { 81 + fmt.Printf("ERROR [oauth/server]: Failed to resolve handle: %v\n", err) 82 + http.Error(w, fmt.Sprintf("failed to resolve handle: %v", err), http.StatusBadRequest) 83 + return 84 + } 85 + 86 + fmt.Printf("DEBUG [oauth/server]: Resolved handle=%s -> did=%s, pds=%s\n", handle, did, pdsEndpoint) 87 + 88 + // Create OAuth client 89 + fmt.Printf("DEBUG [oauth/server]: Creating OAuth client with clientID=%s, redirectURI=%s\n", s.clientID, s.redirectURI) 90 + client, err := NewClient(s.clientID, s.redirectURI) 91 + if err != nil { 92 + fmt.Printf("ERROR [oauth/server]: Failed to create OAuth client: %v\n", err) 93 + http.Error(w, fmt.Sprintf("failed to create OAuth client: %v", err), http.StatusInternalServerError) 94 + return 95 + } 96 + 97 + // Initialize for the handle's PDS 98 + fmt.Printf("DEBUG [oauth/server]: Initializing OAuth client for handle=%s\n", handle) 99 + if err := client.InitializeForHandle(r.Context(), handle); err != nil { 100 + fmt.Printf("ERROR [oauth/server]: Failed to initialize OAuth: %v\n", err) 101 + http.Error(w, fmt.Sprintf("failed to initialize OAuth: %v", err), http.StatusInternalServerError) 102 + return 103 + } 104 + 105 + // Generate authorization URL 106 + state := generateState() 107 + fmt.Printf("DEBUG [oauth/server]: Generating authorization URL with state=%s\n", state) 108 + authURL, codeVerifier, err := client.AuthorizeURL(state) 109 + if err != nil { 110 + fmt.Printf("ERROR [oauth/server]: Failed to generate auth URL: %v\n", err) 111 + http.Error(w, fmt.Sprintf("failed to generate auth URL: %v", err), http.StatusInternalServerError) 112 + return 113 + } 114 + 115 + fmt.Printf("DEBUG [oauth/server]: Generated authURL=%s\n", authURL) 116 + 117 + // Store state for callback 118 + s.statesMu.Lock() 119 + s.states[state] = &OAuthState{ 120 + State: state, 121 + Handle: handle, 122 + DID: did, 123 + PDSEndpoint: pdsEndpoint, 124 + CodeVerifier: codeVerifier, 125 + DPoPKey: client.dpopKey, 126 + CreatedAt: time.Now(), 127 + } 128 + s.statesMu.Unlock() 129 + 130 + // Redirect to PDS authorization page 131 + http.Redirect(w, r, authURL, http.StatusFound) 132 + } 133 + 134 + // ServeCallback handles GET /auth/oauth/callback 135 + func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) { 136 + if r.Method != http.MethodGet { 137 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 138 + return 139 + } 140 + 141 + // Get code and state from query parameters 142 + code := r.URL.Query().Get("code") 143 + state := r.URL.Query().Get("state") 144 + 145 + if code == "" || state == "" { 146 + s.renderError(w, "Missing code or state parameter") 147 + return 148 + } 149 + 150 + // Retrieve OAuth state 151 + s.statesMu.Lock() 152 + oauthState, ok := s.states[state] 153 + delete(s.states, state) // Consume state 154 + s.statesMu.Unlock() 155 + 156 + if !ok { 157 + s.renderError(w, "Invalid or expired state") 158 + return 159 + } 160 + 161 + // Exchange code for tokens 162 + sessionToken, err := s.exchangeCodeForSession(r.Context(), code, oauthState) 163 + if err != nil { 164 + s.renderError(w, fmt.Sprintf("Failed to exchange code: %v", err)) 165 + return 166 + } 167 + 168 + // Render success page with session token 169 + s.renderSuccess(w, sessionToken, oauthState.Handle) 170 + } 171 + 172 + // exchangeCodeForSession exchanges authorization code for tokens and creates session 173 + func (s *Server) exchangeCodeForSession(ctx context.Context, code string, state *OAuthState) (string, error) { 174 + // Discover OAuth metadata 175 + metadata, err := DiscoverAuthServer(ctx, state.PDSEndpoint) 176 + if err != nil { 177 + return "", fmt.Errorf("failed to discover auth server: %w", err) 178 + } 179 + 180 + // Create DPoP transport 181 + dpopTransport := NewDPoPTransport(http.DefaultTransport, state.DPoPKey) 182 + httpClient := &http.Client{Transport: dpopTransport} 183 + 184 + // Configure OAuth2 client 185 + config := &oauth2.Config{ 186 + ClientID: s.clientID, 187 + Endpoint: oauth2.Endpoint{ 188 + AuthURL: metadata.AuthorizationEndpoint, 189 + TokenURL: metadata.TokenEndpoint, 190 + PushedAuthURL: metadata.PushedAuthorizationRequestEndpoint, 191 + }, 192 + RedirectURL: s.redirectURI, 193 + Scopes: []string{"atproto"}, 194 + } 195 + 196 + // Create context with custom HTTP client 197 + ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient) 198 + 199 + // Exchange code for token 200 + token, err := config.Exchange(ctxWithClient, code, oauth2.VerifierOption(state.CodeVerifier)) 201 + if err != nil { 202 + return "", fmt.Errorf("failed to exchange code: %w", err) 203 + } 204 + 205 + // Encode DPoP key to PEM 206 + dpopKeyPEM, err := EncodeDPoPKey(state.DPoPKey) 207 + if err != nil { 208 + return "", fmt.Errorf("failed to encode DPoP key: %w", err) 209 + } 210 + 211 + // Store refresh token 212 + refreshEntry := &RefreshTokenEntry{ 213 + RefreshToken: token.RefreshToken, 214 + DPoPKeyPEM: dpopKeyPEM, 215 + PDS: state.PDSEndpoint, 216 + Handle: state.Handle, 217 + CreatedAt: time.Now(), 218 + LastRefresh: time.Now(), 219 + } 220 + 221 + if err := s.storage.Store(state.DID, refreshEntry); err != nil { 222 + return "", fmt.Errorf("failed to store refresh token: %w", err) 223 + } 224 + 225 + // Create session token for credential helper 226 + sessionToken, err := s.sessionManager.Create(state.DID, state.Handle) 227 + if err != nil { 228 + return "", fmt.Errorf("failed to create session token: %w", err) 229 + } 230 + 231 + return sessionToken, nil 232 + } 233 + 234 + // renderSuccess renders the success page 235 + func (s *Server) renderSuccess(w http.ResponseWriter, sessionToken, handle string) { 236 + tmpl := template.Must(template.New("success").Parse(successTemplate)) 237 + data := struct { 238 + SessionToken string 239 + Handle string 240 + }{ 241 + SessionToken: sessionToken, 242 + Handle: handle, 243 + } 244 + 245 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 246 + if err := tmpl.Execute(w, data); err != nil { 247 + http.Error(w, "failed to render template", http.StatusInternalServerError) 248 + } 249 + } 250 + 251 + // renderError renders an error page 252 + func (s *Server) renderError(w http.ResponseWriter, message string) { 253 + tmpl := template.Must(template.New("error").Parse(errorTemplate)) 254 + data := struct { 255 + Message string 256 + }{ 257 + Message: message, 258 + } 259 + 260 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 261 + w.WriteHeader(http.StatusBadRequest) 262 + if err := tmpl.Execute(w, data); err != nil { 263 + http.Error(w, "failed to render template", http.StatusInternalServerError) 264 + } 265 + } 266 + 267 + // CleanupExpiredStates removes expired OAuth states 268 + // Should be called periodically 269 + func (s *Server) CleanupExpiredStates() { 270 + s.statesMu.Lock() 271 + defer s.statesMu.Unlock() 272 + 273 + now := time.Now() 274 + for state, oauthState := range s.states { 275 + // States expire after 10 minutes 276 + if now.Sub(oauthState.CreatedAt) > 10*time.Minute { 277 + delete(s.states, state) 278 + } 279 + } 280 + } 281 + 282 + // generateState generates a random state parameter 283 + func generateState() string { 284 + b := make([]byte, 32) 285 + rand.Read(b) 286 + return fmt.Sprintf("%x", b) 287 + } 288 + 289 + // HTML templates 290 + 291 + const successTemplate = ` 292 + <!DOCTYPE html> 293 + <html> 294 + <head> 295 + <title>Authorization Successful - ATCR</title> 296 + <style> 297 + body { font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; } 298 + .success { background: #d4edda; border: 1px solid #c3e6cb; padding: 20px; border-radius: 5px; } 299 + code { background: #f5f5f5; padding: 10px; display: block; margin: 10px 0; word-break: break-all; } 300 + .copy-btn { background: #007bff; color: white; border: none; padding: 10px 20px; cursor: pointer; border-radius: 5px; } 301 + .copy-btn:hover { background: #0056b3; } 302 + </style> 303 + </head> 304 + <body> 305 + <div class="success"> 306 + <h1>✓ Authorization Successful!</h1> 307 + <p>You have successfully authorized ATCR to access your ATProto account: <strong>{{.Handle}}</strong></p> 308 + <p>Copy the session token below and paste it into your credential helper:</p> 309 + <code id="token">{{.SessionToken}}</code> 310 + <button class="copy-btn" onclick="copyToken()">Copy Token</button> 311 + </div> 312 + <script> 313 + function copyToken() { 314 + const token = document.getElementById('token').textContent; 315 + navigator.clipboard.writeText(token).then(() => { 316 + alert('Token copied to clipboard!'); 317 + }); 318 + } 319 + </script> 320 + </body> 321 + </html> 322 + ` 323 + 324 + const errorTemplate = ` 325 + <!DOCTYPE html> 326 + <html> 327 + <head> 328 + <title>Authorization Failed - ATCR</title> 329 + <style> 330 + body { font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; } 331 + .error { background: #f8d7da; border: 1px solid #f5c6cb; padding: 20px; border-radius: 5px; } 332 + </style> 333 + </head> 334 + <body> 335 + <div class="error"> 336 + <h1>✗ Authorization Failed</h1> 337 + <p>{{.Message}}</p> 338 + <p><a href="/">Return to home</a></p> 339 + </div> 340 + </body> 341 + </html> 342 + `
+200
pkg/auth/oauth/tokenstorage.go
··· 1 + package oauth 2 + 3 + import ( 4 + "crypto/ecdsa" 5 + "crypto/x509" 6 + "encoding/json" 7 + "encoding/pem" 8 + "fmt" 9 + "os" 10 + "path/filepath" 11 + "sync" 12 + "time" 13 + ) 14 + 15 + // RefreshTokenEntry represents a stored refresh token for a user 16 + type RefreshTokenEntry struct { 17 + RefreshToken string `json:"refresh_token"` 18 + DPoPKeyPEM string `json:"dpop_key_pem"` 19 + PDS string `json:"pds_endpoint"` 20 + Handle string `json:"handle"` 21 + CreatedAt time.Time `json:"created_at"` 22 + LastRefresh time.Time `json:"last_refreshed"` 23 + } 24 + 25 + // RefreshTokenStorage manages persistent storage of refresh tokens 26 + type RefreshTokenStorage struct { 27 + path string 28 + tokens map[string]*RefreshTokenEntry 29 + mu sync.RWMutex 30 + } 31 + 32 + // StorageData represents the JSON structure stored on disk 33 + type StorageData struct { 34 + RefreshTokens map[string]*RefreshTokenEntry `json:"refresh_tokens"` 35 + } 36 + 37 + // NewRefreshTokenStorage creates a new refresh token storage 38 + func NewRefreshTokenStorage(path string) (*RefreshTokenStorage, error) { 39 + storage := &RefreshTokenStorage{ 40 + path: path, 41 + tokens: make(map[string]*RefreshTokenEntry), 42 + } 43 + 44 + // Load existing tokens if file exists 45 + if err := storage.load(); err != nil { 46 + if !os.IsNotExist(err) { 47 + return nil, fmt.Errorf("failed to load tokens: %w", err) 48 + } 49 + // File doesn't exist yet, that's ok 50 + } 51 + 52 + return storage, nil 53 + } 54 + 55 + // GetDefaultPath returns the default storage path 56 + func GetDefaultPath() (string, error) { 57 + homeDir, err := os.UserHomeDir() 58 + if err != nil { 59 + return "", fmt.Errorf("failed to get home directory: %w", err) 60 + } 61 + 62 + atcrDir := filepath.Join(homeDir, ".atcr") 63 + if err := os.MkdirAll(atcrDir, 0700); err != nil { 64 + return "", fmt.Errorf("failed to create .atcr directory: %w", err) 65 + } 66 + 67 + return filepath.Join(atcrDir, "appview-tokens.json"), nil 68 + } 69 + 70 + // Store saves a refresh token for a DID 71 + func (s *RefreshTokenStorage) Store(did string, entry *RefreshTokenEntry) error { 72 + s.mu.Lock() 73 + defer s.mu.Unlock() 74 + 75 + s.tokens[did] = entry 76 + return s.save() 77 + } 78 + 79 + // Get retrieves a refresh token for a DID 80 + func (s *RefreshTokenStorage) Get(did string) (*RefreshTokenEntry, error) { 81 + s.mu.RLock() 82 + defer s.mu.RUnlock() 83 + 84 + entry, ok := s.tokens[did] 85 + if !ok { 86 + return nil, fmt.Errorf("no refresh token found for DID: %s", did) 87 + } 88 + 89 + return entry, nil 90 + } 91 + 92 + // Delete removes a refresh token for a DID 93 + func (s *RefreshTokenStorage) Delete(did string) error { 94 + s.mu.Lock() 95 + defer s.mu.Unlock() 96 + 97 + delete(s.tokens, did) 98 + return s.save() 99 + } 100 + 101 + // List returns all stored DIDs 102 + func (s *RefreshTokenStorage) List() []string { 103 + s.mu.RLock() 104 + defer s.mu.RUnlock() 105 + 106 + dids := make([]string, 0, len(s.tokens)) 107 + for did := range s.tokens { 108 + dids = append(dids, did) 109 + } 110 + return dids 111 + } 112 + 113 + // GetDPoPKey retrieves and parses the DPoP private key for a DID 114 + func (s *RefreshTokenStorage) GetDPoPKey(did string) (*ecdsa.PrivateKey, error) { 115 + entry, err := s.Get(did) 116 + if err != nil { 117 + return nil, err 118 + } 119 + 120 + // Parse PEM encoded private key 121 + block, _ := pem.Decode([]byte(entry.DPoPKeyPEM)) 122 + if block == nil { 123 + return nil, fmt.Errorf("failed to parse PEM block") 124 + } 125 + 126 + // Parse EC private key 127 + key, err := x509.ParseECPrivateKey(block.Bytes) 128 + if err != nil { 129 + return nil, fmt.Errorf("failed to parse EC private key: %w", err) 130 + } 131 + 132 + return key, nil 133 + } 134 + 135 + // UpdateLastRefresh updates the last refresh timestamp for a DID 136 + func (s *RefreshTokenStorage) UpdateLastRefresh(did string) error { 137 + s.mu.Lock() 138 + defer s.mu.Unlock() 139 + 140 + entry, ok := s.tokens[did] 141 + if !ok { 142 + return fmt.Errorf("no refresh token found for DID: %s", did) 143 + } 144 + 145 + entry.LastRefresh = time.Now() 146 + return s.save() 147 + } 148 + 149 + // load reads tokens from disk 150 + func (s *RefreshTokenStorage) load() error { 151 + data, err := os.ReadFile(s.path) 152 + if err != nil { 153 + return err 154 + } 155 + 156 + var storageData StorageData 157 + if err := json.Unmarshal(data, &storageData); err != nil { 158 + return fmt.Errorf("failed to parse token storage: %w", err) 159 + } 160 + 161 + if storageData.RefreshTokens != nil { 162 + s.tokens = storageData.RefreshTokens 163 + } 164 + 165 + return nil 166 + } 167 + 168 + // save writes tokens to disk 169 + func (s *RefreshTokenStorage) save() error { 170 + storageData := StorageData{ 171 + RefreshTokens: s.tokens, 172 + } 173 + 174 + data, err := json.MarshalIndent(storageData, "", " ") 175 + if err != nil { 176 + return fmt.Errorf("failed to marshal tokens: %w", err) 177 + } 178 + 179 + // Write with restrictive permissions 180 + if err := os.WriteFile(s.path, data, 0600); err != nil { 181 + return fmt.Errorf("failed to write tokens: %w", err) 182 + } 183 + 184 + return nil 185 + } 186 + 187 + // EncodeDPoPKey encodes an ECDSA private key to PEM format 188 + func EncodeDPoPKey(key *ecdsa.PrivateKey) (string, error) { 189 + keyBytes, err := x509.MarshalECPrivateKey(key) 190 + if err != nil { 191 + return "", fmt.Errorf("failed to marshal private key: %w", err) 192 + } 193 + 194 + block := &pem.Block{ 195 + Type: "EC PRIVATE KEY", 196 + Bytes: keyBytes, 197 + } 198 + 199 + return string(pem.EncodeToMemory(block)), nil 200 + }
+7
pkg/auth/scope.go
··· 57 57 continue 58 58 } 59 59 60 + // Allow wildcard scope (e.g., "repository:*:pull,push") 61 + // This is used by Docker credential helpers to request broad permissions 62 + // Actual authorization happens later when accessing specific repositories 63 + if entry.Name == "*" { 64 + continue 65 + } 66 + 60 67 // Extract the owner from repository name (e.g., "alice/myapp" -> "alice") 61 68 parts := strings.SplitN(entry.Name, "/", 2) 62 69 if len(parts) < 1 {
+170
pkg/auth/session/handler.go
··· 1 + package session 2 + 3 + import ( 4 + "crypto/hmac" 5 + "crypto/rand" 6 + "crypto/sha256" 7 + "encoding/base64" 8 + "encoding/json" 9 + "fmt" 10 + "os" 11 + "strings" 12 + "time" 13 + ) 14 + 15 + // SessionClaims represents the data stored in a session token 16 + type SessionClaims struct { 17 + DID string `json:"did"` 18 + Handle string `json:"handle"` 19 + IssuedAt time.Time `json:"issued_at"` 20 + ExpiresAt time.Time `json:"expires_at"` 21 + } 22 + 23 + // Manager handles session token creation and validation 24 + type Manager struct { 25 + secret []byte 26 + ttl time.Duration 27 + } 28 + 29 + // NewManager creates a new session manager 30 + func NewManager(secret []byte, ttl time.Duration) *Manager { 31 + return &Manager{ 32 + secret: secret, 33 + ttl: ttl, 34 + } 35 + } 36 + 37 + // NewManagerWithRandomSecret creates a session manager with a random secret 38 + func NewManagerWithRandomSecret(ttl time.Duration) (*Manager, error) { 39 + secret := make([]byte, 32) 40 + if _, err := rand.Read(secret); err != nil { 41 + return nil, fmt.Errorf("failed to generate secret: %w", err) 42 + } 43 + return NewManager(secret, ttl), nil 44 + } 45 + 46 + // NewManagerWithPersistentSecret creates a session manager with a persistent secret 47 + // The secret is stored at secretPath and reused across restarts 48 + func NewManagerWithPersistentSecret(secretPath string, ttl time.Duration) (*Manager, error) { 49 + var secret []byte 50 + 51 + // Try to load existing secret 52 + if data, err := os.ReadFile(secretPath); err == nil { 53 + secret = data 54 + fmt.Printf("Loaded existing session secret from %s\n", secretPath) 55 + } else if os.IsNotExist(err) { 56 + // Generate new secret 57 + secret = make([]byte, 32) 58 + if _, err := rand.Read(secret); err != nil { 59 + return nil, fmt.Errorf("failed to generate secret: %w", err) 60 + } 61 + 62 + // Save secret for future restarts 63 + if err := os.WriteFile(secretPath, secret, 0600); err != nil { 64 + return nil, fmt.Errorf("failed to save secret: %w", err) 65 + } 66 + fmt.Printf("Generated and saved new session secret to %s\n", secretPath) 67 + } else { 68 + return nil, fmt.Errorf("failed to read secret file: %w", err) 69 + } 70 + 71 + return NewManager(secret, ttl), nil 72 + } 73 + 74 + // Create generates a new session token for a DID 75 + func (m *Manager) Create(did, handle string) (string, error) { 76 + now := time.Now() 77 + claims := SessionClaims{ 78 + DID: did, 79 + Handle: handle, 80 + IssuedAt: now, 81 + ExpiresAt: now.Add(m.ttl), 82 + } 83 + 84 + // Marshal claims to JSON 85 + claimsJSON, err := json.Marshal(claims) 86 + if err != nil { 87 + return "", fmt.Errorf("failed to marshal claims: %w", err) 88 + } 89 + 90 + // Base64 encode claims 91 + claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON) 92 + 93 + // Generate HMAC signature 94 + sig := m.sign(claimsB64) 95 + sigB64 := base64.RawURLEncoding.EncodeToString(sig) 96 + 97 + // Token format: <claims>.<signature> 98 + token := claimsB64 + "." + sigB64 99 + 100 + return token, nil 101 + } 102 + 103 + // Validate validates a session token and returns the claims 104 + func (m *Manager) Validate(token string) (*SessionClaims, error) { 105 + // Split token into claims and signature 106 + parts := strings.Split(token, ".") 107 + if len(parts) != 2 { 108 + return nil, fmt.Errorf("invalid token format") 109 + } 110 + 111 + claimsB64 := parts[0] 112 + sigB64 := parts[1] 113 + 114 + // Verify signature 115 + expectedSig := m.sign(claimsB64) 116 + providedSig, err := base64.RawURLEncoding.DecodeString(sigB64) 117 + if err != nil { 118 + return nil, fmt.Errorf("invalid signature encoding: %w", err) 119 + } 120 + 121 + if !hmac.Equal(expectedSig, providedSig) { 122 + return nil, fmt.Errorf("invalid signature") 123 + } 124 + 125 + // Decode claims 126 + claimsJSON, err := base64.RawURLEncoding.DecodeString(claimsB64) 127 + if err != nil { 128 + return nil, fmt.Errorf("invalid claims encoding: %w", err) 129 + } 130 + 131 + var claims SessionClaims 132 + if err := json.Unmarshal(claimsJSON, &claims); err != nil { 133 + return nil, fmt.Errorf("invalid claims format: %w", err) 134 + } 135 + 136 + // Check expiration 137 + if time.Now().After(claims.ExpiresAt) { 138 + return nil, fmt.Errorf("token expired") 139 + } 140 + 141 + return &claims, nil 142 + } 143 + 144 + // sign generates HMAC-SHA256 signature for data 145 + func (m *Manager) sign(data string) []byte { 146 + h := hmac.New(sha256.New, m.secret) 147 + h.Write([]byte(data)) 148 + return h.Sum(nil) 149 + } 150 + 151 + // GetDID extracts the DID from a token without full validation 152 + // Useful for logging/debugging 153 + func (m *Manager) GetDID(token string) (string, error) { 154 + parts := strings.Split(token, ".") 155 + if len(parts) != 2 { 156 + return "", fmt.Errorf("invalid token format") 157 + } 158 + 159 + claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[0]) 160 + if err != nil { 161 + return "", fmt.Errorf("invalid claims encoding: %w", err) 162 + } 163 + 164 + var claims SessionClaims 165 + if err := json.Unmarshal(claimsJSON, &claims); err != nil { 166 + return "", fmt.Errorf("invalid claims format: %w", err) 167 + } 168 + 169 + return claims.DID, nil 170 + }
+49 -29
pkg/auth/token/handler.go
··· 10 10 mainAtproto "atcr.io/pkg/atproto" 11 11 "atcr.io/pkg/auth" 12 12 "atcr.io/pkg/auth/atproto" 13 + "atcr.io/pkg/auth/session" 13 14 ) 14 15 15 16 // Handler handles /auth/token requests 16 17 type Handler struct { 17 18 issuer *Issuer 18 19 validator *atproto.SessionValidator 20 + sessionManager *session.Manager // For validating session tokens 19 21 defaultHoldEndpoint string 20 22 } 21 23 22 24 // NewHandler creates a new token handler 23 - func NewHandler(issuer *Issuer, defaultHoldEndpoint string) *Handler { 25 + func NewHandler(issuer *Issuer, sessionManager *session.Manager, defaultHoldEndpoint string) *Handler { 24 26 return &Handler{ 25 27 issuer: issuer, 26 28 validator: atproto.NewSessionValidator(), 29 + sessionManager: sessionManager, 27 30 defaultHoldEndpoint: defaultHoldEndpoint, 28 31 } 29 32 } ··· 73 76 return 74 77 } 75 78 76 - // Validate credentials against ATProto and get access token 77 - fmt.Printf("DEBUG [token/handler]: Validating credentials for %s\n", username) 78 - did, _, accessToken, err := h.validator.CreateSessionAndGetToken(r.Context(), username, password) 79 - if err != nil { 80 - fmt.Printf("DEBUG [token/handler]: Credential validation failed: %v\n", err) 81 - w.Header().Set("WWW-Authenticate", `Basic realm="ATCR Registry"`) 82 - http.Error(w, fmt.Sprintf("authentication failed: %v", err), http.StatusUnauthorized) 83 - return 84 - } 79 + var did string 80 + var handle string 81 + var accessToken string 85 82 86 - fmt.Printf("DEBUG [token/handler]: Credentials validated successfully, DID=%s, AccessToken length=%d\n", did, len(accessToken)) 83 + // Try to validate as session token first (our OAuth flow) 84 + // Session tokens have format: <base64_claims>.<base64_signature> 85 + sessionClaims, sessionErr := h.sessionManager.Validate(password) 86 + if sessionErr == nil { 87 + // Successfully validated as session token 88 + did = sessionClaims.DID 89 + handle = sessionClaims.Handle 90 + fmt.Printf("DEBUG [token/handler]: Session token validated for DID=%s, handle=%s\n", did, handle) 91 + // For session tokens, we don't have a PDS access token here 92 + // The registry will use OAuth refresh tokens to get one when needed 93 + } else { 94 + // Not a session token, try app password (Basic Auth flow) 95 + fmt.Printf("DEBUG [token/handler]: Not a session token, trying app password for %s\n", username) 96 + did, handle, accessToken, err = h.validator.CreateSessionAndGetToken(r.Context(), username, password) 97 + if err != nil { 98 + fmt.Printf("DEBUG [token/handler]: App password validation failed: %v\n", err) 99 + w.Header().Set("WWW-Authenticate", `Basic realm="ATCR Registry"`) 100 + http.Error(w, fmt.Sprintf("authentication failed: %v", err), http.StatusUnauthorized) 101 + return 102 + } 87 103 88 - // Cache the access token for later use (e.g., when pushing manifests) 89 - // TTL of 2 hours (ATProto tokens typically last longer) 90 - auth.GetGlobalTokenCache().Set(did, accessToken, 2*time.Hour) 91 - fmt.Printf("DEBUG [token/handler]: Cached access token for DID=%s\n", did) 104 + fmt.Printf("DEBUG [token/handler]: App password validated successfully, DID=%s, handle=%s, AccessToken length=%d\n", did, handle, len(accessToken)) 92 105 93 - // Ensure user profile exists (creates with default hold if needed) 94 - // Resolve PDS endpoint for profile management 95 - resolver := mainAtproto.NewResolver() 96 - _, pdsEndpoint, err := resolver.ResolveIdentity(r.Context(), username) 97 - if err != nil { 98 - // Log error but don't fail auth - profile management is not critical 99 - fmt.Printf("WARNING: failed to resolve PDS for profile management: %v\n", err) 100 - } else { 101 - // Create ATProto client with validated token 102 - atprotoClient := mainAtproto.NewClient(pdsEndpoint, did, accessToken) 106 + // Cache the access token for later use (e.g., when pushing manifests) 107 + // TTL of 2 hours (ATProto tokens typically last longer) 108 + auth.GetGlobalTokenCache().Set(did, accessToken, 2*time.Hour) 109 + fmt.Printf("DEBUG [token/handler]: Cached access token for DID=%s\n", did) 103 110 104 - // Ensure profile exists (will create with default hold if not exists and default is configured) 105 - if err := mainAtproto.EnsureProfile(r.Context(), atprotoClient, h.defaultHoldEndpoint); err != nil { 111 + // Ensure user profile exists (creates with default hold if needed) 112 + // Resolve PDS endpoint for profile management 113 + resolver := mainAtproto.NewResolver() 114 + _, pdsEndpoint, err := resolver.ResolveIdentity(r.Context(), username) 115 + if err != nil { 106 116 // Log error but don't fail auth - profile management is not critical 107 - fmt.Printf("WARNING: failed to ensure profile for %s: %v\n", did, err) 117 + fmt.Printf("WARNING: failed to resolve PDS for profile management: %v\n", err) 118 + } else { 119 + // Create ATProto client with validated token 120 + atprotoClient := mainAtproto.NewClient(pdsEndpoint, did, accessToken) 121 + 122 + // Ensure profile exists (will create with default hold if not exists and default is configured) 123 + if err := mainAtproto.EnsureProfile(r.Context(), atprotoClient, h.defaultHoldEndpoint); err != nil { 124 + // Log error but don't fail auth - profile management is not critical 125 + fmt.Printf("WARNING: failed to ensure profile for %s: %v\n", did, err) 126 + } 108 127 } 109 128 } 110 129 111 130 // Validate that the user has permission for the requested access 112 - if err := auth.ValidateAccess(did, username, access); err != nil { 131 + // Use the actual handle from the validated credentials, not the Basic Auth username 132 + if err := auth.ValidateAccess(did, handle, access); err != nil { 113 133 fmt.Printf("DEBUG [token/handler]: Access validation failed: %v\n", err) 114 134 http.Error(w, fmt.Sprintf("access denied: %v", err), http.StatusForbidden) 115 135 return
+36 -10
pkg/middleware/registry.go
··· 13 13 14 14 "atcr.io/pkg/atproto" 15 15 "atcr.io/pkg/auth" 16 + "atcr.io/pkg/auth/oauth" 16 17 "atcr.io/pkg/storage" 17 18 ) 19 + 20 + // Global refresher instance (set by main.go) 21 + var globalRefresher *oauth.Refresher 22 + 23 + // SetGlobalRefresher sets the global OAuth refresher instance 24 + func SetGlobalRefresher(refresher *oauth.Refresher) { 25 + globalRefresher = refresher 26 + } 18 27 19 28 func init() { 20 29 // Register the name resolution middleware ··· 99 108 return nil, err 100 109 } 101 110 102 - // Wrap the repository with our routing repository 103 - // Get the cached access token for this DID 104 - accessToken, ok := auth.GetGlobalTokenCache().Get(did) 105 - if !ok { 106 - fmt.Printf("DEBUG [registry/middleware]: No cached access token found for DID=%s\n", did) 107 - accessToken = "" // Will fail on manifest push, but let it try 108 - } else { 109 - fmt.Printf("DEBUG [registry/middleware]: Using cached access token for DID=%s (length=%d)\n", did, len(accessToken)) 111 + // Get access token for PDS operations 112 + // Try OAuth refresher first (for users who authorized via AppView OAuth) 113 + // Fall back to Basic Auth token cache (for users who used app passwords) 114 + var atprotoClient *atproto.Client 115 + 116 + if globalRefresher != nil { 117 + // Try OAuth flow first 118 + accessToken, dpopKey, err := globalRefresher.GetAccessToken(ctx, did) 119 + if err == nil { 120 + // OAuth token available - create client with DPoP support 121 + fmt.Printf("DEBUG [registry/middleware]: Using OAuth access token for DID=%s\n", did) 122 + dpopTransport := oauth.NewDPoPTransport(nil, dpopKey) 123 + atprotoClient = atproto.NewClientWithDPoP(pdsEndpoint, did, accessToken, dpopKey, dpopTransport) 124 + } else { 125 + fmt.Printf("DEBUG [registry/middleware]: OAuth refresh failed for DID=%s: %v, falling back to Basic Auth\n", did, err) 126 + } 110 127 } 111 128 112 - // This is where we inject ATProto + storage routing 113 - atprotoClient := atproto.NewClient(pdsEndpoint, did, accessToken) 129 + // Fall back to Basic Auth token cache if OAuth not available 130 + if atprotoClient == nil { 131 + accessToken, ok := auth.GetGlobalTokenCache().Get(did) 132 + if !ok { 133 + fmt.Printf("DEBUG [registry/middleware]: No cached access token found for DID=%s (neither OAuth nor Basic Auth)\n", did) 134 + accessToken = "" // Will fail on manifest push, but let it try 135 + } else { 136 + fmt.Printf("DEBUG [registry/middleware]: Using Basic Auth access token for DID=%s (length=%d)\n", did, len(accessToken)) 137 + } 138 + atprotoClient = atproto.NewClient(pdsEndpoint, did, accessToken) 139 + } 114 140 115 141 // IMPORTANT: Use only the image name (not identity/image) for ATProto storage 116 142 // ATProto records are scoped to the user's DID, so we don't need the identity prefix