oidc

package
v0.8.2 Latest Latest
Warning

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

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

Documentation

Overview

derive.go holds the IDENTITY-DERIVATION seam: everything that turns an ID token's claims into WHO this human is (deriveRole, Config.RoleMap, the legacy operator allowlist) and their group snapshot (sessionGroups). Pure claims-in, identity-out — nothing here touches HTTP, cookies, or the OAuth2 exchange, so it is unit-testable without a signed token or a fake IdP.

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

CSRF is two-layered: a random 128-bit state value in an HttpOnly SameSite=Lax cookie, compared to the IdP callback; and SameSite=Lax on every session cookie. SameSite doesn't stop a same-site host from PLANTING a cookie, so under SecureCookies every cookie carries the __Host- prefix — the browser refuses a Domain= one, and the unprefixed name is never read. A PKCE code_challenge (S256) is verified by the token endpoint, holding even if the state check is bypassed (a mix-up attack). Known gap: the nonce is verified in the ID token but not bound to the device (mitigated by state+PKCE).

Sessions live entirely in a signed HttpOnly SameSite=Lax cookie: sub, email, role, expiry, HMAC-SHA256 signed with the key passed to New, never logged and never leaving the process. A cookie with no role decodes as NO session, forcing a fresh re-derivation rather than granting an undefined role.

Middleware accepts a valid session cookie (sets HumanPrincipal on the context) or falls through to the next handler (the integrator's own bearer-auth path).

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.

user_type_derive.go is the user-type half of role derivation. A role-map value names a TARGET: one of the two admin tiers, or a user type id (the user tier on that type). deriveRole picks the tier by rank; pickUserType picks the highest-priority CUSTOM type among the matches, with the built-in "standard" type as the tie floor that never wins and never ties.

Index

Constants

View Source
const (
	// SubjectTokenInvalid: token failed to verify (signature, issuer, expiry, alg=none included) or its claims didn't decode.
	SubjectTokenInvalid = "subject_token_invalid"
	// SubjectTokenAudience: token was neither issued TO the portal nor FOR this deployment at the portal's request.
	SubjectTokenAudience = "subject_token_audience"
	// SubjectTokenNoIssuedAt: token carries no iat, so the session cutoff (SessionRevocations) has nothing to compare against.
	SubjectTokenNoIssuedAt = "subject_token_no_iat"
)

Subject-token refusals VerifySubjectToken names itself; a refusal from shared admission (reserved subject, email-domain gate, role/user-type derivation) uses that step's own auth_error code instead.

View Source
const (
	RoleAdmin         = "admin"
	RoleSecurityAdmin = "security_admin"
	RoleUser          = "user"
)

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

RoleSecurityAdmin is the THIRD tier: governs security posture (approvals, audit, permission grants) WITHOUT the super admin's reach into other people's runs or credentials. NOT a rung below RoleAdmin — internal/api keeps two predicates (isOperator = super only, isSecurityOperator = super OR security admin) since security_admin ⊄ admin at the role-snapshot stamp sites. MAPPED TIER ONLY: reachable only through the merged role map, never an email-allowlist twin, and cmd/wardynd REFUSES it as a fallthrough default.

View Source
const (
	// DenialNoRole: the map is non-empty, nothing matched, no default role set.
	DenialNoRole = "no_role"
	// DenialUserTypeAmbiguous: two or more custom user types tie at top priority.
	DenialUserTypeAmbiguous = "user_type_ambiguous"
	// DenialUserTypeUnknown: a matched value names a user type that doesn't exist.
	DenialUserTypeUnknown = "user_type_unknown"
)

Why a derivation refused a sign-in. Each is also the auth_error code the callback redirects with, so the sign-in screen and the audit row name the same cause.

View Source
const DenialReservedPrincipal = "reserved_principal"

DenialReservedPrincipal reports a sign-in refused because a subject names an identity that isn't a person. Not an auth_error code — the browser sees the generic authErrorSignInRefused, since nothing the user can do clears it.

View Source
const LegacyRoleMember = "member"

LegacyRoleMember is the pre-0.8 name of RoleUser, still accepted (with a boot WARN) as the user tier on the built-in "standard" type. Never stored, never a session role. ponytail: removed in 0.9.

View Source
const SessionCodecVersion = 2

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 — 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 in. Nothing in-band distinguishes "0.6 cookie" from "0.7 cookie, not truncated", so the payload carries a version and a mismatch is simply not a session.

THE COST IS ONE EXTRA LOGIN, once, per 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 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 alone is neither a silent widening nor a permanent one.

Version 2 (0.8) renamed the non-admin tier "member" to "user" and added Session.UserType: one more re-login, and no cookie carrying the old tier word, or no type, is ever half-trusted.

Compared EXACT, not `<`: under `<`, an old binary in a rolling upgrade accepts a new cookie and reads its unknown fields as zero — the same fail-open this constant prevents. A mixed-version rollout costs logins, not containment.

EXPORTED because a hand-minted payload is otherwise unverifiable: anything building a session cookie outside encodeSession (this module's own tests, an embedder's harness) must 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 when the cookie is present but tampered, malformed, or mis-keyed.

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

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

Roles is the closed set of recognized role values, in rank order, pinned against the DDL parity guard.

Functions

func ASCIIOnly added in v0.7.0

func ASCIIOnly(s string) bool

ASCIIOnly reports whether s contains no rune above ASCII. Exported so internal/api's console-managed role-map writes refuse exactly what this package refuses at login.

SECURITY: case-insensitive matching (ToLower/EqualFold) does Unicode case folding, under which a KELVIN SIGN "k" (U+212A) MATCHES ASCII "k" — so guarding the LOWERED value would let a crafted non-ASCII claim fold onto a real operator-authored ASCII entry (RoleMap, LegacyAdminEmails, a group name) and inherit everything bound to it. The guard must run on the RAW value, before the fold, everywhere this package or internal/api compares an ASCII allowlist case-insensitively.

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, or reports ok=false when NO snapshot can ever carry it. Exported because sessionGroups is the MATCH surface and internal/api's group-subject WRITE surfaces must refuse exactly what this drops — one implementation, so write and match can never disagree.

SECURITY: the ASCII guard runs on the RAW value, BEFORE the fold — see ASCIIOnly's doc for why (case folding can turn a non-ASCII claim into a real operator-authored ASCII name).

func CookieName added in v0.8.0

func CookieName(secure bool, name string) string

CookieName is the name a console cookie is written and read under. With secure cookies on it carries the __Host- prefix, so the browser holds it host-only: no sibling host under the console's registrable domain (a relayed sandbox page among them) can plant a Domain= cookie the console would read as its session or its sign-in state. The plain name is never read in that posture, not even as a fallback, because the plain name is exactly what a sibling can plant. Over plain HTTP (secure off) a browser refuses every __Host- cookie, so the plain name is the only one that works.

func CookiePath added in v0.8.0

func CookiePath(secure bool, basePath string) string

CookiePath is the Path of every console cookie. __Host- requires Path=/, so with secure cookies on it is "/" even under WARDYN_BASE_PATH: a neighbouring application on the console's own host then receives the cookies too, which grants it nothing it lacks already, since it shares the console's origin and can call the API with them attached. Over plain HTTP the cookies stay scoped to the base path.

func EmailFromContext added in v0.5.0

func EmailFromContext(ctx context.Context) string

EmailFromContext returns the email claim of the verified session, or "" when absent (possible when AllowedEmailDomains is empty). internal/api resolves the viewer/operator role from this, since the opaque "sub" can't be matched against an admin-writable allowlist.

func ExpiryFromContext added in v0.6.0

func ExpiryFromContext(ctx context.Context) time.Time

ExpiryFromContext returns when the verified session expires, or the zero time when absent. No refresh — the session dies outright — so the console surfaces it as an advance warning rather than a surprise 401.

func GroupsFromContext added in v0.6.0

func GroupsFromContext(ctx context.Context) []string

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

NIL AND EMPTY MEAN DIFFERENT THINGS: empty non-nil means the IdP sent no usable group identity, so grants genuinely don't apply. Nil means no SSO session, or a pre-0.6 cookie predating this field — internal/api surfaces that as groups_snapshot_stale rather than "no groups".

func GroupsTruncatedFromContext added in v0.7.0

func GroupsTruncatedFromContext(ctx context.Context) bool

GroupsTruncatedFromContext reports whether the group snapshot is PARTIAL (hit the cookie byte cap). A caller must treat true exactly like a nil snapshot — no group-scoped decision can be made from it.

func LegacyRoleMemberWarning added in v0.8.0

func LegacyRoleMemberWarning(variable, entry string) string

LegacyRoleMemberWarning is the boot WARN for one aliased "member" value. variable names the setting; entry is the role-map pair as written, or "" for WARDYN_OIDC_DEFAULT_ROLE.

func MemberModeFromContext added in v0.7.4

func MemberModeFromContext(ctx context.Context) bool

MemberModeFromContext reports whether this session is "view as member" — an admin treated as a member for the rest of the session. RoleFromContext already answers member when true, so authorization needs nothing from this; it exists only for the console banner and the credential doors (API-token mint refuses; SSH keys are accepted capped).

func MemberPreviewNoCredential added in v0.7.5

func MemberPreviewNoCredential(ctx context.Context) bool

MemberPreviewNoCredential reports whether this session is in the NO-CREDENTIAL posture of member mode — "view as a new member who has not signed in". Published as `MemberMode && MemberModeNoCredential`, so it implies MemberModeFromContext.

Authorizes and clamps nothing: it only moves whether a per-user model-credential read answers "absent" (previewHidesOwnCredential), letting every already-fail-closed path reach the not-signed-in state.

func NameFromContext added in v0.7.1

func NameFromContext(ctx context.Context) string

NameFromContext returns the display-name claim, or "" when absent. Display only — every decision keys on the sub/email, never this.

func ParseRoleMap added in v0.5.0

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

ParseRoleMap parses WARDYN_OIDC_ROLE_MAP: comma-separated "value=role" pairs, matched case-insensitively against an ID token's roles/groups/email. Empty/blank input returns a nil map (derivation disabled) and no error; non-empty input yielding no usable entry is an error, never a silent nil — nil means "everyone is admin".

func PrincipalFromContext

func PrincipalFromContext(ctx context.Context) string

PrincipalFromContext returns the human principal set by Middleware (the OIDC "sub" claim), or "" if no SSO session is present. internal/api's principalFromRequest calls this first, falling back to the admin-token path.

func RoleFromContext added in v0.5.0

func RoleFromContext(ctx context.Context) string

RoleFromContext returns the EFFECTIVE Wardyn role, or "" when there is no SSO session. Effective, not stamped: in "view as member" mode this always answers RoleUser, clamped once in contextWithPrincipal — the stamped role is never visible outside this package, which only derives/carries the role; internal/api enforces it.

func SessionRejectedFromContext added in v0.6.0

func SessionRejectedFromContext(ctx context.Context) string

SessionRejectedFromContext returns why Middleware rejected a presented session cookie: "invalid_session", "expired_session", "revoked_session", or "session_revocation_unavailable" (fail-closed) when Revocations is wired; "" when no cookie was presented or it decoded fine.

func SplitMappingTarget added in v0.8.0

func SplitMappingTarget(v string) (role, userType string, ok bool)

SplitMappingTarget splits a role-map value into the tier and user type it names: admin/security_admin carry no type, "user" maps to the built-in "standard" type, and any other well-formed, unreserved id is the user tier on that type. ok is false for anything else, "member" included.

func StampedUserTypeFromContext added in v0.8.0

func StampedUserTypeFromContext(ctx context.Context) string

StampedUserTypeFromContext returns the user type stamped at sign-in, which the user view does not change: the view's fallback when no chosen type is remembered. "" when there is no SSO session.

func UserTypeFromContext added in v0.8.0

func UserTypeFromContext(ctx context.Context) string

UserTypeFromContext returns the resolved user type id, or "" when there is no SSO session: the sign-in type, or the user-view type while that's on.

func UserTypeIDReserved added in v0.8.0

func UserTypeIDReserved(id string) bool

UserTypeIDReserved reports whether id is a word a role-map value already means: the three tiers, the retired "member", and "denied".

func UserTypeIDWellFormed added in v0.8.0

func UserTypeIDWellFormed(id string) bool

UserTypeIDWellFormed reports whether id has the shape the user_types table accepts: lowercase ASCII words joined by single hyphens, at most 63 bytes.

func UserViewDroppedFromContext added in v0.8.0

func UserViewDroppedFromContext(ctx context.Context) string

UserViewDroppedFromContext returns the type whose deletion turned this session's user view off (Session.UserViewDropped), or "".

func UserViewDroppedNameFromContext added in v0.8.0

func UserViewDroppedNameFromContext(ctx context.Context) string

UserViewDroppedNameFromContext returns that same type's cached display name (Session.UserViewDroppedName), or "" when there is none — either no drop is recorded, or the cookie predates this field. Pairs with UserViewDroppedFromContext's id.

func ValidMappingTarget added in v0.8.0

func ValidMappingTarget(v string) bool

ValidMappingTarget reports whether v may be a role-map value or WARDYN_OIDC_DEFAULT_ROLE: a tier or a well-formed user type id. Whether the type EXISTS is a store question, answered at sign-in.

func ValidRole added in v0.5.0

func ValidRole(s string) bool

ValidRole reports whether s is a recognized role value; all callers fail closed on a typo. WARDYN_OIDC_DEFAULT_ROLE validates through validDefaultRole (cmd/wardynd), 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 signs session cookies and is never logged.

func (*Authenticator) AttachLoginGrantSink added in v0.7.10

func (a *Authenticator) AttachLoginGrantSink(sink LoginGrantSink)

AttachLoginGrantSink joins the sink to this Authenticator, called once at boot before anything is served; a nil sink detaches, restoring the unwidened login.

func (*Authenticator) AttachPersonKeying added in v0.8.2

func (a *Authenticator) AttachPersonKeying(k PersonKeying)

AttachPersonKeying joins k to this Authenticator; nil detaches.

func (*Authenticator) CallbackHandler

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

func (*Authenticator) CallbackHandlerWithDenials added in v0.8.0

func (a *Authenticator) CallbackHandlerWithDenials(reserved func(sub string) bool, onDenied func(r *http.Request, reason string)) http.HandlerFunc

CallbackHandlerWithDenials is CallbackHandler with a reserved-subject check (DenialReservedPrincipal) and sign-in denials (including DenialUserTypeAmbiguous, DenialUserTypeUnknown) reported to onDenied so internal/api can audit them as auth.fail. This package stays store- and audit-agnostic; internal/api decides which principals are reserved.

func (*Authenticator) ChartRoleMap added in v0.7.0

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

ChartRoleMap returns a COPY of the boot-time role map so a handler can never mutate boot config.

func (*Authenticator) ClientID added in v0.8.0

func (a *Authenticator) ClientID() string

ClientID is this deployment's own client id at the identity provider — what a portal's access token carries in aud when it was requested for Wardyn.

func (*Authenticator) DefaultRole added in v0.7.0

func (a *Authenticator) DefaultRole() string

DefaultRole returns Config.DefaultRole, the fallthrough when the merged map matched nothing. Empty means "deny".

func (*Authenticator) DefaultRoleIsAdmin added in v0.8.0

func (a *Authenticator) DefaultRoleIsAdmin() bool

DefaultRoleIsAdmin reports whether Config.DefaultRole resolves to admin, kept here so this package, not a caller, owns what counts as "the admin role".

func (*Authenticator) DefaultRoleOutcome added in v0.8.0

func (a *Authenticator) DefaultRoleOutcome(userTypes []types.UserType) (role, userType string, ok bool)

DefaultRoleOutcome reports what an UNMATCHED sign-in would actually derive from Config.DefaultRole, validated the same way deriveRole validates a matched default, so a display surface never claims a target a real sign-in would refuse. ok is false when DefaultRole is unset/malformed or names a type the store lacks, except on the admin tier, which falls to "standard" instead, matching deriveRole.

func (*Authenticator) DropUserView added in v0.8.0

func (a *Authenticator) DropUserView(w http.ResponseWriter, r *http.Request) (context.Context, error)

DropUserView turns the user view off when its type no longer exists: the cookie re-signs with view bits cleared and the type recorded in UserViewDropped (so GET /me can say why), returning the context republished from that session with the admin's real tier. Only GET /me may serve its own request from that context — every other request meeting a deleted type is refused, not re-evaluated.

func (*Authenticator) HasEmailDomains added in v0.7.0

func (a *Authenticator) HasEmailDomains() bool

HasEmailDomains reports whether Config.AllowedEmailDomains is non-empty — without a domains list, email_verified is not enforced, a distinction the People page badge otherwise can't express.

func (*Authenticator) HasOperatorEmails added in v0.7.0

func (a *Authenticator) HasOperatorEmails() bool

HasOperatorEmails reports whether Config.LegacyAdminEmails is non-empty — the People page needs this to explain why a row already resolves to admin independent of anything it manages.

func (*Authenticator) IsOperatorEmail added in v0.7.0

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

IsOperatorEmail reports whether v matches an entry on Config.LegacyAdminEmails — the People page write boundary uses this to refuse a row up front rather than discover it inert at sign-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, from which the People page derives a human-facing provider name (e.g. an Entra tenant -> "Microsoft Entra ID") for the SSO chip.

func (*Authenticator) KeysPeopleByObjectID added in v0.8.2

func (a *Authenticator) KeysPeopleByObjectID() bool

KeysPeopleByObjectID reports whether a person may be set up by Entra object id here: the issuer is Entra ID.

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 the console root.

func (*Authenticator) MergedMapEmpty added in v0.7.0

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

MergedMapEmpty reports whether the ACTUAL merged role map is empty — needed instead of a raw row count, which diverges whenever a stored row is shadowed and contributes nothing.

func (*Authenticator) Middleware

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

Middleware sets HumanPrincipal on the context for a valid session cookie, or falls through to next without one so the integrator's own bearer-auth path can handle it — composed as oidc.Middleware(adminAuth(handler)). A cookie presented but rejected (tampered/expired) has its reason ride the context (SessionRejectedFromContext) so the eventual 401 can name it.

func (*Authenticator) PreviewRole added in v0.7.0

func (a *Authenticator) PreviewRole(ctx context.Context, roles, groups []string, email string) (Derivation, error)

PreviewRole runs the SAME derivation CallbackHandler would, so the console's People page can show "who would this row make an admin" before that person signs in. err is non-nil only when the store read failed; it never falls back to an env-only preview (same fail-closed reason as login).

func (*Authenticator) PreviewRoleAgainst added in v0.7.0

func (a *Authenticator) PreviewRoleAgainst(rows []RoleMapping, userTypes []types.UserType, roles, groups []string, email string) Derivation

PreviewRoleAgainst is PreviewRole's PURE twin: runs the identical mergeRoleMaps + deriveRole derivation against a CALLER-SUPPLIED candidate rows slice, so the write-boundary lockout guard can ask "would the acting admin still derive admin AFTER this proposed write" without a second store round trip. No store read, no error return — compare PreviewRole, which reads Config itself.

func (*Authenticator) RecordAttach added in v0.8.2

func (a *Authenticator) RecordAttach(r *http.Request, sess Session)

RecordAttach reports sess's attach to PersonKeying.Attached when admission resolved it to a person set up by object id. Each door calls it once it has no refusal left to make, so a refused sign-in or exchange records none.

func (*Authenticator) SetUserView added in v0.8.0

func (a *Authenticator) SetUserView(w http.ResponseWriter, r *http.Request, on bool, typeID, typeName string, noCredential bool) (stampedRole string, err error)

SetUserView flips this request's session into or out of the user view ("view as member" until 0.8), writes the re-encoded cookie to w, and returns the session's STAMPED role — what the view pauses. typeID is Session.UserViewType, pre-validated by the caller; any switch clears UserViewDropped. The stamped role is returned because RoleFromContext is already clamped to member while the mode is on. Decodes the CURRENT cookie, not a Session (context values would drop IssuedAt); a bad cookie surfaces decodeSession's own error as a 4xx.

ONE exception: a STAMPED member turning the mode ON gets no cookie at all — downstream readers key on the flag, not the tier (a banner this human can't back, a refused token mint, capped SSH keys). OFF always re-signs and never CLEARS the session, so toggling off leaves the human signed in. noCredential is "view as a member who hasn't signed in", stored as `on && noCredential` so OFF clears it by construction.

typeName caches typeID's display name (Session.UserViewTypeName) purely so a LATER DropUserView can still name the type once its row is gone — see that field's own doc. The caller resolves it; this function never reads the store.

func (*Authenticator) VerifySubjectToken added in v0.8.0

func (a *Authenticator) VerifySubjectToken(r *http.Request, raw, portalClientID string, reserved func(string) bool) (Session, string)

VerifySubjectToken verifies raw against this deployment's issuer/key set and admits its subject through the SAME decision the sign-in callback makes, so a portal can never act for someone who couldn't sign in, nor get a role or group snapshot a sign-in wouldn't derive.

The audience rule is the one thing sign-in doesn't share: the token must be either an access token issued for Wardyn (aud holds ClientID) at the portal's request (azp is portalClientID), or issued to the portal itself (aud is exactly portalClientID; azp, if present, agrees). A token for any other client is refused, and a portal registered under Wardyn's own client id matches nothing — else every Wardyn ID token would double as a subject token for it.

The returned Session carries the token's iat as IssuedAt (compared against the session cutoff) and exp as Expiry. denied is non-empty on any refusal, with a zero Session. The caller records an attach to a person set up by object id with RecordAttach once it has admitted the exchange.

type Config

type Config struct {
	// IssuerURL is the OIDC provider's public issuer — the browser redirect target and "iss" claim.
	IssuerURL string
	// InternalIssuerURL, when set, is where wardynd itself reaches the IdP for server-side calls,
	// solving split-horizon where the browser and control plane see different hostnames.
	InternalIssuerURL string
	// ClientID is the OAuth2 client identifier registered with the IdP.
	ClientID string
	// ClientSecret is the OAuth2 client secret; never logged. Optional: empty registers a public
	// client (PKCE S256 is still sent on every login), not a weaker registration.
	ClientSecret string
	// RedirectURL is the callback URL registered with the IdP; must be <wardynd-base>/auth/callback.
	RedirectURL string
	// BasePath is WARDYN_BASE_PATH ("" = host root): prefix of every console redirect and cookie Path.
	BasePath string
	// AllowedEmailDomains, when non-empty, restricts login to emails whose domain exactly matches
	// one of the listed values case-insensitively, AND requires email_verified=true. Entra tokens
	// typically omit email_verified, fail-closing every Entra login — prefer Entra App Roles instead.
	AllowedEmailDomains []string
	// ExtraScopes is WARDYN_OIDC_EXTRA_SCOPES (CSV), appended to the fixed "openid profile email"
	// request. Validated at boot against discovery scopes_supported, so an unadvertised scope
	// refuses boot by name instead of locking every human out at login with invalid_scope.
	ExtraScopes []string
	// RoleMap maps a case-insensitive claim/email value to RoleAdmin or RoleUser (WARDYN_OIDC_ROLE_MAP,
	// parsed by ParseRoleMap). Any RoleAdmin match wins. Empty (default) keeps every human RoleAdmin.
	RoleMap map[string]string
	// DefaultRole is the role a human gets when RoleMap is non-empty but nothing matched: RoleAdmin,
	// RoleUser, or "" (default) to DENY the login.
	DefaultRole string
	// LegacyAdminEmails (WARDYN_OIDC_OPERATOR_EMAILS) is the sole admin-tier source when no RoleMap
	// is set; an email on it always derives RoleAdmin, top precedence like a RoleMap match.
	LegacyAdminEmails []string
	// SecureCookies, when true, marks every cookie Secure (HTTPS only) and adds __Host-/Path=/. MUST
	// be true exactly when the connection is TLS-protected; false is required for plain-HTTP demo
	// deployments, since a Secure cookie is never sent over plain HTTP and login would silently break.
	SecureCookies bool
	// Revocations is the pg-backed revoke-a-human-now lever: sessions are stateless signed cookies
	// with nothing to delete, so this tracks a per-principal (and global) cutoff time and Middleware
	// treats any session issued at-or-before it as invalid. nil (default) means never checked.
	Revocations SessionRevocations

	// RoleMappings is the console-managed role-mapping store, merged with RoleMap on each login
	// (RoleMap wins a duplicate key). A read error DENIES the login rather than falling back to the
	// env-only map, which could widen access under DefaultRole=admin. nil is env-only derivation.
	RoleMappings RoleMappingSource

	// UserTypes is the store of user types, read once per login beside RoleMappings. A read error
	// denies the login. nil means only "standard" exists.
	UserTypes UserTypeSource

	// OnLogin, when set, is called synchronously after an APPROVED login with the derived role/user
	// type and group snapshot. A failure inside it must never fail the login; nil is a no-op.
	OnLogin func(ctx context.Context, sub, role, userType string, groups []string, groupsTruncated bool)
}

Config holds the OIDC client configuration. All fields except AllowedEmailDomains and ClientSecret (C2: optional, for a public client) are required.

type Derivation added in v0.8.0

type Derivation struct {
	Role     string
	UserType string
	Matches  []Match
	Denial   string
	// Tied is the custom types that tied at the top priority, sorted.
	Tied []string
	// Unknown is the named types that do not exist, sorted.
	Unknown []string
}

Derivation is what a sign-in derives: the tier, the user type, and every value that contributed. When Denial is set the sign-in is refused and Role and UserType are empty; Matches, Tied and Unknown still say why, which is what the People page's preview renders.

func (Derivation) OK added in v0.8.0

func (d Derivation) OK() bool

OK reports whether the derivation lets the sign-in through. The zero value (what an errored preview returns) is not OK.

type LoginGrant added in v0.7.10

type LoginGrant struct {
	// RefreshToken is the durable half of the grant; empty if the provider
	// issued none (`offline_access` not asked for, or not granted).
	RefreshToken string
	// Scope is the GRANTED scope string, verbatim — may differ from what was
	// requested; only the sink knows what it was hoping for.
	Scope string
	// Expiry is the access token's expiry, as reported by the exchange.
	Expiry time.Time
}

LoginGrant is what the token exchange returned besides identity. It carries no access token (stale within minutes) — the refresh token is the durable half, and only the party that stores it may redeem it.

type LoginGrantSink added in v0.7.10

type LoginGrantSink interface {
	// LoginScopes returns extra scopes to request, or nil. Called once per
	// authorization request.
	LoginScopes(ctx context.Context) []string
	// CaptureLoginGrant is offered the grant once login is APPROVED — past
	// every denial branch, so a refused login never yields a credential.
	// subject is the verified id_token subject the session is about to be
	// issued for.
	CaptureLoginGrant(ctx context.Context, subject string, grant LoginGrant)
}

LoginGrantSink is the seam: what to ADD to the authorization request, and what to DO with the grant. Both are best-effort by contract — a storage failure in CaptureLoginGrant must not undo an already-decided login. An implementation MUST NOT panic or block for long: it runs inside a browser's login redirect, with a human waiting.

type Match added in v0.7.0

type Match struct {
	// Value is the claim/email value that matched, exactly as carried. Empty for a default match.
	Value string `json:"value"`
	// Role is the tier this value maps to; UserType is set only when Role is the user tier.
	Role     string      `json:"role"`
	UserType string      `json:"user_type,omitempty"`
	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 found, not only the winner, so a caller can show WHY. json tags are POST /access/preview's wire shape.

type MatchSource added in v0.7.0

type MatchSource string

MatchSource names which config source produced a Match: a role-map row, the operator-email allowlist, or DefaultRole's fallthrough.

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

type PersonKeying added in v0.8.2

type PersonKeying interface {
	// PrincipalFor is the principal s signs in as: the person keyed by exactly s.Issuer,
	// s.TenantID and s.ObjectID if there is one and s.Sub names no one yet, else s.Sub. refused is true when s.Sub itself
	// names a person keyed by an object id this token does not carry. An email never enters it.
	PrincipalFor(ctx context.Context, s Subject) (principal string, refused bool, err error)
	// Attached records an admitted sign-in that resolved to such a person, naming both parties.
	Attached(r *http.Request, s Subject, principal string)
}

PersonKeying resolves an Entra ID sign-in to a person an admin set up by object id before their first sign-in (#1195). Entra's `sub` is pairwise — per application registration, unknowable until that first sign-in — so such a person is keyed by (issuer, tid, oid) instead. Consulted only when KeysPeopleByObjectID; every other issuer signs in as its `sub`, exactly as before.

type RoleMapping added in v0.7.0

type RoleMapping struct {
	Value    string
	Role     string
	UserType string
}

RoleMapping is one console-managed value=>role row. Value is EXPECTED already canonical (the API write boundary owns that); mergeRoleMaps does not trust it blindly — a non-canonical row or invalid Role is DROPPED AND LOGGED rather than stored verbatim.

type RoleMappingSource added in v0.7.0

type RoleMappingSource interface {
	// ListRoleMappings: a non-nil error DENIES the login rather than falling back to env-only,
	// which could WIDEN access under WARDYN_OIDC_DEFAULT_ROLE=admin.
	ListRoleMappings(ctx context.Context) ([]RoleMapping, error)
}

RoleMappingSource is the console's store-backed role-mapping source, read once per login and merged with the chart's WARDYN_OIDC_ROLE_MAP. nil (the default) disables the console source.

type Session

type Session struct {
	// V is the payload format version (SessionCodecVersion); any other value, including an absent
	// key, is not a session.
	V     int    `json:"v"`
	Sub   string `json:"sub"`
	Email string `json:"email"`
	// Name is the IdP's display-name claim, for the console header ONLY — gates nothing, keys
	// nothing, never logged. omitempty: an absent key falls back to email (fail-safe).
	Name   string    `json:"name,omitempty"`
	Role   string    `json:"role"`
	Expiry time.Time `json:"expiry"`
	// UserType is stamped beside the tier at sign-in; everyone carries "standard" until the role
	// map names custom ones.
	UserType string `json:"ut"`
	// IssuedAt is when CallbackHandler minted this cookie, compared against a revoke cutoff by
	// IsSessionRevoked. omitempty: an absent key decodes to the zero time either way.
	IssuedAt time.Time `json:"iat,omitempty"`
	// Groups is the LOGIN-TIME SNAPSHOT of the human's group identity (normalized union of the ID
	// token's "roles"/"groups" claims), against which a `group`-subject grant matches. Nothing
	// refreshes it — a group added at the IdP reaches Wardyn on the next login. NO omitempty,
	// deliberately: nil vs empty is the only signal for groups_snapshot_stale (nil = "never asked").
	Groups []string `json:"groups"`
	// GroupsTruncated reports Groups is a PARTIAL snapshot (hit maxSessionGroupsBytes). AUTHORIZATION
	// INPUT, not telemetry: without it the ceiling resolver would silently widen instead of refusing.
	GroupsTruncated bool `json:"groups_truncated,omitempty"`
	// MemberMode is the "view as member" flag. CLAMP INPUT only — Role stays the sign-in-derived
	// truth; the effective role is only ever LOWERED, keeping a cookie flag from being a privilege
	// primitive. omitempty: absent decodes to false (fail-safe); a rolling k8s upgrade can let an
	// old replica answer as admin for that window (documented ceiling, not proof of refusal).
	MemberMode bool `json:"mm,omitempty"`
	// MemberModeNoCredential is the mode's second posture: "view as a NEW member, not yet signed
	// in". Clamps the ROLE only — Sub (ownership, secrets, audit rows) stays the admin's own.
	MemberModeNoCredential bool `json:"mmnc,omitempty"`
	// UserViewType is the user type the user view looks through; the view always clamps the tier
	// to user regardless.
	UserViewType string `json:"uvt,omitempty"`
	// UserViewTypeName caches that type's display name from the instant the
	// view entered it (SetUserView). Meaningful only with MemberMode, same as
	// UserViewType. It exists ONLY so DropUserView can still name the type in
	// UserViewDroppedName after its row is gone — the row's deletion is WHY
	// the drop fires, so nothing can look the name up again at that point.
	UserViewTypeName string `json:"uvtn,omitempty"`
	// UserViewDropped names the type whose deletion turned the view off, so GET /me can say why
	// until the next switch clears it.
	UserViewDropped string `json:"uvd,omitempty"`
	// UserViewDroppedName is that same type's display name, copied from
	// UserViewTypeName the instant DropUserView fires, before it is cleared —
	// see UserViewTypeName's doc for why this is the only chance to record it.
	UserViewDroppedName string `json:"uvdn,omitempty"`
	// contains filtered or unexported fields
}

Session is the content of the wardyn_session cookie, signed and stored client-side. Role is always non-empty in a cookie this package issues — CallbackHandler denies rather than write an undefined role — and decodeSession treats an empty Role (pre-0.5, or corrupt) 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, by a revoke targeting either identity or the reserved "" (global) sub, whichever
	// cutoff is later. A zero issuedAt is always revoked once any matching cutoff exists (fail
	// closed). Checked against BOTH identities since sub and email can diverge (Entra's opaque
	// per-app sub): sub compares exactly, email case-insensitively.
	//
	// ponytail: no in-process cache; add a short-TTL cache keyed on sub if latency shows up.
	IsSessionRevoked(ctx context.Context, sub, email string, issuedAt time.Time) (bool, error)
	// RevokeSub invalidates every CURRENT session for sub, matched against both identities.
	RevokeSub(ctx context.Context, sub string) error
	// RevokeAll invalidates every CURRENT session for every principal.
	RevokeAll(ctx context.Context) error
}

SessionRevocations is the store the revoke-a-human-now admin action reads and writes. Scoped to the stateless OIDC session cookie — distinct from internal/identity's per-run SPIFFE denylist.

type Subject added in v0.8.2

type Subject struct {
	Issuer, Sub, TenantID, ObjectID string
}

Subject is who a verified token names, as PersonKeying reads it: the issuer and raw `sub` every token carries, and Entra ID's tenant (`tid`) and tenant-stable object id (`oid`) claims.

type UserTypeSource added in v0.8.0

type UserTypeSource interface {
	ListUserTypes(ctx context.Context) ([]types.UserType, error)
}

UserTypeSource is the store of user types, read once per login. A non-nil error DENIES the login: a type that can't be looked up can't be shown to exist, and falling back to "standard" could widen a narrower type. Config.UserTypes wires it; nil means only the built-in type exists.

Jump to

Keyboard shortcuts

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