package oauth import ( "context" "errors" "log/slog" "net/http" "os" "sync" "time" "golang.org/x/oauth2" ) // DefaultAuthTimeout bounds how long a single authorization attempt waits for // the user to complete the browser or device flow. const DefaultAuthTimeout = 5 * time.Minute // tokenRefreshTimeout bounds each background refresh of an expiring token so a // stalled GitHub token endpoint cannot block a tool call indefinitely. const tokenRefreshTimeout = 30 * time.Second // flowStatus tracks the manager's single-flight authorization state. type flowStatus int const ( statusIdle flowStatus = iota // no flow running statusStarting // a flow is being prepared (brief) statusInProgress // a flow is running on a secure channel; callers may join statusAwaitingUser // a flow is running but the user must act out-of-band ) // Outcome reports the result of an authorization attempt that did not // immediately yield a token. type Outcome struct { // UserAction, when non-nil, must be surfaced to the user. The authorization // flow continues in the background; the user should retry once they have // completed it. UserAction *UserAction } // UserAction is an instruction for the user to complete authorization out of // band (the last-resort channel, used when neither a browser nor URL // elicitation is available). type UserAction struct { // Message is ready to display to the user. Message string // URL is the authorization URL or device verification URI. URL string // UserCode is the device-flow code to enter, if any. UserCode string } // Manager owns the OAuth login flows and the resulting (refreshing) token for a // single stdio session. It is safe for concurrent use; only one authorization // flow runs at a time. type Manager struct { config Config refreshConfig *oauth2.Config logger *slog.Logger // Test seams, set by NewManager to real implementations. openURL func(string) error inDocker func() bool mu sync.Mutex source oauth2.TokenSource // refreshing source, set once authorized status flowStatus pending *UserAction done chan struct{} lastErr error refreshErrLogged bool // true once a refresh failure has been logged, reset on re-auth } // NewManager builds a Manager for the given configuration. A nil logger logs to // stderr. func NewManager(cfg Config, logger *slog.Logger) *Manager { if logger == nil { logger = slog.New(slog.NewTextHandler(os.Stderr, nil)) } m := &Manager{ config: cfg, logger: logger, openURL: openBrowser, inDocker: isRunningInDocker, } m.refreshConfig = m.oauth2Config("") return m } // AccessToken returns a currently valid access token, refreshing it if needed, // or "" if the session is not authorized (or a refresh has failed and // re-authorization is required). It is cheap to call repeatedly: the underlying // token source caches and only refreshes when the token has expired. func (m *Manager) AccessToken() string { m.mu.Lock() src := m.source m.mu.Unlock() if src == nil { return "" } // Refresh (if needed) happens here, off the lock, because ReuseTokenSource may // make a blocking network call and holding m.mu would serialize every tool call. tok, err := src.Token() if err != nil { // A refresh failure (expired GitHub App refresh token, revoked grant, or a // network blip) leaves the session unauthorized and forces a re-login. // Surface it once, otherwise it only manifests as a surprise re-authorization // prompt. The oauth2 error carries the token endpoint's response, not the // access or refresh token. m.mu.Lock() if !m.refreshErrLogged { m.refreshErrLogged = true m.logger.Warn("OAuth token refresh failed; re-authorization required", "error", err) } m.mu.Unlock() return "" } if !tok.Valid() { return "" } return tok.AccessToken } // HasToken reports whether a valid token is currently available. func (m *Manager) HasToken() bool { return m.AccessToken() != "" } // Authenticate ensures the session is authorized. // // It returns (nil, nil) once a token is available, so the caller may proceed. // It returns (&Outcome{UserAction}, nil) when the user must complete the flow // out of band; the flow continues in the background and the caller should show // the action and have the user retry. It returns (nil, err) on failure. // // Only one flow runs at a time. Concurrent callers either join a running secure // flow, receive the pending user action, or are told to retry shortly. func (m *Manager) Authenticate(ctx context.Context, prompter Prompter) (*Outcome, error) { if m.AccessToken() != "" { return nil, nil } m.mu.Lock() switch m.status { case statusAwaitingUser: ua := m.pending m.mu.Unlock() return &Outcome{UserAction: ua}, nil case statusStarting: m.mu.Unlock() return &Outcome{UserAction: &UserAction{ Message: "GitHub authorization is already in progress. Please retry your request in a few seconds.", }}, nil case statusInProgress: done := m.done m.mu.Unlock() return m.joinWait(ctx, done) } // Idle: this call owns the new flow. m.status = statusStarting m.lastErr = nil m.done = make(chan struct{}) done := m.done m.mu.Unlock() plan, err := m.begin(prompter) if err != nil { m.complete(nil, err) return nil, err } m.mu.Lock() if plan.userAction != nil { m.status = statusAwaitingUser m.pending = plan.userAction } else { m.status = statusInProgress } m.mu.Unlock() bgCtx, cancel := context.WithTimeout(context.Background(), DefaultAuthTimeout) go m.runFlow(bgCtx, cancel, plan) if plan.userAction != nil { return &Outcome{UserAction: plan.userAction}, nil } return m.joinWait(ctx, done) } // runFlow executes a prepared flow in the background and records the result. The // optional display prompt runs concurrently: a decline (or other failure) aborts // the flow, while an undeliverable prompt degrades to the manual fallback without // tearing the flow down, so the user can still authorize out of band. func (m *Manager) runFlow(ctx context.Context, cancel context.CancelFunc, plan *flowPlan) { defer cancel() if plan.display != nil { go func() { err := plan.display(ctx) switch { case err == nil: // Prompt shown; the flow completes when the token arrives. case ctx.Err() != nil: // The flow is already ending (timed out or cancelled elsewhere), // so there is nothing to fall back to. Checking this before the // fallback also prevents misreading a context-cancelled prompt as // a transport failure. case errors.Is(err, ErrPromptUnavailable) && plan.fallback != nil: // The client advertised the capability but could not deliver the // prompt. Surface the manual instructions instead of failing, and // keep the background flow alive so the user can still authorize. m.logger.Debug("authorization prompt undeliverable; falling back to manual instructions", "reason", err) m.fallBackToUserAction(plan.fallback) default: // A user decline (ErrPromptDeclined) or any other prompt failure // ends the flow. m.logger.Debug("authorization prompt closed", "reason", err) cancel() } }() } tok, err := plan.run(ctx) m.complete(tok, err) } // fallBackToUserAction promotes a running secure flow to the manual user-action // channel after its prompt could not be delivered. The background flow keeps // running, so the user can complete authorization out of band and retry. It is a // no-op if the flow has already resolved. func (m *Manager) fallBackToUserAction(ua *UserAction) { m.mu.Lock() defer m.mu.Unlock() if m.status != statusInProgress { return } m.status = statusAwaitingUser m.pending = ua // Wake any callers joined on this flow so they receive the action, and clear // done so complete() does not double-close it when run() later finishes. if m.done != nil { close(m.done) m.done = nil } } // complete records the flow result, installing a refreshing token source on // success, and wakes any joined callers. func (m *Manager) complete(tok *oauth2.Token, err error) { m.mu.Lock() defer m.mu.Unlock() m.status = statusIdle m.pending = nil if err != nil { m.lastErr = err m.logger.Debug("oauth flow failed", "error", err) } else { m.lastErr = nil // Config.TokenSource returns a ReuseTokenSource that refreshes expired // tokens using the refresh token — this is what makes GitHub App // (expiring) tokens work transparently. The refresh uses a bounded HTTP // client so a stalled token endpoint can't block a tool call forever. refreshCtx := context.WithValue(context.Background(), oauth2.HTTPClient, &http.Client{Timeout: tokenRefreshTimeout}) m.source = m.refreshConfig.TokenSource(refreshCtx, tok) m.refreshErrLogged = false m.logger.Info("github authorization complete") } if m.done != nil { close(m.done) m.done = nil } } // joinWait blocks until the running flow finishes or ctx is cancelled. If the // flow was promoted to the manual channel while waiting (its prompt could not be // delivered), it returns that user action rather than an error. func (m *Manager) joinWait(ctx context.Context, done chan struct{}) (*Outcome, error) { select { case <-done: if m.AccessToken() != "" { return nil, nil } m.mu.Lock() pending := m.pending err := m.lastErr m.mu.Unlock() if pending != nil { return &Outcome{UserAction: pending}, nil } if err != nil { return nil, err } return nil, errors.New("authorization did not complete") case <-ctx.Done(): return nil, ctx.Err() } } func (m *Manager) oauth2Config(redirectURL string) *oauth2.Config { return &oauth2.Config{ ClientID: m.config.ClientID, ClientSecret: m.config.ClientSecret, RedirectURL: redirectURL, Scopes: m.config.Scopes, Endpoint: m.config.Endpoint, } }