# Application-level overrides for the standalone template. # # The library ships its declarative defaults in # @o3co/auth-provider-core/reference.conf # (resolved at boot via standalone/src/app.mts withFallback chain). # Values omitted here inherit from that baseline; add overrides only # for keys this deployment intentionally diverges on. http { port = 3000 port = ${?HTTP_PORT} # false | true | |
. Prefer naming the proxy: # HTTP_TRUST_PROXY=10.0.0.0/8 (or `loopback` for a sidecar) rather than # `true`, which trusts a forwarded client address from anyone who can reach # this process. See the header comment in the library's reference.conf. trustProxy = false trustProxy = ${?HTTP_TRUST_PROXY} } # #287: where the audit trail goes. Before this block the template wired no # sink at all, so every security event the routes emit -- token.issued.failure, # authorize.rejected / authorize.granted, logout.cascade_failed, and the shared # rate-limit guard's rate_limit.unavailable -- was discarded by the artifact # operators deploy. Nothing failed and nothing warned: `emitAuditEvent` is a # no-op when the slot is empty. # # "logger" rather than reference.conf's "console": both write newline-delimited # JSON to stdout, but "logger" writes it through the same pino stream that # carries every other line this template emits, so a log aggregator ingests # application logs and audit events with one parser and separates them on # `name` ("provider" vs "audit"). "console" writes the bare event with no log # envelope -- no level, no time -- which most pipelines then timestamp by # ingestion. Set AUDIT_SINK_TYPE=console if that is what your pipeline wants. # # The audit stream's level is FIXED at info and `logging.level` does not gate # it. LOG_LEVEL=warn is an ordinary production setting and LOG_LEVEL=silent a # legitimate one; either silencing the audit trail would be #287 again, reached # from the operator's side. Where events go is chosen here, and this selector # has no "none" -- an unknown type fails boot naming the sinks that exist, # rather than producing a deployment with no audit trail (#304). # # To point this at a real sink (SIEM, log pipeline, message bus): register the # builder in `auditSinkModule` (src/modules.mts), name it here, and put its # options in this block -- `sink { type = "splunk-hec", splunk-hec { ... } }`. audit { sink { type = "logger" type = ${?AUDIT_SINK_TYPE} } } oauth { grants { # Template is a typical web-app OP — enable the universal user-auth path. # client_credentials remains off (reference.conf default); deployments that # need M2M flows set OAUTH_GRANTS_CLIENT_CREDENTIALS_ENABLED=true or add # `client_credentials { enabled = true }` in their own application.conf. # # The env-override line (`enabled = ${?OAUTH_GRANTS_*_ENABLED}`) is repeated # at this layer because HOCON precedence: application.conf wins over # reference.conf, so without the env-override line at the template layer, # an operator setting OAUTH_GRANTS_SESSION_ENABLED=false (etc.) cannot # disable the grant — the env var only takes effect at the layer where # the substitution is written. session { enabled = true enabled = ${?OAUTH_GRANTS_SESSION_ENABLED} } # #273: there is no `pkce` block here any more. PKCE is mandatory for # every authorization-code client and `S256` is the only method the # provider accepts — that is fixed policy (OAuth 2.1 §4.1.1 / RFC 9700 # §2.1.1), not a deployment setting, so `requireS256` / `required` / # `defaultMethod` / `supportedMethods` are gone. A legacy client that # genuinely cannot compute SHA-256 is opted into `plain` one client at a # time, with `allowPlainPkce: true` in clients.yaml. The # OAUTH_GRANTS_AUTHORIZATION_CODE_PKCE_REQUIRE_S256 tombstone in core's # reference.conf makes a still-exported env var warn at boot. authorization_code { enabled = true enabled = ${?OAUTH_GRANTS_AUTHORIZATION_CODE_ENABLED} } refresh_token { enabled = true enabled = ${?OAUTH_GRANTS_REFRESH_TOKEN_ENABLED} } # client_credentials: omitted -> inherits reference.conf default (enabled = false). # Operators that need M2M flows set OAUTH_GRANTS_CLIENT_CREDENTIALS_ENABLED=true # or add `client_credentials { enabled = true }` here. } # OR-9: adapter switch for the OAuth authorization-code repository. When # set to "redis", the standalone composition root wires # `redisCodeRepositoryModule` against the shared ioredis socket # (`standaloneRedisClientsModule`). Memory-only deployments lose codes on # restart and across replicas — multi-replica production MUST use "redis". # Supersedes the legacy `repositories.code.type` switch (kept below as # deprecated for backward compat — see CHANGELOG for the removal version). # #267: /authorize refuses any client not marked `firstParty: true` in # clients.yaml — the template's example clients are marked. There is no # opt-out: the one-time `allowUnmarkedClients` migration flag was removed # (#330), and a config or environment still setting it fails at boot with # migration instructions (via the tombstone in core's reference.conf). code { adapter = "redis" adapter = ${?OAUTH_CODE_ADAPTER} } # #277: POST /oauth/revoke revokes access tokens by writing their `jti` to the # denylist wired below, and boot FAILS if that denylist is missing — an # RFC 7009 endpoint must answer 200, so an unwired denylist would tell every # operator their token is revoked while it kept working until expiry. # # Deployments that genuinely do not revoke access tokens (short-lived tokens # plus refresh-token revocation only) set "unsupported" instead, and the # endpoint then answers `unsupported_token_type` for # `token_type_hint = access_token`. Refresh-token revocation works in both # modes and needs no denylist. revocation { accessToken = "denylist" accessToken = ${?OAUTH_REVOCATION_ACCESS_TOKEN} } } # #277: backend for the access-token denylist. # # "redis" by default, not "memory": the memory adapter keeps revocations in one # process, so a token revoked on one replica stays valid on every other — and # `deployment.mode = "multi"` refuses it by name for exactly that reason. It # shares the ioredis socket configured under `refreshTokenFamilyStore.redis` # below, so switching it on costs no extra connection. # # Single-instance local development can set ACCESS_TOKEN_DENYLIST_ADAPTER=memory # to run without Redis. accessTokenDenylist { adapter = "redis" adapter = ${?ACCESS_TOKEN_DENYLIST_ADAPTER} } session { secret = ${?SESSION_SECRET} # MIN-2: __Host- prefix requires Secure, Path=/, and no Domain attribute. # If SESSION_SECURE=false or SESSION_DOMAIN is set, also override # SESSION_NAME to a non-__Host- value. name = "__Host-auth.session" name = ${?SESSION_NAME} maxAge = 3600000 maxAge = ${?SESSION_MAX_AGE} secure = true secure = ${?SESSION_SECURE} sameSite = "lax" sameSite = ${?SESSION_SAME_SITE} domain = null domain = ${?SESSION_DOMAIN} storage { type = "redis" type = ${?SESSION_STORAGE_TYPE} redis { url = "redis://localhost:6379" url = ${?SESSION_STORAGE_REDIS_URL} password = ${?SESSION_STORAGE_REDIS_PASSWORD} } } } # Rate limiting for SESSION routes (e.g. /session/login bruteforce protection). # Uses windowMs (milliseconds) — consumed by express-rate-limit in Session.mts. # NOTE (IH-18): this section does NOT govern OAuth endpoint rate limiting. # For OAuth rate limiting (/token, /authorize), use the `rateLimiter` # component (memoryRateLimiterModule or redisRateLimiterModule) with # its own `memoryRateLimiter.*` / `redisRateLimiter.*` config section # (uses `windowSeconds` instead of `windowMs`). Switch via # `rateLimiter.adapter = "memory" | "redis"` (see reference.conf). rateLimit { # `login` is per-deployment tuning (bruteforce-window + limit), kept in # this layer so operators can tweak without forking reference.conf. login { windowMs = 900000, limit = 20 } # rateLimit.failMode: inherits reference.conf default ("closed"). # Deployments that prefer fail-open override here: # failMode = "open" # failMode = ${?RATE_LIMIT_FAIL_MODE} } federations { # Template ships a Google federation declaration disabled-by-default. # Operators enable + populate credentials via env vars. google { enabled = false enabled = ${?FEDERATIONS_GOOGLE_ENABLED} clientId = ${?FEDERATIONS_GOOGLE_CLIENT_ID} clientSecret = ${?FEDERATIONS_GOOGLE_CLIENT_SECRET} callbackURL = "http://localhost:3000/session/oauth/federation/google/callback" callbackURL = ${?FEDERATIONS_GOOGLE_CALLBACK_URL} # Every URL a `redirect_to` query parameter may name on # /session/oauth/federation/google. Matching is EXACT — scheme, host, port, # path, query and fragment all count — so list each landing page in full. # There is no wildcard and no subdomain matching. # # An empty list refuses every `redirect_to`, which is the right setting for # a deployment that does not use the parameter. It is NOT a way to allow # everything: before #278 an unset allowlist accepted any http(s) URL, which # made this an open redirect. Nothing falls back to that any more. # # https is required, except for a loopback host (localhost, 127.0.0.0/8, # [::1]) — that carve-out is what lets a local dev front-end and a native # client on an ephemeral loopback listener work without a certificate. The # port is still part of the match, so list the port the client binds. # # redirectAllowlist = [ # "https://app.example.com/welcome" # "http://localhost:5173/welcome" # ] redirectAllowlist = [] # Cookie / session domain. When set, every non-loopback redirectAllowlist # entry must be inside it, checked at boot — an entry outside it fails # startup rather than sitting here looking effective. # sessionDomain = ${?FEDERATIONS_GOOGLE_SESSION_DOMAIN} # Post-callback bridge page. Required only if `redirect_to` is used at all: # the browser lands here as `?redirect_to=`. # authCallbackUrl = ${?FEDERATIONS_GOOGLE_AUTH_CALLBACK_URL} # Where the browser goes after a callback carrying no `redirect_to`. # clientUrl = ${?FEDERATIONS_GOOGLE_CLIENT_URL} } } repositories { client { type = "yaml" type = ${?CLIENT_TYPE} yaml { path = "./config/clients.yaml" path = ${?CLIENT_PATH} } } user { type = "http" type = ${?CLIENT_USER_TYPE} yaml { path = "./config/users.yaml" } http { # Both URLs carry plaintext user credentials, so both must be absolute # https URLs. http:// is accepted only for a loopback host (localhost, # 127.0.0.0/8, [::1]); a private-range address or a container-network # service name still needs https. Boot fails if either is not. authenticateUrl = ${?CLIENT_USER_AUTHENTICATE_URL} authenticateByTokenUrl = ${?CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL} # Positive integer, milliseconds, <= 2147483647. Boot fails otherwise — # including on a blank env override, which HOCON substitutes as "". timeout = 5000 timeout = ${?CLIENT_USER_TIMEOUT} # Largest upstream response body accepted, in bytes. Positive integer. maxResponseBytes = 1048576 maxResponseBytes = ${?CLIENT_USER_MAX_RESPONSE_BYTES} } } code { type = "redis" type = ${?CLIENT_CODE_TYPE} memory { defaultExpiresIn = 600 defaultExpiresIn = ${?CLIENT_CODE_DEFAULT_EXPIRES_IN} } redis { defaultExpiresIn = 600 defaultExpiresIn = ${?CLIENT_CODE_DEFAULT_EXPIRES_IN} endpointUri = ${?CLIENT_CODE_ENDPOINT_URI} password = ${?CLIENT_CODE_PASSWORD} } } } endpoints { login { url = "/login" url = ${?ENDPOINTS_LOGIN_URL} } # IH-10: `client { url }` and `authCallback { url }` removed — no # production consumer reads them. } cors { allowedOrigins = [] } # D-2 v2 + Wave 5d: ioredis connection-config consumed by the shared # `standaloneRedisClientsModule` in `templates/standalone/src/modules.mts`. # One ioredis socket per replica backs ALL Redis-typed clients (RT family # + 4 user-session stores + rate limiter). Multi-replica deployments MUST # point this at a shared Redis 7.2+ instance — without it, each replica # stores RT families in its own ioredis connection against a local-only # Redis, defeating the cross-replica persistence purpose. refreshTokenFamilyStore { redis { url = "redis://localhost:6379" url = ${?REFRESH_TOKEN_FAMILY_STORE_REDIS_URL} password = ${?REFRESH_TOKEN_FAMILY_STORE_REDIS_PASSWORD} } }