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

Configure Feed

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

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 + }