feat(oauth): add stdio OAuth 2.1 login core library (1/4)#2704
feat(oauth): add stdio OAuth 2.1 login core library (1/4)#2704SamMorrowDrums wants to merge 2 commits into
Conversation
Introduce internal/oauth, a self-contained library that performs the user-facing GitHub OAuth login the stdio server uses to obtain a token without a pre-provisioned PAT. It is independent of MCP: client concerns (elicitation) sit behind the Prompter interface so the flows are testable without a live session. What it provides: - Authorization-code + PKCE flow with a local loopback callback server, state/CSRF validation, and XSS-safe result pages. - Device-authorization flow as a fallback (headless, containers). - A Manager that selects the most secure available channel (browser auto-open -> URL elicitation -> last-resort user action), runs a single flow at a time, and exposes a refreshing token source. Both GitHub OAuth Apps and GitHub Apps are supported without special casing: the token is modeled as an x/oauth2 refreshing TokenSource, so expiring GitHub App user tokens are renewed transparently (the gap that made a stored-token approach silently die after ~8h). When a client lacks secure URL elicitation and the flow falls back to a tool-response message, the message advises the user that their agent/CLI/ IDE does not appear to support URL elicitation and suggests requesting it for improved security. Tests exercise real protocol behavior against an httptest GitHub stand-in: PKCE challenge/verifier, GitHub App refresh-on-expiry, device polling, URL elicitation, declined prompts, the last-resort action with advisory, and single-flight concurrency. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds a new internal/oauth core library that implements GitHub OAuth (PKCE + loopback callback, with device-flow fallback) for future stdio-mode authentication, including a Manager that orchestrates flow selection and exposes a refreshing oauth2.TokenSource. This PR is intentionally not wired into the server yet.
Changes:
- Introduces the OAuth core library (
internal/oauth) with PKCE + device-flow support and a user-prompt abstraction (Prompter). - Adds embedded HTML templates for local callback success/error pages.
- Adds unit tests using an
httptestfake GitHub OAuth server and prompter fakes; addsgolang.org/x/oauth2as a direct dependency.
Show a summary per file
| File | Description |
|---|---|
| internal/oauth/oauth.go | Defines OAuth config, GitHub endpoint construction, and host normalization helpers. |
| internal/oauth/oauth_test.go | Tests host normalization, endpoint construction, and random state generation. |
| internal/oauth/prompter.go | Introduces Prompter interface and prompt data model for secure out-of-band UX. |
| internal/oauth/manager.go | Adds Manager that single-flights auth flows and provides refreshed access tokens. |
| internal/oauth/manager_test.go | Behavioral tests for browser PKCE, URL elicitation, declines, device flow, and single-flight. |
| internal/oauth/flow.go | Implements flow selection plus PKCE/device flow preparation and user-action fallback messaging. |
| internal/oauth/env.go | Adds browser-opening and Docker-detection utilities used by the manager. |
| internal/oauth/callback.go | Implements the local loopback callback server with embedded templates and state validation. |
| internal/oauth/callback_test.go | Tests callback handler outcomes and loopback binding for random ports. |
| internal/oauth/testutil_test.go | Test utilities: fake GitHub OAuth endpoints, fake prompter, browser simulation, polling helper. |
| internal/oauth/templates/success.html | HTML template shown after successful authorization redirect. |
| internal/oauth/templates/error.html | HTML template shown after failed authorization redirect (auto-escaped error). |
| go.mod | Adds golang.org/x/oauth2 as a direct dependency. |
Copilot's findings
- Files reviewed: 13/13 changed files
- Comments generated: 4
| cmd.Stdout = io.Discard | ||
| cmd.Stderr = io.Discard | ||
| return cmd.Start() | ||
| } |
There was a problem hiding this comment.
Good catch — fixed in f707dba. openBrowser now reaps the launcher asynchronously (go func() { _ = cmd.Wait() }()) so it can't linger as a zombie for the life of the server. The launcher (xdg-open/open/rundll32) exits as soon as it hands off to the browser, so this is cheap.
| // listenCallback binds the local callback listener. | ||
| // | ||
| // A random port (port == 0) binds to 127.0.0.1 only: the redirect target is | ||
| // loopback and never reachable off-host. A fixed port binds to all interfaces | ||
| // because Docker's published-port DNAT delivers traffic to the container's eth0 | ||
| // rather than to loopback; exposure is still constrained by the host-side | ||
| // publish (e.g. -p 127.0.0.1:8085:8085). | ||
| func listenCallback(port int) (net.Listener, error) { | ||
| host := "127.0.0.1" | ||
| if port > 0 { | ||
| host = "0.0.0.0" | ||
| } | ||
| addr := fmt.Sprintf("%s:%d", host, port) | ||
| listener, err := net.Listen("tcp", addr) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("starting callback listener on %s: %w", addr, err) | ||
| } | ||
| return listener, nil | ||
| } |
There was a problem hiding this comment.
Fixed in f707dba. listenCallback now takes an explicit bindAll flag and binds to 0.0.0.0 only when running inside a container; beginPKCE passes m.inDocker(). Native runs — even with a fixed callback port — stay on 127.0.0.1. (PKCE in a container only happens with a fixed port, since a random port there falls back to device flow, so this is exactly the publish-via-eth0 case that needs all-interfaces.) Call site and test updated accordingly.
| listener, err := listenCallback(m.config.CallbackPort) | ||
| if err != nil { | ||
| return nil, err | ||
| } |
There was a problem hiding this comment.
Done — beginPKCE now calls listenCallback(m.config.CallbackPort, m.inDocker()), so bindAll is driven by container detection as you suggested.
| func TestListenCallbackRandomPortIsLoopback(t *testing.T) { | ||
| listener, err := listenCallback(0) | ||
| require.NoError(t, err) | ||
| defer listener.Close() |
There was a problem hiding this comment.
Updated — the test helper now calls listenCallback(0, false), and I added TestListenCallbackBindAllForContainer to assert the bindAll path binds all interfaces.
Address code review: - openBrowser: reap the launcher process asynchronously so it does not linger as a zombie for the lifetime of the server. - listenCallback: take an explicit bindAll flag and bind to all interfaces only inside a container (where the published port arrives via eth0). A native run, even with a fixed callback port, now stays on 127.0.0.1 instead of 0.0.0.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
OAuth for stdio — PR 1 of 4
This is the first PR in a stack that replaces #1836 with smaller, focused changes. It adds only the OAuth core library (
internal/oauth); nothing is wired into the server yet, so this PR is inert on its own and safe to review in isolation.Stack
internal/oauthcore library ← this PRWhat this adds
A self-contained library that performs the user-facing GitHub OAuth login the stdio server will use to obtain a token without a pre-provisioned PAT. It depends only on
golang.org/x/oauth2and the standard library; MCP concerns (elicitation) sit behind a smallPrompterinterface so the flows are fully testable without a live client.state/CSRF validation,ReadHeaderTimeout, and XSS-safe HTML result pages.Managerthat picks the most secure available channel — browser auto-open → URL elicitation → last-resort tool-response action — runs a single flow at a time (single-flight), and exposes a token.Why a refreshing token source (the key correctness point)
The token is modeled as an
x/oauth2refreshingTokenSource, so both OAuth Apps and GitHub Apps work without special-casing. GitHub App user tokens expire (~8h) and carry a refresh token; this renews them transparently. (A stored-token approach — as in the original PR — silently dies when the token expires.)URL-elicitation security advisory
When a client lacks secure URL elicitation and we fall back to returning the auth URL/device code as a tool response, the message also advises the user that their agent/CLI/IDE does not appear to support URL elicitation and suggests requesting it for improved security — keeping the auth URL out of model context where possible.
Tests
Behavioral tests run against an
httptestGitHub stand-in and assert real protocol behavior rather than mirroring the implementation: PKCES256challenge/verifier round-trip, GitHub App refresh-on-expiry, device polling (incl.authorization_pending), URL elicitation, declined prompts, the last-resort action + advisory, and single-flight concurrency.go test -race,golangci-lint, and the license check all pass.Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com