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
- Variables
- func ASCIIOnly(s string) bool
- func CanonicalGroupSubject(s string) (string, bool)
- func CookieName(secure bool, name string) string
- func CookiePath(secure bool, basePath string) string
- func EmailFromContext(ctx context.Context) string
- func ExpiryFromContext(ctx context.Context) time.Time
- func GroupsFromContext(ctx context.Context) []string
- func GroupsTruncatedFromContext(ctx context.Context) bool
- func LegacyRoleMemberWarning(variable, entry string) string
- func MemberModeFromContext(ctx context.Context) bool
- func MemberPreviewNoCredential(ctx context.Context) bool
- func NameFromContext(ctx context.Context) string
- func ParseRoleMap(csv string) (map[string]string, error)
- func PrincipalFromContext(ctx context.Context) string
- func RoleFromContext(ctx context.Context) string
- func SessionRejectedFromContext(ctx context.Context) string
- func SplitMappingTarget(v string) (role, userType string, ok bool)
- func StampedUserTypeFromContext(ctx context.Context) string
- func UserTypeFromContext(ctx context.Context) string
- func UserTypeIDReserved(id string) bool
- func UserTypeIDWellFormed(id string) bool
- func UserViewDroppedFromContext(ctx context.Context) string
- func UserViewDroppedNameFromContext(ctx context.Context) string
- func ValidMappingTarget(v string) bool
- func ValidRole(s string) bool
- type Authenticator
- func (a *Authenticator) AttachLoginGrantSink(sink LoginGrantSink)
- func (a *Authenticator) AttachPersonKeying(k PersonKeying)
- func (a *Authenticator) CallbackHandler(w http.ResponseWriter, r *http.Request)
- func (a *Authenticator) CallbackHandlerWithDenials(reserved func(sub string) bool, onDenied func(r *http.Request, reason string)) http.HandlerFunc
- func (a *Authenticator) ChartRoleMap() map[string]string
- func (a *Authenticator) ClientID() string
- func (a *Authenticator) DefaultRole() string
- func (a *Authenticator) DefaultRoleIsAdmin() bool
- func (a *Authenticator) DefaultRoleOutcome(userTypes []types.UserType) (role, userType string, ok bool)
- func (a *Authenticator) DropUserView(w http.ResponseWriter, r *http.Request) (context.Context, error)
- func (a *Authenticator) HasEmailDomains() bool
- func (a *Authenticator) HasOperatorEmails() bool
- func (a *Authenticator) IsOperatorEmail(v string) bool
- func (a *Authenticator) Issuer() string
- func (a *Authenticator) KeysPeopleByObjectID() bool
- func (a *Authenticator) LoginHandler(w http.ResponseWriter, r *http.Request)
- func (a *Authenticator) LogoutHandler(w http.ResponseWriter, r *http.Request)
- func (a *Authenticator) MergedMapEmpty(rows []RoleMapping) bool
- func (a *Authenticator) Middleware(next http.Handler) http.Handler
- func (a *Authenticator) PreviewRole(ctx context.Context, roles, groups []string, email string) (Derivation, error)
- func (a *Authenticator) PreviewRoleAgainst(rows []RoleMapping, userTypes []types.UserType, roles, groups []string, ...) Derivation
- func (a *Authenticator) RecordAttach(r *http.Request, sess Session)
- func (a *Authenticator) SetUserView(w http.ResponseWriter, r *http.Request, on bool, typeID, typeName string, ...) (stampedRole string, err error)
- func (a *Authenticator) VerifySubjectToken(r *http.Request, raw, portalClientID string, reserved func(string) bool) (Session, string)
- type Config
- type Derivation
- type LoginGrant
- type LoginGrantSink
- type Match
- type MatchSource
- type PersonKeying
- type RoleMapping
- type RoleMappingSource
- type Session
- type SessionRevocations
- type Subject
- type UserTypeSource
Constants ¶
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.
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.
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.
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.
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.
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 ¶
var ErrInvalidSession = errors.New("oidc: invalid session cookie")
ErrInvalidSession is returned when the cookie is present but tampered, malformed, or mis-keyed.
var ErrNoSession = errors.New("oidc: no session cookie")
ErrNoSession is returned by decodeSession when no session cookie is present.
var Roles = []string{RoleAdmin, RoleSecurityAdmin, RoleUser}
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
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
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
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
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
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
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
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
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
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
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
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
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
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 ¶
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
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
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
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
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
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
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
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
UserViewDroppedFromContext returns the type whose deletion turned this session's user view off (Session.UserViewDropped), or "".
func UserViewDroppedNameFromContext ¶ added in v0.8.0
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
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.
Types ¶
type Authenticator ¶
type Authenticator struct {
// contains filtered or unexported fields
}
Authenticator provides OIDC login, callback, logout, and session-check handlers.
func New ¶
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
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
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.