@o3co/create-auth-provider 0.17.0-rc.1 → 0.17.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +3 -1
- package/README.md +5 -2
- package/package.json +1 -1
- package/templates/standalone/.env.example +7 -5
- package/templates/standalone/README.ja.md +53 -25
- package/templates/standalone/README.md +140 -58
- package/templates/standalone/config/application.conf +41 -36
- package/templates/standalone/config/clients.yaml.example +8 -0
- package/templates/standalone/config/development.conf +2 -2
- package/templates/standalone/config/mailpit.conf +10 -5
- package/templates/standalone/config/reference.conf +30 -5
- package/templates/standalone/docker-compose.mailpit.yml +3 -0
- package/templates/standalone/docker-compose.production.yml +5 -2
- package/templates/standalone/package.json +1 -1
- package/templates/standalone/src/__tests__/adapters.test.mts +92 -25
- package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +112 -98
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +40 -11
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +86 -51
- package/templates/standalone/src/__tests__/audit-sink.test.mts +10 -9
- package/templates/standalone/src/__tests__/config-notices.test.mts +157 -0
- package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +48 -0
- package/templates/standalone/src/__tests__/core-federations.test.mts +57 -31
- package/templates/standalone/src/__tests__/core-sections.test.mts +7 -5
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +1 -1
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +434 -136
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +19 -21
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +9 -15
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +10 -4
- package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
- package/templates/standalone/src/__tests__/google-session.fixture.mts +60 -67
- package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
- package/templates/standalone/src/__tests__/library-references.fixture.mts +51 -46
- package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +81 -16
- package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +8 -4
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +149 -46
- package/templates/standalone/src/__tests__/own-modules.test.mts +78 -23
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +3 -3
- package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +214 -0
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +8 -4
- package/templates/standalone/src/__tests__/replica-safety.test.mts +131 -19
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +25 -23
- package/templates/standalone/src/__tests__/sections.test.mts +24 -0
- package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +3 -7
- package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
- package/templates/standalone/src/__tests__/session-requirements.test.mts +114 -31
- package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +274 -0
- package/templates/standalone/src/__tests__/smoke.test.mts +19 -28
- package/templates/standalone/src/__tests__/startup-log-lines.test.mts +148 -0
- package/templates/standalone/src/__tests__/store-credential.test.mts +8 -13
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +58 -38
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +243 -76
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +23 -31
- package/templates/standalone/src/adapters.mts +31 -18
- package/templates/standalone/src/app.mts +6 -5
- package/templates/standalone/src/bootRefusal.mts +117 -0
- package/templates/standalone/src/buildModules.mts +46 -96
- package/templates/standalone/src/configPath.mts +180 -153
- package/templates/standalone/src/mfaSwitch.mts +49 -27
- package/templates/standalone/src/modules.mts +37 -219
- package/templates/standalone/src/rootRenames.mts +38 -22
- package/templates/standalone/src/sections.mts +13 -2
- package/templates/standalone/tests/index.test.js +4 -3
- package/templates/versions.json +18 -18
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -134
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -202
- package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -144
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# Defaults of the standalone template's own modules (src/modules.mts), each in
|
|
2
2
|
# the section named after its module, and of its own `adapters` and
|
|
3
|
-
# `mfaMode`, each key with its variable, named after its path
|
|
3
|
+
# `mfaMode`, each key with its variable, named after its path, and the MFA
|
|
4
|
+
# routes' budget in each rate limiter's section (below `mfaMode`). Layered
|
|
4
5
|
# beneath application.conf and {env}.conf: set a deployment's own values
|
|
5
6
|
# there, not here. A value either file sets wins over
|
|
6
7
|
# the variable bound here, except for HTTP_PORT, HTTP_TRUST_PROXY,
|
|
@@ -129,11 +130,17 @@ key-store {
|
|
|
129
130
|
|
|
130
131
|
# `redis-clients`: the one ioredis socket per replica every Redis-backed store
|
|
131
132
|
# shares. More than one replica needs a shared Redis 7.2+ here; localhost
|
|
132
|
-
# inside a container is the container itself.
|
|
133
|
+
# inside a container is the container itself. The Redis stores that keep
|
|
134
|
+
# durable keys refuse to boot unless the server reports maxmemory-policy
|
|
135
|
+
# `noeviction`. `assumeNoEviction = true` asserts it for a server that will not
|
|
136
|
+
# report it (INFO and CONFIG refused to the connection's user); a policy the
|
|
137
|
+
# server does report always decides.
|
|
133
138
|
redis-clients {
|
|
134
139
|
url = "redis://localhost:6379"
|
|
135
140
|
url = ${?REDIS_CLIENTS_URL}
|
|
136
141
|
password = ${?REDIS_CLIENTS_PASSWORD}
|
|
142
|
+
assumeNoEviction = false
|
|
143
|
+
assumeNoEviction = ${?REDIS_CLIENTS_ASSUME_NO_EVICTION}
|
|
137
144
|
}
|
|
138
145
|
|
|
139
146
|
# `repositories`: the client registry, and the user repository's settings for
|
|
@@ -217,9 +224,16 @@ audit-sink {}
|
|
|
217
224
|
# replica, and `core.deployment.mode = "multi"` refuses each memory store by
|
|
218
225
|
# name. Each Redis store shares the one socket `redis-clients` opens.
|
|
219
226
|
adapters {
|
|
220
|
-
# The OAuth endpoints' and
|
|
227
|
+
# The rate-limit counters: the OAuth endpoints' and the MFA routes'.
|
|
221
228
|
rateLimiter = "memory"
|
|
222
229
|
rateLimiter = ${?ADAPTERS_RATE_LIMITER}
|
|
230
|
+
# The counter behind /session/login's own attempt limit
|
|
231
|
+
# (session.rateLimit.login), and device verification's
|
|
232
|
+
# (device-grant.rateLimit) when the device grant is added. `memory` wires
|
|
233
|
+
# none: each counts per process, which `core.deployment.mode = "multi"`
|
|
234
|
+
# refuses. `redis` needs a server whose maxmemory-policy is `noeviction`.
|
|
235
|
+
attemptCounter = "memory"
|
|
236
|
+
attemptCounter = ${?ADAPTERS_ATTEMPT_COUNTER}
|
|
223
237
|
# The four user-session stores and the subject-level revocation pair. They
|
|
224
238
|
# must be on Redis whenever express-session's store is, or a restart leaves
|
|
225
239
|
# every browser a session whose user session is gone.
|
|
@@ -294,14 +308,25 @@ adapters {
|
|
|
294
308
|
mfaMode = "required"
|
|
295
309
|
mfaMode = ${?MFA_MODE}
|
|
296
310
|
|
|
311
|
+
# The MFA routes' budget: the one setting this file writes in sections of
|
|
312
|
+
# modules other than the template's own. Every /session/mfa POST is limited
|
|
313
|
+
# per client address under the prefix `mfa` (`mfa:ip:<ip>`) by the wired
|
|
314
|
+
# limiter alone: the MFA package ships no budget of its own. Both limiters
|
|
315
|
+
# carry it, so switching `adapters.rateLimiter` keeps it; written here, beside
|
|
316
|
+
# each package's own defaults, the limiter not wired has it as a default
|
|
317
|
+
# rather than as a setting nothing reads. Without an entry the limiter's
|
|
318
|
+
# `defaultLimit` (60 per 60 s) applies. The MFA lock is separate and unchanged.
|
|
319
|
+
core-rate-limiter-memory.limits.mfa { limit = 60, windowSeconds = 300 }
|
|
320
|
+
redis-rate-limiter.limits.mfa { limit = 60, windowSeconds = 300 }
|
|
321
|
+
|
|
297
322
|
# The old paths of these sections refuse boot naming the new ones: `cors`,
|
|
298
323
|
# `oauth.jwt.signingKey`, `refreshTokenFamilyStore.redis`,
|
|
299
324
|
# `repositories.code` and `audit.sink`; the old adapter switches
|
|
300
325
|
# (`rateLimiter.adapter`, `oauth.code.adapter`, ...) are refused before the
|
|
301
326
|
# modules are chosen, and so are the variables renamed with them. What the
|
|
302
327
|
# resolution saw of each variable they renamed: each name null, then its value
|
|
303
|
-
# when set. Boot refuses an old name set
|
|
304
|
-
#
|
|
328
|
+
# when set. Boot refuses an old name set, whether or not its new name is set,
|
|
329
|
+
# and removes this section before it parses the configuration.
|
|
305
330
|
# Never write it in another layer.
|
|
306
331
|
renamed-variables {
|
|
307
332
|
LOG_LEVEL = null
|
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
#
|
|
5
5
|
# docker compose -f docker-compose.yml -f docker-compose.mailpit.yml up --build
|
|
6
6
|
#
|
|
7
|
+
# Write MFA_ENCRYPTION_KEY into .env first (openssl rand -base64 32): this run
|
|
8
|
+
# reads its MFA key from it, not the development sample key.
|
|
9
|
+
#
|
|
7
10
|
# Mailpit keeps every mail the provider sends and delivers none; read them at
|
|
8
11
|
# http://localhost:8025. Never use it in production, where
|
|
9
12
|
# STANDARD_SMTP_MAIL_SENDER_* name a real relay. Leave every
|
|
@@ -142,8 +142,8 @@ services:
|
|
|
142
142
|
SESSION_STORE_STORAGE_TYPE: redis
|
|
143
143
|
SESSION_STORE_STORAGE_REDIS_URL: redis://redis:6379
|
|
144
144
|
# The UserSession stores: the provider's own record of who is signed in,
|
|
145
|
-
#
|
|
146
|
-
#
|
|
145
|
+
# the subject index and revocation boundaries beside it, and the session
|
|
146
|
+
# lifecycle records. They MUST be on Redis whenever the express-session
|
|
147
147
|
# store above is, and this line is the fix for what happens when they
|
|
148
148
|
# are not.
|
|
149
149
|
#
|
|
@@ -161,6 +161,9 @@ services:
|
|
|
161
161
|
# The memory rate limiter is per-process and evadable under bucket
|
|
162
162
|
# exhaustion (see README) — production points it at Redis.
|
|
163
163
|
ADAPTERS_RATE_LIMITER: redis
|
|
164
|
+
# The counter behind the login's own attempt limit: on Redis, a restart
|
|
165
|
+
# does not reset it. Redis must keep `maxmemory-policy noeviction`.
|
|
166
|
+
ADAPTERS_ATTEMPT_COUNTER: redis
|
|
164
167
|
# The federation token store is deliberately left at its `memory`
|
|
165
168
|
# default, and that is coherent here rather than an oversight: this
|
|
166
169
|
# template ships every federation disabled, so nothing writes to the
|
|
@@ -28,7 +28,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
|
28
28
|
import { tmpdir } from "node:os";
|
|
29
29
|
import { join } from "node:path";
|
|
30
30
|
import { fileURLToPath } from "node:url";
|
|
31
|
-
import { defineModule } from "@o3co/auth-provider-core";
|
|
31
|
+
import { BootError, defineModule } from "@o3co/auth-provider-core";
|
|
32
32
|
import { afterAll, describe, expect, it } from "vitest";
|
|
33
33
|
import { z } from "zod";
|
|
34
34
|
import { buildModules } from "#/buildModules.mjs";
|
|
@@ -60,12 +60,13 @@ const MFA_OFF: Readonly<Record<string, string>> = { MFA_MODE: "off" };
|
|
|
60
60
|
const adaptersFrom = (env: Record<string, string> = {}, hocon?: string) =>
|
|
61
61
|
readSwitches(readOwnLayers(ownFiles(hocon), { env })).adapters;
|
|
62
62
|
|
|
63
|
-
/** What phase one refuses under `env` and an operator's `hocon`. */
|
|
64
|
-
function refusal(env: Record<string, string> = {}, hocon?: string):
|
|
63
|
+
/** What phase one refuses under `env` and an operator's `hocon`: a `BootError`. */
|
|
64
|
+
function refusal(env: Record<string, string> = {}, hocon?: string): BootError {
|
|
65
65
|
try {
|
|
66
66
|
adaptersFrom(env, hocon);
|
|
67
67
|
} catch (err) {
|
|
68
|
-
|
|
68
|
+
expect(err).toBeInstanceOf(BootError);
|
|
69
|
+
return err as BootError;
|
|
69
70
|
}
|
|
70
71
|
throw new Error("phase one read the adapters");
|
|
71
72
|
}
|
|
@@ -141,6 +142,7 @@ describe("the shipped selections", () => {
|
|
|
141
142
|
it("reads every selection from the template's reference.conf, at the values the template ships", () => {
|
|
142
143
|
expect(adaptersFrom()).toEqual({
|
|
143
144
|
rateLimiter: "memory",
|
|
145
|
+
attemptCounter: "memory",
|
|
144
146
|
userSessionStores: "memory",
|
|
145
147
|
accessTokenDenylist: "redis",
|
|
146
148
|
replaySeenSet: "redis",
|
|
@@ -164,14 +166,25 @@ describe("the shipped selections", () => {
|
|
|
164
166
|
},
|
|
165
167
|
);
|
|
166
168
|
|
|
169
|
+
it("reads adapters.attemptCounter from ADAPTERS_ATTEMPT_COUNTER", () => {
|
|
170
|
+
expect(adaptersFrom({ ADAPTERS_ATTEMPT_COUNTER: "redis" }).attemptCounter).toBe("redis");
|
|
171
|
+
expect(refusal({ ADAPTERS_ATTEMPT_COUNTER: "memcached" }).message).toMatch(
|
|
172
|
+
/adapters\.attemptCounter/,
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
167
176
|
it("reads a selection an operator writes in HOCON", () => {
|
|
168
177
|
expect(adaptersFrom({}, 'adapters.rateLimiter = "redis"\n').rateLimiter).toBe("redis");
|
|
169
178
|
});
|
|
170
179
|
|
|
171
180
|
it("refuses a value its schema does not know, naming the key", () => {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
);
|
|
181
|
+
const err = refusal({ ADAPTERS_RATE_LIMITER: "memcached" });
|
|
182
|
+
expect(err.message).toMatch(/adapters\.rateLimiter/);
|
|
183
|
+
expect(err.reason).toBe("config-validation-failed");
|
|
184
|
+
expect(err.details).toMatchObject({
|
|
185
|
+
issues: [{ path: ["adapters", "rateLimiter"] }],
|
|
186
|
+
modules: [{ module: "adapters", schemaPath: "adapters" }],
|
|
187
|
+
});
|
|
175
188
|
});
|
|
176
189
|
|
|
177
190
|
it("refuses the Store for the MFA transaction store, which holds verification state, and accepts it for the factor store", () => {
|
|
@@ -198,9 +211,22 @@ describe("a path a selection moved from, written in the operator's own layer", (
|
|
|
198
211
|
"adapters.%s: refused where it was, %s's old path, naming the new path and ADAPTERS_%s",
|
|
199
212
|
(key, name, oldPath, _oldName, value) => {
|
|
200
213
|
const err = refusal({}, `${oldPath} = "${value}"\n`);
|
|
201
|
-
expect(err).
|
|
202
|
-
|
|
214
|
+
expect(err.message).toContain(
|
|
215
|
+
`${oldPath} has moved to adapters.${key}; see the upgrade guide (docs/upgrading-from-v0.16.0.md).`,
|
|
216
|
+
);
|
|
203
217
|
expect(err.message).toContain(`ADAPTERS_${name}`);
|
|
218
|
+
expect(err.reason).toBe("config-path-relocated");
|
|
219
|
+
expect(err.details).toEqual({
|
|
220
|
+
reason: "config-path-relocated",
|
|
221
|
+
relocated: [
|
|
222
|
+
{
|
|
223
|
+
module: "adapters",
|
|
224
|
+
from: oldPath,
|
|
225
|
+
to: `adapters.${key}`,
|
|
226
|
+
environmentVariable: `ADAPTERS_${name}`,
|
|
227
|
+
},
|
|
228
|
+
],
|
|
229
|
+
});
|
|
204
230
|
},
|
|
205
231
|
);
|
|
206
232
|
|
|
@@ -212,6 +238,13 @@ describe("a path a selection moved from, written in the operator's own layer", (
|
|
|
212
238
|
for (const path of ["rateLimiter.adapter", "consentStore.adapter", "audit.sink.type"]) {
|
|
213
239
|
expect(err.message).toContain(path);
|
|
214
240
|
}
|
|
241
|
+
expect(err.details).toMatchObject({
|
|
242
|
+
relocated: [
|
|
243
|
+
{ from: "rateLimiter.adapter" },
|
|
244
|
+
{ from: "consentStore.adapter" },
|
|
245
|
+
{ from: "audit.sink.type" },
|
|
246
|
+
],
|
|
247
|
+
});
|
|
215
248
|
});
|
|
216
249
|
});
|
|
217
250
|
|
|
@@ -220,9 +253,22 @@ describe("a variable renamed with a selection", () => {
|
|
|
220
253
|
"adapters.%s: %s's old variable set alone is refused, naming ADAPTERS_%s",
|
|
221
254
|
(key, name, _oldPath, oldName, value) => {
|
|
222
255
|
const err = refusal({ [oldName]: value });
|
|
223
|
-
expect(err).
|
|
224
|
-
|
|
225
|
-
|
|
256
|
+
expect(err.message).toContain(
|
|
257
|
+
`${oldName} was renamed ADAPTERS_${name}, the variable adapters.${key} is bound to; see the upgrade guide (docs/upgrading-from-v0.16.0.md).`,
|
|
258
|
+
);
|
|
259
|
+
expect(err.reason).toBe("environment-variable-renamed");
|
|
260
|
+
expect(err.details).toEqual({
|
|
261
|
+
reason: "environment-variable-renamed",
|
|
262
|
+
renamed: [
|
|
263
|
+
{
|
|
264
|
+
module: "adapters",
|
|
265
|
+
from: oldName,
|
|
266
|
+
to: `ADAPTERS_${name}`,
|
|
267
|
+
path: `adapters.${key}`,
|
|
268
|
+
state: "unset",
|
|
269
|
+
},
|
|
270
|
+
],
|
|
271
|
+
});
|
|
226
272
|
},
|
|
227
273
|
);
|
|
228
274
|
|
|
@@ -233,13 +279,18 @@ describe("a variable renamed with a selection", () => {
|
|
|
233
279
|
expect(err.message).toContain(oldName);
|
|
234
280
|
expect(err.message).not.toContain("old-value-5e2d");
|
|
235
281
|
expect(err.message).not.toContain("new-value-c81a");
|
|
282
|
+
expect(err.details).toMatchObject({ renamed: [{ from: oldName, state: "different" }] });
|
|
283
|
+
expect(JSON.stringify(err.details)).not.toMatch(/old-value-5e2d|new-value-c81a/);
|
|
236
284
|
},
|
|
237
285
|
);
|
|
238
286
|
|
|
239
287
|
it.each(SELECTIONS)(
|
|
240
|
-
"adapters.%s: the old variable beside ADAPTERS_%s at the same value is
|
|
241
|
-
(
|
|
242
|
-
|
|
288
|
+
"adapters.%s: the old variable beside ADAPTERS_%s at the same value is refused all the same",
|
|
289
|
+
(_key, name, _oldPath, oldName, value) => {
|
|
290
|
+
const err = refusal({ [oldName]: value, [`ADAPTERS_${name}`]: value });
|
|
291
|
+
expect(err.reason).toBe("environment-variable-renamed");
|
|
292
|
+
expect(err.message).toContain(`unset ${oldName}`);
|
|
293
|
+
expect(err.details).toMatchObject({ renamed: [{ from: oldName, state: "different" }] });
|
|
243
294
|
},
|
|
244
295
|
);
|
|
245
296
|
});
|
|
@@ -277,6 +328,13 @@ describe("the modules phase one chooses by the selections", () => {
|
|
|
277
328
|
expect(modules).toContain(installed);
|
|
278
329
|
expect(modules).not.toContain(absent);
|
|
279
330
|
});
|
|
331
|
+
|
|
332
|
+
it("ADAPTERS_ATTEMPT_COUNTER=redis installs redis-attempt-counter over the shared Redis socket; memory installs no counter", () => {
|
|
333
|
+
const onRedis = names({ ADAPTERS_ATTEMPT_COUNTER: "redis" });
|
|
334
|
+
expect(onRedis).toContain("redis-attempt-counter");
|
|
335
|
+
expect(onRedis).toContain("redis-clients");
|
|
336
|
+
expect(names({ ADAPTERS_ATTEMPT_COUNTER: "memory" })).not.toContain("redis-attempt-counter");
|
|
337
|
+
});
|
|
280
338
|
});
|
|
281
339
|
|
|
282
340
|
describe("the federation-grant stores", () => {
|
|
@@ -335,24 +393,33 @@ describe("boot and the section", () => {
|
|
|
335
393
|
});
|
|
336
394
|
|
|
337
395
|
it.each([
|
|
338
|
-
["named adapters", "adapters"
|
|
339
|
-
["
|
|
340
|
-
] as const)("refuses a module %s: its section would never reach boot", (_label, name
|
|
396
|
+
["named adapters", "adapters"],
|
|
397
|
+
["named mfaMode", "mfaMode"],
|
|
398
|
+
] as const)("refuses a module %s: its section would never reach boot", (_label, name) => {
|
|
341
399
|
const own = readOwnLayers(ownFiles(), { env: MFA_OFF });
|
|
342
400
|
const switches = readSwitches(own);
|
|
343
401
|
const mine = defineModule({
|
|
344
402
|
name,
|
|
345
|
-
section: {
|
|
346
|
-
schema: z.object({}).passthrough().optional(),
|
|
347
|
-
...(at === undefined ? {} : { at }),
|
|
348
|
-
},
|
|
403
|
+
section: { schema: z.object({}).passthrough().optional() },
|
|
349
404
|
});
|
|
350
|
-
|
|
405
|
+
const run = () =>
|
|
351
406
|
resolveForBoot(
|
|
352
407
|
own,
|
|
353
408
|
[...buildModules(switches, { environment: "production" }), mine],
|
|
354
409
|
switches,
|
|
355
|
-
)
|
|
356
|
-
).toThrow(new RegExp(`"${name}".*
|
|
410
|
+
);
|
|
411
|
+
expect(run).toThrow(new RegExp(`"${name}".*composition root's own section`));
|
|
412
|
+
const err = (() => {
|
|
413
|
+
try {
|
|
414
|
+
run();
|
|
415
|
+
} catch (thrown) {
|
|
416
|
+
return thrown;
|
|
417
|
+
}
|
|
418
|
+
})();
|
|
419
|
+
expect(err).toBeInstanceOf(BootError);
|
|
420
|
+
expect(err).toMatchObject({
|
|
421
|
+
reason: "module-section-path-invalid",
|
|
422
|
+
details: { module: name, at: name },
|
|
423
|
+
});
|
|
357
424
|
});
|
|
358
425
|
});
|
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
* The oauth package's consumers of session admission read the session
|
|
19
|
+
* lifecycle's record the template's session stores provide: a session whose
|
|
20
|
+
* record is closing is refused at `/authorize`, the code exchange and the
|
|
21
|
+
* refresh grant, while its user session is still there.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { SessionLifecycleStore, UserSessionStore } from "@o3co/auth-provider-core";
|
|
25
|
+
import request from "supertest";
|
|
26
|
+
import { describe, expect, it } from "vitest";
|
|
27
|
+
import {
|
|
28
|
+
authorize,
|
|
29
|
+
basic,
|
|
30
|
+
codeFrom,
|
|
31
|
+
compose,
|
|
32
|
+
login,
|
|
33
|
+
redeem,
|
|
34
|
+
WEB,
|
|
35
|
+
} from "./all-modules-composition.fixture.mjs";
|
|
36
|
+
|
|
37
|
+
/** A JWT's claims, unverified. */
|
|
38
|
+
const claimsOf = (token: string): Record<string, unknown> =>
|
|
39
|
+
JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
|
|
40
|
+
|
|
41
|
+
describe("a session whose lifecycle record is closing", () => {
|
|
42
|
+
it("is refused at /authorize, the code exchange and the refresh grant", async () => {
|
|
43
|
+
const { app, handle } = await compose();
|
|
44
|
+
try {
|
|
45
|
+
const components = handle.components as Record<string, unknown>;
|
|
46
|
+
const store = components.sessionLifecycleStore as SessionLifecycleStore;
|
|
47
|
+
const sessions = components.userSessionStore as UserSessionStore;
|
|
48
|
+
const { cookies } = await login(app);
|
|
49
|
+
const tokens = (await redeem(app, codeFrom(await authorize(app, cookies)))).body as Record<
|
|
50
|
+
string,
|
|
51
|
+
string
|
|
52
|
+
>;
|
|
53
|
+
const sid = String(claimsOf(tokens.id_token ?? "").sid);
|
|
54
|
+
const pendingCode = codeFrom(await authorize(app, cookies));
|
|
55
|
+
|
|
56
|
+
// The session's close commits, its work pending: the user session stays.
|
|
57
|
+
const session = await sessions.get(sid);
|
|
58
|
+
if (session === null) throw new Error("the login wrote no session");
|
|
59
|
+
expect((await store.open(sid, session.sub, session.expiresAt)).outcome).toBe("opened");
|
|
60
|
+
const closing = await store.beginClose(sid, {
|
|
61
|
+
cause: "rp_logout",
|
|
62
|
+
steps: ["held_open"],
|
|
63
|
+
perParticipant: [],
|
|
64
|
+
retainMs: 0,
|
|
65
|
+
});
|
|
66
|
+
expect(closing.outcome).toBe("closing");
|
|
67
|
+
expect(await sessions.get(sid)).not.toBeNull();
|
|
68
|
+
|
|
69
|
+
const again = await authorize(app, cookies);
|
|
70
|
+
// Sent to log in again, with no code.
|
|
71
|
+
expect(again.status).toBe(302);
|
|
72
|
+
expect(String(again.headers.location)).not.toMatch(/[?&]code=/);
|
|
73
|
+
|
|
74
|
+
expect((await redeem(app, pendingCode)).body).toMatchObject({ error: "invalid_grant" });
|
|
75
|
+
|
|
76
|
+
const refreshed = await request(app)
|
|
77
|
+
.post("/oauth/token")
|
|
78
|
+
.set("Authorization", basic(WEB))
|
|
79
|
+
.type("form")
|
|
80
|
+
.send({ grant_type: "refresh_token", refresh_token: tokens.refresh_token });
|
|
81
|
+
expect(refreshed.status).toBe(400);
|
|
82
|
+
expect(refreshed.body).toMatchObject({ error: "invalid_grant" });
|
|
83
|
+
} finally {
|
|
84
|
+
await handle.dispose();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|