Help us shape the future of Go! Take the 2026 Go Developer Survey

oidc

package
v0.5.5 Latest Latest
Warning

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

Go to latest
Published: Oct 22, 2026 License: Apache-2.0 Imports: 12 Imported by: 0

Documentation

Overview

Package oidc implements the OIDC/SSO login flow: the Authorization Code + PKCE redirect, keyless ID-token verification against the issuer's public JWKS, fail-closed tenant pinning, and group→role mapping. It ends by handing the caller a verified identity; minting the app's own session token is the caller's job (the JWT authenticator stays the request-path verifier).

Index

Constants

View Source
const StateCookieTTL = 10 * time.Minute

StateCookieTTL bounds how long a login may sit at the IdP before the callback must arrive. Short enough to limit the replay window on the signed state cookie, long enough for a human to complete an MFA prompt.

Variables

View Source
var (
	// ErrIssuerMismatch is returned when the ID token's iss differs from the
	// pinned issuer (H1a).
	ErrIssuerMismatch = errors.New("oidc: issuer mismatch")
	// ErrNonceMismatch is returned when the ID token's nonce does not match the
	// value bound in the state cookie (H4 — replay/CSRF on the token).
	ErrNonceMismatch = errors.New("oidc: nonce mismatch")
	// ErrAzpMismatch is returned when the authorized-party (azp) claim is present
	// and is not this client (H4).
	ErrAzpMismatch = errors.New("oidc: azp mismatch")
	// ErrTokenExpired is returned when exp/iat/nbf fall outside the allowed clock
	// skew (H4).
	ErrTokenExpired = errors.New("oidc: token outside validity window")
	// ErrMissingExpiry is returned when the ID token carries no exp claim; with
	// go-oidc's own expiry check disabled, an absent exp must fail closed rather
	// than grant an unbounded session.
	ErrMissingExpiry = errors.New("oidc: token has no expiry")
	// ErrEmailNotVerified is returned when email_verified is not true; an absent
	// claim is treated as false (D6c).
	ErrEmailNotVerified = errors.New("oidc: email not verified")
	// ErrTenantNotAllowed is returned when the tenant claim (tid/hd) is absent or
	// not present in the tenant_claims map (D6b/d) — never a fallback to default.
	ErrTenantNotAllowed = errors.New("oidc: tenant claim not allowed")
	// ErrTenantAmbiguous is a tenant claim whose array names MORE THAN ONE
	// accepted tenant. Picking one would be arbitrary and the choice would decide
	// which tenant's data the session reaches, so this fails closed instead.
	ErrTenantAmbiguous = errors.New("oidc: tenant claim names more than one accepted tenant")
	// ErrTenantClaimShape is a tenant claim that is neither a string nor an array
	// of strings. It is separate from ErrTenantNotAllowed on purpose: "not
	// allowed" sends an operator to inspect their tenant_claims map, which in this
	// case is correct, and nothing would tell them the value had a shape the pin
	// cannot read.
	ErrTenantClaimShape = errors.New("oidc: tenant claim is neither a string nor an array of strings")
	// ErrEmailDomainNotAllowed is returned when a non-empty allowed_email_domains
	// list does not include the verified email's domain (login-level allowlist).
	ErrEmailDomainNotAllowed = errors.New("oidc: email domain not allowed")
	// ErrNoSubject is returned when the ID token carries no subject — there is no
	// stable identity to key on.
	ErrNoSubject = errors.New("oidc: token has no subject")
	// ErrGroupOverage signals the IdP omitted the groups claim and returned a
	// _claim_names overage pointer instead (Azure Entra does this past ~200 group
	// memberships). Silently treating that as "no groups" would demote a heavily-
	// grouped user (often the most privileged) to default-deny / default_role with
	// no signal, so it fails closed with an actionable message: configure Entra
	// app roles (the "roles" claim) or narrow group membership. Full app-role
	// resolution is the EKS-validated follow-up.
	ErrGroupOverage = errors.New("oidc: group claim overage (IdP returned a _claim_names pointer, not the groups) — configure Entra app roles or the groups scope")
)

Verification failures. Each is a fail-closed rejection the caller maps to a 403 and audits by reason; none of them ever falls back to a default tenant or a trusted default identity.

View Source
var ErrInvalidState = errors.New("invalid oidc state")

ErrInvalidState is returned when the state cookie is missing, malformed, expired, or not signed by us — every case is a failed CSRF/replay check.

View Source
var ErrNoIDToken = errors.New("oidc: token response has no id_token")

ErrNoIDToken is returned when the token endpoint's response carries no id_token — a misbehaving or misconfigured IdP. The login fails closed rather than proceeding without an identity to verify.

Functions

func ApplyDefaultRole

func ApplyDefaultRole(roles []string, defaultRole string) []string

ApplyDefaultRole softens the default-deny without weakening it: when the mapped set is empty and a default role is configured, the user is granted that single fallback role. An empty defaultRole leaves the deny in place (the secure default) and a non-empty mapped set is never overridden.

func GenerateCodeVerifier

func GenerateCodeVerifier() string

GenerateCodeVerifier returns a fresh PKCE code verifier (high-entropy, URL-safe). The caller stores it in the state cookie and passes it to Exchange.

func HTTPClient

func HTTPClient() *http.Client

HTTPClient is the client every IdP call in this package uses. A new one per call is deliberate over a package-level singleton: these are boot-time and per-login calls, the allocation is irrelevant next to the round trip, and a shared mutable client is a thing tests reach into.

func MapRoles

func MapRoles(groups []string, mappings map[string]string) []string

MapRoles translates the user's IdP group values into Dexaflow role names using the configured mapping. It is DEFAULT-DENY: a group with no entry in the map contributes no role. The result is de-duplicated and preserves first-seen order, so a token's role list is stable regardless of group ordering.

Types

type CLIBinding added in v0.5.4

type CLIBinding struct {
	Redirect  string `json:"redirect"`
	State     string `json:"state"`
	Challenge string `json:"challenge"`
}

CLIBinding is what a CLI-started login carries through the IdP round trip, signed with the rest of the state so the callback can trust it: where the CLI listens, the CLI's own state to echo back, and the S256 challenge of the PKCE verifier only the CLI holds, which the one-time code is bound to.

type DeviceBinding added in v0.5.4

type DeviceBinding struct {
	UserCode string `json:"user_code,omitempty"`
}

DeviceBinding is what a device sign-in carries through the IdP round trip, signed with the rest of the state: the user code of the link the person opened (verification_uri_complete), well formed but not looked up, to fill in on the page that asks for the code after the sign-in. It is "" when the person opened the bare page.

type Flow

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

Flow drives the Authorization Code + PKCE login: it builds the authorization redirect, exchanges the code for tokens (using the client secret and the PKCE verifier), and exposes the keyless ID-token Verifier. Discovery runs once at construction and pins the issuer.

func NewFlow

func NewFlow(ctx context.Context, cfg config.OIDCSection, hs256Secret string) (*Flow, error)

NewFlow discovers the issuer, pins it, and assembles the OAuth2 client and the ID-token verifier. hs256Secret is the app secret the state cookie is signed with (a derived key). It returns an error if discovery fails, so a misconfigured issuer is a boot failure rather than a per-login surprise.

func (*Flow) AuthCodeURL

func (f *Flow) AuthCodeURL(state, nonce, verifier string) string

AuthCodeURL builds the authorization redirect for the given CSRF state, ID token nonce, and PKCE verifier. The verifier is sent as an S256 challenge (never in the clear); the nonce is echoed back in the ID token and checked on callback.

func (*Flow) AuthEndpoint added in v0.5.4

func (f *Flow) AuthEndpoint() string

AuthEndpoint returns the IdP's discovered authorization endpoint, the URL the browser is sent to, without building a redirect.

func (*Flow) Codec

func (f *Flow) Codec() *StateCodec

Codec returns the state-cookie codec so the caller can encode the cookie on login and decode it on callback.

func (*Flow) Exchange

func (f *Flow) Exchange(ctx context.Context, code, verifier string) (string, error)

Exchange trades the authorization code for tokens (proving possession of the PKCE verifier and the client secret) and returns the raw ID token for verification. A response without an id_token fails closed.

func (*Flow) Verifier

func (f *Flow) Verifier() *Verifier

Verifier returns the ID-token verifier.

type StateCodec

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

StateCodec signs and verifies the state cookie with a key derived from the app's HS256 secret. Deriving a distinct key (rather than reusing the secret directly) keeps a state cookie from ever validating as a session token and vice versa.

func NewStateCodec

func NewStateCodec(hs256Secret string) *StateCodec

NewStateCodec builds a StateCodec from the app's HS256 secret, deriving a dedicated signing key so state cookies and session tokens never cross-verify.

func (*StateCodec) Decode

func (c *StateCodec) Decode(token string) (StatePayload, error)

Decode verifies the cookie's signature and expiry and returns its payload. Any failure — bad signature, wrong issuer, expired, malformed — is ErrInvalidState, so the callback fails closed on a tampered or stale cookie.

func (*StateCodec) Encode

func (c *StateCodec) Encode(p StatePayload, ttl time.Duration) (string, error)

Encode signs the payload into a compact token valid for ttl. The CSRF `state` travels as the JWT ID (jti), so verifying the callback's state query param is a direct comparison against the signed cookie.

type StatePayload

type StatePayload struct {
	State    string
	Nonce    string
	Verifier string
	Next     string
	// CLI is set when the login was started by `dexaflow auth login` rather
	// than by the browser's own sign-in: the callback then hands the CLI a
	// one-time code on its loopback redirect instead of setting a session
	// cookie. Nil for a browser login.
	CLI *CLIBinding
	// Device is set when the login is the first step of a device sign-in of
	// the CLI (`dexaflow auth login --device`, ADR 0081): the callback then
	// asks for the user code instead of setting a session cookie. Nil for
	// any other login.
	Device *DeviceBinding
}

StatePayload is the login-flow state carried statelessly in a signed, HttpOnly cookie between the login redirect and the callback: the CSRF `state`, the ID-token `nonce`, the PKCE `code_verifier`, and the sanitized post-login redirect target. No server-side session store is needed.

type VerifiedIdentity

type VerifiedIdentity struct {
	// Provider is the stable provider key stored on the user row (the pinned
	// issuer URL), half of the (provider, subject) link key.
	Provider string
	// Subject is the immutable IdP subject, the other half of the link key.
	Subject string
	// Email is the user's email; it is only trusted because EmailVerified is true.
	Email string
	// EmailVerified is always true on a returned identity (a false/absent claim
	// fails verification).
	EmailVerified bool
	// Tenant is the resolved Dexaflow tenant name (from the tid/hd pin).
	Tenant string
	// Groups are the raw IdP group values, for group→role mapping.
	Groups []string
}

VerifiedIdentity is the result of a fully-checked ID token: a trustworthy identity the caller may resolve to a Dexaflow user and mint a session for. It is only ever returned after every H1/H4 check and the tenant pin have passed.

type Verifier

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

Verifier verifies ID tokens keylessly against the issuer's public JWKS and enforces the fail-closed identity gate (issuer pin, audience, nonce, azp, clock skew, email_verified, tenant pin, email-domain allowlist). It wraps go-oidc but does NOT rely on it for nonce, azp, or clock skew, which go-oidc does not check — those are enforced here explicitly (H4).

func NewVerifier

func NewVerifier(ctx context.Context, cfg config.OIDCSection) (*Verifier, error)

NewVerifier discovers the issuer's OIDC metadata (which pins the issuer: the discovery document's issuer must equal cfg.Issuer) and builds an ID-token verifier that checks the signature against the published JWKS and the audience against the client id. Expiry is deferred to this package's skew-aware check, so the verifier is configured to skip go-oidc's own expiry check.

func (*Verifier) Verify

func (v *Verifier) Verify(ctx context.Context, rawIDToken, expectedNonce string) (*VerifiedIdentity, error)

Verify performs the full fail-closed check on a raw ID token and returns the resulting identity. expectedNonce is the value bound in the state cookie. The order matters: signature/audience/issuer (go-oidc) → nonce → azp → clock skew → subject → email_verified → tenant pin → email-domain allowlist. The domain allowlist is deliberately last (and only reached once email_verified is true) so a spoofable domain can never substitute for the tid/hd pin.

Jump to

Keyboard shortcuts

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