@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.
- package/README.ja.md +93 -47
- package/README.md +92 -46
- package/package.json +1 -1
- package/templates/standalone/.env.example +34 -0
- package/templates/standalone/Dockerfile +1 -1
- package/templates/standalone/README.ja.md +565 -130
- package/templates/standalone/README.md +356 -92
- package/templates/standalone/config/application.conf +77 -2
- package/templates/standalone/docker-compose.production.yml +7 -0
- package/templates/standalone/docker-compose.yml +7 -0
- package/templates/standalone/gitignore +3 -2
- package/templates/standalone/package.json +3 -2
- 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 +40 -5
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +445 -0
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +446 -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 +85 -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__/scaffold-assets.test.mts +14 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +253 -22
- 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 +30 -51
- package/templates/standalone/src/buildModules.mts +94 -12
- package/templates/standalone/src/listen.mts +66 -0
- package/templates/standalone/src/logger.mts +7 -4
- package/templates/standalone/src/modules.mts +118 -12
- package/templates/standalone/src/routes.mts +95 -0
- package/templates/standalone/src/shutdown.mts +90 -18
- package/templates/standalone/vitest.supertest-loopback.mts +22 -3
- package/templates/versions.json +15 -14
- 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,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.
|
|
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
|
-
#
|
|
126
|
-
|
|
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
|
|
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
|
|
263
|
+
`src/app.mts` loads configuration in three layers, highest precedence first:
|
|
143
264
|
|
|
144
|
-
1. **`config/
|
|
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`);
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
`
|
|
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
|
|
477
|
-
|
|
478
|
-
1. **`
|
|
479
|
-
2.
|
|
480
|
-
3.
|
|
481
|
-
4.
|
|
482
|
-
5.
|
|
483
|
-
|
|
484
|
-
|
|
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/
|
|
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
|
|
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
|
|
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
|
|
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
|
|
641
|
-
|
|
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
|
|
652
|
-
|
|
653
|
-
```
|
|
654
|
-
// src/buildModules.mts
|
|
655
|
-
import { myCustomModule } from "./
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
|
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
|
|
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` |
|
|
971
|
+
| `pnpm run test` | Typecheck (`tsc --noEmit`), then run the tests with Vitest |
|
|
708
972
|
|
|
709
973
|
## See Also
|
|
710
974
|
|