396 lines
20 KiB
Markdown
396 lines
20 KiB
Markdown
# Security Posture
|
|
|
|
This document describes the threat model and security posture of the
|
|
`saltcorn-idp` plugin: how secrets are sealed at rest, how CSRF is handled, the
|
|
asymmetric-signing model, the XML External Entity (XXE) posture, dependency
|
|
pinning, and the concrete hardening already in the code (each tied to a source
|
|
file and line). It also summarizes the adversarial review the protocol surfaces
|
|
went through.
|
|
|
|
For the XML dependency analysis in full, see [`../VENDORING.md`](../VENDORING.md).
|
|
Related docs: [`./architecture.md`](./architecture.md),
|
|
[`./configuration.md`](./configuration.md), [`./oidc.md`](./oidc.md),
|
|
[`./ldap.md`](./ldap.md).
|
|
|
|
## Threat model
|
|
|
|
The plugin turns Saltcorn into an SSO Identity Provider over three protocol
|
|
surfaces, each with a distinct trust boundary:
|
|
|
|
| Surface | Transport | Authentication of caller | Trust assumption |
|
|
|---------|-----------|--------------------------|------------------|
|
|
| OIDC / OAuth2 | Host HTTP(S) stack | Browser session (interactive) + per-client credentials/PKCE | Relying parties are explicitly registered; redirect URIs are allow-listed |
|
|
| SAML 2.0 | Host HTTP(S) stack | Browser session; per-SP registry + optional AuthnRequest signature | Service providers are explicitly registered; ACS URLs are allow-listed |
|
|
| LDAP(S) | Dedicated LDAPS listener | LDAP simple bind (user bcrypt or service account) | Listener is loopback-bound by default; outside Saltcorn's web-login throttling |
|
|
|
|
Adversary capabilities assumed in scope:
|
|
|
|
- An unauthenticated network client able to reach the public `/idp/*` endpoints
|
|
and (if exposed) the LDAPS port.
|
|
- A registered-but-malicious relying party / service provider attempting to
|
|
steer assertions, redirects, or tokens beyond its allow-listed endpoints.
|
|
- A client sending malformed, oversized, or maliciously structured protocol
|
|
messages (XML bombs, deeply nested LDAP filters, multi-GB BER messages,
|
|
DN-injection payloads, XXE).
|
|
|
|
Out of scope: compromise of the host running Saltcorn, compromise of
|
|
`SALTCORN_SESSION_SECRET`, and the security of the upstream Saltcorn auth stack
|
|
itself. The plugin makes no outbound network calls during request handling
|
|
(see [`../VENDORING.md`](../VENDORING.md)).
|
|
|
|
## At-rest secret sealing (KEK)
|
|
|
|
All long-lived secrets the plugin persists -- OIDC signing private keys, the
|
|
SAML signing private key, confidential OIDC client secrets, and the LDAP
|
|
service-account password -- are sealed before they touch the database.
|
|
|
|
Implementation: [`lib/crypto.js`](../lib/crypto.js).
|
|
|
|
| Aspect | Value | Source |
|
|
|--------|-------|--------|
|
|
| Cipher | AES-256-GCM | `crypto.js` (`GCM_ALGORITHM`) |
|
|
| KEK derivation | HKDF-SHA256 from `SALTCORN_SESSION_SECRET` | `crypto.js` |
|
|
| KEK info string | `saltcorn-idp:at-rest:aes-gcm-key:v1` | `crypto.js` |
|
|
| KEK salt | `saltcorn-idp:at-rest:aes-gcm-salt:v1` | `crypto.js` |
|
|
| IV | 12 random bytes per seal | `crypto.js`, `crypto.js` |
|
|
| KEK length | 32 bytes (256-bit) | `crypto.js` |
|
|
| GCM auth tag | stored alongside ciphertext + IV | `crypto.js`, `crypto.js` |
|
|
|
|
The info string is domain-separated so the IdP's KEK differs from any sibling
|
|
plugin's even though both derive from the same session secret
|
|
(`crypto.js`). Ciphertext, IV, and tag are stored as hex strings via
|
|
`sealText`/`openText` because Saltcorn's SQLite layer JSON-stringifies values
|
|
and cannot hold raw Buffers (`crypto.js`, `crypto.js`).
|
|
|
|
The KEK source resolves in this order (`crypto.js`): the
|
|
`SALTCORN_SESSION_SECRET` environment variable, then Saltcorn's `session_secret`
|
|
config, otherwise a hard error (`SALTCORN_SESSION_SECRET not available; cannot
|
|
derive KEK`). The KEK is cached per process and keyed by a SHA-256 hash of the
|
|
secret, so a rotated secret never keeps serving a stale KEK -- if the secret
|
|
changes the KEK is re-derived (`crypto.js`).
|
|
|
|
### Secret-rotation behavior
|
|
|
|
Rotating `SALTCORN_SESSION_SECRET` changes the KEK, which means all previously
|
|
sealed data (signing keys, SAML key, client secrets, LDAP service password) can
|
|
no longer be decrypted. This is documented, intentional behavior
|
|
(`crypto.js`). Plan rotation accordingly: rotating the session secret
|
|
requires re-bootstrapping the IdP's sealed material.
|
|
|
|
## CSRF model
|
|
|
|
| Namespace | CSRF | Mechanism |
|
|
|-----------|------|-----------|
|
|
| `/idp/*` (OIDC, JWKS, SAML, machine endpoints) | Exempt | `noCsrf: true` on each route; `/idp/` added to Saltcorn's `disable_csrf_routes` config at load |
|
|
| `/admin/idp/*` (browser admin UI) | Protected | Saltcorn's standard CSRF guard; every admin POST form embeds a `_csrf` token |
|
|
|
|
The `/idp/` namespace holds only machine/OIDC/SAML endpoints that are never
|
|
driven by Saltcorn browser forms: oidc-provider manages its own CSRF/state, and
|
|
SAML messages are XML payloads validated server-side against the registered-SP
|
|
allow-list rather than via form tokens. The bypass is registered once at startup
|
|
by `ensureCsrfBypass()`, which appends `IDP_BASE_PATH + "/"` to the global
|
|
(root-state) `disable_csrf_routes` config if not already present
|
|
([`index.js`](../index.js)). Each SAML route is additionally declared with
|
|
`noCsrf: true` ([`lib/saml/routes.js`](../lib/saml/routes.js)).
|
|
|
|
Admin pages under `/admin/idp` stay CSRF-protected because they are
|
|
browser-mediated state changes (client registration, SP registration, group and
|
|
LDAP service-account management).
|
|
|
|
## Asymmetric signing (RS256 / JWKS) vs Saltcorn HS256
|
|
|
|
Saltcorn's own session/JWT handling uses a symmetric secret (HS256-style).
|
|
Federated SSO tokens cannot be verified by relying parties that hold only a
|
|
shared secret, so the IdP issues asymmetrically signed tokens whose public half
|
|
is published for verification.
|
|
|
|
| Use | Algorithm | Key material |
|
|
|-----|-----------|--------------|
|
|
| OIDC `id_token` / JWT signing | RS256 (RSA-2048) | Per-tenant keypair in `_idp_keys`; private half sealed at rest, public half published at `/idp/jwks` |
|
|
| SAML assertion signing | RS256 (RSA-2048) | Per-tenant self-signed cert in `_idp_saml`; private key sealed at rest |
|
|
| oidc-provider session/auth cookies | Derived from `SALTCORN_SESSION_SECRET` | Deterministic HKDF (`deriveSecretHex`) so cookie keys survive restarts |
|
|
|
|
Constants: `SIGNING_ALG = "RS256"` and `RSA_MODULUS_BITS = 2048`
|
|
([`lib/constants.js`](../lib/constants.js)). Public keys are exported as
|
|
JWKs with `kid`, `alg`, and `use: "sig"`, and the exporter rejects anything that
|
|
is not a complete RSA public key ([`lib/crypto.js`](../lib/crypto.js)).
|
|
The cookie-signing keys use a distinct HKDF info string and are not the same key
|
|
as the at-rest KEK ([`lib/crypto.js`](../lib/crypto.js)).
|
|
|
|
The asymmetric model means a relying party only ever holds the public JWK: a
|
|
leaked verification key cannot mint tokens, in contrast to a shared-secret
|
|
HS256 deployment.
|
|
|
|
## XXE posture
|
|
|
|
Inbound SAML XML is defended in depth; see [`../VENDORING.md`](../VENDORING.md)
|
|
(section "SAML XML stack") for the full dependency analysis. Summary:
|
|
|
|
- samlify ships an XXE-safe DOMParser by default (throwing `error`/`fatalError`
|
|
handlers). The plugin re-asserts this from its own code via
|
|
`saml.setDOMParserOptions({})` so a future samlify default change cannot
|
|
silently re-enable entity expansion (`lib/saml/idp.js`; VENDORING.md).
|
|
- `@xmldom/xmldom` 0.8.x seeds only the 5 predefined XML entities and never
|
|
resolves external/SYSTEM entities (VENDORING.md).
|
|
- `node-xmllint` validates only against the 4 bundled SAML XSDs; no external
|
|
schema fetch (VENDORING.md).
|
|
- Defence in depth at the edge: `decodeRequest()` rejects any inbound SAML
|
|
message containing a `<!DOCTYPE` or `<!ENTITY` declaration before it reaches
|
|
the parser, returning HTTP 400. The screen is the regex
|
|
`DTD_RE = /<!DOCTYPE|<!ENTITY/i`
|
|
([`lib/saml/routes.js`](../lib/saml/routes.js), enforced at
|
|
`lib/saml/routes.js`).
|
|
|
|
## Dependency pinning and overrides
|
|
|
|
The security-relevant dependency chain is pinned exact (the caret was dropped)
|
|
and the XML parser/signature libraries are floored via npm `overrides`. Details
|
|
and the CVE log are in [`../VENDORING.md`](../VENDORING.md).
|
|
|
|
| Dependency | Constraint | Reason |
|
|
|------------|-----------|--------|
|
|
| `ldapjs` | `3.0.7` (exact) | BER/ASN.1 parser; guards live at the handler layer, not in the parser |
|
|
| `samlify` | `2.13.1` (exact) | SAML 2.0 processing; XXE-safe defaults re-asserted |
|
|
| `@authenio/samlify-node-xmllint` | `2.0.0` (exact) | SAML XSD validation (bundled libxml2) |
|
|
| `@xmldom/xmldom` | `>=0.8.13 <0.9` (npm `overrides`) | Keeps the no-external-entity 0.8.x behavior without a hard pin breaking transitive resolution |
|
|
| `xml-crypto` | `>=6.1.2` (npm `overrides` + runtime assertion) | Floor for the 2025 SAML signature-verification CVEs |
|
|
|
|
The `xml-crypto` floor (`XML_CRYPTO_MIN = "6.1.2"`,
|
|
[`lib/constants.js`](../lib/constants.js)) is additionally asserted at module
|
|
load in `lib/saml/idp.js`, failing closed if the resolved version is below the
|
|
floor (VENDORING.md).
|
|
|
|
The `ldapjs` dependency is governed by a single-chokepoint invariant: only
|
|
[`lib/ldap/vendor.js`](../lib/ldap/vendor.js) is allowed to `require("ldapjs")`.
|
|
Verify with:
|
|
|
|
```
|
|
grep -rn "require(['\"]ldapjs" lib # must match ONLY lib/ldap/vendor.js
|
|
```
|
|
|
|
## Hardening in code
|
|
|
|
Each item below is enforced in the plugin's own code (the layer it controls),
|
|
not delegated to a dependency.
|
|
|
|
### SAML decompression-bomb caps
|
|
|
|
DEFLATE can expand roughly 1000:1, so an unbounded inflate is a memory bomb that
|
|
would crash the worker. The SAML decode path bounds work before any allocation
|
|
([`lib/saml/routes.js`](../lib/saml/routes.js)):
|
|
|
|
| Guard | Constant | Value | Enforced |
|
|
|-------|----------|-------|----------|
|
|
| Base64 size cap (pre-decode) | `SAML_MAX_MSG_B64_BYTES` | 64 KiB | `routes.js` |
|
|
| Inflated XML size cap (redirect binding) | `SAML_MAX_XML_BYTES` | 256 KiB | `routes.js` (`inflateRawSync(..., { maxOutputLength })`) |
|
|
| Decoded size cap (POST binding) | `SAML_MAX_XML_BYTES` | 256 KiB | `routes.js` |
|
|
|
|
Constant values: [`lib/constants.js`](../lib/constants.js). These handlers
|
|
are fully unauthenticated (no CSRF, no API-token gate), so the bound is applied
|
|
before the work, not after (`routes.js`).
|
|
|
|
### SLO hardening: authenticated, subject-bound, no-request-ACS open-redirect fix
|
|
|
|
Ending a session via a GET is state-changing, and the handler signs a
|
|
LogoutResponse it then POSTs somewhere -- so SLO is hardened against forged /
|
|
cross-site LogoutRequests on several axes (`lib/saml/routes.js` `sloHandler`):
|
|
|
|
- **Authenticated session required.** If `req.user` is absent, return HTTP 403
|
|
-- an unauthenticated request (or a forged cross-site GET) has no session to
|
|
end, so it cannot drive SLO.
|
|
- **Subject must match the session.** The LogoutRequest `NameID` must equal the
|
|
session user's email (case-insensitive); a mismatch returns HTTP 403, so one
|
|
SP cannot log out a *different* user.
|
|
- **Signature required for cert-registered SPs.** An SP that registered a signing
|
|
cert must sign its LogoutRequest (`wantLogoutRequestSigned`), verified like the
|
|
AuthnRequest path. Residual: an SP with no registered cert cannot be verified,
|
|
so a cross-site request can at most force the current user's OWN logout
|
|
(bounded by the two checks above); we do not reject unsigned SLO outright
|
|
because some SPs legitimately do not sign LogoutRequests.
|
|
- **Registered-ACS-only destination.** Reject if the SP has no registered ACS
|
|
(HTTP 403), mirroring the SSO empty-list rejection; deliver only to the SP's
|
|
first registered ACS. The `matchAcs(xml)` request value is deliberately not
|
|
used as the destination -- taking the destination from the request would let a
|
|
crafted LogoutRequest steer the signed response (and any RelayState) to an
|
|
attacker-chosen endpoint (open redirect + leak).
|
|
|
|
For contrast, the SSO handler does allow a request-named ACS, but only after
|
|
validating it against the allow-list with `acsAllowed()`
|
|
([`lib/saml/sps.js`](../lib/saml/sps.js)); an unrecognized ACS is rejected
|
|
with HTTP 403 ([`lib/saml/routes.js`](../lib/saml/routes.js)). Allow-list
|
|
matching is exact-string (no trailing-slash or scheme fuzzing,
|
|
`lib/saml/sps.js`). Login Responses are always delivered to the validated
|
|
ACS, never to `response.entityEndpoint` (`lib/saml/routes.js`).
|
|
|
|
Two related SAML guards on the same path:
|
|
|
|
- Parser-differential guard: the issuer parsed authoritatively by samlify must
|
|
equal the entityID the SP was looked up by, else HTTP 403
|
|
([`lib/saml/routes.js`](../lib/saml/routes.js)).
|
|
- Per-SP AuthnRequest signature verification: SPs registered with
|
|
`want_authn_requests_signed` use the signature-verifying IdP variant and have
|
|
the request bound to the exact signed octet string for the redirect binding
|
|
([`lib/saml/routes.js`](../lib/saml/routes.js)); conforming-but-unsigned
|
|
SPs are not locked out.
|
|
|
|
### LDAP per-message byte cap
|
|
|
|
The BER parser has no message-size limit, so a declared multi-GB message would
|
|
buffer unbounded. `createHardenedServer()` wraps ldapjs with a
|
|
`connectionRouter` that counts inbound bytes and destroys the connection if a
|
|
single message exceeds the cap
|
|
([`lib/ldap/vendor.js`](../lib/ldap/vendor.js)):
|
|
|
|
- Cap: `LDAP_MAX_MSG_BYTES = 256 * 1024` (256 KiB,
|
|
[`lib/constants.js`](../lib/constants.js)), enforced at `vendor.js`.
|
|
- The counter resets on each parsed-message boundary
|
|
(`conn.parser.on("message")`, `vendor.js`). It is deliberately per-message,
|
|
not a connection-lifetime quota: a per-connection counter would let an attacker
|
|
kill a legitimate long-lived connection after a few normal operations
|
|
(`vendor.js`).
|
|
|
|
### RFC 4514 DN escaping
|
|
|
|
User emails and group names are admin-controlled free text that may contain DN
|
|
special characters. Without escaping, the concatenated DN would be malformed and
|
|
ldapjs's `DN.fromString()` would throw while building the SearchEntry, aborting
|
|
the whole search. `escapeDnValue()` escapes on output
|
|
([`lib/ldap/dn.js`](../lib/ldap/dn.js)):
|
|
|
|
- Special-anywhere characters `\ " , + ; < > =` (`dn.js`).
|
|
- Positionally special leading `#`/space and trailing space (`dn.js`).
|
|
- NUL byte to `\00` (`dn.js`).
|
|
|
|
Applied to the result and `memberOf` DNs we emit via `userDn`/`groupDn`
|
|
(`dn.js`); inbound bind DNs are formatted by the client, not by us
|
|
(`dn.js`).
|
|
|
|
### Filter-depth cap
|
|
|
|
The LDAP filter parser recurses without a depth bound. The search handler walks
|
|
the parsed filter iteratively (stack-based, not recursive) and rejects nesting
|
|
deeper than the cap before any DB work
|
|
([`lib/ldap/search.js`](../lib/ldap/search.js), guard at `search.js`):
|
|
|
|
- Cap: `LDAP_MAX_FILTER_DEPTH = 32` ([`lib/constants.js`](../lib/constants.js)).
|
|
- Over-depth returns an `OperationsError` (`search.js`).
|
|
|
|
### Per-IP bind lockout
|
|
|
|
The LDAP port is outside Saltcorn's web-login throttling, so the plugin
|
|
rate-limits failed binds per source IP
|
|
([`lib/ldap/harden.js`](../lib/ldap/harden.js)):
|
|
|
|
| Parameter | Value | Source |
|
|
|-----------|-------|--------|
|
|
| Max failures per IP | `MAX_FAILS = 10` | `harden.js` |
|
|
| Window | `WINDOW_MS = 5 * 60 * 1000` (5 min) | `harden.js` |
|
|
|
|
`isLocked(ip)` returns true once the count reaches 10 within the 5-minute window;
|
|
the window resets on expiry or on a successful bind (`harden.js`). The bind
|
|
handler checks the lock first and records a failure on every denial (invalid DN,
|
|
oversized/empty credentials, cross-tenant, wrong password) and a success on a
|
|
good bind ([`lib/ldap/bind.js`](../lib/ldap/bind.js)). The store is an
|
|
in-memory `Map`, per process, cleared on restart (`harden.js`), and is not
|
|
replicated across a cluster.
|
|
|
|
### LDAP loopback-default bind and exposure warning
|
|
|
|
The LDAPS listener binds loopback by default and is LDAPS-only (no plaintext, no
|
|
StartTLS); it binds only in the cluster primary process.
|
|
|
|
| Setting | Default | Env var | Source |
|
|
|---------|---------|---------|--------|
|
|
| Bind host | `127.0.0.1` | `SALTCORN_IDP_LDAP_HOST` | [`lib/constants.js`](../lib/constants.js) |
|
|
| Listener enabled / port | (disabled unless set) | `SALTCORN_IDP_LDAP_PORT` | [`lib/constants.js`](../lib/constants.js) |
|
|
|
|
If the operator binds to a non-loopback host, the server logs an explicit
|
|
exposure warning at startup
|
|
([`lib/ldap/server.js`](../lib/ldap/server.js)):
|
|
|
|
```
|
|
[saltcorn-idp] NOTE: LDAP is bound to <host> (beyond loopback) -- it is
|
|
reachable from the network; ensure this is intended and firewalled appropriately.
|
|
```
|
|
|
|
Loopback is treated as `127.0.0.1`, `::1`, or `localhost`
|
|
([`lib/ldap/server.js`](../lib/ldap/server.js)). The single-listener,
|
|
primary-only binding is documented in [`../VENDORING.md`](../VENDORING.md).
|
|
|
|
### Anonymous-bind / anonymous-operation deny
|
|
|
|
- Bind: anonymous binds (no DN, or empty password) are denied, as are absurdly
|
|
long credentials (`MAX_CRED_LEN = 1024`). The denial returns
|
|
`InvalidCredentialsError` and records a failure for lockout
|
|
([`lib/ldap/bind.js`, `bind.js`](../lib/ldap/bind.js)).
|
|
- Search: an anonymous-bound connection (`cn=anonymous`) is denied with
|
|
`InsufficientAccessRightsError` before any work
|
|
([`lib/ldap/search.js`](../lib/ldap/search.js)).
|
|
- Passwords are verified against Saltcorn's stored bcrypt hash via
|
|
`user.checkPassword()` and never stored or echoed; disabled users are rejected
|
|
(`lib/ldap/bind.js`). The service-account password is compared with a
|
|
constant-time comparison (`crypto.timingSafeEqual`) to avoid a timing oracle
|
|
(`lib/ldap/bind.js`, `bind.js`).
|
|
|
|
### Cross-tenant deny
|
|
|
|
The tenant is derived from the bind DN base (`dc=<tenant>,...`) and all lookups
|
|
run inside `runWithTenant`. Cross-tenant access is denied on both operations:
|
|
|
|
- Bind: the tenant resolved from the bind DN is rejected if it resolves to a deny
|
|
(unknown tenant, or an explicit tenant in single-tenant mode)
|
|
([`lib/ldap/bind.js`](../lib/ldap/bind.js)).
|
|
- Search: the tenant of the search base must equal the tenant of the bound
|
|
connection; a mismatch (or a deny on the base) returns
|
|
`InsufficientAccessRightsError`
|
|
([`lib/ldap/search.js`](../lib/ldap/search.js)).
|
|
|
|
Tenant resolution and the DN-encoding scheme are described in
|
|
[`./ldap.md`](./ldap.md).
|
|
|
|
## Adversarial review
|
|
|
|
The protocol surfaces were built under a security-first directive: enumerate
|
|
abuse cases and write adversarial tests before coding each module. The
|
|
public-facing SAML and LDAP handlers went through two rounds of adversarial
|
|
review, which surfaced four HIGH-severity and one MEDIUM-severity issues; all
|
|
five were fixed, and the fixes are visible in the current code:
|
|
|
|
- SLO open redirect: the LogoutResponse destination could fall back to a
|
|
request-supplied ACS. Fixed by requiring a registered ACS and never using the
|
|
request-parsed URL ([`lib/saml/routes.js`](../lib/saml/routes.js)).
|
|
- SAML decompression bomb: unbounded inflate of a DEFLATE-compressed
|
|
SAMLRequest. Fixed with the base64 and inflated-XML caps
|
|
([`lib/saml/routes.js`](../lib/saml/routes.js)).
|
|
- LDAP BER message bomb: no parser size limit. Fixed with the per-message byte
|
|
cap ([`lib/ldap/vendor.js`](../lib/ldap/vendor.js)).
|
|
- LDAP filter bomb: unbounded recursive filter parsing. Fixed with the
|
|
filter-depth cap ([`lib/ldap/search.js`](../lib/ldap/search.js)).
|
|
- DN injection aborting searches (MEDIUM): unescaped admin-controlled values in
|
|
emitted DNs. Fixed with RFC 4514 escaping on output
|
|
([`lib/ldap/dn.js`](../lib/ldap/dn.js)).
|
|
|
|
Items dismissed during review were those judged not exploitable in the plugin's
|
|
trust model -- notably the decision NOT to vendor a `ldapjs` fork (the security
|
|
guards live in the plugin's owned connection/handler layer, not in the BER
|
|
parser, so a fork buys nothing) and NOT to fork the SAML XML stack (the XXE-safe
|
|
defaults are asserted from plugin code and floored via npm `overrides`). Both
|
|
decisions are recorded in [`../VENDORING.md`](../VENDORING.md).
|
|
|
|
## Dual control: safe defaults
|
|
|
|
The plugin defaults to the least-exposed posture, requiring an explicit operator
|
|
opt-in to widen it:
|
|
|
|
- LDAP is disabled unless `SALTCORN_IDP_LDAP_PORT` is set, and binds only to
|
|
loopback (`127.0.0.1`) unless `SALTCORN_IDP_LDAP_HOST` is changed; a
|
|
non-loopback bind logs an explicit network-exposure warning
|
|
([`lib/ldap/server.js`](../lib/ldap/server.js)).
|
|
- LDAP is LDAPS-only -- no plaintext and no StartTLS (VENDORING.md).
|
|
- SAML and OIDC only issue assertions/tokens to explicitly registered SPs/clients
|
|
with allow-listed ACS/redirect URIs.
|
|
- All persisted secrets are sealed at rest under a KEK derived from the session
|
|
secret.
|
|
|
|
For configuration knobs (env vars, multi-tenant issuer derivation, base_url
|
|
trust), see [`./configuration.md`](./configuration.md).
|