@o3co/create-auth-provider 0.14.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.
Files changed (45) hide show
  1. package/README.ja.md +93 -47
  2. package/README.md +92 -46
  3. package/package.json +1 -1
  4. package/templates/standalone/.env.example +34 -0
  5. package/templates/standalone/Dockerfile +1 -1
  6. package/templates/standalone/README.ja.md +565 -130
  7. package/templates/standalone/README.md +356 -92
  8. package/templates/standalone/config/application.conf +77 -2
  9. package/templates/standalone/docker-compose.production.yml +7 -0
  10. package/templates/standalone/docker-compose.yml +7 -0
  11. package/templates/standalone/gitignore +3 -2
  12. package/templates/standalone/package.json +3 -2
  13. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +49 -15
  14. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1103 -0
  15. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +241 -0
  16. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1100 -0
  17. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +40 -5
  18. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +445 -0
  19. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +446 -0
  20. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +133 -0
  21. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +85 -0
  22. package/templates/standalone/src/__tests__/google-session.fixture.mts +304 -0
  23. package/templates/standalone/src/__tests__/host-routes.test.mts +141 -0
  24. package/templates/standalone/src/__tests__/listen.test.mts +128 -0
  25. package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
  26. package/templates/standalone/src/__tests__/metrics.test.mts +20 -5
  27. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +187 -0
  28. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +139 -0
  29. package/templates/standalone/src/__tests__/replica-safety.test.mts +13 -3
  30. package/templates/standalone/src/__tests__/router-error-answers.test.mts +149 -0
  31. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +14 -0
  32. package/templates/standalone/src/__tests__/shutdown.test.mts +253 -22
  33. package/templates/standalone/src/__tests__/smoke.test.mts +39 -20
  34. package/templates/standalone/src/__tests__/store-credential.test.mts +276 -0
  35. package/templates/standalone/src/app.mts +30 -51
  36. package/templates/standalone/src/buildModules.mts +94 -12
  37. package/templates/standalone/src/listen.mts +66 -0
  38. package/templates/standalone/src/logger.mts +7 -4
  39. package/templates/standalone/src/modules.mts +118 -12
  40. package/templates/standalone/src/routes.mts +95 -0
  41. package/templates/standalone/src/shutdown.mts +90 -18
  42. package/templates/standalone/vitest.supertest-loopback.mts +22 -3
  43. package/templates/versions.json +15 -14
  44. package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
  45. 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 (`makeIoredisClients`). 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`).
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. 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**.
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. The step that changes the former is a consent screen, tracked separately.
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
- **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.
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
- Since #292 it takes four shapes, all of them Express's own:
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. 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.
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, DPoP proof-replay detection forking. 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; before #455 they booted under `multi`. Since #474 that includes express-session's own store under `SESSION_STORAGE_TYPE=memory` and the login / WebAuthn-options rate limiters when no shared `rateLimiter` is wired; with the mode unset those three warn instead (see the operator runbook). 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.
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,39 +175,101 @@ 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. The older `repositories.code.type = "redis"` (`CLIENT_CODE_TYPE`) switch is still honoured with a deprecation warning at boot. `CLIENT_CODE_ENDPOINT_URI` (`repositories.code.redis.endpointUri`) is still bound by `config/application.conf` but nothing reads it: the Redis code repository has run on the shared connection since the `{ endpointUri }` builder shape was retired, so there is one Redis URL for every adapter and that variable is legacy.
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).
182
+ - Federation grants (#593), when enabled, keep two more stores: the grants themselves (`FEDERATION_GRANT_STORE_ADAPTER`) and acquisition's records (`FEDERATION_GRANT_INTENT_STORE_ADAPTER`). Both ship `redis` on the shared socket; `memory` for either is refused under `DEPLOYMENT_MODE=multi`, and Redis grants beside memory user-session stores are refused on any replica count, because the grants would outlive the boundary that ends them. See [Federation Grants](#federation-grants).
118
183
 
119
184
  ## Usage
120
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
+
121
217
  ```bash
122
- # Install dependencies
123
218
  pnpm install
124
219
 
125
- # Copy environment file and fill in required values
126
- cp .env.example .env
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
127
239
 
128
240
  # Start in development mode (hot reload)
129
241
  pnpm run debug
130
242
 
131
- # Build and start in production mode
243
+ # Build, then run the compiled server (CONFIG_ENV or NODE_ENV selects config/<env>.conf)
132
244
  pnpm run build
133
- pnpm start
245
+ pnpm run start
134
246
  ```
135
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
+
136
257
  ## Configuration
137
258
 
138
259
  Configuration is loaded from `config/application.conf` (HOCON format). Each value can be overridden with the corresponding environment variable.
139
260
 
140
261
  ### Environment-specific config overlay
141
262
 
142
- `src/app.mts` loads configuration in two layers:
263
+ `src/app.mts` loads configuration in three layers, highest precedence first:
143
264
 
144
- 1. **`config/application.conf`** — base defaults for the app.
145
- 2. **`config/{ENV}.conf`** — overlay for the current environment, where
265
+ 1. **`config/{ENV}.conf`** — overlay for the current environment, where
146
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.
147
273
 
148
274
  Values in the overlay take precedence over `application.conf`. The scaffold
149
275
  ships with `development.conf` and `production.conf`. To add another
@@ -181,7 +307,7 @@ fail fast rather than silently falling back to defaults.
181
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. |
182
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. |
183
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. |
184
- | `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) |
185
311
  | `OAUTH_JWT_PRIVATE_KEY` | — | PEM-encoded private key (asymmetric algorithms) |
186
312
  | `OAUTH_JWT_PRIVATE_KEY_PATH` | — | Path to PEM private key file |
187
313
  | `OAUTH_JWT_PUBLIC_KEY` | — | PEM-encoded public key |
@@ -213,7 +339,7 @@ every relying party must be handed the shared secret — which also lets it
213
339
  |---|---|---|
214
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`). |
215
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. |
216
- | `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. |
217
343
  | `OAUTH_REFRESH_TOKEN_EXPIRES_IN` | `86400` | Refresh token lifetime in seconds. Whole positive number, at most one year (`31536000`). |
218
344
 
219
345
  Exporting one of these as an empty string is a boot failure, not a fallback:
@@ -335,6 +461,7 @@ all — no headers, no `Vary`, nothing changed.
335
461
  | `FEDERATIONS_GOOGLE_CLIENT_ID` | — | Google OAuth client ID |
336
462
  | `FEDERATIONS_GOOGLE_CLIENT_SECRET` | — | Google OAuth client secret |
337
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) |
338
465
 
339
466
  ### OIDC Federation (any OpenID Connect provider)
340
467
 
@@ -391,6 +518,8 @@ is documented in [its README](../../packages/federation-oidc/README.md).
391
518
  | `CLIENT_USER_AUTHENTICATE_URL` | — | URL for password-based user authentication. **https required** (see below) |
392
519
  | `CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL` | — | URL for token-based user authentication. **https required** (see below) |
393
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** |
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) |
394
523
  | `CLIENT_USER_TIMEOUT` | `5000` | HTTP request timeout in milliseconds. Positive integer ≤ `2147483647` |
395
524
  | `CLIENT_USER_MAX_RESPONSE_BYTES` | `1048576` | Largest upstream response body accepted, in bytes |
396
525
 
@@ -403,11 +532,42 @@ only for a loopback host — `localhost`, an address in `127.0.0.0/8`, or `[::1]
403
532
  deployment does not control end to end. Boot fails if either URL, the timeout,
404
533
  or the response cap is unusable, rather than at the first login attempt.
405
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
+
406
566
  ### Code Repository
407
567
 
408
568
  | Variable | Default | Description |
409
569
  |---|---|---|
410
- | `CLIENT_CODE_TYPE` | `redis` | Deprecated alias of `OAUTH_CODE_ADAPTER` (`repositories.code.type`); honoured with a boot warning |
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 |
411
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 |
412
572
  | `CLIENT_CODE_PASSWORD` | — | Redis password for code storage |
413
573
  | `CLIENT_CODE_DEFAULT_EXPIRES_IN` | `600` | Default authorization code lifetime in seconds |
@@ -443,6 +603,139 @@ socket configured by `REFRESH_TOKEN_FAMILY_STORE_REDIS_URL`.
443
603
  |---|---|---|
444
604
  | `CONSENT_STORE_ADAPTER` | `none` | Consent store for clients that are not first-party (#527): `none` (such clients are refused), `memory` (single replica) or `redis` (shared, #561) |
445
605
 
606
+ ### Federation Grants
607
+
608
+ Offline delegation of an upstream IdP's tokens (#593): a user consents once
609
+ that a client — a backend, an agent — may obtain access tokens for one
610
+ upstream connection on their behalf, and the client obtains them over HTTP
611
+ later, with the user not present. A grant **survives logout**; a credential
612
+ change ends it through the subject-revocation service. Off by default, and
613
+ off installs nothing. The routes and what each answer means are in the
614
+ [package README](../../packages/federation-grants/README.md); what each IdP
615
+ needs before it issues a refresh token is in
616
+ [`docs/offline-access.md`](../../packages/federation-grants/docs/offline-access.md).
617
+
618
+ | Variable | Default | Description |
619
+ |---|---|---|
620
+ | `FEDERATION_GRANTS_ENABLED` | `false` | Installs the routes, the two stores below and the subject-revocation service |
621
+ | `FEDERATION_GRANTS_CONSENT_URL` | — | The deployment's consent page for grants: a path, or an absolute URL on the provider's origin. No default — enabling grants is a statement that the page exists, and boot refuses without it |
622
+ | `FEDERATION_GRANTS_IDENTITY_LOOKUP` | `required` | Whether the connect callback refuses an upstream account already linked to another local user. `required` needs a user repository that covers every connection's registration (below); `unsupported` records that the check is not made |
623
+ | `FEDERATION_GRANT_STORE_ADAPTER` | `redis` | Where grants live: `memory` (one replica; lost on restart, every user reconnects) or `redis` (the shared socket) |
624
+ | `FEDERATION_GRANT_INTENT_STORE_ADAPTER` | `redis` | Where acquisition's records live — the intent a backend lodged, the consent challenge, the connect transaction: `memory` (one replica; a restart loses flows in progress and nothing else) or `redis` |
625
+ | `FEDERATION_GRANTS_ENCRYPTION_MODE` | `required` | `required` or `allow-plaintext`. Plaintext is refused in production/staging and under `DEPLOYMENT_MODE=multi` unless `FEDERATION_TOKENS_ALLOW_INSECURE=1` |
626
+ | `FEDERATION_GRANTS_ALLOW_KEEP_ON_SUBJECT_REVOCATION` | `false` | Whether a subject-wide revocation may be *asked* to leave established grants standing. An allowance, not an instruction |
627
+ | `REDIS_FEDERATION_GRANT_STORE_KEY_PREFIX` | `fg:` | Key namespace of the Redis grant store |
628
+
629
+ **Two things have no environment form**, because a list is HOCON's: the
630
+ connections, and the encryption key ring. Write them in a deployment-owned
631
+ layer — `config/production.conf`, say:
632
+
633
+ ```hocon
634
+ federationGrants {
635
+ encryptionKeys = [
636
+ # The first key seals; every listed key opens. GRANT_KEY_2026_09 is a name
637
+ # you choose (openssl rand -base64 32), not a template override.
638
+ { id = "2026-09", key = ${GRANT_KEY_2026_09} }
639
+ ]
640
+ connections {
641
+ files {
642
+ federation = "entra-files" # an enabled federations.<name> of type "oidc", with an app registration of its own
643
+ scopes = ["openid", "profile", "offline_access", "Files.Read"]
644
+ boundary = "production"
645
+ maxAccessTokenLifetime = 3600
646
+ # The grant flow's own callback on this provider's origin — not the
647
+ # federation's login callback.
648
+ callbackURL = "https://auth.example/session/federation-grants/callback/files"
649
+ allowScopeSubsets = false
650
+ identityClaims = ["oid", "tid"]
651
+ }
652
+ }
653
+ }
654
+ ```
655
+
656
+ **What boot refuses**, each named, rather than met by a user mid-flow: no
657
+ consent page; a connection without `callbackURL`, on a federation that is not
658
+ enabled, or on one whose adapter lacks the delegated capability — only the
659
+ generic OIDC adapter has it, so a Google connection is a `type = "oidc"`
660
+ federation; `required` with a user repository that does not cover a
661
+ connection's registration; Redis grants beside memory user-session stores
662
+ (`USER_SESSION_STORES_ADAPTER=memory` — the grants would outlive the boundary
663
+ that ends them; both compose files set `redis`); the Redis grant store under
664
+ `encryptionMode = "required"` with no key in the ring (the memory store seals
665
+ nothing and needs none); `memory` for either store under `DEPLOYMENT_MODE=multi`.
666
+
667
+ **The user repository.** Under `required`, boot asks the repository whether it
668
+ covers each connection's registration, and the connect callback asks it who
669
+ holds the upstream account. The template's `http` repository asks your Store
670
+ (#613): set `CLIENT_USER_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL` to the
671
+ endpoint, and declare what that endpoint covers — one entry per registration,
672
+ with the claims the Store's strategy needs — in a HOCON layer:
673
+
674
+ ```hocon
675
+ repositories.user.http.federatedIdentityLookupCoverage = [
676
+ { provider = "entra-files", issuer = "https://login.microsoftonline.com/<tenant>/v2.0",
677
+ clientId = "<the grants app registration>", requiredClaims = ["tid", "oid"] }
678
+ ]
679
+ ```
680
+
681
+ Boot then holds every connection to it: one whose registration is not
682
+ declared, or whose `identityClaims` misses a required claim, is refused by
683
+ name. The wire contract the Store implements — and what "unlinked" obliges it
684
+ to have checked — is in the
685
+ [foundation README](../../packages/foundation/README.md#the-identity-lookup-613);
686
+ the Store is not asked at boot. The bundled in-memory repository covers no
687
+ registration. Without a covering Store, set
688
+ `FEDERATION_GRANTS_IDENTITY_LOOKUP=unsupported` — the recorded decision not to
689
+ refuse an upstream account another local user already holds. With no
690
+ connection configured nothing is required.
691
+
692
+ **Registering a client.** In `config/clients.yaml`, a confidential client that
693
+ may hold grants names the connections it may ask for and where the browser
694
+ may be returned. Neither its `allowedRedirectUris` nor `firstParty` grants
695
+ either:
696
+
697
+ ```yaml
698
+ worker:
699
+ tokenEndpointAuthMethod: "client_secret_basic"
700
+ clientSecret: "$2b$10$…"
701
+ allowedFederationGrantConnections: ["files"]
702
+ federationGrantRedirectUris: ["https://worker.example/connected"]
703
+ ```
704
+
705
+ **The flow, from the client's side.** `POST /oauth/federation-grants`
706
+ (client-authenticated) lodges an intent for a `sub` and answers a `grant_id`
707
+ and a `connect_uri`; the client sends the user's browser there. The provider
708
+ signs the user in if needed, shows your consent page, sends them to the
709
+ upstream, and returns the browser to the client's `redirect_uri` with
710
+ `grant_id` and `state` — never a token. The client then calls
711
+ `/oauth/federation-grants/:grantId/token`, `/status` and `/revoke` with its
712
+ own credentials and the user's `sub`, with no user present.
713
+
714
+ **The consent page** is yours, same-origin with the provider, and its contract
715
+ is `/oauth/consent`'s, so one page can serve both. `GET
716
+ /session/federation-grants/consent?challenge=…` answers JSON: `client_id`,
717
+ `client_name`, `connection`, `scopes`, `resource`, `grant_expires_in` (a
718
+ duration after approval, not a date), `continues_after_logout` (which the page
719
+ must show), and `expires_in` (what is left of the flow). `POST` with
720
+ `challenge` and `decision` (`accept` | `deny`) answers `303` — to the upstream,
721
+ or back to the client. Every acquisition and renewal goes through it.
722
+
723
+ **The login page** is the one `ENDPOINTS_LOGIN_URL` names, reached with
724
+ `redirect_to=<the connect link>`; it signs the user in and navigates back to
725
+ that link unchanged, as it does for `/oauth/authorize`. Do not submit the link
726
+ to `POST /session/login` as its `redirect_to`: that route's exact-match
727
+ allowlist is for landing pages and refuses a per-flow handle.
728
+
729
+ **Operating it.** Rotate the key ring by the procedure in the
730
+ [operator runbook](../../docs/operator-runbook.md) — new key last, then first,
731
+ old key kept 365 days. A shutdown gives cleanup 45 seconds while the feature
732
+ is on — more if you raise `upstreamHardTimeoutMs`, `persistRetryBudgetMs` or
733
+ `lockWaitMs`, since the allowance is their sum plus a margin — and both
734
+ compose files give the process 60; see
735
+ [Shutdown guarantees](#shutdown-guarantees). Disabling the feature is not
736
+ revoking: grants stay in Redis until revoked, so end them first, and keep the
737
+ keys and the revocation boundaries through any temporary shutdown.
738
+
446
739
  ### Redis Namespacing
447
740
 
448
741
  For multi-tenant Redis clusters, set deployment-specific key prefixes so two
@@ -466,37 +759,25 @@ Use values that include the deployment name, for example `tenant-a:ss:`,
466
759
  | `ENDPOINTS_LOGIN_URL` | `/login` | URL of the login page (for redirects) |
467
760
  | `ENDPOINTS_CONSENT_URL` | `/consent` | URL of the consent page a client that is not first-party is routed through, with `?challenge=<id>` (#527) |
468
761
 
469
- `ENDPOINTS_CLIENT_URL` and `ENDPOINTS_AUTH_CALLBACK_URL` were removed (IH-10) —
470
- no production consumer read them, and the config lines leaked values into
471
- `AppConfig` that nothing consumed. Federation callback URLs are configured
472
- 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`).
473
765
 
474
766
  ## Module Composition Order
475
767
 
476
- `src/buildModules.mts` is the single source of truth for the manifest; this is its order. The boot planner resolves `requires` / `provides` topologically, so list position matters only where a module says so — `sessionStoreModule` must stay first, because it wires the `express-session` middleware every session-consuming route needs. Entries marked *adapter switch* are one of a memory/Redis pair, chosen by config; both provide the same slot, so exactly one is wired.
477
-
478
- 1. **`sessionStoreModule`** (`@o3co/auth-provider-session`) — `express-session` middleware and its store (`session.storage.type`)
479
- 2. **`oauthModule`** — `POST /oauth/token`, `POST /oauth/introspect`, `POST /oauth/revoke`, `GET /oauth/authorize`, and OIDC discovery when an issuer is configured
480
- 3. **`oauthSessionModule`** — session grant (exchanges an authenticated session for a token)
481
- 4. **`oauthAuthorizationModule`** — authorization-code and refresh-token grants
482
- 5. **`jwksModule`** (`@o3co/auth-provider-core`) — `GET /.well-known/jwks.json` (or `oauth.jwt.jwksPath`); a signing provider publishes its verification keys whether or not an issuer is set
483
- 6. **`sessionModule`** — `POST /session/login`, `POST /session/logout`, `GET/POST /session/oauth/federation/*`. `/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).
484
- 7. **`googleFederationModule`** + **`googleFederationConfigModule`** — only when `federations.google.enabled = true`
485
- 8. **`keyStoreModule`** — the signing key store from `oauth.jwt.signingKey`
486
- 9. **`repositoriesModule`** — client and user repositories from `repositories.client` / `repositories.user`
487
- 10. **`auditSinkModule`** — always wired; `audit.sink.type` picks `logger` (default) or `console`
488
- 11. **`standaloneRedisClientsModule`** — the one shared ioredis connection; present whenever any switch below is `"redis"`
489
- 12. **`redisFederationTokenStoreModule`** / **`inMemoryFederationTokenStoreModule`** — *adapter switch*: upstream federation tokens per session (`federationTokenStore.type`; see [Multi-replica deployments](#multi-replica-deployments))
490
- 13. **`redisSessionStoresModule`** / **`inMemorySessionStoresModule`** — *adapter switch*: user-session stores (`userSessionStores.adapter`)
491
- 14. **`redisRateLimiterModule`** / **`memoryRateLimiterModule`** — *adapter switch*: the rate limiter behind the OAuth endpoints and `POST /session/login` (`rateLimiter.adapter`)
492
- 15. **`redisCodeRepositoryModule`** / **`inMemoryCodeRepositoryModule`** — *adapter switch*: authorization codes (`oauth.code.adapter`)
493
- 16. **`redisAccessTokenDenylistModule`** / **`memoryAccessTokenDenylistModule`** — *adapter switch*: RFC 7009 access-token denylist (`accessTokenDenylist.adapter`)
494
- 17. **`redisRefreshTokenFamilyStoreModule`** — refresh-token families; Redis unless a test override swaps in the memory store
495
- 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).
496
777
 
497
778
  ## Built-in Routes
498
779
 
499
- `src/app.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. The JWKS route is not one of them: `jwksModule` contributes it (see above).
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).
500
781
 
501
782
  | Method | Path | Description |
502
783
  |---|---|---|
@@ -553,8 +834,8 @@ merely stored badly. The same applies to `.env`; keep both out of git.
553
834
 
554
835
  The production file also pins `SESSION_SECURE=true` and the `__Host-` cookie
555
836
  name in its `environment:` block, which wins over `env_file`. `.env.example`
556
- ships `SESSION_SECURE=false` so a plain-HTTP local run works, and this file
557
- 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
558
839
  cookie is the session handed to anyone who can read one plaintext hop.
559
840
 
560
841
  The image installs with `pnpm install --frozen-lockfile`, so a committed
@@ -564,7 +845,7 @@ the result. Rebuilding the same source then produces the same dependency tree.
564
845
 
565
846
  The production image sets `ENV HTTP_PORT=3000` and uses it for both `EXPOSE`
566
847
  and the Docker-native healthcheck against `/_healthcheck`. The HOCON config
567
- also reads `${?HTTP_PORT}` for `http.port`, so `app.listen()` and the
848
+ also reads `${?HTTP_PORT}` for `http.port`, so the listener and the
568
849
  healthcheck cannot drift apart. To run on a different port:
569
850
 
570
851
  ```bash
@@ -621,7 +902,8 @@ changing the image.
621
902
  ```
622
903
 
623
904
  The failure's *reason* is deliberately absent from the body and goes to the log
624
- instead (`readiness_probe_failed`, with the full per-check detail). A driver
905
+ instead (`readiness_probe_failed`, with each failing check's error as core's
906
+ `loggableError` projection). A driver
625
907
  error reads `connect ECONNREFUSED 10.0.3.14:6379` — an internal host and port —
626
908
  and this endpoint is unauthenticated because an orchestrator has no credentials
627
909
  to present. Set `includeErrorDetail: true` on `createReadinessRouter` only when
@@ -637,9 +919,10 @@ rules.
637
919
  Probes are registered by whichever builder opened the connection, so the list
638
920
  reflects what this deployment actually wired: the shared ioredis client
639
921
  (`redis`) and the connect-redis session client (`session-store`) when the
640
- Redis adapters are selected, and **nothing at all** for a memory-only
641
- deployment — which is therefore always ready, because it has no dependency to
642
- 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.
643
926
 
644
927
  `http.readinessTimeoutMs` (env `HTTP_READINESS_TIMEOUT_MS`, default `1000`)
645
928
  is the per-probe deadline. Keep it below the orchestrator's own probe timeout;
@@ -648,43 +931,22 @@ unready one.
648
931
 
649
932
  ## Adding Custom Modules
650
933
 
651
- To add a custom module, import it in `src/buildModules.mts` and include it in the `modules` array passed to `createApp`:
652
-
653
- ```typescript
654
- // src/buildModules.mts
655
- import { myCustomModule } from "./modules/my-custom-module.mjs";
656
-
657
- export function buildModules(config: AppConfig, overrides: BuildModulesOverrides = {}): Module[] {
658
- const googleEnabled =
659
- (config.federations?.google as { enabled?: boolean } | undefined)?.enabled === true;
660
-
661
- return [
662
- oauthModule({ config }),
663
- oauthSessionModule({ config }),
664
- oauthAuthorizationModule({ config }),
665
- sessionModule,
666
- ...(googleEnabled ? [googleFederationModule, googleFederationConfigModule] : []),
667
- myCustomModule, // add here
668
- overrides.keyStoreModule ?? keyStoreModule,
669
- overrides.repositoriesModule ?? repositoriesModule,
670
- overrides.storesModule ?? storesModule,
671
- ];
672
- }
673
-
674
- // src/app.mts
675
- const handle = await createApp({
676
- modules: buildModules(config),
677
- bootstrapComponents: {
678
- config,
679
- pathResolver: import.meta.resolve,
680
- },
681
- });
682
-
683
- app.use(handle.router);
684
- const server = app.listen(config.http.port);
685
- 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
+ ];
686
946
  ```
687
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
+
688
950
  ### Shutdown guarantees
689
951
 
690
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.
@@ -693,18 +955,20 @@ installGracefulShutdown(server, { logger, cleanup: () => handle.dispose() });
693
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.
694
956
  3. **In-flight requests get `drainTimeoutMs`** (default **10s**) to finish.
695
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.
696
- 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. A dispose that throws still exits; it never wedges the process.
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.
959
+
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.
697
961
 
698
- **Size `drainTimeoutMs` below your orchestrator's kill grace period** — Kubernetes `terminationGracePeriodSeconds` and compose `stop_grace_period` are both 30s by default. The point is to close on your terms before `SIGKILL` arrives on someone else's.
962
+ **Size `drainTimeoutMs` and `cleanupTimeoutMs` together below your orchestrator's kill grace period.** Kubernetes `terminationGracePeriodSeconds` is 30s by default and compose's `stop_grace_period` is 10s; with federation grants on, the worst case is drain (10s) + cleanup (45s) = 55s, so **set the grace to 60s or more** — the shipped compose files do, and a Kubernetes deployment must set `terminationGracePeriodSeconds: 60` itself, or a rolling restart SIGKILLs the process in the middle of the write the cleanup exists to finish. The point is to close on your terms before `SIGKILL` arrives on someone else's.
699
963
 
700
964
  ## npm Scripts
701
965
 
702
966
  | Script | Description |
703
967
  |---|---|
704
968
  | `pnpm run build` | Compile TypeScript to `dist/` |
705
- | `pnpm start` | Start the compiled server |
969
+ | `pnpm run start` | Start the compiled server |
706
970
  | `pnpm run debug` | Start with hot reload via `tsx watch` (development mode) |
707
- | `pnpm test` | Run tests with Vitest |
971
+ | `pnpm run test` | Typecheck (`tsc --noEmit`), then run the tests with Vitest |
708
972
 
709
973
  ## See Also
710
974