@o3co/create-auth-provider 0.15.0 → 0.16.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/README.ja.md +93 -47
- package/README.md +92 -46
- package/package.json +1 -1
- package/templates/standalone/.env.example +12 -0
- package/templates/standalone/Dockerfile +1 -1
- package/templates/standalone/README.ja.md +516 -154
- package/templates/standalone/README.md +218 -91
- package/templates/standalone/config/application.conf +26 -3
- package/templates/standalone/gitignore +3 -2
- package/templates/standalone/package.json +1 -1
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +49 -15
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1103 -0
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +241 -0
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1100 -0
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +19 -5
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +64 -3
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +15 -0
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +133 -0
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +28 -0
- package/templates/standalone/src/__tests__/google-session.fixture.mts +304 -0
- package/templates/standalone/src/__tests__/host-routes.test.mts +141 -0
- package/templates/standalone/src/__tests__/listen.test.mts +128 -0
- package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +20 -5
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +187 -0
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +139 -0
- package/templates/standalone/src/__tests__/replica-safety.test.mts +13 -3
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +149 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +157 -20
- package/templates/standalone/src/__tests__/smoke.test.mts +39 -20
- package/templates/standalone/src/__tests__/store-credential.test.mts +276 -0
- package/templates/standalone/src/app.mts +21 -50
- package/templates/standalone/src/buildModules.mts +33 -16
- package/templates/standalone/src/listen.mts +66 -0
- package/templates/standalone/src/logger.mts +7 -4
- package/templates/standalone/src/modules.mts +42 -8
- package/templates/standalone/src/routes.mts +95 -0
- package/templates/standalone/src/shutdown.mts +20 -14
- package/templates/versions.json +15 -15
- package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
- package/templates/standalone/src/terminalError.mts +0 -60
|
@@ -1,21 +1,85 @@
|
|
|
1
1
|
# @o3co/auth-provider-standalone
|
|
2
2
|
|
|
3
|
+
Last updated: 2026-09-26
|
|
4
|
+
|
|
3
5
|
Deployable server template for auth.provider. This is the composition root — it reads config, loads modules, and starts the Express server. Generated by `@o3co/create-auth-provider`.
|
|
4
6
|
|
|
7
|
+
## Responsibility
|
|
8
|
+
|
|
9
|
+
**Role.** The composition root and the starting point for a deployment. It is
|
|
10
|
+
not a library: nothing imports it. `@o3co/create-auth-provider` copies it into
|
|
11
|
+
a new project, and from then on the operator owns and edits the copy. It sits
|
|
12
|
+
on top of the packages: it composes `@o3co/auth-provider-oauth`, `-session`,
|
|
13
|
+
the Google and generic OpenID Connect federation adapters, `-federation-grants`,
|
|
14
|
+
`-redis` and `-foundation` over `@o3co/auth-provider-core`.
|
|
15
|
+
|
|
16
|
+
**Owns** the choices that are specific to one deployment:
|
|
17
|
+
|
|
18
|
+
- which modules are composed, in what order, and which adapter fills each
|
|
19
|
+
store slot — [`src/buildModules.mts`](src/buildModules.mts) (see
|
|
20
|
+
[Module Composition Order](#module-composition-order));
|
|
21
|
+
- the modules only this scaffold has — the signing key store, the client and
|
|
22
|
+
user repositories, the audit sink, the one shared Redis connection, the
|
|
23
|
+
in-memory user-session stores, code repository and federation token store,
|
|
24
|
+
and the federation config bridges — [`src/modules.mts`](src/modules.mts).
|
|
25
|
+
They are the scaffold's rather than a package's because each builds its
|
|
26
|
+
component from this template's configuration sections; a deployment that
|
|
27
|
+
wants another source wires its own module of the same shape;
|
|
28
|
+
- where configuration comes from and how its layers stack —
|
|
29
|
+
[`src/configPath.mts`](src/configPath.mts) and [`config/`](config/);
|
|
30
|
+
- the host process: the Express app, its security headers and startup —
|
|
31
|
+
[`src/app.mts`](src/app.mts); the health, readiness and metrics routes, the
|
|
32
|
+
composed router and core's terminal error handler after them, in the order
|
|
33
|
+
they are mounted — [`src/routes.mts`](src/routes.mts); the listener, which logs `server_listening`
|
|
34
|
+
(info, `port`) once its socket is bound, fails boot with the bind error
|
|
35
|
+
when it cannot be, and logs any later server error as `server_error`
|
|
36
|
+
(error) — [`src/listen.mts`](src/listen.mts). Every error this code logs — an unhandled
|
|
37
|
+
request error, the shared Redis connection's `error` event, a failed
|
|
38
|
+
shutdown — is logged as core's
|
|
39
|
+
[`loggableError`](../../packages/core/README.md#logger) projection, never the
|
|
40
|
+
error, which can carry what an upstream or Redis said (the connection's
|
|
41
|
+
error carries the `AUTH` handshake, password included, when the server
|
|
42
|
+
refuses it);
|
|
43
|
+
- the concrete logger and audit stream (pino) —
|
|
44
|
+
[`src/logger.mts`](src/logger.mts) — and the metrics
|
|
45
|
+
([`src/metrics.mts`](src/metrics.mts));
|
|
46
|
+
- process lifecycle: signal handling with a drain deadline —
|
|
47
|
+
[`src/shutdown.mts`](src/shutdown.mts);
|
|
48
|
+
- packaging: the `Dockerfile`, the compose files, the `Makefile`;
|
|
49
|
+
- its tests: `src/__tests__/`, which `pnpm run test` and `make test` run against
|
|
50
|
+
this composition, and `tests/`, a separate black-box API suite with its own
|
|
51
|
+
`package.json`, run against a server that is already up (`API_BASE_URL`).
|
|
52
|
+
Among what `src/__tests__/` holds: every module this template can turn on
|
|
53
|
+
boots together and keeps the contracts that exist only when they meet — one
|
|
54
|
+
discovery document, each module's body rules in either mount order, a store
|
|
55
|
+
or repository outage answered 503 and logged once — pinned by
|
|
56
|
+
`all-modules-composition.test.mts`.
|
|
57
|
+
|
|
58
|
+
**Does not own** the HTTP API, the grants, the token format, how each store
|
|
59
|
+
behaves, or the config schema — the packages do. Change those upstream, not
|
|
60
|
+
here.
|
|
61
|
+
|
|
62
|
+
**Why separate.** Everything above is a per-deployment decision, so it is kept
|
|
63
|
+
out of the published libraries and put in a tree the operator is expected to
|
|
64
|
+
modify. It is `"private": true` and never published itself; it reaches users
|
|
65
|
+
only through the scaffolder. A scaffolded copy carries this README too; its
|
|
66
|
+
relative links into `../../docs` and `../../packages` resolve only inside this
|
|
67
|
+
repository.
|
|
68
|
+
|
|
5
69
|
## Requirements
|
|
6
70
|
|
|
7
71
|
- **Node.js** `>=22.0.0`
|
|
8
72
|
- **`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
73
|
- **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
|
-
- **ioredis** `^6.0.0` (direct runtime dependency). Every `@o3co/auth-provider-redis` adapter this template wires — refresh-token families, authorization codes, rate-limit counters, the access-token denylist, the user-session stores, the federation-token store — runs off the one ioredis connection `standaloneRedisClientsModule` opens per replica
|
|
74
|
+
- **ioredis** `^6.0.0` (direct runtime dependency). Every `@o3co/auth-provider-redis` adapter this template wires — refresh-token families, authorization codes, rate-limit counters, the access-token denylist, the replay seen-set, the user-session stores, the federation-token store, the consent stores and the federation-grant stores — runs off the one ioredis connection `standaloneRedisClientsModule` opens per replica. The same module provides the client of every other store the Redis package ships — the device-code store and the WebAuthn challenge store — so a deployment that adds the device grant or WebAuthn to `buildModules.mts` with a Redis store finds its client there. The `redis` npm package (`^6.2.1`) stays a dependency for one thing: `connect-redis` takes a node-redis client, so the `express-session` store behind `SESSION_STORAGE_TYPE=redis` is a second, separate connection (the `session-store` probe in `/readyz`).
|
|
11
75
|
|
|
12
76
|
## First-party clients
|
|
13
77
|
|
|
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.
|
|
78
|
+
For a client marked `firstParty: true`, `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
79
|
|
|
16
80
|
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
81
|
|
|
18
|
-
So the assumption is enforced rather than assumed.
|
|
82
|
+
So the assumption is enforced rather than assumed. Every other client — one with no `firstParty` field and one carrying `false` alike — goes through the consent step: the user is asked on your consent page before a code is minted. That step needs a consent store, and the template ships none (`CONSENT_STORE_ADAPTER=none`), so on the defaults such a client is refused at `/authorize` with `unauthorized_client`; see [Consent Store](#consent-store) to serve it. Mark a client first-party only if you would be comfortable with it receiving a user's identity **without the user being asked**.
|
|
19
83
|
|
|
20
84
|
```yaml
|
|
21
85
|
# config/clients.yaml
|
|
@@ -31,9 +95,9 @@ A machine client can authenticate with a key instead of a shared secret (#484):
|
|
|
31
95
|
[oauth package README](../../packages/oauth/README.md#client-authentication-private_key_jwt-rfc-7523-22).
|
|
32
96
|
Nothing secret lives in `clients.yaml` for such a client.
|
|
33
97
|
|
|
34
|
-
**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.
|
|
98
|
+
**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.
|
|
35
99
|
|
|
36
|
-
**
|
|
100
|
+
**Unmarked registrations.** A registration without the field is not first-party: on the defaults it is refused at `/authorize` until you mark it, with no admit-with-warning window. Mark every client you operate before pointing it at `/authorize`. `oauth.authorize.allowUnmarkedClients` is not a setting: a config or environment that sets it (`OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS`, any value) fails at boot with migration instructions rather than being silently ignored.
|
|
37
101
|
|
|
38
102
|
## Multi-replica deployments
|
|
39
103
|
|
|
@@ -41,9 +105,9 @@ The [operator runbook](../../docs/operator-runbook.md) covers the operational si
|
|
|
41
105
|
|
|
42
106
|
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.
|
|
43
107
|
|
|
44
|
-
**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`.
|
|
108
|
+
**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`. An audit event with no `ip` means the request's address was not an address — `req.ip` came from an `X-Forwarded-For` that held something else — so check that `HTTP_TRUST_PROXY` trusts only the hop that sets the header.
|
|
45
109
|
|
|
46
|
-
|
|
110
|
+
It takes four shapes, all of them Express's own:
|
|
47
111
|
|
|
48
112
|
| Value | Meaning |
|
|
49
113
|
|---|---|
|
|
@@ -56,7 +120,7 @@ Since #292 it takes four shapes, all of them Express's own:
|
|
|
56
120
|
|
|
57
121
|
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.
|
|
58
122
|
|
|
59
|
-
**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.
|
|
123
|
+
**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. 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.
|
|
60
124
|
|
|
61
125
|
**Behind a BFF, raise `limits.token` before you need to.** The OAuth-endpoint
|
|
62
126
|
rate limits key on `req.ip` (`packages/core/src/ratelimit/guard.mts` builds the
|
|
@@ -101,7 +165,7 @@ raising it globally to accommodate one topology would weaken it for the other.
|
|
|
101
165
|
Put the throttling that protects the BFF's *own* users in front of the BFF,
|
|
102
166
|
where per-user identity still exists.
|
|
103
167
|
|
|
104
|
-
**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,
|
|
168
|
+
**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, a single-use client assertion or WebAuthn challenge replayable once per replica. The check reads the declaration each installed module carries on its own manifest rather than a list of library module names, so this template's own in-memory modules — the user-session stores (`USER_SESSION_STORES_ADAPTER=memory`), the authorization-code repository (`OAUTH_CODE_ADAPTER=memory`) and the federation token store (`FEDERATION_TOKEN_STORE_TYPE=memory`, the default) — are refused by name too. So are express-session's own store under `SESSION_STORAGE_TYPE=memory` (#474) and the default memory rate limiter (`core-rate-limiter-memory`, `RATE_LIMITER_ADAPTER=memory`). With the mode unset nothing is refused: every one of them is listed in a single `replica_unsafe_adapters` warning at boot. (The login and WebAuthn-options routes carry per-process fallback limiters that warn on their own, but only in a composition that wires no `rateLimiter` at all, which this template never does; see the operator runbook.) With `DEPLOYMENT_MODE=single` the check is silent, because you have said there is one replica. This template does not install DPoP; a composition that adds it records every accepted proof in the same replay seen-set as `private_key_jwt` (`REPLAY_SEEN_SET_ADAPTER`), so it gets the same answer — `memory` is refused under `DEPLOYMENT_MODE=multi` and listed in the unset-mode warning, and the shipped `redis` shares DPoP's records across replicas — see the dpop package's [operator requirements](../../packages/dpop/README.md#operator-requirements).
|
|
105
169
|
|
|
106
170
|
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.
|
|
107
171
|
|
|
@@ -111,7 +175,7 @@ Other multi-replica considerations covered by the default modules:
|
|
|
111
175
|
|
|
112
176
|
- The `express-session` store (`sessionStoreModule`) is its own connection: `SESSION_STORAGE_TYPE=redis` with `SESSION_STORAGE_REDIS_URL` (`session.storage.redis.url`) pointing at the shared instance.
|
|
113
177
|
- The user-session stores switch on `userSessionStores.adapter = "redis"` (`USER_SESSION_STORES_ADAPTER`), which wires `redisSessionStoresModule` off the shared ioredis connection — the one `REFRESH_TOKEN_FAMILY_STORE_REDIS_URL` configures.
|
|
114
|
-
- The authorization-code repository switches on `oauth.code.adapter` (`OAUTH_CODE_ADAPTER`); the template ships `"redis"`, on that same connection.
|
|
178
|
+
- The authorization-code repository switches on `oauth.code.adapter` (`OAUTH_CODE_ADAPTER`); the template ships `"redis"`, on that same connection. `oauth.code.adapter` wins; the deprecated `repositories.code.type` (`CLIENT_CODE_TYPE`) is read, with a `config_key_deprecated` warning at boot, only when it is unset — which the shipped `config/application.conf` never leaves it. `CLIENT_CODE_ENDPOINT_URI` (`repositories.code.redis.endpointUri`) is bound by `config/application.conf` but nothing reads it: the Redis code repository runs on the shared connection, so there is one Redis URL for every adapter.
|
|
115
179
|
- The replay seen-set — the `jti` single-use record behind `private_key_jwt` client authentication (#484) — switches on `replaySeenSet.adapter` (`REPLAY_SEEN_SET_ADAPTER`); the template ships `"redis"` on the shared connection, and `memory` is refused under `DEPLOYMENT_MODE=multi` because a captured client assertion would replay once per replica.
|
|
116
180
|
- The consent step for clients that are not first-party (#527) switches on `consentStore.adapter` (`CONSENT_STORE_ADAPTER`). It is off (`none`) by default; `memory` is refused under `DEPLOYMENT_MODE=multi`, because a consent granted on one replica would be asked for again on every other and a consent page's parked request would be unknown to the replica that receives the answer. `redis` (#561) keeps both on the shared connection. See [Consent Store](#consent-store).
|
|
117
181
|
- The federation token store defaults to memory. Set `FEDERATION_TOKEN_STORE_TYPE=redis` (`federationTokenStore.type = "redis"`) and supply `REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY` — 32 bytes, base64-encoded (`openssl rand -base64 32`); the store encrypts the upstream refresh tokens it holds. It shares the ioredis socket configured by `REFRESH_TOKEN_FAMILY_STORE_REDIS_URL`. See [Federation Token Store](#federation-token-store).
|
|
@@ -119,32 +183,93 @@ Other multi-replica considerations covered by the default modules:
|
|
|
119
183
|
|
|
120
184
|
## Usage
|
|
121
185
|
|
|
186
|
+
`pnpm run debug` runs `src/app.mts` under `tsx watch` on this machine. It reads
|
|
187
|
+
no `.env` file — its configuration is `config/` plus the environment of the
|
|
188
|
+
shell that starts it — and it does not boot until that environment supplies:
|
|
189
|
+
|
|
190
|
+
- `OAUTH_JWT_ISSUER` — this server's origin (`http` is accepted for a loopback
|
|
191
|
+
host);
|
|
192
|
+
- a signing key pair, `OAUTH_JWT_PRIVATE_KEY_PATH` / `OAUTH_JWT_PUBLIC_KEY_PATH`
|
|
193
|
+
(see [OAuth JWT](#oauth-jwt));
|
|
194
|
+
- `SESSION_SECRET`, at least 32 bytes;
|
|
195
|
+
- `CLIENT_USER_AUTHENTICATE_URL` and `CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL` —
|
|
196
|
+
your user service ("the Store"), `https`, or `http` on a loopback host. Boot
|
|
197
|
+
checks their form, not that anything answers; logins fail until something
|
|
198
|
+
does (see [User Repository](#user-repository));
|
|
199
|
+
- a Redis it can reach at `redis://localhost:6379`. The shipped configuration
|
|
200
|
+
keeps the browser session store there (`SESSION_STORAGE_REDIS_URL`), and
|
|
201
|
+
opens one shared connection (`REFRESH_TOKEN_FAMILY_STORE_REDIS_URL`) for the
|
|
202
|
+
refresh-token families, the authorization codes, the access-token denylist
|
|
203
|
+
and the replay seen-set — and for the user-session stores, with the setting
|
|
204
|
+
below.
|
|
205
|
+
|
|
206
|
+
Plain HTTP also needs `SESSION_SECURE=false` and a session cookie name without
|
|
207
|
+
the `__Host-` prefix (see [Session](#session)). And set
|
|
208
|
+
`USER_SESSION_STORES_ADAPTER=redis`: the browser session store is in Redis, the
|
|
209
|
+
user-session stores default to memory, and `tsx watch` restarts the process on
|
|
210
|
+
every save — split, a restart leaves the browser's session pointing at a
|
|
211
|
+
`UserSession` that is gone, and `/authorize` loops (see [Docker](#docker)).
|
|
212
|
+
|
|
213
|
+
The key pair below is generated with Node, which every machine running this
|
|
214
|
+
already has. `openssl genpkey -algorithm ed25519` works too, with OpenSSL 1.1.1
|
|
215
|
+
or later — not with the LibreSSL that macOS ships as `/usr/bin/openssl`.
|
|
216
|
+
|
|
122
217
|
```bash
|
|
123
|
-
# Install dependencies
|
|
124
218
|
pnpm install
|
|
125
219
|
|
|
126
|
-
#
|
|
127
|
-
|
|
220
|
+
# A signing key pair (Ed25519) and a local Redis
|
|
221
|
+
node -e '
|
|
222
|
+
const { generateKeyPairSync } = require("node:crypto");
|
|
223
|
+
const fs = require("node:fs");
|
|
224
|
+
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
|
|
225
|
+
fs.writeFileSync("jwt-private.pem", privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o600 });
|
|
226
|
+
fs.writeFileSync("jwt-public.pem", publicKey.export({ type: "spki", format: "pem" }));
|
|
227
|
+
'
|
|
228
|
+
docker run -d --name auth-redis -p 127.0.0.1:6379:6379 redis:7.2-alpine
|
|
229
|
+
|
|
230
|
+
# What boot requires (replace the user-service URLs with your Store's)
|
|
231
|
+
export OAUTH_JWT_ISSUER=http://localhost:3000 \
|
|
232
|
+
OAUTH_JWT_PRIVATE_KEY_PATH=./jwt-private.pem \
|
|
233
|
+
OAUTH_JWT_PUBLIC_KEY_PATH=./jwt-public.pem \
|
|
234
|
+
SESSION_SECRET="$(openssl rand -hex 32)" \
|
|
235
|
+
SESSION_SECURE=false SESSION_NAME=auth.sid \
|
|
236
|
+
USER_SESSION_STORES_ADAPTER=redis \
|
|
237
|
+
CLIENT_USER_AUTHENTICATE_URL=http://localhost:8080/authenticate \
|
|
238
|
+
CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL=http://localhost:8080/authenticate-by-token
|
|
128
239
|
|
|
129
240
|
# Start in development mode (hot reload)
|
|
130
241
|
pnpm run debug
|
|
131
242
|
|
|
132
|
-
# Build
|
|
243
|
+
# Build, then run the compiled server (CONFIG_ENV or NODE_ENV selects config/<env>.conf)
|
|
133
244
|
pnpm run build
|
|
134
|
-
pnpm start
|
|
245
|
+
pnpm run start
|
|
135
246
|
```
|
|
136
247
|
|
|
248
|
+
`.env` is read only by the compose files (`env_file`). `make dev`
|
|
249
|
+
(`docker compose up --build`) runs the same debug server in a container beside
|
|
250
|
+
a Redis service, with its environment from `.env` — copied from
|
|
251
|
+
`.env.example`, whose Redis URLs name that service. Values there are resolved
|
|
252
|
+
inside the container, which holds no key files: only `src/`, `config/`, the
|
|
253
|
+
package manifests and what installing and building them produced. A key path or
|
|
254
|
+
a `localhost` URL that works on the host does not work there. The deployable
|
|
255
|
+
image and its compose file are under [Docker](#docker).
|
|
256
|
+
|
|
137
257
|
## Configuration
|
|
138
258
|
|
|
139
259
|
Configuration is loaded from `config/application.conf` (HOCON format). Each value can be overridden with the corresponding environment variable.
|
|
140
260
|
|
|
141
261
|
### Environment-specific config overlay
|
|
142
262
|
|
|
143
|
-
`src/app.mts` loads configuration in
|
|
263
|
+
`src/app.mts` loads configuration in three layers, highest precedence first:
|
|
144
264
|
|
|
145
|
-
1. **`config/
|
|
146
|
-
2. **`config/{ENV}.conf`** — overlay for the current environment, where
|
|
265
|
+
1. **`config/{ENV}.conf`** — overlay for the current environment, where
|
|
147
266
|
`ENV = CONFIG_ENV || NODE_ENV || "development"`.
|
|
267
|
+
2. **`config/application.conf`** — this deployment's settings.
|
|
268
|
+
3. **`reference.conf` of `@o3co/auth-provider-core`** — the library defaults,
|
|
269
|
+
resolved from the installed package
|
|
270
|
+
(`import.meta.resolve("@o3co/auth-provider-core/reference.conf")`, in
|
|
271
|
+
[`src/configPath.mts`](src/configPath.mts)). A key neither file above sets
|
|
272
|
+
takes its value from here.
|
|
148
273
|
|
|
149
274
|
Values in the overlay take precedence over `application.conf`. The scaffold
|
|
150
275
|
ships with `development.conf` and `production.conf`. To add another
|
|
@@ -182,7 +307,7 @@ fail fast rather than silently falling back to defaults.
|
|
|
182
307
|
| `OAUTH_CIMD_STALE_IF_ERROR_MS` | `300000` | How long a registration already validated is still served after a revalidation that failed for a reason that is not the document's (a DNS blip, a 5xx, a timeout) — an outage should not break a working client. A document that was *rejected* is dropped immediately. `0` disables it. |
|
|
183
308
|
| `OAUTH_CIMD_NEGATIVE_CACHE_MS` | `60000` | How long a refusal is remembered, so the same `client_id` is not resolved and fetched again on every request. Short, so a client that fixes its document is not locked out. |
|
|
184
309
|
| `OAUTH_CIMD_MAX_CONCURRENT_FETCHES` | `8` | How many documents may be in flight at once, across every `client_id`. Bounds what an unauthenticated caller can make this server dial. |
|
|
185
|
-
| `OAUTH_JWT_KID` | `v0` | Key ID included in the JWT header |
|
|
310
|
+
| `OAUTH_JWT_KID` | `v0` | Key ID included in the JWT header. 1 to 256 characters with no control character; anything else fails boot, including a variable exported but empty (which used to sign under the kid `""`; tokens issued that way are refused once it is corrected, so their users sign in again) |
|
|
186
311
|
| `OAUTH_JWT_PRIVATE_KEY` | — | PEM-encoded private key (asymmetric algorithms) |
|
|
187
312
|
| `OAUTH_JWT_PRIVATE_KEY_PATH` | — | Path to PEM private key file |
|
|
188
313
|
| `OAUTH_JWT_PUBLIC_KEY` | — | PEM-encoded public key |
|
|
@@ -214,7 +339,7 @@ every relying party must be handed the shared secret — which also lets it
|
|
|
214
339
|
|---|---|---|
|
|
215
340
|
| `OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN` | `3600` | Access token lifetime in seconds that every grant mints when the request asks for none. Whole positive number, at most one year (`31536000`). |
|
|
216
341
|
| `OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN` | the default | The most a token-exchange request's `expires_in` parameter can obtain; a larger request is clamped to it. Unset means the default, so no token is extended unless you set this. A default above it is a boot failure naming both keys. It also bounds how long an exchanged token outlives a revocation at a resource server that validates it offline. |
|
|
217
|
-
| `OAUTH_ACCESS_TOKEN_EXPIRES_IN` | — | **Deprecated** alias of `OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN` (config key `oauth.accessToken.expiresIn`, of `oauth.accessToken.defaultExpiresIn`), read only while the new one is unset. Move the value to the new variable. |
|
|
342
|
+
| `OAUTH_ACCESS_TOKEN_EXPIRES_IN` | — | **Deprecated** alias of `OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN` (config key `oauth.accessToken.expiresIn`, of `oauth.accessToken.defaultExpiresIn`), read only while the new one is unset; while it still decides the default, boot logs `config_key_deprecated` (warn). Move the value to the new variable. |
|
|
218
343
|
| `OAUTH_REFRESH_TOKEN_EXPIRES_IN` | `86400` | Refresh token lifetime in seconds. Whole positive number, at most one year (`31536000`). |
|
|
219
344
|
|
|
220
345
|
Exporting one of these as an empty string is a boot failure, not a fallback:
|
|
@@ -336,6 +461,7 @@ all — no headers, no `Vary`, nothing changed.
|
|
|
336
461
|
| `FEDERATIONS_GOOGLE_CLIENT_ID` | — | Google OAuth client ID |
|
|
337
462
|
| `FEDERATIONS_GOOGLE_CLIENT_SECRET` | — | Google OAuth client secret |
|
|
338
463
|
| `FEDERATIONS_GOOGLE_CALLBACK_URL` | `http://localhost:3000/session/oauth/federation/google/callback` | Google OAuth callback URL |
|
|
464
|
+
| `FEDERATIONS_GOOGLE_ACCESS_TYPE` | unset (`offline`) | `offline`: every sign-in shows Google's consent screen and every session gets a refresh token. `online`: consent on the first sign-in only, and no refresh token at all. See [federation-google](../../packages/federation-google/README.md#refresh-tokens-and-the-consent-screen) |
|
|
339
465
|
|
|
340
466
|
### OIDC Federation (any OpenID Connect provider)
|
|
341
467
|
|
|
@@ -393,6 +519,7 @@ is documented in [its README](../../packages/federation-oidc/README.md).
|
|
|
393
519
|
| `CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL` | — | URL for token-based user authentication. **https required** (see below) |
|
|
394
520
|
| `CLIENT_USER_LINK_FEDERATED_IDENTITY_URL` | — | Optional. URL the Store links a federated identity at (#482); enables `?link=1` on the federation start route. **https required** |
|
|
395
521
|
| `CLIENT_USER_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL` | — | Optional. URL the Store answers who holds an upstream identity at (#613, federation grants' check 5). With it, declare what the Store covers in `repositories.user.http.federatedIdentityLookupCoverage` (HOCON). **https required** |
|
|
522
|
+
| `CLIENT_USER_BEARER_TOKEN` | — | Optional. The credential this server presents to the Store: every Store request carries `Authorization: Bearer <token>`. A bare token (no `Bearer ` prefix) of at least 32 bytes — `openssl rand -hex 32`. Unset, no `Authorization` header is sent (see below) |
|
|
396
523
|
| `CLIENT_USER_TIMEOUT` | `5000` | HTTP request timeout in milliseconds. Positive integer ≤ `2147483647` |
|
|
397
524
|
| `CLIENT_USER_MAX_RESPONSE_BYTES` | `1048576` | Largest upstream response body accepted, in bytes |
|
|
398
525
|
|
|
@@ -405,11 +532,42 @@ only for a loopback host — `localhost`, an address in `127.0.0.0/8`, or `[::1]
|
|
|
405
532
|
deployment does not control end to end. Boot fails if either URL, the timeout,
|
|
406
533
|
or the response cap is unusable, rather than at the first login attempt.
|
|
407
534
|
|
|
535
|
+
Each URL must be the endpoint that answers, not one that redirects: no request
|
|
536
|
+
follows a `3xx`, so a URL that redirects fails every call — see
|
|
537
|
+
[What the Store must enforce itself](../../packages/foundation/README.md#what-the-store-must-enforce-itself).
|
|
538
|
+
|
|
539
|
+
**Who may call the Store.** What the token-based login and account linking
|
|
540
|
+
send is an identifier, not a secret, so a Store that answers any caller
|
|
541
|
+
resolves a known identity to its user — or links one to any account — for
|
|
542
|
+
anyone who can reach it. Set `CLIENT_USER_BEARER_TOKEN` and have the Store
|
|
543
|
+
answer every request whose `Authorization` is not exactly `Bearer <that token>`
|
|
544
|
+
with `401` and `WWW-Authenticate: Bearer error="invalid_token"` (or `403` and
|
|
545
|
+
`error="insufficient_scope"`). The one token goes to every Store URL above, so
|
|
546
|
+
those endpoints must be one trust domain. With `CLIENT_USER_TYPE=http` — this
|
|
547
|
+
template's default — boot fails if the token is weaker than 32 bytes
|
|
548
|
+
(measured like `SESSION_SECRET`), malformed, or exported but empty; it appears
|
|
549
|
+
in no error this server throws. With that challenge, a token the Store does not
|
|
550
|
+
accept is an outage on every Store call: logins answer
|
|
551
|
+
`503 temporarily_unavailable` and log an error naming the refused credential,
|
|
552
|
+
not the token, and so do federation-grants connects
|
|
553
|
+
(`federation_grant_callback_unavailable`). A `401` or `403` without the
|
|
554
|
+
challenge keeps its old meaning — at login "no such user", at a `?link=1`
|
|
555
|
+
link a refusal, and at the federation-grants identity lookup an outage like
|
|
556
|
+
any other non-`2xx` — so a mismatch then shows only as every login failing
|
|
557
|
+
and every link refused; and a Store must not put a `Bearer` challenge on a
|
|
558
|
+
user's wrong password or a link it refuses. A Store that cannot be reached,
|
|
559
|
+
closes the connection before a complete response, or answers with a malformed
|
|
560
|
+
HTTP response is logged as such, with at most a transport code
|
|
561
|
+
(`ECONNREFUSED`, `UND_ERR_SOCKET`, `ERR_SSL_WRONG_VERSION_NUMBER`, …), and
|
|
562
|
+
never with what the transport quoted. Without the token, admit only this server to the Store
|
|
563
|
+
by network policy or platform mutual TLS. Rotation, what each caller logs, and
|
|
564
|
+
what the Store checks are in the same foundation README section.
|
|
565
|
+
|
|
408
566
|
### Code Repository
|
|
409
567
|
|
|
410
568
|
| Variable | Default | Description |
|
|
411
569
|
|---|---|---|
|
|
412
|
-
| `CLIENT_CODE_TYPE` | `redis` | Deprecated alias of `OAUTH_CODE_ADAPTER` (`repositories.code.type`);
|
|
570
|
+
| `CLIENT_CODE_TYPE` | `redis` | Deprecated alias of `OAUTH_CODE_ADAPTER` (`repositories.code.type`); read, with a `config_key_deprecated` warning at boot, only when `oauth.code.adapter` is unset — the shipped config sets it |
|
|
413
571
|
| `CLIENT_CODE_ENDPOINT_URI` | — | Legacy; bound to `repositories.code.redis.endpointUri` but not read — codes use the shared `REFRESH_TOKEN_FAMILY_STORE_REDIS_URL` connection |
|
|
414
572
|
| `CLIENT_CODE_PASSWORD` | — | Redis password for code storage |
|
|
415
573
|
| `CLIENT_CODE_DEFAULT_EXPIRES_IN` | `600` | Default authorization code lifetime in seconds |
|
|
@@ -601,37 +759,25 @@ Use values that include the deployment name, for example `tenant-a:ss:`,
|
|
|
601
759
|
| `ENDPOINTS_LOGIN_URL` | `/login` | URL of the login page (for redirects) |
|
|
602
760
|
| `ENDPOINTS_CONSENT_URL` | `/consent` | URL of the consent page a client that is not first-party is routed through, with `?challenge=<id>` (#527) |
|
|
603
761
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
`
|
|
607
|
-
per-provider (`FEDERATIONS_GOOGLE_CALLBACK_URL`).
|
|
762
|
+
There is no deployment-wide client or callback URL: federation callback URLs
|
|
763
|
+
are configured per federation (`FEDERATIONS_GOOGLE_CALLBACK_URL`,
|
|
764
|
+
`FEDERATIONS_OIDC_CALLBACK_URL`).
|
|
608
765
|
|
|
609
766
|
## Module Composition Order
|
|
610
767
|
|
|
611
|
-
`src/buildModules.mts` is the single source of truth for
|
|
612
|
-
|
|
613
|
-
1. **`
|
|
614
|
-
2.
|
|
615
|
-
3.
|
|
616
|
-
4.
|
|
617
|
-
5.
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
8. **`keyStoreModule`** — the signing key store from `oauth.jwt.signingKey`
|
|
621
|
-
9. **`repositoriesModule`** — client and user repositories from `repositories.client` / `repositories.user`
|
|
622
|
-
10. **`auditSinkModule`** — always wired; `audit.sink.type` picks `logger` (default) or `console`
|
|
623
|
-
11. **`standaloneRedisClientsModule`** — the one shared ioredis connection; present whenever any switch below is `"redis"`
|
|
624
|
-
12. **`redisFederationTokenStoreModule`** / **`inMemoryFederationTokenStoreModule`** — *adapter switch*: upstream federation tokens per session (`federationTokenStore.type`; see [Multi-replica deployments](#multi-replica-deployments))
|
|
625
|
-
13. **`redisSessionStoresModule`** / **`inMemorySessionStoresModule`** — *adapter switch*: user-session stores (`userSessionStores.adapter`)
|
|
626
|
-
14. **`redisRateLimiterModule`** / **`memoryRateLimiterModule`** — *adapter switch*: the rate limiter behind the OAuth endpoints and `POST /session/login` (`rateLimiter.adapter`)
|
|
627
|
-
15. **`redisCodeRepositoryModule`** / **`inMemoryCodeRepositoryModule`** — *adapter switch*: authorization codes (`oauth.code.adapter`)
|
|
628
|
-
16. **`redisAccessTokenDenylistModule`** / **`memoryAccessTokenDenylistModule`** — *adapter switch*: RFC 7009 access-token denylist (`accessTokenDenylist.adapter`)
|
|
629
|
-
17. **`redisRefreshTokenFamilyStoreModule`** — refresh-token families; Redis unless a test override swaps in the memory store
|
|
630
|
-
18. **`defaultRefreshTokenFamilyRotationModule`**, **`defaultRefreshTokenFamilyRevocationModule`** — rotation and revocation policies over the family store
|
|
768
|
+
[`src/buildModules.mts`](src/buildModules.mts) is the single source of truth for which modules are composed and in what order — read it rather than a copy of it. The boot planner resolves `requires` / `provides` topologically, but it mounts routes and middleware in list order unless a module declares `before` / `after` — so position matters for anything that mounts. These are the rules to keep when you edit the list:
|
|
769
|
+
|
|
770
|
+
1. **`sessionStoreModuleFor(config)` stays first.** It mounts the `express-session` middleware and declares no `before` / `after`, so its position in the list is what puts it ahead of every route that reads `req.session`. It is built from `config` so that `session.storage.type = "memory"` declares itself replica-unsafe.
|
|
771
|
+
2. **Under `/oauth`, the order does not matter.** `federationGrantsModules` (while federation grants are enabled), `oauthModule` and any module of your own may all mount routes under `/oauth`. `oauthModule`'s router parses the bodies of its own routes only, so each module's requests reach its own parsers whatever the list order — provided every module under `/oauth` parses its own body and scopes its parsers to exactly its own paths, as the shipped ones do — a route (`router.all(path, parser)` or the route's own handler list), not `router.use(path, parser)`, which matches every path beneath `path` as well. The federation grants browser half orders itself after the session middleware with its own `after`.
|
|
772
|
+
3. **One module per store slot.** Each adapter switch — `federationTokenStore.type`, `userSessionStores.adapter`, `rateLimiter.adapter`, `oauth.code.adapter`, `accessTokenDenylist.adapter`, `replaySeenSet.adapter`, `consentStore.adapter`, and the two federation-grant store switches — picks one of a memory / Redis pair. Both provide the same slot, so wiring both is a boot-time slot collision. `consentStore.adapter = "none"` wires neither, and the federation-grant stores are wired only while the feature is enabled.
|
|
773
|
+
4. **The shared Redis connection comes with the first Redis-backed module.** `standaloneRedisClientsModule` opens the one ioredis connection every Redis adapter here uses, and is added whenever a composed module needs one. The refresh-token family store is on Redis in the shipped composition, so a deployment always has it; the in-memory family store is a test override (`overrides.refreshTokenFamilyModules`).
|
|
774
|
+
5. **A federation adapter comes with its config bridge.** `googleFederationModule` with `googleFederationConfigModule` — only for an enabled `federations.google` section whose `type` is `google`, so a `type = "oidc"` section named `google` is not composed twice — and one `oidcFederationModule(name)` per enabled `type = "oidc"` section, with the one `oidcFederationConfigModule` they share. A bridge's provider throws when its section is absent, so the pair is included at composition time rather than gated inside it.
|
|
775
|
+
|
|
776
|
+
`jwksModule` (from core) is always composed: a provider that signs tokens publishes its verification keys whether or not an issuer is configured. What each route module mounts is in its package's README. One behaviour to know when composing: `sessionModule`'s `POST /session/logout` deletes the `UserSession` record (so `/oauth/introspect` and `/oauth/userinfo` stop honouring tokens minted from that session), the subject index and the federation entries — but it does **not** revoke refresh-token families; `POST /oauth/logout` is the endpoint that runs the full cascade. See [Which logout endpoint invalidates what](../../docs/operator-runbook.md#which-logout-endpoint-invalidates-what).
|
|
631
777
|
|
|
632
778
|
## Built-in Routes
|
|
633
779
|
|
|
634
|
-
`src/
|
|
780
|
+
`src/routes.mts` mounts these on the host app ahead of the composed auth router, so they keep answering while the auth pipeline is degraded — which is when an operator needs them — and core's terminal error handler after all of them, so an error one of them lets through is answered as the auth routes' are (`500 server_error`, logged once as `unhandled_request_error`). The JWKS route is not one of them: `jwksModule` contributes it (see above).
|
|
635
781
|
|
|
636
782
|
| Method | Path | Description |
|
|
637
783
|
|---|---|---|
|
|
@@ -688,8 +834,8 @@ merely stored badly. The same applies to `.env`; keep both out of git.
|
|
|
688
834
|
|
|
689
835
|
The production file also pins `SESSION_SECURE=true` and the `__Host-` cookie
|
|
690
836
|
name in its `environment:` block, which wins over `env_file`. `.env.example`
|
|
691
|
-
ships `SESSION_SECURE=false` so a plain-HTTP
|
|
692
|
-
requires that same `.env` — behind the TLS it assumes, a non-Secure session
|
|
837
|
+
ships `SESSION_SECURE=false` so that a plain-HTTP compose run (`make dev`) does
|
|
838
|
+
not trip the `__Host-` cookie check, and this file requires that same `.env` — behind the TLS it assumes, a non-Secure session
|
|
693
839
|
cookie is the session handed to anyone who can read one plaintext hop.
|
|
694
840
|
|
|
695
841
|
The image installs with `pnpm install --frozen-lockfile`, so a committed
|
|
@@ -699,7 +845,7 @@ the result. Rebuilding the same source then produces the same dependency tree.
|
|
|
699
845
|
|
|
700
846
|
The production image sets `ENV HTTP_PORT=3000` and uses it for both `EXPOSE`
|
|
701
847
|
and the Docker-native healthcheck against `/_healthcheck`. The HOCON config
|
|
702
|
-
also reads `${?HTTP_PORT}` for `http.port`, so
|
|
848
|
+
also reads `${?HTTP_PORT}` for `http.port`, so the listener and the
|
|
703
849
|
healthcheck cannot drift apart. To run on a different port:
|
|
704
850
|
|
|
705
851
|
```bash
|
|
@@ -756,7 +902,8 @@ changing the image.
|
|
|
756
902
|
```
|
|
757
903
|
|
|
758
904
|
The failure's *reason* is deliberately absent from the body and goes to the log
|
|
759
|
-
instead (`readiness_probe_failed`, with
|
|
905
|
+
instead (`readiness_probe_failed`, with each failing check's error as core's
|
|
906
|
+
`loggableError` projection). A driver
|
|
760
907
|
error reads `connect ECONNREFUSED 10.0.3.14:6379` — an internal host and port —
|
|
761
908
|
and this endpoint is unauthenticated because an orchestrator has no credentials
|
|
762
909
|
to present. Set `includeErrorDetail: true` on `createReadinessRouter` only when
|
|
@@ -772,9 +919,10 @@ rules.
|
|
|
772
919
|
Probes are registered by whichever builder opened the connection, so the list
|
|
773
920
|
reflects what this deployment actually wired: the shared ioredis client
|
|
774
921
|
(`redis`) and the connect-redis session client (`session-store`) when the
|
|
775
|
-
Redis adapters are selected, and **nothing at all** for a
|
|
776
|
-
|
|
777
|
-
be unready for.
|
|
922
|
+
Redis adapters are selected, and **nothing at all** for a composition with no
|
|
923
|
+
Redis-backed module — which would therefore always be ready, having no
|
|
924
|
+
dependency to be unready for. The shipped composition is never that: its
|
|
925
|
+
refresh-token families are always in Redis, so `redis` is always probed.
|
|
778
926
|
|
|
779
927
|
`http.readinessTimeoutMs` (env `HTTP_READINESS_TIMEOUT_MS`, default `1000`)
|
|
780
928
|
is the per-probe deadline. Keep it below the orchestrator's own probe timeout;
|
|
@@ -783,43 +931,22 @@ unready one.
|
|
|
783
931
|
|
|
784
932
|
## Adding Custom Modules
|
|
785
933
|
|
|
786
|
-
To add a custom module, import it in `src/buildModules.mts` and
|
|
787
|
-
|
|
788
|
-
```
|
|
789
|
-
// src/buildModules.mts
|
|
790
|
-
import { myCustomModule } from "./
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
oauthSessionModule({ config }),
|
|
799
|
-
oauthAuthorizationModule({ config }),
|
|
800
|
-
sessionModule,
|
|
801
|
-
...(googleEnabled ? [googleFederationModule, googleFederationConfigModule] : []),
|
|
802
|
-
myCustomModule, // add here
|
|
803
|
-
overrides.keyStoreModule ?? keyStoreModule,
|
|
804
|
-
overrides.repositoriesModule ?? repositoriesModule,
|
|
805
|
-
overrides.storesModule ?? storesModule,
|
|
806
|
-
];
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
// src/app.mts
|
|
810
|
-
const handle = await createApp({
|
|
811
|
-
modules: buildModules(config),
|
|
812
|
-
bootstrapComponents: {
|
|
813
|
-
config,
|
|
814
|
-
pathResolver: import.meta.resolve,
|
|
815
|
-
},
|
|
816
|
-
});
|
|
817
|
-
|
|
818
|
-
app.use(handle.router);
|
|
819
|
-
const server = app.listen(config.http.port);
|
|
820
|
-
installGracefulShutdown(server, { logger, cleanup: () => handle.dispose() });
|
|
934
|
+
To add a custom module, import it in `src/buildModules.mts` and add it to the array `buildModules` returns. The boot planner resolves dependencies by what modules `require` and `provide`, but it mounts routes and middleware in list order wherever no `before` / `after` says otherwise. So position matters for middleware that other routes depend on — the session store module, rule 1 of [Module Composition Order](#module-composition-order). A route under a prefix another module also uses, such as `/oauth`, needs no particular position (rule 2), provided it parses its own body and scopes its parsers to exactly its own paths: `oauthModule`'s router parses only its own routes', and a parser that ran for every request under the prefix would read other modules' bodies for them. A module that mounts nothing of the first kind can go at the end:
|
|
935
|
+
|
|
936
|
+
```diff
|
|
937
|
+
// src/buildModules.mts
|
|
938
|
+
+import { myCustomModule } from "./my-custom-module.mjs";
|
|
939
|
+
…
|
|
940
|
+
return [
|
|
941
|
+
sessionStoreModuleFor(config),
|
|
942
|
+
…
|
|
943
|
+
...(federationGrantsEnabled ? [subjectRevocationServiceModule] : []),
|
|
944
|
+
+ myCustomModule,
|
|
945
|
+
];
|
|
821
946
|
```
|
|
822
947
|
|
|
948
|
+
Keep the other rules there too: the session store module stays first, and a module that fills a store slot replaces that slot's adapter switch rather than being added beside it. [`src/app.mts`](src/app.mts) needs no change: it passes `buildModules(config, …)` to `createApp`, mounts the router `createApp` returns, and wires the server's lifetime — `installGracefulShutdown` (below) drains it and calls `handle.dispose()`.
|
|
949
|
+
|
|
823
950
|
### Shutdown guarantees
|
|
824
951
|
|
|
825
952
|
`src/shutdown.mts` is deliberately part of the scaffold rather than a dependency (#290): for the component that terminates every user session, *"does SIGTERM wait for in-flight requests, and for how long?"* has to be answerable from the code you deploy.
|
|
@@ -828,7 +955,7 @@ installGracefulShutdown(server, { logger, cleanup: () => handle.dispose() });
|
|
|
828
955
|
2. **New connections stop immediately**, and idle keep-alive sockets are released — they hold the server open with no request behind them, so a quiet server would otherwise wait out the whole deadline for nothing.
|
|
829
956
|
3. **In-flight requests get `drainTimeoutMs`** (default **10s**) to finish.
|
|
830
957
|
4. **Past the deadline the remaining connections are cut and the process exits non-zero.** An orchestrator that only ever sees `0` cannot tell a clean drain from one that ran out of time.
|
|
831
|
-
5. **`cleanup` runs after draining, before exit** — `handle.dispose()`, i.e. reverse-topological component cleanup plus the Redis/timer drain. A failure there is logged through this service's own logger (NDJSON, like every other line) and reflected in the exit code
|
|
958
|
+
5. **`cleanup` runs after draining, before exit** — `handle.dispose()`, i.e. reverse-topological component cleanup plus the Redis/timer drain. A failure there is logged through this service's own logger (NDJSON, like every other line) as `shutdown_cleanup_failed` (or `shutdown_cleanup_timed_out`) and reflected in the exit code; every stage is its own event — `shutdown_draining`, `shutdown_drain_deadline_exceeded`, `shutdown_server_close_failed`, `shutdown_complete`. A dispose that throws still exits; it never wedges the process. The line carries core's [`loggableError`](../../packages/core/README.md#logger) projection of the failure, never the error itself: `dispose()` rejects with an AggregateError of every cleanup's own error, and the line names each of them with its code (`aggregateErrors`, the first five) and nothing of what it holds — a store write that failed, with what it was writing, can be one of them.
|
|
832
959
|
|
|
833
960
|
6. **With federation grants on, `cleanup` gets the longest refresh tail plus a margin** (`cleanupAllowanceFor` in `src/shutdown.mts`): `federationGrants.upstreamHardTimeoutMs` + `persistRetryBudgetMs` + `lockWaitMs` + 12 s, never below 45 s — exactly 45 s under the shipped budgets (25 + 3 + 5 + 12) — instead of inheriting the drain's ten, because the dispose waits for a rotated upstream credential's write. Raise a budget and the allowance grows with it; raise your orchestrator's grace to match. Off, the cleanup budget stays the drain's.
|
|
834
961
|
|
|
@@ -839,9 +966,9 @@ installGracefulShutdown(server, { logger, cleanup: () => handle.dispose() });
|
|
|
839
966
|
| Script | Description |
|
|
840
967
|
|---|---|
|
|
841
968
|
| `pnpm run build` | Compile TypeScript to `dist/` |
|
|
842
|
-
| `pnpm start` | Start the compiled server |
|
|
969
|
+
| `pnpm run start` | Start the compiled server |
|
|
843
970
|
| `pnpm run debug` | Start with hot reload via `tsx watch` (development mode) |
|
|
844
|
-
| `pnpm test` |
|
|
971
|
+
| `pnpm run test` | Typecheck (`tsc --noEmit`), then run the tests with Vitest |
|
|
845
972
|
|
|
846
973
|
## See Also
|
|
847
974
|
|
|
@@ -313,6 +313,14 @@ federations {
|
|
|
313
313
|
# sending it. It permits a missing `iss` only — one that is sent and is not
|
|
314
314
|
# Google's is refused either way.
|
|
315
315
|
# requireAuthorizationResponseIss = ${?FEDERATIONS_GOOGLE_REQUIRE_AUTHORIZATION_RESPONSE_ISS}
|
|
316
|
+
|
|
317
|
+
# Whether sign-in asks Google for a refresh token. Unset is "offline":
|
|
318
|
+
# every sign-in shows Google's consent screen, because Google issues a
|
|
319
|
+
# refresh token only there, and every session gets one that
|
|
320
|
+
# POST /oauth/federation/google/token can refresh with. "online" shows the
|
|
321
|
+
# consent screen on the first sign-in only and gets no refresh token at
|
|
322
|
+
# all — for a deployment that uses Google to sign in and nothing more.
|
|
323
|
+
accessType = ${?FEDERATIONS_GOOGLE_ACCESS_TYPE}
|
|
316
324
|
}
|
|
317
325
|
|
|
318
326
|
# Any OpenID Connect provider (Okta, Entra ID, Auth0, Keycloak, your own
|
|
@@ -371,25 +379,40 @@ repositories {
|
|
|
371
379
|
# Both URLs carry plaintext user credentials, so both must be absolute
|
|
372
380
|
# https URLs. http:// is accepted only for a loopback host (localhost,
|
|
373
381
|
# 127.0.0.0/8, [::1]); a private-range address or a container-network
|
|
374
|
-
# service name still needs https. Boot fails if either is not.
|
|
382
|
+
# service name still needs https. Boot fails if either is not. Each must
|
|
383
|
+
# also be the endpoint that answers: no request follows a 3xx, so a URL
|
|
384
|
+
# that redirects fails every call.
|
|
375
385
|
authenticateUrl = ${?CLIENT_USER_AUTHENTICATE_URL}
|
|
376
386
|
authenticateByTokenUrl = ${?CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL}
|
|
377
387
|
# #482: optional. When set, a signed-in user can link a second federated
|
|
378
388
|
# identity (GET /session/oauth/federation/<name>?link=1): the Store
|
|
379
389
|
# receives { userId, provider, sub, token, claims } and decides — never on
|
|
380
|
-
# an unverified or relay e-mail. Same https rule as the two URLs above.
|
|
390
|
+
# an unverified or relay e-mail. Same https and no-redirect rule as the two URLs above.
|
|
381
391
|
# Also declared in core's reference.conf, so a composition that does not
|
|
382
392
|
# layer this file gets the same override.
|
|
383
393
|
# Without it, link=1 is refused with link_unsupported.
|
|
384
394
|
linkFederatedIdentityUrl = ${?CLIENT_USER_LINK_FEDERATED_IDENTITY_URL}
|
|
385
395
|
# #613: optional. The Store's identity lookup for federation grants —
|
|
386
396
|
# what lets this deployment keep FEDERATION_GRANTS_IDENTITY_LOOKUP=required
|
|
387
|
-
# with a connection configured. Same https rule. Declare what the Store
|
|
397
|
+
# with a connection configured. Same https and no-redirect rule. Declare what the Store
|
|
388
398
|
# covers, per registration, in a HOCON layer (README: Federation Grants):
|
|
389
399
|
# federatedIdentityLookupCoverage = [
|
|
390
400
|
# { provider = "entra-files", issuer = "...", clientId = "...", requiredClaims = ["tid", "oid"] }
|
|
391
401
|
# ]
|
|
392
402
|
findSubjectByFederatedIdentityUrl = ${?CLIENT_USER_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL}
|
|
403
|
+
# Optional. Sent on every Store request as `Authorization: Bearer <token>`
|
|
404
|
+
# so the Store can refuse any caller but this deployment; unset, none is
|
|
405
|
+
# sent. A bare token (no "Bearer " prefix), at least 32 bytes of key
|
|
406
|
+
# material -- `openssl rand -hex 32`. Boot fails on a weak, malformed or
|
|
407
|
+
# blank value. One token goes to every URL above: one trust domain. A
|
|
408
|
+
# Store that refuses it with 401 (or 403) + `WWW-Authenticate: Bearer`
|
|
409
|
+
# makes every Store call an outage rather than a wrong password: 503 and
|
|
410
|
+
# a logged error at login, federation callbacks and the jwt-bearer
|
|
411
|
+
# grant; a temporarily_unavailable redirect and
|
|
412
|
+
# federation_grant_callback_unavailable (err.name
|
|
413
|
+
# StoreCredentialRefusedError) at the federation-grants callback. Also
|
|
414
|
+
# declared in core's reference.conf.
|
|
415
|
+
bearerToken = ${?CLIENT_USER_BEARER_TOKEN}
|
|
393
416
|
# Positive integer, milliseconds, <= 2147483647. Boot fails otherwise —
|
|
394
417
|
# including on a blank env override, which HOCON substitutes as "".
|
|
395
418
|
timeout = 5000
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# #407: without this file, the first `git add .` in a scaffolded project
|
|
2
|
-
# commits the two things
|
|
3
|
-
#
|
|
2
|
+
# commits the two things a local setup creates right here — the signing key
|
|
3
|
+
# pair the README's Usage generates, and the `.env` (holding SESSION_SECRET)
|
|
4
|
+
# the compose files read.
|
|
4
5
|
|
|
5
6
|
# Secrets and per-machine configuration
|
|
6
7
|
.env
|