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 ¶
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 ¶
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.
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 ¶
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 ¶
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:
- r.URL contains no `error` parameter (ErrIdPError)
- `state` query param matches state.Nonce (ErrStateMismatch — T6, CSRF)
- `code` query param non-empty (ErrMissingCode)
- If PKCE enabled, state.Verifier non-empty (T10 defense in depth; LoginURL already enforces it)
- Code-for-token exchange succeeds (ErrTokenExchange)
- id_token present on token response (ErrIDTokenVerify)
- id_token signature + iss + aud + exp + nbf all valid via go-oidc.Verifier.Verify (ErrIDTokenVerify; covers T1-T5)
- id_token nonce matches state.Nonce (ErrNonceMismatch — T1 narrow)
- 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 ¶
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).