chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user