-
Notifications
You must be signed in to change notification settings - Fork 82
feat(cli): add browser-based OAuth2 login with PKCE #1085
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
jeremyeder
wants to merge
3
commits into
ambient-code:main
Choose a base branch
from
jeremyeder:worktree-acpctl-browser-login
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,213 @@ | ||
| // Package browser implements browser-based OAuth2 login using Authorization Code + PKCE. | ||
| package browser | ||
|
|
||
| import ( | ||
| "bufio" | ||
| "context" | ||
| "fmt" | ||
| "net/url" | ||
| "os" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/ambient-code/platform/components/ambient-cli/pkg/config" | ||
| "github.com/ambient-code/platform/components/ambient-cli/pkg/oauth" | ||
| "github.com/spf13/cobra" | ||
| ) | ||
|
|
||
| var args struct { | ||
| issuerURL string | ||
| clientID string | ||
| scopes string | ||
| } | ||
|
|
||
| var Cmd = &cobra.Command{ | ||
| Use: "browser", | ||
| Short: "Log in via browser-based OAuth2 flow", | ||
| Long: `Open a browser to authenticate with the identity provider using OAuth2 | ||
| Authorization Code + PKCE. The CLI starts a local callback server to receive the | ||
| authorization code, then exchanges it for access and refresh tokens.`, | ||
| Args: cobra.NoArgs, | ||
| RunE: run, | ||
| } | ||
|
|
||
| func init() { | ||
| flags := Cmd.Flags() | ||
| flags.StringVar(&args.issuerURL, "issuer-url", "", "OIDC issuer URL (e.g. https://keycloak.example.com/realms/myrealm)") | ||
| flags.StringVar(&args.clientID, "client-id", "", "OAuth2 client ID") | ||
| flags.StringVar(&args.scopes, "scopes", "openid email profile", "OAuth2 scopes to request") | ||
| } | ||
|
|
||
| func run(cmd *cobra.Command, _ []string) error { | ||
| cfg, err := config.Load() | ||
| if err != nil { | ||
| return fmt.Errorf("load config: %w", err) | ||
| } | ||
|
|
||
| issuerURL := args.issuerURL | ||
| if issuerURL == "" { | ||
| issuerURL = cfg.GetIssuerURL() | ||
| } | ||
| if issuerURL == "" { | ||
| return fmt.Errorf("--issuer-url is required (or set AMBIENT_ISSUER_URL / issuer_url in config)") | ||
| } | ||
|
|
||
| clientID := args.clientID | ||
| if clientID == "" { | ||
| clientID = cfg.GetClientID() | ||
| } | ||
| if clientID == "" { | ||
| return fmt.Errorf("--client-id is required (or set AMBIENT_CLIENT_ID / client_id in config)") | ||
| } | ||
|
|
||
| fmt.Fprintf(cmd.OutOrStdout(), "Authenticating with %s...\n", issuerURL) | ||
|
|
||
| oidcCfg, err := oauth.DiscoverEndpoints(issuerURL) | ||
| if err != nil { | ||
| return fmt.Errorf("OIDC discovery: %w", err) | ||
| } | ||
|
|
||
| ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) | ||
| defer cancel() | ||
|
|
||
| state, err := oauth.GenerateState() | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| pkce, err := oauth.GeneratePKCE() | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| addr, resultCh, cleanup, err := oauth.StartCallbackServer(ctx, state) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| defer cleanup() | ||
|
|
||
| redirectURI := "http://" + addr + "/callback" | ||
| authorizeURL, err := oauth.BuildAuthorizeURL( | ||
| oidcCfg.AuthorizationEndpoint, | ||
| clientID, | ||
| redirectURI, | ||
| state, | ||
| pkce.Challenge, | ||
| args.scopes, | ||
| ) | ||
| if err != nil { | ||
| return fmt.Errorf("build authorize URL: %w", err) | ||
| } | ||
|
|
||
| if err := oauth.OpenBrowser(authorizeURL); err != nil { | ||
| fmt.Fprintf(cmd.ErrOrStderr(), "Could not open browser: %v\n", err) | ||
| } | ||
|
|
||
| fmt.Fprintln(cmd.OutOrStdout(), "If the browser did not open, visit this URL:") | ||
| fmt.Fprintln(cmd.OutOrStdout(), authorizeURL) | ||
| fmt.Fprintln(cmd.OutOrStdout()) | ||
| fmt.Fprintln(cmd.OutOrStdout(), "Or paste the redirect URL here:") | ||
|
|
||
| // Listen for both callback and manual URL paste. | ||
| // Use a pipe so we can close the reader to unblock the goroutine. | ||
| pr, pw, err := os.Pipe() | ||
| if err != nil { | ||
| return fmt.Errorf("create pipe: %w", err) | ||
| } | ||
| defer pr.Close() | ||
|
|
||
| // Copy stdin to pipe in background so we can close pr to stop the scanner. | ||
| go func() { | ||
| defer pw.Close() | ||
| buf := make([]byte, 4096) | ||
| for { | ||
| n, err := os.Stdin.Read(buf) | ||
| if n > 0 { | ||
| pw.Write(buf[:n]) //nolint:errcheck | ||
| } | ||
| if err != nil { | ||
| return | ||
| } | ||
| } | ||
| }() | ||
|
|
||
| manualCh := make(chan oauth.CallbackResult, 1) | ||
| go func() { | ||
| scanner := bufio.NewScanner(pr) | ||
| if scanner.Scan() { | ||
| line := strings.TrimSpace(scanner.Text()) | ||
| if line == "" { | ||
| return | ||
| } | ||
| parsed, err := url.Parse(line) | ||
| if err != nil { | ||
| manualCh <- oauth.CallbackResult{Err: fmt.Errorf("invalid URL: %w", err)} | ||
| return | ||
| } | ||
| code := parsed.Query().Get("code") | ||
| pastedState := parsed.Query().Get("state") | ||
| if code == "" { | ||
| manualCh <- oauth.CallbackResult{Err: fmt.Errorf("URL missing 'code' parameter")} | ||
| return | ||
| } | ||
| if pastedState == "" { | ||
| manualCh <- oauth.CallbackResult{Err: fmt.Errorf("URL missing 'state' parameter")} | ||
| return | ||
| } | ||
| if pastedState != state { | ||
| manualCh <- oauth.CallbackResult{Err: fmt.Errorf("state mismatch in pasted URL")} | ||
| return | ||
| } | ||
| manualCh <- oauth.CallbackResult{Code: code} | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| }() | ||
|
|
||
| var result oauth.CallbackResult | ||
| select { | ||
| case result = <-resultCh: | ||
| case result = <-manualCh: | ||
| case <-ctx.Done(): | ||
| return fmt.Errorf("login timed out after 5 minutes") | ||
| } | ||
|
|
||
| if result.Err != nil { | ||
| return fmt.Errorf("authorization failed: %w", result.Err) | ||
| } | ||
|
|
||
| fmt.Fprintln(cmd.OutOrStdout(), "Authorization code received, exchanging for tokens...") | ||
|
|
||
| tokenResp, err := oauth.ExchangeCode( | ||
| oidcCfg.TokenEndpoint, | ||
| clientID, | ||
| result.Code, | ||
| redirectURI, | ||
| pkce.Verifier, | ||
| ) | ||
| if err != nil { | ||
| return fmt.Errorf("token exchange: %w", err) | ||
| } | ||
|
|
||
| cfg.AccessToken = tokenResp.AccessToken | ||
| cfg.RefreshToken = "" | ||
| cfg.IssuerURL = issuerURL | ||
| cfg.ClientID = clientID | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| if err := config.Save(cfg); err != nil { | ||
| return fmt.Errorf("save config: %w", err) | ||
| } | ||
|
|
||
| location, err := config.Location() | ||
| if err != nil { | ||
| fmt.Fprintln(cmd.OutOrStdout(), "Login successful. Configuration saved.") | ||
| } else { | ||
| fmt.Fprintf(cmd.OutOrStdout(), "Login successful. Configuration saved to %s\n", location) | ||
| } | ||
|
|
||
| if exp, err := config.TokenExpiry(tokenResp.AccessToken); err == nil && !exp.IsZero() { | ||
| if time.Until(exp) < 24*time.Hour { | ||
| fmt.Fprintf(cmd.ErrOrStderr(), "Note: token expires at %s\n", exp.Format(time.RFC3339)) | ||
| } | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| package oauth | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "os/exec" | ||
| "runtime" | ||
| ) | ||
|
|
||
| // OpenBrowser opens the specified URL in the user's default browser. | ||
| 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 %q", runtime.GOOS) | ||
| } | ||
| return cmd.Start() | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| package oauth | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "net" | ||
| "net/http" | ||
| "time" | ||
| ) | ||
|
|
||
| // CallbackResult holds the authorization code received from the callback. | ||
| type CallbackResult struct { | ||
| Code string | ||
| Err error | ||
| } | ||
|
|
||
| // StartCallbackServer starts a local HTTP server on a random port to receive | ||
| // the OAuth callback. It returns the server's address and a channel that will | ||
| // receive the authorization code. | ||
| func StartCallbackServer(ctx context.Context, expectedState string) (addr string, resultCh <-chan CallbackResult, cleanup func(), err error) { | ||
| listener, err := net.Listen("tcp", "127.0.0.1:0") | ||
| if err != nil { | ||
| return "", nil, nil, fmt.Errorf("listen on localhost: %w", err) | ||
| } | ||
|
|
||
| ch := make(chan CallbackResult, 1) | ||
| mux := http.NewServeMux() | ||
| server := &http.Server{Handler: mux} | ||
|
|
||
| mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { | ||
| state := r.URL.Query().Get("state") | ||
| if state != expectedState { | ||
| http.Error(w, "Invalid state parameter", http.StatusBadRequest) | ||
| ch <- CallbackResult{Err: fmt.Errorf("state mismatch: expected %q, got %q", expectedState, state)} | ||
| return | ||
| } | ||
|
|
||
| errParam := r.URL.Query().Get("error") | ||
| if errParam != "" { | ||
| desc := r.URL.Query().Get("error_description") | ||
| http.Error(w, "Authorization failed: "+errParam, http.StatusBadRequest) | ||
| ch <- CallbackResult{Err: fmt.Errorf("authorization error: %s: %s", errParam, desc)} | ||
| return | ||
| } | ||
|
|
||
| code := r.URL.Query().Get("code") | ||
| if code == "" { | ||
| http.Error(w, "Missing authorization code", http.StatusBadRequest) | ||
| ch <- CallbackResult{Err: fmt.Errorf("callback missing authorization code")} | ||
| return | ||
| } | ||
|
|
||
| w.Header().Set("Content-Type", "text/html") | ||
| fmt.Fprint(w, successHTML) | ||
| ch <- CallbackResult{Code: code} | ||
| }) | ||
|
|
||
| go func() { | ||
| if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { | ||
| ch <- CallbackResult{Err: fmt.Errorf("callback server: %w", err)} | ||
| } | ||
| }() | ||
|
|
||
| cleanupFn := func() { | ||
| shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) | ||
| defer shutdownCancel() | ||
| server.Shutdown(shutdownCtx) //nolint:errcheck | ||
| } | ||
|
|
||
| return listener.Addr().String(), ch, cleanupFn, nil | ||
| } | ||
|
|
||
| const successHTML = `<!DOCTYPE html> | ||
| <html><head><title>Login Successful</title> | ||
| <style> | ||
| body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; | ||
| display: flex; justify-content: center; align-items: center; height: 100vh; | ||
| margin: 0; background: #1a1a2e; color: #e0e0e0; } | ||
| .container { text-align: center; padding: 2rem; } | ||
| h1 { color: #4ecca3; } | ||
| p { color: #a0a0a0; } | ||
| </style></head> | ||
| <body><div class="container"> | ||
| <h1>Login Successful</h1> | ||
| <p>You can close this window and return to the terminal.</p> | ||
| </div></body></html>` |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Stdin copy goroutine will outlive the function if callback succeeds first.
The goroutine reading from
os.Stdinwill remain blocked indefinitely if the browser callback completes before any stdin input. While this is a minor leak, it's acceptable for a CLI tool that exits after login. No action required, but worth noting for future refactoring if this code is reused in a long-running context.🤖 Prompt for AI Agents