Authentication
TesseraQL routes are deny-by-default (design ch. 11, 20.14): a route is reachable only when it
declares how it authenticates, and authorization policies are evaluated against the resolved
Principal. Every authentication method plugs in behind the same authentication step and the same
principal model, so claims, roles, permissions, and tenant resolve identically downstream
regardless of how the caller proved its identity.
security: auth: bearer # bearer | apiKey | browser | public policy: users.read # authorization policy evaluated against the principalThis page covers the bearer JWT and API-key methods. Browser sessions are covered in hypermedia-ui.md; corporate SSO (SAML, and OIDC) is configured separately.
All JWT and API-key crypto is JDK-only — there is no JOSE/JWT third-party dependency, matching the SAML module’s supply-chain posture.
Bearer JWT
Section titled “Bearer JWT”A bearer route reads the Authorization: Bearer <jwt> header, verifies the signature, validates
the claims, and maps them to a Principal. Configure it under tesseraql.security.jwt.
Claim mapping (all algorithms)
Section titled “Claim mapping (all algorithms)”tesseraql: security: jwt: issuer: https://idp.example.com/ # optional; checked against the `iss` claim clockSkew: 60s # optional leeway for exp/nbf (default 0) rolesClaim: roles # claim names; these are the defaults permissionsClaim: permissions groupsClaim: groups tenantClaim: tenant_id loginClaim: preferred_username nameClaim: namesub becomes the principal subject; the mapped claims become its roles, permissions, groups,
tenant, login id, and display name; the full claim set is available to SQL binds as
principal.claim.<name>.
HS256 (shared secret)
Section titled “HS256 (shared secret)”The default algorithm. Verifies with an HMAC secret — appropriate when your own service issues the tokens.
tesseraql: security: jwt: algorithm: HS256 # default; may be omitted secret: ${secret.env.JWT_SECRET}RS256 with a static public key
Section titled “RS256 with a static public key”Verifies with an RSA public key — appropriate for tokens issued by an external identity provider
when you pin a single signing key. The key may be a PEM PUBLIC KEY (SubjectPublicKeyInfo), an
X.509 CERTIFICATE, or a JWK / JWK Set in JSON.
tesseraql: security: jwt: algorithm: RS256 issuer: https://idp.example.com/ publicKey: ${secret.file.idp_signing_pub} # PEM, certificate, or JWK JSONRS256 with a JWKS endpoint
Section titled “RS256 with a JWKS endpoint”Verifies against the key set the identity provider publishes, selecting the key by the token’s
kid and following key rotation automatically.
tesseraql: security: jwt: algorithm: RS256 issuer: https://idp.example.com/ jwksUri: https://idp.example.com/.well-known/jwks.json jwks: cacheTtl: 10m # how long a fetched key set is trusted before refresh (default 10m) refreshFloor: 1m # min interval between unknown-kid refetches (default 1m) requestTimeout: 5s # JWKS connect/request timeout (default 5s)The JWKS endpoint must be https (loopback http is allowed for local development). The fetched
key set is cached for cacheTtl. A token whose kid is not in the cache — typically a key the IdP
rotated in — triggers at most one refetch per refreshFloor, so a flood of tokens carrying
random kids cannot become a flood of JWKS requests; an unknown kid that survives a permitted
refetch is rejected (fail closed). On a transient JWKS fetch failure the last good key set keeps
serving; if none was ever fetched, authentication fails closed.
Algorithm confusion is rejected by design
Section titled “Algorithm confusion is rejected by design”The expected algorithm is bound from configuration, never from the token header. Before any key is
consulted, the token’s header alg must equal the configured algorithm — so an alg: none token,
or an HS256 token presented to an RS256 config (the classic “use the RSA public key as the HMAC
secret” attack), is rejected. The lint enforces the same statically (see below).
API keys
Section titled “API keys”API keys authenticate service callers — machine clients with a small, mostly-static set of
keys. A route opts in with auth: apiKey:
security: auth: apiKey policy: invoices.writeThe key is presented either in the configured header (default X-API-Key: <key>) or as
Authorization: ApiKey <key> for gateways that forward only Authorization. Clients are declared
in config:
tesseraql: security: apiKeys: header: X-API-Key clients: billing-service: secretHash: ${secret.env.BILLING_API_KEY_SHA256} # hex SHA-256 of the raw key subject: svc:billing # defaults to the client id tenantId: tenant-a roles: [SERVICE] permissions: [invoices:write] status: ACTIVE # ACTIVE (default) | DISABLEDOnly a hex SHA-256 of the key is stored — never the raw key — and is best supplied through the
secret SPI. Generate it with, for example, printf %s "$RAW_KEY" | sha256sum. The presented key is
hashed and compared in constant time against every active client; the raw key is never stored or
logged. A match resolves to that client’s principal — with its tenant bound from the key, not the
request, so a key cannot escalate across tenants — and the route’s authorization policy then applies
as for any other caller. No match denies (401); an authenticated key that fails the policy is
forbidden (403).
Mutual TLS (client certificates)
Section titled “Mutual TLS (client certificates)”mTLS authenticates service callers by an X.509 client certificate. The runtime does not
terminate TLS itself; a trusted edge — a reverse proxy, ingress controller, or service-mesh sidecar
(nginx, Envoy/Istio, HAProxy) — terminates TLS, validates the client certificate, and forwards it to
the runtime in a configured header (URL-encoded PEM, the de-facto ssl_client_escaped_cert
convention). A route opts in with auth: mtls:
security: auth: mtls policy: ledger.writeClients are declared in config, each mapping a certificate identity to an explicit principal:
tesseraql: security: mtls: forwardedHeader: ssl-client-cert # the header the edge forwards the cert in (no default) trustBundle: ${secret.file.client_ca} # optional PEM CA bundle; enables in-app PKIX validation clockSkew: 60s # leeway for the certificate validity window (default 0) clients: billing-service: subjectDn: "CN=billing-service,O=Acme" # exactly one matcher (see below) subject: svc:billing # principal subject; defaults to the client id tenantId: tenant-a roles: [SERVICE] permissions: [invoices:write] status: ACTIVE # ACTIVE (default) | DISABLEDEach client declares exactly one certificate matcher:
subjectDn— the certificate’s subject distinguished name, compared order- and case-insensitively over its RDNs (so a CA that orders or cases the DN differently still matches).san— a Subject Alternative Name value the certificate carries (DNS, URI, email, or IP); for example a SPIFFE URIspiffe://acme/ns/default/sa/billing.sha256— the hex SHA-256 fingerprint of the DER certificate (colons and case are ignored); the strongest binding, pinning one exact certificate.
The forwarded certificate is parsed (JDK only — there is no third-party PKI dependency), its
validity window checked against clockSkew, and its identity matched against the declared clients.
A match resolves to that client’s principal — with its tenant bound from the certificate binding,
not the request — and the route’s authorization policy then applies as for any other caller. No
match, an expired or malformed certificate, or a missing header denies (401); an authenticated
certificate that fails the policy is forbidden (403). Unlike an API key, a certificate is public —
possession of the private key was proven during the handshake at the edge — so identity matching is
a lookup, not a secret comparison; the certificate is never logged.
When trustBundle is set, the runtime additionally PKIX-validates the forwarded certificate
against the configured CA(s) as defense-in-depth, in addition to the edge’s own validation
(revocation checking is left to the edge, which is positioned to do CRL/OCSP). Omitting it is allowed
but means the runtime fully trusts the edge’s validation — see the lint warning below.
Trust contract. A forwarded certificate header is only trustworthy if callers cannot set it themselves: the edge must overwrite (or strip) the
forwardedHeaderon every inbound request, and the runtime must not be reachable except through that edge. Because certificates are public, fingerprint or DN pinning alone does not stop header injection — network isolation does. Only the URL-encoded (and raw) PEM convention is supported; Envoy/Istio’sx-forwarded-client-cert(XFCC) envelope is not parsed.
OpenID Connect (relying party)
Section titled “OpenID Connect (relying party)”OIDC logs a browser user in through an external identity provider using the authorization-code
flow with PKCE, then issues a TesseraQL browser session — the same session the SAML SP and
password login produce. It is an opt-in leaf module: add the tesseraql-oidc jar to the runtime
classpath and enable it. The provider’s endpoints are discovered at runtime, and the ID token is
validated with the same RS256/JWKS verifier as bearer JWT.
tesseraql: oidc: enabled: true discoveryUri: https://idp.example.com/.well-known/openid-configuration clientId: my-app clientSecret: ${secret.env.OIDC_CLIENT_SECRET} # omit for a public (PKCE-only) client redirectUri: https://app.example.com/_tesseraql/oidc/callback scopes: [openid, profile, email] # "openid profile email" (a string) also works postLoginUrl: / # fixed same-origin path after login clockSkew: 60s claims: # ID-token claim → principal mappings login: preferred_username name: name roles: roles groups: groups tenant: tenant_id link: enabled: true # resolve/authorize via local identity contracts (else IdP-asserted) provision: false # JIT-provision an unknown user the first time they sign inIt serves three endpoints under /_tesseraql/oidc:
GET /login— generates an anti-CSRFstate, an ID-tokennonce, and a PKCEcode_verifier, records them server-side (single-use, intql_oidc_state), and redirects to the provider’s authorization endpoint with thecode_challenge(S256).GET /callback— validates and consumes thestate(a forged, replayed, or expired one is rejected, as is anerror=response), exchanges the code at the token endpoint (client_secret_basicwhen a secret is set, else a public PKCE client), validates the ID token (signature via JWKS,iss,exp/nbf,audincludes the client id,noncematches), resolves or provisions the principal, opens a session, and redirects to the fixedpostLoginUrl.GET /logout— clears the local session and, when the provider advertises one, redirects to its end-session endpoint.
Discovery is lazy: the provider is contacted on the first login, not at app startup, so a brief
provider outage does not stop the app from booting. The expected token issuer is always the
discovered issuer, the post-login redirect is a fixed configured path (never a request parameter,
so there is no open redirect), and the client secret, code, and tokens are never logged. An IAM
admin wizard in Studio (OIDC provider) generates this config block.
Browser sessions and the admin console
Section titled “Browser sessions and the admin console”The bundled admin console — Studio (/_tesseraql/studio/ui), the Operations console
(/_tesseraql/ops/console), and IAM Admin (/_tesseraql/admin/users) — authenticates with a
browser session (auth: browser): it is opened in a browser, not with a hand-minted token.
Opening a protected page without a session redirects (302) to the login page,
GET /_tesseraql/login?next=<original-path>; after signing in, the browser returns to next.
A session is established the same way regardless of method — password, OIDC, and SAML all create one
session cookie (tesseraql_sid) — so a route’s auth: browser is satisfied however the user signed
in. The login method is therefore a config switch, with no per-route changes:
-
Password (default). The login form posts to
POST /_tesseraql/login, which verifies a credential in the identity store and opens the session. The store is not seeded automatically — create the first administrator once:Terminal window tesseraql identity-schema --admin-login admin --admin-password-file ./admin.pw -
OIDC. Set
tesseraql.oidc.enabled: true(see OpenID Connect above). The login page then shows Sign in with OIDC, linking toGET /_tesseraql/oidc/login. -
SAML. Set
tesseraql.saml.enabled: trueand configure the SP. The login page shows Sign in with SAML, linking toGET /_tesseraql/saml/login.
To run SSO-only, hide the password form with tesseraql.console.login.password.enabled: false;
turn the login page off entirely with tesseraql.console.login.enabled: false. Logging out is
GET /_tesseraql/logout (invalidates the session, clears the cookie).
State-changing console actions are CSRF-protected (csrf: true): the page publishes the session’s
token as <meta name="csrf-token">, the Hypermedia Components kit replays it as the X-CSRF-Token
header on htmx requests, and no-JS forms carry it as a hidden _csrf field.
Returning to the requested page. The page the user originally opened is threaded through every method: password login carries
next, OIDC carries it across the IdP round-trip in a short-lived cookie, and SAML uses RelayState. The target is always sanitized to a same-origin path (no open redirect);tesseraql.oidc.postLoginUrlis the fallback when none was requested.
The hand-built Studio JSON API under
/_tesseraql/studio/*(distinct from the/uipages) staysauth: bearerfor programmatic callers; only the browser UI uses sessions.
Runtime error codes
Section titled “Runtime error codes”Returned at request time (distinct from the lint codes below, which are static checks):
| Code | HTTP | Meaning & what to do |
|---|---|---|
TQL-SEC-4011 |
401 | Unauthorized — the route needs authentication and the request carried no valid credential (missing/expired session cookie, or missing/invalid/expired bearer token). For the admin console, sign in at /_tesseraql/login; for a bearer route, present a valid Authorization: Bearer <jwt>. A browser navigation (Accept: text/html) is redirected to the login page automatically. |
TQL-SEC-4031 |
403 | Forbidden — authenticated, but the principal does not satisfy the route’s policy (missing role/permission), or the policy is undefined (deny by default). Grant the role/permission, or define the policy. |
TQL-SEC-4032 |
403 | CSRF check failed — a state-changing auth: browser request arrived without a valid CSRF token. Send the page’s X-CSRF-Token header (htmx does this automatically) or the _csrf form field from a live session. |
Lint rules
Section titled “Lint rules”| Code | Severity | Meaning |
|---|---|---|
TQL-SEC-4040 |
error | RS256 JWT config declares no key source (jwksUri or publicKey). |
TQL-SEC-4041 |
error | RS256 JWT config declares both key sources; set exactly one. |
TQL-SEC-4042 |
error | Algorithm and key material disagree (HS256 secret with RS256 key material, or vice versa) — an algorithm-confusion risk. |
TQL-SEC-4043 |
error | Unsupported JWT algorithm (use HS256 or RS256; none is rejected). |
TQL-SEC-4044 |
error | A route declares auth: apiKey but no tesseraql.security.apiKeys is configured. |
TQL-SEC-4045 |
error | An API-key client declares no secretHash. |
TQL-SEC-4046 |
warning | An API-key client grants no roles or permissions (least-privilege hint). |
TQL-SEC-4050 |
error | OIDC is enabled but no discoveryUri is configured. |
TQL-SEC-4051 |
error | The OIDC discoveryUri is not https (loopback http is allowed for dev). |
TQL-SEC-4052 |
error | OIDC is enabled but no clientId is configured. |
TQL-SEC-4053 |
error | OIDC is enabled but no redirectUri is configured. |
TQL-SEC-4060 |
error | A route declares auth: mtls but no tesseraql.security.mtls is configured. |
TQL-SEC-4061 |
error | mTLS is configured but declares no forwardedHeader (the certificate has no source). |
TQL-SEC-4062 |
error | An mTLS client declares no certificate matcher (subjectDn/san/sha256). |
TQL-SEC-4063 |
error | An mTLS client declares more than one certificate matcher; set exactly one. |
TQL-SEC-4064 |
warning | An mTLS client grants no roles or permissions (least-privilege hint). |
TQL-SEC-4065 |
warning | mTLS declares no trustBundle; the runtime does not independently validate the chain. |
The lint reads raw config — it never resolves secret placeholders — so it runs without a live secret store.
Coverage
Section titled “Coverage”The api-key coverage kind declares every route authenticated by auth: apiKey and marks it
covered when a declarative suite exercises it, so a test gap on a service-caller route is visible.
Gate it like any kind with coverage.thresholds.api-key. The mtls coverage kind does the same for
routes authenticated by auth: mtls; gate it with coverage.thresholds.mtls. The oidc coverage
kind (like saml)
declares the identity contracts the login path runs when user linking is on, covered by contract
test cases; gate it with coverage.thresholds.oidc. RS256 vs HS256 is a verification detail of the
same bearer path and is covered by the existing security/route kinds; its cryptographic
guarantees are pinned by the unit tests in tesseraql-security.
Testing
Section titled “Testing”Declarative suites exercise a route’s SQL through the same pipeline regardless of authentication
method. End-to-end authentication wiring is covered by integration tests in tesseraql-camel-runtime
(RsaJwksIntegrationTest serves a JWKS document from a local HTTP server and asserts accept /
reject / rotation; ApiKeyIntegrationTest asserts 200/401/403 for valid, invalid, and
under-privileged keys; MtlsIntegrationTest forwards a client certificate in the configured header
and asserts 200/401/403 for a recognized, an expired/unrecognized/missing, and an
under-privileged certificate; OidcLoginIntegrationTest drives the full authorization-code + PKCE
flow against a local mock provider and asserts a session is issued and that tampered / replayed state
and provider errors are rejected). mTLS certificate parsing, validity, PKIX trust, and DN/SAN/sha256
matching are unit-tested in tesseraql-security (MtlsAuthenticatorTest); OIDC’s PKCE S256 is
unit-tested against the RFC 7636 vector.