diff --git a/cmd/credential-helper/main.go b/cmd/credential-helper/main.go index 603bba0..4bb5811 100644 --- a/cmd/credential-helper/main.go +++ b/cmd/credential-helper/main.go @@ -1,12 +1,42 @@ package main import ( + "bytes" + "context" "encoding/json" "fmt" + "io" + "net/http" "os" "path/filepath" + "strings" + "time" + + "atcr.io/pkg/atproto" + "atcr.io/pkg/auth/oauth" ) +const ( + callbackPort = "8888" + baseURL = "http://127.0.0.1:" + callbackPort + callbackPath = "/callback" +) + +var ( + clientID string + redirectURI string +) + +func init() { + // Use shared helper to create localhost client ID + cfg := oauth.ClientIDConfig{ + BaseURL: baseURL, + CallbackPath: callbackPath, + Scopes: []string{"atproto"}, + } + clientID, redirectURI = cfg.MakeClientID() +} + // Docker credential helper protocol // https://github.com/docker/docker-credential-helpers @@ -19,7 +49,7 @@ type Credentials struct { func main() { if len(os.Args) < 2 { - fmt.Fprintf(os.Stderr, "Usage: docker-credential-atcr \n") + fmt.Fprintf(os.Stderr, "Usage: docker-credential-atcr \n") os.Exit(1) } @@ -33,7 +63,12 @@ func main() { case "erase": handleErase() case "configure": - handleConfigure() + // Optional handle argument + var handle string + if len(os.Args) > 2 { + handle = os.Args[2] + } + handleConfigure(handle) default: fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command) os.Exit(1) @@ -42,30 +77,69 @@ func main() { // handleGet retrieves credentials for the given server func handleGet() { - var request Credentials - if err := json.NewDecoder(os.Stdin).Decode(&request); err != nil { - fmt.Fprintf(os.Stderr, "Error decoding request: %v\n", err) + // Docker sends the server URL as a plain string on stdin (not JSON) + var serverURL string + if _, err := fmt.Fscanln(os.Stdin, &serverURL); err != nil { + fmt.Fprintf(os.Stderr, "Error reading server URL: %v\n", err) os.Exit(1) } // Load token from storage tokenPath := getTokenPath() - token, err := loadToken(tokenPath) + token, err := oauth.LoadTokenStore(tokenPath) if err != nil { fmt.Fprintf(os.Stderr, "Error loading token: %v\n", err) os.Exit(1) } // Check if token is expired and refresh if needed - if token.IsExpired && token.RefreshToken != "" { - if err := refreshToken(token); err != nil { + if token.IsExpired() && token.RefreshToken != "" { + // Create OAuth client + client, err := oauth.NewClient(clientID, redirectURI) + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating OAuth client: %v\n", err) + os.Exit(1) + } + + // Load DPoP key + dpopKey, err := token.GetDPoPKey() + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading DPoP key: %v\n", err) + os.Exit(1) + } + client.SetDPoPKey(dpopKey) + + // Initialize for the handle + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := client.InitializeForHandle(ctx, token.Handle); err != nil { + fmt.Fprintf(os.Stderr, "Error initializing OAuth client: %v\n", err) + os.Exit(1) + } + + // Refresh the token + newToken, err := client.RefreshToken(ctx, token.RefreshToken) + if err != nil { fmt.Fprintf(os.Stderr, "Error refreshing token: %v\n", err) os.Exit(1) } + + // Update token store + token.AccessToken = newToken.AccessToken + token.RefreshToken = newToken.RefreshToken + token.ExpiresAt = newToken.Expiry + + // Save updated token + if err := token.Save(tokenPath); err != nil { + fmt.Fprintf(os.Stderr, "Error saving token: %v\n", err) + os.Exit(1) + } } // Exchange ATProto token for registry JWT - registryJWT, err := exchangeForRegistryToken(token.AccessToken, request.ServerURL) + fmt.Fprintf(os.Stderr, "[DEBUG] Exchanging token for %s, handle=%s, token_expired=%v\n", serverURL, token.Handle, token.IsExpired()) + registryJWT, err := exchangeForRegistryToken(token.AccessToken, serverURL, token.Handle) if err != nil { fmt.Fprintf(os.Stderr, "Error exchanging token: %v\n", err) os.Exit(1) @@ -73,7 +147,7 @@ func handleGet() { // Return credentials creds := Credentials{ - ServerURL: request.ServerURL, + ServerURL: serverURL, Username: "oauth2", Secret: registryJWT, } @@ -99,9 +173,10 @@ func handleStore() { // handleErase removes stored credentials func handleErase() { - var request Credentials - if err := json.NewDecoder(os.Stdin).Decode(&request); err != nil { - fmt.Fprintf(os.Stderr, "Error decoding request: %v\n", err) + // Docker sends the server URL as a plain string on stdin (not JSON) + var serverURL string + if _, err := fmt.Fscanln(os.Stdin, &serverURL); err != nil { + fmt.Fprintf(os.Stderr, "Error reading server URL: %v\n", err) os.Exit(1) } @@ -114,17 +189,20 @@ func handleErase() { } // handleConfigure runs the OAuth flow to get initial credentials -func handleConfigure() { +func handleConfigure(handle string) { fmt.Println("ATCR Credential Helper Configuration") fmt.Println("=====================================") fmt.Println() - // Ask for handle - fmt.Print("Enter your ATProto handle (e.g., alice.bsky.social): ") - var handle string - if _, err := fmt.Scanln(&handle); err != nil { - fmt.Fprintf(os.Stderr, "Error reading handle: %v\n", err) - os.Exit(1) + // Ask for handle if not provided as argument + if handle == "" { + fmt.Print("Enter your ATProto handle (e.g., alice.bsky.social): ") + if _, err := fmt.Scanln(&handle); err != nil { + fmt.Fprintf(os.Stderr, "Error reading handle: %v\n", err) + os.Exit(1) + } + } else { + fmt.Printf("Using handle: %s\n", handle) } // Run OAuth flow @@ -156,3 +234,140 @@ func getTokenPath() string { return filepath.Join(homeDir, ".atcr", "oauth-token.json") } + +// exchangeForRegistryToken exchanges the ATProto OAuth token for a registry JWT +func exchangeForRegistryToken(atprotoToken, registryURL, handle string, dpopKey interface{}) (string, error) { + // Call the registry's /auth/exchange endpoint + // This endpoint validates the ATProto token and returns a registry JWT + + // Normalize registry URL - add scheme if missing + if !strings.HasPrefix(registryURL, "http://") && !strings.HasPrefix(registryURL, "https://") { + registryURL = "http://" + registryURL + } + + exchangeURL := fmt.Sprintf("%s/auth/exchange", registryURL) + + reqBody := map[string]any{ + "access_token": atprotoToken, + "handle": handle, // Required for PDS resolution and token validation + "scope": []string{"repository:*:pull,push"}, + } + + body, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("failed to marshal request: %w", err) + } + + fmt.Fprintf(os.Stderr, "[DEBUG] POST %s\n", exchangeURL) + fmt.Fprintf(os.Stderr, "[DEBUG] Request: handle=%s, token_prefix=%s..., scope=%v\n", + handle, + atprotoToken[:min(20, len(atprotoToken))], + reqBody["scope"]) + + // Create HTTP client with DPoP transport + transport := oauth.NewDPoPTransport(http.DefaultTransport, dpopKey) + transport.SetAccessToken(atprotoToken) + client := &http.Client{Transport: transport} + + req, err := http.NewRequest("POST", exchangeURL, bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to call exchange endpoint: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + // Read response body for debugging + bodyBytes, _ := io.ReadAll(resp.Body) + fmt.Fprintf(os.Stderr, "[DEBUG] Exchange failed with status %d\n", resp.StatusCode) + fmt.Fprintf(os.Stderr, "[DEBUG] Response body: %s\n", string(bodyBytes)) + return "", fmt.Errorf("exchange failed with status %d", resp.StatusCode) + } + + var result struct { + Token string `json:"token"` + AccessToken string `json:"access_token"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("failed to decode response: %w", err) + } + + if result.Token != "" { + return result.Token, nil + } + return result.AccessToken, nil +} + +// runOAuthFlow executes the OAuth flow with browser +func runOAuthFlow(handle string) (*oauth.TokenStore, error) { + var server *http.Server + + // Run interactive OAuth flow with ephemeral server + result, err := oauth.RunInteractiveFlow( + context.Background(), + oauth.InteractiveFlowConfig{ + ClientID: clientID, + RedirectURI: redirectURI, + Handle: handle, + }, + func(authURL string, handler *oauth.CallbackHandler, metadata *oauth.ClientMetadata) error { + // First call (authURL empty): start server + if authURL == "" { + var err error + server, err = oauth.StartCallbackServer(handler, metadata) + if err != nil { + return fmt.Errorf("failed to start callback server: %w", err) + } + return nil + } + + // Second call (authURL populated): display URL and open browser + fmt.Printf("Opening browser to: %s\n", authURL) + if err := oauth.OpenBrowser(authURL); err != nil { + fmt.Printf("Failed to open browser automatically. Please open this URL manually:\n%s\n", authURL) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + // Shutdown ephemeral server + if server != nil { + defer server.Shutdown(context.Background()) + } + + fmt.Println("Authorization successful!") + + // Resolve handle to get DID + resolver := atproto.NewResolver() + did, _, err := resolver.ResolveIdentity(context.Background(), handle) + if err != nil { + return nil, fmt.Errorf("failed to resolve DID: %w", err) + } + + // Create token store + store := &oauth.TokenStore{ + AccessToken: result.Token.AccessToken, + RefreshToken: result.Token.RefreshToken, + TokenType: result.Token.TokenType, + ExpiresAt: result.Token.Expiry, + Handle: handle, + DID: did, + } + + // Save DPoP key + if err := store.SetDPoPKey(result.Client.DPoPKey()); err != nil { + return nil, fmt.Errorf("failed to save DPoP key: %w", err) + } + + return store, nil +} diff --git a/cmd/credential-helper/oauth.go b/cmd/credential-helper/oauth.go deleted file mode 100644 index 2e9b3c9..0000000 --- a/cmd/credential-helper/oauth.go +++ /dev/null @@ -1,176 +0,0 @@ -package main - -import ( - "context" - "fmt" - "net/http" - "os/exec" - "runtime" - "time" - - "atcr.io/pkg/atproto" - "atcr.io/pkg/auth/oauth" -) - -const ( - clientID = "http://localhost:8888/client-metadata.json" - redirectURI = "http://localhost:8888/callback" -) - -// runOAuthFlow executes the OAuth flow with browser -func runOAuthFlow(handle string) (*oauth.TokenStore, error) { - // Create OAuth client - client, err := oauth.NewClient(clientID, redirectURI) - if err != nil { - return nil, fmt.Errorf("failed to create OAuth client: %w", err) - } - - // Initialize for the given handle - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - if err := client.InitializeForHandle(ctx, handle); err != nil { - return nil, fmt.Errorf("failed to initialize client: %w", err) - } - - // Start local callback server - codeChan := make(chan string, 1) - errChan := make(chan error, 1) - server := startCallbackServer(codeChan, errChan) - defer server.Shutdown(context.Background()) - - // Also serve client metadata - http.HandleFunc("/client-metadata.json", oauth.ServeMetadata( - oauth.NewClientMetadata(clientID, []string{redirectURI}), - )) - - // Generate authorization URL with PKCE - state := generateState() - authURL, codeVerifier, err := client.AuthorizeURL(state) - if err != nil { - return nil, fmt.Errorf("failed to generate auth URL: %w", err) - } - - // Open browser - fmt.Printf("Opening browser to: %s\n", authURL) - if err := openBrowser(authURL); err != nil { - fmt.Printf("Failed to open browser automatically. Please open this URL manually:\n%s\n", authURL) - } - - // Wait for callback - var code string - select { - case code = <-codeChan: - fmt.Println("Authorization successful!") - case err := <-errChan: - return nil, fmt.Errorf("authorization failed: %w", err) - case <-time.After(5 * time.Minute): - return nil, fmt.Errorf("authorization timed out") - } - - // Exchange code for token - ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - token, err := client.Exchange(ctx, code, codeVerifier) - if err != nil { - return nil, fmt.Errorf("failed to exchange code: %w", err) - } - - // Resolve handle to get DID - resolver := atproto.NewResolver() - did, _, err := resolver.ResolveIdentity(context.Background(), handle) - if err != nil { - return nil, fmt.Errorf("failed to resolve DID: %w", err) - } - - // Create token store - store := &oauth.TokenStore{ - AccessToken: token.AccessToken, - RefreshToken: token.RefreshToken, - TokenType: token.TokenType, - ExpiresAt: token.Expiry, - Handle: handle, - DID: did, - } - - // Save DPoP key - if err := store.SetDPoPKey(client.DPoPKey()); err != nil { - return nil, fmt.Errorf("failed to save DPoP key: %w", err) - } - - return store, nil -} - -// startCallbackServer starts a local HTTP server to receive the OAuth callback -func startCallbackServer(codeChan chan string, errChan chan error) *http.Server { - mux := http.NewServeMux() - - mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { - code := r.URL.Query().Get("code") - errorParam := r.URL.Query().Get("error") - - if errorParam != "" { - errChan <- fmt.Errorf("OAuth error: %s (%s)", - errorParam, - r.URL.Query().Get("error_description")) - http.Error(w, "Authorization failed", http.StatusBadRequest) - return - } - - if code == "" { - errChan <- fmt.Errorf("no code in callback") - http.Error(w, "No code provided", http.StatusBadRequest) - return - } - - codeChan <- code - w.Header().Set("Content-Type", "text/html") - fmt.Fprintf(w, ` - - ATCR Authorization - -

Authorization Successful!

-

You can close this window and return to the terminal.

- - - `) - }) - - server := &http.Server{ - Addr: ":8888", - Handler: mux, - } - - go func() { - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - errChan <- fmt.Errorf("callback server error: %w", err) - } - }() - - return server -} - -// openBrowser opens the default browser to the given URL -func openBrowser(url string) error { - var cmd *exec.Cmd - - switch runtime.GOOS { - case "darwin": - cmd = exec.Command("open", url) - case "linux": - cmd = exec.Command("xdg-open", url) - case "windows": - cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) - default: - return fmt.Errorf("unsupported platform") - } - - return cmd.Start() -} - -// generateState generates a random state parameter -func generateState() string { - // Use the same UUID generation as we do elsewhere - return fmt.Sprintf("state_%d", time.Now().UnixNano()) -} diff --git a/cmd/credential-helper/token.go b/cmd/credential-helper/token.go deleted file mode 100644 index 43e1291..0000000 --- a/cmd/credential-helper/token.go +++ /dev/null @@ -1,119 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "atcr.io/pkg/auth/oauth" -) - -// tokenData holds the token information -type tokenData struct { - *oauth.TokenStore - IsExpired bool -} - -// loadToken loads the token from disk -func loadToken(path string) (*tokenData, error) { - store, err := oauth.LoadTokenStore(path) - if err != nil { - return nil, err - } - - return &tokenData{ - TokenStore: store, - IsExpired: store.IsExpired(), - }, nil -} - -// refreshToken refreshes an expired token -func refreshToken(token *tokenData) error { - // Create OAuth client - client, err := oauth.NewClient("http://localhost:8888/client-metadata.json", "http://localhost:8888/callback") - if err != nil { - return fmt.Errorf("failed to create OAuth client: %w", err) - } - - // Load DPoP key - dpopKey, err := token.GetDPoPKey() - if err != nil { - return fmt.Errorf("failed to load DPoP key: %w", err) - } - client.SetDPoPKey(dpopKey) - - // Initialize for the handle - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - if err := client.InitializeForHandle(ctx, token.Handle); err != nil { - return fmt.Errorf("failed to initialize client: %w", err) - } - - // Refresh the token - newToken, err := client.RefreshToken(ctx, token.RefreshToken) - if err != nil { - return fmt.Errorf("failed to refresh token: %w", err) - } - - // Update token store - token.AccessToken = newToken.AccessToken - token.RefreshToken = newToken.RefreshToken - token.ExpiresAt = newToken.Expiry - token.IsExpired = false - - // Save updated token - return token.Save(getTokenPath()) -} - -// exchangeForRegistryToken exchanges the ATProto OAuth token for a registry JWT -func exchangeForRegistryToken(atprotoToken, registryURL string) (string, error) { - // Call the registry's /auth/exchange endpoint - // This endpoint validates the ATProto token and returns a registry JWT - - exchangeURL := fmt.Sprintf("%s/auth/exchange", registryURL) - - // Load token store to get DID/handle - store, err := loadToken(getTokenPath()) - if err != nil { - return "", fmt.Errorf("failed to load token store: %w", err) - } - - reqBody := map[string]any{ - "access_token": atprotoToken, - "handle": store.Handle, // Required for PDS resolution and token validation - "scope": []string{"repository:*:pull,push"}, - } - - body, err := json.Marshal(reqBody) - if err != nil { - return "", fmt.Errorf("failed to marshal request: %w", err) - } - - resp, err := http.Post(exchangeURL, "application/json", bytes.NewReader(body)) - if err != nil { - return "", fmt.Errorf("failed to call exchange endpoint: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("exchange failed with status %d", resp.StatusCode) - } - - var result struct { - Token string `json:"token"` - AccessToken string `json:"access_token"` - } - - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", fmt.Errorf("failed to decode response: %w", err) - } - - if result.Token != "" { - return result.Token, nil - } - return result.AccessToken, nil -} diff --git a/cmd/hold/main.go b/cmd/hold/main.go index 55e2563..9333877 100644 --- a/cmd/hold/main.go +++ b/cmd/hold/main.go @@ -67,12 +67,8 @@ type ServerConfig struct { // HoldService provides presigned URLs for blob storage in a hold type HoldService struct { - driver storagedriver.StorageDriver - config *Config - oauthCodeCh chan string - oauthErrCh chan error - oauthState string - codeVerifier string + driver storagedriver.StorageDriver + config *Config } // NewHoldService creates a new hold service @@ -85,10 +81,8 @@ func NewHoldService(cfg *Config) (*HoldService, error) { } return &HoldService{ - driver: driver, - config: cfg, - oauthCodeCh: make(chan string, 1), - oauthErrCh: make(chan error, 1), + driver: driver, + config: cfg, }, nil } @@ -550,34 +544,6 @@ func (s *HoldService) HealthHandler(w http.ResponseWriter, r *http.Request) { }) } -// HandleOAuthCallback handles OAuth callback from authorization server -func (s *HoldService) HandleOAuthCallback(w http.ResponseWriter, r *http.Request) { - code := r.URL.Query().Get("code") - receivedState := r.URL.Query().Get("state") - - if receivedState != s.oauthState { - s.oauthErrCh <- fmt.Errorf("invalid state parameter") - http.Error(w, "Invalid state", http.StatusBadRequest) - return - } - - if code == "" { - s.oauthErrCh <- fmt.Errorf("no authorization code received") - http.Error(w, "No code", http.StatusBadRequest) - return - } - - w.Header().Set("Content-Type", "text/html") - fmt.Fprintf(w, `

Authorization Successful!

You can close this window and return to the terminal.

`) - - // Send code to registration flow - select { - case s.oauthCodeCh <- code: - default: - // Channel already has a value or nobody is listening - } -} - func main() { // Load configuration from environment variables cfg, err := loadConfigFromEnv() @@ -597,7 +563,6 @@ func main() { mux.HandleFunc("/register", service.HandleRegister) mux.HandleFunc("/get-presigned-url", service.HandleGetPresignedURL) mux.HandleFunc("/put-presigned-url", service.HandlePutPresignedURL) - mux.HandleFunc("/oauth/callback", service.HandleOAuthCallback) // OAuth callback on same port // OAuth client metadata endpoint for ATProto OAuth clientID := cfg.Server.PublicURL + "/client-metadata.json" @@ -606,11 +571,12 @@ func main() { clientMetadata.ApplicationType = "web" // Changed from "native" since this is a web service mux.HandleFunc("/client-metadata.json", oauth.ServeMetadata(clientMetadata)) mux.HandleFunc("/blobs/", func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet || r.Method == http.MethodHead { + switch r.Method { + case http.MethodGet, http.MethodHead: service.HandleProxyGet(w, r) - } else if r.Method == http.MethodPut { + case http.MethodPut: service.HandleProxyPut(w, r) - } else { + default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } }) @@ -837,15 +803,18 @@ func (s *HoldService) AutoRegister() error { // registerWithOAuth performs OAuth flow and registers the hold func (s *HoldService) registerWithOAuth(publicURL, handle, did, pdsEndpoint string) error { - // Extract port from publicURL for test mode - var redirectURI string - var clientID string - // Define the scopes we need for hold registration - // Need create and update permissions for hold and crew collections - scopes := fmt.Sprintf("atproto repo:%s?action=create repo:%s?action=update repo:%s?action=create repo:%s?action=update", - atproto.HoldCollection, atproto.HoldCollection, - atproto.HoldCrewCollection, atproto.HoldCrewCollection) + holdScopes := []string{ + "atproto", + fmt.Sprintf("repo:%s?action=create", atproto.HoldCollection), + fmt.Sprintf("repo:%s?action=update", atproto.HoldCollection), + fmt.Sprintf("repo:%s?action=create", atproto.HoldCrewCollection), + fmt.Sprintf("repo:%s?action=update", atproto.HoldCrewCollection), + } + + // Determine base URL and client ID based on mode + var baseURL, callbackPath string + callbackPath = "/oauth/callback" if s.config.Server.TestMode { // Test mode: Use localhost for OAuth (browser accessible) but store real URL in hold record @@ -858,76 +827,57 @@ func (s *HoldService) registerWithOAuth(publicURL, handle, did, pdsEndpoint stri if port == "" { port = "8080" // default } - redirectURI = fmt.Sprintf("http://127.0.0.1:%s/oauth/callback", port) - clientID = fmt.Sprintf("http://localhost?redirect_uri=%s&scope=%s", - url.QueryEscape(redirectURI), url.QueryEscape(scopes)) - } else if strings.Contains(publicURL, "127.0.0.1") || strings.Contains(publicURL, "localhost") { - // Localhost development mode per ATProto OAuth spec - redirectURI = publicURL + "/oauth/callback" - clientID = fmt.Sprintf("http://localhost?redirect_uri=%s&scope=%s", - url.QueryEscape(redirectURI), url.QueryEscape(scopes)) + baseURL = fmt.Sprintf("http://127.0.0.1:%s", port) } else { - // Production mode - use client metadata URL - redirectURI = publicURL + "/oauth/callback" - clientID = publicURL + "/client-metadata.json" + baseURL = publicURL } - // Create OAuth client - oauthClient, err := oauth.NewClient(clientID, redirectURI) - if err != nil { - return fmt.Errorf("failed to create OAuth client: %w", err) + // Use shared helper to construct client ID + cfg := oauth.ClientIDConfig{ + BaseURL: baseURL, + CallbackPath: callbackPath, + Scopes: holdScopes, } + clientID, redirectURI := cfg.MakeClientID() - // Initialize for the user's handle + // Run interactive OAuth flow with persistent server ctx := context.Background() - if err := oauthClient.InitializeForHandle(ctx, handle); err != nil { - return fmt.Errorf("failed to initialize OAuth: %w", err) - } + result, err := oauth.RunInteractiveFlow( + ctx, + oauth.InteractiveFlowConfig{ + ClientID: clientID, + RedirectURI: redirectURI, + Handle: handle, + Scopes: holdScopes, + }, + func(authURL string, handler *oauth.CallbackHandler, metadata *oauth.ClientMetadata) error { + // First call (authURL empty): register callback handler + if authURL == "" { + // Register callback on existing server (persistent server pattern) + // Note: metadata is not used here since hold service serves it separately on main server + http.HandleFunc("/oauth/callback", handler.ServeHTTP) + return nil + } - // Set the scopes we need for hold registration (create and update) - oauthClient.SetScopes([]string{ - "atproto", - fmt.Sprintf("repo:%s?action=create", atproto.HoldCollection), - fmt.Sprintf("repo:%s?action=update", atproto.HoldCollection), - fmt.Sprintf("repo:%s?action=create", atproto.HoldCrewCollection), - fmt.Sprintf("repo:%s?action=update", atproto.HoldCrewCollection), - }) + // Second call (authURL populated): display URL + // Print the OAuth URL for user to visit (hold-specific formatting) + log.Print("\n" + strings.Repeat("=", 80)) + log.Printf("OAUTH AUTHORIZATION REQUIRED") + log.Print(strings.Repeat("=", 80)) + log.Printf("\nPlease visit this URL to authorize the hold service:\n") + log.Printf(" %s\n", authURL) + log.Printf("Waiting for authorization...") + log.Print(strings.Repeat("=", 80) + "\n") - // Generate authorization URL - s.oauthState = "hold-registration" - authURL, codeVerifier, err := oauthClient.AuthorizeURL(s.oauthState) + return nil + }, + ) if err != nil { - return fmt.Errorf("failed to generate auth URL: %w", err) - } - s.codeVerifier = codeVerifier - - // Print the OAuth URL for user to visit - log.Print("\n" + strings.Repeat("=", 80)) - log.Printf("OAUTH AUTHORIZATION REQUIRED") - log.Print(strings.Repeat("=", 80)) - log.Printf("\nPlease visit this URL to authorize the hold service:\n") - log.Printf(" %s\n", authURL) - log.Printf("Waiting for authorization...") - log.Print(strings.Repeat("=", 80) + "\n") - - // Wait for callback or error (callback happens on main server) - var code string - select { - case code = <-s.oauthCodeCh: - // Got the code from callback - case err := <-s.oauthErrCh: return err - case <-time.After(5 * time.Minute): - return fmt.Errorf("OAuth timeout - no response after 5 minutes") } log.Printf("Authorization received, exchanging code for token...") - - // Exchange code for token - token, err := oauthClient.Exchange(ctx, code, s.codeVerifier) - if err != nil { - return fmt.Errorf("failed to exchange code: %w", err) - } + token := result.Token log.Printf("OAuth token obtained successfully") log.Printf("DID: %s", did) @@ -935,7 +885,7 @@ func (s *HoldService) registerWithOAuth(publicURL, handle, did, pdsEndpoint stri // Now register with the token using DPoP // Create ATProto client with DPoP transport from OAuth client - dpopKey := oauthClient.DPoPKey() + dpopKey := result.Client.DPoPKey() dpopTransport := oauth.NewDPoPTransport(http.DefaultTransport, dpopKey) // Set the access token in the transport for "ath" claim computation dpopTransport.SetAccessToken(token.AccessToken) diff --git a/cmd/profile-update/main.go b/cmd/profile-update/main.go index 40c55ef..630b453 100644 --- a/cmd/profile-update/main.go +++ b/cmd/profile-update/main.go @@ -11,8 +11,8 @@ import ( "path/filepath" "strings" - atprotoAuth "atcr.io/pkg/auth/atproto" "atcr.io/pkg/atproto" + atprotoAuth "atcr.io/pkg/auth/atproto" ) // DockerConfig represents ~/.docker/config.json diff --git a/pkg/auth/oauth/callback.go b/pkg/auth/oauth/callback.go new file mode 100644 index 0000000..5f62624 --- /dev/null +++ b/pkg/auth/oauth/callback.go @@ -0,0 +1,269 @@ +package oauth + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "net" + "net/http" + "net/url" + "os/exec" + "runtime" + "strings" + "time" +) + +// CallbackHandler manages OAuth callback handling +type CallbackHandler struct { + state string + codeChan chan string + errChan chan error +} + +// NewCallbackHandler creates a new callback handler +func NewCallbackHandler(state string) *CallbackHandler { + return &CallbackHandler{ + state: state, + codeChan: make(chan string, 1), + errChan: make(chan error, 1), + } +} + +// ServeHTTP handles the OAuth callback request +func (h *CallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + code := r.URL.Query().Get("code") + receivedState := r.URL.Query().Get("state") + errorParam := r.URL.Query().Get("error") + + // Validate state parameter + if receivedState != h.state { + h.errChan <- fmt.Errorf("invalid state parameter") + http.Error(w, "Invalid state", http.StatusBadRequest) + return + } + + // Check for OAuth error + if errorParam != "" { + h.errChan <- fmt.Errorf("OAuth error: %s (%s)", + errorParam, + r.URL.Query().Get("error_description")) + http.Error(w, "Authorization failed", http.StatusBadRequest) + return + } + + // Validate code is present + if code == "" { + h.errChan <- fmt.Errorf("no authorization code received") + http.Error(w, "No code provided", http.StatusBadRequest) + return + } + + // Send success response to browser + RenderSuccessHTML(w) + + // Send code to waiting goroutine + select { + case h.codeChan <- code: + default: + // Channel already has a value or nobody is listening + } +} + +// WaitForCode waits for the OAuth callback to complete +func (h *CallbackHandler) WaitForCode(timeout time.Duration) (string, error) { + select { + case code := <-h.codeChan: + return code, nil + case err := <-h.errChan: + return "", err + case <-time.After(timeout): + return "", fmt.Errorf("OAuth timeout after %v", timeout) + } +} + +// GenerateState generates a random state parameter for OAuth +func GenerateState() (string, error) { + // Generate 32 random bytes + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("failed to generate random state: %w", err) + } + return base64.RawURLEncoding.EncodeToString(bytes), nil +} + +// OpenBrowser opens the default browser to the given URL +func OpenBrowser(url string) error { + var cmd *exec.Cmd + + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "linux": + cmd = exec.Command("xdg-open", url) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + default: + return fmt.Errorf("unsupported platform: %s", runtime.GOOS) + } + + return cmd.Start() +} + +// RenderSuccessHTML renders the OAuth success page +func RenderSuccessHTML(w http.ResponseWriter) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, ` + + + + ATCR Authorization + + + +
+
+

Authorization Successful!

+

You can close this window and return to the terminal.

+
+ +`) +} + +// StartCallbackServer creates an ephemeral HTTP server for OAuth callbacks +// This is useful for CLI tools that need a temporary OAuth endpoint +// Derives the listen address and paths from the metadata's ClientID and RedirectURIs +func StartCallbackServer(handler *CallbackHandler, metadata *ClientMetadata) (*http.Server, error) { + if len(metadata.RedirectURIs) == 0 { + return nil, fmt.Errorf("no redirect URIs in metadata") + } + + // Parse redirect URI to extract listen address and callback path + redirectURI := metadata.RedirectURIs[0] + u, err := url.Parse(redirectURI) + if err != nil { + return nil, fmt.Errorf("failed to parse redirect URI: %w", err) + } + + // Extract listen address (host:port) + addr := u.Host + callbackPath := u.Path + + mux := http.NewServeMux() + + // Check if this is a query-based client ID (localhost OAuth) + isQueryBased := strings.HasPrefix(metadata.ClientID, "http://localhost?") + + var metadataPath string + if !isQueryBased { + // Metadata URL client ID - parse and serve metadata + clientIDURL := metadata.ClientID + if idx := strings.Index(clientIDURL, "?"); idx != -1 { + clientIDURL = clientIDURL[:idx] + } + + clientURL, err := url.Parse(clientIDURL) + if err != nil { + return nil, fmt.Errorf("failed to parse client ID: %w", err) + } + metadataPath = clientURL.Path + + // Serve client metadata at the path from ClientID + mux.Handle(metadataPath, ServeMetadata(metadata)) + } + + // Register OAuth callback handler at the path from RedirectURI + mux.Handle(callbackPath, handler) + + server := &http.Server{ + Addr: addr, + Handler: mux, + } + + go func() { + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + // Server error will be caught by WaitForCode timeout + } + }() + + // Wait for server to be ready + if isQueryBased { + // For localhost/query-based, just check if port is listening + if !waitForPort(addr, 5*time.Second) { + return nil, fmt.Errorf("server failed to start within 5 seconds") + } + } else { + // For metadata URLs, check the metadata endpoint + checkURL := "http://" + addr + metadataPath + if !waitForServer(checkURL, 5*time.Second) { + return nil, fmt.Errorf("server failed to start within 5 seconds") + } + } + + return server, nil +} + +// waitForPort checks if a TCP port is listening +func waitForPort(addr string, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + + for time.Now().Before(deadline) { + conn, err := net.DialTimeout("tcp", addr, 100*time.Millisecond) + if err == nil { + conn.Close() + return true + } + time.Sleep(10 * time.Millisecond) + } + return false +} + +// waitForServer checks if the server is responding at the given URL +func waitForServer(url string, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + client := &http.Client{Timeout: 100 * time.Millisecond} + + for time.Now().Before(deadline) { + resp, err := client.Get(url) + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return true + } + } + time.Sleep(10 * time.Millisecond) + } + return false +} diff --git a/pkg/auth/oauth/client_id.go b/pkg/auth/oauth/client_id.go new file mode 100644 index 0000000..96050da --- /dev/null +++ b/pkg/auth/oauth/client_id.go @@ -0,0 +1,52 @@ +package oauth + +import ( + "fmt" + "net/url" + "strings" +) + +// MakeLocalhostClientID creates a query-based client ID for localhost development +// Per ATProto OAuth spec: http://localhost?redirect_uri=...&scope=... +func MakeLocalhostClientID(redirectURI string, scopes string) string { + return fmt.Sprintf("http://localhost?redirect_uri=%s&scope=%s", + url.QueryEscape(redirectURI), + url.QueryEscape(scopes)) +} + +// MakeProductionClientID creates a metadata URL client ID for production +// Format: https://example.com/client-metadata.json +func MakeProductionClientID(baseURL string) string { + return baseURL + "/client-metadata.json" +} + +// IsLocalhostURL checks if a URL is localhost/127.0.0.1 +func IsLocalhostURL(urlStr string) bool { + return strings.Contains(urlStr, "127.0.0.1") || strings.Contains(urlStr, "localhost") +} + +// ClientIDConfig helps construct appropriate client IDs for different environments +type ClientIDConfig struct { + BaseURL string // Base URL (e.g., "http://127.0.0.1:8888" or "https://example.com") + CallbackPath string // Callback path (e.g., "/oauth/callback") + Scopes []string // OAuth scopes +} + +// MakeClientID creates the appropriate client ID based on the environment +// Returns (clientID, redirectURI) +func (c *ClientIDConfig) MakeClientID() (string, string) { + redirectURI := c.BaseURL + c.CallbackPath + scopeStr := strings.Join(c.Scopes, " ") + + if scopeStr == "" { + scopeStr = "atproto" + } + + if IsLocalhostURL(c.BaseURL) { + // Localhost: use query-based client ID + return MakeLocalhostClientID(redirectURI, scopeStr), redirectURI + } + + // Production: use metadata URL + return MakeProductionClientID(c.BaseURL), redirectURI +} diff --git a/pkg/auth/oauth/discovery.go b/pkg/auth/oauth/discovery.go index 67de48a..80735fe 100644 --- a/pkg/auth/oauth/discovery.go +++ b/pkg/auth/oauth/discovery.go @@ -10,8 +10,8 @@ import ( // ProtectedResourceMetadata represents the OAuth protected resource metadata // as defined in ATProto OAuth spec type ProtectedResourceMetadata struct { - Resource string `json:"resource"` - AuthorizationServers []string `json:"authorization_servers"` + Resource string `json:"resource"` + AuthorizationServers []string `json:"authorization_servers"` } // AuthServerMetadata represents the OAuth authorization server metadata diff --git a/pkg/auth/oauth/flow.go b/pkg/auth/oauth/flow.go new file mode 100644 index 0000000..3c545ff --- /dev/null +++ b/pkg/auth/oauth/flow.go @@ -0,0 +1,97 @@ +package oauth + +import ( + "context" + "fmt" + "time" + + "authelia.com/client/oauth2" +) + +// InteractiveFlowConfig configures an interactive OAuth flow +type InteractiveFlowConfig struct { + ClientID string + RedirectURI string + Handle string + Scopes []string // optional, defaults to ["atproto"] +} + +// FlowResult contains the result of a successful OAuth flow +type FlowResult struct { + Token *oauth2.Token + Client *Client // OAuth client with DPoP key set +} + +// RunInteractiveFlow executes an interactive OAuth authorization code flow +// The setupCallback function is called TWICE: +// 1. First with authURL="" to start the server (before PAR) +// 2. Then with the actual authURL to display it to the user (after PAR) +// This two-phase approach ensures the server is running before PAR tries to fetch client metadata +func RunInteractiveFlow(ctx context.Context, cfg InteractiveFlowConfig, + setupCallback func(authURL string, handler *CallbackHandler, metadata *ClientMetadata) error) (*FlowResult, error) { + + // Create OAuth client + client, err := NewClient(cfg.ClientID, cfg.RedirectURI) + if err != nil { + return nil, fmt.Errorf("failed to create OAuth client: %w", err) + } + + // Initialize for the given handle + initCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + if err := client.InitializeForHandle(initCtx, cfg.Handle); err != nil { + return nil, fmt.Errorf("failed to initialize client: %w", err) + } + + // Set scopes if provided + if len(cfg.Scopes) > 0 { + client.SetScopes(cfg.Scopes) + } + + // Generate state for OAuth flow + state, err := GenerateState() + if err != nil { + return nil, fmt.Errorf("failed to generate state: %w", err) + } + + // Create callback handler and client metadata FIRST + callbackHandler := NewCallbackHandler(state) + metadata := NewClientMetadata(cfg.ClientID, []string{cfg.RedirectURI}) + + // Start server BEFORE generating auth URL (so PAR can fetch metadata) + if err := setupCallback("", callbackHandler, metadata); err != nil { + return nil, fmt.Errorf("callback setup failed: %w", err) + } + + // NOW generate authorization URL with PKCE (PAR can succeed) + authURL, codeVerifier, err := client.AuthorizeURL(state) + if err != nil { + return nil, fmt.Errorf("failed to generate auth URL: %w", err) + } + + // Display the auth URL (callback gets called again with URL) + if err := setupCallback(authURL, callbackHandler, metadata); err != nil { + return nil, fmt.Errorf("failed to display auth URL: %w", err) + } + + // Wait for callback (5 minute timeout) + code, err := callbackHandler.WaitForCode(5 * time.Minute) + if err != nil { + return nil, err + } + + // Exchange code for token + exchangeCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + token, err := client.Exchange(exchangeCtx, code, codeVerifier) + if err != nil { + return nil, fmt.Errorf("failed to exchange code: %w", err) + } + + return &FlowResult{ + Token: token, + Client: client, + }, nil +}