oauth

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 2, 2026 License: MIT Imports: 23 Imported by: 0

Documentation

Overview

Package oauth is zero's reusable OAuth 2.0 engine. It generalizes the authorization-code + PKCE flow that internal/mcp already uses for MCP servers into a transport/identity-agnostic core, and adds the pieces provider login needs: a device-authorization (RFC 8628) grant, a provider registry, a namespaced + refreshing token store, and a proactive refresh scheduler.

Security invariants (enforced throughout, never relaxed):

  • PKCE S256 is mandatory on every authorization-code flow; "plain" is refused.
  • A per-flow CSRF state is generated and verified on the callback.
  • The loopback callback server binds 127.0.0.1 only, on an OS-assigned port, serves a single request, then closes.
  • The token endpoint must be https (loopback exempt) before any credential is sent — see validateTokenEndpoint.
  • Tokens, codes, and verifiers are never logged; error bodies are redacted.
  • Token files are 0600, base-confined, and file-locked.

This package holds NO vendor secrets: provider client IDs and endpoints come from config/env by default, so no third-party OAuth client identity is used unless the operator opts in. A small set of built-in presets (e.g. xAI's public client) exists for convenience but is OFF by default and only consulted when ZERO_OAUTH_ALLOW_PRESETS is set; env always overrides a preset.

Index

Constants

View Source
const (

	// KeyPrefixProvider namespaces provider-login tokens; MCP server tokens live
	// under KeyPrefixMCP in the same format (so a future MCP migration is a key
	// rename, not a format change).
	KeyPrefixProvider = "provider:"
	KeyPrefixMCP      = "mcp:"
)
View Source
const (

	// MethodS256 is the only code-challenge method this engine accepts.
	MethodS256 = "S256"
)

Variables

View Source
var (
	// ErrPKCEDowngrade is returned if a flow is asked to use anything but S256.
	ErrPKCEDowngrade = errors.New("oauth: PKCE S256 is mandatory; plain is refused")
	// ErrStateMismatch is returned when the callback state does not match (CSRF).
	ErrStateMismatch = errors.New("oauth: callback state mismatch; possible CSRF, login aborted")
	// ErrInsecureTokenEndpoint is returned when a credential would be sent over a
	// non-https, non-loopback endpoint.
	ErrInsecureTokenEndpoint = errors.New("oauth: refusing to send credential to a non-https token endpoint")
	// ErrNoRefreshToken is returned when a refresh is attempted without one.
	ErrNoRefreshToken = errors.New("oauth: no refresh token available")
	// ErrAuthorizationPending is the RFC 8628 "keep polling" signal.
	ErrAuthorizationPending = errors.New("oauth: authorization pending")
	// ErrSlowDown is the RFC 8628 "increase the poll interval" signal.
	ErrSlowDown = errors.New("oauth: slow down")
)

Errors returned by the engine. Callers can match these with errors.Is.

View Source
var ErrNoToken = errors.New("oauth: no stored token")

ErrNoToken reports that no token is stored for a key. Callers (e.g. a request path that prefers OAuth but can fall back to an API key) match it with errors.Is to distinguish "not logged in" from a real refresh failure.

Functions

func BuildAuthorizationURL

func BuildAuthorizationURL(cfg Config, pkce PKCE, state, redirectURI string, extraParams map[string]string) (string, error)

BuildAuthorizationURL constructs the authorization request URL for an authorization-code + PKCE flow. PKCE S256 is always included; a non-S256 method is refused (ErrPKCEDowngrade). extraParams (and cfg.ExtraAuthParams) are appended last.

func FormatStatuses

func FormatStatuses(statuses []Status) string

FormatStatuses renders a human-readable status table without leaking token material.

func NewState

func NewState() (string, error)

NewState generates a high-entropy CSRF state value.

func ProviderKey

func ProviderKey(name string) string

ProviderKey builds the store key for a provider login.

func ResolveStorePath

func ResolveStorePath(env map[string]string) (string, error)

ResolveStorePath determines the on-disk location for provider OAuth tokens, honoring ZERO_OAUTH_TOKENS_PATH, then XDG_CONFIG_HOME, then the home dir.

func ValidateKey

func ValidateKey(key string) error

ValidateKey reports whether key is a well-formed namespaced token key.

func ValidateProviderName

func ValidateProviderName(name string) error

ValidateProviderName reports whether name is a safe provider identifier.

Types

type Config

type Config struct {
	ClientID     string
	ClientSecret string
	Scopes       []string

	AuthorizationEndpoint       string
	TokenEndpoint               string
	DeviceAuthorizationEndpoint string
	RegistrationEndpoint        string
	// IssuerURL is the base for metadata discovery when endpoints are not set.
	IssuerURL string

	// ExtraAuthParams are appended to the authorization URL (e.g. login_hint).
	ExtraAuthParams map[string]string
}

Config describes how to talk to one authorization server for a flow. Endpoints may be discovered (RFC 8414) and overridden here.

type DeviceAuth

type DeviceAuth struct {
	DeviceCode              string
	UserCode                string
	VerificationURI         string
	VerificationURIComplete string
	Interval                time.Duration
	ExpiresAt               time.Time
}

DeviceAuth is the result of an RFC 8628 device-authorization request.

func RequestDeviceCode

func RequestDeviceCode(ctx context.Context, client *http.Client, cfg Config, now func() time.Time) (DeviceAuth, error)

RequestDeviceCode performs the RFC 8628 device-authorization request. This is the headless/SSH login path (no browser, no loopback).

type Flow

type Flow string

Flow selects how a provider delivers the authorization result.

const (
	// FlowLoopback uses a 127.0.0.1 callback server (browser required).
	FlowLoopback Flow = "loopback"
	// FlowDevice uses the RFC 8628 device-code flow (headless/SSH).
	FlowDevice Flow = "device"
)

type KeyringClient

type KeyringClient interface {
	Get(service, account string) (string, bool, error)
	Set(service, account, secret string) error
	Delete(service, account string) (bool, error)
}

KeyringClient is the minimal OS-keyring surface the store needs. *keyring.Keyring satisfies it; tests inject a fake.

type LoginOptions

type LoginOptions struct {
	Provider    string
	Device      bool          // force device-code flow
	ExtraScopes []string      // appended to the provider's scopes
	Timeout     time.Duration // bounds the whole interactive login
}

LoginOptions configures a single provider login.

type LoopbackListener

type LoopbackListener struct {
	// contains filtered or unexported fields
}

LoopbackListener is a single-use loopback HTTP server that captures an OAuth redirect (?code=&state=) on 127.0.0.1 only: bind an OS-assigned loopback port, verify the CSRF state, hand back the code, then close.

func NewLoopbackListener

func NewLoopbackListener(state string) (*LoopbackListener, error)

NewLoopbackListener binds 127.0.0.1:0 (loopback only, OS-assigned port) and begins serving. state is the CSRF value the callback must echo back. Call RedirectURI to build the redirect_uri, then Wait for the code. Always Close.

func NewLoopbackListenerOnPort

func NewLoopbackListenerOnPort(state string, port int) (*LoopbackListener, error)

NewLoopbackListenerOnPort is like NewLoopbackListener but binds a specific port (0 = OS-assigned). Used by ChatGPT OAuth which requires a fixed redirect_uri of http://localhost:1455/auth/callback.

func (*LoopbackListener) Close

func (l *LoopbackListener) Close()

Close shuts the listener down (bounded), idempotent.

func (*LoopbackListener) RedirectURI

func (l *LoopbackListener) RedirectURI() string

RedirectURI returns the http://127.0.0.1:<port>/callback redirect URI.

func (*LoopbackListener) RedirectURIWithHost

func (l *LoopbackListener) RedirectURIWithHost(host, path string) string

RedirectURIWithHost returns a redirect URI using the given host (e.g. "localhost") and path (e.g. "/auth/callback"). The listener still binds 127.0.0.1, but the OAuth client may require "localhost" in the redirect_uri (OpenAI's ChatGPT client registration does). The listener accepts both /callback and the given path.

func (*LoopbackListener) Wait

func (l *LoopbackListener) Wait(ctx context.Context) (string, error)

Wait blocks until the callback arrives or ctx is done, returning the authorization code.

type Manager

type Manager struct {
	// contains filtered or unexported fields
}

Manager ties the token store, provider registry, and HTTP client together to run logins and serve fresh access tokens. It is the high-level entrypoint the CLI and request paths use.

func NewManager

func NewManager(opts ManagerOptions) (*Manager, error)

NewManager builds a Manager, filling defaults.

func (*Manager) CompleteDeviceLogin

func (m *Manager) CompleteDeviceLogin(ctx context.Context, provider string, cfg Config, auth DeviceAuth) (Status, error)

CompleteDeviceLogin polls for the token authorized via PrepareDeviceLogin and stores it under "provider:<name>", returning a redaction-safe status. Pass the cfg and auth returned by PrepareDeviceLogin.

func (*Manager) GetFresh

func (m *Manager) GetFresh(ctx context.Context, key string) (string, error)

GetFresh returns a valid access token for key, refreshing on-demand if the stored token is expired or within the refresh buffer. Mirrors checkAndRefreshOAuthTokenIfNeeded.

func (*Manager) Handle401

func (m *Manager) Handle401(ctx context.Context, key string) (string, error)

Handle401 forces a refresh after an upstream 401, returning the new access token. Mirrors handleOAuth401Error.

func (*Manager) Login

func (m *Manager) Login(ctx context.Context, opts LoginOptions) (Status, error)

Login runs the provider login (loopback by default, device-code when requested or when the provider only supports device), stores the token under "provider:<name>", and returns a redaction-safe status.

func (*Manager) Logout

func (m *Manager) Logout(name string) (bool, error)

Logout removes a provider's stored token, reporting whether one was present.

func (*Manager) PrepareDeviceLogin

func (m *Manager) PrepareDeviceLogin(ctx context.Context, opts LoginOptions) (DeviceAuth, Config, error)

PrepareDeviceLogin resolves the provider config and requests an RFC 8628 device code, returning the user-facing DeviceAuth (verification URI + user code) and the resolved config. It is split from the token poll (CompleteDeviceLogin) so a UI can display the code to the user while waiting for authorization. The CLI's monolithic Login(Device:true) is unaffected.

func (*Manager) StatusAll

func (m *Manager) StatusAll() ([]Status, error)

StatusAll returns the status of every provider login.

type ManagerOptions

type ManagerOptions struct {
	Store      *Store
	Registry   *Registry
	HTTPClient *http.Client
	Env        map[string]string
	// AllowPresets opts this manager into the baked-in OAuth presets without the
	// operator exporting ZERO_OAUTH_ALLOW_PRESETS — used by the interactive wizard
	// and CLI login (and the runtime token refresh) for a provider the user chose
	// to sign into, whose preset client identity is public (e.g. xAI). It layers the
	// flag onto Env (or the process environment when Env is nil), preserving any
	// ZERO_OAUTH_<NAME>_* overrides. Leave false for hermetic tests.
	AllowPresets  bool
	Now           func() time.Time
	RefreshBuffer time.Duration
	Out           io.Writer
	OpenBrowser   func(authURL string) error
}

ManagerOptions configures a Manager.

type PKCE

type PKCE struct {
	Verifier  string
	Challenge string
	Method    string
}

PKCE is a verifier/challenge pair for an authorization-code flow. The method is always S256 (the engine refuses "plain").

func NewPKCE

func NewPKCE() (PKCE, error)

NewPKCE generates a high-entropy code verifier and its S256 challenge. It matches internal/mcp's newPKCE byte-for-byte (RawURLEncoding of 32 random bytes; challenge = base64url(SHA-256(verifier))) so the two engines are interchangeable.

type RefreshScheduler

type RefreshScheduler struct {
	// contains filtered or unexported fields
}

RefreshScheduler proactively refreshes a stored provider token shortly before it expires. It is an OPTIMIZATION over the on-demand GetFresh path, never the source of truth: a failed scheduled refresh is non-fatal and simply retried at the next expiry. It is a no-op for a token with no refresh token or no expiry.

func NewRefreshScheduler

func NewRefreshScheduler() *RefreshScheduler

NewRefreshScheduler returns an idle scheduler.

func (*RefreshScheduler) Start

func (s *RefreshScheduler) Start(ctx context.Context, m *Manager, key string)

Start begins proactively refreshing key via the manager until the context is canceled or Stop is called. It returns immediately; refresh happens in a goroutine. Calling Start twice is a no-op after the first.

func (*RefreshScheduler) Stop

func (s *RefreshScheduler) Stop()

Stop cancels the scheduler and waits for its goroutine to exit. Safe to call more than once and on a never-started scheduler.

type Registry

type Registry struct{}

Registry resolves a provider's Config from env/config. By default every provider is defined entirely by the operator via ZERO_OAUTH_<NAME>_* variables, so no third-party OAuth client identity is used. A small set of built-in presets exists for convenience but stays inert unless the operator opts in with ZERO_OAUTH_ALLOW_PRESETS (see presetsAllowed); env always overrides a preset.

func NewRegistry

func NewRegistry() *Registry

NewRegistry returns the (stateless) env-driven registry.

func (*Registry) ResolveConfig

func (r *Registry) ResolveConfig(name string, env map[string]string) (Config, Flow, error)

ResolveConfig builds the oauth.Config and default Flow for a provider from its env/config:

ZERO_OAUTH_<NAME>_CLIENT_ID       (required)
ZERO_OAUTH_<NAME>_CLIENT_SECRET   (optional)
ZERO_OAUTH_<NAME>_AUTHORIZE_URL   (loopback flow; or discovered via issuer)
ZERO_OAUTH_<NAME>_TOKEN_URL       (or discovered via issuer)
ZERO_OAUTH_<NAME>_DEVICE_URL      (device flow; or discovered via issuer)
ZERO_OAUTH_<NAME>_ISSUER_URL      (RFC 8414 / OIDC discovery base)
ZERO_OAUTH_<NAME>_SCOPES          (space-separated)
ZERO_OAUTH_<NAME>_FLOW            ("loopback" [default] | "device")

Pinned credential-bearing endpoints must be https (loopback exempt).

type ServerMetadata

type ServerMetadata struct {
	Issuer                      string   `json:"issuer"`
	AuthorizationEndpoint       string   `json:"authorization_endpoint"`
	TokenEndpoint               string   `json:"token_endpoint"`
	DeviceAuthorizationEndpoint string   `json:"device_authorization_endpoint"`
	RegistrationEndpoint        string   `json:"registration_endpoint"`
	ScopesSupported             []string `json:"scopes_supported"`
}

ServerMetadata is the subset of an RFC 8414 authorization-server metadata document the engine consumes.

func DiscoverAuthorizationServer

func DiscoverAuthorizationServer(ctx context.Context, client *http.Client, baseURL string) (ServerMetadata, error)

DiscoverAuthorizationServer fetches the RFC 8414 metadata document at the well-known path under baseURL.

type Status

type Status struct {
	Key             string    `json:"key"`
	HasToken        bool      `json:"hasToken"`
	HasRefreshToken bool      `json:"hasRefreshToken"`
	TokenType       string    `json:"tokenType,omitempty"`
	Account         string    `json:"account,omitempty"`
	Scopes          []string  `json:"scopes,omitempty"`
	ExpiresAt       time.Time `json:"expiresAt,omitempty"`
	Expired         bool      `json:"expired"`
}

Status is a redaction-safe summary of a stored token (no secret material).

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store persists OAuth tokens (provider + MCP namespaces) as one JSON blob, written atomically through a pluggable backend (a 0600 file guarded by a cross-process lock, or the OS keyring). When crypter is non-nil the file blob is AES-256-GCM ciphertext at rest.

func NewStore

func NewStore(options StoreOptions) (*Store, error)

NewStore builds a token store with the configured backend (file by default, or the OS keyring when Storage/ZERO_OAUTH_STORAGE selects it).

func (*Store) Delete

func (s *Store) Delete(key string) (bool, error)

Delete removes the token for key, reporting whether one was present.

func (*Store) FilePath

func (s *Store) FilePath() string

FilePath returns the resolved token store location (a path for the file backend, or a "keyring:..." identifier for the keyring backend).

func (*Store) Load

func (s *Store) Load(key string) (Token, bool, error)

Load returns the token for key; the bool is false when none is stored.

func (*Store) Save

func (s *Store) Save(key string, token Token) error

Save persists a token under key, replacing any existing entry.

func (*Store) Status

func (s *Store) Status(prefix string) ([]Status, error)

Status returns redaction-safe summaries of every stored token, sorted by key. An optional prefix filters to one namespace (e.g. KeyPrefixProvider).

type StoreOptions

type StoreOptions struct {
	FilePath string
	Env      map[string]string
	Now      func() time.Time
	// Storage selects the backend: "" / "file" => a 0600 JSON file (default);
	// "encrypted-file" => an AES-256-GCM encrypted file; "keyring" => the OS
	// keyring. When empty it falls back to ZERO_OAUTH_STORAGE.
	Storage string
	// Encrypted is a legacy alias for Storage=="encrypted-file" (AES-256-GCM at
	// rest). Ignored when Storage is set.
	Encrypted bool
	// Keyring is the client used when Storage=="keyring"; nil => keyring.New().
	// Injected by tests to avoid touching a real keychain.
	Keyring KeyringClient
}

StoreOptions configures where provider OAuth tokens are persisted.

type Token

type Token struct {
	AccessToken  string    `json:"access_token"`
	RefreshToken string    `json:"refresh_token,omitempty"`
	TokenType    string    `json:"token_type,omitempty"`
	Scopes       []string  `json:"scopes,omitempty"`
	ExpiresAt    time.Time `json:"expires_at,omitempty"`
	// Account is an optional non-secret identifier (email / account id) shown in
	// status output; never a credential.
	Account string `json:"account,omitempty"`
	// IDToken is the OIDC ID token returned alongside the access token (when the
	// `openid` scope was requested). It is a JWS and may carry claims (such as
	// `chatgpt_account_id`) that the request path needs as headers. Treated as
	// sensitive like the access token: never logged, persisted 0600.
	IDToken string `json:"id_token,omitempty"`
}

Token holds the credentials issued by an authorization server. The token fields are sensitive: callers must never log them, and the store persists them 0600. It mirrors the on-disk shape internal/mcp uses so the two stay format-compatible.

func ExchangeCode

func ExchangeCode(ctx context.Context, client *http.Client, cfg Config, code, verifier, redirectURI string, now func() time.Time) (Token, error)

ExchangeCode swaps an authorization code + PKCE verifier for tokens.

func FirstStored

func FirstStored(store *Store, candidates []string) (Token, string, bool)

FirstStored returns the token and its ProviderKey for the FIRST candidate name that has a token in the store, with ok=false when none do. Callers pass ProviderProfile.OAuthLoginCandidates() so that everything derived from a login — the bearer token AND any header claim like chatgpt-account-id — comes from the SAME login; selecting independently per consumer could otherwise pair a bearer from one login with an account header from another. A load error on a candidate is treated as a miss (skip to the next), never a hard failure.

func PollDeviceToken

func PollDeviceToken(ctx context.Context, client *http.Client, cfg Config, auth DeviceAuth, now func() time.Time) (Token, error)

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 PostToken

func PostToken(ctx context.Context, client *http.Client, tokenEndpoint string, form url.Values, base Token, now func() time.Time) (Token, error)

PostToken performs a token-endpoint POST and maps the response onto a Token. The https guard is applied first. The base token supplies values to preserve (e.g. an existing refresh token or scopes) when the response omits them. Error messages carry only the server's error/error_description — never the raw body — so token material in an unexpected payload is not leaked.

func Refresh

func Refresh(ctx context.Context, client *http.Client, cfg Config, current Token, now func() time.Time) (Token, error)

Refresh exchanges a refresh token for a fresh access token. A response that omits a new refresh token preserves the current one.

func (Token) Expired

func (t Token) Expired(now time.Time) bool

Expired reports whether the token has an expiry that is at or before now. A zero ExpiresAt means "no known expiry" and is treated as not expired.

func (Token) NeedsRefresh

func (t Token) NeedsRefresh(now time.Time, buffer time.Duration) bool

NeedsRefresh reports whether the token is expired or falls within buffer of expiry (so a proactive/on-demand refresh should run). A token with no expiry never needs a refresh on a timer.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL