@o3co/create-auth-provider 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +20 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +99 -6
- package/package.json +4 -4
- package/templates/standalone/.env.example +43 -4
- package/templates/standalone/Dockerfile +51 -16
- package/templates/standalone/README.ja.md +79 -12
- package/templates/standalone/README.md +345 -17
- package/templates/standalone/config/application.conf +121 -4
- package/templates/standalone/config/clients.yaml.example +41 -0
- package/templates/standalone/docker-compose.production.yml +116 -0
- package/templates/standalone/package.json +14 -18
- package/templates/standalone/src/__tests__/audit-sink.test.mts +343 -0
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +419 -0
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +114 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +231 -0
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +76 -1
- package/templates/standalone/src/__tests__/smoke.test.mts +177 -11
- package/templates/standalone/src/__tests__/terminal-error.test.mts +96 -0
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +117 -7
- package/templates/standalone/src/app.mts +67 -8
- package/templates/standalone/src/buildModules.mts +43 -1
- package/templates/standalone/src/configPath.mts +2 -2
- package/templates/standalone/src/logger.mts +90 -2
- package/templates/standalone/src/metrics.mts +190 -0
- package/templates/standalone/src/modules.mts +281 -33
- package/templates/standalone/src/terminalError.mts +60 -0
- package/templates/standalone/tests/index.test.js +69 -5
- package/templates/standalone/tsconfig.json +9 -1
- package/templates/standalone/vitest.config.mts +8 -0
- package/templates/versions.json +11 -11
|
@@ -4,15 +4,58 @@ Deployable server template for auth.provider. This is the composition root — i
|
|
|
4
4
|
|
|
5
5
|
## Requirements
|
|
6
6
|
|
|
7
|
-
- **Node.js** `>=
|
|
8
|
-
- **`bcrypt` native binary**: this template depends transitively on `bcrypt@6.x`, which ships **prebuilt N-API binaries** for `darwin-arm64`, `darwin-x64`, `linux-x64` (glibc and musl), `linux-arm64` (glibc and musl), `linux-arm`, `win32-x64`, and `win32-arm64`. On these platforms `pnpm install` succeeds without compiling from source, and no extra toolchain is required (the `darwin-*`, `node:*-alpine`, and `node:*-bookworm` images all match a shipped prebuild). On platforms or libc/arch combinations that have no matching prebuild, `pnpm install` falls back to compiling, which then requires a C++ compiler and Python (the standard Node.js native-addon toolchain): Debian/Ubuntu `apt-get install build-essential python3`, Alpine `apk add make g++ python3`, macOS `xcode-select --install`. The pnpm 10 `onlyBuiltDependencies` allowlist for `bcrypt`
|
|
7
|
+
- **Node.js** `>=22.0.0`
|
|
8
|
+
- **`bcrypt` native binary**: this template depends transitively on `bcrypt@6.x`, which ships **prebuilt N-API binaries** for `darwin-arm64`, `darwin-x64`, `linux-x64` (glibc and musl), `linux-arm64` (glibc and musl), `linux-arm`, `win32-x64`, and `win32-arm64`. On these platforms `pnpm install` succeeds without compiling from source, and no extra toolchain is required (the `darwin-*`, `node:*-alpine`, and `node:*-bookworm` images all match a shipped prebuild). On platforms or libc/arch combinations that have no matching prebuild, `pnpm install` falls back to compiling, which then requires a C++ compiler and Python (the standard Node.js native-addon toolchain): Debian/Ubuntu `apt-get install build-essential python3`, Alpine `apk add make g++ python3`, macOS `xcode-select --install`. The pnpm 10 `onlyBuiltDependencies` allowlist for `bcrypt` lives in `pnpm-workspace.yaml` — pnpm ≥10.29 reads it only from there, in single-package projects too. `create-auth-provider` writes that file into the project it scaffolds (inside this monorepo, the allowlist sits in the workspace root's `pnpm-workspace.yaml`); without it a fresh `pnpm install` silently skips the install hook on those platforms.
|
|
9
9
|
- **Redis 7.2 LTS or later** for any Redis-backed adapter (refresh token family store, code repository, federation token store, session store). The `pExpireGT` flag pair on which several adapters depend was introduced in Redis 7.0+; 7.2 LTS is the tested floor. Tested against AWS ElastiCache for Redis 7.2, Upstash Redis, Redis Cloud 7.2, and self-managed `redis:7.2-alpine`.
|
|
10
10
|
- **ioredis** `^5.4.1` (direct runtime dependency, used by the refresh-token-family client). Other Redis-backed adapters in this template still use the `redis` npm package; ioredis is added for the RT family store specifically.
|
|
11
11
|
|
|
12
|
+
## First-party clients
|
|
13
|
+
|
|
14
|
+
`GET /authorize` mints an authorization code as soon as the user's session is authenticated. There is **no consent step**: the user is never asked whether this client may have their identity.
|
|
15
|
+
|
|
16
|
+
That is a coherent model for a **first-party** OP — one where every registered client is operated by you — and it is coherent nowhere else. A page you do not control can force a top-level navigation to `/authorize?client_id=X&redirect_uri=...&code_challenge=<theirs>`, a logged-in user's browser mints a code bound to *their* session, it is delivered to X's registered `redirect_uri`, and whoever chose the `code_challenge` redeems it for tokens carrying that user's identity.
|
|
17
|
+
|
|
18
|
+
So the assumption is enforced rather than assumed. A client must carry `firstParty: true` in its registration to use `/authorize`; anything else is refused with `unauthorized_client`. Mark a client first-party only if you would be comfortable with it receiving a user's identity **without the user being asked**.
|
|
19
|
+
|
|
20
|
+
```yaml
|
|
21
|
+
# config/clients.yaml
|
|
22
|
+
my-app:
|
|
23
|
+
tokenEndpointAuthMethod: "client_secret_basic"
|
|
24
|
+
firstParty: true
|
|
25
|
+
allowedRedirectUris: ["https://app.example/callback"]
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**What this does not do.** It does not make `/authorize` safe against forced navigation for a client that *is* first-party — that remains the accepted model here. What it prevents is a client that should never have been trusted with a silent code being registered into that position by accident. The step that changes the former is a consent screen, tracked separately.
|
|
29
|
+
|
|
30
|
+
**Migrating.** Registrations that predate the field are refused at `/authorize` until you mark them — a client with no `firstParty` field and one carrying an explicit `false` are treated alike. Mark every client you operate before pointing it at `/authorize`; there is no admit-with-warning window. The one-time `oauth.authorize.allowUnmarkedClients` migration flag was removed before it ever shipped in a release, and a config or environment still setting it (`OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS`, any value) fails at boot with migration instructions rather than being silently ignored.
|
|
31
|
+
|
|
12
32
|
## Multi-replica deployments
|
|
13
33
|
|
|
14
34
|
When running multiple instances of the standalone server behind a load balancer, point `REFRESH_TOKEN_FAMILY_STORE_REDIS_URL` at a **shared** Redis 7.2+ instance. Without a shared Redis URL, every replica holds refresh-token families in its own connection (and any local-only Redis), so a refresh request that lands on a replica that did not issue the token returns `invalid_grant` on every other request in a round-robin LB.
|
|
15
35
|
|
|
36
|
+
**Set `HTTP_TRUST_PROXY` to your proxy's address behind a load balancer.** It defaults to `false`, which makes `req.ip` the *load balancer's* address rather than the client's. Every rate limit keyed by IP — the OAuth endpoint limiters and the `POST /session/login` brute-force guard — then shares **one bucket across all users**: the first 20 login attempts from anyone exhaust the window and every subsequent user gets `429`. The failure reads like an attack rather than a misconfiguration, which is what makes it expensive to diagnose at 3am. Set it whenever anything terminates TLS or proxies in front of this service, and make sure that hop is the one setting `X-Forwarded-For`.
|
|
37
|
+
|
|
38
|
+
Since #292 it takes four shapes, all of them Express's own:
|
|
39
|
+
|
|
40
|
+
| Value | Meaning |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `false` (default) | Trust nothing; `req.ip` is the socket peer |
|
|
43
|
+
| `10.0.0.0/8,192.168.0.0/16` | Trust exactly these hops. IP literals, CIDR ranges, or the named ranges `loopback` / `linklocal` / `uniquelocal`. Comma-separate for the env var |
|
|
44
|
+
| `1` | Trust that many hops back from the socket peer |
|
|
45
|
+
| `true` | Trust every hop — the leftmost `X-Forwarded-For` entry, from whoever opened the connection |
|
|
46
|
+
|
|
47
|
+
**Prefer the address list.** `true` believes a forwarded client address from anyone who can reach this process; a client that routes to it directly, or through one hop more than you accounted for, then chooses its own `req.ip` and with it any rate-limit identity keyed on it. Entries are validated at boot, so a hostname or a typo'd range fails loudly instead of becoming a rule that never matches.
|
|
48
|
+
|
|
49
|
+
An allowlist is a network control, not a cryptographic one. The edge must also **strip** inbound `X-Forwarded-*` headers rather than appending to them, and the hop between it and this process must not be reachable by anyone able to spoof a source address.
|
|
50
|
+
|
|
51
|
+
**Point `rateLimiter.adapter` at Redis.** It defaults to `"memory"`, which is per-process: with N replicas every configured limit is effectively N times larger and resets on every deploy. The memory adapter is also **evadable under bucket exhaustion**: it caps itself at 10,000 buckets and, at the cap, admitting a new key evicts the bucket closest to reset — so an attacker who can present many source IPs (and `req.ip` is client-influenced whenever `HTTP_TRUST_PROXY` is broader than your actual hops) can churn the table until a target's counter is evicted and starts over. That is acceptable for one dev process; it is not a production rate limit. Since #270 the login guard runs on this same shared component, so one setting covers both the OAuth endpoints and `/session/login`. The login window and limit stay configured at `rateLimit.login`; both adapters seed their own `limits.login` from it, so there is nothing to restate.
|
|
52
|
+
|
|
53
|
+
**Set `DEPLOYMENT_MODE=multi` once you run more than one replica.** Boot then *fails* if any in-memory store that has to be shared is still wired, naming every offender and what it costs — user sessions forking (back-channel logout reaches one replica, a logged-out session stays valid on the others), rate-limit counters multiplying, access-token revocation not propagating, DPoP proof-replay detection forking. With the mode unset you get one `replica_unsafe_adapters` warning at boot instead; with `DEPLOYMENT_MODE=single` the check is silent, because you have said there is one replica.
|
|
54
|
+
|
|
55
|
+
Be aware of what this check *cannot* do: if you scale to N replicas without ever setting `DEPLOYMENT_MODE`, nothing fails. A process holding all its state in its own memory has no shared medium through which to notice peers — the condition is undetectable from inside exactly when it is true. Set the variable as part of scaling, not after something breaks.
|
|
56
|
+
|
|
57
|
+
**Access-token revocation needs a denylist, and the template ships one.** `POST /oauth/revoke` revokes an access token by writing its `jti` to the `accessTokenDenylist`, which token verification and introspection consult. Boot *fails* if the endpoint is mounted with no denylist behind it: RFC 7009 obliges the endpoint to answer `200`, so an unwired denylist would tell you a token is revoked while it keeps working until it expires. `accessTokenDenylist.adapter` defaults to `"redis"` here (`ACCESS_TOKEN_DENYLIST_ADAPTER=memory` for single-instance local work) and shares the ioredis socket configured by `REFRESH_TOKEN_FAMILY_STORE_REDIS_URL`, so it costs no extra connection. A deployment that genuinely does not revoke access tokens sets `OAUTH_REVOCATION_ACCESS_TOKEN=unsupported` instead; the endpoint then answers `unsupported_token_type` for `token_type_hint=access_token`. **Refresh-token revocation works in either case and never needed a denylist.**
|
|
58
|
+
|
|
16
59
|
Other multi-replica considerations covered by the default modules:
|
|
17
60
|
|
|
18
61
|
- The session store is wired via `redisSessionStoresModule`; set the corresponding session-store Redis URL (per `session.storage.redis.url`).
|
|
@@ -59,27 +102,55 @@ fail fast rather than silently falling back to defaults.
|
|
|
59
102
|
| Variable | Default | Description |
|
|
60
103
|
|---|---|---|
|
|
61
104
|
| `HTTP_PORT` | `3000` | Port the server listens on |
|
|
62
|
-
| `HTTP_TRUST_PROXY` | `false` |
|
|
105
|
+
| `HTTP_TRUST_PROXY` | `false` | Express `trust proxy`: `false`, an address/CIDR list (`10.0.0.0/8,loopback`), a hop count (`1`), or `true`. **Required behind a load balancer** — otherwise every IP-keyed rate limit shares one bucket across all users. Prefer naming the proxy over `true`. See [Multi-replica deployments](#multi-replica-deployments) |
|
|
106
|
+
| `HTTP_READINESS_TIMEOUT_MS` | `1000` | Per-probe deadline for `/readyz` (see [Health endpoints](#health-endpoints)) |
|
|
107
|
+
| `LOG_LEVEL` | `info` | Minimum level emitted: `trace`\|`debug`\|`info`\|`warn`\|`error`\|`fatal`\|`silent`. Does **not** gate the audit trail — see [Audit trail](#audit-trail) |
|
|
108
|
+
| `AUDIT_SINK_TYPE` | `logger` | Where security events go: `logger` (pino-enveloped NDJSON on stdout) or `console` (bare event JSON). There is no `none`. See [Audit trail](#audit-trail) |
|
|
63
109
|
|
|
64
110
|
### OAuth JWT
|
|
65
111
|
|
|
66
112
|
| Variable | Default | Description |
|
|
67
113
|
|---|---|---|
|
|
68
|
-
| `OAUTH_JWT_ALGORITHM` | `
|
|
69
|
-
| `OAUTH_JWT_SECRET` | — | Signing secret
|
|
70
|
-
| `OAUTH_JWT_ISSUER` |
|
|
114
|
+
| `OAUTH_JWT_ALGORITHM` | `EdDSA` | JWT signing algorithm: `EdDSA`, `ES256`, `RS256` or `HS256`. The default is asymmetric so `/.well-known/jwks.json` publishes a real verification key. |
|
|
115
|
+
| `OAUTH_JWT_SECRET` | — | Signing secret, **HMAC (`HS256`) only**. At least 32 bytes (256 bits) of random material — `openssl rand -hex 32`. Hex/base64 values are measured **decoded**, so a 32-character hex string counts as 16 bytes and is refused. |
|
|
116
|
+
| `OAUTH_JWT_ISSUER` | **(required)** | Canonical issuer URL stamped as `iss` on every token. Must be absolute `https` (`http` only for a loopback host), with no query or fragment. Boot fails when unset — it is never derived from the `Host` header. |
|
|
117
|
+
| `OAUTH_REQUIRE_EMAIL_VERIFIED` | `false` | Refuse to issue tokens for a user until the Store publishes `emailVerified: true`. Enforced at `/authorize` and on the `session` grant. Verification itself is the Store's job — this only reads the result. |
|
|
71
118
|
| `OAUTH_JWT_KID` | `v0` | Key ID included in the JWT header |
|
|
72
119
|
| `OAUTH_JWT_PRIVATE_KEY` | — | PEM-encoded private key (asymmetric algorithms) |
|
|
73
120
|
| `OAUTH_JWT_PRIVATE_KEY_PATH` | — | Path to PEM private key file |
|
|
74
121
|
| `OAUTH_JWT_PUBLIC_KEY` | — | PEM-encoded public key |
|
|
75
122
|
| `OAUTH_JWT_PUBLIC_KEY_PATH` | — | Path to PEM public key file |
|
|
123
|
+
| `OAUTH_JWT_LEGACY_TYP_ACCEPT` | `false` | Accept tokens whose `typ` header is absent. `false` rejects them, treating a typ-less token as the misconfiguration or downgrade attempt it usually is. Set `true` only for a bounded migration window while v0.4.x tokens are still in circulation. |
|
|
124
|
+
|
|
125
|
+
**Signing keys are required.** The default algorithm is `EdDSA` and there is no
|
|
126
|
+
key-material default: a deployment that sets none fails at boot naming the keys
|
|
127
|
+
to set, rather than quietly signing with something guessable. Generate an
|
|
128
|
+
Ed25519 pair:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
openssl genpkey -algorithm ed25519 -out jwt-private.pem
|
|
132
|
+
openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
then point `OAUTH_JWT_PRIVATE_KEY_PATH` / `OAUTH_JWT_PUBLIC_KEY_PATH` at them
|
|
136
|
+
(or inline the PEMs via `OAUTH_JWT_PRIVATE_KEY` / `OAUTH_JWT_PUBLIC_KEY`).
|
|
137
|
+
|
|
138
|
+
`HS256` is still selectable (`OAUTH_JWT_ALGORITHM=HS256` plus a ≥32-byte
|
|
139
|
+
`OAUTH_JWT_SECRET`), but understand what it costs: a symmetric key has no
|
|
140
|
+
public half, so `/.well-known/jwks.json` answers `404 jwks_not_published` and
|
|
141
|
+
every relying party must be handed the shared secret — which also lets it
|
|
142
|
+
**mint** tokens, not merely verify them.
|
|
76
143
|
|
|
77
144
|
### Token Expiry
|
|
78
145
|
|
|
79
146
|
| Variable | Default | Description |
|
|
80
147
|
|---|---|---|
|
|
81
|
-
| `OAUTH_ACCESS_TOKEN_EXPIRES_IN` | `3600` | Access token lifetime in seconds |
|
|
82
|
-
| `OAUTH_REFRESH_TOKEN_EXPIRES_IN` | `86400` | Refresh token lifetime in seconds |
|
|
148
|
+
| `OAUTH_ACCESS_TOKEN_EXPIRES_IN` | `3600` | Access token lifetime in seconds. Whole positive number, at most one year (`31536000`). |
|
|
149
|
+
| `OAUTH_REFRESH_TOKEN_EXPIRES_IN` | `86400` | Refresh token lifetime in seconds. Whole positive number, at most one year (`31536000`). |
|
|
150
|
+
|
|
151
|
+
Exporting one of these as an empty string is a boot failure, not a fallback:
|
|
152
|
+
HOCON resolves `FOO=` to `""`, which coerces to `0`, and a zero lifetime mints
|
|
153
|
+
tokens that have already expired.
|
|
83
154
|
|
|
84
155
|
### Grant Types
|
|
85
156
|
|
|
@@ -93,12 +164,13 @@ fail fast rather than silently falling back to defaults.
|
|
|
93
164
|
|
|
94
165
|
| Variable | Default | Description |
|
|
95
166
|
|---|---|---|
|
|
96
|
-
| `SESSION_SECRET` | — | **Required.**
|
|
167
|
+
| `SESSION_SECRET` | — | **Required.** Signs the cookie that *is* the authenticated session, so guessing it forges logins. At least 32 bytes (256 bits) — `openssl rand -hex 32`. Measured **decoded**, same rule as `OAUTH_JWT_SECRET`. |
|
|
97
168
|
| `SESSION_NAME` | `__Host-auth.session` | Session cookie name. The default uses the `__Host-` prefix and therefore requires `SESSION_SECURE=true` and no `SESSION_DOMAIN`. |
|
|
98
|
-
| `SESSION_MAX_AGE` | `3600000` | Session cookie max age in milliseconds |
|
|
169
|
+
| `SESSION_MAX_AGE` | `3600000` | Session cookie max age in milliseconds. Whole positive number, at most one year (`31536000000`). |
|
|
99
170
|
| `SESSION_SECURE` | `true` | Set `Secure` flag on session cookie |
|
|
100
|
-
| `SESSION_SAME_SITE` | `lax` | `SameSite` attribute (`lax`, `strict`, `none`) |
|
|
171
|
+
| `SESSION_SAME_SITE` | `lax` | `SameSite` attribute (`lax`, `strict`, `none`). `none` **requires** `SESSION_SECURE=true` — browsers drop a `SameSite=None` cookie that is not `Secure`, so boot refuses the combination rather than letting every login fail silently in the client. |
|
|
101
172
|
| `SESSION_DOMAIN` | — | Cookie domain (unset by default) |
|
|
173
|
+
| `SESSION_CSRF_TTL_SECONDS` | `7200` | Lifetime of an issued CSRF token, in seconds. Integer, 1–86400; boot fails otherwise (an *empty* value coerces to `0` and would silently disable the token arm). |
|
|
102
174
|
| `SESSION_STORAGE_TYPE` | `redis` | Session store backend: `redis` or `memory` |
|
|
103
175
|
| `SESSION_STORAGE_REDIS_URL` | `redis://localhost:6379` | Redis connection URL for session storage |
|
|
104
176
|
| `SESSION_STORAGE_REDIS_PASSWORD` | — | Redis password for session storage |
|
|
@@ -108,6 +180,25 @@ for shared-domain cookies, also set `SESSION_NAME` to a non-`__Host-` value such
|
|
|
108
180
|
`auth.sid`. The server fails fast when a `__Host-` cookie name is combined with
|
|
109
181
|
attributes that browsers reject for that prefix.
|
|
110
182
|
|
|
183
|
+
#### CSRF on `/session/login` and `/session/logout`
|
|
184
|
+
|
|
185
|
+
Both routes accept a request carrying **either** a same-origin (or trusted)
|
|
186
|
+
`Origin` / `Referer`, **or** a valid double-submit CSRF token, and reject one
|
|
187
|
+
carrying neither (#272). Browsers satisfy this automatically; a scripted client
|
|
188
|
+
calls `GET /session/csrf` first and sends the returned `csrf_token` back as
|
|
189
|
+
both the `<SESSION_NAME>.csrf` cookie and an `x-csrf-token` header (or a
|
|
190
|
+
`csrf_token` form field).
|
|
191
|
+
|
|
192
|
+
Two configuration notes:
|
|
193
|
+
|
|
194
|
+
- Behind a TLS-terminating proxy, set `HTTP_TRUST_PROXY` (to the proxy's
|
|
195
|
+
address or CIDR range, not `true`). Without it `req.protocol` reads `http`
|
|
196
|
+
while the browser sends `Origin: https://…`, and the origin arm rejects every
|
|
197
|
+
request.
|
|
198
|
+
- If your login UI is served from a **different origin** than the provider,
|
|
199
|
+
list that origin under `session.csrf.trustedOrigins` in your HOCON config.
|
|
200
|
+
`cors.allowedOrigins` no longer confers CSRF trust.
|
|
201
|
+
|
|
111
202
|
### Google Federation
|
|
112
203
|
|
|
113
204
|
| Variable | Default | Description |
|
|
@@ -129,9 +220,19 @@ attributes that browsers reject for that prefix.
|
|
|
129
220
|
| Variable | Default | Description |
|
|
130
221
|
|---|---|---|
|
|
131
222
|
| `CLIENT_USER_TYPE` | `http` | User repository backend: `http` |
|
|
132
|
-
| `CLIENT_USER_AUTHENTICATE_URL` | — | URL for password-based user authentication |
|
|
133
|
-
| `CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL` | — | URL for token-based user authentication |
|
|
134
|
-
| `CLIENT_USER_TIMEOUT` | `5000` | HTTP request timeout in milliseconds |
|
|
223
|
+
| `CLIENT_USER_AUTHENTICATE_URL` | — | URL for password-based user authentication. **https required** (see below) |
|
|
224
|
+
| `CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL` | — | URL for token-based user authentication. **https required** (see below) |
|
|
225
|
+
| `CLIENT_USER_TIMEOUT` | `5000` | HTTP request timeout in milliseconds. Positive integer ≤ `2147483647` |
|
|
226
|
+
| `CLIENT_USER_MAX_RESPONSE_BYTES` | `1048576` | Largest upstream response body accepted, in bytes |
|
|
227
|
+
|
|
228
|
+
Both user-authentication URLs carry **plaintext user credentials** to the
|
|
229
|
+
upstream store, so both must be absolute `https://` URLs. `http://` is accepted
|
|
230
|
+
only for a loopback host — `localhost`, an address in `127.0.0.0/8`, or `[::1]`
|
|
231
|
+
— so that local development needs no certificate. A private-range address
|
|
232
|
+
(`http://10.0.0.5/…`) or a container-network service name
|
|
233
|
+
(`http://user-service/…`) still requires `https://`: those cross a network the
|
|
234
|
+
deployment does not control end to end. Boot fails if either URL, the timeout,
|
|
235
|
+
or the response cap is unusable, rather than at the first login attempt.
|
|
135
236
|
|
|
136
237
|
### Code Repository
|
|
137
238
|
|
|
@@ -160,9 +261,12 @@ Use values that include the deployment name, for example `tenant-a:ss:`,
|
|
|
160
261
|
|
|
161
262
|
| Variable | Default | Description |
|
|
162
263
|
|---|---|---|
|
|
163
|
-
| `ENDPOINTS_LOGIN_URL` |
|
|
164
|
-
|
|
165
|
-
|
|
264
|
+
| `ENDPOINTS_LOGIN_URL` | `/login` | URL of the login page (for redirects) |
|
|
265
|
+
|
|
266
|
+
`ENDPOINTS_CLIENT_URL` and `ENDPOINTS_AUTH_CALLBACK_URL` were removed (IH-10) —
|
|
267
|
+
no production consumer read them, and the config lines leaked values into
|
|
268
|
+
`AppConfig` that nothing consumed. Federation callback URLs are configured
|
|
269
|
+
per-provider (`FEDERATIONS_GOOGLE_CALLBACK_URL`).
|
|
166
270
|
|
|
167
271
|
## Module Composition Order
|
|
168
272
|
|
|
@@ -205,6 +309,37 @@ make test
|
|
|
205
309
|
|
|
206
310
|
The `docker-compose.yml` starts the auth server together with a Redis container. Configure environment variables in `.env`.
|
|
207
311
|
|
|
312
|
+
`docker-compose.yml` is a **development** file: it builds the `develop` target and bind-mounts `./src` and `./config`, so deploying it ships a hot-reload server running your working tree. The deployable shape is
|
|
313
|
+
[`docker-compose.production.yml`](docker-compose.production.yml) — `runtime` target, no source mounts, restart policies, a network-internal persistent Redis, and a **required** `.env` (a boot without a real `SESSION_SECRET` must fail loudly). Its header comments state what it deliberately leaves to you: TLS termination in front plus `HTTP_TRUST_PROXY`, and the [multi-replica](#multi-replica-deployments) steps before any `--scale`.
|
|
314
|
+
|
|
315
|
+
```bash
|
|
316
|
+
# The signing keys are a required input: EdDSA is the default and there is no
|
|
317
|
+
# key-material default, so a deployment that generates none fails at boot.
|
|
318
|
+
openssl genpkey -algorithm ed25519 -out jwt-private.pem
|
|
319
|
+
openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
|
|
320
|
+
chmod 600 jwt-private.pem
|
|
321
|
+
|
|
322
|
+
docker compose -f docker-compose.production.yml up -d --build
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
**Keys in containers.** The pem pair reaches the container as a compose
|
|
326
|
+
**secret**, mounted read-only at `/run/secrets/`, and the compose file points
|
|
327
|
+
`OAUTH_JWT_*_KEY_PATH` there. Do not park them in `config/`: the `Dockerfile`
|
|
328
|
+
`COPY`s that directory, so a key placed there is baked into an image layer and
|
|
329
|
+
travels with every push — a key you then have to rotate rather than one you
|
|
330
|
+
merely stored badly. The same applies to `.env`; keep both out of git.
|
|
331
|
+
|
|
332
|
+
The production file also pins `SESSION_SECURE=true` and the `__Host-` cookie
|
|
333
|
+
name in its `environment:` block, which wins over `env_file`. `.env.example`
|
|
334
|
+
ships `SESSION_SECURE=false` so a plain-HTTP local run works, and this file
|
|
335
|
+
requires that same `.env` — behind the TLS it assumes, a non-Secure session
|
|
336
|
+
cookie is the session handed to anyone who can read one plaintext hop.
|
|
337
|
+
|
|
338
|
+
The image installs with `pnpm install --frozen-lockfile`, so a committed
|
|
339
|
+
`pnpm-lock.yaml` is a required build input — `create-auth-provider` generates
|
|
340
|
+
one at scaffold time; if yours is missing, run `pnpm install` once and commit
|
|
341
|
+
the result. Rebuilding the same source then produces the same dependency tree.
|
|
342
|
+
|
|
208
343
|
The production image sets `ENV HTTP_PORT=3000` and uses it for both `EXPOSE`
|
|
209
344
|
and the Docker-native healthcheck against `/_healthcheck`. The HOCON config
|
|
210
345
|
also reads `${?HTTP_PORT}` for `http.port`, so `app.listen()` and the
|
|
@@ -217,6 +352,78 @@ docker run -e HTTP_PORT=8080 -p 8080:8080 my-auth-provider
|
|
|
217
352
|
`EXPOSE` is image metadata and does not publish ports by itself, so the
|
|
218
353
|
explicit `-p` mapping is still required.
|
|
219
354
|
|
|
355
|
+
### Health endpoints
|
|
356
|
+
|
|
357
|
+
Two routes, answering two different questions. Both are mounted on the host
|
|
358
|
+
app ahead of the auth router, so they keep answering while the auth pipeline
|
|
359
|
+
is degraded.
|
|
360
|
+
|
|
361
|
+
| Route | Question | Answer |
|
|
362
|
+
|---|---|---|
|
|
363
|
+
| `GET /_healthcheck` | Is the process up? | Always `200 {"status":"ok"}` |
|
|
364
|
+
| `GET /readyz` | Can this replica serve right now? | `200 {"status":"ready", …}` or `503 {"status":"unready", …}` |
|
|
365
|
+
|
|
366
|
+
Use them for different probes:
|
|
367
|
+
|
|
368
|
+
```yaml
|
|
369
|
+
livenessProbe:
|
|
370
|
+
httpGet: { path: /_healthcheck, port: 3000 }
|
|
371
|
+
readinessProbe:
|
|
372
|
+
httpGet: { path: /readyz, port: 3000 }
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
Pointing liveness at `/readyz` is the mistake worth avoiding: a Redis
|
|
376
|
+
partition would then look like a broken container and Kubernetes would
|
|
377
|
+
restart every replica in a loop, which reconnects nothing and adds cold
|
|
378
|
+
starts to an incident. Losing Redis is a reason to stop *routing* to a
|
|
379
|
+
replica, not to kill it.
|
|
380
|
+
|
|
381
|
+
The image's `HEALTHCHECK` probes `/_healthcheck` for the same reason. Docker
|
|
382
|
+
has exactly one health signal, and on Swarm and ECS an unhealthy container is
|
|
383
|
+
stopped and replaced — so wiring it to `/readyz` would reintroduce the restart
|
|
384
|
+
loop for every non-Kubernetes orchestrator. If you want
|
|
385
|
+
`depends_on: condition: service_healthy` to mean "ready" rather than "alive",
|
|
386
|
+
override `healthcheck:` for that one service in your compose file instead of
|
|
387
|
+
changing the image.
|
|
388
|
+
|
|
389
|
+
`/readyz` runs one probe per backing connection and reports each by name:
|
|
390
|
+
|
|
391
|
+
```json
|
|
392
|
+
{
|
|
393
|
+
"status": "unready",
|
|
394
|
+
"checks": [
|
|
395
|
+
{ "name": "redis", "ok": true, "durationMs": 2 },
|
|
396
|
+
{ "name": "session-store", "ok": false, "durationMs": 1001 }
|
|
397
|
+
]
|
|
398
|
+
}
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
The failure's *reason* is deliberately absent from the body and goes to the log
|
|
402
|
+
instead (`readiness_probe_failed`, with the full per-check detail). A driver
|
|
403
|
+
error reads `connect ECONNREFUSED 10.0.3.14:6379` — an internal host and port —
|
|
404
|
+
and this endpoint is unauthenticated because an orchestrator has no credentials
|
|
405
|
+
to present. Set `includeErrorDetail: true` on `createReadinessRouter` only when
|
|
406
|
+
the endpoint is reachable solely from inside the deployment.
|
|
407
|
+
|
|
408
|
+
For the same reason, keep `/readyz` off the public listener. It sits ahead of
|
|
409
|
+
the auth router, so it is outside that router's rate limiter, and each request
|
|
410
|
+
issues one command per probe against the very dependency it reports on —
|
|
411
|
+
enough for an anonymous request loop to add load during an incident. Bind it to
|
|
412
|
+
the internal network, an admin listener, or your ingress' internal-only path
|
|
413
|
+
rules.
|
|
414
|
+
|
|
415
|
+
Probes are registered by whichever builder opened the connection, so the list
|
|
416
|
+
reflects what this deployment actually wired: the shared ioredis client
|
|
417
|
+
(`redis`) and the connect-redis session client (`session-store`) when the
|
|
418
|
+
Redis adapters are selected, and **nothing at all** for a memory-only
|
|
419
|
+
deployment — which is therefore always ready, because it has no dependency to
|
|
420
|
+
be unready for.
|
|
421
|
+
|
|
422
|
+
`http.readinessTimeoutMs` (env `HTTP_READINESS_TIMEOUT_MS`, default `1000`)
|
|
423
|
+
is the per-probe deadline. Keep it below the orchestrator's own probe timeout;
|
|
424
|
+
otherwise an unreachable dependency reads as a slow replica rather than an
|
|
425
|
+
unready one.
|
|
426
|
+
|
|
220
427
|
## Adding Custom Modules
|
|
221
428
|
|
|
222
429
|
To add a custom module, import it in `src/buildModules.mts` and include it in the `modules` array passed to `createApp`:
|
|
@@ -272,3 +479,124 @@ gracefulShutdown(server, () => handle.dispose());
|
|
|
272
479
|
- [`@o3co/auth-provider-session`](../../packages/session) — Session module
|
|
273
480
|
- [`@o3co/auth-provider-foundation`](../../packages/foundation) — Built-in adapter registration
|
|
274
481
|
- [`@o3co/create-auth-provider`](../../create-app) — CLI scaffolder that generates this template
|
|
482
|
+
|
|
483
|
+
## Observability
|
|
484
|
+
|
|
485
|
+
### Logging
|
|
486
|
+
|
|
487
|
+
The provider logs newline-delimited JSON on stdout via [pino](https://getpino.io),
|
|
488
|
+
which is what a log aggregator ingests without a parser. `LOG_LEVEL` (HOCON
|
|
489
|
+
`logging.level`) sets the threshold; `trace` and `debug` carry request-shaped
|
|
490
|
+
detail and are off by default, and pino drops sub-threshold calls before
|
|
491
|
+
formatting, so they cost nothing in production rather than being emitted and
|
|
492
|
+
filtered downstream.
|
|
493
|
+
|
|
494
|
+
The logger is wired into the boot planner's `logger` component slot, so every
|
|
495
|
+
module that declares `optional: ["logger"]` — oauth, session, dpop, mtls,
|
|
496
|
+
token-exchange — logs through this one instance. Filling that slot is what makes
|
|
497
|
+
`LOG_LEVEL` reach them; a composition root that leaves it empty gets each
|
|
498
|
+
module's own `consoleLogger` default instead, and configuring a level changes
|
|
499
|
+
nothing.
|
|
500
|
+
|
|
501
|
+
Events are structured and named: `logger.error({ err }, "session_store_redis_error")`.
|
|
502
|
+
Alert on the name, not on the message text.
|
|
503
|
+
|
|
504
|
+
To swap the backend, replace `createAppLogger` in `src/logger.mts`. Anything
|
|
505
|
+
satisfying core's `Logger` works — the interface has pino's two-overload call
|
|
506
|
+
shape, so a pino-compatible logger needs no adapter.
|
|
507
|
+
|
|
508
|
+
### Audit trail
|
|
509
|
+
|
|
510
|
+
Security-relevant events — who authenticated, what was issued, what was refused,
|
|
511
|
+
and when the rate limiter could not answer — go to the sink named by
|
|
512
|
+
`audit.sink.type` (env `AUDIT_SINK_TYPE`). The template ships `"logger"`, which
|
|
513
|
+
writes one event per line through the same pino stream as everything else:
|
|
514
|
+
|
|
515
|
+
```json
|
|
516
|
+
{"level":30,"time":1787841564013,"name":"audit","audit":{"timestamp":"2026-08-27T14:39:24.013Z","type":"token.issued.failure","ip":"203.0.113.7","details":{"reason":"unsupported_grant_type"}},"msg":"token.issued.failure"}
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
One output style, so an aggregator ingests application logs and audit events
|
|
520
|
+
through one parser and separates them on `name` (`"provider"` vs `"audit"`).
|
|
521
|
+
The event type is also the message, so alert on the name — `authorize.rejected`,
|
|
522
|
+
`token.issued.failure`, `rate_limit.unavailable` — the same way you would on
|
|
523
|
+
`session_store_redis_error`.
|
|
524
|
+
|
|
525
|
+
`AUDIT_SINK_TYPE=console` selects core's built-in sink instead: the bare event
|
|
526
|
+
as one JSON object per line, with no log envelope, for a pipeline that wants the
|
|
527
|
+
event and nothing else.
|
|
528
|
+
|
|
529
|
+
**`LOG_LEVEL` does not gate the audit trail.** The audit stream's level is fixed
|
|
530
|
+
at `info`. `warn` is an ordinary production setting and `silent` a legitimate
|
|
531
|
+
one, and either silencing this would delete the record of every authentication
|
|
532
|
+
and issuance while looking like a logging preference. Where audit events go is
|
|
533
|
+
chosen by `audit.sink.type` — and that selector has **no `"none"`**. An unknown
|
|
534
|
+
type fails boot naming the sinks that are registered, rather than producing a
|
|
535
|
+
deployment with no audit trail.
|
|
536
|
+
|
|
537
|
+
To point it at a real sink — SIEM, log pipeline, message bus — register a
|
|
538
|
+
builder in `auditSinkModule` (`src/modules.mts`) and name it in config; its
|
|
539
|
+
options ride along in the same block:
|
|
540
|
+
|
|
541
|
+
```ts
|
|
542
|
+
factory.register("splunk-hec", (cfg) => createSplunkSink(cfg));
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
```hocon
|
|
546
|
+
audit {
|
|
547
|
+
sink {
|
|
548
|
+
type = "splunk-hec"
|
|
549
|
+
splunk-hec { endpoint = ${?SPLUNK_HEC_URL}, token = ${?SPLUNK_HEC_TOKEN} }
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
Sinks are fire-and-forget by contract: core dispatches without awaiting and
|
|
555
|
+
swallows rejections, so a slow or failing sink cannot add latency to — or
|
|
556
|
+
fail — an auth flow. The flip side is that a sink which cannot deliver drops
|
|
557
|
+
events silently, and those drops are not counted yet — see **Not published
|
|
558
|
+
yet** under Metrics below.
|
|
559
|
+
|
|
560
|
+
### Metrics
|
|
561
|
+
|
|
562
|
+
`GET /metrics` serves the Prometheus text exposition format.
|
|
563
|
+
|
|
564
|
+
| Metric | Type | What it answers |
|
|
565
|
+
|---|---|---|
|
|
566
|
+
| `http_request_duration_seconds` | histogram | Request rate, error rate and latency, by `method` / `route` / `status` |
|
|
567
|
+
| `auth_dependency_up` | gauge | Whether each backing dependency answered its readiness probe (`dependency="redis"`, `"session-store"`) |
|
|
568
|
+
| `auth_provider_*` | various | Node process defaults — event-loop lag, heap, GC, handles |
|
|
569
|
+
|
|
570
|
+
`auth_dependency_up` is the series that separates "Redis is gone" from "the app
|
|
571
|
+
is slow", which is otherwise only distinguishable by grepping container logs. It
|
|
572
|
+
reuses the probes the adapter builders already registered, so it reflects what
|
|
573
|
+
this deployment actually wired rather than a second, drifting list. Dependencies
|
|
574
|
+
are re-sampled on every scrape — a cached verdict would report healthy for
|
|
575
|
+
exactly as long as an incident lasts — and in-flight probes are joined rather
|
|
576
|
+
than re-issued, so a scrape loop does not stack commands on a dependency that is
|
|
577
|
+
already in trouble.
|
|
578
|
+
|
|
579
|
+
Route labels use the Express route **pattern** (`/oauth/token`,
|
|
580
|
+
`/api/widgets/:id`), never the URL, and unmatched requests collapse to
|
|
581
|
+
`route="unmatched"`. Labelling by path would mint a series per distinct URL, and
|
|
582
|
+
this server's paths carry opaque values — that is how a metrics endpoint takes
|
|
583
|
+
down the monitoring that was supposed to watch it.
|
|
584
|
+
|
|
585
|
+
A scrape example:
|
|
586
|
+
|
|
587
|
+
```yaml
|
|
588
|
+
scrape_configs:
|
|
589
|
+
- job_name: auth-provider
|
|
590
|
+
static_configs:
|
|
591
|
+
- targets: ["auth-provider:3000"]
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
Keep `/metrics` off the public listener, for the same reason as `/readyz`: it is
|
|
595
|
+
unauthenticated, it sits ahead of the auth router and so outside its rate
|
|
596
|
+
limiter, and each scrape samples the readiness probes.
|
|
597
|
+
|
|
598
|
+
**Not published yet:** rate-limiter fail-closed counts and audit-sink drops.
|
|
599
|
+
Both happen inside `@o3co/auth-provider-core` and are not observable from the
|
|
600
|
+
composition root; counting them needs a metrics hook in core rather than
|
|
601
|
+
something this template can reach. Until then, alert on the
|
|
602
|
+
`rate_limiter_failed_closed` and `rate_limit.unavailable` log events.
|
|
@@ -9,10 +9,46 @@
|
|
|
9
9
|
http {
|
|
10
10
|
port = 3000
|
|
11
11
|
port = ${?HTTP_PORT}
|
|
12
|
+
# false | true | <hop count> | <address list>. Prefer naming the proxy:
|
|
13
|
+
# HTTP_TRUST_PROXY=10.0.0.0/8 (or `loopback` for a sidecar) rather than
|
|
14
|
+
# `true`, which trusts a forwarded client address from anyone who can reach
|
|
15
|
+
# this process. See the header comment in the library's reference.conf.
|
|
12
16
|
trustProxy = false
|
|
13
17
|
trustProxy = ${?HTTP_TRUST_PROXY}
|
|
14
18
|
}
|
|
15
19
|
|
|
20
|
+
# #287: where the audit trail goes. Before this block the template wired no
|
|
21
|
+
# sink at all, so every security event the routes emit -- token.issued.failure,
|
|
22
|
+
# authorize.rejected / authorize.granted, logout.cascade_failed, and the shared
|
|
23
|
+
# rate-limit guard's rate_limit.unavailable -- was discarded by the artifact
|
|
24
|
+
# operators deploy. Nothing failed and nothing warned: `emitAuditEvent` is a
|
|
25
|
+
# no-op when the slot is empty.
|
|
26
|
+
#
|
|
27
|
+
# "logger" rather than reference.conf's "console": both write newline-delimited
|
|
28
|
+
# JSON to stdout, but "logger" writes it through the same pino stream that
|
|
29
|
+
# carries every other line this template emits, so a log aggregator ingests
|
|
30
|
+
# application logs and audit events with one parser and separates them on
|
|
31
|
+
# `name` ("provider" vs "audit"). "console" writes the bare event with no log
|
|
32
|
+
# envelope -- no level, no time -- which most pipelines then timestamp by
|
|
33
|
+
# ingestion. Set AUDIT_SINK_TYPE=console if that is what your pipeline wants.
|
|
34
|
+
#
|
|
35
|
+
# The audit stream's level is FIXED at info and `logging.level` does not gate
|
|
36
|
+
# it. LOG_LEVEL=warn is an ordinary production setting and LOG_LEVEL=silent a
|
|
37
|
+
# legitimate one; either silencing the audit trail would be #287 again, reached
|
|
38
|
+
# from the operator's side. Where events go is chosen here, and this selector
|
|
39
|
+
# has no "none" -- an unknown type fails boot naming the sinks that exist,
|
|
40
|
+
# rather than producing a deployment with no audit trail (#304).
|
|
41
|
+
#
|
|
42
|
+
# To point this at a real sink (SIEM, log pipeline, message bus): register the
|
|
43
|
+
# builder in `auditSinkModule` (src/modules.mts), name it here, and put its
|
|
44
|
+
# options in this block -- `sink { type = "splunk-hec", splunk-hec { ... } }`.
|
|
45
|
+
audit {
|
|
46
|
+
sink {
|
|
47
|
+
type = "logger"
|
|
48
|
+
type = ${?AUDIT_SINK_TYPE}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
16
52
|
oauth {
|
|
17
53
|
grants {
|
|
18
54
|
# Template is a typical web-app OP — enable the universal user-auth path.
|
|
@@ -30,13 +66,18 @@ oauth {
|
|
|
30
66
|
enabled = true
|
|
31
67
|
enabled = ${?OAUTH_GRANTS_SESSION_ENABLED}
|
|
32
68
|
}
|
|
69
|
+
# #273: there is no `pkce` block here any more. PKCE is mandatory for
|
|
70
|
+
# every authorization-code client and `S256` is the only method the
|
|
71
|
+
# provider accepts — that is fixed policy (OAuth 2.1 §4.1.1 / RFC 9700
|
|
72
|
+
# §2.1.1), not a deployment setting, so `requireS256` / `required` /
|
|
73
|
+
# `defaultMethod` / `supportedMethods` are gone. A legacy client that
|
|
74
|
+
# genuinely cannot compute SHA-256 is opted into `plain` one client at a
|
|
75
|
+
# time, with `allowPlainPkce: true` in clients.yaml. The
|
|
76
|
+
# OAUTH_GRANTS_AUTHORIZATION_CODE_PKCE_REQUIRE_S256 tombstone in core's
|
|
77
|
+
# reference.conf makes a still-exported env var warn at boot.
|
|
33
78
|
authorization_code {
|
|
34
79
|
enabled = true
|
|
35
80
|
enabled = ${?OAUTH_GRANTS_AUTHORIZATION_CODE_ENABLED}
|
|
36
|
-
pkce {
|
|
37
|
-
requireS256 = false
|
|
38
|
-
requireS256 = ${?OAUTH_GRANTS_AUTHORIZATION_CODE_PKCE_REQUIRE_S256}
|
|
39
|
-
}
|
|
40
81
|
}
|
|
41
82
|
refresh_token {
|
|
42
83
|
enabled = true
|
|
@@ -53,10 +94,44 @@ oauth {
|
|
|
53
94
|
# restart and across replicas — multi-replica production MUST use "redis".
|
|
54
95
|
# Supersedes the legacy `repositories.code.type` switch (kept below as
|
|
55
96
|
# deprecated for backward compat — see CHANGELOG for the removal version).
|
|
97
|
+
# #267: /authorize refuses any client not marked `firstParty: true` in
|
|
98
|
+
# clients.yaml — the template's example clients are marked. There is no
|
|
99
|
+
# opt-out: the one-time `allowUnmarkedClients` migration flag was removed
|
|
100
|
+
# (#330), and a config or environment still setting it fails at boot with
|
|
101
|
+
# migration instructions (via the tombstone in core's reference.conf).
|
|
56
102
|
code {
|
|
57
103
|
adapter = "redis"
|
|
58
104
|
adapter = ${?OAUTH_CODE_ADAPTER}
|
|
59
105
|
}
|
|
106
|
+
# #277: POST /oauth/revoke revokes access tokens by writing their `jti` to the
|
|
107
|
+
# denylist wired below, and boot FAILS if that denylist is missing — an
|
|
108
|
+
# RFC 7009 endpoint must answer 200, so an unwired denylist would tell every
|
|
109
|
+
# operator their token is revoked while it kept working until expiry.
|
|
110
|
+
#
|
|
111
|
+
# Deployments that genuinely do not revoke access tokens (short-lived tokens
|
|
112
|
+
# plus refresh-token revocation only) set "unsupported" instead, and the
|
|
113
|
+
# endpoint then answers `unsupported_token_type` for
|
|
114
|
+
# `token_type_hint = access_token`. Refresh-token revocation works in both
|
|
115
|
+
# modes and needs no denylist.
|
|
116
|
+
revocation {
|
|
117
|
+
accessToken = "denylist"
|
|
118
|
+
accessToken = ${?OAUTH_REVOCATION_ACCESS_TOKEN}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
# #277: backend for the access-token denylist.
|
|
123
|
+
#
|
|
124
|
+
# "redis" by default, not "memory": the memory adapter keeps revocations in one
|
|
125
|
+
# process, so a token revoked on one replica stays valid on every other — and
|
|
126
|
+
# `deployment.mode = "multi"` refuses it by name for exactly that reason. It
|
|
127
|
+
# shares the ioredis socket configured under `refreshTokenFamilyStore.redis`
|
|
128
|
+
# below, so switching it on costs no extra connection.
|
|
129
|
+
#
|
|
130
|
+
# Single-instance local development can set ACCESS_TOKEN_DENYLIST_ADAPTER=memory
|
|
131
|
+
# to run without Redis.
|
|
132
|
+
accessTokenDenylist {
|
|
133
|
+
adapter = "redis"
|
|
134
|
+
adapter = ${?ACCESS_TOKEN_DENYLIST_ADAPTER}
|
|
60
135
|
}
|
|
61
136
|
|
|
62
137
|
session {
|
|
@@ -113,6 +188,39 @@ federations {
|
|
|
113
188
|
clientSecret = ${?FEDERATIONS_GOOGLE_CLIENT_SECRET}
|
|
114
189
|
callbackURL = "http://localhost:3000/session/oauth/federation/google/callback"
|
|
115
190
|
callbackURL = ${?FEDERATIONS_GOOGLE_CALLBACK_URL}
|
|
191
|
+
|
|
192
|
+
# Every URL a `redirect_to` query parameter may name on
|
|
193
|
+
# /session/oauth/federation/google. Matching is EXACT — scheme, host, port,
|
|
194
|
+
# path, query and fragment all count — so list each landing page in full.
|
|
195
|
+
# There is no wildcard and no subdomain matching.
|
|
196
|
+
#
|
|
197
|
+
# An empty list refuses every `redirect_to`, which is the right setting for
|
|
198
|
+
# a deployment that does not use the parameter. It is NOT a way to allow
|
|
199
|
+
# everything: before #278 an unset allowlist accepted any http(s) URL, which
|
|
200
|
+
# made this an open redirect. Nothing falls back to that any more.
|
|
201
|
+
#
|
|
202
|
+
# https is required, except for a loopback host (localhost, 127.0.0.0/8,
|
|
203
|
+
# [::1]) — that carve-out is what lets a local dev front-end and a native
|
|
204
|
+
# client on an ephemeral loopback listener work without a certificate. The
|
|
205
|
+
# port is still part of the match, so list the port the client binds.
|
|
206
|
+
#
|
|
207
|
+
# redirectAllowlist = [
|
|
208
|
+
# "https://app.example.com/welcome"
|
|
209
|
+
# "http://localhost:5173/welcome"
|
|
210
|
+
# ]
|
|
211
|
+
redirectAllowlist = []
|
|
212
|
+
|
|
213
|
+
# Cookie / session domain. When set, every non-loopback redirectAllowlist
|
|
214
|
+
# entry must be inside it, checked at boot — an entry outside it fails
|
|
215
|
+
# startup rather than sitting here looking effective.
|
|
216
|
+
# sessionDomain = ${?FEDERATIONS_GOOGLE_SESSION_DOMAIN}
|
|
217
|
+
|
|
218
|
+
# Post-callback bridge page. Required only if `redirect_to` is used at all:
|
|
219
|
+
# the browser lands here as `<authCallbackUrl>?redirect_to=<encoded>`.
|
|
220
|
+
# authCallbackUrl = ${?FEDERATIONS_GOOGLE_AUTH_CALLBACK_URL}
|
|
221
|
+
|
|
222
|
+
# Where the browser goes after a callback carrying no `redirect_to`.
|
|
223
|
+
# clientUrl = ${?FEDERATIONS_GOOGLE_CLIENT_URL}
|
|
116
224
|
}
|
|
117
225
|
}
|
|
118
226
|
|
|
@@ -132,10 +240,19 @@ repositories {
|
|
|
132
240
|
path = "./config/users.yaml"
|
|
133
241
|
}
|
|
134
242
|
http {
|
|
243
|
+
# Both URLs carry plaintext user credentials, so both must be absolute
|
|
244
|
+
# https URLs. http:// is accepted only for a loopback host (localhost,
|
|
245
|
+
# 127.0.0.0/8, [::1]); a private-range address or a container-network
|
|
246
|
+
# service name still needs https. Boot fails if either is not.
|
|
135
247
|
authenticateUrl = ${?CLIENT_USER_AUTHENTICATE_URL}
|
|
136
248
|
authenticateByTokenUrl = ${?CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL}
|
|
249
|
+
# Positive integer, milliseconds, <= 2147483647. Boot fails otherwise —
|
|
250
|
+
# including on a blank env override, which HOCON substitutes as "".
|
|
137
251
|
timeout = 5000
|
|
138
252
|
timeout = ${?CLIENT_USER_TIMEOUT}
|
|
253
|
+
# Largest upstream response body accepted, in bytes. Positive integer.
|
|
254
|
+
maxResponseBytes = 1048576
|
|
255
|
+
maxResponseBytes = ${?CLIENT_USER_MAX_RESPONSE_BYTES}
|
|
139
256
|
}
|
|
140
257
|
}
|
|
141
258
|
code {
|