oidc

package module
v0.4.1 Latest Latest
Warning

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

Go to latest
Published: Oct 10, 2026 License: BSD-3-Clause Imports: 22 Imported by: 0

README

oidc

Go Reference License CI

Verify an OpenID Connect token into an identity. Discovery, JWKS, and the refusals that matter. Pure Go, CGO_ENABLED=0, no dependencies.

v, err := oidc.New(ctx, oidc.Config{
    Issuer:   "https://login.example.org",
    Audience: "fileshare",
})

tok, err := v.Verify(ctx, raw)          // from an Authorization: Bearer header
fmt.Println(tok.Subject(), tok.Username(), tok.Groups())

A token is a signed statement by somebody else about who is asking. Almost all of the work is refusing the ones that are not, so the refusals are the package.

The rest of Config is optional: JWKSURL skips discovery, for a provider that does not publish /.well-known/openid-configuration; UsernameClaim and GroupsClaim (default groups) say which claims name the person and their groups; ClockSkew (default one minute) and MinRefresh (default one minute) bound clock drift and key-set refetches; Client is the HTTP client for discovery and the key set, for a proxy, a private CA or a timeout of its own.

A Token reads Subject, Issuer, Audience, Email, EmailVerified, Username, Groups, Expiry, NotBefore and IssuedAt; Claim(name, &v) decodes any other claim and Has(name) says whether it is there.

What it accepts

Ten signature algorithms, as an allowlist — a token whose alg is not one of these is refused before any key is fetched:

family algorithms
RSASSA-PKCS1-v1_5 RS256 RS384 RS512
RSASSA-PSS PS256 PS384 PS512
ECDSA ES256 ES384 ES512
EdDSA (Ed25519) EdDSA

An allowlist rather than a list of things to reject, because the two fail differently: a denylist is only as complete as the taxonomy it was written from, and the algorithm it has never heard of is the one it lets through. alg: none and the HMAC family are refused by not being here, which is a stronger statement than a rule naming them — and the table below says why each would be wrong.

What it refuses

why
alg: none a token that says it is unsigned is not a token; it is a JSON object somebody typed
HMAC against a public key HS256 takes a shared secret, and a verifier that uses the issuer's public key as that secret accepts a token anybody can mint. The oldest JWT attack there is
an issuer that merely starts with the right one https://login.example.org.evil.test starts with the right string
a missing or wrong audience a token minted for another service is a valid token; it is simply not addressed to this one
a token with no expiry that is not a token, it is a password somebody can copy once
an issuer or a key set over cleartext HTTP the JWKS decides every signature, and discovery at the issuer says where the JWKS is; over a link somebody can rewrite, so does everything else. Refused by New, at every redirect too (since v0.2.4: a redirect from https to http was followed). Loopback is exempt — there is no link
an RSA key under 2048 bits, an EC point not on the curve a short key is not a small inconvenience: it is a signature somebody else can produce. The size is counted in BITS, and n and e must be in their shortest form (RFC 7518 §2): before v0.2.2 a 1024-bit modulus padded with zero octets passed as 2048 bits
a key published for encryption (use: enc) a key set legitimately holds both, and only one of them checks signatures
with Config.Type set, a token of another type an ID token is not an access token, even when its audience matches the one a resource server is configured with: RFC 9068 §4 requires a resource server to check typ: at+jwt. Type compares the header's typ without case, with application/ optional (RFC 7515 §4.1.9), and also serves for anything else an issuer signs as a JWT. Unset, it takes no typ, JWT or at+jwt (since v0.4.0)

A token verifies as one string: each part must be base64url in its canonical spelling, with nothing else inside (since v0.2.4; a newline or flipped trailing bits used to give other strings that verified). ECDSA's s and n − s both verify, as the algorithm allows, and refusing the second would refuse half of all honest tokens. So a denylist or a replay cache keys on jti or sub, never on the token as sent.

Verify's error says which check refused the token. That detail is for the server's own log, and this package logs nothing itself: the server answers the client with one refusal, whatever the check — a client that sent a token it should not have is not owed an explanation of which check caught it.

Keys rotate, and an unknown key is not a way to hammer the issuer

A signature that nothing held can verify is worth one refetch per MinRefresh (a minute by default). That covers both a new kid and — the inadvisable thing several providers do — the same kid with a new key. A verifier that only refetched on an unknown kid would refuse every token from the moment of the roll until it was restarted.

The bound is on attempts, not successes, and it holds for requests that arrive together:

  • tokens that need the key set while a fetch is under way wait for that fetch instead of starting their own — two hundred at once are one request to the issuer;
  • a fetch that fails still counts. While the issuer's key set is down, it is asked once per MinRefresh, not once per forged token.

Before v0.2.0 neither held: concurrent tokens each fetched, and a failing key set was asked again for every token.

Which name Username() gives

By default preferred_username, then email only when email_verified is true, then sub. An email the issuer did not verify is a string the person typed (OIDC Core §5.1); taking it as a name would admit a correctly signed token under somebody else's address. sub with iss is the only identifier the issuer promises is stable (OIDC Core §5.7), and the fallback ends there.

A configured UsernameClaim is read as named, with no fallback and no email_verified check.

Changed in v0.2.0: earlier versions fell back to email whether or not it was verified. A deployment that relied on that, with an issuer that does not send email_verified, now gets sub instead — and should name the claim it means.

What it is not

There is no login flow here: no redirect, no code exchange, no client secret, no cookies. This is the resource-server half — something arrives with a token, and this says who that is. The half that gets people a token belongs to whatever is talking to them, and putting both in one package makes the security question twice as large for everybody who needed one of them.

Verified against tokens this repository did not sign

  • pyjwt signs every token in the test suite. A token this repository both minted and verified would prove only that its two halves agree with each other — and one person wrote both.
  • The HMAC-confusion token is forged by hand in the test, because pyjwt refuses to produce it: "the specified key is an asymmetric key … and should not be used as an HMAC secret". An independent implementation agreeing that the shape is an attack rather than a use is worth writing down.
  • Committed vectors — testdata/fixed.json, signed once by pyjwt with a fixed key — mean the architecture lanes verify real signatures too, including s390x, where a byte-order mistake in the JWKS arithmetic would show. The clock is fixed there, because a committed token expires.
  • The three refusals that would be catastrophic if they quietly stopped working were sabotage-checked: accepting HMAC, comparing the issuer by prefix, and skipping the audience check each make a test say the token was ACCEPTED.

Licence

BSD-3-Clause.

Documentation

Overview

Package oidc verifies an OpenID Connect token into an identity.

v, err := oidc.New(ctx, oidc.Config{
    Issuer:   "https://login.example.org",
    Audience: "fileshare",
})
tok, err := v.Verify(ctx, raw)   // from an Authorization: Bearer header
fmt.Println(tok.Subject, tok.Username, tok.Groups)

A token is a signed statement by somebody else about who is asking. Almost all of the work is refusing the ones that are not, so the refusals are the package:

  • ⛔ alg: none. A token that says it is unsigned is not a token; it is a JSON object somebody typed.

  • ⛔ An HMAC algorithm against a public key. HS256 takes a shared secret, and a verifier that treats the issuer's PUBLIC key as that secret can be handed a token anybody forged with it -- the key is public. Only the signature algorithms the issuer actually published are accepted, and HMAC is never one of them here.

  • ⛔ An issuer that merely starts with the right string. "iss" is compared whole: https://login.example.org.evil.test starts with the right thing.

  • ⛔ A missing audience. A token minted for another service is a valid token; it is simply not addressed to this one, and a server that skips the check accepts every token the issuer ever signed.

  • ⛔ A key fetched over cleartext HTTP, except from loopback. The JWKS is what decides every signature; over a link somebody can rewrite, so is everything else.

What it is not

There is no login flow here: no redirect, no code exchange, no client secret, no cookies. This is the resource-server half -- something arrives with a token, and this says who that is. The half that gets people a token belongs to whatever is talking to them, and putting both in one package makes the security question twice as large for everybody who needed one of them.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Config

type Config struct {
	// Issuer is the "iss" a token must carry, exactly. It is also where
	// discovery starts, unless JWKSURL is given.
	Issuer string
	// Audience is the "aud" a token must be addressed to -- this service's
	// client id at the provider.
	Audience string

	// JWKSURL skips discovery, for a provider that does not publish
	// /.well-known/openid-configuration or one behind something that does not
	// forward it.
	JWKSURL string

	// UsernameClaim is which claim names the person. Default
	// "preferred_username", falling back to "email" when email_verified is
	// true, and then "sub" -- see [Token.Username], where the fallback is
	// explained.
	UsernameClaim string
	// GroupsClaim is which claim carries their groups. Default "groups".
	GroupsClaim string

	// Type is the "typ" a token's header must carry, when it is set: "at+jwt"
	// for a resource server taking access tokens (RFC 9068 4: it MUST check
	// that typ is at+jwt), or the media type of whatever else an issuer
	// signs. Compared without case and without an "application/" prefix (RFC
	// 7515 4.1.9). Empty takes no typ, "JWT" or "at+jwt", as an ID token may
	// carry any of them.
	//
	// ⛔ Without it a resource server takes an ID token for an access token
	// whenever the audiences agree -- and an ID token is addressed to the
	// client, which is exactly the audience a resource server is often
	// configured with.
	Type string

	// ClockSkew is how much a clock may differ before a token is early or
	// late. Default one minute; a provider and a server that disagree by more
	// than that have a problem worth fixing rather than tolerating.
	ClockSkew time.Duration
	// MinRefresh is how long to wait before fetching the key set again after
	// a key was not found. Default one minute: keys rotate, and a token with
	// an unknown kid must not be a way to make this server hammer the
	// issuer.
	MinRefresh time.Duration

	// Client is the HTTP client for discovery and the key set. A caller with
	// a proxy, a private CA or a timeout of its own passes one.
	Client *http.Client
	// Now overrides the clock, for tests.
	Now func() time.Time
}

Config is what a verifier needs to know. Issuer and Audience are required: a verifier without them is one that accepts every token anybody ever signed.

type Token

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

A Token is a verified token: what the issuer said about who is asking.

Every method reads the claims that arrived. Nothing here re-checks anything -- a Token exists only because Verifier.Verify returned it -- and nothing here reaches the network.

func (*Token) Audience

func (t *Token) Audience() []string

Audience is every "aud" in the token. It is a list in the specification and a string in most tokens, and both spellings mean the same thing.

func (*Token) Claim

func (t *Token) Claim(name string, v any) error

Claim reads any other claim into v, which is how a deployment gets at something this package has no opinion about.

func (*Token) Email

func (t *Token) Email() string

Email is "email", which a provider may or may not send and may or may not have verified -- see Token.EmailVerified.

func (*Token) EmailVerified

func (t *Token) EmailVerified() bool

EmailVerified is "email_verified".

⛔ An unverified email is a string the person typed. Matching people by one lets somebody claim to be anybody whose address they know, at any provider that does not check.

func (*Token) Expiry

func (t *Token) Expiry() (time.Time, bool)

Expiry is "exp".

func (*Token) Groups

func (t *Token) Groups() []string

Groups is the groups claim, which providers spell differently and some do not send at all.

func (*Token) Has

func (t *Token) Has(name string) bool

Has reports whether a claim is present at all, which is different from present and empty.

func (*Token) IssuedAt

func (t *Token) IssuedAt() (time.Time, bool)

IssuedAt is "iat".

func (*Token) Issuer

func (t *Token) Issuer() string

Issuer is "iss".

func (*Token) NotBefore

func (t *Token) NotBefore() (time.Time, bool)

NotBefore is "nbf", which many tokens do not carry.

func (*Token) Subject

func (t *Token) Subject() string

Subject is "sub": the issuer's own identifier for the person, stable across name changes and the only one that is promised to be.

func (*Token) Username

func (t *Token) Username() string

Username is the name to call this person, from the configured claim.

The default order is preferred_username, then email -- only when email_verified is true -- then sub. It is a fallback rather than a requirement because providers differ about which they send, and it ends at sub, which every token has and which is the one the issuer promises is stable (OIDC Core §5.7).

⛔ An email the issuer did not verify is skipped, not used: it is a string the person typed (OIDC Core §5.1), and a correctly signed token carrying somebody else's address would otherwise be admitted under that person's name. Before v0.2.0 an unverified email was used.

A deployment that maps people by name should say which claim it means rather than take what arrives. A configured UsernameClaim is read as named, with no fallback and no email_verified check: naming "email" there is a decision about that issuer, and this does not second-guess it.

type Verifier

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

A Verifier checks tokens from one issuer for one audience.

It is safe for concurrent use, and it holds the key set: build one at startup and keep it, rather than one per request.

func New

func New(ctx context.Context, cfg Config) (*Verifier, error)

New reads the provider's configuration and prepares to verify.

Discovery happens HERE rather than at the first token, for the same reason a database is pinged at startup: a provider that is not answering is a server that cannot authenticate anybody, and it should say so before it listens.

func (*Verifier) Verify

func (v *Verifier) Verify(ctx context.Context, raw string) (*Token, error)

Verify checks a token and says who it is about.

The error says which check refused the token. That detail is for the server's own log -- this package logs nothing -- and the server answers the client with one refusal whatever the check: a client that sent a token it should not have is not owed an explanation of which check caught it.

Jump to

Keyboard shortcuts

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