@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 +3 -3
- package/templates/standalone/.dockerignore +4 -3
- package/templates/standalone/.env.example +5 -0
- package/templates/standalone/Dockerfile +10 -3
- package/templates/standalone/README.ja.md +6 -1
- package/templates/standalone/README.md +83 -2
- package/templates/standalone/config/application.conf +88 -0
- package/templates/standalone/config/clients.yaml.example +22 -0
- package/templates/standalone/package.json +5 -4
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +101 -0
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +89 -6
- package/templates/standalone/src/__tests__/metrics.test.mts +14 -2
- package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +138 -0
- package/templates/standalone/src/__tests__/replica-safety.test.mts +65 -0
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +168 -0
- package/templates/standalone/src/__tests__/smoke.test.mts +2 -1
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +125 -0
- package/templates/standalone/src/buildModules.mts +86 -2
- package/templates/standalone/src/modules.mts +44 -2
- package/templates/standalone/vitest.config.mts +15 -0
- package/templates/standalone/vitest.supertest-loopback.mts +231 -0
- package/templates/versions.json +14 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@o3co/create-auth-provider",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
32
|
-
"vitest": "^
|
|
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:
|
|
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:
|
|
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
|
-
| `
|
|
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
|
-
| `
|
|
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:
|
|
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.
|
|
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.
|
|
44
|
+
"@types/node": "^26.5.1",
|
|
44
45
|
"@types/supertest": "^7.2.1",
|
|
45
46
|
"supertest": "^7.1.0",
|
|
46
|
-
"tsx": "^4.23.
|
|
47
|
+
"tsx": "^4.23.13",
|
|
47
48
|
"typescript": "^6.0.3",
|
|
48
|
-
"vitest": "^
|
|
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
|
+
});
|