@o3co/create-auth-provider 0.17.0-rc.1 → 0.17.0-rc.2
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 +1 -1
- package/templates/standalone/.env.example +0 -3
- package/templates/standalone/README.ja.md +42 -18
- package/templates/standalone/README.md +120 -44
- package/templates/standalone/config/application.conf +40 -36
- package/templates/standalone/config/reference.conf +21 -2
- package/templates/standalone/docker-compose.production.yml +5 -2
- package/templates/standalone/package.json +1 -1
- package/templates/standalone/src/__tests__/adapters.test.mts +80 -19
- 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 +27 -6
- 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 +154 -0
- package/templates/standalone/src/__tests__/core-federations.test.mts +48 -25
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +1 -1
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +349 -54
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +15 -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 +0 -2
- package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +70 -25
- package/templates/standalone/src/__tests__/own-modules.test.mts +40 -11
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +3 -3
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +8 -4
- package/templates/standalone/src/__tests__/replica-safety.test.mts +127 -19
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +15 -20
- 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 +270 -0
- package/templates/standalone/src/__tests__/smoke.test.mts +19 -28
- package/templates/standalone/src/__tests__/store-credential.test.mts +8 -13
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +50 -34
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +242 -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 +43 -20
- package/templates/standalone/src/modules.mts +25 -213
- package/templates/standalone/src/rootRenames.mts +35 -18
- package/templates/standalone/src/sections.mts +1 -0
- 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
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
* - Every memory branch must be refused by name, and the all-Redis
|
|
23
23
|
* environment must boot. The replica-safety guard reads each module's
|
|
24
24
|
* manifest declaration, which covers the template's own in-memory modules
|
|
25
|
-
* and express-session's store (`
|
|
26
|
-
*
|
|
25
|
+
* and express-session's store (`sessionStoreModule`, which declares from
|
|
26
|
+
* its own section).
|
|
27
27
|
* - `federationTokenStore.type = "redis"` mounts
|
|
28
28
|
* `redisFederationTokenStoreModule` off the shared ioredis socket.
|
|
29
29
|
* - The Redis federation store's `allow-plaintext` guard reads the
|
|
@@ -39,28 +39,28 @@
|
|
|
39
39
|
|
|
40
40
|
import { fileURLToPath } from "node:url";
|
|
41
41
|
import {
|
|
42
|
-
AppConfigSchema,
|
|
43
42
|
createApp,
|
|
44
43
|
createKeyStoreFactory,
|
|
45
44
|
defineModule,
|
|
46
45
|
InMemoryClientRepository,
|
|
47
46
|
InMemoryUserRepository,
|
|
47
|
+
type Module,
|
|
48
48
|
memoryRefreshTokenFamilyStoreModule,
|
|
49
49
|
registerBuiltinKeyStores,
|
|
50
50
|
replicaUnsafeReason,
|
|
51
51
|
} from "@o3co/auth-provider-core";
|
|
52
|
+
import { sessionStoreModule } from "@o3co/auth-provider-session";
|
|
52
53
|
import { standardSmtpMailSenderConfigForTests } from "@o3co/auth-provider-standard/testing";
|
|
53
54
|
import { parseFile } from "@o3co/ts.hocon";
|
|
54
|
-
import { validate } from "@o3co/ts.hocon/zod";
|
|
55
55
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
56
56
|
import { buildModules } from "../buildModules.mjs";
|
|
57
|
-
import { resolveConfigPaths
|
|
57
|
+
import { resolveConfigPaths } from "../configPath.mjs";
|
|
58
58
|
import { templateReference } from "../modules.mjs";
|
|
59
59
|
import {
|
|
60
|
+
type BothPhases,
|
|
61
|
+
bothPhasesOf,
|
|
60
62
|
capturedRenames,
|
|
61
63
|
libraryLayers,
|
|
62
|
-
rootSectionsOf,
|
|
63
|
-
sectionsCoreDoesNotDeclare,
|
|
64
64
|
} from "./library-references.fixture.mjs";
|
|
65
65
|
|
|
66
66
|
// The redis session-store builder, which the baseline selects, dynamically
|
|
@@ -152,6 +152,8 @@ const ALL_REDIS_ENV: Readonly<Record<string, string>> = {
|
|
|
152
152
|
REDIS_CLIENTS_URL: "redis://redis.test:6379",
|
|
153
153
|
ADAPTERS_USER_SESSION_STORES: "redis",
|
|
154
154
|
ADAPTERS_RATE_LIMITER: "redis",
|
|
155
|
+
// The login's attempt counter, shared: per process, `multi` refuses the login.
|
|
156
|
+
ADAPTERS_ATTEMPT_COUNTER: "redis",
|
|
155
157
|
ADAPTERS_CODE_REPOSITORY: "redis",
|
|
156
158
|
ADAPTERS_ACCESS_TOKEN_DENYLIST: "redis",
|
|
157
159
|
ADAPTERS_REPLAY_SEEN_SET: "redis",
|
|
@@ -162,20 +164,17 @@ const ALL_REDIS_ENV: Readonly<Record<string, string>> = {
|
|
|
162
164
|
ADAPTERS_CONSENT_STORE: "redis",
|
|
163
165
|
};
|
|
164
166
|
|
|
165
|
-
function resolveConfig(env: Record<string, string>):
|
|
167
|
+
function resolveConfig(env: Record<string, string>): BothPhases {
|
|
166
168
|
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
|
|
167
169
|
const layers = parseFile(envConfPath, { env })
|
|
168
170
|
.withFallback(parseFile(applicationConfPath, { env }))
|
|
169
171
|
.withFallback(parseFile(fileURLToPath(templateReference()), { env }))
|
|
170
172
|
.withFallback(libraryLayers(env));
|
|
171
173
|
return {
|
|
172
|
-
...
|
|
173
|
-
|
|
174
|
-
...validate(layers, AppConfigSchema),
|
|
175
|
-
// What the resolution captured of core's renamed variables, which the
|
|
176
|
-
// schema's parse drops.
|
|
174
|
+
...bothPhasesOf(layers, env),
|
|
175
|
+
// What the resolution captured of the renamed variables.
|
|
177
176
|
"renamed-variables": capturedRenames(env),
|
|
178
|
-
} as
|
|
177
|
+
} as BothPhases;
|
|
179
178
|
}
|
|
180
179
|
|
|
181
180
|
/** Drops a variable, so the HOCON default takes over. */
|
|
@@ -210,14 +209,21 @@ const testKeyStoreModule = defineModule({
|
|
|
210
209
|
},
|
|
211
210
|
});
|
|
212
211
|
|
|
213
|
-
const modulesFor = (config:
|
|
212
|
+
const modulesFor = (config: BothPhases, environment?: string) =>
|
|
214
213
|
buildModules(config, {
|
|
215
214
|
keyStoreModule: testKeyStoreModule,
|
|
216
215
|
repositoriesModule: testRepositoriesModule,
|
|
217
216
|
...(environment === undefined ? {} : { environment }),
|
|
218
217
|
});
|
|
219
218
|
|
|
220
|
-
|
|
219
|
+
/**
|
|
220
|
+
* `module`'s section in `config`, for a declaration made from it: each module
|
|
221
|
+
* here that has one sits at its name.
|
|
222
|
+
*/
|
|
223
|
+
const sectionOf = (config: BothPhases, module: Module): unknown =>
|
|
224
|
+
(config as unknown as Record<string, unknown>)[module.name];
|
|
225
|
+
|
|
226
|
+
const boot = (config: BothPhases, environment?: string) =>
|
|
221
227
|
createApp({
|
|
222
228
|
modules: modulesFor(config, environment),
|
|
223
229
|
bootstrapComponents: { config, pathResolver: (s) => s },
|
|
@@ -242,8 +248,8 @@ describe('the standalone\'s memory modules are refused under core.deployment.mod
|
|
|
242
248
|
["ADAPTERS_USER_SESSION_STORES", "standalone-in-memory-session-stores"],
|
|
243
249
|
["ADAPTERS_CODE_REPOSITORY", "standalone-in-memory-code-repository"],
|
|
244
250
|
["ADAPTERS_FEDERATION_TOKEN_STORE", "standalone-in-memory-federation-token-store"],
|
|
245
|
-
// express-session's own store. Not a module of this template
|
|
246
|
-
//
|
|
251
|
+
// express-session's own store. Not a module of this template: it
|
|
252
|
+
// declares from its own section, which boot parses.
|
|
247
253
|
["SESSION_STORE_STORAGE_TYPE", "session-store"],
|
|
248
254
|
// The consent store, wired only when the switch says so.
|
|
249
255
|
["ADAPTERS_CONSENT_STORE", "core-consent-store-memory"],
|
|
@@ -311,13 +317,115 @@ describe('the standalone\'s memory modules are refused under core.deployment.mod
|
|
|
311
317
|
it("boots the all-Redis environment, with nothing declaring replica-unsafe state", async () => {
|
|
312
318
|
const config = resolveConfig(ALL_REDIS_ENV);
|
|
313
319
|
for (const m of modulesFor(config)) {
|
|
314
|
-
expect(replicaUnsafeReason(m), m.name).toBeUndefined();
|
|
320
|
+
expect(replicaUnsafeReason(m, sectionOf(config, m)), m.name).toBeUndefined();
|
|
315
321
|
}
|
|
316
322
|
handleRef = await boot(config);
|
|
317
323
|
expect(handleRef).toBeDefined();
|
|
318
324
|
});
|
|
319
325
|
});
|
|
320
326
|
|
|
327
|
+
describe("express-session's store declares its replica safety from its own section", () => {
|
|
328
|
+
it("is the session package's sessionStoreModule, listed first", () => {
|
|
329
|
+
expect(modulesFor(resolveConfig(ALL_REDIS_ENV))[0]).toBe(sessionStoreModule);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
/** Each way the storage type is set, laid over the all-Redis environment. */
|
|
333
|
+
const STORAGE: ReadonlyArray<readonly [string, Record<string, string>]> = [
|
|
334
|
+
[
|
|
335
|
+
"SESSION_STORE_STORAGE_TYPE=memory",
|
|
336
|
+
{ ...ALL_REDIS_ENV, SESSION_STORE_STORAGE_TYPE: "memory" },
|
|
337
|
+
],
|
|
338
|
+
["SESSION_STORE_STORAGE_TYPE=redis", ALL_REDIS_ENV],
|
|
339
|
+
[
|
|
340
|
+
"no SESSION_STORE_STORAGE_TYPE, the reference's default",
|
|
341
|
+
without(ALL_REDIS_ENV, "SESSION_STORE_STORAGE_TYPE"),
|
|
342
|
+
],
|
|
343
|
+
[
|
|
344
|
+
"the variable it was renamed from, SESSION_STORAGE_TYPE=memory",
|
|
345
|
+
{ ...without(ALL_REDIS_ENV, "SESSION_STORE_STORAGE_TYPE"), SESSION_STORAGE_TYPE: "memory" },
|
|
346
|
+
],
|
|
347
|
+
];
|
|
348
|
+
const MODES: ReadonlyArray<
|
|
349
|
+
readonly [string, (env: Record<string, string>) => Record<string, string>]
|
|
350
|
+
> = [
|
|
351
|
+
["multi", (env) => ({ ...env, CORE_DEPLOYMENT_MODE: "multi" })],
|
|
352
|
+
["single", (env) => ({ ...env, CORE_DEPLOYMENT_MODE: "single" })],
|
|
353
|
+
["unset", (env) => without(env, "CORE_DEPLOYMENT_MODE")],
|
|
354
|
+
];
|
|
355
|
+
|
|
356
|
+
/** What a boot of `modules` settles as: the refusal, or the replica-safety warnings it logged. */
|
|
357
|
+
async function outcomeOf(config: BothPhases, modules: readonly Module[]): Promise<unknown> {
|
|
358
|
+
const warn = vi.fn();
|
|
359
|
+
const logger = {
|
|
360
|
+
warn,
|
|
361
|
+
info: vi.fn(),
|
|
362
|
+
error: vi.fn(),
|
|
363
|
+
debug: vi.fn(),
|
|
364
|
+
trace: vi.fn(),
|
|
365
|
+
fatal: vi.fn(),
|
|
366
|
+
child: vi.fn(),
|
|
367
|
+
};
|
|
368
|
+
try {
|
|
369
|
+
const handle = await createApp({
|
|
370
|
+
modules: [...modules],
|
|
371
|
+
bootstrapComponents: { config, logger, pathResolver: (s: string) => s } as never,
|
|
372
|
+
});
|
|
373
|
+
await handle.dispose();
|
|
374
|
+
} catch (err) {
|
|
375
|
+
const e = err as { reason?: string; message?: string; details?: unknown };
|
|
376
|
+
return { refused: { reason: e.reason, message: e.message, details: e.details } };
|
|
377
|
+
}
|
|
378
|
+
return {
|
|
379
|
+
warned: warn.mock.calls.filter(([, event]) => event === "replica_unsafe_adapters"),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
for (const [storage, env] of STORAGE) {
|
|
384
|
+
for (const [mode, withMode] of MODES) {
|
|
385
|
+
it(`${storage} under ${mode}`, async () => {
|
|
386
|
+
const config = resolveConfig(withMode(env));
|
|
387
|
+
const modules = modulesFor(config);
|
|
388
|
+
expect(modules[0]).toBe(sessionStoreModule);
|
|
389
|
+
const outcome = await outcomeOf(config, modules);
|
|
390
|
+
if (
|
|
391
|
+
storage === "SESSION_STORE_STORAGE_TYPE=redis" ||
|
|
392
|
+
storage === "no SESSION_STORE_STORAGE_TYPE, the reference's default"
|
|
393
|
+
) {
|
|
394
|
+
// A shared store: nothing is refused or warned about.
|
|
395
|
+
expect(outcome).toEqual({ warned: [] });
|
|
396
|
+
}
|
|
397
|
+
if (storage === "SESSION_STORE_STORAGE_TYPE=memory") {
|
|
398
|
+
const named = { modules: ["session-store"] };
|
|
399
|
+
expect(outcome).toEqual(
|
|
400
|
+
mode === "multi"
|
|
401
|
+
? {
|
|
402
|
+
refused: expect.objectContaining({
|
|
403
|
+
reason: "replica-unsafe-adapter",
|
|
404
|
+
message: expect.stringContaining(
|
|
405
|
+
"session-store: the express-session store forks per replica",
|
|
406
|
+
),
|
|
407
|
+
details: expect.objectContaining(named),
|
|
408
|
+
}),
|
|
409
|
+
}
|
|
410
|
+
: {
|
|
411
|
+
warned:
|
|
412
|
+
mode === "unset"
|
|
413
|
+
? [[expect.objectContaining(named), "replica_unsafe_adapters"]]
|
|
414
|
+
: [],
|
|
415
|
+
},
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
if (storage === "the variable it was renamed from, SESSION_STORAGE_TYPE=memory") {
|
|
419
|
+
// Refused as renamed before the replica-safety guard reads anything.
|
|
420
|
+
expect(outcome).toEqual({
|
|
421
|
+
refused: expect.objectContaining({ reason: "environment-variable-renamed" }),
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
|
|
321
429
|
describe('adapters.federationTokenStore = "redis" in the standalone', () => {
|
|
322
430
|
let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
|
|
323
431
|
|
|
@@ -24,16 +24,14 @@
|
|
|
24
24
|
|
|
25
25
|
import { existsSync, readFileSync } from "node:fs";
|
|
26
26
|
import { fileURLToPath } from "node:url";
|
|
27
|
-
import {
|
|
27
|
+
import { BootError } from "@o3co/auth-provider-core";
|
|
28
28
|
import { parseFile } from "@o3co/ts.hocon";
|
|
29
|
-
import { validate } from "@o3co/ts.hocon/zod";
|
|
30
29
|
import { describe, expect, it } from "vitest";
|
|
31
30
|
import { readAdapters } from "../adapters.mjs";
|
|
32
31
|
import { buildModules } from "../buildModules.mjs";
|
|
33
32
|
import { readOwnLayers, readSwitches, resolveConfigPaths } from "../configPath.mjs";
|
|
34
33
|
import { templateReference } from "../modules.mjs";
|
|
35
34
|
import { type Adapters, repositoriesSectionSchema } from "../sections.mjs";
|
|
36
|
-
import { createRecordingLogger } from "./all-modules-composition.fixture.mjs";
|
|
37
35
|
|
|
38
36
|
const standaloneDir = fileURLToPath(new URL("../..", import.meta.url));
|
|
39
37
|
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
@@ -64,18 +62,6 @@ function composeAppEnvironment(rel: string): Map<string, string | null> {
|
|
|
64
62
|
return env;
|
|
65
63
|
}
|
|
66
64
|
|
|
67
|
-
/** Resolve the shipped config layers under a given environment, as `src/app.mts` does. */
|
|
68
|
-
function resolveWith(env: Record<string, string>, configEnv = "production"): AppConfig {
|
|
69
|
-
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, configEnv);
|
|
70
|
-
return validate(
|
|
71
|
-
parseFile(envConfPath, { env })
|
|
72
|
-
.withFallback(parseFile(applicationConfPath, { env }))
|
|
73
|
-
.withFallback(parseFile(fileURLToPath(templateReference()), { env }))
|
|
74
|
-
.withFallback(parseFile(fileURLToPath(coreReference()), { env })),
|
|
75
|
-
AppConfigSchema,
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
65
|
/** The template's own layers under `env`, over its own reference, unparsed. */
|
|
80
66
|
function ownResolved(
|
|
81
67
|
env: Record<string, string>,
|
|
@@ -88,6 +74,14 @@ function ownResolved(
|
|
|
88
74
|
.toObject() as Record<string, unknown>;
|
|
89
75
|
}
|
|
90
76
|
|
|
77
|
+
/** `session-store.storage.type` as the template's own layers resolve it under `env`. */
|
|
78
|
+
function sessionStorageTypeWith(env: Record<string, string>): unknown {
|
|
79
|
+
const section = ownResolved(env)["session-store"] as
|
|
80
|
+
| { readonly storage?: { readonly type?: unknown } }
|
|
81
|
+
| undefined;
|
|
82
|
+
return section?.storage?.type;
|
|
83
|
+
}
|
|
84
|
+
|
|
91
85
|
/** The adapters phase one reads under `env`. */
|
|
92
86
|
function adaptersWith(env: Record<string, string>, configEnv = "production"): Adapters {
|
|
93
87
|
return readAdapters(ownResolved(env, configEnv), env);
|
|
@@ -330,7 +324,7 @@ describe("the compose files put a store and its lifetime-sibling on the same bac
|
|
|
330
324
|
// Resolved through the real config layers, not read off the file:
|
|
331
325
|
// what matters is the value the process ends up with, whether the
|
|
332
326
|
// compose stated it or `config/application.conf` did.
|
|
333
|
-
expect(
|
|
327
|
+
expect(sessionStorageTypeWith(env)).toBe("redis");
|
|
334
328
|
expect(adaptersWith(env).userSessionStores).toBe("redis");
|
|
335
329
|
});
|
|
336
330
|
}
|
|
@@ -341,7 +335,7 @@ describe("the compose files put a store and its lifetime-sibling on the same bac
|
|
|
341
335
|
// token store is deliberately absent: this template ships every
|
|
342
336
|
// federation disabled, so nothing writes to it, and turning it on needs
|
|
343
337
|
// an AES key the compose file must not invent.
|
|
344
|
-
expect(
|
|
338
|
+
expect(sessionStorageTypeWith(env)).toBe("redis");
|
|
345
339
|
expect(adaptersWith(env)).toMatchObject({
|
|
346
340
|
userSessionStores: "redis",
|
|
347
341
|
codeRepository: "redis",
|
|
@@ -375,12 +369,13 @@ describe("the production compose leaves MFA on, for the deployment to decide", (
|
|
|
375
369
|
|
|
376
370
|
let err: unknown;
|
|
377
371
|
try {
|
|
378
|
-
buildModules(switches, { environment: configEnv
|
|
372
|
+
buildModules(switches, { environment: configEnv });
|
|
379
373
|
} catch (caught) {
|
|
380
374
|
err = caught;
|
|
381
375
|
}
|
|
382
|
-
expect(err).toBeInstanceOf(
|
|
383
|
-
expect((err as
|
|
376
|
+
expect(err).toBeInstanceOf(BootError);
|
|
377
|
+
expect((err as BootError).reason).toBe("config-validation-failed");
|
|
378
|
+
expect((err as BootError).message).toContain("MFA_MODE=off");
|
|
384
379
|
});
|
|
385
380
|
});
|
|
386
381
|
|
|
@@ -0,0 +1,215 @@
|
|
|
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 template composes core's session lifecycle module: it serves relying
|
|
19
|
+
* parties, so boot holds it to a session-close notifier, which the oauth
|
|
20
|
+
* module contributes.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
type FederationTokenStore,
|
|
25
|
+
readVersionedSessionLifecycle,
|
|
26
|
+
type SessionLifecycle,
|
|
27
|
+
type SessionLifecycleStore,
|
|
28
|
+
type SubjectSessionIndex,
|
|
29
|
+
type UserSessionStore,
|
|
30
|
+
} from "@o3co/auth-provider-core";
|
|
31
|
+
import request from "supertest";
|
|
32
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
33
|
+
import {
|
|
34
|
+
ALICE,
|
|
35
|
+
basic,
|
|
36
|
+
compose,
|
|
37
|
+
cookiesOf,
|
|
38
|
+
federatedCallback,
|
|
39
|
+
ISSUER,
|
|
40
|
+
login,
|
|
41
|
+
WEB,
|
|
42
|
+
webTokens,
|
|
43
|
+
} from "./all-modules-composition.fixture.mjs";
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
vi.restoreAllMocks();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/** A JWT's claims, unverified. */
|
|
50
|
+
const claimsOf = (token: string): Record<string, unknown> =>
|
|
51
|
+
JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
|
|
52
|
+
|
|
53
|
+
describe("the template, which composes the session lifecycle module", () => {
|
|
54
|
+
it("boots, the oauth module's notifier contributed", async () => {
|
|
55
|
+
const { handle } = await compose();
|
|
56
|
+
try {
|
|
57
|
+
const lifecycle = handle.components.sessionLifecycle as SessionLifecycle | undefined;
|
|
58
|
+
expect(lifecycle).toBeDefined();
|
|
59
|
+
expect(handle.components.sessionCloseNotifierResolver?.get()).toBeDefined();
|
|
60
|
+
expect(await lifecycle?.close("no-such-session", "expiry")).toEqual({
|
|
61
|
+
outcome: "done",
|
|
62
|
+
rps: [],
|
|
63
|
+
federations: [],
|
|
64
|
+
});
|
|
65
|
+
} finally {
|
|
66
|
+
await handle.dispose();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("tells a relying party that joined, through the oauth module's notifier, when the session closes", async () => {
|
|
71
|
+
const { handle } = await compose({
|
|
72
|
+
extraClients: {
|
|
73
|
+
"rp-bc": {
|
|
74
|
+
tokenEndpointAuthMethod: "client_secret_basic",
|
|
75
|
+
clientSecret: "rp-bc-secret-long-enough",
|
|
76
|
+
allowedRedirectUris: ["https://rp-bc.test/cb"],
|
|
77
|
+
allowedScopes: ["openid"],
|
|
78
|
+
allowedGrantTypes: ["authorization_code"],
|
|
79
|
+
backchannelLogoutUri: "https://rp-bc.test/logout",
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
try {
|
|
84
|
+
const components = handle.components as Record<string, unknown>;
|
|
85
|
+
const lifecycle = components.sessionLifecycle as SessionLifecycle;
|
|
86
|
+
const sessions = components.userSessionStore as UserSessionStore;
|
|
87
|
+
const expiresAt = new Date(Date.now() + 3_600_000);
|
|
88
|
+
await sessions.create({
|
|
89
|
+
sid: "sid-bc",
|
|
90
|
+
sub: "u-bc",
|
|
91
|
+
authTime: new Date(),
|
|
92
|
+
expiresAt,
|
|
93
|
+
claims: {},
|
|
94
|
+
amr: ["pwd"],
|
|
95
|
+
authentication: undefined,
|
|
96
|
+
});
|
|
97
|
+
expect(await lifecycle.open("sid-bc", { sub: "u-bc", expiresAt })).toEqual({
|
|
98
|
+
outcome: "opened",
|
|
99
|
+
});
|
|
100
|
+
const rp = {
|
|
101
|
+
clientId: "rp-bc",
|
|
102
|
+
backchannelLogoutUri: "https://rp-bc.test/logout",
|
|
103
|
+
backchannelLogoutSessionRequired: true,
|
|
104
|
+
frontchannelLogoutUri: undefined,
|
|
105
|
+
frontchannelLogoutSessionRequired: undefined,
|
|
106
|
+
registeredAt: new Date(),
|
|
107
|
+
};
|
|
108
|
+
expect(await lifecycle.join("sid-bc", { rp, familyId: "family-bc" })).toEqual({
|
|
109
|
+
outcome: "joined",
|
|
110
|
+
});
|
|
111
|
+
const posted = vi
|
|
112
|
+
.spyOn(globalThis, "fetch")
|
|
113
|
+
.mockImplementation(async () => new Response(null, { status: 200 }));
|
|
114
|
+
expect((await lifecycle.close("sid-bc", "rp_logout")).outcome).toBe("done");
|
|
115
|
+
expect(posted).toHaveBeenCalledTimes(1);
|
|
116
|
+
const [url, init] = posted.mock.calls[0] as [string, RequestInit];
|
|
117
|
+
expect(url).toBe("https://rp-bc.test/logout");
|
|
118
|
+
const token = new URLSearchParams(String(init.body)).get("logout_token");
|
|
119
|
+
expect(claimsOf(token ?? "")).toMatchObject({
|
|
120
|
+
iss: ISSUER,
|
|
121
|
+
aud: "rp-bc",
|
|
122
|
+
sub: "u-bc",
|
|
123
|
+
sid: "sid-bc",
|
|
124
|
+
});
|
|
125
|
+
} finally {
|
|
126
|
+
await handle.dispose();
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("opens a password login's session in the lifecycle, active with nothing joined yet", async () => {
|
|
131
|
+
const { app, handle } = await compose();
|
|
132
|
+
try {
|
|
133
|
+
expect((await login(app)).res.status).toBe(200);
|
|
134
|
+
const components = handle.components as Record<string, unknown>;
|
|
135
|
+
const [sid] = await (components.subjectSessionIndex as SubjectSessionIndex).listSids(
|
|
136
|
+
ALICE.sub,
|
|
137
|
+
);
|
|
138
|
+
const store = components.sessionLifecycleStore as SessionLifecycleStore;
|
|
139
|
+
const record = readVersionedSessionLifecycle(await store.read(String(sid)));
|
|
140
|
+
expect(record?.value).toMatchObject({ sub: ALICE.sub, state: "active", participants: [] });
|
|
141
|
+
} finally {
|
|
142
|
+
await handle.dispose();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("joins a federated login's federation to the session's lifecycle record, beside its tokens, through the session module's federation routes", async () => {
|
|
147
|
+
const { app, handle, upstreams } = await compose();
|
|
148
|
+
try {
|
|
149
|
+
const callback = await (await federatedCallback(app, "oidc", upstreams.oidc))();
|
|
150
|
+
expect(callback.status).toBe(302);
|
|
151
|
+
expect(cookiesOf(callback)).not.toEqual([]);
|
|
152
|
+
const components = handle.components as Record<string, unknown>;
|
|
153
|
+
const [sid] = await (components.subjectSessionIndex as SubjectSessionIndex).listSids(
|
|
154
|
+
ALICE.sub,
|
|
155
|
+
);
|
|
156
|
+
const store = components.sessionLifecycleStore as SessionLifecycleStore;
|
|
157
|
+
const record = readVersionedSessionLifecycle(await store.read(String(sid)));
|
|
158
|
+
expect(record?.value).toMatchObject({ sub: ALICE.sub, state: "active" });
|
|
159
|
+
expect(record?.value.participants.map((p) => `${p.kind}:${p.id}`)).toEqual([
|
|
160
|
+
"federation:oidc",
|
|
161
|
+
]);
|
|
162
|
+
const tokens = components.federationTokenStore as FederationTokenStore;
|
|
163
|
+
expect(await tokens.get(String(sid), "oidc")).not.toBeNull();
|
|
164
|
+
} finally {
|
|
165
|
+
await handle.dispose();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("joins a code exchange's relying party and family to the session's lifecycle record", async () => {
|
|
170
|
+
const { app, handle } = await compose();
|
|
171
|
+
try {
|
|
172
|
+
const tokens = await webTokens(app);
|
|
173
|
+
const claims = claimsOf(tokens.refresh_token ?? "");
|
|
174
|
+
const sid = String(claims.sid);
|
|
175
|
+
const store = (handle.components as Record<string, unknown>)
|
|
176
|
+
.sessionLifecycleStore as SessionLifecycleStore;
|
|
177
|
+
const record = readVersionedSessionLifecycle(await store.read(sid));
|
|
178
|
+
expect(record?.value.state).toBe("active");
|
|
179
|
+
expect(record?.value.participants.map((p) => `${p.kind}:${p.id}`)).toEqual([
|
|
180
|
+
`rp:${WEB.id}`,
|
|
181
|
+
`family:${String(claims.family_id)}`,
|
|
182
|
+
]);
|
|
183
|
+
} finally {
|
|
184
|
+
await handle.dispose();
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("closes the session through the lifecycle at /oauth/logout: the record closed, the session and its refresh token gone", async () => {
|
|
189
|
+
const { app, handle } = await compose();
|
|
190
|
+
try {
|
|
191
|
+
const tokens = await webTokens(app);
|
|
192
|
+
const sid = String(claimsOf(tokens.refresh_token ?? "").sid);
|
|
193
|
+
const components = handle.components as Record<string, unknown>;
|
|
194
|
+
|
|
195
|
+
const res = await request(app)
|
|
196
|
+
.post("/oauth/logout")
|
|
197
|
+
.type("form")
|
|
198
|
+
.send({ id_token_hint: tokens.id_token });
|
|
199
|
+
|
|
200
|
+
expect(res.status).toBe(200);
|
|
201
|
+
expect(res.body).toEqual({ logged_out: true });
|
|
202
|
+
const store = components.sessionLifecycleStore as SessionLifecycleStore;
|
|
203
|
+
expect(readVersionedSessionLifecycle(await store.read(sid))?.value.state).toBe("closed");
|
|
204
|
+
expect(await (components.userSessionStore as UserSessionStore).get(sid)).toBeNull();
|
|
205
|
+
const refreshed = await request(app)
|
|
206
|
+
.post("/oauth/token")
|
|
207
|
+
.set("Authorization", basic(WEB))
|
|
208
|
+
.type("form")
|
|
209
|
+
.send({ grant_type: "refresh_token", refresh_token: tokens.refresh_token });
|
|
210
|
+
expect(refreshed.status).toBe(400);
|
|
211
|
+
} finally {
|
|
212
|
+
await handle.dispose();
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
});
|
|
@@ -90,17 +90,13 @@ const config: AppConfig & Record<string, unknown> = {
|
|
|
90
90
|
},
|
|
91
91
|
// The session requirements this composition expects, as the shipped
|
|
92
92
|
// `application.conf` does (ADR 2026-09-28-session-admission): none.
|
|
93
|
-
...coreConfigForTests({ federations: { google: { enabled: false } } }),
|
|
93
|
+
...coreConfigForTests({ federations: { google: { enabled: false, type: "google" } } }),
|
|
94
94
|
oauth: {
|
|
95
95
|
jwt: {
|
|
96
96
|
issuer: "https://auth.test",
|
|
97
97
|
},
|
|
98
|
-
accessToken: {
|
|
99
|
-
refreshToken: {
|
|
100
|
-
expiresIn: 86400,
|
|
101
|
-
unknownFamilyPolicy: "reject" as const,
|
|
102
|
-
legacyRtPolicy: "reject" as const,
|
|
103
|
-
},
|
|
98
|
+
accessToken: { defaultExpiresIn: 3600 },
|
|
99
|
+
refreshToken: { expiresIn: 86400 },
|
|
104
100
|
oidcMode: "oidc-required",
|
|
105
101
|
},
|
|
106
102
|
// The grant this whole test is about: it mints straight from an
|
|
@@ -0,0 +1,130 @@
|
|
|
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 template with core's session lifecycle module: `POST /session/logout`
|
|
19
|
+
* closes the session through it, so the browser's own logout revokes the
|
|
20
|
+
* session's refresh-token families and tells its relying parties, as
|
|
21
|
+
* `/oauth/logout` does.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
type RefreshTokenFamilyRevocation,
|
|
26
|
+
readVersionedSessionLifecycle,
|
|
27
|
+
type SessionLifecycle,
|
|
28
|
+
type SessionLifecycleStore,
|
|
29
|
+
type UserSessionStore,
|
|
30
|
+
} from "@o3co/auth-provider-core";
|
|
31
|
+
import request from "supertest";
|
|
32
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
33
|
+
import { authorize, codeFrom, compose, login, redeem } from "./all-modules-composition.fixture.mjs";
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
vi.restoreAllMocks();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/** A JWT's claims, unverified. */
|
|
40
|
+
const claimsOf = (token: string): Record<string, unknown> =>
|
|
41
|
+
JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
|
|
42
|
+
|
|
43
|
+
/** The CSRF token a login reissued, read off its double-submit cookie. */
|
|
44
|
+
const csrfTokenOf = (cookies: readonly string[]): string => {
|
|
45
|
+
const cookie = cookies.find((c) => /^[^=]*\.csrf=/.test(c));
|
|
46
|
+
if (cookie === undefined) throw new Error("the login reissued no CSRF cookie");
|
|
47
|
+
return decodeURIComponent(cookie.slice(cookie.indexOf("=") + 1).split(";")[0] ?? "");
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
describe("POST /session/logout with the session lifecycle module", () => {
|
|
51
|
+
it("revokes the session's families, tells its relying parties and closes its record", async () => {
|
|
52
|
+
const { app, handle } = await compose({
|
|
53
|
+
extraClients: {
|
|
54
|
+
"rp-bc": {
|
|
55
|
+
tokenEndpointAuthMethod: "client_secret_basic",
|
|
56
|
+
clientSecret: "rp-bc-secret-long-enough",
|
|
57
|
+
allowedRedirectUris: ["https://rp-bc.test/cb"],
|
|
58
|
+
allowedScopes: ["openid"],
|
|
59
|
+
allowedGrantTypes: ["authorization_code"],
|
|
60
|
+
backchannelLogoutUri: "https://rp-bc.test/logout",
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
try {
|
|
65
|
+
const components = handle.components as Record<string, unknown>;
|
|
66
|
+
const lifecycle = components.sessionLifecycle as SessionLifecycle;
|
|
67
|
+
const sessions = components.userSessionStore as UserSessionStore;
|
|
68
|
+
const families = components.refreshTokenFamilyRevocation as RefreshTokenFamilyRevocation;
|
|
69
|
+
const { cookies } = await login(app);
|
|
70
|
+
const exchanged = await redeem(app, codeFrom(await authorize(app, cookies)));
|
|
71
|
+
expect(exchanged.status).toBe(200);
|
|
72
|
+
const claims = claimsOf(exchanged.body.refresh_token as string);
|
|
73
|
+
const sid = String(claims.sid);
|
|
74
|
+
const familyId = String(claims.family_id);
|
|
75
|
+
expect(
|
|
76
|
+
await lifecycle.join(sid, {
|
|
77
|
+
rp: {
|
|
78
|
+
clientId: "rp-bc",
|
|
79
|
+
backchannelLogoutUri: "https://rp-bc.test/logout",
|
|
80
|
+
backchannelLogoutSessionRequired: true,
|
|
81
|
+
frontchannelLogoutUri: undefined,
|
|
82
|
+
frontchannelLogoutSessionRequired: undefined,
|
|
83
|
+
registeredAt: new Date(),
|
|
84
|
+
},
|
|
85
|
+
}),
|
|
86
|
+
).toEqual({ outcome: "joined" });
|
|
87
|
+
const posted = vi
|
|
88
|
+
.spyOn(globalThis, "fetch")
|
|
89
|
+
.mockImplementation(async () => new Response(null, { status: 200 }));
|
|
90
|
+
|
|
91
|
+
const csrfToken = csrfTokenOf(cookies);
|
|
92
|
+
const res = await request(app)
|
|
93
|
+
.post("/session/logout")
|
|
94
|
+
.set("Cookie", cookies)
|
|
95
|
+
.set("x-csrf-token", csrfToken);
|
|
96
|
+
|
|
97
|
+
expect(res.status).toBe(200);
|
|
98
|
+
expect(await families.isFamilyRevoked(familyId)).toBe(true);
|
|
99
|
+
expect(posted.mock.calls.map(([url]) => String(url))).toEqual(["https://rp-bc.test/logout"]);
|
|
100
|
+
expect(await sessions.get(sid)).toBeNull();
|
|
101
|
+
const store = components.sessionLifecycleStore as SessionLifecycleStore;
|
|
102
|
+
expect(readVersionedSessionLifecycle(await store.read(sid))?.value.state).toBe("closed");
|
|
103
|
+
} finally {
|
|
104
|
+
await handle.dispose();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("answers 503 when the close cannot commit, and the cookie still admits the session for a retry", async () => {
|
|
109
|
+
const { app, handle } = await compose();
|
|
110
|
+
try {
|
|
111
|
+
const { cookies } = await login(app);
|
|
112
|
+
const components = handle.components as Record<string, unknown>;
|
|
113
|
+
const store = components.sessionLifecycleStore as SessionLifecycleStore;
|
|
114
|
+
vi.spyOn(store, "beginClose").mockRejectedValue(new Error("lifecycle store down"));
|
|
115
|
+
|
|
116
|
+
const res = await request(app)
|
|
117
|
+
.post("/session/logout")
|
|
118
|
+
.set("Cookie", cookies)
|
|
119
|
+
.set("x-csrf-token", csrfTokenOf(cookies));
|
|
120
|
+
|
|
121
|
+
expect(res.status).toBe(503);
|
|
122
|
+
expect(res.body.error).toBe("temporarily_unavailable");
|
|
123
|
+
// The session is still live and its cookie still admits it.
|
|
124
|
+
const authorized = await authorize(app, cookies);
|
|
125
|
+
expect(codeFrom(authorized)).toEqual(expect.any(String));
|
|
126
|
+
} finally {
|
|
127
|
+
await handle.dispose();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
});
|