Documentation
¶
Overview ¶
Package tokenmanager orchestrates core-token storage and RFC 8693 token exchanges for an OAuth 2.0 device-flow client.
One Manager per CLI process. Construct it once from the embedding CLI's identity (Issuer, ClientID, STSPath, Store) and call TokenForResource / Token from data-API call sites.
The package is provider-agnostic: every endpoint, identifier, and default value comes from Config. It has no env-var reads, no implicit URLs, and no embedded provider tables. Tests inject behaviour via SetExchangeForTest / SetNowForTest / SetRefreshForTest / SetProcessLockForTest (see testseams.go) rather than via the public Config — keeping the STS call out of the caller-controllable surface.
Index ¶
- Constants
- Variables
- func SetExchangeForTest(t TestingTB, m *Manager, ...)
- func SetNowForTest(t TestingTB, m *Manager, now func() time.Time)
- func SetProcessLockForTest(t TestingTB, m *Manager, lock ProcessLock)
- func SetRefreshForTest(t TestingTB, m *Manager, ...)
- type Config
- type Manager
- func (m *Manager) DeleteCoreToken() error
- func (m *Manager) Issuer() string
- func (m *Manager) LookupCoreToken() (string, error)
- func (m *Manager) Refresh(ctx context.Context) (string, error)
- func (m *Manager) SaveCoreToken(t tokens.TokenSet) error
- func (m *Manager) Token(ctx context.Context, req TokenRequest) (string, error)
- func (m *Manager) TokenForResource(ctx context.Context, resourceBaseURL string) (string, error)
- type ProcessLock
- type TestingTB
- type TokenRequest
Constants ¶
const DefaultRequestedTokenType = "urn:ietf:params:oauth:token-type:access_token"
DefaultRequestedTokenType is the RFC 8693 §3 URI used when neither Config.RequestedTokenType nor TokenRequest.RequestedTokenType is set. :access_token is the canonical "give me an OAuth access token" URI; the wire format is the server's choice.
Variables ¶
var ErrNoRefreshPath = errors.New("refresh required but Config.RefreshPath is empty")
ErrNoRefreshPath is returned when a refresh is needed but Config.RefreshPath is empty. Mirrors ErrNoSTSPath.
var ErrNoSTSPath = errors.New("token exchange required but Config.STSPath is empty")
ErrNoSTSPath is returned when an exchange is needed but Config.STSPath is empty. Single-host deployments hit the same-host shortcut and never reach this; split-host deployments must configure STSPath.
var ErrNotLoggedIn = errors.New("not logged in")
ErrNotLoggedIn is returned by Token / TokenForResource when no core token is present in the store, or when a stored token is expired and carries no refresh token (the session cannot be silently renewed). Callers can match on it to render a "run <login>" message.
var ErrReauthRequired = errors.New("reauthentication required")
ErrReauthRequired is returned by Token / Refresh when the stored refresh token is genuinely revoked or expired (server returned invalid_grant and a store re-read confirmed the refresh token was not concurrently rotated). Distinct from ErrNotLoggedIn — there was a credential, the session simply needs a fresh interactive login. Callers can match on it to render "your session expired, log in again".
Functions ¶
func SetExchangeForTest ¶ added in v0.3.1
func SetExchangeForTest(t TestingTB, m *Manager, fn func(context.Context, sts.ExchangeRequest) (*tokens.TokenSet, error))
SetExchangeForTest replaces the STS-exchange dispatch on m with fn for the lifetime of the test. The previous override (if any) is restored when t.Cleanup runs. Stores go through atomic.Pointer so they don't race the unsynchronised hot-path reads in runExchange.
This is the test seam previously exposed as Config.Exchange. It was moved off the public Config so production callers can't bypass the STS call by setting a struct field — a fn that returns attacker-controlled tokens defeats every server-side validation the library otherwise relies on.
func SetNowForTest ¶ added in v0.3.1
SetNowForTest replaces the manager's clock with now for the lifetime of the test. The previous override (if any) is restored when t.Cleanup runs. Stores go through atomic.Pointer so they don't race the unsynchronised hot-path reads in m.now().
This is the test seam previously exposed as Config.Now. It was moved off the public Config alongside Exchange so the two have a single idiom for test injection.
func SetProcessLockForTest ¶ added in v0.4.0
func SetProcessLockForTest(t TestingTB, m *Manager, lock ProcessLock)
SetProcessLockForTest replaces the cross-process lock on m with lock for the lifetime of the test, restoring the previous override on t.Cleanup. It lives off Config (as a test seam) so production callers can't bypass the real file lock by setting a struct field — same rationale as SetExchangeForTest.
func SetRefreshForTest ¶ added in v0.4.0
func SetRefreshForTest(t TestingTB, m *Manager, fn func(context.Context, refresh.Request) (*tokens.TokenSet, error))
SetRefreshForTest replaces the refresh_token-grant dispatch on m with fn for the lifetime of the test, restoring the previous override on t.Cleanup. Held behind atomic.Pointer so it doesn't race runRefresh's hot-path read. Mirrors SetExchangeForTest — production callers can't set it, so a fake can't bypass the real grant.
Types ¶
type Config ¶
type Config struct {
// Issuer is the auth host base URL where the device-flow login
// happened and STS exchanges are POSTed. Required. Doubles as the
// Store profile key, so a user can be logged into multiple issuers
// (e.g. regions / staging) without conflict.
Issuer string
// ClientID identifies the public client per RFC 6749 §2.3.1 / §3.2.1.
// Sent on STS exchanges via the client_id form field. Required.
ClientID string
// STSPath is the path on Issuer where token-exchange requests are
// POSTed. Optional: single-host deployments never trigger an
// exchange (the same-host shortcut wins) so they can leave it
// empty. When empty and an exchange is attempted, runExchange
// returns ErrNoSTSPath rather than POSTing to a bogus URL.
STSPath string
// RefreshPath is the token-endpoint path where grant_type=refresh_token
// is POSTed to re-mint the login JWT. Optional: when empty and a
// refresh is needed, runRefresh returns ErrNoRefreshPath. Often equal
// to STSPath or the device-flow token path, since servers typically
// multiplex grants at one /oauth/token.
RefreshPath string
// LockDir is the directory holding the cross-process advisory lock
// file. Empty → os.UserCacheDir()/auth-go (falling back to the system
// temp dir if the user cache dir is unavailable). The lock file holds
// no credentials.
LockDir string
// Store persists the core token. Required. Use any tokenstore.Store
// implementation; a per-CLI service name keeps credentials isolated
// from other CLIs sharing this library.
Store tokenstore.Store
// RequestedTokenType is the default RFC 8693 requested_token_type
// URI. Empty → DefaultRequestedTokenType.
RequestedTokenType string
// SubjectTokenType is the RFC 8693 subject_token_type sent on
// exchanges. Empty → sts.SubjectTokenTypeAccessToken.
//
// :access_token is the RFC 8693 §3 URI for "OAuth 2.0 access token
// issued by the given authorization server" — exactly what the
// device-code grant returns into Store. The distinction from :jwt
// matters at the server: zitadel-oidc's STS validator (pkg/op/
// token_exchange.go's GetTokenIDAndSubjectFromToken) only switches on
// :access_token / :refresh_token / :id_token; :jwt passes the
// IsSupported() check upstream but silently falls through to the
// not-handled branch and surfaces as the (uninformative)
// "subject_token is invalid" error_description. Other servers
// generally treat :jwt and :access_token interchangeably for OAuth
// access tokens, so :access_token is the safer default. A caller who
// genuinely needs :jwt semantics (RFC 7519 JWT-as-credential rather
// than OAuth-issued bearer) can set this field explicitly, or bypass
// tokenmanager and call sts.Client.Exchange directly.
SubjectTokenType string
// Scope is the default scope sent on exchanges. Empty → omitted.
Scope string
// UserAgent for HTTP requests. Empty → none.
UserAgent string
// AllowInsecureHTTP permits exchanges against http:// issuers. Off
// by default — STS calls ship the user's core token in the request
// body and must be TLS-protected. Only flip this for local/dev
// deployments pinned to loopback.
AllowInsecureHTTP bool
// Transport overrides the http.RoundTripper used for STS calls.
// Useful for installing a debug logger or proxy. nil →
// http.DefaultTransport. Replaces the previous HTTPClient field —
// see sts.Client.Transport for the security rationale.
Transport http.RoundTripper
}
Config configures a Manager.
type Manager ¶
type Manager struct {
// contains filtered or unexported fields
}
Manager orchestrates core-token storage and STS exchanges. Safe for concurrent use.
func New ¶
New builds a Manager from cfg. Returns an error when required fields are missing or Issuer is not an absolute URL.
Issuer is normalized via RFC 3986 §6.2.2 (lowercase scheme/host, default-port stripped, no trailing slash) before being used as the Store profile key and as the same-host shortcut comparison. Without this, two Managers configured with cosmetically-different issuers (`https://auth.example.com/` vs `https://auth.example.com`) would write to different keyring entries but compare equal for the shortcut — one Manager handing out the other's tokens.
func (*Manager) DeleteCoreToken ¶
DeleteCoreToken removes the stored core token and any cached exchanges derived from it.
Order matters within the locked region: the keyring delete runs first, then the in-memory cache is cleared. If the keyring delete fails the cache is left alone — clearing it pre-emptively would create a window where the CLI thinks it's logged out (no cache entries) but the keyring still hands out the core token to the next process.
DeleteCoreToken is serialised against in-flight refreshes by acquiring refreshMu and the cross-process lock before mutating the store. Without this, a refresh whose grant is mid-flight could land its persist after a concurrent logout and resurrect the deleted session. Lock ordering matches refreshLocked: refreshMu first, then processLock. Can block up to ~30s under contention and may return a wrapped lock error.
func (*Manager) LookupCoreToken ¶
LookupCoreToken returns the stored core token, or "" if none is stored. A nil-return-no-error mirrors how callers expect "not-logged-in" to look.
func (*Manager) Refresh ¶ added in v0.4.0
Refresh ensures a fresh login JWT, re-minting it from the stored refresh token when the current one is expired or near expiry, and returns it. It is idempotent when the token is already fresh (a cheap store read, no grant). Returns ErrNotLoggedIn when no credential is stored (or an expired one carries no refresh token), and ErrReauthRequired when the refresh token is revoked/expired.
Callers can use this to warm the session at startup and surface a re-login prompt before the first data call rather than mid-request.
Expiry is judged from the login JWT's exp claim. Opaque (non-JWT) stored tokens have no client-visible expiry, so they are treated as live and never trigger a re-mint — the refresh tier assumes a JWT login token (as in the three-tier session model).
func (*Manager) SaveCoreToken ¶
SaveCoreToken persists the full device-flow token bundle under the configured Issuer. Takes the entire tokens.TokenSet (rather than just the access token) so RefreshToken, absolute ExpiresAt, and Scope survive the round-trip through the keyring — earlier versions dropped these fields silently, blocking refresh-token support and losing the wire-side expiry hint for opaque tokens.
AccessToken is required (rejected here rather than letting it surface as a confusing "Bearer <empty>" later). The tokens.TokenSet is otherwise stored verbatim; consumers can read the persisted fields back via Store.LoadTokens.
On successful save the in-memory exchange cache is cleared so a re-login under a different identity can't return the previous user's exchanged tokens. The cacheKey already binds entries to the core token's SHA-256 hash, so this is defence-in-depth — see TestSaveCoreToken_ClearsExchangeCache.
SaveCoreToken is serialised against in-flight refreshes by acquiring refreshMu and the cross-process lock before mutating the store. Without this, a refresh whose grant is mid-flight could land its persist after a concurrent SaveCoreToken (re-login) and overwrite the new identity with the old account's refreshed credentials. Lock ordering matches refreshLocked: refreshMu first, then processLock. Can block up to ~30s under contention and may return a wrapped lock error. The empty-AccessToken check fires before lock acquisition so an obviously bad call doesn't touch the filesystem.
func (*Manager) Token ¶
Token resolves a bearer token for use against req.Resource, performing an RFC 8693 exchange when needed.
Resolution rules:
- No core token in the store → ErrNotLoggedIn.
- Core (login JWT) expired or near expiry → transparently re-mint it from the stored refresh token (see Refresh). No refresh token → ErrNotLoggedIn; refresh token revoked/expired → ErrReauthRequired; Config.RefreshPath unset → ErrNoRefreshPath.
- m.Issuer() == req.Resource (and req.Audience is empty) → use the core token directly. Single-host deployments hit this path.
- Core token's `aud` claim already includes req.Resource → use the core token directly. Multi-audience tokens skip exchange.
- Otherwise → RFC 8693 token exchange.
Successful exchanges are cached in-memory keyed by (core token, resource, audience, requested-token-type, scope) until expiry.
type ProcessLock ¶ added in v0.4.0
ProcessLock serialises credential mutations (refresh, save, delete) across processes. Acquire blocks until the lock is held or ctx is done, returning an idempotent release func. On error the returned release func is nil and must not be called. The default implementation is a file lock (internal/proclock); SetProcessLockForTest swaps a fake.
type TestingTB ¶ added in v0.3.1
type TestingTB interface {
Helper()
Cleanup(func())
}
TestingTB is the subset of testing.TB used by the test-seam setters. It's a minimal interface so tests can use *testing.T directly without the seam helpers having to import "testing" in production builds.
Production code should never construct one of these by hand. The presence of the Cleanup method is the signal: misusing the seams requires manufacturing a fake t.Cleanup, which is awkward enough to trip a reviewer.
type TokenRequest ¶
type TokenRequest struct {
// Resource is the origin where the bearer will be presented.
// Required. Used for the same-host shortcut, the JWT-aud shortcut,
// and as part of the cache key.
Resource string
// Audience is the wire-level RFC 8693 audience parameter. Empty →
// omitted (the AS picks). Independent of Resource — most callers
// leave Audience empty.
Audience string
// RequestedTokenType overrides Config.RequestedTokenType for this
// call. Empty → Config default.
RequestedTokenType string
// Scope overrides Config.Scope for this call. Empty → Config default.
Scope string
}
TokenRequest customises one Token call. Empty fields fall back to Config defaults.