@o3co/create-auth-provider 0.13.0 → 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 +2 -2
- package/templates/standalone/.env.example +5 -0
- package/templates/standalone/Dockerfile +1 -1
- package/templates/standalone/README.ja.md +6 -1
- package/templates/standalone/README.md +22 -3
- package/templates/standalone/config/application.conf +4 -1
- package/templates/standalone/package.json +2 -2
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +101 -0
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +41 -3
- package/templates/standalone/src/__tests__/metrics.test.mts +14 -2
- package/templates/standalone/src/__tests__/replica-safety.test.mts +58 -0
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +23 -0
- package/templates/standalone/src/__tests__/smoke.test.mts +1 -0
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +125 -0
- package/templates/standalone/src/buildModules.mts +44 -5
- package/templates/standalone/src/modules.mts +20 -2
- package/templates/standalone/vitest.config.mts +6 -0
- package/templates/standalone/vitest.supertest-loopback.mts +231 -0
- package/templates/versions.json +14 -14
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,7 +28,7 @@
|
|
|
28
28
|
"node": ">=22.0.0"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
|
-
"@types/node": "^26.5.
|
|
31
|
+
"@types/node": "^26.5.1",
|
|
32
32
|
"vitest": "^5.0.0"
|
|
33
33
|
},
|
|
34
34
|
"scripts": {
|
|
@@ -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
|
|
@@ -103,7 +103,7 @@ 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
108
|
# #512: the suite reads these from the project root (scaffold-assets,
|
|
109
109
|
# documented-env-overrides, smoke). The builder stage brings only what the
|
|
@@ -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
|
| 変数 | デフォルト | 説明 |
|
|
@@ -113,6 +113,7 @@ Other multi-replica considerations covered by the default modules:
|
|
|
113
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.
|
|
114
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
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).
|
|
116
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).
|
|
117
118
|
|
|
118
119
|
## Usage
|
|
@@ -210,13 +211,18 @@ every relying party must be handed the shared secret — which also lets it
|
|
|
210
211
|
|
|
211
212
|
| Variable | Default | Description |
|
|
212
213
|
|---|---|---|
|
|
213
|
-
| `
|
|
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. |
|
|
214
217
|
| `OAUTH_REFRESH_TOKEN_EXPIRES_IN` | `86400` | Refresh token lifetime in seconds. Whole positive number, at most one year (`31536000`). |
|
|
215
218
|
|
|
216
219
|
Exporting one of these as an empty string is a boot failure, not a fallback:
|
|
217
220
|
HOCON resolves `FOO=` to `""`, which coerces to `0`, and a zero lifetime mints
|
|
218
221
|
tokens that have already expired.
|
|
219
222
|
|
|
223
|
+
Only token exchange (RFC 8693) reads an `expires_in` request parameter; every
|
|
224
|
+
other grant ignores it and mints the default.
|
|
225
|
+
|
|
220
226
|
### Grant Types
|
|
221
227
|
|
|
222
228
|
| Variable | Default | Description |
|
|
@@ -417,7 +423,6 @@ needs a key.
|
|
|
417
423
|
| Variable | Default | Description |
|
|
418
424
|
|---|---|---|
|
|
419
425
|
| `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) |
|
|
421
426
|
| `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` |
|
|
422
427
|
| `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 |
|
|
423
428
|
|
|
@@ -425,6 +430,19 @@ needs a key.
|
|
|
425
430
|
`scanFallback` migration flag live under `redisFederationTokenStore` in a
|
|
426
431
|
config layer rather than behind an environment variable.
|
|
427
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
|
+
|
|
428
446
|
### Redis Namespacing
|
|
429
447
|
|
|
430
448
|
For multi-tenant Redis clusters, set deployment-specific key prefixes so two
|
|
@@ -436,9 +454,10 @@ auth.provider instances cannot collide in the same database:
|
|
|
436
454
|
| `REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX` | `rtfam:` | Prefix for refresh-token family records. |
|
|
437
455
|
| `CLIENT_CODE_KEY_PREFIX` | `oauth:code:` | Prefix for OAuth authorization codes. |
|
|
438
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). |
|
|
439
458
|
|
|
440
459
|
Use values that include the deployment name, for example `tenant-a:ss:`,
|
|
441
|
-
`tenant-a:rtfam:`, `tenant-a:code:`, and `tenant-a:
|
|
460
|
+
`tenant-a:rtfam:`, `tenant-a:code:`, `tenant-a:ft:`, and `tenant-a:consent:`.
|
|
442
461
|
|
|
443
462
|
### Endpoints
|
|
444
463
|
|
|
@@ -162,7 +162,10 @@ replaySeenSet {
|
|
|
162
162
|
# #527: where consent to a client that is not first-party is recorded.
|
|
163
163
|
# "none" (the default) wires no store and such clients are refused at
|
|
164
164
|
# /authorize; "memory" is single-replica only (refused under
|
|
165
|
-
# DEPLOYMENT_MODE=multi by name)
|
|
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:").
|
|
166
169
|
consentStore {
|
|
167
170
|
adapter = "none"
|
|
168
171
|
adapter = ${?CONSENT_STORE_ADAPTER}
|
|
@@ -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": {
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@types/express": "^5.0.6",
|
|
43
43
|
"@types/express-session": "^1",
|
|
44
|
-
"@types/node": "^26.5.
|
|
44
|
+
"@types/node": "^26.5.1",
|
|
45
45
|
"@types/supertest": "^7.2.1",
|
|
46
46
|
"supertest": "^7.1.0",
|
|
47
47
|
"tsx": "^4.23.13",
|
|
@@ -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
|
+
});
|
|
@@ -17,7 +17,11 @@
|
|
|
17
17
|
import { existsSync, readFileSync } from "node:fs";
|
|
18
18
|
import { join } from "node:path";
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
type AppConfig,
|
|
22
|
+
AppConfigSchema,
|
|
23
|
+
resolveAccessTokenLifetime,
|
|
24
|
+
} from "@o3co/auth-provider-core";
|
|
21
25
|
import { parseFile } from "@o3co/ts.hocon";
|
|
22
26
|
import { validate } from "@o3co/ts.hocon/zod";
|
|
23
27
|
import { describe, expect, it } from "vitest";
|
|
@@ -83,6 +87,10 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
|
|
|
83
87
|
OAUTH_JWT_LEGACY_TYP_ACCEPT: "true",
|
|
84
88
|
|
|
85
89
|
// --- oauth tokens / policy ----------------------------------------
|
|
90
|
+
OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN: "900",
|
|
91
|
+
OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN: "7200",
|
|
92
|
+
// Deprecated alias of the default. Exported alongside the new variable on
|
|
93
|
+
// purpose, with a different value: the new variable must win.
|
|
86
94
|
OAUTH_ACCESS_TOKEN_EXPIRES_IN: "3600",
|
|
87
95
|
OAUTH_REFRESH_TOKEN_EXPIRES_IN: "86400",
|
|
88
96
|
OAUTH_REFRESH_TOKEN_UNKNOWN_FAMILY_POLICY: "reject",
|
|
@@ -151,7 +159,9 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
|
|
|
151
159
|
ACCESS_TOKEN_DENYLIST_ADAPTER: "redis",
|
|
152
160
|
// #484: the replay seen-set behind private_key_jwt client authentication.
|
|
153
161
|
REPLAY_SEEN_SET_ADAPTER: "redis",
|
|
154
|
-
|
|
162
|
+
// #561: the consent stores' shared backend and its namespace.
|
|
163
|
+
CONSENT_STORE_ADAPTER: "redis",
|
|
164
|
+
REDIS_CONSENT_STORE_KEY_PREFIX: "tenant-a:consent:",
|
|
155
165
|
REDIS_ACCESS_TOKEN_DENYLIST_KEY_PREFIX: "atdeny:",
|
|
156
166
|
REDIS_SESSION_STORES_KEY_PREFIX: "ss:",
|
|
157
167
|
REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX: "rtfam:",
|
|
@@ -320,11 +330,19 @@ describe("#288: the shipped config boots with every documented override supplied
|
|
|
320
330
|
expect(config.http.port).toBe(3000);
|
|
321
331
|
expect(config.http.readinessTimeoutMs).toBe(1500);
|
|
322
332
|
expect(config.http.trustProxy).toEqual(["10.0.0.0/8", "loopback"]);
|
|
323
|
-
|
|
333
|
+
// The new default wins over the deprecated variable, and the parsed
|
|
334
|
+
// config mirrors it onto the old key for readers that predate the split.
|
|
335
|
+
expect(resolveAccessTokenLifetime(config)).toEqual({
|
|
336
|
+
defaultExpiresIn: 900,
|
|
337
|
+
maxExpiresIn: 7200,
|
|
338
|
+
});
|
|
339
|
+
expect(config.oauth.accessToken.expiresIn).toBe(900);
|
|
324
340
|
expect(config.oauth.refreshToken.expiresIn).toBe(86400);
|
|
325
341
|
expect(config.session.maxAge).toBe(3600000);
|
|
326
342
|
expect(config.session.csrf?.ttlSeconds).toBe(7200);
|
|
327
343
|
expect(config.oauth.nonce?.maxLength).toBe(256);
|
|
344
|
+
expect(config.consentStore?.adapter).toBe("redis");
|
|
345
|
+
expect(config.redisConsentStore?.keyPrefix).toBe("tenant-a:consent:");
|
|
328
346
|
// #529: the comma-separated lists become lists, trimmed; the numbers, numbers.
|
|
329
347
|
expect(config.oauth.clientIdMetadataDocuments).toEqual({
|
|
330
348
|
enabled: true,
|
|
@@ -508,6 +526,26 @@ describe("#288: the shipped config boots with every documented override supplied
|
|
|
508
526
|
).toThrow(/allowUnmarkedClients/);
|
|
509
527
|
});
|
|
510
528
|
|
|
529
|
+
it("refuses an access-token default above the max, naming both keys", () => {
|
|
530
|
+
expect(() =>
|
|
531
|
+
buildResolvedConfig({
|
|
532
|
+
...DOCUMENTED_ENV,
|
|
533
|
+
OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN: "7200",
|
|
534
|
+
OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN: "3600",
|
|
535
|
+
}),
|
|
536
|
+
).toThrow(/defaultExpiresIn.*maxExpiresIn/s);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
for (const name of [
|
|
540
|
+
"OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN",
|
|
541
|
+
"OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN",
|
|
542
|
+
"OAUTH_ACCESS_TOKEN_EXPIRES_IN",
|
|
543
|
+
]) {
|
|
544
|
+
it(`refuses an empty ${name} rather than minting already-expired tokens`, () => {
|
|
545
|
+
expect(() => buildResolvedConfig({ ...DOCUMENTED_ENV, [name]: "" })).toThrow();
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
511
549
|
it("still refuses an empty SESSION_CSRF_TTL_SECONDS (#272)", () => {
|
|
512
550
|
// Pinned alongside the boolean cases because it is the same trap
|
|
513
551
|
// read from the other side: for a *number*, empty means fail loudly.
|
|
@@ -37,6 +37,18 @@ function buildApp(probes: Parameters<ReturnType<typeof createMetrics>["route"]>[
|
|
|
37
37
|
return app;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Starts `app` on 127.0.0.1, the address the raw-socket tests dial. A hostless
|
|
42
|
+
* `listen(0)` binds `[::]`, and on macOS the port it gets can be one another
|
|
43
|
+
* process holds on 127.0.0.1 — the request then reaches that process and can
|
|
44
|
+
* hang until the test times out (#556).
|
|
45
|
+
*/
|
|
46
|
+
function listenOnLoopback(app: express.Express): Promise<ReturnType<express.Express["listen"]>> {
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
const server = app.listen(0, "127.0.0.1", () => resolve(server));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
40
52
|
describe("GET /metrics", () => {
|
|
41
53
|
it("serves the Prometheus text exposition format", async () => {
|
|
42
54
|
const res = await request(buildApp()).get("/metrics");
|
|
@@ -95,7 +107,7 @@ describe("GET /metrics", () => {
|
|
|
95
107
|
// route label, reachable without any access to /metrics. Sent over a real
|
|
96
108
|
// socket because supertest cannot express a non-standard method.
|
|
97
109
|
const app = buildApp();
|
|
98
|
-
const server = app
|
|
110
|
+
const server = await listenOnLoopback(app);
|
|
99
111
|
const port = (server.address() as { port: number }).port;
|
|
100
112
|
try {
|
|
101
113
|
for (const method of ["FOO", "M000001", "PURGE"]) {
|
|
@@ -135,7 +147,7 @@ describe("GET /metrics", () => {
|
|
|
135
147
|
void res;
|
|
136
148
|
});
|
|
137
149
|
|
|
138
|
-
const server = app
|
|
150
|
+
const server = await listenOnLoopback(app);
|
|
139
151
|
const port = (server.address() as { port: number }).port;
|
|
140
152
|
try {
|
|
141
153
|
const controller = new AbortController();
|
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
defineModule,
|
|
61
61
|
InMemoryClientRepository,
|
|
62
62
|
InMemoryUserRepository,
|
|
63
|
+
memoryRefreshTokenFamilyStoreModule,
|
|
63
64
|
registerBuiltinKeyStores,
|
|
64
65
|
replicaUnsafeReason,
|
|
65
66
|
} from "@o3co/auth-provider-core";
|
|
@@ -162,6 +163,9 @@ const ALL_REDIS_ENV: Readonly<Record<string, string>> = {
|
|
|
162
163
|
REPLAY_SEEN_SET_ADAPTER: "redis",
|
|
163
164
|
FEDERATION_TOKEN_STORE_TYPE: "redis",
|
|
164
165
|
REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: ENCRYPTION_KEY,
|
|
166
|
+
// #561: the consent step for clients that are not first-party, on the
|
|
167
|
+
// shared store — the one switch value `multi` accepts besides `none`.
|
|
168
|
+
CONSENT_STORE_ADAPTER: "redis",
|
|
165
169
|
};
|
|
166
170
|
|
|
167
171
|
function resolveConfig(env: Record<string, string>): AppConfig {
|
|
@@ -397,6 +401,60 @@ describe('#456: federationTokenStore.type = "redis" in the standalone', () => {
|
|
|
397
401
|
});
|
|
398
402
|
});
|
|
399
403
|
|
|
404
|
+
describe('#561: consentStore.adapter = "redis" in the standalone', () => {
|
|
405
|
+
let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
|
|
406
|
+
|
|
407
|
+
afterEach(async () => {
|
|
408
|
+
await handleRef?.dispose();
|
|
409
|
+
handleRef = undefined;
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("selects the Redis consent module, providing both slots, and not the memory one", () => {
|
|
413
|
+
const modules = modulesFor(resolveConfig(ALL_REDIS_ENV));
|
|
414
|
+
const names = modules.map((m) => m.name);
|
|
415
|
+
expect(names).toContain("redis-consent-store");
|
|
416
|
+
expect(names).not.toContain("core-consent-store-memory");
|
|
417
|
+
// Exactly one provider for each slot: the consent step refuses a
|
|
418
|
+
// composition with one and not the other, and two would collide.
|
|
419
|
+
for (const slot of ["consentStore", "pendingConsentStore"]) {
|
|
420
|
+
const providers = modules.filter((m) => Object.keys(m.provides ?? {}).includes(slot));
|
|
421
|
+
expect(
|
|
422
|
+
providers.map((m) => m.name),
|
|
423
|
+
slot,
|
|
424
|
+
).toEqual(["redis-consent-store"]);
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("pulls the shared clients module in for the consent stores alone", () => {
|
|
429
|
+
// Every other store on memory, single replica: the consent module still
|
|
430
|
+
// needs its two client slots, which only the shared clients module
|
|
431
|
+
// provides — the #439 shape otherwise.
|
|
432
|
+
const config = resolveConfig({
|
|
433
|
+
...ALL_REDIS_ENV,
|
|
434
|
+
DEPLOYMENT_MODE: "single",
|
|
435
|
+
USER_SESSION_STORES_ADAPTER: "memory",
|
|
436
|
+
OAUTH_CODE_ADAPTER: "memory",
|
|
437
|
+
RATE_LIMITER_ADAPTER: "memory",
|
|
438
|
+
ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
|
|
439
|
+
REPLAY_SEEN_SET_ADAPTER: "memory",
|
|
440
|
+
FEDERATION_TOKEN_STORE_TYPE: "memory",
|
|
441
|
+
});
|
|
442
|
+
const names = buildModules(config, {
|
|
443
|
+
keyStoreModule: testKeyStoreModule,
|
|
444
|
+
repositoriesModule: testRepositoriesModule,
|
|
445
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
446
|
+
}).map((m) => m.name);
|
|
447
|
+
expect(names).toContain("redis-consent-store");
|
|
448
|
+
expect(names).toContain("standalone:redis-clients");
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("boots under DEPLOYMENT_MODE=multi with both slots resolved to the Redis adapters", async () => {
|
|
452
|
+
handleRef = await boot(resolveConfig(ALL_REDIS_ENV));
|
|
453
|
+
expect(handleRef.components.consentStore?.kind).toBe("redis");
|
|
454
|
+
expect(handleRef.components.pendingConsentStore?.kind).toBe("redis");
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
400
458
|
describe("#473: the Redis federation store's plaintext guard, booted from the shipped config", () => {
|
|
401
459
|
let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
|
|
402
460
|
let warnSpy: ReturnType<typeof vi.spyOn>;
|
|
@@ -324,6 +324,29 @@ describe("#512 — the shipped suite is green outside this repository", () => {
|
|
|
324
324
|
).toBe(false);
|
|
325
325
|
}
|
|
326
326
|
});
|
|
327
|
+
|
|
328
|
+
it("ships every setup file vitest.config.mts loads, into the `test` image as well (#556)", async () => {
|
|
329
|
+
// vitest refuses to start when a `setupFiles` entry is missing, so a setup
|
|
330
|
+
// file the image never copied fails the whole of `make test`, not one test.
|
|
331
|
+
const configPath = fileURLToPath(new URL("../../vitest.config.mts", import.meta.url));
|
|
332
|
+
const { default: config } = (await import(configPath)) as {
|
|
333
|
+
default: { test?: { setupFiles?: string | readonly string[] } };
|
|
334
|
+
};
|
|
335
|
+
const setupFiles = [config.test?.setupFiles ?? []].flat();
|
|
336
|
+
expect(setupFiles, "the #556 supertest loopback guard is not wired in").not.toHaveLength(0);
|
|
337
|
+
|
|
338
|
+
const copied = copiedIntoStage(read("/Dockerfile"), "test");
|
|
339
|
+
const dockerignore = read("/.dockerignore");
|
|
340
|
+
for (const entry of setupFiles) {
|
|
341
|
+
const file = entry.replace(/^\.\//, "");
|
|
342
|
+
expect(existsSync(`${standaloneDir}${file}`), `${file} does not exist`).toBe(true);
|
|
343
|
+
expect(copied.has(file), `${file} is not copied into the test image`).toBe(true);
|
|
344
|
+
expect(
|
|
345
|
+
dockerignoreExcludes(dockerignore, file),
|
|
346
|
+
`${file} is excluded from the build context`,
|
|
347
|
+
).toBe(false);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
327
350
|
});
|
|
328
351
|
|
|
329
352
|
/**
|
|
@@ -486,6 +486,7 @@ describe("standalone smoke test", () => {
|
|
|
486
486
|
userSessionStores: { adapter: "redis" as const },
|
|
487
487
|
rateLimiter: { adapter: "redis" as const },
|
|
488
488
|
accessTokenDenylist: { adapter: "redis" as const },
|
|
489
|
+
consentStore: { adapter: "redis" as const },
|
|
489
490
|
oauth: { ...config.oauth, code: { adapter: "redis" as const } },
|
|
490
491
|
};
|
|
491
492
|
// No `refreshTokenFamilyModules` override: the default is the Redis
|
|
@@ -0,0 +1,125 @@
|
|
|
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
|
+
* #556 — the server `request(app)` starts listens on the address supertest
|
|
19
|
+
* dials, in this project's own test run.
|
|
20
|
+
*
|
|
21
|
+
* Unpatched, supertest starts its server with `app.listen(0)` — the
|
|
22
|
+
* dual-stack wildcard `[::]:P` — and sends the request to `127.0.0.1:P`. On
|
|
23
|
+
* macOS the kernel can hand out a `P` another process already holds as
|
|
24
|
+
* `127.0.0.1:P`; the request then reaches that process, and if it never
|
|
25
|
+
* answers the test hangs until its timeout. `vitest.supertest-loopback.mts`
|
|
26
|
+
* (wired in through `setupFiles` in `vitest.config.mts`) binds the server to
|
|
27
|
+
* `127.0.0.1` instead. These tests fail if that wiring is lost.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import http from "node:http";
|
|
31
|
+
import net, { type AddressInfo } from "node:net";
|
|
32
|
+
import express from "express";
|
|
33
|
+
import request from "supertest";
|
|
34
|
+
import { describe, expect, it } from "vitest";
|
|
35
|
+
|
|
36
|
+
function helloApp() {
|
|
37
|
+
const app = express();
|
|
38
|
+
app.get("/hello", (_req, res) => {
|
|
39
|
+
res.status(200).send("hello");
|
|
40
|
+
});
|
|
41
|
+
return app;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Resolves with the address `server` binds, whoever calls `listen`. */
|
|
45
|
+
function boundAddress(server: net.Server): Promise<AddressInfo> {
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
server.once("listening", () => resolve(server.address() as AddressInfo));
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("#556 — supertest's own server listens on the loopback address it dials", () => {
|
|
52
|
+
it("binds 127.0.0.1, not the dual-stack wildcard", async () => {
|
|
53
|
+
const server = http.createServer(helloApp());
|
|
54
|
+
const bound = boundAddress(server);
|
|
55
|
+
|
|
56
|
+
const res = await request(server).get("/hello");
|
|
57
|
+
|
|
58
|
+
expect(res.status).toBe(200);
|
|
59
|
+
expect(await bound).toMatchObject({ address: "127.0.0.1", family: "IPv4" });
|
|
60
|
+
// supertest still closes the server it started.
|
|
61
|
+
expect(server.listening).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("rejects promptly with the request's own error when the request cannot be built", async () => {
|
|
65
|
+
// Node refuses this header value (ERR_INVALID_CHAR) while supertest builds
|
|
66
|
+
// the request. With the loopback bind that happens after the bind settles,
|
|
67
|
+
// outside the promise the test awaits; the throw must still reject that
|
|
68
|
+
// promise, as it does unpatched, and not become an unhandled rejection
|
|
69
|
+
// that leaves the request hanging until the test timeout.
|
|
70
|
+
const server = http.createServer(helloApp());
|
|
71
|
+
|
|
72
|
+
await expect(request(server).get("/hello").set("x-test", "bad\nvalue")).rejects.toMatchObject({
|
|
73
|
+
code: "ERR_INVALID_CHAR",
|
|
74
|
+
});
|
|
75
|
+
// The request never went out, so nothing would close the server it started.
|
|
76
|
+
expect(server.listening).toBe(false);
|
|
77
|
+
}, 5_000);
|
|
78
|
+
|
|
79
|
+
it("hands the same error to an .end() callback", async () => {
|
|
80
|
+
const server = http.createServer(helloApp());
|
|
81
|
+
|
|
82
|
+
const err = await new Promise<unknown>((resolve) => {
|
|
83
|
+
request(server)
|
|
84
|
+
.get("/hello")
|
|
85
|
+
.set("x-test", "bad\nvalue")
|
|
86
|
+
.end((error) => resolve(error));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(err).toMatchObject({ code: "ERR_INVALID_CHAR" });
|
|
90
|
+
}, 5_000);
|
|
91
|
+
|
|
92
|
+
it("serves requests an agent sends together over its one server", async () => {
|
|
93
|
+
const agent = request.agent(helloApp());
|
|
94
|
+
|
|
95
|
+
const responses = await Promise.all([agent.get("/hello"), agent.get("/hello")]);
|
|
96
|
+
|
|
97
|
+
expect(responses.map((res) => res.status)).toEqual([200, 200]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("fails the request, instead of hanging on another socket, when 127.0.0.1:P is taken", async () => {
|
|
101
|
+
// The collision the kernel produces by chance, produced on purpose: a
|
|
102
|
+
// socket that accepts and never answers holds 127.0.0.1:P, and the
|
|
103
|
+
// server supertest starts is steered onto P.
|
|
104
|
+
const held: net.Socket[] = [];
|
|
105
|
+
const squatter = net.createServer((socket) => {
|
|
106
|
+
held.push(socket);
|
|
107
|
+
});
|
|
108
|
+
await new Promise<void>((resolve) => squatter.listen(0, "127.0.0.1", resolve));
|
|
109
|
+
const { port } = squatter.address() as AddressInfo;
|
|
110
|
+
|
|
111
|
+
const server = http.createServer(helloApp());
|
|
112
|
+
const listen = server.listen.bind(server) as (...args: unknown[]) => net.Server;
|
|
113
|
+
(server as { listen: (...args: unknown[]) => net.Server }).listen = (_port, ...rest) =>
|
|
114
|
+
listen(port, ...rest);
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
await expect(request(server).get("/hello")).rejects.toMatchObject({
|
|
118
|
+
code: "EADDRINUSE",
|
|
119
|
+
});
|
|
120
|
+
} finally {
|
|
121
|
+
for (const socket of held) socket.destroy();
|
|
122
|
+
squatter.close();
|
|
123
|
+
}
|
|
124
|
+
}, 5_000);
|
|
125
|
+
});
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
import {
|
|
35
35
|
redisAccessTokenDenylistModule,
|
|
36
36
|
redisCodeRepositoryModule,
|
|
37
|
+
redisConsentStoreModule,
|
|
37
38
|
redisFederationTokenStoreModuleFor,
|
|
38
39
|
redisRateLimiterModule,
|
|
39
40
|
redisRefreshTokenFamilyStoreModule,
|
|
@@ -102,6 +103,15 @@ export interface BuildModulesOverrides {
|
|
|
102
103
|
readonly auditSinkModule?: Module;
|
|
103
104
|
}
|
|
104
105
|
|
|
106
|
+
/**
|
|
107
|
+
* The access-token lifetime core's `reference.conf` ships on the deprecated
|
|
108
|
+
* `oauth.accessToken.expiresIn`. Not a default — nothing mints with it — only
|
|
109
|
+
* what an unmodified deployment carries there, so the deprecation line can tell
|
|
110
|
+
* an operator's override from the shipped value. Pinned against the real file
|
|
111
|
+
* by `access-token-lifetime-alias.test.mts`.
|
|
112
|
+
*/
|
|
113
|
+
const SHIPPED_ACCESS_TOKEN_EXPIRES_IN = 3600;
|
|
114
|
+
|
|
105
115
|
/**
|
|
106
116
|
* Compose the standalone v0.5.0 module list from `config`. Splitting this
|
|
107
117
|
* out of `app.mts` keeps the composition root testable: a smoke test can
|
|
@@ -156,6 +166,7 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
156
166
|
// first-party. `"none"` (the default) wires nothing — such clients are
|
|
157
167
|
// refused, as before — so a first-party-only deployment pays nothing and
|
|
158
168
|
// a multi-replica one is not handed a memory store it cannot run.
|
|
169
|
+
// `"redis"` (#561) is the store such a deployment selects.
|
|
159
170
|
const consentStoreAdapter = config.consentStore?.adapter ?? "none";
|
|
160
171
|
// #456: adapter switch for the federation token store. `"memory"` by
|
|
161
172
|
// default, the template's local-dev shape; `"redis"` mounts
|
|
@@ -184,6 +195,26 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
184
195
|
codeRepositoryAdapter = "memory";
|
|
185
196
|
}
|
|
186
197
|
|
|
198
|
+
// The access-token lifetime's deprecated alias: `oauth.accessToken.expiresIn`
|
|
199
|
+
// (OAUTH_ACCESS_TOKEN_EXPIRES_IN) is still read as the default while
|
|
200
|
+
// `defaultExpiresIn` is unset — `resolveAccessTokenLifetime` does that, and
|
|
201
|
+
// this only says so. Core's `reference.conf` keeps the shipped lifetime on
|
|
202
|
+
// the deprecated key, so "the alias supplied the default" is every
|
|
203
|
+
// deployment that set nothing; an override of it is the one case with
|
|
204
|
+
// something to move, the reading `legacyCodeType === "redis"` gives above.
|
|
205
|
+
// See CHANGELOG for the removal version.
|
|
206
|
+
const accessToken = config.oauth.accessToken;
|
|
207
|
+
if (
|
|
208
|
+
accessToken.defaultExpiresIn === undefined &&
|
|
209
|
+
accessToken.expiresIn !== SHIPPED_ACCESS_TOKEN_EXPIRES_IN
|
|
210
|
+
) {
|
|
211
|
+
console.warn(
|
|
212
|
+
"[buildModules] `oauth.accessToken.expiresIn` (OAUTH_ACCESS_TOKEN_EXPIRES_IN) is " +
|
|
213
|
+
"deprecated; use `oauth.accessToken.defaultExpiresIn` " +
|
|
214
|
+
"(OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN) instead — see CHANGELOG for the removal version.",
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
187
218
|
const refreshTokenFamilyModules: readonly Module[] = overrides.refreshTokenFamilyModules ?? [
|
|
188
219
|
redisRefreshTokenFamilyStoreModule,
|
|
189
220
|
];
|
|
@@ -202,6 +233,7 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
202
233
|
codeRepositoryAdapter === "redis" ||
|
|
203
234
|
accessTokenDenylistAdapter === "redis" ||
|
|
204
235
|
replaySeenSetAdapter === "redis" ||
|
|
236
|
+
consentStoreAdapter === "redis" ||
|
|
205
237
|
federationTokenStoreAdapter === "redis";
|
|
206
238
|
|
|
207
239
|
// The four user-session stores switch on `userSessionStores.adapter`; the
|
|
@@ -237,10 +269,17 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
237
269
|
? [redisAccessTokenDenylistModule]
|
|
238
270
|
: [memoryAccessTokenDenylistModule];
|
|
239
271
|
|
|
240
|
-
// #527: opt-in
|
|
241
|
-
//
|
|
272
|
+
// #527: opt-in. Each module provides both slots the consent step needs —
|
|
273
|
+
// the consent records and the requests parked while the page asks — so the
|
|
274
|
+
// two cannot be wired apart. The memory one declares itself replica-unsafe
|
|
275
|
+
// and `deployment.mode = "multi"` refuses it by name; the Redis one (#561)
|
|
276
|
+
// shares both over the ioredis socket.
|
|
242
277
|
const consentStoreModules: Module[] =
|
|
243
|
-
consentStoreAdapter === "
|
|
278
|
+
consentStoreAdapter === "redis"
|
|
279
|
+
? [redisConsentStoreModule]
|
|
280
|
+
: consentStoreAdapter === "memory"
|
|
281
|
+
? [memoryConsentStoreModule]
|
|
282
|
+
: [];
|
|
244
283
|
|
|
245
284
|
// #455 / #456: mutually-exclusive federation-token-store pair, the same
|
|
246
285
|
// shape as the session stores. One module per adapter, so the memory one
|
|
@@ -314,8 +353,8 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
314
353
|
// (single-instance dev). Always wired — see the switch above.
|
|
315
354
|
...accessTokenDenylistModules,
|
|
316
355
|
...replaySeenSetModules,
|
|
317
|
-
// Consent records for clients that are not first-party:
|
|
318
|
-
// nothing (#527).
|
|
356
|
+
// Consent records for clients that are not first-party: redis
|
|
357
|
+
// (multi-replica, #561), memory (single-instance), or nothing (#527).
|
|
319
358
|
...consentStoreModules,
|
|
320
359
|
// RT family store: redis by default (closes OR-1); override path
|
|
321
360
|
// swaps to `[memoryRefreshTokenFamilyStoreModule]` for unit tests.
|
|
@@ -361,11 +361,12 @@ export const storesModule: Module = defineModule({
|
|
|
361
361
|
|
|
362
362
|
/**
|
|
363
363
|
* Shared ioredis clients module — opens ONE long-lived ioredis connection
|
|
364
|
-
* per replica, wraps it via `makeIoredisClients()` (returns
|
|
364
|
+
* per replica, wraps it via `makeIoredisClients()` (returns 16 typed
|
|
365
365
|
* per-purpose clients), and exposes the slots consumed by the standalone's
|
|
366
366
|
* Redis-backed adapters (refresh-token-family + 4 user-session stores +
|
|
367
367
|
* subject-level revocation index + watermark + rate limiter + code
|
|
368
|
-
* repository + access-token denylist
|
|
368
|
+
* repository + access-token denylist + replay seen-set + federation token
|
|
369
|
+
* store + device-code store + the consent stores).
|
|
369
370
|
*
|
|
370
371
|
* Per F4 PR1 (D-2 v2) + Wave 5d unification: the previous design opened a
|
|
371
372
|
* separate ioredis socket per Redis-backed module (3+ sockets per replica).
|
|
@@ -504,6 +505,23 @@ export const standaloneRedisClientsModule: Module = defineModule({
|
|
|
504
505
|
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
505
506
|
.deviceCodeStoreClient;
|
|
506
507
|
},
|
|
508
|
+
// #561: the consent stores' clients, off the same shared socket.
|
|
509
|
+
// `redisConsentStoreModule` requires both — it provides the consent
|
|
510
|
+
// records and the parked requests together — and `buildModules` selects
|
|
511
|
+
// it under `consentStore.adapter = "redis"`.
|
|
512
|
+
consentStoreClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
|
|
513
|
+
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
514
|
+
.consentStoreClient;
|
|
515
|
+
},
|
|
516
|
+
pendingConsentStoreClient: async ({
|
|
517
|
+
config,
|
|
518
|
+
lifecycleRegistrar,
|
|
519
|
+
readinessRegistrar,
|
|
520
|
+
logger,
|
|
521
|
+
}) => {
|
|
522
|
+
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
523
|
+
.pendingConsentStoreClient;
|
|
524
|
+
},
|
|
507
525
|
// #456: the federation-token-store client, off the same shared socket.
|
|
508
526
|
// `redisFederationTokenStoreModule` requires it, and nothing provided
|
|
509
527
|
// it — which is why the Redis branch of the federation store could not
|
|
@@ -20,5 +20,11 @@ export default defineConfig({
|
|
|
20
20
|
// monorepo the same packages are symlinks to source and are inlined
|
|
21
21
|
// anyway; this makes the two layouts behave the same.
|
|
22
22
|
server: { deps: { inline: [/@o3co\/auth-provider-/] } },
|
|
23
|
+
// #556: the server `request(app)` starts listens on 127.0.0.1, the
|
|
24
|
+
// address supertest dials. Unpatched, macOS can give it a port another
|
|
25
|
+
// process holds on 127.0.0.1, and the request hangs on that process until
|
|
26
|
+
// the test timeout. Rationale and mechanics in the file itself. The
|
|
27
|
+
// Dockerfile `test` stage copies it; scaffold-assets.test.mts checks that.
|
|
28
|
+
setupFiles: ["./vitest.supertest-loopback.mts"],
|
|
23
29
|
},
|
|
24
30
|
});
|
|
@@ -0,0 +1,231 @@
|
|
|
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
|
+
* vitest setup file (#556): the server supertest starts listens on the
|
|
19
|
+
* address supertest dials.
|
|
20
|
+
*
|
|
21
|
+
* `request(app)` / `request.agent(app)` start a server with `app.listen(0)`
|
|
22
|
+
* and send the request to `127.0.0.1:<port>`. A hostless `listen` binds the
|
|
23
|
+
* dual-stack wildcard `[::]:P`, and on macOS (BSD `SO_REUSEADDR` semantics —
|
|
24
|
+
* libuv sets it on every TCP listener) the kernel will choose a `P` that
|
|
25
|
+
* another process already holds as `127.0.0.1:P`. The more specific socket
|
|
26
|
+
* wins, so the test's request is delivered to that process. When it accepts
|
|
27
|
+
* and never answers — an editor helper, in the case that was caught — the
|
|
28
|
+
* request hangs until the test timeout, in whichever supertest test drew the
|
|
29
|
+
* port, and passes on the next run. Linux refuses the conflicting bind
|
|
30
|
+
* (EADDRINUSE), so CI does not show it.
|
|
31
|
+
*
|
|
32
|
+
* Binding `127.0.0.1` explicitly removes both halves: the kernel will not
|
|
33
|
+
* give out a `127.0.0.1:P` someone else holds, and against a wildcard holder
|
|
34
|
+
* our specific socket is the one that receives. A specific-host `listen` is
|
|
35
|
+
* asynchronous (it goes through `dns.lookup`), and supertest reads the port
|
|
36
|
+
* synchronously in its constructor, so the URL is completed when the request
|
|
37
|
+
* is sent (`end`, which `then` and `expect(..., fn)` go through). A server the
|
|
38
|
+
* test started itself is never touched.
|
|
39
|
+
*
|
|
40
|
+
* supertest is loaded from the package that owns the running test file (the
|
|
41
|
+
* nearest `package.json` above it), so the patched class is the one that test
|
|
42
|
+
* imports, whatever directory vitest was started from. A package that does
|
|
43
|
+
* not declare supertest is left alone. Everything else this file cannot do —
|
|
44
|
+
* no test path, a declared supertest that does not load, a supertest whose
|
|
45
|
+
* internals no longer look like this — throws, so the guard is never silently
|
|
46
|
+
* off.
|
|
47
|
+
*
|
|
48
|
+
* This file is part of the project template and ships with every scaffold.
|
|
49
|
+
* The auth.provider workspace loads this same file for its packages (see
|
|
50
|
+
* `WORKSPACE_TEST_SETUP` in its `vitest.shared.mts`).
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
import { once } from "node:events";
|
|
54
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
55
|
+
import { createRequire } from "node:module";
|
|
56
|
+
import type { Server } from "node:net";
|
|
57
|
+
import { dirname, join } from "node:path";
|
|
58
|
+
import { Server as TlsServer } from "node:tls";
|
|
59
|
+
import { expect } from "vitest";
|
|
60
|
+
|
|
61
|
+
const LOOPBACK = "127.0.0.1";
|
|
62
|
+
const PATCHED = Symbol.for("o3co.auth.provider.supertest-loopback");
|
|
63
|
+
const PENDING = Symbol("o3co.auth.provider.supertest-loopback.pending");
|
|
64
|
+
|
|
65
|
+
type Pending = {
|
|
66
|
+
readonly server: Server;
|
|
67
|
+
readonly protocol: string;
|
|
68
|
+
readonly path: string;
|
|
69
|
+
/** Settles on `listening`, rejects on a listen `error`. */
|
|
70
|
+
readonly ready: Promise<unknown>;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
type TestInstance = {
|
|
74
|
+
url: string;
|
|
75
|
+
_server?: Server;
|
|
76
|
+
[PENDING]?: Pending;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
type TestPrototype = {
|
|
80
|
+
serverAddress(this: TestInstance, app: Server, path: string): string;
|
|
81
|
+
end(this: TestInstance, fn?: (err: unknown, res?: unknown) => void): TestInstance;
|
|
82
|
+
[PATCHED]?: true;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const DEPENDENCY_FIELDS = [
|
|
86
|
+
"dependencies",
|
|
87
|
+
"devDependencies",
|
|
88
|
+
"peerDependencies",
|
|
89
|
+
"optionalDependencies",
|
|
90
|
+
] as const;
|
|
91
|
+
|
|
92
|
+
/** The nearest `package.json` at or above `dir`. */
|
|
93
|
+
function owningManifest(dir: string): string | undefined {
|
|
94
|
+
for (let current = dir; ; current = dirname(current)) {
|
|
95
|
+
const candidate = join(current, "package.json");
|
|
96
|
+
if (existsSync(candidate)) return candidate;
|
|
97
|
+
if (dirname(current) === current) return undefined;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** supertest's `Test.prototype` as the running test file sees it, or `undefined` when its package has no supertest. */
|
|
102
|
+
function supertestFor(testPath: string): TestPrototype | undefined {
|
|
103
|
+
const manifestPath = owningManifest(dirname(testPath));
|
|
104
|
+
if (!manifestPath) {
|
|
105
|
+
throw new Error(`vitest.supertest-loopback.mts: no package.json above ${testPath}.`);
|
|
106
|
+
}
|
|
107
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Record<string, unknown>;
|
|
108
|
+
const declared = DEPENDENCY_FIELDS.some(
|
|
109
|
+
(field) => (manifest[field] as Record<string, unknown> | undefined)?.supertest !== undefined,
|
|
110
|
+
);
|
|
111
|
+
if (!declared) return undefined;
|
|
112
|
+
|
|
113
|
+
let supertest: { Test?: { prototype: TestPrototype } };
|
|
114
|
+
try {
|
|
115
|
+
supertest = createRequire(manifestPath)("supertest");
|
|
116
|
+
} catch (cause) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`vitest.supertest-loopback.mts: ${manifestPath} declares supertest, but it cannot be loaded from there; the #556 loopback guard would be off.`,
|
|
119
|
+
{ cause },
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
const proto = supertest.Test?.prototype;
|
|
123
|
+
if (typeof proto?.serverAddress !== "function" || typeof proto.end !== "function") {
|
|
124
|
+
throw new Error(
|
|
125
|
+
"vitest.supertest-loopback.mts: supertest no longer exposes Test.prototype.serverAddress/end; " +
|
|
126
|
+
"re-check the #556 loopback guard against the installed supertest before removing it.",
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return proto;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The bind in progress per server. A second request on a server whose bind has
|
|
134
|
+
* not finished (an agent's requests sent together share one server) waits on
|
|
135
|
+
* the same bind instead of calling `listen` twice — and, like unpatched
|
|
136
|
+
* supertest when it finds a server already listening, does not own it.
|
|
137
|
+
*/
|
|
138
|
+
const binding = new WeakMap<Server, Promise<unknown>>();
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A throw from the test's own callback, raised where it cannot propagate to
|
|
142
|
+
* the test: reported as an uncaught exception, which fails the run by name,
|
|
143
|
+
* instead of an unhandled rejection of a promise nobody holds.
|
|
144
|
+
*/
|
|
145
|
+
function rethrowOutsideThisChain(err: unknown): void {
|
|
146
|
+
process.nextTick(() => {
|
|
147
|
+
throw err;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function patch(proto: TestPrototype): void {
|
|
152
|
+
if (proto[PATCHED]) return;
|
|
153
|
+
const originalServerAddress = proto.serverAddress;
|
|
154
|
+
const originalEnd = proto.end;
|
|
155
|
+
|
|
156
|
+
proto.serverAddress = function serverAddress(app, path) {
|
|
157
|
+
// Already listening: the test owns the server and chose its address.
|
|
158
|
+
if (app.address()) return originalServerAddress.call(this, app, path);
|
|
159
|
+
|
|
160
|
+
const protocol = app instanceof TlsServer ? "https" : "http";
|
|
161
|
+
let ready = binding.get(app);
|
|
162
|
+
if (!ready) {
|
|
163
|
+
// supertest closes `_server` after the response, as it does for the
|
|
164
|
+
// server it would have started itself.
|
|
165
|
+
this._server = app.listen(0, LOOPBACK);
|
|
166
|
+
// Subscribed now, not in `end`: a listen error emitted before the test
|
|
167
|
+
// sends the request must still reach it, not crash the worker as an
|
|
168
|
+
// unhandled 'error' event.
|
|
169
|
+
const bound = once(app, "listening");
|
|
170
|
+
binding.set(app, bound);
|
|
171
|
+
bound.then(
|
|
172
|
+
() => binding.delete(app),
|
|
173
|
+
() => binding.delete(app),
|
|
174
|
+
);
|
|
175
|
+
ready = bound;
|
|
176
|
+
}
|
|
177
|
+
this[PENDING] = { server: app, protocol, path, ready };
|
|
178
|
+
// No port yet; `end` fills it in before anything is sent.
|
|
179
|
+
return `${protocol}://${LOOPBACK}${path}`;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
proto.end = function end(fn) {
|
|
183
|
+
const pending = this[PENDING];
|
|
184
|
+
if (!pending) return originalEnd.call(this, fn);
|
|
185
|
+
this[PENDING] = undefined;
|
|
186
|
+
|
|
187
|
+
const { server, protocol, path, ready } = pending;
|
|
188
|
+
let called = false;
|
|
189
|
+
const callback = (err: unknown, res?: unknown) => {
|
|
190
|
+
called = true;
|
|
191
|
+
fn?.(err, res);
|
|
192
|
+
};
|
|
193
|
+
// Unpatched, whatever `end` throws while building the request (a header
|
|
194
|
+
// value Node refuses, a body that will not serialize) reaches the caller
|
|
195
|
+
// at once — and `then` runs `end` inside a promise executor, so the
|
|
196
|
+
// awaited request rejects with it. Deferred, there is no caller left: the
|
|
197
|
+
// throw, like a failed bind, is handed to the request's callback, which
|
|
198
|
+
// is what rejects that promise. Nothing here may reject: a rejection of
|
|
199
|
+
// this chain is one nobody awaits.
|
|
200
|
+
const fail = (err: unknown) => {
|
|
201
|
+
if (called) return rethrowOutsideThisChain(err);
|
|
202
|
+
// The request never went out, so supertest will not close the server
|
|
203
|
+
// this request started.
|
|
204
|
+
if (this._server?.listening) this._server.close();
|
|
205
|
+
try {
|
|
206
|
+
callback(err);
|
|
207
|
+
} catch (thrown) {
|
|
208
|
+
rethrowOutsideThisChain(thrown);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
ready
|
|
212
|
+
.then(() => {
|
|
213
|
+
const { port } = server.address() as { port: number };
|
|
214
|
+
this.url = `${protocol}://${LOOPBACK}:${port}${path}`;
|
|
215
|
+
originalEnd.call(this, callback);
|
|
216
|
+
})
|
|
217
|
+
.catch(fail);
|
|
218
|
+
return this;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
proto[PATCHED] = true;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const { testPath } = expect.getState();
|
|
225
|
+
if (!testPath) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
"vitest.supertest-loopback.mts: vitest did not say which test file this setup runs for, so the #556 loopback guard cannot find supertest.",
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
const proto = supertestFor(testPath);
|
|
231
|
+
if (proto) patch(proto);
|
package/templates/versions.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
|
-
"@o3co/auth-provider-core": "0.
|
|
3
|
-
"@o3co/auth-provider-device-grant": "0.
|
|
4
|
-
"@o3co/auth-provider-dpop": "0.
|
|
5
|
-
"@o3co/auth-provider-mtls": "0.
|
|
6
|
-
"@o3co/auth-provider-federation-apple": "0.
|
|
7
|
-
"@o3co/auth-provider-federation-google": "0.
|
|
8
|
-
"@o3co/auth-provider-federation-github": "0.
|
|
9
|
-
"@o3co/auth-provider-federation-oidc": "0.
|
|
10
|
-
"@o3co/auth-provider-oauth": "0.
|
|
11
|
-
"@o3co/auth-provider-oauth-token-exchange": "0.
|
|
12
|
-
"@o3co/auth-provider-session": "0.
|
|
13
|
-
"@o3co/auth-provider-foundation": "0.
|
|
14
|
-
"@o3co/auth-provider-redis": "0.
|
|
15
|
-
"@o3co/auth-provider-webauthn": "0.
|
|
2
|
+
"@o3co/auth-provider-core": "0.14.0",
|
|
3
|
+
"@o3co/auth-provider-device-grant": "0.14.0",
|
|
4
|
+
"@o3co/auth-provider-dpop": "0.14.0",
|
|
5
|
+
"@o3co/auth-provider-mtls": "0.14.0",
|
|
6
|
+
"@o3co/auth-provider-federation-apple": "0.14.0",
|
|
7
|
+
"@o3co/auth-provider-federation-google": "0.14.0",
|
|
8
|
+
"@o3co/auth-provider-federation-github": "0.14.0",
|
|
9
|
+
"@o3co/auth-provider-federation-oidc": "0.14.0",
|
|
10
|
+
"@o3co/auth-provider-oauth": "0.14.0",
|
|
11
|
+
"@o3co/auth-provider-oauth-token-exchange": "0.14.0",
|
|
12
|
+
"@o3co/auth-provider-session": "0.14.0",
|
|
13
|
+
"@o3co/auth-provider-foundation": "0.14.0",
|
|
14
|
+
"@o3co/auth-provider-redis": "0.14.0",
|
|
15
|
+
"@o3co/auth-provider-webauthn": "0.14.0"
|
|
16
16
|
}
|