8ce77e3c16
* feat(oauth): add stdio OAuth 2.1 stdio login 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> * fix(oauth): reap browser launcher and keep native callback on loopback 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> * fix(oauth): fail fast when a fixed callback port is unavailable 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> * fix(oauth): surface refresh failures, bound refresh, prefer device flow when headless Addresses pre-merge review of the OAuth stdio core: - Log a one-time warning when token refresh fails instead of silently returning an empty access token, so a forced re-login isn't a surprise. - Bound each background token refresh with a 30s HTTP client timeout so a stalled GitHub token endpoint can't block tool calls indefinitely. - On a headless host (no display server) with a random callback port, fall back to the device-code flow — the only channel reachable from a browser on another machine — instead of dead-ending on an unreachable localhost redirect. A generic browser-open failure still offers the manual URL. - Mark the callback bind failure with a sentinel so the fixed-port-busy fatal path can't misreport an unrelated error as a port conflict. - Export NormalizeHost so callers can recognize the default github.com host (consumed by the build-time baked-in credential guard). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(oauth): wire stdio OAuth 2.1 login into the server (2/4) (#2710) * feat(oauth): wire stdio OAuth 2.1 login into the server Connect the internal/oauth core library to the stdio MCP server so users can authenticate with an OAuth App or GitHub App client ID instead of a static personal access token. - BearerAuthTransport gains a TokenProvider that is consulted per request, letting the lazily-acquired, auto-refreshing OAuth token take effect without rebuilding the client. - createGitHubClients uses BearerAuthTransport (and skips go-github's WithAuthToken, which would pin a static token) when a TokenProvider is set. - RunStdioServer starts without a token and installs receiving middleware that runs the authorization flow on the first tool call, surfacing the auth URL or device code via elicitation (or a tool result as a fallback). - Tool filtering uses the requested OAuth scopes; the default supported set hides nothing, while a narrower --oauth-scopes both narrows the grant and filters tools accordingly. - A sessionPrompter adapts the MCP server session to oauth.Prompter, keeping the authorization URL off the model's context. - New stdio flags: --oauth-client-id/-client-secret/-scopes/-callback-port. This is stdio-only and deliberately does not touch MCP-HTTP auth. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(oauth): address review — omit empty bearer header, guard token/oauth - BearerAuthTransport omits the Authorization header entirely when the token is empty (pre-authorization) rather than sending an empty "Bearer " value. - RunStdioServer rejects the ambiguous combination of a static Token and an OAuthManager up front, enforcing the documented mutual exclusivity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(oauth): clarify SupportedScopes is the stdio default and tool filter Document that stdio OAuth login requests these scopes by default and then filters the exposed tools to the scopes actually granted, so a tool whose required scope is absent from this list is hidden under default OAuth even though a PAT carrying that scope would expose it. Keep the list in sync with tool scope requirements when scopes change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Distinguish undeliverable auth prompts from user declines An elicitation prompt that the client cannot deliver (a transport or protocol failure) was treated the same as a user actively declining: any display error cancelled the flow. That conflated a system failure with a deliberate "no", so a client that advertised URL elicitation but failed to deliver it would hard-fail the login instead of degrading. Add an ErrPromptUnavailable sentinel alongside ErrPromptDeclined and have the MCP adapter return it when Elicit fails at the transport level. The manager now falls back to the manual user-action channel on an undeliverable prompt (keeping the background flow alive so the user can still authorize out of band), while a genuine decline still aborts. A context-cancelled prompt is checked first so an ending flow is never misread as a transport failure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * build(oauth): bake in default OAuth credentials for official releases (3/4) (#2711) * build(oauth): bake in default OAuth credentials via build-time ldflags Inject the public OAuth client credentials (stored as the OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET repo secrets) at build time via -ldflags so official binaries and images ship a working default app for zero-config login. Security relies on PKCE, not on the secret. Local/dev builds leave the values empty and continue to require an explicit token or --oauth-client-id. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(oauth): recognize github.com host aliases for the baked-in client Match the default host via oauth.NormalizeHost instead of only an empty host string, so an explicit GITHUB_HOST=github.com (or api.github.com) still counts as the default and keeps zero-config baked-in login working. GHES and ghe.com users continue to bring their own --oauth-client-id. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(oauth): document stdio OAuth login; make PAT optional in install config (#2717) Add a dedicated Local Server OAuth Login guide (docs/oauth-login.md) covering the PKCE/device flows, display channels and the URL-elicitation security advisory, scope-based tool filtering, the fixed-port Docker recipe and its loopback/port-safety behavior, bringing your own OAuth or GitHub App, and the GitHub Enterprise Server / ghe.com requirement to register an app on that host (custom --gh-host directs login at that instance's authorization server). Reflect that the local server now logs in with OAuth by default on github.com: - README: make the stdio Docker install badges OAuth-first (fixed callback port 8085 published to loopback), drop the PAT prompt, and reframe the PAT as an optional alternative with a pointer to the new guide. - server.json: make GITHUB_PERSONAL_ACCESS_TOKEN optional and publish the OAuth callback port so the registry default works without a token. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
221 lines
8.8 KiB
Go
221 lines
8.8 KiB
Go
package oauth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
// deviceAuthTimeout bounds the synchronous device-code request made while
|
|
// preparing the device flow (before any waiting on the user).
|
|
const deviceAuthTimeout = 30 * time.Second
|
|
|
|
// errCallbackBind marks a failure to bind the local OAuth callback listener, so
|
|
// begin can treat a busy fixed port as fatal without mislabeling unrelated
|
|
// errors (e.g. a failure to generate the state parameter) as a port conflict.
|
|
var errCallbackBind = errors.New("OAuth callback listener could not bind")
|
|
|
|
// flowPlan is a prepared authorization flow ready to run in the background.
|
|
type flowPlan struct {
|
|
// run performs the blocking part of the flow (await callback + exchange, or
|
|
// poll the device endpoint) and returns the token.
|
|
run func(context.Context) (*oauth2.Token, error)
|
|
// display, if set, presents the prompt to the user via the Prompter and
|
|
// blocks until they act. ErrPromptDeclined (the user said no) or any other
|
|
// error aborts the flow, except ErrPromptUnavailable, which degrades to
|
|
// fallback when that is set.
|
|
display func(context.Context) error
|
|
// fallback, if set alongside display, is the manual user action to surface
|
|
// when the display prompt cannot be delivered (ErrPromptUnavailable). It lets
|
|
// a runtime elicitation failure degrade to the manual channel — keeping the
|
|
// background flow alive — instead of aborting.
|
|
fallback *UserAction
|
|
// userAction, if set, indicates the last-resort channel: the caller must
|
|
// surface it and the user retries after authorizing out of band.
|
|
userAction *UserAction
|
|
}
|
|
|
|
// begin selects and prepares the appropriate flow. PKCE is preferred for its
|
|
// stronger security; device flow is the fallback. A random callback port inside
|
|
// Docker cannot be reached from the host browser, so that combination goes
|
|
// straight to device flow.
|
|
func (m *Manager) begin(prompter Prompter) (*flowPlan, error) {
|
|
canPKCE := m.config.CallbackPort != 0 || !m.inDocker()
|
|
if canPKCE {
|
|
plan, err := m.beginPKCE(prompter)
|
|
if err == nil {
|
|
return plan, nil
|
|
}
|
|
// A fixed callback port that won't bind is fatal, not a cue to downgrade.
|
|
// The port was chosen deliberately (and registered with the OAuth app), so
|
|
// a bind failure means another process holds it — possibly one positioned
|
|
// to intercept the authorization redirect. Silently switching to device
|
|
// flow would mask that, so stop and make the user resolve it. Only genuine
|
|
// bind failures qualify; other errors fall through to device flow.
|
|
if m.config.CallbackPort != 0 && errors.Is(err, errCallbackBind) {
|
|
return nil, fmt.Errorf("OAuth callback port %d is not available; another process may be using it — free the port or set a different --oauth-callback-port: %w", m.config.CallbackPort, err)
|
|
}
|
|
m.logger.Info("PKCE flow unavailable, falling back to device flow", "reason", err)
|
|
} else {
|
|
m.logger.Info("no callback port inside container; using device flow")
|
|
}
|
|
return m.beginDevice(prompter)
|
|
}
|
|
|
|
// beginPKCE prepares the authorization-code + PKCE flow. It binds the callback
|
|
// server and selects the most secure available display channel: browser
|
|
// auto-open, then URL elicitation, then a tool-response message. On a headless
|
|
// host with a random callback port it diverts to device flow, whose redirect
|
|
// does not depend on reaching this machine's localhost.
|
|
func (m *Manager) beginPKCE(prompter Prompter) (*flowPlan, error) {
|
|
state, err := randomState()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
verifier := oauth2.GenerateVerifier()
|
|
|
|
// Bind to all interfaces only inside a container, where the published port
|
|
// is delivered via eth0 rather than loopback. Native runs stay on loopback.
|
|
listener, err := listenCallback(m.config.CallbackPort, m.inDocker())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %w", errCallbackBind, err)
|
|
}
|
|
if m.inDocker() {
|
|
// Inside a container the callback binds all interfaces so the published
|
|
// port is reachable, which also exposes it to the container network.
|
|
// Publishing to loopback only (e.g. -p 127.0.0.1:%d:%d) keeps the
|
|
// authorization code off the network.
|
|
m.logger.Warn(fmt.Sprintf("OAuth callback is listening on all container interfaces; publish it to loopback only (e.g. -p 127.0.0.1:%d:%d) so the authorization code is not exposed on your network", m.config.CallbackPort, m.config.CallbackPort))
|
|
}
|
|
cs := newCallbackServer(listener, state)
|
|
|
|
oc := m.oauth2Config(cs.redirect)
|
|
authURL := oc.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier))
|
|
|
|
run := func(ctx context.Context) (*oauth2.Token, error) {
|
|
code, err := cs.wait(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tok, err := oc.Exchange(ctx, code, oauth2.VerifierOption(verifier))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("exchanging authorization code: %w", err)
|
|
}
|
|
return tok, nil
|
|
}
|
|
|
|
browserErr := m.openURL(authURL)
|
|
switch {
|
|
case browserErr == nil:
|
|
m.logger.Info("opened browser for GitHub authorization")
|
|
return &flowPlan{run: run}, nil
|
|
case errors.Is(browserErr, errNoDisplay) && m.config.CallbackPort == 0:
|
|
// Headless host with a random callback port: every PKCE channel ends in a
|
|
// redirect to this machine's localhost, which a browser on another machine
|
|
// (e.g. a remote SSH client) cannot reach — so even URL elicitation would
|
|
// dead-end. Device flow is the only channel reachable from elsewhere, so
|
|
// prefer it when the app supports it; otherwise fall through to the manual
|
|
// authorization URL below for a same-machine browser.
|
|
plan, deviceErr := m.beginDevice(prompter)
|
|
if deviceErr == nil {
|
|
cs.close()
|
|
m.logger.Info("no display server; using device flow")
|
|
return plan, nil
|
|
}
|
|
m.logger.Debug("device flow unavailable on headless host; offering manual authorization URL", "reason", deviceErr)
|
|
default:
|
|
m.logger.Debug("browser auto-open unavailable", "reason", browserErr)
|
|
}
|
|
|
|
// The manual instructions double as the fallback if a chosen display channel
|
|
// turns out to be undeliverable at runtime, so build them once here.
|
|
manual := &UserAction{
|
|
URL: authURL,
|
|
Message: fmt.Sprintf(
|
|
"To authorize the GitHub MCP Server, open this URL in your browser:\n\n%s\n\nAfter authorizing, retry your request.\n\n%s",
|
|
authURL, securityAdvisory,
|
|
),
|
|
}
|
|
|
|
if canPromptURL(prompter) {
|
|
display := func(ctx context.Context) error {
|
|
return prompter.PromptURL(ctx, Prompt{
|
|
Message: "Authorize the GitHub MCP Server in your browser to continue.",
|
|
URL: authURL,
|
|
})
|
|
}
|
|
return &flowPlan{run: run, display: display, fallback: manual}, nil
|
|
}
|
|
|
|
return &flowPlan{run: run, userAction: manual}, nil
|
|
}
|
|
|
|
// beginDevice prepares the device authorization flow. It requests a device code
|
|
// up front (so the code can be displayed) and selects a display channel:
|
|
// URL elicitation, then form elicitation, then a tool-response message.
|
|
func (m *Manager) beginDevice(prompter Prompter) (*flowPlan, error) {
|
|
oc := m.oauth2Config("")
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), deviceAuthTimeout)
|
|
defer cancel()
|
|
da, err := oc.DeviceAuth(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("requesting device code: %w", err)
|
|
}
|
|
|
|
run := func(ctx context.Context) (*oauth2.Token, error) {
|
|
tok, err := oc.DeviceAccessToken(ctx, da)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("awaiting device authorization: %w", err)
|
|
}
|
|
return tok, nil
|
|
}
|
|
|
|
// As with PKCE, the manual instructions double as the runtime fallback, so
|
|
// build them once and reuse for both display plans and the last resort.
|
|
manual := &UserAction{
|
|
URL: da.VerificationURI,
|
|
UserCode: da.UserCode,
|
|
Message: fmt.Sprintf(
|
|
"%s\n\nAfter authorizing, retry your request.\n\n%s",
|
|
deviceInstruction(da), securityAdvisory,
|
|
),
|
|
}
|
|
|
|
if canPromptURL(prompter) {
|
|
display := func(ctx context.Context) error {
|
|
return prompter.PromptURL(ctx, Prompt{
|
|
Message: fmt.Sprintf("Enter code %s to authorize the GitHub MCP Server.", da.UserCode),
|
|
URL: da.VerificationURI,
|
|
UserCode: da.UserCode,
|
|
})
|
|
}
|
|
return &flowPlan{run: run, display: display, fallback: manual}, nil
|
|
}
|
|
|
|
if canPromptForm(prompter) {
|
|
display := func(ctx context.Context) error {
|
|
return prompter.PromptForm(ctx, Prompt{
|
|
Message: deviceInstruction(da),
|
|
URL: da.VerificationURI,
|
|
UserCode: da.UserCode,
|
|
})
|
|
}
|
|
return &flowPlan{run: run, display: display, fallback: manual}, nil
|
|
}
|
|
|
|
return &flowPlan{run: run, userAction: manual}, nil
|
|
}
|
|
|
|
// securityAdvisory nudges users on clients without URL elicitation to ask their
|
|
// vendor for it, since it keeps the authorization URL out of the model context.
|
|
const securityAdvisory = "Note: your MCP client does not appear to support secure URL elicitation. " +
|
|
"For improved security, consider asking your agent, CLI, or IDE to add it (for example, by opening an issue)."
|
|
|
|
func deviceInstruction(da *oauth2.DeviceAuthResponse) string {
|
|
return fmt.Sprintf("Visit %s and enter the code %s to authorize the GitHub MCP Server.", da.VerificationURI, da.UserCode)
|
|
}
|