oidc

package
v0.7.2 Latest Latest
Warning

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

Go to latest
Published: Sep 13, 2026 License: Apache-2.0 Imports: 23 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.

The wardyn_session cookie codec: the signed payload's format version, and the encode/decode/HMAC trio around it. Split from oidc.go by seam (the file-size gate), exactly as context.go was; no behaviour lives here that oidc.go's package doc does not describe.

Index

Constants

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

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

RoleSecurityAdmin is the THIRD tier (0.7): a principal who governs the deployment's security posture — approvals, audit, permissions/capability grants, governance profiles — WITHOUT the super admin's reach into other people's runs, credentials or host configuration. It is NOT a rung on a monotone ladder below RoleAdmin: internal/api keeps two named predicates (isOperator = super only, isSecurityOperator = super OR security admin) precisely because security_admin ⊄ admin at the role-SNAPSHOT stamp sites (ssh keys, attach tickets), where RoleAdmin means the narrower, concrete thing "this credential reaches runs its holder does not own". A ladder would stamp admin on a security admin's SSH key — an interactive shell in every developer's sandbox. See internal/api/http.go's isSecurityOperator.

MAPPED TIER ONLY. security_admin is reachable through the merged role map (chart WARDYN_OIDC_ROLE_MAP rows plus console-managed rows) and nothing else: there is deliberately no WARDYN_OIDC_SECURITY_EMAILS twin of the operator allowlist, and cmd/wardynd REFUSES it as WARDYN_OIDC_DEFAULT_ROLE (a fallthrough tier is exactly the accident that should never grant it).

View Source
const SessionCodecVersion = 1

SessionCodecVersion is the wardyn_session payload's format version, stamped by encodeSession and REQUIRED to match by decodeSession.

It exists for one field that cannot be tolerantly decoded: GroupsTruncated. A 0.6 cookie carries no such key, so a JSON decode reads it as false — and false is the FAIL-OPEN answer (see the field). A human whose group snapshot was ALREADY truncated at their last login would keep a cookie asserting "complete" and silently shed the group-assigned governance profile walling them, which is exactly the evaporation the bit closes. Nothing in-band distinguishes "0.6 cookie" from "0.7 cookie, not truncated", so the payload gets a version and a mismatch is simply not a session.

THE COST IS ONE EXTRA LOGIN, once, for every human holding a pre-0.7 cookie — folded into the same re-login PF-12 already documents for the pre-0.6 snapshot. A mismatch behaves exactly like the empty-Role case below: Middleware falls through with no principal, the browser is bounced to sign in, and CallbackHandler mints a current cookie. Of the three options — this, tolerant-decode-false, or a permanent second guess — it is the only one that is neither a silent widening nor a permanent one.

The compare is EXACT rather than `<`: under `<`, an OLD binary in a rolling upgrade accepts a NEW cookie and reads its unknown fields as zero, which is the same fail-open this constant exists to prevent. A mixed-version rollout therefore costs logins, not containment.

EXPORTED because a hand-minted payload is otherwise unverifiable: anything that builds a session cookie without going through encodeSession — this module's own integration tests, an embedder's harness — has to stamp the current version or produce a cookie the very next request rejects.

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.

ValidRole reports whether s is a recognized role value. Used to validate WARDYN_OIDC_ROLE_MAP entries (ParseRoleMap), console role-mapping rows (mergeRoleMaps, internal/api's /access write boundary) and WARDYN_OIDC_DEFAULT_ROLE (cmd/wardynd, at boot) — all fail closed on a typo rather than letting a garbage role value silently reach a session cookie.

Roles is the closed set, in rank order, and it is the ONE place the set is written down. ValidRole is implemented over it and the DDL parity guard (internal/db's TestClosedEnumChecksMatchConstants, role_mappings.role) reads it, so a fourth role cannot land on one side alone in EITHER direction. It used to be typed out a third time in that guard, which left it blind in the Go-widens-first direction — and that is the direction of the incident 0053 documents: ValidRole accepted security_admin while 0051's CHECK still refused it, so POST /access/mappings passed validation and then 500'd at the database. The sibling user_drives enums already derive from types.DriveBackends and friends for exactly this reason; this is the surface that did not.

A slice rather than a map so the order is stable for callers that render it; membership goes through ValidRole.

Functions

func ASCIIOnly added in v0.7.0

func ASCIIOnly(s string) bool

ASCIIOnly reports whether s contains no rune above ASCII. It is exported for internal/api, whose console-managed role-map writes must refuse exactly what this package refuses at login — one implementation, so the write surface and the match surface can never disagree about what "ASCII" means.

Case-insensitive matching (ToLower/EqualFold) does Unicode case folding, under which e.g. "roſs" (U+017F) or a KELVIN SIGN "k" (U+212A) MATCHES an ASCII string — the escalating direction (same fold-escalation guard as internal/api's isOperator, applied here to the RoleMap / LegacyAdminEmails match: both are operator-authored ASCII allowlists — WARDYN_OIDC_ROLE_MAP and WARDYN_OIDC_OPERATOR_EMAILS — that a crafted non-ASCII claim must never fold onto).

func CanonicalGroupSubject added in v0.7.0

func CanonicalGroupSubject(s string) (string, bool)

CanonicalGroupSubject canonicalizes an operator-authored group name into the EXACT string a session snapshot carries for a claim of that name, or reports ok=false when NO snapshot can ever carry it (empty/whitespace-only, or any character outside printable ASCII).

It is exported because sessionGroups is the MATCH surface and internal/api's three group-subject WRITE surfaces — a capability grant, a governance assignment, a user-drive grant — must refuse exactly what this one drops. Every one of those subject columns is matched by exact string equality against this snapshot, so a subject this function refuses is a row that can never match anyone: a DENY that protects nothing, or a group tier that counts as "assigned" while resolving to no profile. One implementation, so the two surfaces cannot drift about what a group name is.

THE ASCII GUARD RUNS ON THE RAW VALUE, BEFORE THE FOLD, and the order is the security property — the same ordering ParseRoleMap and deriveRole's own lookup loop already use, for the same reason. strings.ToLower does Unicode case mapping: KELVIN SIGN U+212A folds to ASCII 'k' and U+0130 folds to 'i', so guarding the LOWERED value lets a crafted claim "Kubernetes-admins" (U+212A) fold ONTO the operator-authored ASCII group "kubernetes-admins" and enter the snapshot as it — inheriting every grant and every governance profile bound to the real group. Fold first, guard second, and the guard is decorative.

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 GroupsTruncatedFromContext added in v0.7.0

func GroupsTruncatedFromContext(ctx context.Context) bool

GroupsTruncatedFromContext reports whether the session's group snapshot is PARTIAL — sessionGroups hit the cookie byte cap and dropped entries (see Session.GroupsTruncated).

A caller must treat true exactly as it treats a nil snapshot: the group identity is not answerable, so no group-scoped decision can be made from it. Reading it as "these are all their groups" is the silent tier evaporation PF-26 names.

func NameFromContext added in v0.7.1

func NameFromContext(ctx context.Context) string

NameFromContext returns the display-name ("name") claim of the session Middleware verified, or "" when there is no SSO session or the IdP sent none. Display only — internal/api shows it in the console header and nothing else reads it; the identity every decision is keyed on stays the sub (PrincipalFromContext) and the allowlist identity stays the email.

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 satisfy ValidRole — RoleAdmin, RoleSecurityAdmin or RoleMember. This map is the ONLY way a session reaches RoleSecurityAdmin (see that constant's doc): the chart carries it the moment ValidRole accepts it, with no other boot knob to turn. 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

WARDYN_OIDC_DEFAULT_ROLE validates through validDefaultRole (cmd/wardynd), which is STRICTER than this: it additionally refuses RoleSecurityAdmin.

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)

func (*Authenticator) ChartRoleMap added in v0.7.0

func (a *Authenticator) ChartRoleMap() map[string]string

ChartRoleMap returns a COPY of the boot-time WARDYN_OIDC_ROLE_MAP (Config.RoleMap) entries — the console's People page uses it to show which rows are chart-owned (and therefore always win a collision, see mergeRoleMaps) versus console-managed. A copy, not the live map, so a handler can never mutate boot config through the returned value.

func (*Authenticator) DefaultRole added in v0.7.0

func (a *Authenticator) DefaultRole() string

DefaultRole returns Config.DefaultRole — the role a login falls through to when the merged map (chart or console) is non-empty but nothing in it matched. Empty means "deny", the same as an unset WARDYN_OIDC_DEFAULT_ROLE.

func (*Authenticator) HasEmailDomains added in v0.7.0

func (a *Authenticator) HasEmailDomains() bool

HasEmailDomains reports whether Config.AllowedEmailDomains (WARDYN_OIDC_EMAIL_DOMAINS) is non-empty — the console's People page EMAIL_KEY badge copy depends on this: without a domains list configured, email_verified is not enforced (see oidc.go's boot/callback warnings), a distinction the response otherwise has no way to express.

func (*Authenticator) HasOperatorEmails added in v0.7.0

func (a *Authenticator) HasOperatorEmails() bool

HasOperatorEmails reports whether Config.LegacyAdminEmails (WARDYN_OIDC_OPERATOR_EMAILS) is non-empty — the console's People page needs this to explain why a row's value already resolves to admin independent of anything it manages (see mergeRoleMaps' shadow rule).

func (*Authenticator) IsOperatorEmail added in v0.7.0

func (a *Authenticator) IsOperatorEmail(v string) bool

IsOperatorEmail reports whether v case-insensitively matches an entry on Config.LegacyAdminEmails (WARDYN_OIDC_OPERATOR_EMAILS) — the console's People page write boundary uses this to name the SAME shadow cause mergeRoleMaps' operator-allowlist arm already enforces at login time (collision_operator_allowlist, not a second, possibly-drifting rule): a row a write would add is refused up front with the reason it would be silently shadowed anyway, rather than accepted and only discovered inert the next time someone signs in. Delegates to emailInList, the exact match deriveRole's own allowlist check uses.

func (*Authenticator) Issuer added in v0.7.0

func (a *Authenticator) Issuer() string

Issuer returns the PUBLIC OIDC issuer URL (Config.IssuerURL) — the console's People page derives a human-facing provider name from it (e.g. an Entra tenant issuer -> "Microsoft Entra ID") so the SSO chip names WHERE sign-in comes from, not just THAT it is SSO.

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) MergedMapEmpty added in v0.7.0

func (a *Authenticator) MergedMapEmpty(rows []RoleMapping) bool

MergedMapEmpty reports whether the ACTUAL merged role map (chart plus the given console rows, applying mergeRoleMaps' own collision/shadow rules) is empty — the console's People page needs this instead of a raw row count (len(chart)==0 && len(rows)==0), which diverges from the real map whenever a stored row is shadowed by the chart or the operator allowlist and therefore contributes nothing to what deriveRole actually looks values up in.

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)).

func (*Authenticator) PreviewRole added in v0.7.0

func (a *Authenticator) PreviewRole(ctx context.Context, roles, groups []string, email string) (role string, matched []Match, ok bool, err error)

PreviewRole runs the SAME derivation CallbackHandler would for a login carrying roles/groups/email — including a real read of Config.RoleMappings when wired — so the console's People page can show an operator "who would this row make an admin" without waiting for that person to sign in. err is non-nil only when the store read itself failed (couldn't-check, distinct from ok=false's "checked, and nothing matched"); it never falls back to an env-only preview, for the same fail-closed reason CallbackHandler doesn't.

roles/groups/email need no pre-normalization from the caller: deriveRole does its own lowering at lookup time, and PreviewRole reuses it rather than duplicating that rule here.

func (*Authenticator) PreviewRoleAgainst added in v0.7.0

func (a *Authenticator) PreviewRoleAgainst(rows []RoleMapping, roles, groups []string, email string) (role string, ok bool)

PreviewRoleAgainst is PreviewRole's PURE twin: it runs the identical mergeRoleMaps + deriveRole derivation against a CALLER-SUPPLIED candidate rows slice instead of a real Config.RoleMappings read, so the console's write-boundary lockout guard (POST/DELETE /access/mappings) can ask "would the acting admin still derive admin AFTER this proposed write" by constructing the candidate rows slice itself (the current store list, with the one row added/removed) — without a second store round trip, and without reimplementing merge/derive precedence at the API layer where it could drift from what a REAL login would actually decide.

No store read, no error return: rows is exactly what merged, this is resolution over data already in hand. Compare PreviewRole, which reads Config.RoleMappings itself and can fail on that read (err) — this method never does, because it never reads anything.

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.
	// OPTIONAL (C2): empty registers this as a PUBLIC client — PKCE S256 is
	// sent on every login regardless (LoginHandler), so a public client is
	// not weaker, just a different registration shape some IdPs require
	// (a SPA/native-app client type with no secret at all). See New's
	// AuthStyleInParams wiring for why omitting the field here is not
	// enough on its own.
	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

	// RoleMappings is the console-managed (Getting Started → People) role-
	// mapping store. When wired, CallbackHandler reads it once per login and
	// MERGES its rows with the chart's WARDYN_OIDC_ROLE_MAP (RoleMap, above) —
	// see mergeRoleMaps: the chart wins a duplicate key. A wired store's read
	// error DENIES the login exactly like the no-match case, via the distinct
	// authErrorRoleCheckUnavailable code — it never falls back to the
	// env-only map on error, since that could WIDEN access under
	// DefaultRole=admin. nil (the default) is env-only role derivation,
	// byte-identical to before this field existed — the same
	// optional-Config-field rule Revocations, above, follows.
	RoleMappings RoleMappingSource

	// 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 and ClientSecret (C2: optional, for a public client) are required.

type Match added in v0.7.0

type Match struct {
	// Value is the claim/email value that matched, exactly as the ID token
	// or email carried it — matching itself is case-insensitive (deriveRole
	// lowers before lookup) but Value is not lowered, so a preview can show
	// the human the literal claim that hit. Empty for a MatchSourceDefaultRole
	// match, which was not driven by any claim value at all.
	Value  string      `json:"value"`
	Role   string      `json:"role"`
	Source MatchSource `json:"source"`
}

Match is one claim/email value that contributed to a derived role, with where it came from. deriveRole returns every match it found — not only the one that decided the outcome — so a caller (CallbackHandler's audit log, PreviewRole's console preview) can show WHY a role came out the way it did, not just which role won.

The json tags are POST /access/preview's wire shape: internal/api returns this type directly rather than copying it into a twin that could drift.

type MatchSource added in v0.7.0

type MatchSource string

MatchSource names which config source produced a Match: a chart WARDYN_OIDC_ROLE_MAP entry or a merged-in console row (MatchSourceMapRow — mergeRoleMaps folds both into one map before deriveRole ever runs, so the two are indistinguishable by the time a Match is built), the WARDYN_OIDC_OPERATOR_EMAILS allowlist (MatchSourceOperatorAllowlist), or DefaultRole's no-match fallthrough (MatchSourceDefaultRole).

const (
	MatchSourceMapRow            MatchSource = "map_row"
	MatchSourceOperatorAllowlist MatchSource = "operator_allowlist"
	MatchSourceDefaultRole       MatchSource = "default_role"
)

type RoleMapping added in v0.7.0

type RoleMapping struct {
	Value string
	Role  string
}

RoleMapping is one console-managed (Getting Started → People) value=>role row. Value is EXPECTED already canonical — trimmed, ASCII, lowercase — the API layer that owns writes to this store is responsible for canonicalizing a row before it ever reaches here. mergeRoleMaps no longer trusts that contract blindly, though: a non-canonical row (empty, whitespace, non-ASCII, mixed case) or an invalid Role is DROPPED AND LOGGED (via mergeRoleMaps' shadowed return), never stored verbatim — an empty/ whitespace Value would otherwise match ANY empty/whitespace claim (ASCIIOnly("") is true) and an invalid Role would be a dead key that still changes deriveRole's arm. A canonical, valid row is still stored under Value exactly as given, the same way ParseRoleMap's chart keys are already lowercase by the time deriveRole looks one up.

type RoleMappingSource added in v0.7.0

type RoleMappingSource interface {
	// ListRoleMappings returns every console-managed row. A non-nil error
	// DENIES the login in progress (CallbackHandler fails closed rather than
	// falling back to env-only, which could WIDEN access under
	// WARDYN_OIDC_DEFAULT_ROLE=admin) — this is not a best-effort read.
	ListRoleMappings(ctx context.Context) ([]RoleMapping, error)
}

RoleMappingSource is the console's store-backed role-mapping source, read once per login by CallbackHandler and merged with the chart's WARDYN_OIDC_ROLE_MAP (Config.RoleMap) — see mergeRoleMaps. Config.RoleMappings wires it in; nil (the default) disables the console source entirely, the same additive-optional-Config-field rule SessionRevocations follows.

type Session

type Session struct {
	// V is the payload format version (SessionCodecVersion), stamped by
	// encodeSession so no caller has to remember it. A payload carrying any
	// other value — a pre-0.7 cookie's absent key decodes to 0 — is not a
	// session.
	V     int    `json:"v"`
	Sub   string `json:"sub"`
	Email string `json:"email"`
	// Name is the IdP's display-name claim ("name"), carried for the console
	// header ONLY: it gates nothing, keys nothing, and is never logged (the
	// same hygiene Email already keeps). omitempty and no codec bump on
	// purpose — an absent key decodes to "" and the header falls back to the
	// email, which is fail-safe, so a pre-0.7.1 cookie stays a session.
	Name   string    `json:"name,omitempty"`
	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 — but it
	// does require the codec VERSION, and that is the fact that decides who is
	// signed out. `sess.V != SessionCodecVersion` (session_codec.go) is an exact
	// compare, and a pre-0.7 cookie carries no "v" key at all, so it decodes to
	// 0 and is refused outright: upgrading to 0.7 signs every SSO human out
	// ONCE, on their next request. This comment used to assert the opposite —
	// that an older cookie survived the upgrade and nobody was forced to sign in
	// again — and three shipped documents were written from it.
	//
	// So the nil-vs-empty signal above discriminates within ONE codec version:
	// a session this binary wrote either has groups or has `[]`. The pre-0.6
	// no-groups-key case it was designed for cannot reach the decoder any more —
	// keeping the distinction is what lets the NEXT codec-compatible change
	// carry a "we never asked" session without inventing a second flag.
	Groups []string `json:"groups"`
	// GroupsTruncated reports that Groups is a PARTIAL snapshot — sessionGroups
	// hit the maxSessionGroupsBytes cap and dropped the alphabetically-last
	// entries (PF-26).
	//
	// It is an AUTHORIZATION INPUT, not telemetry. A member in enough groups
	// loses the very group whose governance assignment walls them, and without
	// this bit the ceiling resolver would answer "no group matched" and hand
	// them the deployment ceiling — a silent widening with no refusal and no
	// audit line. internal/api therefore treats a truncated snapshot exactly as
	// it treats a missing one.
	//
	// omitempty is SAFE here only because SessionCodecVersion exists: false and
	// "key absent" mean the same thing within one codec version, and a
	// pre-0.7 cookie — where they would NOT mean the same thing — never
	// decodes at all.
	GroupsTruncated bool `json:"groups_truncated,omitempty"`
}

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 this human, issued at
	// issuedAt, must be treated as revoked — because of a revoke targeting
	// EITHER of their identities, 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.
	//
	// BOTH IDENTITIES, for the reason every other user-addressing surface in
	// the product already takes: an admin naming a human "shouldn't have to
	// guess which the IdP made authoritative" (docs/OPERATIONS.md on a
	// subject_type=user capability grant, which matches the sub OR the email;
	// capabilitySubjects in internal/api offers both the same way). Revocation
	// was the one such surface keyed on sub ALONE, and on any IdP where the two
	// differ — Entra, where sub is an opaque per-app identifier — a revoke
	// naming the email stamped a cutoff that matched nobody: 204, an audited
	// success, and a compromised human still signed in. sub is compared
	// EXACTLY (an OIDC sub is opaque and case-sensitive); email is compared
	// case-insensitively, since that is how humans type one.
	//
	// ponytail: Middleware calls this on every authenticated request with no
	// in-process cache — one extra lookup per request against the store
	// wardynd already requires (Postgres), over a table holding one row per
	// revoked principal plus the global one. 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, email string, issuedAt time.Time) (bool, error)
	// RevokeSub invalidates every CURRENT session for sub, effective now —
	// a targeted "log this one person out everywhere". sub is whichever
	// identity the caller named; IsSessionRevoked matches it against both.
	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