@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
|
@@ -16,8 +16,10 @@
|
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* A browser session with Google linked, on the standalone composed as a
|
|
19
|
-
* deployment does: `
|
|
20
|
-
* adapter
|
|
19
|
+
* deployment does: `oauthEndpointsModule`, the session module and the real Google
|
|
20
|
+
* adapter — the Google federation type `buildModules` lists, handed the
|
|
21
|
+
* shipped `core.federations.google` entry — booted through `createApp` under
|
|
22
|
+
* the shipped configuration.
|
|
21
23
|
*
|
|
22
24
|
* The two logout routes and `POST /oauth/federation/:name/token` live in
|
|
23
25
|
* `@o3co/auth-provider-oauth`, which depends on no adapter; only here can a
|
|
@@ -32,7 +34,6 @@
|
|
|
32
34
|
import { createHmac } from "node:crypto";
|
|
33
35
|
import { fileURLToPath } from "node:url";
|
|
34
36
|
import {
|
|
35
|
-
AppConfigSchema,
|
|
36
37
|
createApp,
|
|
37
38
|
createKeyStoreFactory,
|
|
38
39
|
defineModule,
|
|
@@ -42,26 +43,22 @@ import {
|
|
|
42
43
|
InMemoryUserRepository,
|
|
43
44
|
memoryRefreshTokenFamilyStoreModule,
|
|
44
45
|
registerBuiltinKeyStores,
|
|
45
|
-
type
|
|
46
|
+
type SessionLifecycle,
|
|
46
47
|
type UserSessionStore,
|
|
47
48
|
} from "@o3co/auth-provider-core";
|
|
48
|
-
import {
|
|
49
|
-
type GoogleProviderConfig,
|
|
50
|
-
googleFederationModule,
|
|
51
|
-
} from "@o3co/auth-provider-federation-google";
|
|
49
|
+
import { googleFederationTypeModule } from "@o3co/auth-provider-federation-google";
|
|
52
50
|
import { parseFile } from "@o3co/ts.hocon";
|
|
53
|
-
import { validate } from "@o3co/ts.hocon/zod";
|
|
54
51
|
import express from "express";
|
|
55
52
|
import request from "supertest";
|
|
56
53
|
import { expect } from "vitest";
|
|
57
54
|
import { buildModules } from "#/buildModules.mjs";
|
|
58
|
-
import { resolveConfigPaths
|
|
55
|
+
import { resolveConfigPaths } from "#/configPath.mjs";
|
|
59
56
|
import { templateReference } from "../modules.mjs";
|
|
60
57
|
import {
|
|
58
|
+
type BothPhases,
|
|
59
|
+
bothPhasesOf,
|
|
61
60
|
capturedRenames,
|
|
62
61
|
libraryLayers,
|
|
63
|
-
rootSectionsOf,
|
|
64
|
-
sectionsCoreDoesNotDeclare,
|
|
65
62
|
} from "./library-references.fixture.mjs";
|
|
66
63
|
|
|
67
64
|
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
@@ -106,40 +103,50 @@ const ENV: Readonly<Record<string, string>> = {
|
|
|
106
103
|
};
|
|
107
104
|
|
|
108
105
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* `
|
|
106
|
+
* Google switched on the way an operator does, through its environment
|
|
107
|
+
* variables, and handled by the Google federation type the template lists.
|
|
108
|
+
* `"shipped"` is that alone. Otherwise, beside it: `endSessionEndpoint`, a key
|
|
109
|
+
* of the entry with no environment form, written into the entry as an
|
|
110
|
+
* operator's own layer would; and `fetch`, the one the type module is built
|
|
111
|
+
* with in place of the global one.
|
|
114
112
|
*/
|
|
115
113
|
export type GoogleWiring =
|
|
116
114
|
| "shipped"
|
|
117
|
-
|
|
|
115
|
+
| { readonly endSessionEndpoint?: string; readonly fetch?: typeof fetch };
|
|
116
|
+
|
|
117
|
+
const GOOGLE_ENV: Readonly<Record<string, string>> = {
|
|
118
|
+
CORE_FEDERATIONS_GOOGLE_ENABLED: "true",
|
|
119
|
+
CORE_FEDERATIONS_GOOGLE_CLIENT_ID: GOOGLE_CLIENT_ID,
|
|
120
|
+
CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET: GOOGLE_CLIENT_SECRET,
|
|
121
|
+
CORE_FEDERATIONS_GOOGLE_CALLBACK_URL: GOOGLE_CALLBACK,
|
|
122
|
+
};
|
|
118
123
|
|
|
119
|
-
function resolveConfig(google: GoogleWiring):
|
|
120
|
-
const env: Record<string, string> =
|
|
121
|
-
google === "shipped"
|
|
122
|
-
? {
|
|
123
|
-
...ENV,
|
|
124
|
-
CORE_FEDERATIONS_GOOGLE_ENABLED: "true",
|
|
125
|
-
CORE_FEDERATIONS_GOOGLE_CLIENT_ID: GOOGLE_CLIENT_ID,
|
|
126
|
-
CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET: GOOGLE_CLIENT_SECRET,
|
|
127
|
-
CORE_FEDERATIONS_GOOGLE_CALLBACK_URL: GOOGLE_CALLBACK,
|
|
128
|
-
}
|
|
129
|
-
: { ...ENV };
|
|
124
|
+
function resolveConfig(google: GoogleWiring): BothPhases {
|
|
125
|
+
const env: Record<string, string> = { ...ENV, ...GOOGLE_ENV };
|
|
130
126
|
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
|
|
131
127
|
const layers = parseFile(envConfPath, { env })
|
|
132
128
|
.withFallback(parseFile(applicationConfPath, { env }))
|
|
133
129
|
.withFallback(parseFile(fileURLToPath(templateReference()), { env }))
|
|
134
130
|
.withFallback(libraryLayers(env));
|
|
131
|
+
const config = bothPhasesOf(layers, env);
|
|
132
|
+
const endSessionEndpoint = google === "shipped" ? undefined : google.endSessionEndpoint;
|
|
133
|
+
const federations = config.core?.federations;
|
|
135
134
|
return {
|
|
136
|
-
...
|
|
137
|
-
...
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
135
|
+
...config,
|
|
136
|
+
...(endSessionEndpoint === undefined
|
|
137
|
+
? {}
|
|
138
|
+
: {
|
|
139
|
+
core: {
|
|
140
|
+
...config.core,
|
|
141
|
+
federations: {
|
|
142
|
+
...federations,
|
|
143
|
+
google: { ...federations?.google, endSessionEndpoint },
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
}),
|
|
147
|
+
// What the resolution captured of the renamed variables.
|
|
141
148
|
"renamed-variables": capturedRenames(env),
|
|
142
|
-
} as
|
|
149
|
+
} as BothPhases;
|
|
143
150
|
}
|
|
144
151
|
|
|
145
152
|
const testRepositoriesModule = defineModule({
|
|
@@ -227,15 +234,15 @@ export interface GoogleSession {
|
|
|
227
234
|
/** The access token the `session` grant minted for the session; it carries `azp`. */
|
|
228
235
|
readonly accessToken: string;
|
|
229
236
|
readonly userSessionStore: UserSessionStore;
|
|
230
|
-
readonly
|
|
237
|
+
readonly sessionLifecycle: SessionLifecycle;
|
|
231
238
|
readonly federationTokenStore: FederationTokenStore;
|
|
232
239
|
readonly dispose: () => Promise<void>;
|
|
233
240
|
}
|
|
234
241
|
|
|
235
242
|
/**
|
|
236
243
|
* Boots the deployment, signs alice in with her password, and links Google to
|
|
237
|
-
* her session as a federated sign-in would: the
|
|
238
|
-
*
|
|
244
|
+
* her session as a federated sign-in would: the token store holds `tokens`,
|
|
245
|
+
* and Google joins the session through its lifecycle.
|
|
239
246
|
*/
|
|
240
247
|
export async function signInLinkedToGoogle(options: {
|
|
241
248
|
readonly google: GoogleWiring;
|
|
@@ -243,38 +250,24 @@ export async function signInLinkedToGoogle(options: {
|
|
|
243
250
|
}): Promise<GoogleSession> {
|
|
244
251
|
const { google, tokens } = options;
|
|
245
252
|
const config = resolveConfig(google);
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
? []
|
|
249
|
-
: [
|
|
250
|
-
googleFederationModule,
|
|
251
|
-
defineModule({
|
|
252
|
-
name: "test:google-federation-config",
|
|
253
|
-
provides: {
|
|
254
|
-
googleFederationConfig: (): GoogleProviderConfig => ({
|
|
255
|
-
clientId: GOOGLE_CLIENT_ID,
|
|
256
|
-
clientSecret: GOOGLE_CLIENT_SECRET,
|
|
257
|
-
callbackURL: GOOGLE_CALLBACK,
|
|
258
|
-
...google,
|
|
259
|
-
}),
|
|
260
|
-
},
|
|
261
|
-
}),
|
|
262
|
-
];
|
|
253
|
+
const upstreamFetch = google === "shipped" ? undefined : google.fetch;
|
|
254
|
+
const googleType = googleFederationTypeModule().name;
|
|
263
255
|
const handle = await createApp({
|
|
264
|
-
modules:
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
256
|
+
modules: buildModules(config, {
|
|
257
|
+
keyStoreModule: testKeyStoreModule,
|
|
258
|
+
repositoriesModule: testRepositoriesModule,
|
|
259
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
260
|
+
}).map((module) =>
|
|
261
|
+
module.name === googleType && upstreamFetch !== undefined
|
|
262
|
+
? googleFederationTypeModule({ fetch: upstreamFetch })
|
|
263
|
+
: module,
|
|
264
|
+
),
|
|
272
265
|
bootstrapComponents: { config, pathResolver: (s) => s },
|
|
273
266
|
});
|
|
274
267
|
try {
|
|
275
268
|
const components = handle.components as {
|
|
276
269
|
userSessionStore: UserSessionStore;
|
|
277
|
-
|
|
270
|
+
sessionLifecycle: SessionLifecycle;
|
|
278
271
|
federationTokenStore: FederationTokenStore;
|
|
279
272
|
};
|
|
280
273
|
const app = express();
|
|
@@ -303,10 +296,10 @@ export async function signInLinkedToGoogle(options: {
|
|
|
303
296
|
const sid = claims.sid as string;
|
|
304
297
|
expect(typeof sid).toBe("string");
|
|
305
298
|
|
|
306
|
-
const session = await components.userSessionStore.get(sid);
|
|
307
|
-
if (session === null) throw new Error("fixture: the sign-in left no session");
|
|
308
|
-
await components.sessionFederationIndex.addFederation(sid, "google", session.expiresAt);
|
|
309
299
|
await components.federationTokenStore.attach(sid, "google", tokens);
|
|
300
|
+
expect(await components.sessionLifecycle.join(sid, { federation: "google" })).toEqual({
|
|
301
|
+
outcome: "joined",
|
|
302
|
+
});
|
|
310
303
|
return { app, sid, accessToken, ...components, dispose: () => handle.dispose() };
|
|
311
304
|
} catch (error) {
|
|
312
305
|
await handle.dispose();
|
|
@@ -24,6 +24,6 @@ describe("library reference.conf subpath resolution", () => {
|
|
|
24
24
|
const path = fileURLToPath(url);
|
|
25
25
|
expect(existsSync(path)).toBe(true);
|
|
26
26
|
const content = readFileSync(path, "utf-8");
|
|
27
|
-
expect(content).toMatch(
|
|
27
|
+
expect(content).toMatch(/^core\s*\{/m); // sanity: HOCON content, core's own section
|
|
28
28
|
});
|
|
29
29
|
});
|
|
@@ -18,21 +18,22 @@
|
|
|
18
18
|
* The libraries' layers beneath the template's own files, for a test that
|
|
19
19
|
* resolves its configuration by hand: the `reference.conf` of every package
|
|
20
20
|
* the template composes a module from, core's last, as `app.mts` layers them;
|
|
21
|
-
*
|
|
21
|
+
* the resolution handed to both phases at once; and what that resolution
|
|
22
|
+
* captures of the renamed variables.
|
|
22
23
|
*/
|
|
23
24
|
|
|
24
25
|
import { fileURLToPath } from "node:url";
|
|
25
26
|
import {
|
|
26
|
-
|
|
27
|
+
type AppConfig,
|
|
27
28
|
memoryRateLimiterModule,
|
|
28
29
|
moduleReferences,
|
|
29
30
|
} from "@o3co/auth-provider-core";
|
|
30
31
|
import { CORE_RELOCATIONS, renamedVariableCaptures } from "@o3co/auth-provider-core/testing";
|
|
31
32
|
import { federationGrantsModules } from "@o3co/auth-provider-federation-grants";
|
|
32
33
|
import {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
oauthAuthorizationGrantsModule,
|
|
35
|
+
oauthEndpointsModule,
|
|
36
|
+
oauthSessionGrantModule,
|
|
36
37
|
} from "@o3co/auth-provider-oauth";
|
|
37
38
|
import {
|
|
38
39
|
redisCodeRepositoryModule,
|
|
@@ -42,8 +43,8 @@ import {
|
|
|
42
43
|
} from "@o3co/auth-provider-redis";
|
|
43
44
|
import { sessionModule, sessionStoreModule } from "@o3co/auth-provider-session";
|
|
44
45
|
import { type Config, empty, parseFile } from "@o3co/ts.hocon";
|
|
45
|
-
import {
|
|
46
|
-
import {
|
|
46
|
+
import { readAdapters } from "../adapters.mjs";
|
|
47
|
+
import { readSwitches, type Switches } from "../configPath.mjs";
|
|
47
48
|
import {
|
|
48
49
|
httpModule,
|
|
49
50
|
inMemoryCodeRepositoryModule,
|
|
@@ -53,13 +54,13 @@ import {
|
|
|
53
54
|
standaloneRedisClientsModule,
|
|
54
55
|
templateReference,
|
|
55
56
|
} from "../modules.mjs";
|
|
56
|
-
import type
|
|
57
|
+
import { type Adapters, isPlainSection } from "../sections.mjs";
|
|
57
58
|
|
|
58
|
-
/** The oauth package's modules
|
|
59
|
+
/** The oauth package's modules: none reads the configuration it is handed. */
|
|
59
60
|
const OAUTH_MODULES = [
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
oauthEndpointsModule,
|
|
62
|
+
oauthSessionGrantModule,
|
|
63
|
+
oauthAuthorizationGrantsModule,
|
|
63
64
|
];
|
|
64
65
|
|
|
65
66
|
/** The modules the template composes that declare a renamed variable. */
|
|
@@ -97,23 +98,46 @@ export function libraryLayers(env: Readonly<Record<string, string>>): Config {
|
|
|
97
98
|
);
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
/** Phase one's switches laid over the configuration boot parses. */
|
|
102
|
+
export type BothPhases = Switches & AppConfig;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* `switches` laid over `resolved`, the configuration boot parses, as a test
|
|
106
|
+
* that hands one object to both phases does: the composition root's own keys
|
|
107
|
+
* beside the sections, and `federation-grants.enabled` written as phase one
|
|
108
|
+
* decided it into the section as resolved. Boot is so handed phase one's
|
|
109
|
+
* boolean, not the value as written, unlike `app.mts`; boot's refusal of a
|
|
110
|
+
* written value phase one installs the modules for is pinned in
|
|
111
|
+
* `two-phase-config.test.mts`.
|
|
112
|
+
*/
|
|
113
|
+
export function withSwitches(
|
|
114
|
+
resolved: Readonly<Record<string, unknown>>,
|
|
115
|
+
switches: Switches,
|
|
116
|
+
): BothPhases {
|
|
117
|
+
const grants = resolved["federation-grants"];
|
|
118
|
+
return {
|
|
119
|
+
...resolved,
|
|
120
|
+
...switches,
|
|
121
|
+
"federation-grants": {
|
|
122
|
+
...(isPlainSection(grants) ? grants : {}),
|
|
123
|
+
...switches["federation-grants"],
|
|
124
|
+
},
|
|
125
|
+
} as unknown as BothPhases;
|
|
126
|
+
}
|
|
127
|
+
|
|
100
128
|
/**
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
129
|
+
* A resolution — `layers`, the template's `config/reference.conf` among them,
|
|
130
|
+
* under `env` — as a test that resolves by hand hands it to both phases at
|
|
131
|
+
* once: the configuration as resolved and unparsed, which boot parses as
|
|
132
|
+
* `app.mts` hands it on, with what phase one reads of it (`readSwitches`)
|
|
133
|
+
* laid over it — the composition root's `adapters` and `mfaMode`, the Store
|
|
134
|
+
* transport settings, and `federation-grants.enabled` as phase one decides
|
|
135
|
+
* it.
|
|
106
136
|
*/
|
|
107
|
-
export function
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
([key]) =>
|
|
112
|
-
!Object.hasOwn(AppConfigSchema.shape, key) &&
|
|
113
|
-
key !== "renamed-variables" &&
|
|
114
|
-
key !== ADAPTERS_SECTION &&
|
|
115
|
-
key !== MFA_SWITCH,
|
|
116
|
-
),
|
|
137
|
+
export function bothPhasesOf(layers: Config, env: Readonly<Record<string, string>>): BothPhases {
|
|
138
|
+
return withSwitches(
|
|
139
|
+
layers.toObject() as Record<string, unknown>,
|
|
140
|
+
readSwitches({ config: layers, env }),
|
|
117
141
|
);
|
|
118
142
|
}
|
|
119
143
|
|
|
@@ -126,25 +150,6 @@ export function adaptersOf(layers: Config, env: Readonly<Record<string, string>>
|
|
|
126
150
|
return readAdapters(layers.toObject() as Record<string, unknown>, env);
|
|
127
151
|
}
|
|
128
152
|
|
|
129
|
-
/**
|
|
130
|
-
* What phase one reads from a resolution beside core's switches —
|
|
131
|
-
* `layers`, the template's `config/reference.conf` among them, under `env` —
|
|
132
|
-
* as `readSwitches` reads it: the composition root's `adapters` and
|
|
133
|
-
* `mfaMode`, and the Store transport settings.
|
|
134
|
-
*/
|
|
135
|
-
export function rootSectionsOf(
|
|
136
|
-
layers: Config,
|
|
137
|
-
env: Readonly<Record<string, string>>,
|
|
138
|
-
): { readonly adapters: Adapters; readonly mfaMode: MfaSwitch; readonly storeTransport: unknown } {
|
|
139
|
-
const raw = layers.toObject() as Record<string, unknown>;
|
|
140
|
-
const repositories = raw.repositories as { user?: { http?: unknown } } | undefined;
|
|
141
|
-
return {
|
|
142
|
-
adapters: readAdapters(raw, env),
|
|
143
|
-
mfaMode: readMfaSwitch(raw, env),
|
|
144
|
-
storeTransport: repositories?.user?.http,
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
|
|
148
153
|
/** The adapters the template ships, as its `config/reference.conf` sets them with no environment. */
|
|
149
154
|
export function shippedAdapters(): Adapters {
|
|
150
155
|
return adaptersOf(parseFile(fileURLToPath(templateReference()), { env: {} }), {});
|
|
@@ -0,0 +1,216 @@
|
|
|
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: a close whose
|
|
19
|
+
* relying-party notice fails stays pending, its user session still there,
|
|
20
|
+
* and from its commit no token reader treats the session as live —
|
|
21
|
+
* introspection, userinfo and the session grant all refuse it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { SessionLifecycle, UserSessionStore } from "@o3co/auth-provider-core";
|
|
25
|
+
import request from "supertest";
|
|
26
|
+
import { afterEach, describe, expect, it, vi } 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
|
+
afterEach(() => {
|
|
38
|
+
vi.restoreAllMocks();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** A JWT's claims, unverified. */
|
|
42
|
+
const claimsOf = (token: string): Record<string, unknown> =>
|
|
43
|
+
JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
|
|
44
|
+
|
|
45
|
+
describe("a session whose close is pending on a failed relying-party notice", () => {
|
|
46
|
+
it("is refused by introspection, userinfo and the session grant, its user session still there", async () => {
|
|
47
|
+
const { app, handle } = await compose({
|
|
48
|
+
extraClients: {
|
|
49
|
+
"rp-down": {
|
|
50
|
+
tokenEndpointAuthMethod: "client_secret_basic",
|
|
51
|
+
clientSecret: "rp-down-secret-long-enough",
|
|
52
|
+
allowedRedirectUris: ["https://rp-down.test/cb"],
|
|
53
|
+
allowedScopes: ["openid"],
|
|
54
|
+
allowedGrantTypes: ["authorization_code"],
|
|
55
|
+
backchannelLogoutUri: "https://rp-down.test/logout",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
try {
|
|
60
|
+
const components = handle.components as Record<string, unknown>;
|
|
61
|
+
const lifecycle = components.sessionLifecycle as SessionLifecycle;
|
|
62
|
+
const sessions = components.userSessionStore as UserSessionStore;
|
|
63
|
+
const { cookies } = await login(app);
|
|
64
|
+
const sessionGrant = () =>
|
|
65
|
+
request(app)
|
|
66
|
+
.post("/oauth/token")
|
|
67
|
+
.set("Authorization", basic(WEB))
|
|
68
|
+
.set("Cookie", cookies)
|
|
69
|
+
.type("form")
|
|
70
|
+
.send({ grant_type: "session", scope: "openid profile" });
|
|
71
|
+
const issued = await sessionGrant();
|
|
72
|
+
expect(issued.status).toBe(200);
|
|
73
|
+
const accessToken = issued.body.access_token as string;
|
|
74
|
+
const sid = String(claimsOf(accessToken).sid);
|
|
75
|
+
const introspect = () =>
|
|
76
|
+
request(app)
|
|
77
|
+
.post("/oauth/introspect")
|
|
78
|
+
.set("Authorization", basic(WEB))
|
|
79
|
+
.type("form")
|
|
80
|
+
.send({ token: accessToken });
|
|
81
|
+
const userinfo = () =>
|
|
82
|
+
request(app).get("/oauth/userinfo").set("Authorization", `Bearer ${accessToken}`);
|
|
83
|
+
expect((await introspect()).body.active).toBe(true);
|
|
84
|
+
expect((await userinfo()).status).toBe(200);
|
|
85
|
+
|
|
86
|
+
// A relying party joins with its family; its back-channel endpoint is
|
|
87
|
+
// down, so the close commits with its notice pending.
|
|
88
|
+
expect(
|
|
89
|
+
await lifecycle.join(sid, {
|
|
90
|
+
rp: {
|
|
91
|
+
clientId: "rp-down",
|
|
92
|
+
backchannelLogoutUri: "https://rp-down.test/logout",
|
|
93
|
+
backchannelLogoutSessionRequired: true,
|
|
94
|
+
frontchannelLogoutUri: undefined,
|
|
95
|
+
frontchannelLogoutSessionRequired: undefined,
|
|
96
|
+
registeredAt: new Date(),
|
|
97
|
+
},
|
|
98
|
+
familyId: "family-down",
|
|
99
|
+
}),
|
|
100
|
+
).toEqual({ outcome: "joined" });
|
|
101
|
+
const posted = vi
|
|
102
|
+
.spyOn(globalThis, "fetch")
|
|
103
|
+
.mockImplementation(async () => new Response(null, { status: 503 }));
|
|
104
|
+
expect((await lifecycle.close(sid, "rp_logout")).outcome).toBe("pending");
|
|
105
|
+
expect(posted).toHaveBeenCalled();
|
|
106
|
+
expect(await sessions.get(sid)).not.toBeNull();
|
|
107
|
+
|
|
108
|
+
const afterIntrospect = await introspect();
|
|
109
|
+
expect(afterIntrospect.status).toBe(200);
|
|
110
|
+
expect(afterIntrospect.body).toEqual({ active: false });
|
|
111
|
+
|
|
112
|
+
const afterUserinfo = await userinfo();
|
|
113
|
+
expect(afterUserinfo.status).toBe(401);
|
|
114
|
+
expect(afterUserinfo.body.error_description).toBe("session_invalid");
|
|
115
|
+
|
|
116
|
+
const again = await sessionGrant();
|
|
117
|
+
expect(again.status).toBe(400);
|
|
118
|
+
expect(again.body).toMatchObject({
|
|
119
|
+
error: "invalid_grant",
|
|
120
|
+
error_description: "session_invalid",
|
|
121
|
+
});
|
|
122
|
+
} finally {
|
|
123
|
+
await handle.dispose();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("an RP-initiated logout whose notice fails answers success, and its session is refused by introspection, userinfo and the session grant", async () => {
|
|
128
|
+
const { app, handle } = await compose({
|
|
129
|
+
extraClients: {
|
|
130
|
+
"rp-down": {
|
|
131
|
+
tokenEndpointAuthMethod: "client_secret_basic",
|
|
132
|
+
clientSecret: "rp-down-secret-long-enough",
|
|
133
|
+
allowedRedirectUris: ["https://rp-down.test/cb"],
|
|
134
|
+
allowedScopes: ["openid"],
|
|
135
|
+
allowedGrantTypes: ["authorization_code"],
|
|
136
|
+
backchannelLogoutUri: "https://rp-down.test/logout",
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
try {
|
|
141
|
+
const components = handle.components as Record<string, unknown>;
|
|
142
|
+
const lifecycle = components.sessionLifecycle as SessionLifecycle;
|
|
143
|
+
const sessions = components.userSessionStore as UserSessionStore;
|
|
144
|
+
const { cookies } = await login(app);
|
|
145
|
+
// The code exchange joins the session; its id_token is the logout's hint.
|
|
146
|
+
const exchanged = await redeem(app, codeFrom(await authorize(app, cookies)));
|
|
147
|
+
expect(exchanged.status).toBe(200);
|
|
148
|
+
const idToken = exchanged.body.id_token as string;
|
|
149
|
+
const sid = String(claimsOf(idToken).sid);
|
|
150
|
+
const sessionGrant = () =>
|
|
151
|
+
request(app)
|
|
152
|
+
.post("/oauth/token")
|
|
153
|
+
.set("Authorization", basic(WEB))
|
|
154
|
+
.set("Cookie", cookies)
|
|
155
|
+
.type("form")
|
|
156
|
+
.send({ grant_type: "session", scope: "openid profile" });
|
|
157
|
+
const issued = await sessionGrant();
|
|
158
|
+
expect(issued.status).toBe(200);
|
|
159
|
+
// A session-grant token carries the session and no family.
|
|
160
|
+
const accessToken = issued.body.access_token as string;
|
|
161
|
+
expect(claimsOf(accessToken)).toMatchObject({ sid });
|
|
162
|
+
expect(claimsOf(accessToken).family_id).toBeUndefined();
|
|
163
|
+
|
|
164
|
+
// A relying party whose back-channel endpoint is down joins the session.
|
|
165
|
+
expect(
|
|
166
|
+
await lifecycle.join(sid, {
|
|
167
|
+
rp: {
|
|
168
|
+
clientId: "rp-down",
|
|
169
|
+
backchannelLogoutUri: "https://rp-down.test/logout",
|
|
170
|
+
backchannelLogoutSessionRequired: true,
|
|
171
|
+
frontchannelLogoutUri: undefined,
|
|
172
|
+
frontchannelLogoutSessionRequired: undefined,
|
|
173
|
+
registeredAt: new Date(),
|
|
174
|
+
},
|
|
175
|
+
}),
|
|
176
|
+
).toEqual({ outcome: "joined" });
|
|
177
|
+
const posted = vi
|
|
178
|
+
.spyOn(globalThis, "fetch")
|
|
179
|
+
.mockImplementation(async () => new Response(null, { status: 503 }));
|
|
180
|
+
|
|
181
|
+
const logout = await request(app)
|
|
182
|
+
.post("/oauth/logout")
|
|
183
|
+
.type("form")
|
|
184
|
+
.send({ id_token_hint: idToken });
|
|
185
|
+
|
|
186
|
+
// The close committed with the notice pending: the logout succeeded.
|
|
187
|
+
expect(logout.status).toBe(200);
|
|
188
|
+
expect(logout.body).toEqual({ logged_out: true });
|
|
189
|
+
expect(posted).toHaveBeenCalled();
|
|
190
|
+
expect(await sessions.get(sid)).not.toBeNull();
|
|
191
|
+
|
|
192
|
+
const introspected = await request(app)
|
|
193
|
+
.post("/oauth/introspect")
|
|
194
|
+
.set("Authorization", basic(WEB))
|
|
195
|
+
.type("form")
|
|
196
|
+
.send({ token: accessToken });
|
|
197
|
+
expect(introspected.status).toBe(200);
|
|
198
|
+
expect(introspected.body).toEqual({ active: false });
|
|
199
|
+
|
|
200
|
+
const userinfo = await request(app)
|
|
201
|
+
.get("/oauth/userinfo")
|
|
202
|
+
.set("Authorization", `Bearer ${accessToken}`);
|
|
203
|
+
expect(userinfo.status).toBe(401);
|
|
204
|
+
expect(userinfo.body.error_description).toBe("session_invalid");
|
|
205
|
+
|
|
206
|
+
const again = await sessionGrant();
|
|
207
|
+
expect(again.status).toBe(400);
|
|
208
|
+
expect(again.body).toMatchObject({
|
|
209
|
+
error: "invalid_grant",
|
|
210
|
+
error_description: "session_invalid",
|
|
211
|
+
});
|
|
212
|
+
} finally {
|
|
213
|
+
await handle.dispose();
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -262,7 +262,6 @@ describe("the process each development run describes", () => {
|
|
|
262
262
|
|
|
263
263
|
const modules = buildModules(switches, {
|
|
264
264
|
environment: "development",
|
|
265
|
-
logger: createRecordingLogger(),
|
|
266
265
|
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
267
266
|
});
|
|
268
267
|
const handle = await createApp({
|
|
@@ -300,7 +299,6 @@ describe("the process each development run describes", () => {
|
|
|
300
299
|
|
|
301
300
|
const modules = buildModules(switches, {
|
|
302
301
|
environment: configEnv,
|
|
303
|
-
logger: createRecordingLogger(),
|
|
304
302
|
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
305
303
|
});
|
|
306
304
|
const names = modules.map((module) => module.name);
|