oidc

package
v0.6.2 Latest Latest
Warning

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

Go to latest
Published: Aug 27, 2026 License: Apache-2.0 Imports: 17 Imported by: 0

Documentation

Overview

derive.go holds the IDENTITY-DERIVATION seam: everything that turns an ID token's claims into what a Wardyn session carries about WHO this human is — their role (deriveRole, Config.RoleMap, the legacy operator allowlist) and their group snapshot (sessionGroups, the subject a capability grant matches).

Split out of oidc.go when the group snapshot pushed that file past the 1000-line gate. The seam is real, not arithmetic: nothing here touches HTTP, cookies, or the OAuth2 exchange. It is pure claims-in, identity-out, which is also why it is the part of this package that is unit-testable without a signed token or a fake IdP.

Package oidc implements human SSO for Wardyn via OpenID Connect (Dex-compatible).

CSRF posture (v0)

CSRF protection is two-layered:

  1. State parameter: a random 128-bit value stored in an HttpOnly SameSite=Lax cookie ("wardyn_oidc_state") and compared to the IdP callback parameter.
  2. SameSite=Lax on all session cookies: protects all same-site navigations from cross-site request forgery without requiring a synchronizer token.

A PKCE code_challenge (S256) is included in the authorization request and verified by the token endpoint. This provides additional security even when the state check is bypassed (e.g. by a mix-up attack).

Known gap: the nonce is verified in the ID token but is not bound to the device (mitigated by state + PKCE). A future milestone can pin it.

Session storage

Sessions live entirely in a signed HttpOnly SameSite=Lax cookie named "wardyn_session". The cookie payload is a JSON struct containing sub, email, role, and expiry, HMAC-SHA256 signed with the key passed to New. The key is never logged and never leaves the process. A cookie with no role — signed before the role field existed (pre-0.5), or otherwise carrying an empty one — decodes as NO session (see decodeSession), forcing a re-login that derives one fresh rather than granting an undefined role.

Integration

The Middleware exposed here accepts either a valid session cookie (sets a HumanPrincipal on the context via a package-private key) or falls through to the next handler (which the integrator wraps with the existing adminAuth bearer path). Use PrincipalFromContext to read the principal; it returns "" when no SSO session is present so the caller can fall through gracefully.

Index

Constants

View Source
const (
	RoleAdmin  = "admin"
	RoleMember = "member"
)

Wardyn roles a session can carry. See Session.Role / Config.RoleMap.

Variables

View Source
var ErrInvalidSession = errors.New("oidc: invalid session cookie")

ErrInvalidSession is returned by decodeSession when the cookie is present but tampered, malformed, or uses a different HMAC key.

View Source
var ErrNoSession = errors.New("oidc: no session cookie")

ErrNoSession is returned by decodeSession when no session cookie is present.

Functions

func EmailFromContext added in v0.5.0

func EmailFromContext(ctx context.Context) string

EmailFromContext returns the email claim of the session Middleware verified, or "" when there is no SSO session (or the IdP returned no email — which is possible whenever AllowedEmailDomains is empty, since that is the only check that requires one). It is the identity internal/api resolves the minimal viewer/operator role from; the "sub" is opaque and cannot be matched against an operator allowlist an admin can actually write down.

func ExpiryFromContext added in v0.6.0

func ExpiryFromContext(ctx context.Context) time.Time

ExpiryFromContext returns when the session Middleware verified will expire, or the zero time when there is no SSO session. W31-S1-7: there is no refresh — the session dies outright at this instant — so the console surfaces it as an advance warning instead of a surprise 401 that wipes mid-work state back to the sign-in gate.

func GroupsFromContext added in v0.6.0

func GroupsFromContext(ctx context.Context) []string

GroupsFromContext returns the login-time group snapshot of the session Middleware verified — the subjects a `group` capability grant matches.

NIL AND EMPTY MEAN DIFFERENT THINGS and callers must keep them apart. Empty non-nil: this session was minted by 0.6+, the IdP sent no usable group identity, and group grants genuinely do not apply. Nil: either there is no SSO session at all, or the human is holding a PRE-0.6 cookie that predates the field — group grants cannot be evaluated for them until they log in again, which is what internal/api surfaces as groups_snapshot_stale rather than silently reporting "no groups".

Same DERIVES-not-ENFORCES split as RoleFromContext: this package carries the snapshot, internal/api decides what it permits.

func ParseRoleMap added in v0.5.0

func ParseRoleMap(csv string) (map[string]string, error)

ParseRoleMap parses WARDYN_OIDC_ROLE_MAP: a comma-separated list of "value=role" pairs, e.g. "Wardyn.Admin=admin,eng-team=member,alice@corp.com=admin". value is matched case-insensitively against an ID token's roles/groups claims or its email (see deriveRole); role must be RoleAdmin or RoleMember. Empty/blank input returns a nil map (role derivation disabled — Config.RoleMap's empty behavior) and no error; non-empty input that yields no usable entry (e.g. "," or a single malformed pair) is an error, never a silent nil — nil means "everyone is admin" (deriveRole), which must never be an accident.

func PrincipalFromContext

func PrincipalFromContext(ctx context.Context) string

PrincipalFromContext returns the human principal set by Middleware, or "" if no SSO session is present on the context. The returned value is the OIDC "sub" claim (a stable opaque identifier from the IdP).

Integration note: internal/api's principalFromRequest should call this first and fall back to the admin-token path when the result is "".

func RoleFromContext added in v0.5.0

func RoleFromContext(ctx context.Context) string

RoleFromContext returns the Wardyn role (RoleAdmin or RoleMember) derived for the session Middleware verified, or "" when there is no SSO session. This package only DERIVES and CARRIES the role — see CallbackHandler / deriveRole for how it is computed. Enforcing it (deciding what an admin vs a member may do) belongs to internal/api, the same split PrincipalFromContext/EmailFromContext already follow.

func SessionRejectedFromContext added in v0.6.0

func SessionRejectedFromContext(ctx context.Context) string

SessionRejectedFromContext returns why Middleware rejected a presented session cookie on this request: "invalid_session" for a tampered/malformed cookie, "expired_session" for a valid-but-expired one, "revoked_session" for a valid cookie the revocation store has cut off, or "session_revocation_unavailable" when that store errored (fail-closed) — the last two only when Revocations is wired. Returns "" when no session cookie was presented at all (the ordinary non-browser-client case) or the session decoded fine.

func ValidRole added in v0.5.0

func ValidRole(s string) bool

ValidRole reports whether s is a recognized role value. Used to validate WARDYN_OIDC_ROLE_MAP entries (ParseRoleMap) and WARDYN_OIDC_DEFAULT_ROLE (cmd/wardynd, at boot) — both fail closed on a typo rather than letting a garbage role value silently reach a session cookie.

Types

type Authenticator

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

Authenticator provides OIDC login, callback, logout, and session-check handlers.

func New

func New(ctx context.Context, cfg Config, hmacKey []byte) (*Authenticator, error)

New constructs an Authenticator by performing OIDC discovery against cfg.IssuerURL. hmacKey is the secret used to sign session cookies; it must be provided by the caller (e.g. loaded from the secret store). The key is never logged.

func (*Authenticator) CallbackHandler

func (a *Authenticator) CallbackHandler(w http.ResponseWriter, r *http.Request)

CallbackHandler handles the IdP redirect. It:

  1. Verifies the state parameter against the state cookie (CSRF).
  2. Exchanges the code for tokens using PKCE.
  3. Verifies the ID token signature, issuer, audience, expiry, and nonce.
  4. Optionally checks email domain (fail closed when AllowedEmailDomains is set).
  5. Derives the session's role from the roles/groups/email claims (see Config.RoleMap / deriveRole); denies the login if nothing matches and no DefaultRole is configured.
  6. Creates a signed Wardyn session cookie.

W31-S1-5: the USER-actionable denials (5's role-denied, 4's domain/ unverified-email) redirect to "/?auth_error=<code>" (302) instead of a bare http.Error text page — a login failure otherwise dead-ended the browser on plain text with no way back to the console, and no chance for the sign-in screen to explain what to do next (ask the operator to map a role, use a corp email, etc). The state/nonce/PKCE branches in (1)-(3) stay http.Error: those are ATTACK-shaped (a forged/replayed/mismatched callback), not a real user hitting a real policy denial, and a redirect there would be a worse UX for a case an operator needs to see failed loudly, not routed back into a retry loop.

func (*Authenticator) LoginHandler

func (a *Authenticator) LoginHandler(w http.ResponseWriter, r *http.Request)

LoginHandler initiates the OIDC authorization code flow. It generates a random state and nonce, stores them in HttpOnly SameSite=Lax cookies, and redirects the user to the IdP authorization endpoint.

func (*Authenticator) LogoutHandler

func (a *Authenticator) LogoutHandler(w http.ResponseWriter, r *http.Request)

LogoutHandler clears the Wardyn session cookie and redirects to "/".

func (*Authenticator) Middleware

func (a *Authenticator) Middleware(next http.Handler) http.Handler

Middleware returns an http.Handler wrapper that:

  • If a valid (non-expired, correctly signed) session cookie is present, sets the HumanPrincipal on the request context and calls next.
  • Otherwise falls through to next without a principal, allowing the integrator's adminAuth bearer path to handle the request. When a session cookie WAS presented but rejected (tampered/malformed, or valid-but-expired), the rejection reason rides along on the context — see SessionRejectedFromContext — so the integrator's eventual 401 can name it instead of failing silently.

This design lets the integrator compose: oidc.Middleware(adminAuth(handler)).

type Config

type Config struct {
	// IssuerURL is the OIDC provider's PUBLIC issuer — the URL the user's
	// browser is redirected to and the value of the "iss" claim in ID tokens
	// (e.g. http://localhost:5556). Must match the IdP's configured issuer.
	IssuerURL string
	// InternalIssuerURL, when set, is the address at which wardynd itself
	// reaches the IdP for server-side calls (discovery, token exchange, JWKS)
	// — e.g. http://dex:5556 on a Docker network. It solves the split-horizon
	// problem where the browser and the control plane reach the IdP at
	// different hostnames: the browser-facing endpoints keep the public
	// IssuerURL, while wardynd's HTTP client transparently rewrites the public
	// authority to this internal one. Empty => IssuerURL is used for both.
	InternalIssuerURL string
	// ClientID is the OAuth2 client identifier registered with the IdP.
	ClientID string
	// ClientSecret is the OAuth2 client secret. Never log this value.
	ClientSecret string
	// RedirectURL is the callback URL registered with the IdP.
	// Must be <wardynd-base>/auth/callback.
	RedirectURL string
	// AllowedEmailDomains, when non-empty, restricts login to email addresses
	// whose domain — the part after the last '@' — exactly equals one of the
	// listed values, case-insensitively. Matching is exact, not suffix-based:
	// listing "example.com" does NOT admit "eng.example.com", which must be
	// listed separately; wildcards are not supported.
	// An empty list allows any verified email. Fail closed: if the IdP does not
	// return a verified email and AllowedEmailDomains is non-empty, login is denied.
	// Entra ID tokens typically OMIT email_verified entirely, so this option
	// fail-closes on every login against an Entra tenant; prefer Entra App Roles
	// (WARDYN_OIDC_ROLE_MAP against the "roles" claim, below) plus the app
	// registration's "assignment required" setting there instead.
	AllowedEmailDomains []string
	// RoleMap maps a case-insensitive claim/email value — an Entra App Role
	// from the ID token's "roles" claim, a "groups" claim entry, or the user's
	// email — to a Wardyn role, RoleAdmin or RoleMember. Parsed from
	// WARDYN_OIDC_ROLE_MAP by ParseRoleMap ("value=role" CSV pairs, e.g.
	// "Wardyn.Admin=admin,eng-team=member,alice@corp.com=admin"); ParseRoleMap
	// is also where a bad role value is rejected, so every entry here is
	// already valid. Precedence when more than one entry matches: ANY match
	// resolving to RoleAdmin wins over one resolving to RoleMember, regardless
	// of which claim produced it (see deriveRole). Empty (the default) disables
	// role derivation entirely: every signed-in human keeps today's pre-0.5
	// behavior (RoleAdmin) — opt-in and upgrade-safe.
	RoleMap map[string]string
	// DefaultRole is the role a signed-in human gets when RoleMap is non-empty
	// but nothing in their roles/groups/email matched an entry: RoleAdmin,
	// RoleMember, or "" (the default) to DENY the login instead, with a message
	// telling them to ask their operator for a WARDYN_OIDC_ROLE_MAP entry.
	// Ignored when RoleMap is empty (see RoleMap's own empty-map behavior).
	DefaultRole string
	// LegacyAdminEmails is WARDYN_OIDC_OPERATOR_EMAILS — the operator allowlist,
	// now the SOLE source of the admin tier (internal/api's isOperator reads the
	// derived role, not this list directly). An email on it always derives
	// RoleAdmin, same top precedence as any RoleMap admin match, and with no
	// RoleMap at all the list alone splits admin from member — so a 0.4.5
	// deployment keeps its operators as admins and everyone else as members
	// (viewers) with zero re-configuration, with or without a role map.
	LegacyAdminEmails []string
	// SecureCookies, when true, marks every cookie Wardyn issues (the session
	// cookie and the one-time login state/nonce/pkce cookies) with the Secure
	// attribute, so browsers only send them over HTTPS. It MUST be true exactly
	// when the connection is TLS-protected — either wardynd serves TLS directly
	// or TLS terminates at an upstream reverse proxy. CRITICAL: Secure cookies
	// are never sent over plain HTTP, so leaving this false (the default) is
	// required for plain-HTTP demo deployments — otherwise login silently breaks.
	SecureCookies bool
	// Revocations is the pg-backed revoke-a-human-now lever (D16). Sessions
	// are stateless signed cookies with no server-side session table (see the
	// package doc's "Session storage" section), so there is nothing to delete
	// on revoke — instead Revocations tracks a per-principal (and a global)
	// CUTOFF time, and Middleware treats any session whose IssuedAt is
	// at-or-before the applicable cutoff as invalid. nil (the default) means
	// revocation is never checked — unset changes nothing, same as every
	// other optional Config field.
	Revocations SessionRevocations

	// OnLogin, when set, is called synchronously from CallbackHandler after a
	// login is APPROVED (role derived, session about to be issued) with the
	// ID token's sub and the freshly-derived role. It exists for exactly one
	// caller today — internal/api wires it to refresh ssh_public_keys.role /
	// role_checked_at (migration 0046) for every key this principal owns, the
	// bounded-stale re-check the SSH gateway's admin override reads — but this
	// package stays store-agnostic: it knows nothing about SSH keys, only that
	// a login happened. A failure inside OnLogin must never fail the login
	// itself (the integrator is expected to log-and-continue, not panic);
	// CallbackHandler does not inspect its return because it has none. nil
	// (the default) is a plain no-op, so every existing caller is unaffected.
	OnLogin func(ctx context.Context, sub, role string)
}

Config holds the OIDC client configuration. All fields except AllowedEmailDomains are required.

type Session

type Session struct {
	Sub    string    `json:"sub"`
	Email  string    `json:"email"`
	Role   string    `json:"role"`
	Expiry time.Time `json:"expiry"`
	// IssuedAt (D16) is when CallbackHandler minted this cookie — the value
	// SessionRevocations.IsSessionRevoked compares against a revoke cutoff.
	// omitempty, unlike Groups below: an absent key decodes to the zero
	// time either way (a pre-D16 cookie or a same-version cookie that
	// happened to omit it are indistinguishable, and both SHOULD read as
	// "issued at the beginning of time" — see IsSessionRevoked's doc — so
	// there is no second state worth spending cookie bytes to keep apart).
	IssuedAt time.Time `json:"iat,omitempty"`
	// Groups is the LOGIN-TIME SNAPSHOT of the human's group identity: the
	// normalized union of the ID token's "roles" and "groups" claims (see
	// sessionGroups). It is what a `group`-subject capability grant matches
	// against — folding Entra App Roles in for free, since those arrive on
	// "roles" and are the claim an Entra admin can actually assign.
	//
	// It is a SNAPSHOT and nothing refreshes it: a group added at the IdP
	// reaches Wardyn on the human's next login, and that ceiling is published
	// rather than hidden (grants themselves resolve per request from the DB, so
	// only MEMBERSHIP is stale, never the grant list).
	//
	// NO omitempty, deliberately. nil and empty must stay distinguishable
	// across the cookie round trip: a PRE-0.6 cookie has no groups key at all
	// and decodes to nil ("we never asked"), while a 0.6 login with no groups
	// encodes `[]` and decodes to an empty non-nil slice ("we asked, there were
	// none"). That is the ONLY signal for groups_snapshot_stale — with
	// omitempty both cases would encode identically and a member holding a
	// pre-upgrade cookie would be told their group grants simply do not apply.
	// The cost is 12 bytes of cookie.
	//
	// decodeSession is deliberately NOT widened to require this field: a
	// pre-0.6 cookie stays VALID and nobody is forced to re-login by an
	// upgrade.
	Groups []string `json:"groups"`
}

Session is the content of the wardyn_session cookie, signed and stored client-side. Sub, email, role, and expiry are persisted. Role is always non-empty in a cookie this package issues — CallbackHandler denies the login rather than write one with an undefined role — and decodeSession treats an empty Role (a pre-0.5 cookie, or a corrupt payload) as no session.

type SessionRevocations added in v0.6.0

type SessionRevocations interface {
	// IsSessionRevoked reports whether a session for sub, issued at issuedAt,
	// must be treated as revoked — because of a revoke targeting exactly sub,
	// or the reserved "" (global revoke-all) sub, whichever cutoff is later.
	// issuedAt.IsZero() (a pre-D16 cookie with no iat) is always revoked once
	// ANY matching cutoff exists: an old session predating this feature has
	// no reliable issued-at to compare, so it fails closed the moment revoke
	// is used for the first time against it, rather than staying immune.
	//
	// ponytail: Middleware calls this on every authenticated request with no
	// in-process cache — one extra indexed point-lookup per request against
	// the store wardynd already requires (Postgres). Add a short-TTL
	// in-memory cache keyed on sub if that round trip ever shows up in
	// latency; a POC-scale deployment's request volume doesn't justify one
	// yet, and a cache is one more place revocation could go stale.
	IsSessionRevoked(ctx context.Context, sub string, issuedAt time.Time) (bool, error)
	// RevokeSub invalidates every CURRENT session for sub, effective now —
	// a targeted "log this one person out everywhere".
	RevokeSub(ctx context.Context, sub string) error
	// RevokeAll invalidates every CURRENT session for every principal,
	// effective now — the incident-response "log everyone out" lever.
	RevokeAll(ctx context.Context) error
}

SessionRevocations is the store D16's revoke-a-human-now admin action reads and writes. It is scoped to the STATELESS OIDC human session cookie (Session, above) — a distinct concern from internal/identity's per-run SPIFFE-style identity_revocations denylist, which this package never touches.

Jump to

Keyboard shortcuts

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