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) }