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 d
(prompter Prompter)
| 71 | // host with a random callback port it diverts to device flow, whose redirect |
| 72 | // does not depend on reaching this machine's localhost. |
| 73 | func (m *Manager) beginPKCE(prompter Prompter) (*flowPlan, error) { |
| 74 | state, err := randomState() |
| 75 | if err != nil { |
| 76 | return nil, err |
| 77 | } |
| 78 | verifier := oauth2.GenerateVerifier() |
| 79 | |
| 80 | // Bind to all interfaces only inside a container, where the published port |
| 81 | // is delivered via eth0 rather than loopback. Native runs stay on loopback. |
| 82 | listener, err := listenCallback(m.config.CallbackPort, m.inDocker()) |
| 83 | if err != nil { |
| 84 | return nil, fmt.Errorf("%w: %w", errCallbackBind, err) |
| 85 | } |
| 86 | if m.inDocker() { |
| 87 | // Inside a container the callback binds all interfaces so the published |
| 88 | // port is reachable, which also exposes it to the container network. |
| 89 | // Publishing to loopback only (e.g. -p 127.0.0.1:%d:%d) keeps the |
| 90 | // authorization code off the network. |
| 91 | 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)) |
| 92 | } |
| 93 | cs := newCallbackServer(listener, state) |
| 94 | |
| 95 | oc := m.oauth2Config(cs.redirect) |
| 96 | authURL := oc.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier)) |
| 97 | |
| 98 | run := func(ctx context.Context) (*oauth2.Token, error) { |
| 99 | code, err := cs.wait(ctx) |
| 100 | if err != nil { |
| 101 | return nil, err |
| 102 | } |
| 103 | tok, err := oc.Exchange(ctx, code, oauth2.VerifierOption(verifier)) |
| 104 | if err != nil { |
| 105 | return nil, fmt.Errorf("exchanging authorization code: %w", err) |
| 106 | } |
| 107 | return tok, nil |
| 108 | } |
| 109 | |
| 110 | browserErr := m.openURL(authURL) |
| 111 | switch { |
| 112 | case browserErr == nil: |
| 113 | m.logger.Info("opened browser for GitHub authorization") |
| 114 | return &flowPlan{run: run}, nil |
| 115 | case errors.Is(browserErr, errNoDisplay) && m.config.CallbackPort == 0: |
| 116 | // Headless host with a random callback port: every PKCE channel ends in a |
| 117 | // redirect to this machine's localhost, which a browser on another machine |
| 118 | // (e.g. a remote SSH client) cannot reach — so even URL elicitation would |
| 119 | // dead-end. Device flow is the only channel reachable from elsewhere, so |
| 120 | // prefer it when the app supports it; otherwise fall through to the manual |
| 121 | // authorization URL below for a same-machine browser. |
| 122 | plan, deviceErr := m.beginDevice(prompter) |
| 123 | if deviceErr == nil { |
| 124 | cs.close() |
| 125 | m.logger.Info("no display server; using device flow") |
| 126 | return plan, nil |
| 127 | } |
| 128 | m.logger.Debug("device flow unavailable on headless host; offering manual authorization URL", "reason", deviceErr) |
| 129 | default: |
| 130 | m.logger.Debug("browser auto-open unavailable", "reason", browserErr) |
no test coverage detected