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 3 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>
A fixed --oauth-callback-port is registered with the OAuth app and chosen deliberately, so a bind failure means another process holds the port and could intercept the authorization redirect. Treat that as fatal instead of silently downgrading to the device flow, which would mask the conflict. Also warn, when binding the callback inside a container, that the listener is on all interfaces and should be published to loopback only so the authorization code is not exposed on the container network. 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