@o3co/create-auth-provider 0.12.0 → 0.13.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/create-auth-provider",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Scaffold a new o3co/auth.provider project from the standalone template",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -28,8 +28,8 @@
28
28
  "node": ">=22.0.0"
29
29
  },
30
30
  "devDependencies": {
31
- "@types/node": "^26.2.0",
32
- "vitest": "^4.1.11"
31
+ "@types/node": "^26.5.0",
32
+ "vitest": "^5.0.0"
33
33
  },
34
34
  "scripts": {
35
35
  "build": "tsc",
@@ -1,9 +1,10 @@
1
1
  node_modules
2
2
  dist
3
3
  .git
4
- .gitignore
5
4
  .env*
6
5
  !.env.example
7
- *.md
8
- docker-compose*.yml
9
6
  Makefile
7
+ # .gitignore, the READMEs and the compose files stay in the context: the
8
+ # `test` image copies them because the shipped suite reads them (#512). The
9
+ # runtime image never copies them, so they cost the context a few kilobytes
10
+ # and the image nothing.
@@ -1,9 +1,9 @@
1
- # The base image is pinned by digest, not just by tag: `node:24-alpine` is a
1
+ # The base image is pinned by digest, not just by tag: `node:26-alpine` is a
2
2
  # moving pointer, so the same source produced a different image on every
3
3
  # rebuild (#289). The digest is the multi-arch index, so amd64 and arm64 both
4
4
  # resolve from it. Dependabot's `docker` ecosystem keeps the pair in step —
5
5
  # bump the tag and the digest together, never the tag alone.
6
- FROM node:24-alpine@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 AS node-base
6
+ FROM node:26-alpine@sha256:ef24c5053d50fdc3e4e56eb4e7ddb7861874ab0fdc797046ba897581deb8e868 AS node-base
7
7
 
8
8
  ENV HOME=/home/node
9
9
 
@@ -105,6 +105,13 @@ FROM builder AS test
105
105
 
106
106
  COPY --chown=node:node vitest.config.mts ./
107
107
  COPY --chown=node:node tests/ ./tests/
108
+ # #512: the suite reads these from the project root (scaffold-assets,
109
+ # documented-env-overrides, smoke). The builder stage brings only what the
110
+ # build needs, so inside this image they did not exist and `make test` failed
111
+ # on files that were never a test's business. `.dockerignore` lets them into
112
+ # the context for the same reason; nothing copies them into `runtime`.
113
+ COPY --chown=node:node Dockerfile .dockerignore .env.example .gitignore README.md README.ja.md \
114
+ docker-compose.yml docker-compose.production.yml ./
108
115
 
109
116
  CMD ["pnpm", "run", "test", "--", "--passWithNoTests"]
110
117
 
@@ -25,6 +25,12 @@ my-app:
25
25
  allowedRedirectUris: ["https://app.example/callback"]
26
26
  ```
27
27
 
28
+ A machine client can authenticate with a key instead of a shared secret (#484):
29
+ `tokenEndpointAuthMethod: "private_key_jwt"` plus `jwks` (inline public keys) or
30
+ `jwksUri`, and it presents a signed assertion at `/oauth/token` — see the
31
+ [oauth package README](../../packages/oauth/README.md#client-authentication-private_key_jwt-rfc-7523-22).
32
+ Nothing secret lives in `clients.yaml` for such a client.
33
+
28
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.
29
35
 
30
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.
@@ -106,6 +112,7 @@ Other multi-replica considerations covered by the default modules:
106
112
  - 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.
107
113
  - 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.
108
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.
115
+ - 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.
109
116
  - 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).
110
117
 
111
118
  ## Usage
@@ -161,6 +168,18 @@ fail fast rather than silently falling back to defaults.
161
168
  | `OAUTH_JWT_SECRET` | — | Signing secret, **HMAC (`HS256`) only**. At least 32 bytes (256 bits) of random material — `openssl rand -hex 32`. Hex/base64 values are measured **decoded**, so a 32-character hex string counts as 16 bytes and is refused. |
162
169
  | `OAUTH_JWT_ISSUER` | **(required)** | Canonical issuer URL stamped as `iss` on every token. Must be absolute `https` (`http` only for a loopback host), with no query or fragment. Boot fails when unset — it is never derived from the `Host` header. |
163
170
  | `OAUTH_REQUIRE_EMAIL_VERIFIED` | `false` | Refuse to issue tokens for a user until the Store publishes `emailVerified: true`. Enforced at `/authorize` and on the `session` grant. Verification itself is the Store's job — this only reads the result. |
171
+ | `OAUTH_CIMD_ENABLED` | `false` | Accept Client ID Metadata Documents (#529): a client whose `client_id` is the `https` URL of its own registration, the model MCP hosts use. Such a client is public, never first-party — it goes through the consent step, so wire `CONSENT_STORE_ADAPTER`. |
172
+ | `OAUTH_CIMD_ALLOWED_SCOPES` | — | Comma-separated: what any such client may obtain (its document's `scope` is intersected with this). Empty admits none. |
173
+ | `OAUTH_CIMD_ALLOWED_AUDIENCES` | — | Comma-separated: the resource servers it may mint for (RFC 8707 `resource`). Empty admits only its own `client_id`. |
174
+ | `OAUTH_CIMD_ALLOWED_HOSTS` | — | Comma-separated hosts a document may live on: exact, or `.suffix` for a domain and its subdomains. Empty admits any public host. |
175
+ | `OAUTH_CIMD_DENIED_HOSTS` | — | Comma-separated hosts refused even when allowed above. |
176
+ | `OAUTH_CIMD_MAX_BYTES` | `5120` | Byte cap on a document (the draft recommends 5 KB). |
177
+ | `OAUTH_CIMD_TIMEOUT_MS` | `5000` | Fetch timeout. |
178
+ | `OAUTH_CIMD_CACHE_MAX_AGE_MS` | `600000` | Upper bound on how long a valid document is served from cache; `Cache-Control: max-age` may shorten it. |
179
+ | `OAUTH_CIMD_MAX_CACHE_ENTRIES` | `256` | How many documents are remembered at once. The keys are chosen by an unauthenticated caller, so the map is bounded. |
180
+ | `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. |
181
+ | `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. |
182
+ | `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. |
164
183
  | `OAUTH_JWT_KID` | `v0` | Key ID included in the JWT header |
165
184
  | `OAUTH_JWT_PRIVATE_KEY` | — | PEM-encoded private key (asymmetric algorithms) |
166
185
  | `OAUTH_JWT_PRIVATE_KEY_PATH` | — | Path to PEM private key file |
@@ -311,6 +330,46 @@ all — no headers, no `Vary`, nothing changed.
311
330
  | `FEDERATIONS_GOOGLE_CLIENT_SECRET` | — | Google OAuth client secret |
312
331
  | `FEDERATIONS_GOOGLE_CALLBACK_URL` | `http://localhost:3000/session/oauth/federation/google/callback` | Google OAuth callback URL |
313
332
 
333
+ ### OIDC Federation (any OpenID Connect provider)
334
+
335
+ One instance ships in `config/application.conf` under `federations.oidc`
336
+ (`@o3co/auth-provider-federation-oidc`, #524): Okta, Entra ID, Auth0, Keycloak
337
+ or your own tenant, selected by issuer. Discovery runs at boot and a failure
338
+ refuses boot. The identity handed to the Store is `oidc:<sub>`; an identity
339
+ the Store does not know is refused with 401 — nothing is provisioned.
340
+
341
+ | Variable | Default | Description |
342
+ |---|---|---|
343
+ | `FEDERATIONS_OIDC_ENABLED` | `false` | Enable the OIDC federation |
344
+ | `FEDERATIONS_OIDC_ISSUER` | — | Issuer identifier (https), exactly as the IdP writes `iss` |
345
+ | `FEDERATIONS_OIDC_CLIENT_ID` | — | Client ID registered at the IdP |
346
+ | `FEDERATIONS_OIDC_CLIENT_SECRET` | — | Client secret (`client_secret_basic`); set `privateKey` in the config file for `private_key_jwt` instead |
347
+ | `FEDERATIONS_OIDC_CALLBACK_URL` | `http://localhost:3000/session/oauth/federation/oidc/callback` | Where the IdP sends the browser back |
348
+
349
+ A second IdP is another section with `type = "oidc"` and its own callback:
350
+
351
+ ```hocon
352
+ federations {
353
+ okta {
354
+ enabled = true
355
+ type = "oidc"
356
+ issuer = "https://dev-123.okta.com"
357
+ clientId = ${OKTA_CLIENT_ID}
358
+ clientSecret = ${OKTA_CLIENT_SECRET}
359
+ callbackURL = "https://auth.example.com/session/oauth/federation/okta/callback"
360
+ redirectAllowlist = ["https://app.example.com/welcome"]
361
+ }
362
+ }
363
+ ```
364
+
365
+ The section's `type` names the implementation. `federations.google` without a
366
+ `type` is the built-in Google federation; with `type = "oidc"` it is a generic
367
+ OIDC instance named `google`, and the built-in module is not composed.
368
+
369
+ Every field the package accepts — `scopes`, `discovery` / `endpoints`,
370
+ `privateKey`, `userInfo`, `idTokenSignedResponseAlg`, `clockToleranceSeconds` —
371
+ is documented in [its README](../../packages/federation-oidc/README.md).
372
+
314
373
  ### Client Repository
315
374
 
316
375
  | Variable | Default | Description |
@@ -325,6 +384,7 @@ all — no headers, no `Vary`, nothing changed.
325
384
  | `CLIENT_USER_TYPE` | `http` | User repository backend: `http` |
326
385
  | `CLIENT_USER_AUTHENTICATE_URL` | — | URL for password-based user authentication. **https required** (see below) |
327
386
  | `CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL` | — | URL for token-based user authentication. **https required** (see below) |
387
+ | `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** |
328
388
  | `CLIENT_USER_TIMEOUT` | `5000` | HTTP request timeout in milliseconds. Positive integer ≤ `2147483647` |
329
389
  | `CLIENT_USER_MAX_RESPONSE_BYTES` | `1048576` | Largest upstream response body accepted, in bytes |
330
390
 
@@ -357,6 +417,7 @@ needs a key.
357
417
  | Variable | Default | Description |
358
418
  |---|---|---|
359
419
  | `FEDERATION_TOKEN_STORE_TYPE` | `memory` | Federation token store backend: `memory` or `redis` |
420
+ | `CONSENT_STORE_ADAPTER` | `none` | Consent store for clients that are not first-party (#527): `none` (such clients are refused) or `memory` (single replica) |
360
421
  | `REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY` | — | AES-256-GCM key for records at rest: 32 bytes, base64-encoded (`openssl rand -base64 32`). **Required** with `redis` unless the mode below is `allow-plaintext` |
361
422
  | `REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_MODE` | `required` | `required` or `allow-plaintext`. Plaintext is refused when the config was selected by a production/staging environment (`CONFIG_ENV` or `NODE_ENV`) and under `DEPLOYMENT_MODE=multi` in any environment, unless `FEDERATION_TOKENS_ALLOW_INSECURE=1` is also set — development only |
362
423
 
@@ -384,6 +445,7 @@ Use values that include the deployment name, for example `tenant-a:ss:`,
384
445
  | Variable | Default | Description |
385
446
  |---|---|---|
386
447
  | `ENDPOINTS_LOGIN_URL` | `/login` | URL of the login page (for redirects) |
448
+ | `ENDPOINTS_CONSENT_URL` | `/consent` | URL of the consent page a client that is not first-party is routed through, with `?challenge=<id>` (#527) |
387
449
 
388
450
  `ENDPOINTS_CLIENT_URL` and `ENDPOINTS_AUTH_CALLBACK_URL` were removed (IH-10) —
389
451
  no production consumer read them, and the config lines leaked values into
@@ -94,6 +94,18 @@ oauth {
94
94
  # restart and across replicas — multi-replica production MUST use "redis".
95
95
  # Supersedes the legacy `repositories.code.type` switch (kept below as
96
96
  # deprecated for backward compat — see CHANGELOG for the removal version).
97
+ # #481: the Authentication Context Class References this deployment can
98
+ # vouch for, each mapped to the RFC 8176 `amr` values a session must carry
99
+ # (`pwd` for a password login, `hwk` for a passkey, `fed` for a federated
100
+ # login, `mfa` when a resumed MFA login records it). `/authorize` answers
101
+ # `acr_values` from this table alone — anything else is
102
+ # `unmet_authentication_requirements` — and discovery advertises the keys.
103
+ #
104
+ # authorize.acrValues {
105
+ # "urn:example:pwd" = ["pwd"]
106
+ # "urn:example:mfa" = ["pwd", "mfa"]
107
+ # }
108
+ #
97
109
  # #267: /authorize refuses any client not marked `firstParty: true` in
98
110
  # clients.yaml — the template's example clients are marked. There is no
99
111
  # opt-out: the one-time `allowUnmarkedClients` migration flag was removed
@@ -134,6 +146,28 @@ accessTokenDenylist {
134
146
  adapter = ${?ACCESS_TOKEN_DENYLIST_ADAPTER}
135
147
  }
136
148
 
149
+ # #484: backend for the replay seen-set — the `jti` single-use record behind
150
+ # `private_key_jwt` client authentication (a client registered with `jwks` or
151
+ # `jwksUri` in config/clients.yaml). Same reasoning as the denylist above:
152
+ # "memory" forks per replica, so a captured client assertion would replay
153
+ # once per replica, and `deployment.mode = "multi"` refuses it by name. It
154
+ # shares the ioredis socket, so it costs no extra connection.
155
+ #
156
+ # Single-instance local development can set REPLAY_SEEN_SET_ADAPTER=memory.
157
+ replaySeenSet {
158
+ adapter = "redis"
159
+ adapter = ${?REPLAY_SEEN_SET_ADAPTER}
160
+ }
161
+
162
+ # #527: where consent to a client that is not first-party is recorded.
163
+ # "none" (the default) wires no store and such clients are refused at
164
+ # /authorize; "memory" is single-replica only (refused under
165
+ # DEPLOYMENT_MODE=multi by name).
166
+ consentStore {
167
+ adapter = "none"
168
+ adapter = ${?CONSENT_STORE_ADAPTER}
169
+ }
170
+
137
171
  session {
138
172
  secret = ${?SESSION_SECRET}
139
173
  # MIN-2: __Host- prefix requires Secure, Path=/, and no Domain attribute.
@@ -233,6 +267,42 @@ federations {
233
267
  # Where the browser goes after a callback carrying no `redirect_to`.
234
268
  # clientUrl = ${?FEDERATIONS_GOOGLE_CLIENT_URL}
235
269
  }
270
+
271
+ # Any OpenID Connect provider (Okta, Entra ID, Auth0, Keycloak, your own
272
+ # tenant) — `@o3co/auth-provider-federation-oidc` (#524). Disabled by
273
+ # default; the env vars below switch this one instance on. The section
274
+ # name is the federation name: the browser starts at
275
+ # /session/oauth/federation/oidc and the identity handed to the Store is
276
+ # "oidc:<sub>". An identity the Store does not know is refused (401);
277
+ # nothing is provisioned. To broker to a second IdP, copy this block under
278
+ # another name (`okta { type = "oidc" ... }`) with its own callbackURL.
279
+ oidc {
280
+ enabled = false
281
+ enabled = ${?FEDERATIONS_OIDC_ENABLED}
282
+ type = "oidc"
283
+
284
+ # Issuer identifier, exactly as the IdP writes it into `iss`. Discovery
285
+ # runs at boot from <issuer>/.well-known/openid-configuration and a
286
+ # failure refuses boot. https only (plain http on localhost for dev).
287
+ issuer = ${?FEDERATIONS_OIDC_ISSUER}
288
+ clientId = ${?FEDERATIONS_OIDC_CLIENT_ID}
289
+ # client_secret_basic. For private_key_jwt drop clientSecret and set
290
+ # privateKey (a PEM PKCS#8 key, or { pem, kid, alg }) instead.
291
+ clientSecret = ${?FEDERATIONS_OIDC_CLIENT_SECRET}
292
+ callbackURL = "http://localhost:3000/session/oauth/federation/oidc/callback"
293
+ callbackURL = ${?FEDERATIONS_OIDC_CALLBACK_URL}
294
+ scopes = ["openid", "profile", "email"]
295
+
296
+ # Same rules as the Google block above: exact URLs, https except on a
297
+ # loopback host, and an empty list refuses every `redirect_to`.
298
+ redirectAllowlist = []
299
+ # sessionDomain, authCallbackUrl and clientUrl work as for Google.
300
+ #
301
+ # Hand-typed endpoints instead of discovery (the IdP's document is wrong
302
+ # or unreachable from this network):
303
+ # discovery = false
304
+ # endpoints { authorizationEndpoint = "...", tokenEndpoint = "...", jwksUri = "..." }
305
+ }
236
306
  }
237
307
 
238
308
  repositories {
@@ -257,6 +327,14 @@ repositories {
257
327
  # service name still needs https. Boot fails if either is not.
258
328
  authenticateUrl = ${?CLIENT_USER_AUTHENTICATE_URL}
259
329
  authenticateByTokenUrl = ${?CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL}
330
+ # #482: optional. When set, a signed-in user can link a second federated
331
+ # identity (GET /session/oauth/federation/<name>?link=1): the Store
332
+ # receives { userId, provider, sub, token, claims } and decides — never on
333
+ # an unverified or relay e-mail. Same https rule as the two URLs above.
334
+ # Also declared in core's reference.conf, so a composition that does not
335
+ # layer this file gets the same override.
336
+ # Without it, link=1 is refused with link_unsupported.
337
+ linkFederatedIdentityUrl = ${?CLIENT_USER_LINK_FEDERATED_IDENTITY_URL}
260
338
  # Positive integer, milliseconds, <= 2147483647. Boot fails otherwise —
261
339
  # including on a blank env override, which HOCON substitutes as "".
262
340
  timeout = 5000
@@ -287,6 +365,13 @@ endpoints {
287
365
  url = "/login"
288
366
  url = ${?ENDPOINTS_LOGIN_URL}
289
367
  }
368
+ # #527: the consent page a client that is not first-party is routed
369
+ # through (`?challenge=<id>`). Deployment-owned, like the login page: it
370
+ # reads GET /oauth/consent and posts to POST /oauth/consent.
371
+ consent {
372
+ url = "/consent"
373
+ url = ${?ENDPOINTS_CONSENT_URL}
374
+ }
290
375
  # IH-10: `client { url }` and `authCallback { url }` removed — no
291
376
  # production consumer reads them.
292
377
  }
@@ -4,6 +4,9 @@
4
4
  # `tokenEndpointAuthMethod` is REQUIRED (RFC 6749 §2.3 / RFC 7591 §2):
5
5
  # - "client_secret_basic": HTTP Basic Authorization header — recommended for confidential clients
6
6
  # - "client_secret_post": form-encoded body parameters
7
+ # - "private_key_jwt": the client signs a JWT with its own private key
8
+ # (RFC 7523 §2.2, #484) — register its public keys as
9
+ # `jwks` (inline) or `jwksUri`, exactly one; no clientSecret
7
10
  # - "none": public client — no clientSecret; PKCE/S256 mandatory at /authorize
8
11
  #
9
12
  # Secret format (only for confidential clients — basic / post):
@@ -44,6 +47,25 @@
44
47
  # can read the authorization request. Set it only for a legacy client that
45
48
  # genuinely cannot compute SHA-256, and treat it as a migration deadline.
46
49
 
50
+ # A machine client that authenticates with a key pair (#484). Nothing here is
51
+ # secret: the private half stays with the client, which signs an assertion
52
+ # (iss = sub = client id, aud = the issuer or the token endpoint, exp within
53
+ # an hour, a fresh jti) per token request. Rotating the key is a JWKS
54
+ # publish when `jwksUri` is used. Needs the replay seen-set wired
55
+ # (REPLAY_SEEN_SET_ADAPTER, on by default).
56
+ #
57
+ # orders-service:
58
+ # tokenEndpointAuthMethod: "private_key_jwt"
59
+ # jwksUri: "https://orders.example.com/.well-known/jwks.json"
60
+ # # or inline:
61
+ # # jwks:
62
+ # # keys:
63
+ # # - { kty: "EC", crv: "P-256", kid: "2026-09", x: "...", y: "..." }
64
+ # allowedGrantTypes: ["client_credentials"]
65
+ # allowedScopes: ["orders:read"]
66
+ # defaultScopes: ["orders:read"]
67
+ # allowedAudiences: ["https://api.example.com/orders"]
68
+
47
69
  my-app:
48
70
  tokenEndpointAuthMethod: "client_secret_basic"
49
71
  firstParty: true
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/auth-provider-standalone",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -23,6 +23,7 @@
23
23
  "dependencies": {
24
24
  "@o3co/auth-provider-core": "workspace:*",
25
25
  "@o3co/auth-provider-federation-google": "workspace:*",
26
+ "@o3co/auth-provider-federation-oidc": "workspace:*",
26
27
  "@o3co/auth-provider-foundation": "workspace:*",
27
28
  "@o3co/auth-provider-oauth": "workspace:*",
28
29
  "@o3co/auth-provider-redis": "workspace:*",
@@ -40,11 +41,11 @@
40
41
  "devDependencies": {
41
42
  "@types/express": "^5.0.6",
42
43
  "@types/express-session": "^1",
43
- "@types/node": "^26.2.0",
44
+ "@types/node": "^26.5.0",
44
45
  "@types/supertest": "^7.2.1",
45
46
  "supertest": "^7.1.0",
46
- "tsx": "^4.23.10",
47
+ "tsx": "^4.23.13",
47
48
  "typescript": "^6.0.3",
48
- "vitest": "^4.1.11"
49
+ "vitest": "^5.0.0"
49
50
  }
50
51
  }
@@ -14,7 +14,8 @@
14
14
  * limitations under the License.
15
15
  */
16
16
 
17
- import { readFileSync } from "node:fs";
17
+ import { existsSync, readFileSync } from "node:fs";
18
+ import { join } from "node:path";
18
19
  import { fileURLToPath } from "node:url";
19
20
  import { type AppConfig, AppConfigSchema } from "@o3co/auth-provider-core";
20
21
  import { parseFile } from "@o3co/ts.hocon";
@@ -95,6 +96,18 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
95
96
  OAUTH_NONCE_MAX_LENGTH: "256",
96
97
  OAUTH_TOKEN_EXCHANGE_MAX_ACTOR_CHAIN_DEPTH: "3",
97
98
  OAUTH_RESOURCE_INDICATOR_ENABLED: "true",
99
+ OAUTH_CIMD_ENABLED: "true",
100
+ OAUTH_CIMD_ALLOWED_SCOPES: "read, write",
101
+ OAUTH_CIMD_ALLOWED_AUDIENCES: "https://mcp.example",
102
+ OAUTH_CIMD_ALLOWED_HOSTS: "client.example, .trusted.example",
103
+ OAUTH_CIMD_DENIED_HOSTS: "evil.example",
104
+ OAUTH_CIMD_MAX_BYTES: "8192",
105
+ OAUTH_CIMD_TIMEOUT_MS: "3000",
106
+ OAUTH_CIMD_CACHE_MAX_AGE_MS: "60000",
107
+ OAUTH_CIMD_MAX_CACHE_ENTRIES: "128",
108
+ OAUTH_CIMD_STALE_IF_ERROR_MS: "120000",
109
+ OAUTH_CIMD_NEGATIVE_CACHE_MS: "30000",
110
+ OAUTH_CIMD_MAX_CONCURRENT_FETCHES: "4",
98
111
  OAUTH_TOKEN_BINDING_DISPATCH_POLICY: "intent-explicit",
99
112
  OAUTH_TOKEN_BINDING_BIND_CONFIDENTIAL_CLIENT_REFRESH_TOKENS: "true",
100
113
 
@@ -136,6 +149,9 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
136
149
  DEPLOYMENT_MODE: "multi",
137
150
  USER_SESSION_STORES_ADAPTER: "redis",
138
151
  ACCESS_TOKEN_DENYLIST_ADAPTER: "redis",
152
+ // #484: the replay seen-set behind private_key_jwt client authentication.
153
+ REPLAY_SEEN_SET_ADAPTER: "redis",
154
+ CONSENT_STORE_ADAPTER: "none",
139
155
  REDIS_ACCESS_TOKEN_DENYLIST_KEY_PREFIX: "atdeny:",
140
156
  REDIS_SESSION_STORES_KEY_PREFIX: "ss:",
141
157
  REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX: "rtfam:",
@@ -154,6 +170,12 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
154
170
  FEDERATIONS_GOOGLE_CLIENT_ID: "google-client-id",
155
171
  FEDERATIONS_GOOGLE_CLIENT_SECRET: "google-client-secret",
156
172
  FEDERATIONS_GOOGLE_CALLBACK_URL: "https://auth.test/session/oauth/federation/google/callback",
173
+ // #524: the generic OIDC federation the template ships disabled.
174
+ FEDERATIONS_OIDC_ENABLED: "true",
175
+ FEDERATIONS_OIDC_ISSUER: "https://idp.test",
176
+ FEDERATIONS_OIDC_CLIENT_ID: "oidc-client-id",
177
+ FEDERATIONS_OIDC_CLIENT_SECRET: "oidc-client-secret",
178
+ FEDERATIONS_OIDC_CALLBACK_URL: "https://auth.test/session/oauth/federation/oidc/callback",
157
179
 
158
180
  // --- repositories -------------------------------------------------
159
181
  CLIENT_TYPE: "yaml",
@@ -162,6 +184,7 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
162
184
  CLIENT_USER_PATH: "./config/users.yaml",
163
185
  CLIENT_USER_AUTHENTICATE_URL: "https://users.example.com/authenticate",
164
186
  CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL: "https://users.example.com/authenticate-by-token",
187
+ CLIENT_USER_LINK_FEDERATED_IDENTITY_URL: "https://users.example.com/link-federated-identity",
165
188
  CLIENT_USER_TIMEOUT: "5000",
166
189
  CLIENT_USER_MAX_RESPONSE_BYTES: "1048576",
167
190
  CLIENT_CODE_TYPE: "redis",
@@ -172,6 +195,7 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
172
195
 
173
196
  // --- endpoints ----------------------------------------------------
174
197
  ENDPOINTS_LOGIN_URL: "/login",
198
+ ENDPOINTS_CONSENT_URL: "/consent",
175
199
 
176
200
  // --- cors ---------------------------------------------------------
177
201
  // #500: a list, in the only shape an environment variable can carry one.
@@ -288,6 +312,7 @@ describe("#288: the shipped config boots with every documented override supplied
288
312
  expect(config.oauth.requireEmailVerified).toBe(true);
289
313
  expect(config.oauth.resourceIndicator?.enabled).toBe(true);
290
314
  expect(config.federations.google?.enabled).toBe(true);
315
+ expect(config.federations.oidc?.enabled).toBe(true);
291
316
  });
292
317
 
293
318
  it("turns every non-boolean override into its declared type", () => {
@@ -300,6 +325,21 @@ describe("#288: the shipped config boots with every documented override supplied
300
325
  expect(config.session.maxAge).toBe(3600000);
301
326
  expect(config.session.csrf?.ttlSeconds).toBe(7200);
302
327
  expect(config.oauth.nonce?.maxLength).toBe(256);
328
+ // #529: the comma-separated lists become lists, trimmed; the numbers, numbers.
329
+ expect(config.oauth.clientIdMetadataDocuments).toEqual({
330
+ enabled: true,
331
+ allowedScopes: ["read", "write"],
332
+ allowedAudiences: ["https://mcp.example"],
333
+ allowedHosts: ["client.example", ".trusted.example"],
334
+ deniedHosts: ["evil.example"],
335
+ maxBytes: 8192,
336
+ timeoutMs: 3000,
337
+ maxCacheEntries: 128,
338
+ staleIfErrorMs: 120000,
339
+ negativeCacheMs: 30000,
340
+ maxConcurrentFetches: 4,
341
+ cacheMaxAgeMs: 60000,
342
+ });
303
343
  // #500: a comma-separated string becomes a list of origins, trimmed.
304
344
  expect(config.cors?.allowedOrigins).toEqual([
305
345
  "https://app.example.com",
@@ -477,10 +517,15 @@ describe("#288: the shipped config boots with every documented override supplied
477
517
  });
478
518
  });
479
519
 
480
- it("resolves against the shipped standalone template, not a fixture", () => {
520
+ it("resolves against the shipped standalone artifact, not a fixture", () => {
481
521
  // Guards the guard: if the paths above ever stop pointing at the real
482
- // artifact these tests would pass while testing nothing.
483
- expect(standaloneDir).toMatch(/templates[/\\]standalone[/\\]?$/);
522
+ // artifact these tests would pass while testing nothing. Checked by
523
+ // what the directory holds, not by what it is called: this file ships
524
+ // in every scaffolded project, and none of those lives at
525
+ // `templates/standalone` (#512).
526
+ for (const file of ["config/application.conf", "config/production.conf", "src/app.mts"]) {
527
+ expect(existsSync(join(standaloneDir, file)), file).toBe(true);
528
+ }
484
529
  expect(liveSubstitutions().size).toBeGreaterThan(40);
485
530
  });
486
531
  });
@@ -0,0 +1,138 @@
1
+ /*
2
+ * Copyright 2026 1o1 Co. Ltd.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import type { AppConfig } from "@o3co/auth-provider-core";
18
+ import { makeValidAppConfig } from "@o3co/auth-provider-core/testing";
19
+ import { describe, expect, it } from "vitest";
20
+ import { buildModules } from "../buildModules.mjs";
21
+ import { oidcFederationConfigModule } from "../modules.mjs";
22
+
23
+ /**
24
+ * #524: the scaffold turns every enabled `federations.<name>` of type
25
+ * `oidc` into one instance of the generic OIDC federation module, fed by a
26
+ * single config bridge. A deployment adds an IdP with configuration only.
27
+ */
28
+ type Factory = (deps: { config: unknown }) => Record<string, unknown>;
29
+
30
+ const bridge = (
31
+ oidcFederationConfigModule as unknown as {
32
+ provides: { oidcFederationConfigs: Factory };
33
+ }
34
+ ).provides.oidcFederationConfigs;
35
+
36
+ const okta = {
37
+ issuer: "https://dev-1.okta.test",
38
+ clientId: "okta-client",
39
+ clientSecret: "okta-secret",
40
+ callbackURL: "https://auth.test/session/oauth/federation/okta/callback",
41
+ };
42
+
43
+ /** Core's valid fixture, with the federations under test and every adapter on memory. */
44
+ const configWith = (federations: Record<string, unknown>): AppConfig => {
45
+ const base = makeValidAppConfig();
46
+ return {
47
+ ...base,
48
+ federations,
49
+ oauth: { ...base.oauth, code: { adapter: "memory" } },
50
+ } as unknown as AppConfig;
51
+ };
52
+
53
+ describe("oidcFederationConfigModule (#524)", () => {
54
+ it("has the scaffold's module name and reads config", () => {
55
+ expect(oidcFederationConfigModule.name).toBe("standalone:oidc-federation-config");
56
+ expect(oidcFederationConfigModule.requires).toEqual(["config"]);
57
+ });
58
+
59
+ it("reads every enabled oidc section into the slot, keyed by federation name", () => {
60
+ const out = bridge({
61
+ config: {
62
+ federations: {
63
+ okta: { enabled: true, type: "oidc", ...okta },
64
+ keycloak: {
65
+ enabled: true,
66
+ type: "oidc",
67
+ clientUrl: "https://app.test/",
68
+ oidc: {
69
+ issuer: "https://kc.test/realms/dev",
70
+ clientId: "kc-client",
71
+ clientSecret: "kc-secret",
72
+ callbackURL: "https://auth.test/session/oauth/federation/keycloak/callback",
73
+ },
74
+ },
75
+ google: {
76
+ enabled: true,
77
+ clientId: "g",
78
+ clientSecret: "s",
79
+ callbackURL: "https://auth.test/g",
80
+ },
81
+ paused: { enabled: false, type: "oidc", ...okta },
82
+ },
83
+ },
84
+ });
85
+ expect(Object.keys(out).sort()).toEqual(["keycloak", "okta"]);
86
+ expect(out.okta).toEqual(okta);
87
+ expect(out.keycloak).toMatchObject({
88
+ issuer: "https://kc.test/realms/dev",
89
+ clientUrl: "https://app.test/",
90
+ });
91
+ });
92
+
93
+ it("refuses a malformed section at boot, naming the field", () => {
94
+ const { issuer: _dropped, ...noIssuer } = okta;
95
+ expect(() =>
96
+ bridge({ config: { federations: { okta: { enabled: true, type: "oidc", ...noIssuer } } } }),
97
+ ).toThrow(/federations\.okta\.issuer/);
98
+ });
99
+
100
+ it("is an empty slot when no section is of type oidc", () => {
101
+ expect(bridge({ config: { federations: { google: { enabled: false } } } })).toEqual({});
102
+ });
103
+ });
104
+
105
+ describe("buildModules gating for OIDC federations (#524)", () => {
106
+ it("lists one federation:oidc:<name> per enabled section, plus the bridge once", () => {
107
+ const names = buildModules(
108
+ configWith({
109
+ okta: { enabled: true, type: "oidc", ...okta },
110
+ keycloak: { enabled: true, type: "oidc", ...okta },
111
+ google: { enabled: false },
112
+ }),
113
+ ).map((m) => m.name);
114
+ expect(names).toContain("federation:oidc:okta");
115
+ expect(names).toContain("federation:oidc:keycloak");
116
+ expect(names.filter((n) => n === "standalone:oidc-federation-config")).toHaveLength(1);
117
+ expect(names).not.toContain("federation:google");
118
+ });
119
+
120
+ it("a google section of type oidc is the generic provider, not the built-in Google pair", () => {
121
+ // The section's `type` names the implementation. Composing both would
122
+ // contribute the same federation and redirect-policy keys twice.
123
+ const names = buildModules(
124
+ configWith({ google: { enabled: true, type: "oidc", ...okta } }),
125
+ ).map((m) => m.name);
126
+ expect(names).toContain("federation:oidc:google");
127
+ expect(names).not.toContain("federation:google");
128
+ expect(names).not.toContain("standalone:google-federation-config");
129
+ });
130
+
131
+ it("lists nothing OIDC when no section is enabled (the shipped default)", () => {
132
+ const names = buildModules(
133
+ configWith({ google: { enabled: false }, oidc: { enabled: false, type: "oidc" } }),
134
+ ).map((m) => m.name);
135
+ expect(names.some((n) => n.startsWith("federation:oidc:"))).toBe(false);
136
+ expect(names).not.toContain("standalone:oidc-federation-config");
137
+ });
138
+ });
@@ -159,6 +159,7 @@ const ALL_REDIS_ENV: Readonly<Record<string, string>> = {
159
159
  RATE_LIMITER_ADAPTER: "redis",
160
160
  OAUTH_CODE_ADAPTER: "redis",
161
161
  ACCESS_TOKEN_DENYLIST_ADAPTER: "redis",
162
+ REPLAY_SEEN_SET_ADAPTER: "redis",
162
163
  FEDERATION_TOKEN_STORE_TYPE: "redis",
163
164
  REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: ENCRYPTION_KEY,
164
165
  };
@@ -240,10 +241,14 @@ describe('#455: the standalone\'s memory modules are refused under deployment.mo
240
241
  // #474: express-session's own store. Not a module of this template but
241
242
  // built here from its config, which is what lets the manifest declare.
242
243
  ["SESSION_STORAGE_TYPE", "sessionStoreModule"],
244
+ // #527: the consent store, wired only when the switch says so.
245
+ ["CONSENT_STORE_ADAPTER", "core-consent-store-memory"],
243
246
  // Core's, selected by the same kind of switch. The guard knew these by
244
247
  // name; pinned so the name table's departure (#455) did not lose them.
245
248
  ["RATE_LIMITER_ADAPTER", "core-rate-limiter-memory"],
246
249
  ["ACCESS_TOKEN_DENYLIST_ADAPTER", "core-access-token-denylist-memory"],
250
+ // #484: the jti single-use record behind private_key_jwt client auth.
251
+ ["REPLAY_SEEN_SET_ADAPTER", "core-replay-seen-set-memory"],
247
252
  ];
248
253
 
249
254
  for (const [variable, module] of cases) {
@@ -266,6 +271,8 @@ describe('#455: the standalone\'s memory modules are refused under deployment.mo
266
271
  SESSION_STORAGE_TYPE: "memory",
267
272
  RATE_LIMITER_ADAPTER: "memory",
268
273
  ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
274
+ REPLAY_SEEN_SET_ADAPTER: "memory",
275
+ CONSENT_STORE_ADAPTER: "memory",
269
276
  });
270
277
  await expect(boot(config)).rejects.toMatchObject({
271
278
  reason: "replica-unsafe-adapter",
@@ -244,3 +244,148 @@ describe("#407 — the two READMEs agree on security advice", () => {
244
244
  expect(read("/README.ja.md")).not.toMatch(/HTTP_TRUST_PROXY=true/);
245
245
  });
246
246
  });
247
+
248
+ /**
249
+ * Issue #512 — the suite that ships with a scaffolded project has to be green
250
+ * in that project, not only in this workspace.
251
+ *
252
+ * A scaffold installs `@o3co/auth-provider-*` from npm, where the packages sit
253
+ * under `node_modules` and vitest externalizes them: Node loads them natively
254
+ * and their own `import "ioredis"` / `import "redis"` never meet the
255
+ * `vi.mock` registry. In this workspace the same packages are symlinks to
256
+ * source outside `node_modules`, vitest inlines them, and the mocks apply —
257
+ * which is how replica-safety.test.mts stayed green here while dialling
258
+ * `redis.test` for real in every scaffold. The second half is `make test`:
259
+ * it runs this suite inside the `test` image, where a file the Dockerfile
260
+ * never copied simply does not exist.
261
+ */
262
+ describe("#512 — the shipped suite is green outside this repository", () => {
263
+ it("runs the published packages through vitest, so module mocks reach them", async () => {
264
+ // The config is loaded, not grepped: what matters is the value vitest
265
+ // resolves, and a `server.deps.inline` naming the wrong package would
266
+ // satisfy a substring check just as well.
267
+ const configPath = fileURLToPath(new URL("../../vitest.config.mts", import.meta.url));
268
+ const { default: config } = (await import(configPath)) as {
269
+ default: {
270
+ test?: { server?: { deps?: { inline?: ReadonlyArray<string | RegExp> | true } } };
271
+ };
272
+ };
273
+ const inline = config.test?.server?.deps?.inline;
274
+ expect(inline).toBeDefined();
275
+ if (inline === true) return;
276
+ for (const name of [
277
+ "@o3co/auth-provider-core",
278
+ "@o3co/auth-provider-redis",
279
+ "@o3co/auth-provider-session",
280
+ ]) {
281
+ expect(
282
+ (inline ?? []).some((m) => (m instanceof RegExp ? m.test(name) : m === name)),
283
+ `${name} is not inlined`,
284
+ ).toBe(true);
285
+ }
286
+ });
287
+
288
+ /**
289
+ * Every file this suite reads from the project root. `config/` and `src/`
290
+ * reach the image through the builder stage; these do not, so the `test`
291
+ * stage has to bring them — and `.dockerignore` has to let them into the
292
+ * build context first.
293
+ */
294
+ const READ_FROM_THE_PROJECT_ROOT = [
295
+ "Dockerfile",
296
+ ".dockerignore",
297
+ ".env.example",
298
+ ".gitignore",
299
+ "README.md",
300
+ "README.ja.md",
301
+ "docker-compose.yml",
302
+ "docker-compose.production.yml",
303
+ ] as const;
304
+
305
+ it("reads only files the template actually ships", () => {
306
+ for (const file of READ_FROM_THE_PROJECT_ROOT) {
307
+ expect(existsSync(`${standaloneDir}${file}`), file).toBe(true);
308
+ }
309
+ });
310
+
311
+ it("copies each of them into the image the `test` target runs", () => {
312
+ const copied = copiedIntoStage(read("/Dockerfile"), "test");
313
+ for (const file of READ_FROM_THE_PROJECT_ROOT) {
314
+ expect(copied.has(file), `${file} is not copied into the test image`).toBe(true);
315
+ }
316
+ });
317
+
318
+ it("lets each of them through .dockerignore", () => {
319
+ const dockerignore = read("/.dockerignore");
320
+ for (const file of READ_FROM_THE_PROJECT_ROOT) {
321
+ expect(
322
+ dockerignoreExcludes(dockerignore, file),
323
+ `${file} is excluded from the build context`,
324
+ ).toBe(false);
325
+ }
326
+ });
327
+ });
328
+
329
+ /**
330
+ * The build-context sources every `COPY` in `stage` and its ancestors brings
331
+ * into the image. `COPY --from=` is skipped — it reads another stage, not
332
+ * the context — and `--chown` / other flags are dropped. Sources are
333
+ * normalised to the bare entry name (`./config/` → `config`).
334
+ */
335
+ function copiedIntoStage(dockerfile: string, stage: string): Set<string> {
336
+ const stages = new Map<string, { parent: string; sources: string[] }>();
337
+ let current: { parent: string; sources: string[] } | undefined;
338
+ // Continuation lines are joined first, so a multi-line instruction reads
339
+ // as one.
340
+ for (const line of dockerfile.replace(/\\\n/g, " ").split("\n")) {
341
+ const text = line.trim();
342
+ if (text === "" || text.startsWith("#")) continue;
343
+ const from = /^FROM\s+(\S+)(?:\s+AS\s+(\S+))?/i.exec(text);
344
+ if (from) {
345
+ const parent = from[1] as string;
346
+ current = { parent, sources: [] };
347
+ stages.set(from[2] ?? parent, current);
348
+ continue;
349
+ }
350
+ if (!current || !/^COPY\b/i.test(text)) continue;
351
+ const args = text.split(/\s+/).slice(1);
352
+ if (args.some((a) => a.startsWith("--from="))) continue;
353
+ const operands = args.filter((a) => !a.startsWith("--"));
354
+ for (const source of operands.slice(0, -1)) {
355
+ current.sources.push(source.replace(/^\.\//, "").replace(/\/$/, ""));
356
+ }
357
+ }
358
+ const copied = new Set<string>();
359
+ for (let name: string | undefined = stage; name !== undefined; ) {
360
+ const s = stages.get(name);
361
+ if (!s) break;
362
+ for (const source of s.sources) copied.add(source);
363
+ name = s.parent;
364
+ }
365
+ return copied;
366
+ }
367
+
368
+ /**
369
+ * Whether `.dockerignore` keeps a root-level entry out of the build context.
370
+ * The last matching pattern wins and a leading `!` re-includes; `*` and `?`
371
+ * stop at a `/` while `**` does not — the subset of Docker's matching that
372
+ * root-level file names exercise.
373
+ */
374
+ function dockerignoreExcludes(dockerignore: string, file: string): boolean {
375
+ let excluded = false;
376
+ for (const raw of dockerignore.split("\n")) {
377
+ const line = raw.trim();
378
+ if (line === "" || line.startsWith("#")) continue;
379
+ const negated = line.startsWith("!");
380
+ const pattern = negated ? line.slice(1) : line;
381
+ // `**` is split out first so the single-`*` rewrite cannot see it.
382
+ const segment = (s: string): string =>
383
+ s
384
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
385
+ .replace(/\*/g, "[^/]*")
386
+ .replace(/\?/g, "[^/]");
387
+ const regex = new RegExp(`^${pattern.split("**").map(segment).join(".*")}$`);
388
+ if (regex.test(file)) excluded = !negated;
389
+ }
390
+ return excluded;
391
+ }
@@ -251,7 +251,7 @@ describe("standalone smoke test", () => {
251
251
  it("Dockerfile pins its mutable build inputs (#289)", () => {
252
252
  // Base image by digest — the tag alone is a moving pointer, and
253
253
  // Dependabot's docker ecosystem bumps tag and digest together.
254
- expect(dockerfile).toMatch(/FROM node:24-alpine@sha256:[0-9a-f]{64} AS node-base/);
254
+ expect(dockerfile).toMatch(/FROM node:26-alpine@sha256:[0-9a-f]{64} AS node-base/);
255
255
  // Global corepack by version — `npm install -g corepack` with no pin
256
256
  // resolved whatever was latest at build time.
257
257
  expect(dockerfile).toMatch(/npm install -g corepack@\d+\.\d+\.\d+ --force/);
@@ -20,9 +20,12 @@ import {
20
20
  jwksModule,
21
21
  type Module,
22
22
  memoryAccessTokenDenylistModule,
23
+ memoryConsentStoreModule,
23
24
  memoryRateLimiterModule,
25
+ memoryReplaySeenSetModule,
24
26
  } from "@o3co/auth-provider-core";
25
27
  import { googleFederationModule } from "@o3co/auth-provider-federation-google";
28
+ import { oidcFederationModule, oidcFederationNames } from "@o3co/auth-provider-federation-oidc";
26
29
  import {
27
30
  oauthAuthorizationModule,
28
31
  oauthModule,
@@ -34,9 +37,14 @@ import {
34
37
  redisFederationTokenStoreModuleFor,
35
38
  redisRateLimiterModule,
36
39
  redisRefreshTokenFamilyStoreModule,
40
+ redisReplaySeenSetModule,
37
41
  redisSessionStoresModule,
38
42
  } from "@o3co/auth-provider-redis";
39
- import { sessionModule, sessionStoreModuleFor } from "@o3co/auth-provider-session";
43
+ import {
44
+ extractFederationSection,
45
+ sessionModule,
46
+ sessionStoreModuleFor,
47
+ } from "@o3co/auth-provider-session";
40
48
  import {
41
49
  auditSinkModule,
42
50
  googleFederationConfigModule,
@@ -44,6 +52,7 @@ import {
44
52
  inMemoryFederationTokenStoreModule,
45
53
  inMemorySessionStoresModule,
46
54
  keyStoreModule,
55
+ oidcFederationConfigModule,
47
56
  repositoriesModule,
48
57
  standaloneRedisClientsModule,
49
58
  } from "./modules.mjs";
@@ -105,10 +114,23 @@ export interface BuildModulesOverrides {
105
114
  * absent), the config-bridge module's provider throws — so the entire pair
106
115
  * MUST be conditionally included at composition time, not gated inside the
107
116
  * provider.
117
+ *
118
+ * #524: the generic OIDC federation follows the same rule, once per enabled
119
+ * `federations.<name>` of type "oidc": `oidcFederationModule(name)` per
120
+ * instance plus the one `oidcFederationConfigModule` that reads every
121
+ * instance's config into the slot they share. A section's `type` decides
122
+ * which implementation owns the name, so the two gates never both select
123
+ * the same section.
108
124
  */
109
125
  export function buildModules(config: AppConfig, overrides: BuildModulesOverrides = {}): Module[] {
126
+ // A section's `type` names the implementation: `federations.google` is the
127
+ // built-in Google federation only when its type is `google` (the default
128
+ // for that name). With `type = "oidc"` it is a generic OIDC instance named
129
+ // "google" (#524), and composing both would contribute the same
130
+ // federation and redirect-policy keys twice.
110
131
  const googleEnabled =
111
- (config.federations?.google as { enabled?: boolean } | undefined)?.enabled === true;
132
+ extractFederationSection(config.federations ?? {}, "google")?.type === "google";
133
+ const oidcFederations = oidcFederationNames(config.federations ?? {});
112
134
 
113
135
  // Wave 5d (IH-14 + OR-M1 + OR-4) + OR-9: adapter-driven branching for
114
136
  // the OAuth-endpoint rate limiter, the user-session-store family, AND
@@ -128,6 +150,13 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
128
150
  // endpoint would answer 200 while the token kept working. The switch here is
129
151
  // only over WHICH denylist.
130
152
  const accessTokenDenylistAdapter = config.accessTokenDenylist?.adapter ?? "memory";
153
+ // #484: the jti single-use record behind private_key_jwt client auth.
154
+ const replaySeenSetAdapter = config.replaySeenSet?.adapter ?? "memory";
155
+ // #527: the consent store behind /authorize for clients that are not
156
+ // first-party. `"none"` (the default) wires nothing — such clients are
157
+ // refused, as before — so a first-party-only deployment pays nothing and
158
+ // a multi-replica one is not handed a memory store it cannot run.
159
+ const consentStoreAdapter = config.consentStore?.adapter ?? "none";
131
160
  // #456: adapter switch for the federation token store. `"memory"` by
132
161
  // default, the template's local-dev shape; `"redis"` mounts
133
162
  // `redisFederationTokenStoreModule` off the shared socket — what the README
@@ -172,6 +201,7 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
172
201
  userSessionStoresAdapter === "redis" ||
173
202
  codeRepositoryAdapter === "redis" ||
174
203
  accessTokenDenylistAdapter === "redis" ||
204
+ replaySeenSetAdapter === "redis" ||
175
205
  federationTokenStoreAdapter === "redis";
176
206
 
177
207
  // The four user-session stores switch on `userSessionStores.adapter`; the
@@ -207,6 +237,11 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
207
237
  ? [redisAccessTokenDenylistModule]
208
238
  : [memoryAccessTokenDenylistModule];
209
239
 
240
+ // #527: opt-in, memory only for now. The module declares itself
241
+ // replica-unsafe, so `deployment.mode = "multi"` refuses it by name.
242
+ const consentStoreModules: Module[] =
243
+ consentStoreAdapter === "memory" ? [memoryConsentStoreModule] : [];
244
+
210
245
  // #455 / #456: mutually-exclusive federation-token-store pair, the same
211
246
  // shape as the session stores. One module per adapter, so the memory one
212
247
  // can declare `replicaSafety` on its manifest and the Redis one can
@@ -217,6 +252,9 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
217
252
  // #473: the Redis module is built for this composition root so its
218
253
  // plaintext guard knows which environment selected the config; it reads
219
254
  // `deployment.mode` off the config itself.
255
+ const replaySeenSetModules: Module[] =
256
+ replaySeenSetAdapter === "redis" ? [redisReplaySeenSetModule] : [memoryReplaySeenSetModule];
257
+
220
258
  const federationTokenStoreModules: Module[] =
221
259
  federationTokenStoreAdapter === "redis"
222
260
  ? [redisFederationTokenStoreModuleFor({ environment: overrides.environment })]
@@ -245,6 +283,9 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
245
283
  jwksModule,
246
284
  sessionModule,
247
285
  ...(googleEnabled ? [googleFederationModule, googleFederationConfigModule] : []),
286
+ ...(oidcFederations.length > 0
287
+ ? [oidcFederationConfigModule, ...oidcFederations.map((name) => oidcFederationModule(name))]
288
+ : []),
248
289
  overrides.keyStoreModule ?? keyStoreModule,
249
290
  overrides.repositoriesModule ?? repositoriesModule,
250
291
  // #287: the audit sink, always wired. Unlike every switch above there is
@@ -272,6 +313,10 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
272
313
  // RFC 7009 access-token denylist: redis (shared revocations) or memory
273
314
  // (single-instance dev). Always wired — see the switch above.
274
315
  ...accessTokenDenylistModules,
316
+ ...replaySeenSetModules,
317
+ // Consent records for clients that are not first-party: memory, or
318
+ // nothing (#527).
319
+ ...consentStoreModules,
275
320
  // RT family store: redis by default (closes OR-1); override path
276
321
  // swaps to `[memoryRefreshTokenFamilyStoreModule]` for unit tests.
277
322
  ...refreshTokenFamilyModules,
@@ -37,6 +37,7 @@ import {
37
37
  registerBuiltinKeyStores,
38
38
  } from "@o3co/auth-provider-core";
39
39
  import type { GoogleProviderConfig } from "@o3co/auth-provider-federation-google";
40
+ import { readOidcFederationConfigs } from "@o3co/auth-provider-federation-oidc";
40
41
  import { registerBuiltinAdapters } from "@o3co/auth-provider-foundation";
41
42
  import { makeIoredisClients } from "@o3co/auth-provider-redis/ioredis";
42
43
  import { extractFederationSection } from "@o3co/auth-provider-session";
@@ -436,6 +437,11 @@ export const standaloneRedisClientsModule: Module = defineModule({
436
437
  return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
437
438
  .sessionFederationIndexClient;
438
439
  },
440
+ // #484: the replay seen-set behind private_key_jwt client assertions.
441
+ replaySeenSetClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
442
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
443
+ .replaySeenSetClient;
444
+ },
439
445
  rateLimiterClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
440
446
  return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
441
447
  .rateLimiterClient;
@@ -749,3 +755,21 @@ export const googleFederationConfigModule: Module = defineModule({
749
755
  },
750
756
  },
751
757
  });
758
+
759
+ /**
760
+ * OIDC federation config bridge (#524) — supplies the `oidcFederationConfigs`
761
+ * slot every `oidcFederationModule(<name>)` in the manifest reads its entry
762
+ * from. One bridge for all instances: `readOidcFederationConfigs` walks
763
+ * `config.federations` and reads every enabled section of type `oidc`,
764
+ * refusing a malformed field by `federations.<name>.<field>` at boot.
765
+ * `buildModules` lists this module only when at least one such section
766
+ * exists.
767
+ */
768
+ export const oidcFederationConfigModule: Module = defineModule({
769
+ name: "standalone:oidc-federation-config",
770
+ requires: ["config"] as const,
771
+ provides: {
772
+ oidcFederationConfigs: ({ config }) =>
773
+ readOidcFederationConfigs((config as AppConfig).federations),
774
+ },
775
+ });
@@ -11,5 +11,14 @@ export default defineConfig({
11
11
  // 20s.
12
12
  testTimeout: 20_000,
13
13
  hookTimeout: 20_000,
14
+ // #512: run the @o3co/auth-provider-* packages through vitest instead of
15
+ // letting Node load them natively. In a scaffolded project they are
16
+ // installed from npm under node_modules, which vitest externalizes by
17
+ // default — and an externalized package's own `import "ioredis"` /
18
+ // `import "redis"` never sees a `vi.mock` from a test file, so
19
+ // replica-safety.test.mts dialled a Redis that is not there. Inside the
20
+ // monorepo the same packages are symlinks to source and are inlined
21
+ // anyway; this makes the two layouts behave the same.
22
+ server: { deps: { inline: [/@o3co\/auth-provider-/] } },
14
23
  },
15
24
  });
@@ -1,15 +1,16 @@
1
1
  {
2
- "@o3co/auth-provider-core": "0.12.0",
3
- "@o3co/auth-provider-device-grant": "0.12.0",
4
- "@o3co/auth-provider-dpop": "0.12.0",
5
- "@o3co/auth-provider-mtls": "0.12.0",
6
- "@o3co/auth-provider-federation-apple": "0.12.0",
7
- "@o3co/auth-provider-federation-google": "0.12.0",
8
- "@o3co/auth-provider-federation-github": "0.12.0",
9
- "@o3co/auth-provider-oauth": "0.12.0",
10
- "@o3co/auth-provider-oauth-token-exchange": "0.12.0",
11
- "@o3co/auth-provider-session": "0.12.0",
12
- "@o3co/auth-provider-foundation": "0.12.0",
13
- "@o3co/auth-provider-redis": "0.12.0",
14
- "@o3co/auth-provider-webauthn": "0.12.0"
2
+ "@o3co/auth-provider-core": "0.13.0",
3
+ "@o3co/auth-provider-device-grant": "0.13.0",
4
+ "@o3co/auth-provider-dpop": "0.13.0",
5
+ "@o3co/auth-provider-mtls": "0.13.0",
6
+ "@o3co/auth-provider-federation-apple": "0.13.0",
7
+ "@o3co/auth-provider-federation-google": "0.13.0",
8
+ "@o3co/auth-provider-federation-github": "0.13.0",
9
+ "@o3co/auth-provider-federation-oidc": "0.13.0",
10
+ "@o3co/auth-provider-oauth": "0.13.0",
11
+ "@o3co/auth-provider-oauth-token-exchange": "0.13.0",
12
+ "@o3co/auth-provider-session": "0.13.0",
13
+ "@o3co/auth-provider-foundation": "0.13.0",
14
+ "@o3co/auth-provider-redis": "0.13.0",
15
+ "@o3co/auth-provider-webauthn": "0.13.0"
15
16
  }