Documentation
¶
Overview ¶
Package oidc verifies OAuth 2.0 access tokens locally against a JWKS discovered from an OpenID Connect issuer.
It lives in the chassis because it is pure OAuth plumbing: it knows nothing about findings, organisations or compliance, and the second resource server needs the identical thing (core-api-surface §1.6, §21.5). It passes the §21.5 test, which is that it could be open-sourced without mentioning what this product does.
Two properties drive the whole design, and both come from §1.4:
- Verification is local and in-process. Introspection (RFC 7662) would put the authorization server in the hot path of every request and make it a single point of failure for every page render. The trade is revocation latency, which ten-minute access tokens bound and the jti deny-list closes.
- Nothing about the issuer is hard-coded. The JWKS endpoint comes from the discovery document, so a self-hoster points at their own Keycloak, Authentik, Dex or Entra rather than running a Zitadel they did not want (§18.2).
Index ¶
Constants ¶
const DefaultRefetchCooldown = time.Minute
DefaultRefetchCooldown bounds how often an unknown key id may reach the network.
One minute is chosen against the two failure modes either side of it. Too short and an unknown kid becomes a request amplifier: every caller holding a stale or forged token drives a fetch, and the authorization server absorbs our traffic. Too long and a genuine key rotation is a multi-minute outage, because tokens signed with the new key cannot verify until the cooldown lapses. Access tokens live ten minutes (§1.2), so a minute is well inside the window a rotation has to complete in.
const DiscoveryPath = "/.well-known/openid-configuration"
DiscoveryPath is fixed by RFC 8414 and OIDC Discovery. It is the one path in this package that is allowed to be a constant, because it is the path that lets every other one be discovered.
const ScopeOpenID = "openid"
ScopeOpenID is the one scope in the vocabulary that is not a permission.
Variables ¶
var ( ErrTokenInvalid = errors.New("oidc: token invalid") ErrTokenExpired = errors.New("oidc: token expired") ErrAudienceMismatch = errors.New("oidc: token audience mismatch") ErrIssuerMismatch = errors.New("oidc: token issuer mismatch") )
The failure modes a resource server has to tell apart. All four deny the request, so they are not a policy decision; they exist so a log line says which check bit, and so the test battery can assert it was the intended one rather than any refusal at all. A test that only asserts "denied" passes when the token is rejected for the wrong reason, which is how a broken audience check hides behind a working expiry check.
var ErrKeyNotFound = errors.New("oidc: no signing key for kid")
ErrKeyNotFound is returned when a token names a key id the authorization server does not serve, after a refetch has been given its chance.
var ErrSubjectMismatch = errors.New("oidc: userinfo describes a different subject")
ErrSubjectMismatch means a userinfo document described somebody other than the holder of the token used to fetch it.
var SigningAlgorithms = []string{jwt.SigningMethodRS256.Alg(), jwt.SigningMethodES256.Alg()}
SigningAlgorithms is the allow-list, and it is the single most important line in this package.
Both entries are asymmetric. That is what makes the two classic algorithm-confusion attacks impossible rather than merely unlikely:
- `alg: none`, a token with a valid-looking header and an empty signature.
- `alg: HS256` signed with the authorization server's *public* key as the HMAC secret, which verifies if the library is allowed to pick the algorithm from the token and the key material is symmetric-capable.
The rule generalises: the verifier decides the algorithm, never the token.
Functions ¶
This section is empty.
Types ¶
type Bearer ¶
type Bearer struct {
// Source mints the token. Required.
Source *ClientCredentials
// Base is the wrapped round tripper. Nil uses http.DefaultTransport.
Base http.RoundTripper
}
Bearer wraps a round tripper so every request carries a freshly minted token.
A round tripper rather than a Connect interceptor, so the same token source serves a Connect client, a plain HTTP call and anything else a service needs to reach. Connect clients take an http.Client, so this composes with them without either side knowing about the other.
type Claims ¶
type Claims struct {
// Issuer is the authorization server that minted this token, already
// verified to be the configured one. Carried because it is half of the
// identity: the subject is only unique within an issuer, so anything
// deriving a stable user id needs both (see libs/chassis/subject).
Issuer string
Subject string
Email string
EmailVerified bool
// Name is the OIDC `name` claim, used only as a human-readable label.
// Never for identity, and never for authorization: it is self-asserted at
// many providers and changes freely.
Name string
Scopes []string
// ClientID is the OAuth client the token was minted for, from `client_id`.
//
// It names the CLIENT, never the user, and that distinction is the whole of
// its usefulness: it answers "what kind of caller is this" where Subject
// answers "who".
//
// `client_id` and not `azp`, which is measured rather than chosen. On this
// stack Zitadel emits `client_id` on both authorization-code and
// client-credentials tokens and emits no `azp` at all, so a reader written
// against azp would match nothing and look like it worked (ENT-221).
//
// Empty is normal: a provider may omit it. Anything deciding authority from
// this must treat empty as "unknown client" and grant nothing, never as a
// match against an unset configuration value.
ClientID string
// TokenID is the `jti`, and it is what the Redis deny-list keys on to
// close the revocation window local verification opens (§1.4, §15.1).
TokenID string
ExpiresAt time.Time
}
Claims is the verified identity a handler is allowed to trust.
Standard OIDC claims only. Nothing here knows what an organisation is: the active organisation travels in a request header and is resolved against the database, deliberately not carried in the token, so switching organisation needs no re-minting and there is exactly one source of truth for membership (§20.1).
type ClientCredentials ¶
type ClientCredentials struct {
// contains filtered or unexported fields
}
ClientCredentials mints and caches a service's own access token.
WHY A TOKEN SOURCE RATHER THAN A CONFIGURED TOKEN ¶
Access tokens live minutes. A static token in a deployment's environment is a service that works until the first expiry and then reports that the far side refused it, which gets diagnosed as a network problem two or three times before somebody checks an `exp` claim. So a caller holds credentials and mints, which is the same grant every other machine principal in this system uses.
It is in the chassis rather than in a service because it carries no business type: an endpoint, a credential, an audience, and a cache.
func NewClientCredentials ¶
func NewClientCredentials(cfg ClientCredentialsConfig) (*ClientCredentials, error)
NewClientCredentials builds a token source. It contacts nothing until the first Token call, so a process can construct one during startup without depending on the authorization server being up yet.
type ClientCredentialsConfig ¶
type ClientCredentialsConfig struct {
// Endpoint is the token endpoint, already rebased onto an address this
// process can reach. Provider.TokenEndpoint is that value.
Endpoint string
// ClientID identifies the client.
//
// On Zitadel a service user's client id is its USERNAME rather than its
// id. That is not guessable, it is in no specification, and it has cost an
// afternoon before, so it is written here as well as in the Postman
// collection: a reader debugging a refused token will be in one of the two
// places.
ClientID string
// Secret is the client secret.
Secret string
// Audience is the resource the token is for, requested through whichever
// scopes the provider defines for it.
//
// On Zitadel that is the PROJECT ID, requested through the reserved
// `urn:zitadel:iam:org:project:id:<project>:aud` scope, and the granted
// roles only reach the token when `urn:zitadel:iam:org:projects:roles` is
// requested too. The plural in the second is not a typo. Without it the
// caller authenticates perfectly and holds no authority at all, which
// presents as a permission error rather than an authentication one and
// sends you reading grants that are already correct.
//
// Callers pass the full scope list through Scopes; this field is kept
// separate only so an error message can name what the token was for.
Audience string
// Scopes is the scope list sent with the request, verbatim.
Scopes []string
// Transport bounds the request and carries the Host override that the
// split-horizon deployments need. Nil is fine.
Transport *Transport
}
ClientCredentialsConfig is what minting a token needs.
type KeySet ¶
type KeySet struct {
// contains filtered or unexported fields
}
KeySet is an in-process cache of an authorization server's public signing keys.
In-process rather than in Redis, deliberately: it is a few kilobytes that can always be rebuilt from the network, and putting it in Redis would add a hop and a failure mode to something that has neither (§15.2).
The whole subtlety of this type is when it goes back to the network, and it is worth stating plainly because getting it wrong produces an outage that looks like a signature bug.
A freshly seeded Zitadel serves `{"keys": []}`. It generates its signing key lazily, on the first token it issues, so an empty set at boot is correct rather than broken. A cache populated once at boot would therefore hold nothing for the entire life of the process and reject every token that followed, reporting each one as an unknown key rather than as an empty cache. Hence the rule this type exists to enforce: **the boot fetch must never be the last fetch.** Warm is explicitly not counted as a refetch, so the first token to arrive still gets one.
Found on the real stack while building the Postman collection against a clean checkout, and recorded at §1.4.
func NewKeySet ¶
NewKeySet returns a cache for the JWKS served at uri. It performs no I/O; call Warm to populate it at boot, or let the first token drive the fetch.
func (*KeySet) KeyFor ¶
KeyFor returns the public key for a key id, refetching once if the id is unknown.
An empty kid is accepted only when the server serves exactly one key, which keeps this usable against an IdP that omits the header on a single-key set without ever weakening the check to "trust whichever key happens to work" (§18.2 asks for portability, not for leniency about signatures).
func (*KeySet) SetRefetchCooldown ¶
SetRefetchCooldown overrides DefaultRefetchCooldown.
func (*KeySet) Warm ¶
Warm fetches the key set once at boot.
A failure here is worth logging and not worth crashing on. `auth` and `core-api` start together, so losing the race is ordinary, and the first token to arrive will fetch anyway. Crashing on it would turn a startup ordering detail into a restart loop.
Warm does not count as a refetch. See the type comment for why that single line is the difference between a working stack and one that rejects every token it is ever shown.
type Profile ¶
Profile is the human-readable half of an identity: the claims a resource server may want for a label, and must never use for authorization.
Deliberately separate from Claims. Claims is what a signature proves, and it is the only thing a policy decision is allowed to read. This is what an endpoint said over a connection, which is a weaker thing, and keeping the two types apart is what stops the weaker one drifting into an access check.
func FetchUserInfo ¶
func FetchUserInfo( ctx context.Context, transport *Transport, endpoint, accessToken, expectedSubject string, ) (*Profile, error)
FetchUserInfo asks the authorization server who the bearer of this token is.
This is a network call to the authorization server, so it belongs nowhere near a per-request path: the whole point of local verification (§1.4) is that a page render does not depend on `auth` being up. Call it when the answer is needed for a decision that happens once, such as naming something after the person on first arrival, and treat failure as "no profile" rather than as a failed request.
expectedSubject is required and is compared against the document's `sub`. OIDC Core §5.3.2 mandates the comparison, and skipping it is not a small omission: this function is given an endpoint from a discovery document, and without the check any response that endpoint can be made to return is accepted as the caller's own identity.
type Provider ¶
type Provider struct {
Issuer string
JWKSURI string
// UserInfoURI is the OIDC Core §5.3 endpoint, and it is empty when the
// document declares none.
//
// It is here rather than in the "anything more would be a Zitadel-shaped
// assumption" category above because it is the opposite of one: an access
// token is not obliged to carry `name` or `email`, several providers do not
// carry them, and this is the standard place to ask. A caller that needs a
// human-readable identity has exactly two conformant options, this or an id
// token it was never given, so leaving it undiscovered would push every
// caller into provider-specific guesswork.
UserInfoURI string
// TokenEndpoint is RFC 8414's `token_endpoint`, rebased onto the address
// the document was fetched from, and empty when the document declares none.
//
// Discovered for the caller half of this package rather than the verifier
// half: a service that must call another service mints its own token, and
// the endpoint it mints against is the one it already discovered rather
// than a second setting somebody keeps in step by hand.
TokenEndpoint string
}
Provider is the subset of the discovery document this system needs.
Deliberately small: an issuer and a JWKS URI is the whole contract a resource server has with an authorization server (§18.2). Anything more would be a reason for some other component to hard-code a Zitadel-shaped assumption.
func Discover ¶
Discover fetches and validates the discovery document for an issuer that is reachable at the address it advertises.
func DiscoverAt ¶
func DiscoverAt(ctx context.Context, transport *Transport, discoveryURL, expectedIssuer string) (*Provider, error)
DiscoverAt fetches the discovery document from an address that need not be the issuer's own, and requires the document to claim the issuer expected.
The issuer comparison is not ceremony: without it, anyone who can influence where this service fetches its configuration can hand it a document naming their issuer and their JWKS, and every subsequent token verifies against keys they control. RFC 8414 §3.3 requires the comparison for that reason.
The endpoints in the document are rebased onto the address they were fetched from. That sounds like a liberty and is the opposite: the alternative is to fetch keys from whatever host a document names, where here the only host ever contacted is the one an operator configured. It is also the only way the document is usable at all when the issuer's advertised address does not resolve on this network.
type Transport ¶
type Transport struct {
// Client bounds the requests. Nil uses a sensible default.
Client *http.Client
// Host overrides the Host header. Empty sends the URL's own host, which is
// correct everywhere the issuer is reachable at the address it advertises.
Host string
}
Transport is how this package reaches the authorization server, as distinct from the identity that server claims.
The separation exists because the two are genuinely different in a container deployment, and conflating them is what makes "just configure an issuer URL" insufficient in practice (§18.2 is right about the principle and quiet about this). The bundled Zitadel advertises `http://localhost:8300` as its issuer, because that is where a browser reaches it for the redirect flow. From inside the compose network there is no such address: the container answers at `auth:8080`, and it routes by Host, so a request without the right Host header reaches the wrong virtual server.
So `core-api` needs to say: fetch from here, send this Host, and expect the document to claim that issuer. Three facts, not one.
type Verifier ¶
type Verifier struct {
// contains filtered or unexported fields
}
Verifier checks access tokens against one issuer and one audience.
func NewVerifier ¶
func NewVerifier(keys *KeySet, issuer, audience string, opts ...VerifierOption) (*Verifier, error)
NewVerifier binds a key set to the issuer and audience it is allowed to accept.
The audience is not optional and there is no "accept any" mode, because §1.4 turns on it: `core-api` accepts only `aud: kindlast-core-api` and `intelligence` only `aud: kindlast-intelligence`. Without that, a token minted for one resource server replays against the other, which is the most common OAuth misconfiguration in a multi-service estate.
type VerifierOption ¶
type VerifierOption func(*Verifier)
VerifierOption adjusts how a Verifier reads a token.
func WithScopeClaims ¶
func WithScopeClaims(names ...string) VerifierOption
WithScopeClaims names additional claims to read the caller's scopes from, on top of the standard `scope` and `scp`.
This exists because of a fact measured against the bundled Zitadel rather than assumed: its access tokens carry neither `scope` nor `scp`. An authorization server is free to express granted authority in a claim of its own choosing, and several do. Zitadel asserts project roles under `urn:zitadel:iam:org:project:{projectID}:roles`; Keycloak uses `realm_access.roles`; Entra uses `roles`.
Configurable rather than hard-coded, for the §18.2 reason: a self-hoster pointing at their own IdP must not need a code change, and this package must not grow a table of vendor quirks. The default stays RFC 9068, which is what a conformant server does.
Values are read whether the claim is a space-delimited string, an array of strings, or an object whose keys are the grants, which is the shape Zitadel and Keycloak both produce.