oidc

package
v0.5.3 Latest Latest
Warning

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

Go to latest
Published: Jun 12, 2026 License: MIT Imports: 12 Imported by: 0

Documentation

Overview

Package oidc is the OpenID Connect (RFC 6749 + OIDC Core 1.0) implementation of the auth.Provider interface defined in pkg/auth.

The package is intentionally decoupled from osctrl's config and DB layers: callers pass a Config struct rather than a viper-bound YAML struct. This makes the package reusable from:

  • cmd/admin (legacy, configured via YAML)
  • cmd/api (v1, configured via DB row in env_auth_providers)
  • tests (configured inline)

Security: every operation is bounded by a context deadline (caller responsibility), uses the verified go-oidc.Verifier for id_token checks, and never logs raw tokens or secrets. See docs/proposals/osctrl-auth-providers-v0.1-spec.md §Security.

Index

Constants

View Source
const (
	// DefaultUsernameClaim is the OIDC claim consulted for the
	// AdminUser.Username field. preferred_username is the most
	// stable choice for human-readable login names that IdPs emit
	// consistently. Configurable via Config.UsernameClaim.
	DefaultUsernameClaim = "preferred_username"

	// DefaultGroupsClaim is the OIDC claim consulted for the
	// RequiredGroups gate (and for the future role-mapping
	// extension point). Both Keycloak and Auth0 emit "groups"
	// by convention when a group-membership mapper is attached.
	DefaultGroupsClaim = "groups"
)

Default values used when a Config field is left at the zero value.

Variables

View Source
var (
	// ErrStateMismatch covers any mismatch between the State the
	// caller transported (cookie) and the parameters the IdP
	// returned (query). Specifically, the OAuth2 state-param check
	// (T6, CSRF). The id_token nonce check has its own sentinel
	// (ErrNonceMismatch).
	ErrStateMismatch = errors.New("oidc: state mismatch")

	// ErrIdPError is returned when the IdP itself signaled an
	// error via the OAuth2 `error` query parameter on the
	// callback. Common values: access_denied, login_required.
	ErrIdPError = errors.New("oidc: identity provider returned error")

	// ErrTokenExchange wraps any failure during the
	// code-for-token exchange (network, IdP rejection, etc.).
	ErrTokenExchange = errors.New("oidc: token exchange failed")

	// ErrIDTokenVerify wraps verification failures (bad sig,
	// wrong iss/aud, expired, etc.). Threats T1–T5.
	ErrIDTokenVerify = errors.New("oidc: id_token verification failed")

	// ErrNonceMismatch is the specific id_token-vs-state nonce
	// failure (threat T1 narrow case). Distinguishable in logs;
	// callers still map to the same generic client response.
	ErrNonceMismatch = errors.New("oidc: nonce mismatch")

	// ErrGroupNotAllowed surfaces RequiredGroups-gate denials.
	// Threat T17.
	ErrGroupNotAllowed = errors.New("oidc: user not in required group")

	// ErrUsernameInvalid is returned when the resolved username
	// contains characters that violate sanitizeUsername. Threat
	// T23 + audit-log poisoning T26.
	ErrUsernameInvalid = errors.New("oidc: username failed character validation")

	// ErrMissingCode catches the OAuth2 callback edge case where
	// `code` is absent (some IdPs do this when the user
	// cancels). Distinct so the handler logs cleanly.
	ErrMissingCode = errors.New("oidc: authorization code missing from callback")
)

Errors returned by HandleCallback. Callers map these to HTTP status codes. The strings are stable across versions; logging may use them, but client-facing responses should be a single generic "authentication failed" message (timing-oracle defense, threat T31).

Functions

This section is empty.

Types

type Config

type Config struct {
	// IssuerURL is the OIDC issuer (typically the realm root for
	// Keycloak, the tenant URL for Auth0/Entra, etc.). Must be a
	// well-formed http(s) URL.
	//
	// http:// is permitted but production callers should reject it
	// unless an explicit opt-in flag is set; the package itself
	// allows http to support dev IdPs (Keycloak on localhost) but
	// emits no production guarantees over plaintext.
	IssuerURL string

	// ClientID is the OIDC client identifier registered with the IdP.
	ClientID string

	// ClientSecret is the OIDC client secret. Required for the
	// "confidential client" pattern; may be empty when UsePKCE is
	// true and the client is registered as public.
	ClientSecret string

	// RedirectURL is the absolute callback URL osctrl-api advertises
	// to the IdP. Must match exactly what's registered IdP-side; any
	// drift causes the IdP to reject the authorize request.
	//
	// Must be https in production. The package permits http for the
	// same reason as IssuerURL (dev IdPs) but callers are expected
	// to enforce https for non-dev configs.
	RedirectURL string

	// Scopes are passed verbatim to the authorize endpoint. If empty,
	// the provider injects ["openid", "profile", "email"]. If non-empty
	// without "openid", "openid" is prepended.
	Scopes []string

	// UsernameClaim is the OIDC claim used as the AdminUser.Username.
	// Values: "preferred_username" (default), "email", "sub". An
	// invalid value falls back to "sub" (always available, always
	// stable, but unfriendly to humans).
	UsernameClaim string

	// GroupsClaim is the OIDC claim consulted for group membership.
	// Default: "groups". Used only when RequiredGroups is non-empty.
	GroupsClaim string

	// RequiredGroups, if non-empty, gates HandleCallback so only users
	// whose group claim contains at least one of these strings can
	// authenticate. Empty list disables the gate entirely.
	RequiredGroups []string

	// JITProvision controls whether HandleCallback's downstream
	// caller (cmd/api/handlers/auth_callback.go) should auto-create a
	// new AdminUser on first login. The package itself never creates
	// users; this field is plumbed through ResolvedIdentity-adjacent
	// caller logic.
	//
	// We expose this on Config rather than on the resolved-identity
	// path so per-env policy can vary: some envs JIT, others don't.
	JITProvision bool

	// UsePKCE enables PKCE (RFC 7636) on the authorize + token
	// requests. Recommended for any deployment; mandatory for public
	// clients (those without a client secret).
	UsePKCE bool

	// LegacyPermissiveUsername disables the strict character-class
	// validation on the resolved username, passing the IdP-supplied
	// value (after TrimSpace) directly into ResolvedIdentity.
	// PreferredUsername.
	//
	// New callers MUST leave this false — strict validation is the
	// safe default and prevents audit-log poisoning (T26) and
	// injection-shaped usernames (T23) from reaching downstream
	// code.
	//
	// This flag exists ONLY to preserve backwards compatibility with
	// legacy osctrl-admin deployments where operators may have
	// pre-existing AdminUser rows whose usernames contain `.`, `@`,
	// or spaces (typical when an IdP emits `preferred_username` as
	// an email). Setting this true bypasses the regex but leaves
	// every other verification step intact (signature, iss, aud,
	// exp, nonce, groups).
	//
	// cmd/admin/oidc.go sets this true. cmd/api/handlers/oidc.go
	// MUST leave it false.
	LegacyPermissiveUsername bool
}

Config holds everything NewOIDCProvider needs to construct a working OIDC client. Callers populate this struct from whatever config source they prefer (YAML, DB row, env var) — the package doesn't care.

Field validity is checked once at NewOIDCProvider time. Failed validation returns an error rather than panicking so callers can surface a clean operator message.

func (Config) Validate

func (c Config) Validate() error

Validate is called by NewOIDCProvider; callers may invoke it independently when persisting a Config to verify shape before write. Returns the first error encountered; full validation requires fixing each error and re-running.

type Provider

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

Provider is the concrete OIDC implementation of auth.Provider. Constructed once at startup (or once per env, when loaded from DB) and reused across requests. Safe for concurrent use.

func NewOIDCProvider

func NewOIDCProvider(ctx context.Context, cfg Config) (*Provider, error)

NewOIDCProvider constructs a Provider from the given Config. The context is used for OIDC discovery (fetching the IdP's metadata document and JWKS); pass a context with a deadline so a hung IdP during init doesn't wedge startup.

Returns a non-nil error and a nil Provider on:

  • Config validation failure
  • OIDC discovery failure (IdP unreachable, malformed metadata, etc.)

The returned Provider's verifier pins the audience to cfg.ClientID — id_tokens issued for a different audience will fail verification (threat T3).

func (*Provider) EndSessionURL

func (p *Provider) EndSessionURL() string

EndSessionURL returns the IdP's RP-initiated logout endpoint URL, or "" if the IdP didn't advertise one in its discovery document. Callers append `?post_logout_redirect_uri=...&id_token_hint=...` when they redirect the user — Keycloak and most IdPs accept those query params per the OIDC RP-Initiated Logout spec.

Best-effort: a discovery doc without end_session_endpoint yields an empty string, and the caller falls back to client-only cookie clearing (no IdP session termination).

func (*Provider) HandleCallback

func (p *Provider) HandleCallback(parentCtx context.Context, r *http.Request, state auth.State) (auth.ResolvedIdentity, error)

HandleCallback consumes the callback request and returns a ResolvedIdentity. Validates, in order:

  1. r.URL contains no `error` parameter (ErrIdPError)
  2. `state` query param matches state.Nonce (ErrStateMismatch — T6, CSRF)
  3. `code` query param non-empty (ErrMissingCode)
  4. If PKCE enabled, state.Verifier non-empty (T10 defense in depth; LoginURL already enforces it)
  5. Code-for-token exchange succeeds (ErrTokenExchange)
  6. id_token present on token response (ErrIDTokenVerify)
  7. id_token signature + iss + aud + exp + nbf all valid via go-oidc.Verifier.Verify (ErrIDTokenVerify; covers T1-T5)
  8. id_token nonce matches state.Nonce (ErrNonceMismatch — T1 narrow)
  9. Required-groups gate satisfied if configured (ErrGroupNotAllowed — T17)

10. Resolved username passes sanitizeUsername (ErrUsernameInvalid — T23)

Implementations of HandleCallback MUST NOT trust the caller to pre-verify any of the above. This is the security perimeter.

func (*Provider) LoginURL

func (p *Provider) LoginURL(ctx context.Context, state auth.State) (string, error)

LoginURL builds the authorize-endpoint URL that the user's browser should be redirected to. The state argument carries TWO independent random values plus the optional PKCE verifier; HandleCallback validates each against the corresponding protocol slot.

Slot 1: OAuth2 `state` query parameter ← state.OAuthState. Echoed verbatim by the IdP onto the callback URL; HandleCallback checks it as the CSRF defense (threat T6). An attacker who has not seen the state cookie cannot mint a callback URL whose `state` echoes what HandleCallback expects.

Slot 2: OIDC `nonce` query parameter ← state.Nonce. Embedded in the id_token's `nonce` claim by the IdP; go-oidc.Verifier exposes it after signature verification and HandleCallback compares it to state.Nonce (threats T1, T9 — id_token replay defense).

Why two independent values: a leak of either via a Referer header, access log, or proxy buffer must NOT compromise the other. Reusing a single random value across both slots (the pre-May-2026 implementation) collapsed both defenses to one leak surface.

State.EnvUUID is informational only at this layer: it must be non-empty (so the state cookie has stable shape) but its value isn't checked against anything in the callback URL. Callers that want env-scoping above the protocol layer use the EnvUUID out-of-band (legacy admin sets it to "admin"; cmd/api sets it to "api" / "global"; whatever the operator-side code wants).

func (*Provider) Type

func (p *Provider) Type() string

Type identifies this provider as OIDC.

Jump to

Keyboard shortcuts

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