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.

trying to consolidate oauth logic. trying to get credential helper working

+714 -426
+235 -20
cmd/credential-helper/main.go
··· 1 1 package main 2 2 3 3 import ( 4 + "bytes" 5 + "context" 4 6 "encoding/json" 5 7 "fmt" 8 + "io" 9 + "net/http" 6 10 "os" 7 11 "path/filepath" 12 + "strings" 13 + "time" 14 + 15 + "atcr.io/pkg/atproto" 16 + "atcr.io/pkg/auth/oauth" 8 17 ) 9 18 19 + const ( 20 + callbackPort = "8888" 21 + baseURL = "http://127.0.0.1:" + callbackPort 22 + callbackPath = "/callback" 23 + ) 24 + 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() 38 + } 39 + 10 40 // Docker credential helper protocol 11 41 // https://github.com/docker/docker-credential-helpers 12 42 ··· 19 49 20 50 func main() { 21 51 if len(os.Args) < 2 { 22 - fmt.Fprintf(os.Stderr, "Usage: docker-credential-atcr <get|store|erase|configure>\n") 52 + fmt.Fprintf(os.Stderr, "Usage: docker-credential-atcr <get|store|erase|configure [handle]>\n") 23 53 os.Exit(1) 24 54 } 25 55 ··· 33 63 case "erase": 34 64 handleErase() 35 65 case "configure": 36 - handleConfigure() 66 + // Optional handle argument 67 + var handle string 68 + if len(os.Args) > 2 { 69 + handle = os.Args[2] 70 + } 71 + handleConfigure(handle) 37 72 default: 38 73 fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command) 39 74 os.Exit(1) ··· 42 77 43 78 // handleGet retrieves credentials for the given server 44 79 func handleGet() { 45 - var request Credentials 46 - if err := json.NewDecoder(os.Stdin).Decode(&request); err != nil { 47 - fmt.Fprintf(os.Stderr, "Error decoding request: %v\n", err) 80 + // Docker sends the server URL as a plain string on stdin (not JSON) 81 + var serverURL string 82 + if _, err := fmt.Fscanln(os.Stdin, &serverURL); err != nil { 83 + fmt.Fprintf(os.Stderr, "Error reading server URL: %v\n", err) 48 84 os.Exit(1) 49 85 } 50 86 51 87 // Load token from storage 52 88 tokenPath := getTokenPath() 53 - token, err := loadToken(tokenPath) 89 + token, err := oauth.LoadTokenStore(tokenPath) 54 90 if err != nil { 55 91 fmt.Fprintf(os.Stderr, "Error loading token: %v\n", err) 56 92 os.Exit(1) 57 93 } 58 94 59 95 // Check if token is expired and refresh if needed 60 - if token.IsExpired && token.RefreshToken != "" { 61 - if err := refreshToken(token); err != nil { 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 { 62 124 fmt.Fprintf(os.Stderr, "Error refreshing token: %v\n", err) 63 125 os.Exit(1) 64 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 + } 65 138 } 66 139 67 140 // Exchange ATProto token for registry JWT 68 - registryJWT, err := exchangeForRegistryToken(token.AccessToken, request.ServerURL) 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) 69 143 if err != nil { 70 144 fmt.Fprintf(os.Stderr, "Error exchanging token: %v\n", err) 71 145 os.Exit(1) ··· 73 147 74 148 // Return credentials 75 149 creds := Credentials{ 76 - ServerURL: request.ServerURL, 150 + ServerURL: serverURL, 77 151 Username: "oauth2", 78 152 Secret: registryJWT, 79 153 } ··· 99 173 100 174 // handleErase removes stored credentials 101 175 func handleErase() { 102 - var request Credentials 103 - if err := json.NewDecoder(os.Stdin).Decode(&request); err != nil { 104 - fmt.Fprintf(os.Stderr, "Error decoding request: %v\n", err) 176 + // Docker sends the server URL as a plain string on stdin (not JSON) 177 + var serverURL string 178 + if _, err := fmt.Fscanln(os.Stdin, &serverURL); err != nil { 179 + fmt.Fprintf(os.Stderr, "Error reading server URL: %v\n", err) 105 180 os.Exit(1) 106 181 } 107 182 ··· 114 189 } 115 190 116 191 // handleConfigure runs the OAuth flow to get initial credentials 117 - func handleConfigure() { 192 + func handleConfigure(handle string) { 118 193 fmt.Println("ATCR Credential Helper Configuration") 119 194 fmt.Println("=====================================") 120 195 fmt.Println() 121 196 122 - // Ask for handle 123 - fmt.Print("Enter your ATProto handle (e.g., alice.bsky.social): ") 124 - var handle string 125 - if _, err := fmt.Scanln(&handle); err != nil { 126 - fmt.Fprintf(os.Stderr, "Error reading handle: %v\n", err) 127 - os.Exit(1) 197 + // Ask for handle if not provided as argument 198 + if handle == "" { 199 + fmt.Print("Enter your ATProto handle (e.g., alice.bsky.social): ") 200 + if _, err := fmt.Scanln(&handle); err != nil { 201 + fmt.Fprintf(os.Stderr, "Error reading handle: %v\n", err) 202 + os.Exit(1) 203 + } 204 + } else { 205 + fmt.Printf("Using handle: %s\n", handle) 128 206 } 129 207 130 208 // Run OAuth flow ··· 156 234 157 235 return filepath.Join(homeDir, ".atcr", "oauth-token.json") 158 236 } 237 + 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 242 + 243 + // Normalize registry URL - add scheme if missing 244 + if !strings.HasPrefix(registryURL, "http://") && !strings.HasPrefix(registryURL, "https://") { 245 + registryURL = "http://" + registryURL 246 + } 247 + 248 + exchangeURL := fmt.Sprintf("%s/auth/exchange", registryURL) 249 + 250 + reqBody := map[string]any{ 251 + "access_token": atprotoToken, 252 + "handle": handle, // Required for PDS resolution and token validation 253 + "scope": []string{"repository:*:pull,push"}, 254 + } 255 + 256 + body, err := json.Marshal(reqBody) 257 + if err != nil { 258 + return "", fmt.Errorf("failed to marshal request: %w", err) 259 + } 260 + 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 + req, err := http.NewRequest("POST", exchangeURL, bytes.NewReader(body)) 273 + if err != nil { 274 + return "", fmt.Errorf("failed to create request: %w", err) 275 + } 276 + req.Header.Set("Content-Type", "application/json") 277 + 278 + resp, err := client.Do(req) 279 + if err != nil { 280 + return "", fmt.Errorf("failed to call exchange endpoint: %w", err) 281 + } 282 + defer resp.Body.Close() 283 + 284 + if resp.StatusCode != http.StatusOK { 285 + // Read response body for debugging 286 + 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) 290 + } 291 + 292 + var result struct { 293 + Token string `json:"token"` 294 + AccessToken string `json:"access_token"` 295 + } 296 + 297 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 298 + return "", fmt.Errorf("failed to decode response: %w", err) 299 + } 300 + 301 + if result.Token != "" { 302 + return result.Token, nil 303 + } 304 + return result.AccessToken, nil 305 + } 306 + 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 + }
-176
cmd/credential-helper/oauth.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "net/http" 7 - "os/exec" 8 - "runtime" 9 - "time" 10 - 11 - "atcr.io/pkg/atproto" 12 - "atcr.io/pkg/auth/oauth" 13 - ) 14 - 15 - const ( 16 - clientID = "http://localhost:8888/client-metadata.json" 17 - redirectURI = "http://localhost:8888/callback" 18 - ) 19 - 20 - // runOAuthFlow executes the OAuth flow with browser 21 - func runOAuthFlow(handle string) (*oauth.TokenStore, error) { 22 - // Create OAuth client 23 - client, err := oauth.NewClient(clientID, redirectURI) 24 - if err != nil { 25 - return nil, fmt.Errorf("failed to create OAuth client: %w", err) 26 - } 27 - 28 - // Initialize for the given handle 29 - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 30 - defer cancel() 31 - 32 - if err := client.InitializeForHandle(ctx, handle); err != nil { 33 - return nil, fmt.Errorf("failed to initialize client: %w", err) 34 - } 35 - 36 - // Start local callback server 37 - codeChan := make(chan string, 1) 38 - errChan := make(chan error, 1) 39 - server := startCallbackServer(codeChan, errChan) 40 - defer server.Shutdown(context.Background()) 41 - 42 - // Also serve client metadata 43 - http.HandleFunc("/client-metadata.json", oauth.ServeMetadata( 44 - oauth.NewClientMetadata(clientID, []string{redirectURI}), 45 - )) 46 - 47 - // Generate authorization URL with PKCE 48 - state := generateState() 49 - authURL, codeVerifier, err := client.AuthorizeURL(state) 50 - if err != nil { 51 - return nil, fmt.Errorf("failed to generate auth URL: %w", err) 52 - } 53 - 54 - // Open browser 55 - fmt.Printf("Opening browser to: %s\n", authURL) 56 - if err := openBrowser(authURL); err != nil { 57 - fmt.Printf("Failed to open browser automatically. Please open this URL manually:\n%s\n", authURL) 58 - } 59 - 60 - // Wait for callback 61 - var code string 62 - select { 63 - case code = <-codeChan: 64 - fmt.Println("Authorization successful!") 65 - case err := <-errChan: 66 - return nil, fmt.Errorf("authorization failed: %w", err) 67 - case <-time.After(5 * time.Minute): 68 - return nil, fmt.Errorf("authorization timed out") 69 - } 70 - 71 - // Exchange code for token 72 - ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second) 73 - defer cancel() 74 - 75 - token, err := client.Exchange(ctx, code, codeVerifier) 76 - if err != nil { 77 - return nil, fmt.Errorf("failed to exchange code: %w", err) 78 - } 79 - 80 - // Resolve handle to get DID 81 - resolver := atproto.NewResolver() 82 - did, _, err := resolver.ResolveIdentity(context.Background(), handle) 83 - if err != nil { 84 - return nil, fmt.Errorf("failed to resolve DID: %w", err) 85 - } 86 - 87 - // Create token store 88 - store := &oauth.TokenStore{ 89 - AccessToken: token.AccessToken, 90 - RefreshToken: token.RefreshToken, 91 - TokenType: token.TokenType, 92 - ExpiresAt: token.Expiry, 93 - Handle: handle, 94 - DID: did, 95 - } 96 - 97 - // Save DPoP key 98 - if err := store.SetDPoPKey(client.DPoPKey()); err != nil { 99 - return nil, fmt.Errorf("failed to save DPoP key: %w", err) 100 - } 101 - 102 - return store, nil 103 - } 104 - 105 - // startCallbackServer starts a local HTTP server to receive the OAuth callback 106 - func startCallbackServer(codeChan chan string, errChan chan error) *http.Server { 107 - mux := http.NewServeMux() 108 - 109 - mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { 110 - code := r.URL.Query().Get("code") 111 - errorParam := r.URL.Query().Get("error") 112 - 113 - if errorParam != "" { 114 - errChan <- fmt.Errorf("OAuth error: %s (%s)", 115 - errorParam, 116 - r.URL.Query().Get("error_description")) 117 - http.Error(w, "Authorization failed", http.StatusBadRequest) 118 - return 119 - } 120 - 121 - if code == "" { 122 - errChan <- fmt.Errorf("no code in callback") 123 - http.Error(w, "No code provided", http.StatusBadRequest) 124 - return 125 - } 126 - 127 - codeChan <- code 128 - w.Header().Set("Content-Type", "text/html") 129 - fmt.Fprintf(w, ` 130 - <html> 131 - <head><title>ATCR Authorization</title></head> 132 - <body> 133 - <h1>Authorization Successful!</h1> 134 - <p>You can close this window and return to the terminal.</p> 135 - </body> 136 - </html> 137 - `) 138 - }) 139 - 140 - server := &http.Server{ 141 - Addr: ":8888", 142 - Handler: mux, 143 - } 144 - 145 - go func() { 146 - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { 147 - errChan <- fmt.Errorf("callback server error: %w", err) 148 - } 149 - }() 150 - 151 - return server 152 - } 153 - 154 - // openBrowser opens the default browser to the given URL 155 - func openBrowser(url string) error { 156 - var cmd *exec.Cmd 157 - 158 - switch runtime.GOOS { 159 - case "darwin": 160 - cmd = exec.Command("open", url) 161 - case "linux": 162 - cmd = exec.Command("xdg-open", url) 163 - case "windows": 164 - cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) 165 - default: 166 - return fmt.Errorf("unsupported platform") 167 - } 168 - 169 - return cmd.Start() 170 - } 171 - 172 - // generateState generates a random state parameter 173 - func generateState() string { 174 - // Use the same UUID generation as we do elsewhere 175 - return fmt.Sprintf("state_%d", time.Now().UnixNano()) 176 - }
-119
cmd/credential-helper/token.go
··· 1 - package main 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "encoding/json" 7 - "fmt" 8 - "net/http" 9 - "time" 10 - 11 - "atcr.io/pkg/auth/oauth" 12 - ) 13 - 14 - // tokenData holds the token information 15 - type tokenData struct { 16 - *oauth.TokenStore 17 - IsExpired bool 18 - } 19 - 20 - // loadToken loads the token from disk 21 - func loadToken(path string) (*tokenData, error) { 22 - store, err := oauth.LoadTokenStore(path) 23 - if err != nil { 24 - return nil, err 25 - } 26 - 27 - return &tokenData{ 28 - TokenStore: store, 29 - IsExpired: store.IsExpired(), 30 - }, nil 31 - } 32 - 33 - // refreshToken refreshes an expired token 34 - func refreshToken(token *tokenData) error { 35 - // Create OAuth client 36 - client, err := oauth.NewClient("http://localhost:8888/client-metadata.json", "http://localhost:8888/callback") 37 - if err != nil { 38 - return fmt.Errorf("failed to create OAuth client: %w", err) 39 - } 40 - 41 - // Load DPoP key 42 - dpopKey, err := token.GetDPoPKey() 43 - if err != nil { 44 - return fmt.Errorf("failed to load DPoP key: %w", err) 45 - } 46 - client.SetDPoPKey(dpopKey) 47 - 48 - // Initialize for the handle 49 - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 50 - defer cancel() 51 - 52 - if err := client.InitializeForHandle(ctx, token.Handle); err != nil { 53 - return fmt.Errorf("failed to initialize client: %w", err) 54 - } 55 - 56 - // Refresh the token 57 - newToken, err := client.RefreshToken(ctx, token.RefreshToken) 58 - if err != nil { 59 - return fmt.Errorf("failed to refresh token: %w", err) 60 - } 61 - 62 - // Update token store 63 - token.AccessToken = newToken.AccessToken 64 - token.RefreshToken = newToken.RefreshToken 65 - token.ExpiresAt = newToken.Expiry 66 - token.IsExpired = false 67 - 68 - // Save updated token 69 - return token.Save(getTokenPath()) 70 - } 71 - 72 - // exchangeForRegistryToken exchanges the ATProto OAuth token for a registry JWT 73 - func exchangeForRegistryToken(atprotoToken, registryURL string) (string, error) { 74 - // Call the registry's /auth/exchange endpoint 75 - // This endpoint validates the ATProto token and returns a registry JWT 76 - 77 - exchangeURL := fmt.Sprintf("%s/auth/exchange", registryURL) 78 - 79 - // Load token store to get DID/handle 80 - store, err := loadToken(getTokenPath()) 81 - if err != nil { 82 - return "", fmt.Errorf("failed to load token store: %w", err) 83 - } 84 - 85 - reqBody := map[string]any{ 86 - "access_token": atprotoToken, 87 - "handle": store.Handle, // Required for PDS resolution and token validation 88 - "scope": []string{"repository:*:pull,push"}, 89 - } 90 - 91 - body, err := json.Marshal(reqBody) 92 - if err != nil { 93 - return "", fmt.Errorf("failed to marshal request: %w", err) 94 - } 95 - 96 - resp, err := http.Post(exchangeURL, "application/json", bytes.NewReader(body)) 97 - if err != nil { 98 - return "", fmt.Errorf("failed to call exchange endpoint: %w", err) 99 - } 100 - defer resp.Body.Close() 101 - 102 - if resp.StatusCode != http.StatusOK { 103 - return "", fmt.Errorf("exchange failed with status %d", resp.StatusCode) 104 - } 105 - 106 - var result struct { 107 - Token string `json:"token"` 108 - AccessToken string `json:"access_token"` 109 - } 110 - 111 - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 112 - return "", fmt.Errorf("failed to decode response: %w", err) 113 - } 114 - 115 - if result.Token != "" { 116 - return result.Token, nil 117 - } 118 - return result.AccessToken, nil 119 - }
+58 -108
cmd/hold/main.go
··· 67 67 68 68 // HoldService provides presigned URLs for blob storage in a hold 69 69 type HoldService struct { 70 - driver storagedriver.StorageDriver 71 - config *Config 72 - oauthCodeCh chan string 73 - oauthErrCh chan error 74 - oauthState string 75 - codeVerifier string 70 + driver storagedriver.StorageDriver 71 + config *Config 76 72 } 77 73 78 74 // NewHoldService creates a new hold service ··· 85 81 } 86 82 87 83 return &HoldService{ 88 - driver: driver, 89 - config: cfg, 90 - oauthCodeCh: make(chan string, 1), 91 - oauthErrCh: make(chan error, 1), 84 + driver: driver, 85 + config: cfg, 92 86 }, nil 93 87 } 94 88 ··· 550 544 }) 551 545 } 552 546 553 - // HandleOAuthCallback handles OAuth callback from authorization server 554 - func (s *HoldService) HandleOAuthCallback(w http.ResponseWriter, r *http.Request) { 555 - code := r.URL.Query().Get("code") 556 - receivedState := r.URL.Query().Get("state") 557 - 558 - if receivedState != s.oauthState { 559 - s.oauthErrCh <- fmt.Errorf("invalid state parameter") 560 - http.Error(w, "Invalid state", http.StatusBadRequest) 561 - return 562 - } 563 - 564 - if code == "" { 565 - s.oauthErrCh <- fmt.Errorf("no authorization code received") 566 - http.Error(w, "No code", http.StatusBadRequest) 567 - return 568 - } 569 - 570 - w.Header().Set("Content-Type", "text/html") 571 - fmt.Fprintf(w, `<html><body><h1>Authorization Successful!</h1><p>You can close this window and return to the terminal.</p></body></html>`) 572 - 573 - // Send code to registration flow 574 - select { 575 - case s.oauthCodeCh <- code: 576 - default: 577 - // Channel already has a value or nobody is listening 578 - } 579 - } 580 - 581 547 func main() { 582 548 // Load configuration from environment variables 583 549 cfg, err := loadConfigFromEnv() ··· 597 563 mux.HandleFunc("/register", service.HandleRegister) 598 564 mux.HandleFunc("/get-presigned-url", service.HandleGetPresignedURL) 599 565 mux.HandleFunc("/put-presigned-url", service.HandlePutPresignedURL) 600 - mux.HandleFunc("/oauth/callback", service.HandleOAuthCallback) // OAuth callback on same port 601 566 602 567 // OAuth client metadata endpoint for ATProto OAuth 603 568 clientID := cfg.Server.PublicURL + "/client-metadata.json" ··· 606 571 clientMetadata.ApplicationType = "web" // Changed from "native" since this is a web service 607 572 mux.HandleFunc("/client-metadata.json", oauth.ServeMetadata(clientMetadata)) 608 573 mux.HandleFunc("/blobs/", func(w http.ResponseWriter, r *http.Request) { 609 - if r.Method == http.MethodGet || r.Method == http.MethodHead { 574 + switch r.Method { 575 + case http.MethodGet, http.MethodHead: 610 576 service.HandleProxyGet(w, r) 611 - } else if r.Method == http.MethodPut { 577 + case http.MethodPut: 612 578 service.HandleProxyPut(w, r) 613 - } else { 579 + default: 614 580 http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 615 581 } 616 582 }) ··· 837 803 838 804 // registerWithOAuth performs OAuth flow and registers the hold 839 805 func (s *HoldService) registerWithOAuth(publicURL, handle, did, pdsEndpoint string) error { 840 - // Extract port from publicURL for test mode 841 - var redirectURI string 842 - var clientID string 806 + // Define the scopes we need for hold registration 807 + holdScopes := []string{ 808 + "atproto", 809 + fmt.Sprintf("repo:%s?action=create", atproto.HoldCollection), 810 + fmt.Sprintf("repo:%s?action=update", atproto.HoldCollection), 811 + fmt.Sprintf("repo:%s?action=create", atproto.HoldCrewCollection), 812 + fmt.Sprintf("repo:%s?action=update", atproto.HoldCrewCollection), 813 + } 843 814 844 - // Define the scopes we need for hold registration 845 - // Need create and update permissions for hold and crew collections 846 - scopes := fmt.Sprintf("atproto repo:%s?action=create repo:%s?action=update repo:%s?action=create repo:%s?action=update", 847 - atproto.HoldCollection, atproto.HoldCollection, 848 - atproto.HoldCrewCollection, atproto.HoldCrewCollection) 815 + // Determine base URL and client ID based on mode 816 + var baseURL, callbackPath string 817 + callbackPath = "/oauth/callback" 849 818 850 819 if s.config.Server.TestMode { 851 820 // Test mode: Use localhost for OAuth (browser accessible) but store real URL in hold record ··· 858 827 if port == "" { 859 828 port = "8080" // default 860 829 } 861 - redirectURI = fmt.Sprintf("http://127.0.0.1:%s/oauth/callback", port) 862 - clientID = fmt.Sprintf("http://localhost?redirect_uri=%s&scope=%s", 863 - url.QueryEscape(redirectURI), url.QueryEscape(scopes)) 864 - } else if strings.Contains(publicURL, "127.0.0.1") || strings.Contains(publicURL, "localhost") { 865 - // Localhost development mode per ATProto OAuth spec 866 - redirectURI = publicURL + "/oauth/callback" 867 - clientID = fmt.Sprintf("http://localhost?redirect_uri=%s&scope=%s", 868 - url.QueryEscape(redirectURI), url.QueryEscape(scopes)) 830 + baseURL = fmt.Sprintf("http://127.0.0.1:%s", port) 869 831 } else { 870 - // Production mode - use client metadata URL 871 - redirectURI = publicURL + "/oauth/callback" 872 - clientID = publicURL + "/client-metadata.json" 832 + baseURL = publicURL 873 833 } 874 834 875 - // Create OAuth client 876 - oauthClient, err := oauth.NewClient(clientID, redirectURI) 877 - if err != nil { 878 - return fmt.Errorf("failed to create OAuth client: %w", err) 835 + // Use shared helper to construct client ID 836 + cfg := oauth.ClientIDConfig{ 837 + BaseURL: baseURL, 838 + CallbackPath: callbackPath, 839 + Scopes: holdScopes, 879 840 } 841 + clientID, redirectURI := cfg.MakeClientID() 880 842 881 - // Initialize for the user's handle 843 + // Run interactive OAuth flow with persistent server 882 844 ctx := context.Background() 883 - if err := oauthClient.InitializeForHandle(ctx, handle); err != nil { 884 - return fmt.Errorf("failed to initialize OAuth: %w", err) 885 - } 845 + result, err := oauth.RunInteractiveFlow( 846 + ctx, 847 + oauth.InteractiveFlowConfig{ 848 + ClientID: clientID, 849 + RedirectURI: redirectURI, 850 + Handle: handle, 851 + Scopes: holdScopes, 852 + }, 853 + func(authURL string, handler *oauth.CallbackHandler, metadata *oauth.ClientMetadata) error { 854 + // First call (authURL empty): register callback handler 855 + if authURL == "" { 856 + // Register callback on existing server (persistent server pattern) 857 + // Note: metadata is not used here since hold service serves it separately on main server 858 + http.HandleFunc("/oauth/callback", handler.ServeHTTP) 859 + return nil 860 + } 886 861 887 - // Set the scopes we need for hold registration (create and update) 888 - oauthClient.SetScopes([]string{ 889 - "atproto", 890 - fmt.Sprintf("repo:%s?action=create", atproto.HoldCollection), 891 - fmt.Sprintf("repo:%s?action=update", atproto.HoldCollection), 892 - fmt.Sprintf("repo:%s?action=create", atproto.HoldCrewCollection), 893 - fmt.Sprintf("repo:%s?action=update", atproto.HoldCrewCollection), 894 - }) 862 + // Second call (authURL populated): display URL 863 + // Print the OAuth URL for user to visit (hold-specific formatting) 864 + log.Print("\n" + strings.Repeat("=", 80)) 865 + log.Printf("OAUTH AUTHORIZATION REQUIRED") 866 + log.Print(strings.Repeat("=", 80)) 867 + log.Printf("\nPlease visit this URL to authorize the hold service:\n") 868 + log.Printf(" %s\n", authURL) 869 + log.Printf("Waiting for authorization...") 870 + log.Print(strings.Repeat("=", 80) + "\n") 895 871 896 - // Generate authorization URL 897 - s.oauthState = "hold-registration" 898 - authURL, codeVerifier, err := oauthClient.AuthorizeURL(s.oauthState) 872 + return nil 873 + }, 874 + ) 899 875 if err != nil { 900 - return fmt.Errorf("failed to generate auth URL: %w", err) 901 - } 902 - s.codeVerifier = codeVerifier 903 - 904 - // Print the OAuth URL for user to visit 905 - log.Print("\n" + strings.Repeat("=", 80)) 906 - log.Printf("OAUTH AUTHORIZATION REQUIRED") 907 - log.Print(strings.Repeat("=", 80)) 908 - log.Printf("\nPlease visit this URL to authorize the hold service:\n") 909 - log.Printf(" %s\n", authURL) 910 - log.Printf("Waiting for authorization...") 911 - log.Print(strings.Repeat("=", 80) + "\n") 912 - 913 - // Wait for callback or error (callback happens on main server) 914 - var code string 915 - select { 916 - case code = <-s.oauthCodeCh: 917 - // Got the code from callback 918 - case err := <-s.oauthErrCh: 919 876 return err 920 - case <-time.After(5 * time.Minute): 921 - return fmt.Errorf("OAuth timeout - no response after 5 minutes") 922 877 } 923 878 924 879 log.Printf("Authorization received, exchanging code for token...") 925 - 926 - // Exchange code for token 927 - token, err := oauthClient.Exchange(ctx, code, s.codeVerifier) 928 - if err != nil { 929 - return fmt.Errorf("failed to exchange code: %w", err) 930 - } 880 + token := result.Token 931 881 932 882 log.Printf("OAuth token obtained successfully") 933 883 log.Printf("DID: %s", did) ··· 935 885 936 886 // Now register with the token using DPoP 937 887 // Create ATProto client with DPoP transport from OAuth client 938 - dpopKey := oauthClient.DPoPKey() 888 + dpopKey := result.Client.DPoPKey() 939 889 dpopTransport := oauth.NewDPoPTransport(http.DefaultTransport, dpopKey) 940 890 // Set the access token in the transport for "ath" claim computation 941 891 dpopTransport.SetAccessToken(token.AccessToken)
+1 -1
cmd/profile-update/main.go
··· 11 11 "path/filepath" 12 12 "strings" 13 13 14 - atprotoAuth "atcr.io/pkg/auth/atproto" 15 14 "atcr.io/pkg/atproto" 15 + atprotoAuth "atcr.io/pkg/auth/atproto" 16 16 ) 17 17 18 18 // DockerConfig represents ~/.docker/config.json
+269
pkg/auth/oauth/callback.go
··· 1 + package oauth 2 + 3 + import ( 4 + "crypto/rand" 5 + "encoding/base64" 6 + "fmt" 7 + "net" 8 + "net/http" 9 + "net/url" 10 + "os/exec" 11 + "runtime" 12 + "strings" 13 + "time" 14 + ) 15 + 16 + // CallbackHandler manages OAuth callback handling 17 + type CallbackHandler struct { 18 + state string 19 + codeChan chan string 20 + errChan chan error 21 + } 22 + 23 + // NewCallbackHandler creates a new callback handler 24 + func NewCallbackHandler(state string) *CallbackHandler { 25 + return &CallbackHandler{ 26 + state: state, 27 + codeChan: make(chan string, 1), 28 + errChan: make(chan error, 1), 29 + } 30 + } 31 + 32 + // ServeHTTP handles the OAuth callback request 33 + func (h *CallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 34 + code := r.URL.Query().Get("code") 35 + receivedState := r.URL.Query().Get("state") 36 + errorParam := r.URL.Query().Get("error") 37 + 38 + // Validate state parameter 39 + if receivedState != h.state { 40 + h.errChan <- fmt.Errorf("invalid state parameter") 41 + http.Error(w, "Invalid state", http.StatusBadRequest) 42 + return 43 + } 44 + 45 + // Check for OAuth error 46 + if errorParam != "" { 47 + h.errChan <- fmt.Errorf("OAuth error: %s (%s)", 48 + errorParam, 49 + r.URL.Query().Get("error_description")) 50 + http.Error(w, "Authorization failed", http.StatusBadRequest) 51 + return 52 + } 53 + 54 + // Validate code is present 55 + if code == "" { 56 + h.errChan <- fmt.Errorf("no authorization code received") 57 + http.Error(w, "No code provided", http.StatusBadRequest) 58 + return 59 + } 60 + 61 + // Send success response to browser 62 + RenderSuccessHTML(w) 63 + 64 + // Send code to waiting goroutine 65 + select { 66 + case h.codeChan <- code: 67 + default: 68 + // Channel already has a value or nobody is listening 69 + } 70 + } 71 + 72 + // WaitForCode waits for the OAuth callback to complete 73 + func (h *CallbackHandler) WaitForCode(timeout time.Duration) (string, error) { 74 + select { 75 + case code := <-h.codeChan: 76 + return code, nil 77 + case err := <-h.errChan: 78 + return "", err 79 + case <-time.After(timeout): 80 + return "", fmt.Errorf("OAuth timeout after %v", timeout) 81 + } 82 + } 83 + 84 + // GenerateState generates a random state parameter for OAuth 85 + func GenerateState() (string, error) { 86 + // Generate 32 random bytes 87 + bytes := make([]byte, 32) 88 + if _, err := rand.Read(bytes); err != nil { 89 + return "", fmt.Errorf("failed to generate random state: %w", err) 90 + } 91 + return base64.RawURLEncoding.EncodeToString(bytes), nil 92 + } 93 + 94 + // OpenBrowser opens the default browser to the given URL 95 + func OpenBrowser(url string) error { 96 + var cmd *exec.Cmd 97 + 98 + switch runtime.GOOS { 99 + case "darwin": 100 + cmd = exec.Command("open", url) 101 + case "linux": 102 + cmd = exec.Command("xdg-open", url) 103 + case "windows": 104 + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) 105 + default: 106 + return fmt.Errorf("unsupported platform: %s", runtime.GOOS) 107 + } 108 + 109 + return cmd.Start() 110 + } 111 + 112 + // RenderSuccessHTML renders the OAuth success page 113 + func RenderSuccessHTML(w http.ResponseWriter) { 114 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 115 + fmt.Fprintf(w, `<!DOCTYPE html> 116 + <html> 117 + <head> 118 + <meta charset="UTF-8"> 119 + <title>ATCR Authorization</title> 120 + <style> 121 + body { 122 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; 123 + display: flex; 124 + justify-content: center; 125 + align-items: center; 126 + height: 100vh; 127 + margin: 0; 128 + background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%); 129 + } 130 + .container { 131 + background: white; 132 + padding: 3rem; 133 + border-radius: 1rem; 134 + box-shadow: 0 10px 40px rgba(0,0,0,0.2); 135 + text-align: center; 136 + max-width: 400px; 137 + } 138 + h1 { 139 + color: #2d3748; 140 + margin: 0 0 1rem 0; 141 + font-size: 2rem; 142 + } 143 + p { 144 + color: #718096; 145 + margin: 0; 146 + font-size: 1.1rem; 147 + } 148 + .checkmark { 149 + font-size: 4rem; 150 + color: #48bb78; 151 + margin-bottom: 1rem; 152 + } 153 + </style> 154 + </head> 155 + <body> 156 + <div class="container"> 157 + <div class="checkmark">✓</div> 158 + <h1>Authorization Successful!</h1> 159 + <p>You can close this window and return to the terminal.</p> 160 + </div> 161 + </body> 162 + </html>`) 163 + } 164 + 165 + // StartCallbackServer creates an ephemeral HTTP server for OAuth callbacks 166 + // This is useful for CLI tools that need a temporary OAuth endpoint 167 + // Derives the listen address and paths from the metadata's ClientID and RedirectURIs 168 + func StartCallbackServer(handler *CallbackHandler, metadata *ClientMetadata) (*http.Server, error) { 169 + if len(metadata.RedirectURIs) == 0 { 170 + return nil, fmt.Errorf("no redirect URIs in metadata") 171 + } 172 + 173 + // Parse redirect URI to extract listen address and callback path 174 + redirectURI := metadata.RedirectURIs[0] 175 + u, err := url.Parse(redirectURI) 176 + if err != nil { 177 + return nil, fmt.Errorf("failed to parse redirect URI: %w", err) 178 + } 179 + 180 + // Extract listen address (host:port) 181 + addr := u.Host 182 + callbackPath := u.Path 183 + 184 + mux := http.NewServeMux() 185 + 186 + // Check if this is a query-based client ID (localhost OAuth) 187 + isQueryBased := strings.HasPrefix(metadata.ClientID, "http://localhost?") 188 + 189 + var metadataPath string 190 + if !isQueryBased { 191 + // Metadata URL client ID - parse and serve metadata 192 + clientIDURL := metadata.ClientID 193 + if idx := strings.Index(clientIDURL, "?"); idx != -1 { 194 + clientIDURL = clientIDURL[:idx] 195 + } 196 + 197 + clientURL, err := url.Parse(clientIDURL) 198 + if err != nil { 199 + return nil, fmt.Errorf("failed to parse client ID: %w", err) 200 + } 201 + metadataPath = clientURL.Path 202 + 203 + // Serve client metadata at the path from ClientID 204 + mux.Handle(metadataPath, ServeMetadata(metadata)) 205 + } 206 + 207 + // Register OAuth callback handler at the path from RedirectURI 208 + mux.Handle(callbackPath, handler) 209 + 210 + server := &http.Server{ 211 + Addr: addr, 212 + Handler: mux, 213 + } 214 + 215 + go func() { 216 + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { 217 + // Server error will be caught by WaitForCode timeout 218 + } 219 + }() 220 + 221 + // Wait for server to be ready 222 + if isQueryBased { 223 + // For localhost/query-based, just check if port is listening 224 + if !waitForPort(addr, 5*time.Second) { 225 + return nil, fmt.Errorf("server failed to start within 5 seconds") 226 + } 227 + } else { 228 + // For metadata URLs, check the metadata endpoint 229 + checkURL := "http://" + addr + metadataPath 230 + if !waitForServer(checkURL, 5*time.Second) { 231 + return nil, fmt.Errorf("server failed to start within 5 seconds") 232 + } 233 + } 234 + 235 + return server, nil 236 + } 237 + 238 + // waitForPort checks if a TCP port is listening 239 + func waitForPort(addr string, timeout time.Duration) bool { 240 + deadline := time.Now().Add(timeout) 241 + 242 + for time.Now().Before(deadline) { 243 + conn, err := net.DialTimeout("tcp", addr, 100*time.Millisecond) 244 + if err == nil { 245 + conn.Close() 246 + return true 247 + } 248 + time.Sleep(10 * time.Millisecond) 249 + } 250 + return false 251 + } 252 + 253 + // waitForServer checks if the server is responding at the given URL 254 + func waitForServer(url string, timeout time.Duration) bool { 255 + deadline := time.Now().Add(timeout) 256 + client := &http.Client{Timeout: 100 * time.Millisecond} 257 + 258 + for time.Now().Before(deadline) { 259 + resp, err := client.Get(url) 260 + if err == nil { 261 + resp.Body.Close() 262 + if resp.StatusCode == http.StatusOK { 263 + return true 264 + } 265 + } 266 + time.Sleep(10 * time.Millisecond) 267 + } 268 + return false 269 + }
+52
pkg/auth/oauth/client_id.go
··· 1 + package oauth 2 + 3 + import ( 4 + "fmt" 5 + "net/url" 6 + "strings" 7 + ) 8 + 9 + // MakeLocalhostClientID creates a query-based client ID for localhost development 10 + // Per ATProto OAuth spec: http://localhost?redirect_uri=...&scope=... 11 + func MakeLocalhostClientID(redirectURI string, scopes string) string { 12 + return fmt.Sprintf("http://localhost?redirect_uri=%s&scope=%s", 13 + url.QueryEscape(redirectURI), 14 + url.QueryEscape(scopes)) 15 + } 16 + 17 + // MakeProductionClientID creates a metadata URL client ID for production 18 + // Format: https://example.com/client-metadata.json 19 + func MakeProductionClientID(baseURL string) string { 20 + return baseURL + "/client-metadata.json" 21 + } 22 + 23 + // IsLocalhostURL checks if a URL is localhost/127.0.0.1 24 + func IsLocalhostURL(urlStr string) bool { 25 + return strings.Contains(urlStr, "127.0.0.1") || strings.Contains(urlStr, "localhost") 26 + } 27 + 28 + // ClientIDConfig helps construct appropriate client IDs for different environments 29 + type ClientIDConfig struct { 30 + BaseURL string // Base URL (e.g., "http://127.0.0.1:8888" or "https://example.com") 31 + CallbackPath string // Callback path (e.g., "/oauth/callback") 32 + Scopes []string // OAuth scopes 33 + } 34 + 35 + // MakeClientID creates the appropriate client ID based on the environment 36 + // Returns (clientID, redirectURI) 37 + func (c *ClientIDConfig) MakeClientID() (string, string) { 38 + redirectURI := c.BaseURL + c.CallbackPath 39 + scopeStr := strings.Join(c.Scopes, " ") 40 + 41 + if scopeStr == "" { 42 + scopeStr = "atproto" 43 + } 44 + 45 + if IsLocalhostURL(c.BaseURL) { 46 + // Localhost: use query-based client ID 47 + return MakeLocalhostClientID(redirectURI, scopeStr), redirectURI 48 + } 49 + 50 + // Production: use metadata URL 51 + return MakeProductionClientID(c.BaseURL), redirectURI 52 + }
+2 -2
pkg/auth/oauth/discovery.go
··· 10 10 // ProtectedResourceMetadata represents the OAuth protected resource metadata 11 11 // as defined in ATProto OAuth spec 12 12 type ProtectedResourceMetadata struct { 13 - Resource string `json:"resource"` 14 - AuthorizationServers []string `json:"authorization_servers"` 13 + Resource string `json:"resource"` 14 + AuthorizationServers []string `json:"authorization_servers"` 15 15 } 16 16 17 17 // AuthServerMetadata represents the OAuth authorization server metadata
+97
pkg/auth/oauth/flow.go
··· 1 + package oauth 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "time" 7 + 8 + "authelia.com/client/oauth2" 9 + ) 10 + 11 + // InteractiveFlowConfig configures an interactive OAuth flow 12 + type InteractiveFlowConfig struct { 13 + ClientID string 14 + RedirectURI string 15 + Handle string 16 + Scopes []string // optional, defaults to ["atproto"] 17 + } 18 + 19 + // FlowResult contains the result of a successful OAuth flow 20 + type FlowResult struct { 21 + Token *oauth2.Token 22 + Client *Client // OAuth client with DPoP key set 23 + } 24 + 25 + // RunInteractiveFlow executes an interactive OAuth authorization code flow 26 + // The setupCallback function is called TWICE: 27 + // 1. First with authURL="" to start the server (before PAR) 28 + // 2. Then with the actual authURL to display it to the user (after PAR) 29 + // This two-phase approach ensures the server is running before PAR tries to fetch client metadata 30 + func RunInteractiveFlow(ctx context.Context, cfg InteractiveFlowConfig, 31 + setupCallback func(authURL string, handler *CallbackHandler, metadata *ClientMetadata) error) (*FlowResult, error) { 32 + 33 + // Create OAuth client 34 + client, err := NewClient(cfg.ClientID, cfg.RedirectURI) 35 + if err != nil { 36 + return nil, fmt.Errorf("failed to create OAuth client: %w", err) 37 + } 38 + 39 + // Initialize for the given handle 40 + initCtx, cancel := context.WithTimeout(ctx, 30*time.Second) 41 + defer cancel() 42 + 43 + if err := client.InitializeForHandle(initCtx, cfg.Handle); err != nil { 44 + return nil, fmt.Errorf("failed to initialize client: %w", err) 45 + } 46 + 47 + // Set scopes if provided 48 + if len(cfg.Scopes) > 0 { 49 + client.SetScopes(cfg.Scopes) 50 + } 51 + 52 + // Generate state for OAuth flow 53 + state, err := GenerateState() 54 + if err != nil { 55 + return nil, fmt.Errorf("failed to generate state: %w", err) 56 + } 57 + 58 + // Create callback handler and client metadata FIRST 59 + callbackHandler := NewCallbackHandler(state) 60 + metadata := NewClientMetadata(cfg.ClientID, []string{cfg.RedirectURI}) 61 + 62 + // Start server BEFORE generating auth URL (so PAR can fetch metadata) 63 + if err := setupCallback("", callbackHandler, metadata); err != nil { 64 + return nil, fmt.Errorf("callback setup failed: %w", err) 65 + } 66 + 67 + // NOW generate authorization URL with PKCE (PAR can succeed) 68 + authURL, codeVerifier, err := client.AuthorizeURL(state) 69 + if err != nil { 70 + return nil, fmt.Errorf("failed to generate auth URL: %w", err) 71 + } 72 + 73 + // Display the auth URL (callback gets called again with URL) 74 + if err := setupCallback(authURL, callbackHandler, metadata); err != nil { 75 + return nil, fmt.Errorf("failed to display auth URL: %w", err) 76 + } 77 + 78 + // Wait for callback (5 minute timeout) 79 + code, err := callbackHandler.WaitForCode(5 * time.Minute) 80 + if err != nil { 81 + return nil, err 82 + } 83 + 84 + // Exchange code for token 85 + exchangeCtx, cancel := context.WithTimeout(ctx, 30*time.Second) 86 + defer cancel() 87 + 88 + token, err := client.Exchange(exchangeCtx, code, codeVerifier) 89 + if err != nil { 90 + return nil, fmt.Errorf("failed to exchange code: %w", err) 91 + } 92 + 93 + return &FlowResult{ 94 + Token: token, 95 + Client: client, 96 + }, nil 97 + }