tokenexchange

package
v1.18.1 Latest Latest
Warning

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

Go to latest
Published: Jul 24, 2026 License: Apache-2.0 Imports: 21 Imported by: 0

Documentation

Overview

Package tokenexchange ships Harbor's pull-based, non-interactive external-credential acquisition strategy: a driver on the OAuth flow-strategy registry that obtains a downstream tool credential (a user's Microsoft 365 token, a Google Workspace token — foreign-IdP tokens used to call third-party tools) from an operator-configured external credential broker (a fleet orchestrator, an enterprise token vault, an STS) via an RFC-8693-shaped token exchange, instead of Harbor's interactive authorization-code flow.

Why a pull, not a push

A fleet orchestrator holding each user's downstream credentials in ONE central place wants to provide them to whichever runtime's tool call needs them, instead of every runtime independently acquiring and sealing its own copy (N consents, N encrypted copies). Harbor answers with a PULL: the runtime, at token-miss time, presents its own env-indirected broker credential plus the VERIFIED ctx identity triple and receives a short-lived, audience-bound downstream token. A push (a per-run credential arriving in-band over the Protocol) is rejected as credential passthrough — the runtime cannot verify its provenance / audience / subject binding, and it rides channels the codebase is engineered to keep secrets out of.

Never persisted

Brokered tokens are TTL-cached in memory only, single-flight per (scope, tenant, user, source). The shared TokenStore's Put is NEVER called: the broker stays the single source of truth, so revocation at the broker is not defeated by a live sealed copy in N runtimes (the shadow-source-of-truth smell read southbound).

Cache TTL semantics: a token is served from cache until the EARLIER of the broker-advertised expiry and the operator cache-TTL cap (extra.cache_ttl_cap, default 5m) — a token is never served past the broker's own expiry. Separately, RE-EXCHANGE is rate-floored at 30s per key: within 30s of the previous exchange a re-exchange fails loudly (a typed exchange error naming the floor) rather than round-tripping the broker; once the window elapses the next Token() call re-exchanges normally. The floor only ever bites when the broker advertises an expiry shorter than 30s (a broker misconfiguration) — the net effect there is at most one exchange per 30s window with loud failures in between, never one exchange + one audit event per tool call.

Cache granularity: the subject_token presented to the broker carries the full verified (tenant, user, session) triple, but the cache and single-flight key deliberately scope by (binding scope, tenant, user, source) — the user-bound granularity. Two sessions of the same (tenant, user) share the cached credential by design; a broker that scopes grants per-session should advertise a short expiry (the cache never outlives it).

Fail loud, one mode per source

Broker unreachable / 5xx / a non-consent OAuth error → a wrapped ErrExchangeFailed to the run. NEVER a silent fallback to the interactive flow — that would silently void the central-custody policy (N consent prompts reappear). A source is EITHER interactive (`oauth2`) or brokered (`tokenexchange`), declared in config — no dual path. The interactive-flow methods (InitiateFlow / CompleteFlow / DenyFlow) return the typed auth.ErrNonInteractive sentinel.

One pause path

A broker `consent_required`-class refusal surfaces the SAME typed *auth.ErrAuthRequired the interactive flow uses (AuthorizeURL = the broker-supplied consent URL when present), so the run parks on the unified pause/resume primitive. After the user consents centrally, a resume re-drives Token() against the now-granting broker. A bare resume against a still-declining broker simply re-parks.

Trust model, named honestly

V1 is RFC 8693 impersonation semantics: the broker trusts the runtime's client credential to assert the subject triple (subject_token_type = urn:harbor:oauth:token-type:identity-triple, a Harbor-defined URN). The northbound JWT is deliberately NOT forwarded as subject_token — durable runs outlive the initiating request's JWT, so request-token forwarding breaks exactly the durable-run cases Harbor exists for. A signed runtime-side subject assertion is the named post-V1 upgrade path. Every actual exchange emits the canonical tool.credential_exchanged event (zero token bytes).

Credential source

The runtime's OWN broker client credential (`client_id` / `client_secret`) is resolved through the §4.4 credential-source seam (`internal/tools/auth/credsource`), NOT captured at construction. With the default `env` source the credential is resolved once at boot (zero behavior change); with the `remote` source it is pulled from a coordinator endpoint LAZILY at the first exchange — TTL-cached in memory, single-flight, fail-loud. A resolution failure fails the exchange with the typed credsource.ErrCredentialSourceUnavailable; the driver NEVER falls back to env, to an unauthenticated call, or to the interactive flow (§13). A resolved credential with an empty component fails the exchange loud.

Fail-loud at construction

Operator-facing seams demand explicit configuration. The driver fails closed on: a nil CredentialSource (BuildProviders always threads one), empty TokenURL (the broker endpoint — auth_url / redirect_url are interactive-flow fields and are NOT required here), a malformed extra.cache_ttl_cap, and missing deps (Store / Bus / Redactor / Coordinator).

Index

Constants

View Source
const DriverName = "tokenexchange"

DriverName is the canonical name the driver registers under. The `internal/config` validator's `allowedOAuthDrivers` allowlist mirrors this constant.

Variables

View Source
var (
	// ErrMissingCredentialSource — cfg.CredentialSource was nil at
	// construction. BuildProviders always threads one (env or remote);
	// a nil source is a direct-construction bug.
	ErrMissingCredentialSource = errors.New("auth/tokenexchange: CredentialSource is nil (BuildProviders threads the env/remote source; direct callers pass credsource.Static)")
	// ErrMissingClientID — the resolved credential carried an empty
	// client_id. Surfaced at exchange time (the credential resolves
	// through the source seam — at boot for env, lazily for remote).
	ErrMissingClientID = errors.New("auth/tokenexchange: resolved client_id is empty")
	// ErrMissingClientSecret — the resolved credential carried an empty
	// client_secret.
	ErrMissingClientSecret = errors.New("auth/tokenexchange: resolved client_secret is empty")
	// ErrMissingTokenURL — cfg.TokenURL was empty. The broker's
	// RFC-8693 token-exchange endpoint is mandatory; auth_url /
	// redirect_url are interactive-flow fields and are NOT used.
	ErrMissingTokenURL = errors.New("auth/tokenexchange: token_url must not be empty (the credential broker's RFC-8693 token-exchange endpoint)")
	// ErrBadCacheTTLCap — extra.cache_ttl_cap did not parse as a Go
	// duration (e.g. "5m", "90s").
	ErrBadCacheTTLCap = errors.New("auth/tokenexchange: extra.cache_ttl_cap must be a Go duration (e.g. \"5m\", \"90s\")")
	// ErrMissingDeps — a mandatory FactoryDep (Store / Bus / Redactor
	// / Coordinator) was nil.
	ErrMissingDeps = errors.New("auth/tokenexchange: Store / Bus / Redactor / Coordinator are mandatory")
	// ErrAudienceMismatch — a JWT-shaped exchanged access token's `aud` claim
	// excluded the boot-declared RFC 8707 resource indicator. The exchange
	// fails loud and the token is NEVER cached (a confused-deputy defence). It
	// is an `aud`-claim comparison only, never signature verification — Harbor
	// has no keying relationship with the broker's AS.
	ErrAudienceMismatch = errors.New("auth/tokenexchange: exchanged token audience excludes the declared resource_indicator")
)

Sentinel errors specific to the tokenexchange driver. Broker / upstream failures reuse the parent package's sentinels (auth.ErrExchangeFailed, auth.ErrAuthRequired, auth.ErrIdentityRequired).

View Source
var ErrPrivateDialRefused = errors.New("auth/tokenexchange: dial to a private/link-local address refused (the token POST carries client_id/client_secret)")

ErrPrivateDialRefused is the typed sentinel the hardened client refuses a private-range / link-local dial destination with (post-DNS) — the DNS-rebinding backstop. Wrapped under auth.ErrExchangeFailed at the call site. Loopback is deliberately NOT refused (see hardenTokenExchangeClient).

View Source
var ErrTokenEndpointRedirect = errors.New("auth/tokenexchange: token endpoint attempted a redirect (the token POST carries client_id/client_secret; a redirecting endpoint is a fault, not a hop)")

ErrTokenEndpointRedirect is the typed sentinel the hardened token-exchange client refuses a redirect with. The token-exchange POST carries the org's client_id / client_secret; Go replays the request body on a 307/308, so a redirecting token endpoint is a credential-exfil fault, never a hop to follow. Wrapped under auth.ErrExchangeFailed so callers observe the exchange as failed.

Functions

func New

New constructs the `tokenexchange` driver's OAuthProvider for one operator-config entry. Registered as the driver's auth.Factory; the dev stack never calls it directly — auth.Resolve dispatches by name.

func RegisterAllowPrivateExchangeCaptured added in v1.17.3

func RegisterAllowPrivateExchangeCaptured(v bool)

RegisterAllowPrivateExchangeCaptured records that the runtime booted with the dev-only private-IP token-exchange escape hatch. It is called exactly once at boot from the SAME call site that prints the `[DEV-ONLY PRIVATE-IP TOKEN EXCHANGE — DO NOT USE IN PRODUCTION]` stderr banner, so the capture and the banner stay structurally reciprocal — a future re-route of the dev-hatch path cannot flip the dial relaxation without also emitting the banner. Calling it with `true` relaxes the private / link-local / ULA branch of the dial guard for providers that do not already opt in via their own config; `false` (or never calling it — the zero value) leaves the guard armed. It is dev-only, fail-closed, and boot-only; it is never Protocol-writable.

Types

This section is empty.

Jump to

Keyboard shortcuts

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