oidc

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Sep 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.

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
a key set over cleartext HTTP the JWKS decides every signature; over a link somebody can rewrite, so does everything else. 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
a key published for encryption (use: enc) a key set legitimately holds both, and only one of them checks signatures

Everything wrong with a token is one error to the caller — a client that sent a token it should not have is not owed an explanation of which check caught it — while the detail goes to the server's own log.

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.

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 vectorstestdata/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" and then "sub" -- see
	// [Token.Username], where the fallback is explained.
	UsernameClaim string
	// GroupsClaim is which claim carries their groups. Default "groups".
	GroupsClaim 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, 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. A deployment that maps people by name should say which claim it means rather than take what arrives.

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.

Everything that can be wrong with a token is one error to the caller, and the detail is for a server's own log: 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