@o3co/create-auth-provider 0.12.1 → 0.14.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.1",
3
+ "version": "0.14.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.1",
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.
@@ -72,6 +72,11 @@ SESSION_NAME=auth.sid
72
72
 
73
73
  # Optional
74
74
  # HTTP_PORT=3000
75
+ # OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN=3600
76
+ # The most a token-exchange `expires_in` can obtain; unset = the default.
77
+ # OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN=3600
78
+ # DEPRECATED alias of OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN, read only while
79
+ # that one is unset.
75
80
  # OAUTH_ACCESS_TOKEN_EXPIRES_IN=3600
76
81
  # OAUTH_REFRESH_TOKEN_EXPIRES_IN=86400
77
82
  # SESSION_MAX_AGE=3600000
@@ -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
 
@@ -103,8 +103,15 @@ CMD ["node", "dist/app.mjs"]
103
103
  #############################################
104
104
  FROM builder AS test
105
105
 
106
- COPY --chown=node:node vitest.config.mts ./
106
+ COPY --chown=node:node vitest.config.mts vitest.supertest-loopback.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
 
@@ -79,13 +79,18 @@ openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
79
79
 
80
80
  | 変数 | デフォルト | 説明 |
81
81
  |---|---|---|
82
- | `OAUTH_ACCESS_TOKEN_EXPIRES_IN` | `3600` | アクセストークンの有効期間(秒)。正の整数、上限は 1 年(`31536000`)。 |
82
+ | `OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN` | `3600` | リクエストが有効期間を指定しないときに全グラントが発行するアクセストークンの有効期間(秒)。正の整数、上限は 1 年(`31536000`)。 |
83
+ | `OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN` | デフォルトと同じ | token exchange リクエストの `expires_in` パラメータで得られる上限。超えるリクエストはこの値に切り詰められる。未設定ならデフォルトと同じで、設定しない限り延長されない。デフォルトがこれを超えると両キーを名指しして起動失敗。オフラインで検証するリソースサーバーに対し、交換で発行されたトークンが失効後も通用し得る期間の上限でもある。 |
84
+ | `OAUTH_ACCESS_TOKEN_EXPIRES_IN` | — | `OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN` の**非推奨(deprecated)**エイリアス(config キーでは `oauth.accessToken.expiresIn` が `oauth.accessToken.defaultExpiresIn` のエイリアス)。新しい変数が未設定の間だけ読まれる。値は新しい変数へ移すこと。 |
83
85
  | `OAUTH_REFRESH_TOKEN_EXPIRES_IN` | `86400` | リフレッシュトークンの有効期間(秒)。正の整数、上限は 1 年(`31536000`)。 |
84
86
 
85
87
  これらを空文字で export すると fallback ではなく起動失敗になる: HOCON は `FOO=` を
86
88
  `""` に解決し、それが `0` に coerce され、有効期間 0 は「発行時点で期限切れ」の
87
89
  トークンを作るため。
88
90
 
91
+ `expires_in` リクエストパラメータを読むのは token exchange(RFC 8693)だけで、
92
+ 他のグラントはそれを無視してデフォルトを発行する。
93
+
89
94
  ### グラントタイプ
90
95
 
91
96
  | 変数 | デフォルト | 説明 |
@@ -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,8 @@ 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.
116
+ - 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).
109
117
  - 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
118
 
111
119
  ## Usage
@@ -161,6 +169,18 @@ fail fast rather than silently falling back to defaults.
161
169
  | `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
170
  | `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
171
  | `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. |
172
+ | `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`. |
173
+ | `OAUTH_CIMD_ALLOWED_SCOPES` | — | Comma-separated: what any such client may obtain (its document's `scope` is intersected with this). Empty admits none. |
174
+ | `OAUTH_CIMD_ALLOWED_AUDIENCES` | — | Comma-separated: the resource servers it may mint for (RFC 8707 `resource`). Empty admits only its own `client_id`. |
175
+ | `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. |
176
+ | `OAUTH_CIMD_DENIED_HOSTS` | — | Comma-separated hosts refused even when allowed above. |
177
+ | `OAUTH_CIMD_MAX_BYTES` | `5120` | Byte cap on a document (the draft recommends 5 KB). |
178
+ | `OAUTH_CIMD_TIMEOUT_MS` | `5000` | Fetch timeout. |
179
+ | `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. |
180
+ | `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. |
181
+ | `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
+ | `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
+ | `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
184
  | `OAUTH_JWT_KID` | `v0` | Key ID included in the JWT header |
165
185
  | `OAUTH_JWT_PRIVATE_KEY` | — | PEM-encoded private key (asymmetric algorithms) |
166
186
  | `OAUTH_JWT_PRIVATE_KEY_PATH` | — | Path to PEM private key file |
@@ -191,13 +211,18 @@ every relying party must be handed the shared secret — which also lets it
191
211
 
192
212
  | Variable | Default | Description |
193
213
  |---|---|---|
194
- | `OAUTH_ACCESS_TOKEN_EXPIRES_IN` | `3600` | Access token lifetime in seconds. Whole positive number, at most one year (`31536000`). |
214
+ | `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
+ | `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. |
195
217
  | `OAUTH_REFRESH_TOKEN_EXPIRES_IN` | `86400` | Refresh token lifetime in seconds. Whole positive number, at most one year (`31536000`). |
196
218
 
197
219
  Exporting one of these as an empty string is a boot failure, not a fallback:
198
220
  HOCON resolves `FOO=` to `""`, which coerces to `0`, and a zero lifetime mints
199
221
  tokens that have already expired.
200
222
 
223
+ Only token exchange (RFC 8693) reads an `expires_in` request parameter; every
224
+ other grant ignores it and mints the default.
225
+
201
226
  ### Grant Types
202
227
 
203
228
  | Variable | Default | Description |
@@ -311,6 +336,46 @@ all — no headers, no `Vary`, nothing changed.
311
336
  | `FEDERATIONS_GOOGLE_CLIENT_SECRET` | — | Google OAuth client secret |
312
337
  | `FEDERATIONS_GOOGLE_CALLBACK_URL` | `http://localhost:3000/session/oauth/federation/google/callback` | Google OAuth callback URL |
313
338
 
339
+ ### OIDC Federation (any OpenID Connect provider)
340
+
341
+ One instance ships in `config/application.conf` under `federations.oidc`
342
+ (`@o3co/auth-provider-federation-oidc`, #524): Okta, Entra ID, Auth0, Keycloak
343
+ or your own tenant, selected by issuer. Discovery runs at boot and a failure
344
+ refuses boot. The identity handed to the Store is `oidc:<sub>`; an identity
345
+ the Store does not know is refused with 401 — nothing is provisioned.
346
+
347
+ | Variable | Default | Description |
348
+ |---|---|---|
349
+ | `FEDERATIONS_OIDC_ENABLED` | `false` | Enable the OIDC federation |
350
+ | `FEDERATIONS_OIDC_ISSUER` | — | Issuer identifier (https), exactly as the IdP writes `iss` |
351
+ | `FEDERATIONS_OIDC_CLIENT_ID` | — | Client ID registered at the IdP |
352
+ | `FEDERATIONS_OIDC_CLIENT_SECRET` | — | Client secret (`client_secret_basic`); set `privateKey` in the config file for `private_key_jwt` instead |
353
+ | `FEDERATIONS_OIDC_CALLBACK_URL` | `http://localhost:3000/session/oauth/federation/oidc/callback` | Where the IdP sends the browser back |
354
+
355
+ A second IdP is another section with `type = "oidc"` and its own callback:
356
+
357
+ ```hocon
358
+ federations {
359
+ okta {
360
+ enabled = true
361
+ type = "oidc"
362
+ issuer = "https://dev-123.okta.com"
363
+ clientId = ${OKTA_CLIENT_ID}
364
+ clientSecret = ${OKTA_CLIENT_SECRET}
365
+ callbackURL = "https://auth.example.com/session/oauth/federation/okta/callback"
366
+ redirectAllowlist = ["https://app.example.com/welcome"]
367
+ }
368
+ }
369
+ ```
370
+
371
+ The section's `type` names the implementation. `federations.google` without a
372
+ `type` is the built-in Google federation; with `type = "oidc"` it is a generic
373
+ OIDC instance named `google`, and the built-in module is not composed.
374
+
375
+ Every field the package accepts — `scopes`, `discovery` / `endpoints`,
376
+ `privateKey`, `userInfo`, `idTokenSignedResponseAlg`, `clockToleranceSeconds` —
377
+ is documented in [its README](../../packages/federation-oidc/README.md).
378
+
314
379
  ### Client Repository
315
380
 
316
381
  | Variable | Default | Description |
@@ -325,6 +390,7 @@ all — no headers, no `Vary`, nothing changed.
325
390
  | `CLIENT_USER_TYPE` | `http` | User repository backend: `http` |
326
391
  | `CLIENT_USER_AUTHENTICATE_URL` | — | URL for password-based user authentication. **https required** (see below) |
327
392
  | `CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL` | — | URL for token-based user authentication. **https required** (see below) |
393
+ | `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
394
  | `CLIENT_USER_TIMEOUT` | `5000` | HTTP request timeout in milliseconds. Positive integer ≤ `2147483647` |
329
395
  | `CLIENT_USER_MAX_RESPONSE_BYTES` | `1048576` | Largest upstream response body accepted, in bytes |
330
396
 
@@ -364,6 +430,19 @@ needs a key.
364
430
  `scanFallback` migration flag live under `redisFederationTokenStore` in a
365
431
  config layer rather than behind an environment variable.
366
432
 
433
+ ### Consent Store
434
+
435
+ Where an end-user's consent to a client that is not first-party is recorded,
436
+ together with the `/authorize` request parked while the consent page asks
437
+ (#527, #552). One switch wires both. `none` by default: such clients are
438
+ refused and only first-party clients are served. `memory` forks per replica and
439
+ is refused under `DEPLOYMENT_MODE=multi`; `redis` (#561) shares both over the
440
+ socket configured by `REFRESH_TOKEN_FAMILY_STORE_REDIS_URL`.
441
+
442
+ | Variable | Default | Description |
443
+ |---|---|---|
444
+ | `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
+
367
446
  ### Redis Namespacing
368
447
 
369
448
  For multi-tenant Redis clusters, set deployment-specific key prefixes so two
@@ -375,15 +454,17 @@ auth.provider instances cannot collide in the same database:
375
454
  | `REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX` | `rtfam:` | Prefix for refresh-token family records. |
376
455
  | `CLIENT_CODE_KEY_PREFIX` | `oauth:code:` | Prefix for OAuth authorization codes. |
377
456
  | `REDIS_FEDERATION_TOKEN_STORE_KEY_PREFIX` | `ft:` | Prefix for federation token records, their per-session index and their lock keys. |
457
+ | `REDIS_CONSENT_STORE_KEY_PREFIX` | `consent:` | Prefix for consent records and for parked consent requests with their per-session index (`CONSENT_STORE_ADAPTER=redis`, #561). |
378
458
 
379
459
  Use values that include the deployment name, for example `tenant-a:ss:`,
380
- `tenant-a:rtfam:`, `tenant-a:code:`, and `tenant-a:ft:`.
460
+ `tenant-a:rtfam:`, `tenant-a:code:`, `tenant-a:ft:`, and `tenant-a:consent:`.
381
461
 
382
462
  ### Endpoints
383
463
 
384
464
  | Variable | Default | Description |
385
465
  |---|---|---|
386
466
  | `ENDPOINTS_LOGIN_URL` | `/login` | URL of the login page (for redirects) |
467
+ | `ENDPOINTS_CONSENT_URL` | `/consent` | URL of the consent page a client that is not first-party is routed through, with `?challenge=<id>` (#527) |
387
468
 
388
469
  `ENDPOINTS_CLIENT_URL` and `ENDPOINTS_AUTH_CALLBACK_URL` were removed (IH-10) —
389
470
  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,31 @@ 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); "redis" (#561) keeps the consent records and
166
+ # the requests parked while the consent page asks on the shared ioredis socket,
167
+ # and is what a deployment of more than one replica selects. Its key namespace
168
+ # is REDIS_CONSENT_STORE_KEY_PREFIX (default "consent:").
169
+ consentStore {
170
+ adapter = "none"
171
+ adapter = ${?CONSENT_STORE_ADAPTER}
172
+ }
173
+
137
174
  session {
138
175
  secret = ${?SESSION_SECRET}
139
176
  # MIN-2: __Host- prefix requires Secure, Path=/, and no Domain attribute.
@@ -233,6 +270,42 @@ federations {
233
270
  # Where the browser goes after a callback carrying no `redirect_to`.
234
271
  # clientUrl = ${?FEDERATIONS_GOOGLE_CLIENT_URL}
235
272
  }
273
+
274
+ # Any OpenID Connect provider (Okta, Entra ID, Auth0, Keycloak, your own
275
+ # tenant) — `@o3co/auth-provider-federation-oidc` (#524). Disabled by
276
+ # default; the env vars below switch this one instance on. The section
277
+ # name is the federation name: the browser starts at
278
+ # /session/oauth/federation/oidc and the identity handed to the Store is
279
+ # "oidc:<sub>". An identity the Store does not know is refused (401);
280
+ # nothing is provisioned. To broker to a second IdP, copy this block under
281
+ # another name (`okta { type = "oidc" ... }`) with its own callbackURL.
282
+ oidc {
283
+ enabled = false
284
+ enabled = ${?FEDERATIONS_OIDC_ENABLED}
285
+ type = "oidc"
286
+
287
+ # Issuer identifier, exactly as the IdP writes it into `iss`. Discovery
288
+ # runs at boot from <issuer>/.well-known/openid-configuration and a
289
+ # failure refuses boot. https only (plain http on localhost for dev).
290
+ issuer = ${?FEDERATIONS_OIDC_ISSUER}
291
+ clientId = ${?FEDERATIONS_OIDC_CLIENT_ID}
292
+ # client_secret_basic. For private_key_jwt drop clientSecret and set
293
+ # privateKey (a PEM PKCS#8 key, or { pem, kid, alg }) instead.
294
+ clientSecret = ${?FEDERATIONS_OIDC_CLIENT_SECRET}
295
+ callbackURL = "http://localhost:3000/session/oauth/federation/oidc/callback"
296
+ callbackURL = ${?FEDERATIONS_OIDC_CALLBACK_URL}
297
+ scopes = ["openid", "profile", "email"]
298
+
299
+ # Same rules as the Google block above: exact URLs, https except on a
300
+ # loopback host, and an empty list refuses every `redirect_to`.
301
+ redirectAllowlist = []
302
+ # sessionDomain, authCallbackUrl and clientUrl work as for Google.
303
+ #
304
+ # Hand-typed endpoints instead of discovery (the IdP's document is wrong
305
+ # or unreachable from this network):
306
+ # discovery = false
307
+ # endpoints { authorizationEndpoint = "...", tokenEndpoint = "...", jwksUri = "..." }
308
+ }
236
309
  }
237
310
 
238
311
  repositories {
@@ -257,6 +330,14 @@ repositories {
257
330
  # service name still needs https. Boot fails if either is not.
258
331
  authenticateUrl = ${?CLIENT_USER_AUTHENTICATE_URL}
259
332
  authenticateByTokenUrl = ${?CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL}
333
+ # #482: optional. When set, a signed-in user can link a second federated
334
+ # identity (GET /session/oauth/federation/<name>?link=1): the Store
335
+ # receives { userId, provider, sub, token, claims } and decides — never on
336
+ # an unverified or relay e-mail. Same https rule as the two URLs above.
337
+ # Also declared in core's reference.conf, so a composition that does not
338
+ # layer this file gets the same override.
339
+ # Without it, link=1 is refused with link_unsupported.
340
+ linkFederatedIdentityUrl = ${?CLIENT_USER_LINK_FEDERATED_IDENTITY_URL}
260
341
  # Positive integer, milliseconds, <= 2147483647. Boot fails otherwise —
261
342
  # including on a blank env override, which HOCON substitutes as "".
262
343
  timeout = 5000
@@ -287,6 +368,13 @@ endpoints {
287
368
  url = "/login"
288
369
  url = ${?ENDPOINTS_LOGIN_URL}
289
370
  }
371
+ # #527: the consent page a client that is not first-party is routed
372
+ # through (`?challenge=<id>`). Deployment-owned, like the login page: it
373
+ # reads GET /oauth/consent and posts to POST /oauth/consent.
374
+ consent {
375
+ url = "/consent"
376
+ url = ${?ENDPOINTS_CONSENT_URL}
377
+ }
290
378
  # IH-10: `client { url }` and `authCallback { url }` removed — no
291
379
  # production consumer reads them.
292
380
  }
@@ -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.1",
3
+ "version": "0.14.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.1",
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
  }
@@ -0,0 +1,101 @@
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
+ /**
18
+ * `oauth.accessToken.expiresIn` / OAUTH_ACCESS_TOKEN_EXPIRES_IN is a deprecated
19
+ * alias of `defaultExpiresIn`, and the composition says so once — the OR-9
20
+ * shape `repositories.code.type` already has.
21
+ *
22
+ * The line has to be exact in both directions. Core's `reference.conf` keeps
23
+ * the shipped lifetime on the deprecated key, so "the alias supplied the
24
+ * default" describes every deployment that set nothing; warning on that would
25
+ * train operators to ignore the line. Only an override of the old key is
26
+ * something to move.
27
+ */
28
+
29
+ import { fileURLToPath } from "node:url";
30
+ import { type AppConfig, AppConfigSchema } from "@o3co/auth-provider-core";
31
+ import { parseFile } from "@o3co/ts.hocon";
32
+ import { validate } from "@o3co/ts.hocon/zod";
33
+ import { afterEach, describe, expect, it, vi } from "vitest";
34
+ import { buildModules } from "../buildModules.mjs";
35
+ import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
36
+
37
+ const configDir = fileURLToPath(new URL("../../config", import.meta.url));
38
+
39
+ const REQUIRED_ENV = {
40
+ OAUTH_JWT_SECRET: "access-token-alias.at-least-32-bytes.ok",
41
+ OAUTH_JWT_ISSUER: "https://auth.test",
42
+ SESSION_SECRET: "access-token-alias-session.at-least-32-bytes.ok",
43
+ };
44
+
45
+ /** The shipped layers, resolved the way `app.mts` resolves them. */
46
+ function loadShipped(env: Record<string, string> = {}): AppConfig {
47
+ const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "development");
48
+ const resolvedEnv = { ...REQUIRED_ENV, ...env };
49
+ return validate(
50
+ parseFile(envConfPath, { env: resolvedEnv })
51
+ .withFallback(parseFile(applicationConfPath, { env: resolvedEnv }))
52
+ .withFallback(parseFile(resolveLibraryReferenceConfPath(), { env: resolvedEnv })),
53
+ AppConfigSchema,
54
+ );
55
+ }
56
+
57
+ /** Every console warning `buildModules` emits about the deprecated key. */
58
+ function aliasWarnings(config: AppConfig): string[] {
59
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
60
+ buildModules(config);
61
+ return warn.mock.calls
62
+ .map((call) => String(call[0]))
63
+ .filter((message) => message.includes("oauth.accessToken.expiresIn"));
64
+ }
65
+
66
+ describe("the deprecated access-token lifetime alias", () => {
67
+ afterEach(() => {
68
+ vi.restoreAllMocks();
69
+ });
70
+
71
+ it("says nothing for a deployment that sets no lifetime at all", () => {
72
+ // Also what keeps the template's idea of "the shipped value" honest: it
73
+ // is measured against core's real `reference.conf`, so a change to the
74
+ // shipped literal fails here rather than warning every deployment.
75
+ expect(aliasWarnings(loadShipped())).toEqual([]);
76
+ });
77
+
78
+ it("says nothing for a deployment on the new variable", () => {
79
+ expect(aliasWarnings(loadShipped({ OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN: "900" }))).toEqual(
80
+ [],
81
+ );
82
+ });
83
+
84
+ it("warns once when OAUTH_ACCESS_TOKEN_EXPIRES_IN still decides the default", () => {
85
+ const warnings = aliasWarnings(loadShipped({ OAUTH_ACCESS_TOKEN_EXPIRES_IN: "900" }));
86
+ expect(warnings).toHaveLength(1);
87
+ expect(warnings[0]).toMatch(/deprecated/);
88
+ expect(warnings[0]).toMatch(/oauth\.accessToken\.defaultExpiresIn/);
89
+ expect(warnings[0]).toMatch(/OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN/);
90
+ });
91
+
92
+ it("warns for a hand-built configuration that overrides only the old key", () => {
93
+ const config = loadShipped();
94
+ expect(
95
+ aliasWarnings({
96
+ ...config,
97
+ oauth: { ...config.oauth, accessToken: { expiresIn: 900 } },
98
+ }),
99
+ ).toHaveLength(1);
100
+ });
101
+ });