Files
zero/internal/oauth/device.go
KRATOS bb7de29c80 feat(oauth): reusable provider OAuth engine + zero auth CLI (#210)
* feat(oauth): reusable provider OAuth engine + `zero auth` CLI

Add internal/oauth: a transport/identity-agnostic OAuth 2.0 engine that powers
provider login alongside the MCP-server OAuth zero already has. It is additive
and provider-neutral — NO providers, endpoints, or client identities are baked
into the binary; every provider is configured entirely via ZERO_OAUTH_<NAME>_*
env vars, so the engine works with any OAuth 2.0 / OIDC server.

Engine (internal/oauth):
- PKCE (S256 mandatory; "plain" refused) + per-flow CSRF state.
- Authorization-code flow: BuildAuthorizationURL / ExchangeCode / Refresh, with
  an https-only token-endpoint guard (loopback exempt) and error bodies that
  carry only error/error_description (never token material).
- Loopback callback server: 127.0.0.1-only, OS-assigned port, single use, state
  verified, then closed.
- Device-authorization grant (RFC 8628): RequestDeviceCode + PollDeviceToken
  honoring authorization_pending / slow_down / interval / expiry (headless/SSH).
- RFC 8414 + OIDC discovery (issuer-driven endpoint resolution).
- Namespaced token store ("provider:<name>" / "mcp:<name>"), 0600, file-locked
  (ownership-aware), atomic writes, malformed-fails-closed; GetFresh (on-demand
  refresh) + Handle401 (forced refresh).
- Opt-in proactive RefreshScheduler.

CLI (zero auth):
- login <provider> [--device] [--scope ...], logout, status [provider],
  refresh <provider> [--watch]. Status/output never print token material.
- Registered in app.go dispatch + top-level help.

MCP OAuth (internal/mcp) is left byte-for-byte unchanged (zero regression);
sharing the engine with MCP and encrypted-at-rest/keyring storage are documented
follow-ups. Tests cover every path (PKCE, auth URL, https-guard + redaction,
exchange/refresh, loopback capture + state-mismatch + timeout, device pending/
slow_down/expired, store namespacing/0600/locking, provider env resolution,
manager login (loopback+device)/GetFresh/Handle401, scheduler). Gates: gofmt /
vet / build (host+linux+windows) / test -race / staticcheck (no new) /
govulncheck (0) / deadcode (no new) all pass.

* oauth: fix Windows smoke — use OS-appropriate path in store-path test

TestResolveStorePathHonorsOverride hardcoded a unix "/tmp/custom/tok.json"
literal, which is not absolute on Windows (no drive), so ResolveStorePath
correctly resolves it against the current drive and the verbatim comparison
failed (D:\tmp\custom\tok.json). The production code is correct; the test now
builds the override with filepath.Join(t.TempDir(), ...) so it is absolute and
clean on every OS.

* oauth: address CodeRabbit review (11 findings)

Security / correctness:
- flow: caller-supplied ExtraAuthParams/extraParams can no longer override the
  reserved OAuth/PKCE fields (state, redirect_uri, code_challenge[_method],
  response_type, client_id) — e.g. forcing method=plain.
- device: a device-authorization response without expires_in now gets a bounded
  default lifetime (fail closed), and the poll loop re-checks expiry AFTER the
  interval sleep so it never polls past the deadline (e.g. after slow_down).
- lock: handle WriteString/Close errors on the token lock file — a partial write
  no longer strands an undeletable lock.
- store: a non-nil env map is now authoritative (hermetic) — a controlled map
  never falls back to ambient ZERO_OAUTH_* / HOME / XDG_CONFIG_HOME.
- scheduler: a transient loadForKey error backs off and retries (bounded) instead
  of permanently stopping proactive refresh.
- cli auth: reject flags a subcommand does not accept (e.g. login --watch,
  status --device) and reject empty --scope values — fail fast.

Lint blockers:
- manager: restructured the discovery fallback so the non-nil-err branch no longer
  returns nil (nilerr).
- providers: dropped the ineffectual flow initializer (ineffassign).

Tests: assert Save/Load errors in the scheduler test; the slow_down device test
no longer depends on post-expiry polling. Added tests for reserved-param
stripping, hermetic env, device default-expiry, and CLI flag validation. The
store-path test was already made OS-portable in the Windows-smoke fix.

Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(no new)/
govulncheck(0)/deadcode(no new) all pass.

* swarm: de-flake TestMailboxConcurrentSends on Windows CI

The 200-way concurrent-send stress test inherited from the swarm package timed
out on Windows CI (103/200 sends hit the 10s lock timeout): the lock's 20ms
retry sleep starves waiters when Windows file ops are slow under heavy
contention. Shorten the retry to 2ms (a freed lock is re-acquired promptly) and
raise the test's lock timeout to 60s so a legitimately-slow CI never times a send
out. Passes repeatedly under -race. (CI de-flake of a test that surfaced on
#210's Windows smoke; functionally unrelated to the OAuth changes.)

* oauth: address CodeRabbit re-review (4 findings)

- loopback (security): NewLoopbackListener refuses an empty CSRF state — an empty
  state would match a callback carrying no state at all, defeating the check.
- device (correctness): RequestDeviceCode now sends client_secret when set, so a
  confidential client authenticates on the device-authorization endpoint too
  (consistent with the token poll).
- cli auth: document --watch in the help Flags section.
- scheduler test: assert the seed Save error instead of ignoring it.

Also scrubbed a stray third-party reference from a loopback.go comment.
Tests added: empty-state rejection, device client_secret is sent. Gates:
gofmt/vet/build/test -race/staticcheck(no new)/govulncheck(0)/deadcode(no new)
all pass.

* mcp(oauth): delegate the OAuth engine to internal/oauth (dedup) (#212)

OAuth Phase 2: the MCP OAuth code now reuses the shared internal/oauth engine
instead of carrying its own copy of the transport/identity-agnostic primitives.
internal/mcp/oauth.go drops ~110 lines (526 -> 418): newPKCE, newState,
discoverAuthorizationServer, the authorize-URL builder, token exchange/refresh,
the token-response parser, and joinWellKnown now delegate to internal/oauth.

Behavior-preserving: MCP keeps its own OAuthConfig/StoredToken types,
LoginOptions/Login orchestration, loopback handling, registerClient (dynamic
client registration — not part of the shared engine), CLI output, and on-disk
token format (mcp-oauth-tokens.json, raw server-name keys). The existing mcp
oauth tests pass UNCHANGED. The only behavioral delta is a hardening inherited
from the shared engine: the token endpoint must be https (loopback exempt) —
a credential is no longer sent to a plaintext non-loopback endpoint.

Stacks on #210 (the internal/oauth package). Gates: gofmt/vet/build(host+
linux+windows)/test -race/staticcheck(no new)/govulncheck(0)/deadcode(no new
vs base) all pass; `zero mcp oauth status` output is identical.
2026-06-15 18:47:43 +05:30

214 lines
7.6 KiB
Go

package oauth
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const deviceGrantType = "urn:ietf:params:oauth:grant-type:device_code"
// defaultDeviceCodeLifetime bounds a device authorization that omits expires_in,
// so the expiry gate in PollDeviceToken is always active (fail closed).
const defaultDeviceCodeLifetime = 10 * time.Minute
// DeviceAuth is the result of an RFC 8628 device-authorization request.
type DeviceAuth struct {
DeviceCode string
UserCode string
VerificationURI string
VerificationURIComplete string
Interval time.Duration
ExpiresAt time.Time
}
type deviceAuthResponse struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
VerificationURIComplete string `json:"verification_uri_complete"`
ExpiresIn int64 `json:"expires_in"`
Interval int64 `json:"interval"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
// RequestDeviceCode performs the RFC 8628 device-authorization request. This is
// the headless/SSH login path (no browser, no loopback).
func RequestDeviceCode(ctx context.Context, client *http.Client, cfg Config, now func() time.Time) (DeviceAuth, error) {
endpoint := trimmed(cfg.DeviceAuthorizationEndpoint)
if endpoint == "" {
return DeviceAuth{}, errors.New("oauth: provider has no device authorization endpoint")
}
if err := validateTokenEndpoint(endpoint); err != nil {
return DeviceAuth{}, err
}
if client == nil {
client = http.DefaultClient
}
if now == nil {
now = time.Now
}
form := url.Values{}
form.Set("client_id", cfg.ClientID)
if secret := trimmed(cfg.ClientSecret); secret != "" {
// Confidential clients must authenticate on the device endpoint too,
// consistent with the token poll (pollDeviceOnce).
form.Set("client_secret", secret)
}
if len(cfg.Scopes) > 0 {
form.Set("scope", strings.Join(cfg.Scopes, " "))
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return DeviceAuth{}, err
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("Accept", "application/json")
response, err := client.Do(request)
if err != nil {
return DeviceAuth{}, fmt.Errorf("oauth: device authorization request failed: %w", err)
}
defer response.Body.Close()
body, _ := io.ReadAll(io.LimitReader(response.Body, tokenResponseLimit))
var parsed deviceAuthResponse
if len(body) > 0 {
_ = json.Unmarshal(body, &parsed)
}
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
if parsed.Error != "" {
return DeviceAuth{}, fmt.Errorf("oauth: device authorization error %q", parsed.Error)
}
return DeviceAuth{}, fmt.Errorf("oauth: device authorization returned HTTP %d", response.StatusCode)
}
if trimmed(parsed.DeviceCode) == "" || trimmed(parsed.UserCode) == "" {
return DeviceAuth{}, errors.New("oauth: device authorization response missing device_code/user_code")
}
interval := time.Duration(parsed.Interval) * time.Second
if interval <= 0 {
interval = 5 * time.Second // RFC 8628 default
}
auth := DeviceAuth{
DeviceCode: parsed.DeviceCode,
UserCode: parsed.UserCode,
VerificationURI: parsed.VerificationURI,
VerificationURIComplete: parsed.VerificationURIComplete,
Interval: interval,
}
lifetime := time.Duration(parsed.ExpiresIn) * time.Second
if lifetime <= 0 {
// Fail closed: a response without expires_in must still get a bounded
// lifetime so the poll loop's expiry gate stays active.
lifetime = defaultDeviceCodeLifetime
}
auth.ExpiresAt = now().Add(lifetime)
return auth, nil
}
// PollDeviceToken polls the token endpoint for the device grant until the user
// approves, the device code expires, ctx is done, or a terminal error occurs. It
// honors authorization_pending (keep waiting), slow_down (increase the
// interval), and expired_token.
func PollDeviceToken(ctx context.Context, client *http.Client, cfg Config, auth DeviceAuth, now func() time.Time) (Token, error) {
if now == nil {
now = time.Now
}
interval := auth.Interval
if interval <= 0 {
interval = 5 * time.Second
}
for {
if !auth.ExpiresAt.IsZero() && !auth.ExpiresAt.After(now()) {
return Token{}, errors.New("oauth: device code expired before authorization")
}
select {
case <-ctx.Done():
return Token{}, fmt.Errorf("oauth: device authorization canceled: %w", ctx.Err())
case <-time.After(interval):
}
// Re-check expiry after sleeping: slow_down (or a long interval) can push
// the next poll past ExpiresAt, and we must not poll after the deadline.
if !auth.ExpiresAt.IsZero() && !auth.ExpiresAt.After(now()) {
return Token{}, errors.New("oauth: device code expired before authorization")
}
token, err := pollDeviceOnce(ctx, client, cfg, auth.DeviceCode, now)
switch {
case err == nil:
return token, nil
case errors.Is(err, ErrAuthorizationPending):
// keep waiting at the current interval
case errors.Is(err, ErrSlowDown):
interval += 5 * time.Second // RFC 8628: add 5s on slow_down
default:
return Token{}, err
}
}
}
// pollDeviceOnce makes a single device-token poll, translating the RFC 8628
// error codes into ErrAuthorizationPending / ErrSlowDown / terminal errors.
func pollDeviceOnce(ctx context.Context, client *http.Client, cfg Config, deviceCode string, now func() time.Time) (Token, error) {
if err := validateTokenEndpoint(cfg.TokenEndpoint); err != nil {
return Token{}, err
}
if client == nil {
client = http.DefaultClient
}
form := url.Values{}
form.Set("grant_type", deviceGrantType)
form.Set("device_code", deviceCode)
form.Set("client_id", cfg.ClientID)
if secret := trimmed(cfg.ClientSecret); secret != "" {
form.Set("client_secret", secret)
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, trimmed(cfg.TokenEndpoint), strings.NewReader(form.Encode()))
if err != nil {
return Token{}, err
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("Accept", "application/json")
response, err := client.Do(request)
if err != nil {
return Token{}, fmt.Errorf("oauth: device token poll failed: %w", err)
}
defer response.Body.Close()
body, _ := io.ReadAll(io.LimitReader(response.Body, tokenResponseLimit))
var parsed tokenResponse
if len(body) > 0 {
_ = json.Unmarshal(body, &parsed)
}
if response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices && trimmed(parsed.AccessToken) != "" {
token := Token{Scopes: cfg.Scopes}
token.AccessToken = parsed.AccessToken
token.RefreshToken = parsed.RefreshToken
token.TokenType = parsed.TokenType
if parsed.ExpiresIn > 0 {
token.ExpiresAt = now().Add(time.Duration(parsed.ExpiresIn) * time.Second).UTC()
}
if scope := trimmed(parsed.Scope); scope != "" {
token.Scopes = strings.Fields(scope)
}
return token, nil
}
switch parsed.Error {
case "authorization_pending":
return Token{}, ErrAuthorizationPending
case "slow_down":
return Token{}, ErrSlowDown
case "expired_token":
return Token{}, errors.New("oauth: device code expired before authorization")
case "access_denied":
return Token{}, errors.New("oauth: authorization denied by the user")
case "":
return Token{}, fmt.Errorf("oauth: device token poll returned HTTP %d", response.StatusCode)
default:
return Token{}, fmt.Errorf("oauth: device token error %q", parsed.Error)
}
}