Signet

Flow hooks and claim mappers

Configure additive admission refusals and client-scoped OIDC claims without adding code to the authentication hot path. This vocabulary is append-only: existing predicate names will not be renamed or reinterpreted.

Ordering and security fences

pre_sign_up and pre_sign_in rules run in file order. The first matching rule wins; when no rule matches, the default is allow. A matching allow means only “no hook refuses.” It never bypasses a user ban, credential lockout, email policy, rate limit, request origin validation, or callback/redirect fence.

The fence order is fixed: rate-limit middleware runs before callback URL validation; a route's validate_origin check runs at handler entry; only then may the handler load its existing facts and evaluate a pre-hook, before that sign-in family's first mutating write. A request refused by any earlier fence never reaches a hook. The closed v1 predicate names are email, email_domain, auth_method, provider_id, and client_id; this vocabulary is append-only.

post_sign_in is annotate-only. Its non-empty label is limited to 64 characters at boot and is emitted through the existing event sink as session.annotated, whose data is exactly {"id": session id, "userId": user id, "label": configured label}. It is never stored as a session-row flag and carries no email, name, provider, token, or predicate fact. Hook evaluation is an in-memory check over facts the path already loaded and may not add a database query or network request.

Facts available by sign-in family

A fact slot may be absent on a particular attempt: for example, a direct password sign-in has no social provider, and client_id exists only when authentication is completing an OAuth authorization. An absent fact does not match a predicate that names it. SSO alone may use the already-loaded organization assertion facts before identity commit.

Sign-in familyEmail + domainAuth methodProvider idOAuth client idOrganization facts
Password / emailyespasswordabsentwhen resuming OAuthno
Usernamestored user emailusernameabsentwhen resuming OAuthno
Phonestored user emailphoneabsentwhen resuming OAuthno
Email or phone OTPresolved user emailotpabsentwhen resuming OAuthno
Magic linkresolved user emailmagic_linkabsentwhen resuming OAuthno
SIWEwallet account emailsiweabsentwhen resuming OAuthno
Anonymousgenerated anonymous emailanonymousabsentwhen resuming OAuthno
Passkeystored user emailpasskeyabsentwhen resuming OAuthno
Social OAuthprovider-asserted emailsocialsocial provider idwhen resuming OAuthno
Enterprise SSO (SAML or OIDC)assertion emailssoSSO provider idwhen resuming OAuthorganization id, membership and role from the assertion path
OIDC authorizeabsent (the platform session does not carry email)oidc_authorizeabsentregistered OAuth client idno
IP and CIDR are not v1 facts. This server currently reads client-supplied X-Forwarded-For without a trusted-proxy declaration, so making an authorization decision from it would trust attacker-controlled input. An ip predicate stops boot and names that precondition.

Refusal rendering

Operator-supplied hook prose is for logs and events only. It never appears in a JSON body, HTML page, or Location header.

On shared-credential paths, including email/password and username/password, a hook refusal renders as the path’s pinned INVALID_EMAIL_OR_PASSWORD response, byte for byte. It also runs the dummy password hash and advances persistent lockout exactly like a wrong password. Matching the bytes alone is insufficient: equal hashing work and equal lockout side effects prevent account and policy enumeration.

On /oauth2/authorize, the redirect URI is the security fence. Before that URI has been validated against the client, a refusal is a fixed JSON 400 with no Location. After the fence, the refusal is delivered only to the validated redirect URI as the closed OAuth error access_denied, with the request’s state and the issuer iss. No operator text or tenant identifier is placed in the redirect. This is the R377 shape used by the authorization flow.

Claim mapper contract

A mapper applies to both the id_token and userinfo. Its clients array is an explicit per-client disclosure decision, like groups_claim; omission means the client receives nothing. Organization-role and membership-organization sources require an organization-bound client.

The five sources are user_field (a declared key stored under publicMetadata), org_role, membership_orgs, bounded JSON static, and bounded string template. Templates accept only scalar {{user.<field>}} placeholders; they first read a built-in user field, then the declared publicMetadata key. Missing or non-scalar placeholders fail closed.

Mapper names may not collide with any claim the builders can emit: sub, name, picture, preferred_username, given_name, family_name, email, email_verified, auth_time, acr, groups, iss, aud, nonce, iat, or exp. Duplicate mapper names also stop boot. One mapped value is capped at 1024 UTF-8/serialized JSON bytes.

An unresolvable user-field, template, organization-role, or membership source fails closed and withholds the token. A missing optional custom user field omits that claim; it does not turn into JSON null or an empty authorization value.

Declared [user.fields] values arrive as top-level sign-up/update JSON keys, are type-checked before storage, and are persisted inside publicMetadata. A declaration marked required = true is required only on sign-up; omitting the entire section preserves the existing request contract.

Treat every relying party’s consumer alphabet as hostile. Proxmox ACL identifiers use : structurally, so a mapped value such as engineering:admin must not be replayed into a Proxmox role or path token without an explicit reversible encoding and a consumer-side fixture. A valid JSON string is not automatically valid application authorization input.

Worked signet.toml examples

Refuse contractor password sign-ins, while documenting that allow cannot override the ordinary ban and lockout gates:

[[hooks.pre_sign_in]]
when = { email_domain = "contractors.example.com", auth_method = "password" }
action = "refuse"
message = "contractor password access is disabled"

[[hooks.pre_sign_in]]
when = { email = "breakglass@example.com", auth_method = "password" }
action = "allow"

Declare a typed metadata field, map it for one client, and attach an event annotation to passkey sign-ins:

[user.fields]
department = { type = "string", max_length = 64, required = false }

[[claims.mappers]]
name = "department"
source = "user_field"
field = "department"
clients = [{ client_id = "operations-console" }]

[[hooks.post_sign_in]]
when = { auth_method = "passkey" }
action = "annotate"
annotation = "strong-authentication"

Expose an organization role only to an explicitly organization-bound client:

[[claims.mappers]]
name = "tenant_role"
source = "org_role"
clients = [{ client_id = "tenant-console", organization_id = "org_01HV..." }]