A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
80
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