Operator authentication — OIDC SSO, LDAP/AD, mutual-TLS, WebAuthn step-up, and API tokens¶
The RBAC layer decides what a principal may do. This
document covers who a caller is: how the console and API authenticate
operators and machine clients, and how a first-class authentication event is
tied to an RBAC principal (a UserInfo carrying platform and per-tenant roles).
Several complementary mechanisms are provided, each configured under a top-level
auth: block and each inert until enabled. Both OIDC paths accept
multiple identity providers — the issuer that
authenticates a CI pipeline is usually not the one your operators log in through. The pre-existing stateless paths —
HTTP basic-auth for the built-in root user and OIDC Bearer tokens for API
callers — keep working unchanged, so nothing here is a breaking change.
| Mechanism | For | Credential | Config |
|---|---|---|---|
| OIDC/OAuth2 login | Console operators | Interactive SSO → server-side session cookie | auth.oidc, auth.oidc.providers |
| LDAP / Active Directory | Console + machine callers | Directory username + password (bind) | auth.ldap |
| Mutual-TLS | Machine / API callers | Client certificate bound to a principal | auth.mtls |
| WebAuthn step-up | Console operators, high-risk ops | Passkey assertion | auth.webauthn |
| API token | Machine / service accounts | Native, revocable, role/tenant-scoped secret | always on; auth.api_tokens tunes lifetime |
| Basic-auth (root) | Break-glass / bootstrap | root_user password |
policy.allow_root_basic_auth |
| Bearer token | API scripting, CI pipelines | OIDC access/id token | oidc, oidc.providers |
The middleware tries credentials in order: basic-auth (root → LDAP) → API token
→ Bearer (OIDC) → session cookie → mutual-TLS, then rejects with 401. A basic
credential that is not the built-in root user is bound against the directory
when auth.ldap is enabled, so CLI/API callers can present AD credentials over
HTTP Basic (always over TLS — the server refuses cleartext HTTP by default).
1. Interactive OIDC/OAuth2 login (console SSO)¶
Rather than have the browser hold and refresh an IdP token, the server drives the full Authorization-Code + PKCE handshake and establishes a server-side session:
GET /auth/login → 302 to the IdP (state + nonce + PKCE in a signed,
short-lived transaction cookie)
GET /auth/callback → verifies state, exchanges the code, verifies the id
token + nonce, maps claims → RBAC roles, sets the
session cookie, redirects to /console/
POST /auth/logout → terminates the session, clears cookies
GET /auth/session → returns the current principal + CSRF token
PKCE (RFC 7636) — always on, S256 only¶
Every console login runs Proof Key for Code Exchange. It is not a setting
and cannot be turned off: GET /auth/login mints a fresh 256-bit verifier,
sends only its SHA-256 digest to the IdP, and completes the exchange with the
verifier itself.
GET /auth/login
→ 302 …/authorize?response_type=code
&code_challenge=<BASE64URL(SHA-256(verifier))>
&code_challenge_method=S256
&state=…&nonce=…
Set-Cookie: secsy_session_oidc_tx=<payload>.<HMAC> HttpOnly, SameSite=Lax, 10 min
GET /auth/callback?code=…&state=…
→ POST …/token grant_type=authorization_code
code=…&code_verifier=<verifier>&redirect_uri=…
client_id=… (public client) | Basic <id:secret> (confidential)
| Property | Where it lives | Why |
|---|---|---|
| S256 only | code_challenge_method=S256 |
plain transmits the verifier itself, so anyone who can read the authorization request can redeem a stolen code. It is never sent. |
| Fresh 256-bit verifier per attempt | oauth2.GenerateVerifier() — 43 characters of the RFC 7636 §4.1 unreserved alphabet |
Two logins never share a verifier, and it cannot be guessed from the challenge. |
| Verifier never reaches the browser | sealed in the HMAC-signed, HttpOnly transaction cookie alongside state and nonce |
Script on the page cannot read it; the server keeps no login state, so any replica can finish the flow. |
| 10-minute transaction | cookie MaxAge, re-checked on the callback |
Bounds how long a leaked code could be paired with a live verifier. |
| Single-use | the callback clears the cookie before exchanging | A replayed callback finds no transaction. |
What it buys you. An authorization code that leaks — out of a browser
history, a Referer header, a proxy log, a crafted redirect — is worthless
without the verifier. That is the whole protection a public client has in
place of a client secret, and a second lock for a confidential one.
Public clients. Omit client_secret and the client is public: the token
request carries client_id in the request body and no HTTP Basic header
(RFC 6749 §2.3.1), with PKCE standing in for client authentication. This is
explicit rather than auto-detected — an empty-password Basic base64("<id>:")
probe is what most OAuth libraries try first, and IdPs answer it with
invalid_client, logging a failed client authentication on every single login.
Does your IdP check it? The challenge is sent regardless, but an IdP that
does not implement PKCE simply ignores it. At startup the server reads
code_challenge_methods_supported from the IdP's discovery document and logs
what it found:
Interactive OIDC login provider "entra" enabled (issuer=… redirect_url=… pkce=S256 public client (no secret; PKCE binds the exchange))
WARNING: OIDC login provider "partner" does not advertise the S256 code-challenge method …
Set require_pkce to turn that warning into a refusal to start:
auth:
oidc:
enabled: true
require_pkce: true # every provider must advertise S256
providers:
- name: partner
issuer_url: "https://idp.partner.example.net"
client_id: "console-partner"
require_pkce: false # …except this one, which omits the metadata field
It defaults to false because a few otherwise conformant providers implement
PKCE without publishing the optional metadata field, and a boot failure is the
wrong way to discover that. The method in force is also published, unauthenticated,
in /api/auth/config → oidc_providers[].pkce, and the console's sign-in page
shows it.
secsy-ca doctor makes the same determination before the server starts, with an
auth.oidc.pkce check that reads each provider's discovery document:
$ secsy-ca doctor
STATUS CHECK DETAIL
✓ pass auth.oidc.pkce entra: S256 advertised; partner: S256 advertised
# …or, against a provider that does not say so:
! warn auth.oidc.pkce partner: no S256 in code_challenge_methods_supported ([plain]); the challenge may be ignored
✗ FAIL auth.oidc.pkce partner: require_pkce is set but the IdP advertises [plain] — the server will refuse to start
An IdP this host cannot reach is a warning, never a failure — doctor often runs from somewhere with different egress than the server.
Claim / group → RBAC role mapping¶
An operator's roles are the union of:
- the top-level
rbac:subject / verified-email / group assignments, and - the
auth.oidc.role_mappings— IdP claim values (typically groups) mapped to roles, optionally scoped to a tenant.
auth:
oidc:
enabled: true
# issuer_url / client_id inherit the top-level oidc block when omitted
client_secret: "…" # omit for a public (PKCE) client
redirect_url: "https://pki.example.com/auth/callback"
scopes: ["openid", "profile", "email", "groups"]
groups_claim: "groups"
role_mappings:
- { value: "pki-admins", roles: ["admin"] } # platform-wide
- { value: "pki-issuers", roles: ["issuer"] }
- { value: "acme-issuers", tenant: "acme-corp", roles: ["issuer"] }
- { claim: "roles", value: "auditor", roles: ["auditor"] } # any claim
# Or a CEL expression, when the group names encode the structure:
- expression: 'groups.exists(g, g.startsWith("pki-issuers-"))'
tenant_expression: |
groups.filter(g, g.startsWith("pki-issuers-"))
.map(g, g.replace("pki-issuers-", ""))
roles: ["issuer"]
allow_zero_role: false # deny login to an operator with no resolved role
A mapping matches when the named claim (a string or a list of strings) contains
value, or when its CEL expression evaluates true. Unknown role names are
dropped at load, and an enabled login block with a mapping referencing an
unknown role fails validation at startup.
The expression form — expression and tenant_expression, with
startsWith/endsWith/matches, regex.replace/regex.extract, sets and
lists over the authenticated identity — is what an organization needs when a
group name carries the tenant (pki-issuers-acme-corp,
CN=PKI-Admins-emea,OU=Groups,…) instead of enumerating one mapping per team.
It is compiled at startup, cost-bounded at evaluation, and fail-closed
throughout; secsy-ca role-mapping test dry-runs it offline. See
role-mapping expressions for the variables,
the function library, and the rules that keep a derived tenant from escalating.
1a. Multiple OIDC providers¶
A deployment rarely has one identity provider. The pipeline that asks for an artifact signature authenticates through its forge's workload-identity issuer (GitHub Actions, GitLab, a Kubernetes projected service-account token); the humans who administer the PKI sign in through the corporate IdP; a partner's automation may arrive through a third. Each mints its own JWTs, under its own issuer, with its own audience and its own idea of what a subject string means.
secsy-pki keeps these separate on two axes:
| Axis | Config | Credential | Typical IdP |
|---|---|---|---|
Machine — Authorization: Bearer <jwt> |
oidc / oidc.providers[] |
a token the caller already holds | GitHub Actions OIDC, GitLab, a cloud IdP |
| Console — interactive browser login | auth.oidc / auth.oidc.providers[] |
the Authorization-Code + PKCE handshake | Entra ID, Okta, Keycloak |
Both axes accept several providers at once, and the two are independent: the issuer that signs your CI tokens need not be, and usually is not, the one your operators log in through.
Machine callers: oidc.providers¶
oidc:
providers:
# Automated signing from a CI pipeline. The workflow's OIDC token is
# verified here and maps to exactly the signer role.
- name: github
issuer_url: "https://token.actions.githubusercontent.com"
client_id: "secsy-pki-signing" # the audience the workflow requests
role_mappings:
- { claim: "sub", value: "repo:example-org/app:environment:release", roles: ["signer"] }
# API scripting by staff service principals, through the corporate IdP.
- name: entra
issuer_url: "https://login.microsoftonline.com/<tenant-id>/v2.0"
client_id: "api://secsy-pki"
# audiences: ["api://secsy-pki-legacy"] # additional accepted audiences
groups_claim: "roles" # Entra app roles
role_mappings:
- { value: "PKI.Issue", roles: ["issuer"] }
A presented token is routed to exactly one provider by its iss claim, then
verified there (signature, issuer, expiry, audience). Three properties make
federating issuers safe rather than additive risk:
- Routing cannot pick a lenient verifier. The
issused for routing is read from the unverified payload, which is sound because the selected provider re-checks the issuer cryptographically. A forgedissonly lands the token at a provider whose JWKS will not verify it. An issuer no provider serves is rejected outright, never offered to the others in turn. - Every provider is audience-bound.
client_id(plus anyaudiences) is the acceptedaudset. A provider with neither is a load error: it would otherwise honor any token the same IdP minted for an unrelated relying party. - Authorization is per provider. A provider's
role_mappingsare evaluated only for tokens it verified. So a mapping grantingsignertorepo:example-org/app:environment:releasegrants nothing to a token from a different issuer asserting that identical subject — which is the failure mode a shared, provider-agnostic subject list would have.
The top-level
rbac.subjects/rbac.groupsassignments remain provider-agnostic and are unioned in for every provider. Use them for identities you trust regardless of issuer; use a provider'srole_mappingswhen the grant is specific to one IdP.
Two providers may not share an issuer URL (routing would become order-dependent), and provider names must be unique. Both are refused at load.
Console operators: auth.oidc.providers¶
auth:
oidc:
enabled: true
# Shared defaults — a provider omitting a field inherits it from here.
redirect_url: "https://pki.example.com/auth/callback"
scopes: ["openid", "profile", "email", "groups"]
groups_claim: "groups"
allow_zero_role: false
providers:
- name: entra # /auth/login?provider=entra
display_name: "Microsoft Entra ID" # the console's button label
issuer_url: "https://login.microsoftonline.com/<tenant-id>/v2.0"
client_id: "console-entra"
client_secret_file: "/etc/secsy/entra-client-secret"
default: true # a bare /auth/login uses this one
role_mappings:
- { value: "PKI-Admins", roles: ["admin"] }
- name: partner
display_name: "Partner IdP"
issuer_url: "https://idp.partner.example.net"
client_id: "console-partner"
redirect_url: "https://pki.example.com/auth/callback/partner"
allow_zero_role: true # overrides the shared policy
role_mappings:
- { value: "pki-readers", tenant: "partner", roles: ["auditor"] }
The console reads /api/auth/config and renders one sign-in button per
provider, so an operator chooses their IdP instead of being sent to whichever
one is the default.
GET /auth/login → the default provider (first, or `default: true`)
GET /auth/login?provider=<name> → that provider
GET /auth/login/oidc/<name> → the same, as a path
GET /auth/callback → dispatched by the provider recorded in the
signed login transaction cookie
GET /auth/callback/<name> → a per-provider callback path, for an IdP
whose registration demands a distinct URI
Providers may share one registered redirect_url. The name of the provider
that began the flow is sealed inside the HMAC-signed, 10-minute transaction
cookie alongside the state, nonce, and PKCE verifier, so the callback knows which
verifier, client credentials, and claim mapping to complete with. A transaction
arriving at a different provider's callback is refused
(code: provider_mismatch) rather than completed — otherwise a code issued by
one IdP would be exchanged with another's client credentials and its claims
mapped through the wrong role mapping.
Role mapping across providers¶
Mappings under auth.oidc.role_mappings (the shared block) are evaluated for
every console provider; a provider's own role_mappings are unioned on top.
That is what keeps the single-provider form working — but it also means a group
name asserted by any federated IdP matches a shared mapping. When providers
differ in how much you trust them, declare the mapping on the provider, not in
the shared block.
allow_zero_role is per provider (falling back to the shared setting), so one
IdP can admit role-less viewers while another denies them at login.
Behaviour note. A Bearer caller's group claim now feeds the
rbac.groupsassignments and resource grants, as it always has for console logins. Previously the Bearer path consulted only the groups recorded in the database, so the same identity resolved to different roles depending on whether it arrived as a token or through the browser. If you userbac.groupsfor internal group ids that happen to collide with an IdP group name, a machine caller asserting that name now matches it — the same way that identity already did when signing in to the console.
Audit, diagnosis, and discovery¶
- Login audit detail carries
provider=<name>:auth.login method=oidc provider=entra, andauth.login_failed … reason=provider_mismatch. - The resolved principal carries
auth_provider(/api/me,/auth/session, and the console's "signed in as" line) — diagnostic only; the roles already reflect that provider's mapping. /api/auth/configpublishesoidc_providers(name, label, login URL, PKCE method) andbearer_providers(name, issuer, accepted audiences), so a workload can discover which issuer and audience to request a token for rather than having that pairing passed around out of band. No client secret ever appears there.- An IdP that cannot be discovered at startup is logged as a
WARNINGand skipped; the other providers still come up. For machine issuers that leaves Bearer verification working for the rest; for console login, if no provider comes up the server refuses to start, since SSO was configured and is wholly unavailable.
Backward compatibility¶
The single-provider form is unchanged and remains a provider in its own right,
named default:
oidc.issuer_url+oidc.client_id→ one machine provider nameddefault. Addingoidc.providersdoes not drop it; the two are unioned (declaring the same issuer twice is a load error).auth.oidc.issuer_url/client_idstill inherit the top-leveloidcblock when omitted, so one IdP can serve both axes.
See examples/multi-oidc/ for a complete
GitHub-Actions-plus-Entra-ID configuration.
1b. LDAP / Active Directory authentication¶
Deployments that centralize identity in LDAP or Active Directory can
authenticate operators with their directory username + password, mapping the
caller's directory groups → RBAC roles through the same role_mappings
mechanism as OIDC. It serves both the console (an interactive login that
establishes a session) and machine/CLI callers (HTTP/RPC Basic).
POST /auth/login/ldap → binds the directory credentials, maps groups → roles,
sets the session cookie, returns the CSRF token
Authorization: Basic … → a non-root basic credential is bound against the
directory per request (machine/CLI callers)
Two bind flows are supported (RFC 4511):
- search-then-bind (default; set
bind_dn): the server binds as a low-privilege service account, searches the user subtree for the entry matching the login name, then re-binds as that entry's DN with the supplied password to verify it. This is the standard AD pattern and the only way to accept a friendly login name (sAMAccountName) rather than a full DN. - simple-bind (leave
bind_dnempty; setuser_dn_template): the login name is templated straight into a bind DN oruserPrincipalNameand bound. No service account is required.
auth:
ldap:
enabled: true
url: "ldaps://ad.example.com:636" # ldaps:// (implicit TLS) or ldap:// + start_tls
# start_tls: true # required for an ldap:// URL
bind_dn: "CN=svc-pki,OU=Service,DC=example,DC=com"
bind_password: "…" # or bind_password_source (below)
user_base_dn: "OU=Users,DC=example,DC=com"
user_filter: "(&(objectClass=user)(sAMAccountName=%s))" # %s = escaped login name
group_attribute: "memberOf" # read groups from the user entry…
# group_base_dn / group_filter: # …or search a group subtree instead
# group_base_dn: "OU=Groups,DC=example,DC=com"
# group_filter: "(&(objectClass=group)(member=%s))" # %s = user DN, %u = username
email_attribute: "mail"
name_attribute: "displayName"
groups_claim: "groups"
role_mappings:
- { value: "CN=PKI-Admins,OU=Groups,DC=example,DC=com", roles: ["admin"] }
- { value: "CN=PKI-Issuers,OU=Groups,DC=example,DC=com", tenant: "acme", roles: ["issuer"] }
allow_zero_role: false # deny login to a directory user matching no mapping
tls:
ca_file: "/etc/secsy/ad-ca.pem" # trust anchors (empty = system store)
server_name: "ad.example.com" # cert-name / SNI override (for IP dials)
min_version: "1.2"
timeout_seconds: 10
The resolved directory groups are presented to the shared ClaimMapper under
groups_claim, so role_mappings behaves identically to OIDC's — a group's DN
(or, with group_attribute naming an attribute such as cn, its name) is
matched against value, granting platform-wide or tenant-scoped roles. The
result is unioned with the top-level rbac: subject/email/group assignments.
A directory is where the CEL expression form earns its keep: group DNs encode the organizational unit and frequently the team, so one mapping can cover an entire naming convention and extract the tenant out of the CN rather than enumerating every group DN by hand.
Sourcing the bind password. bind_password may be given inline, or sourced
from a credential store with bind_password_source — the same machinery as the
HSM PIN (env, file, vault, aws, azure),
resolved lazily and never written back into a redacted config:
TLS is mandatory. A bind (which carries the password) is never sent over an
unencrypted connection: ldaps:// uses implicit TLS, ldap:// must be upgraded
with start_tls: true, and a StartTLS failure fails the login closed rather
than continuing in the clear. Server-certificate verification is on by default
(tls.insecure_skip_verify exists only for tests). An ldap:// URL without
start_tls is rejected at startup unless insecure_allow_cleartext is explicitly
set.
Other hardening. Empty passwords are rejected before any bind (defeating the
LDAP unauthenticated-bind pitfall, where a DN + empty password can be silently
promoted to a successful anonymous bind); login names are escaped
(ldap.EscapeFilter / ldap.EscapeDN) before interpolation, so a username can
never inject into the search filter or bind DN; and an unknown user fails with
the same generic error as a wrong password (no user enumeration).
secsy-ca doctor gains an auth.ldap check that connects, negotiates TLS,
and performs the service-account bind (no end-user login), so a directory
misconfiguration is caught before operators are locked out.
2. Mutual-TLS client-certificate authentication¶
Machine and API callers can authenticate with a client certificate instead
of a token. When auth.mtls.enabled is set, the TLS listener requests a client
certificate and verifies it against ca_file (tls.VerifyClientCertIfGiven, so
the console and public endpoints stay reachable without one). A verified
certificate is then bound to a principal by the first matching binding:
auth:
mtls:
enabled: true
ca_file: "certs/client-ca.pem"
bindings:
# every non-empty selector must match; the principal gets the listed roles
- { subject_cn: "build-robot", subject: "svc:build-robot", roles: ["issuer"] }
- { san_uri: "spiffe://example.org/ns/ops/sa/deployer", roles: ["admin"],
tenant_roles: { acme-corp: ["issuer"] } }
- { san_dns: "scanner.example.com", roles: ["auditor"] }
Selectors: subject_cn, subject_dn, san_dns, san_uri, san_email. A
binding with no selector matches nothing (fail-closed) and is dropped. The
chain itself is verified by the TLS stack, not the binder.
3. WebAuthn / passkey step-up¶
High-risk operations can require a fresh WebAuthn assertion in addition to a valid session. This defends against a hijacked session or an unattended console: revoking a certificate, running a CA key ceremony, cross-signing, deleting a CA, or factory-resetting the HSM all demand a passkey tap.
auth:
webauthn:
enabled: true
rp_id: "pki.example.com" # the console's registrable domain
origins: ["https://pki.example.com"]
step_up_ttl_minutes: 5 # how long one step-up stays valid
step_up_operations: # defaults to the set below when omitted
- cert.revoke
- ca.init_root
- ca.issue_intermediate
- ca.cross_sign
- ca.manage
- hsm.factory_reset
Endpoints (all require a live session + CSRF token):
POST /auth/webauthn/register/begin /register/finish — enroll a passkey
POST /auth/webauthn/stepup/begin /stepup/finish — assert for step-up
GET /auth/webauthn/credentials — list enrolled passkeys
Only the credential id, its public key (SPKI DER), and the authenticator's
signature counter are stored (webauthn_credentials table) — the private key
never leaves the authenticator. A non-advancing counter is rejected as a cloned
authenticator. When a gated request lacks a valid step-up, the API returns
403 with {"code":"step_up_required","operation":"…"}; the console runs the
assertion ceremony and retries automatically.
Step-up gates session (console) callers. A caller authenticated by a strong non-interactive credential — root basic-auth, a bearer token, or a bound mutual- TLS certificate — already presented a strong credential and is not gated.
4. Native scoped API tokens (service accounts)¶
Machine callers no longer have to share the single root basic credential or
depend on an external OIDC IdP. A native API token is a first-class,
revocable, long-lived credential bound to a set of RBAC roles and a tenant
scope, with an optional expiry.
A token is an opaque, high-entropy secret of the form secsy_pat_<random>
(256 bits of CSPRNG entropy). It is presented under a distinct Authorization
scheme so it never conflates with OIDC Bearer verification:
Clients that can only emit Bearer may use Authorization: Bearer secsy_pat_…
— the secsy_pat_ prefix unambiguously routes it to token verification (an
OIDC JWT never carries that prefix). Verification is fail-closed: an
unknown, malformed, expired, or revoked token is rejected with no
distinguishing detail.
At-rest storage — a deliberate security decision¶
The plaintext secret is returned exactly once at creation and is never
persisted. Only a one-way hash — hex(SHA-256(secret)) — is stored
(api_tokens.token_hash, UNIQUE-indexed). A fast hash, not argon2/bcrypt, is
the correct choice here:
- the secret carries 256 bits of entropy and cannot be brute-forced offline, so the password-hardening KDFs that exist to slow guessing of low-entropy passwords add nothing; and
- a deterministic, unsalted hash is what makes the O(1) indexed lookup on the hot verification path possible.
This matches how GitHub/GitLab personal access tokens are stored. A database disclosure therefore cannot yield a usable credential.
Scope, roles, and privilege¶
- A tenant-scoped token (
scope: tenant, the default) exercises its roles only within its owning tenant — the same cross-tenant isolation that confines a tenant OIDC operator applies. - A platform-scoped token (
scope: platform) holds its roles across all tenants and may only be minted by a platform administrator.
Managing tokens is an administrative capability (token:manage, admin-only): a
token can carry any role, so allowing a lesser role to mint tokens would let it
escalate its own privilege. Tenant admins may mint tenant-scoped tokens within
their tenant; platform tokens require a platform admin.
When the four-eyes gate is enabled, minting a token
with a sensitive grant — any privileged role (anything beyond read-only
auditor) or platform scope — is held for approval (token.create class,
202 response) and minted only after the approver threshold is met. Revocation
is deliberately not gated: it removes access and must stay fast for incident
response.
Lifecycle surfaces¶
REST (token:manage; create returns the secret once):
GET /api/tokens — list tokens the caller may manage (?tenant=)
POST /api/tokens — mint a token (201 with secret, or 202 pending approval)
DELETE /api/tokens/{id} — revoke a token
CLI (platform-operator level; direct store access):
secsy-ca token create -name ci-issuer -roles issuer -tenant acme -expires-days 90
secsy-ca token create -name platform-bot -roles admin -scope platform
secsy-ca token list [-tenant acme]
secsy-ca token revoke <id>
Console: an API Tokens page lists tokens, mints them (revealing the secret once with a copy button), and revokes them.
Optional lifetime policy — cap how long a token may live (0/omitted = no cap, non-expiring tokens allowed):
Each successful verification updates a throttled last-used timestamp and client IP, so a stale or leaked token is easy to spot and revoke.
Sessions & CSRF¶
Interactive logins (OIDC and password) create a server-side session referenced
by an opaque, random id in an HttpOnly cookie. A companion CSRF token is
issued per session; every state-changing request authenticated by the cookie
must echo it in the X-CSRF-Token header (the console reads it from a
JS-readable cookie / /auth/session). Token/basic/mTLS callers are exempt —
they do not rely on ambient browser credentials and so are not forgeable
cross-site. Sessions are process-local (no shared state); a restart simply
re-prompts for SSO.
POST /auth/login/password establishes a session from the root_user
credentials, so even password logins get CSRF protection and can use step-up.
Audit & metrics¶
Every login, logout, and step-up is written to the tamper-evident event log:
auth.login, auth.login_failed, auth.logout, auth.step_up,
auth.step_up_denied, webauthn.register, webauthn.remove, token.create,
token.revoke (the token id is the event target; the secret is never logged).
LDAP logins record auth.login / auth.login_failed with method=ldap (the
failed-login detail carries a non-sensitive reason: invalid_credentials,
directory_unavailable, or no_access).
With several OIDC providers configured, the OIDC
login events carry provider=<name> so each IdP's logins are separable in the
log.
Prometheus metrics: secsy_auth_logins_total{method,result} (method ∈
oidc|password|ldap|mtls), secsy_auth_logouts_total,
secsy_auth_step_ups_total{result},
secsy_auth_sessions_active, secsy_auth_token_operations_total{operation,result},
secsy_auth_token_verifications_total{result} (result ∈
success|expired|revoked|unknown|error), and secsy_auth_tokens_active.
Security notes¶
- The server refuses cleartext HTTP by default; cookies are
Secureunlessauth.session.insecureis set (local testing only). - The OIDC login binds state (CSRF on the callback), nonce (token replay), and PKCE (code interception); the transaction is carried in an HMAC-signed, 10-minute cookie that also names the provider that began the flow, so one IdP's authorization code cannot be completed at another's callback.
- With several machine issuers federated, a token is verified by exactly one provider and authorized by that provider's claim mapping. A provider with no accepted audience is refused at load rather than trusting every token the issuer mints.
- mTLS bindings never widen access on their own — the bound roles are still subject to the same RBAC checks and, for tenant resources, tenant membership.
- LDAP binds occur only over TLS (LDAPS or StartTLS, fail-closed); empty passwords are rejected before binding; and login names are escaped before use in filters/DNs. HTTP Basic against the directory re-binds per request — keep it behind TLS (the default) and prefer sessions or API tokens for high-volume machine callers.