@o3co/create-auth-provider 0.17.0-rc.4 → 0.18.0-rc.1

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.
Files changed (110) hide show
  1. package/README.md +2 -198
  2. package/main.js +4 -0
  3. package/package.json +8 -30
  4. package/README.ja.md +0 -200
  5. package/dist/cli.d.mts +0 -3
  6. package/dist/cli.d.mts.map +0 -1
  7. package/dist/cli.mjs +0 -18
  8. package/dist/index.d.mts +0 -48
  9. package/dist/index.d.mts.map +0 -1
  10. package/dist/index.mjs +0 -325
  11. package/dist/internal/template-filter.d.mts +0 -14
  12. package/dist/internal/template-filter.d.mts.map +0 -1
  13. package/dist/internal/template-filter.mjs +0 -42
  14. package/templates/standalone/.dockerignore +0 -23
  15. package/templates/standalone/.env.example +0 -152
  16. package/templates/standalone/Dockerfile +0 -125
  17. package/templates/standalone/Makefile +0 -12
  18. package/templates/standalone/README.ja.md +0 -913
  19. package/templates/standalone/README.md +0 -1537
  20. package/templates/standalone/config/application.conf +0 -351
  21. package/templates/standalone/config/clients.yaml +0 -4
  22. package/templates/standalone/config/clients.yaml.example +0 -118
  23. package/templates/standalone/config/development.conf +0 -17
  24. package/templates/standalone/config/mailpit.conf +0 -13
  25. package/templates/standalone/config/production.conf +0 -15
  26. package/templates/standalone/config/reference.conf +0 -424
  27. package/templates/standalone/docker-compose.mailpit.yml +0 -39
  28. package/templates/standalone/docker-compose.production.yml +0 -229
  29. package/templates/standalone/docker-compose.test.yml +0 -16
  30. package/templates/standalone/docker-compose.yml +0 -67
  31. package/templates/standalone/gitignore +0 -23
  32. package/templates/standalone/package.json +0 -55
  33. package/templates/standalone/src/__tests__/adapters.test.mts +0 -425
  34. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +0 -87
  35. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +0 -1192
  36. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +0 -285
  37. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +0 -1154
  38. package/templates/standalone/src/__tests__/audit-sink.test.mts +0 -346
  39. package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
  40. package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
  41. package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
  42. package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
  43. package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
  44. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
  45. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1290
  46. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -784
  47. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -461
  48. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
  49. package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
  50. package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -308
  51. package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
  52. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
  53. package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
  54. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -216
  55. package/templates/standalone/src/__tests__/listen.test.mts +0 -121
  56. package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
  57. package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
  58. package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
  59. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
  60. package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
  61. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
  62. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
  63. package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
  64. package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1512
  65. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
  66. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -214
  67. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
  68. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
  69. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
  70. package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -654
  71. package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
  72. package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
  73. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
  74. package/templates/standalone/src/__tests__/sections.test.mts +0 -504
  75. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -215
  76. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -296
  77. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
  78. package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
  79. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
  80. package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
  81. package/templates/standalone/src/__tests__/smoke.test.mts +0 -634
  82. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -148
  83. package/templates/standalone/src/__tests__/store-credential.test.mts +0 -312
  84. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
  85. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
  86. package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
  87. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -382
  88. package/templates/standalone/src/adapters.mts +0 -162
  89. package/templates/standalone/src/app.mts +0 -136
  90. package/templates/standalone/src/bootRefusal.mts +0 -117
  91. package/templates/standalone/src/buildModules.mts +0 -358
  92. package/templates/standalone/src/configPath.mts +0 -487
  93. package/templates/standalone/src/listen.mts +0 -60
  94. package/templates/standalone/src/logger.mts +0 -80
  95. package/templates/standalone/src/metrics.mts +0 -167
  96. package/templates/standalone/src/mfaSwitch.mts +0 -284
  97. package/templates/standalone/src/modules.mts +0 -705
  98. package/templates/standalone/src/rootRenames.mts +0 -95
  99. package/templates/standalone/src/routes.mts +0 -89
  100. package/templates/standalone/src/sections.mts +0 -377
  101. package/templates/standalone/src/shutdown.mts +0 -213
  102. package/templates/standalone/tests/Makefile +0 -5
  103. package/templates/standalone/tests/index.test.js +0 -283
  104. package/templates/standalone/tests/package-lock.json +0 -1617
  105. package/templates/standalone/tests/package.json +0 -16
  106. package/templates/standalone/tests/vitest.config.mjs +0 -26
  107. package/templates/standalone/tsconfig.json +0 -31
  108. package/templates/standalone/vitest.config.mts +0 -31
  109. package/templates/standalone/vitest.supertest-loopback.mts +0 -278
  110. package/templates/versions.json +0 -20
@@ -1,215 +0,0 @@
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
- });
@@ -1,296 +0,0 @@
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
- * `POST /session/logout` must invalidate the token the `session` grant minted
19
- * from the same browser session.
20
- *
21
- * Neither package can assert this alone: the logout endpoint is in
22
- * `@o3co/auth-provider-session`, introspection and userinfo are in
23
- * `@o3co/auth-provider-oauth`, and the two are siblings that may not import
24
- * each other. This template is the composition root that mounts both.
25
- *
26
- * The `session` grant stamps `sid` on its access token, and `/oauth/introspect`
27
- * and `/oauth/userinfo` both check that the `UserSession` it names is alive.
28
- * In the BFF / `auth.proxy` injection topology, whose logout IS this endpoint,
29
- * a logout that destroyed only the cookie would leave the token live at both
30
- * for its full lifetime.
31
- */
32
-
33
- import { generateKeyPairSync } from "node:crypto";
34
- import {
35
- type AppConfig,
36
- createApp,
37
- createKeyStoreFactory,
38
- createReadinessRouter,
39
- defineModule,
40
- InMemoryClientRepository,
41
- InMemoryUserRepository,
42
- memoryRefreshTokenFamilyStoreModule,
43
- registerBuiltinKeyStores,
44
- } from "@o3co/auth-provider-core";
45
- import { coreConfigForTests } from "@o3co/auth-provider-core/testing";
46
- import express from "express";
47
- import request from "supertest";
48
- import { afterEach, describe, expect, it } from "vitest";
49
- import { buildModules } from "../buildModules.mjs";
50
- import type { Switches } from "../configPath.mjs";
51
- import { capturedRenames, inProcessAdapters } from "./library-references.fixture.mjs";
52
-
53
- const keyPair = generateKeyPairSync("ed25519", {
54
- publicKeyEncoding: { type: "spki", format: "pem" },
55
- privateKeyEncoding: { type: "pkcs8", format: "pem" },
56
- });
57
-
58
- const CLIENT_ID = "bff-client";
59
- const CLIENT_SECRET = "bff-secret";
60
- const BASIC = `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")}`;
61
- const USERNAME = "alice";
62
- const PASSWORD = "correct-horse-battery-staple";
63
-
64
- /** The `http` module's section, as the hand-built configuration below carries it. */
65
- const HTTP = {
66
- port: 0,
67
- trustProxy: false,
68
- readinessTimeoutMs: 1000,
69
- cors: { allowedOrigins: [] as string[] },
70
- };
71
-
72
- /** The template modules' sections sit beside core's, so the configuration is wider than `AppConfig`. */
73
- const config: AppConfig & Record<string, unknown> = {
74
- // What a resolution under an environment that sets none captures of
75
- // core's renamed variables.
76
- ...{
77
- "renamed-variables": capturedRenames({}),
78
- },
79
- http: HTTP,
80
- logging: { level: "silent" },
81
- "key-store": {
82
- provider: "local",
83
- local: {
84
- algorithm: "EdDSA",
85
- kid: "v0",
86
- privateKey: keyPair.privateKey,
87
- publicKey: keyPair.publicKey,
88
- previousKeys: [],
89
- },
90
- },
91
- // The session requirements this composition expects, as the shipped
92
- // `application.conf` does (ADR 2026-09-28-session-admission): none.
93
- ...coreConfigForTests({ federations: { google: { enabled: false, type: "google" } } }),
94
- oauth: {
95
- jwt: {
96
- issuer: "https://auth.test",
97
- },
98
- accessToken: { defaultExpiresIn: 3600 },
99
- refreshToken: { expiresIn: 86400 },
100
- oidcMode: "oidc-required",
101
- },
102
- // The grant this whole test is about: it mints straight from an
103
- // authenticated browser session, which is the BFF topology.
104
- "oauth-session": { enabled: true },
105
- "session-store": {
106
- secret: "test-session-secret.at-least-32-bytes.ok",
107
- name: "auth.sid",
108
- maxAge: 3600000,
109
- secure: false,
110
- sameSite: "lax",
111
- domain: null,
112
- storage: { type: "memory", redis: { url: "redis://localhost:6379" } },
113
- },
114
- session: {
115
- loginPage: { url: "/login" },
116
- rateLimit: { login: { windowMs: 60000, limit: 100 } },
117
- },
118
- rateLimit: { failMode: "open" },
119
- "standalone-in-memory-code-repository": { defaultExpiresIn: 600 },
120
- } as unknown as AppConfig;
121
-
122
- /** What phase one hands `buildModules`: the configuration, and every store in process. */
123
- const switches = {
124
- ...config,
125
- adapters: inProcessAdapters(),
126
- mfaMode: "off",
127
- } as unknown as Switches;
128
-
129
- const testRepositoriesModule = defineModule({
130
- name: "test:repositories",
131
- provides: {
132
- clientRepository: () =>
133
- new InMemoryClientRepository(
134
- new Map([
135
- [
136
- CLIENT_ID,
137
- {
138
- tokenEndpointAuthMethod: "client_secret_basic",
139
- clientSecret: CLIENT_SECRET,
140
- allowedRedirectUris: [],
141
- allowedScopes: ["openid", "profile"],
142
- allowedAudiences: [],
143
- allowedGrantTypes: ["session"],
144
- backchannelLogoutSessionRequired: true,
145
- frontchannelLogoutSessionRequired: true,
146
- allowedAzpForFederationToken: false,
147
- },
148
- ],
149
- ]),
150
- ),
151
- userRepository: () =>
152
- new InMemoryUserRepository(
153
- new Map([[USERNAME, { id: "u-alice", password: PASSWORD, email: "alice@example.com" }]]),
154
- ),
155
- } as never,
156
- });
157
-
158
- const testKeyStoreModule = defineModule({
159
- name: "test:key-store",
160
- requires: ["config"] as const,
161
- provides: {
162
- keyStore: async ({ config: c }) => {
163
- const factory = createKeyStoreFactory();
164
- registerBuiltinKeyStores(factory);
165
- return factory.create({
166
- type: "local",
167
- ...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
168
- });
169
- },
170
- },
171
- });
172
-
173
- describe("POST /session/logout invalidates the session grant's access token", () => {
174
- let handleRef: Awaited<ReturnType<typeof createApp>> | undefined;
175
-
176
- afterEach(async () => {
177
- await handleRef?.dispose();
178
- handleRef = undefined;
179
- });
180
-
181
- async function buildApp() {
182
- const handle = await createApp({
183
- modules: buildModules(switches, {
184
- keyStoreModule: testKeyStoreModule,
185
- repositoriesModule: testRepositoriesModule,
186
- refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
187
- }),
188
- bootstrapComponents: { config, pathResolver: (s) => s },
189
- });
190
- handleRef = handle;
191
-
192
- const app = express();
193
- app.use(
194
- createReadinessRouter(express, {
195
- probes: handle.readinessProbes,
196
- timeoutMs: HTTP.readinessTimeoutMs,
197
- }),
198
- );
199
- app.use(handle.router);
200
- return app;
201
- }
202
-
203
- /**
204
- * Drives the real browser half: fetch a CSRF pair, log in, and return the
205
- * cookies plus the token the login response reissued. `/session/login`
206
- * hands back a fresh CSRF cookie precisely so the follow-up logout needs no
207
- * second round trip.
208
- */
209
- async function login(app: express.Express) {
210
- const csrfRes = await request(app).get("/session/csrf");
211
- expect(csrfRes.status).toBe(200);
212
- const csrfToken = csrfRes.body.csrf_token as string;
213
- const headerName = csrfRes.body.header_name as string;
214
- const csrfCookies = csrfRes.headers["set-cookie"] as unknown as string[];
215
-
216
- const loginRes = await request(app)
217
- .post("/session/login")
218
- .set("Cookie", csrfCookies)
219
- .set(headerName, csrfToken)
220
- .type("form")
221
- .send({ username: USERNAME, password: PASSWORD });
222
- expect(loginRes.status).toBe(200);
223
-
224
- const loginCookies = loginRes.headers["set-cookie"] as unknown as string[];
225
- // The mechanism is a signed double-submit, so the reissued cookie's
226
- // value IS the token the header has to echo; the pre-login token no
227
- // longer matches.
228
- const csrfCookiePrefix = `${(config["session-store"] as { name: string }).name}.csrf=`;
229
- const reissued = loginCookies
230
- .find((c) => c.startsWith(csrfCookiePrefix))
231
- ?.slice(csrfCookiePrefix.length)
232
- .split(";")[0];
233
- expect(reissued).toBeDefined();
234
- return {
235
- cookies: loginCookies,
236
- csrfToken: decodeURIComponent(reissued as string),
237
- headerName,
238
- };
239
- }
240
-
241
- it("a token minted from the session stops introspecting active after the session logs out", async () => {
242
- const app = await buildApp();
243
- const { cookies, csrfToken, headerName } = await login(app);
244
-
245
- // The BFF exchanges the browser session for an access token.
246
- const tokenRes = await request(app)
247
- .post("/oauth/token")
248
- .set("Authorization", BASIC)
249
- .set("Cookie", cookies)
250
- .type("form")
251
- .send({ grant_type: "session", scope: "openid profile" });
252
- expect(tokenRes.status).toBe(200);
253
- const accessToken = tokenRes.body.access_token as string;
254
- expect(typeof accessToken).toBe("string");
255
-
256
- // Before the logout the token is a live credential at both surfaces.
257
- const beforeIntrospect = await request(app)
258
- .post("/oauth/introspect")
259
- .set("Authorization", BASIC)
260
- .type("form")
261
- .send({ token: accessToken });
262
- expect(beforeIntrospect.status).toBe(200);
263
- expect(beforeIntrospect.body.active).toBe(true);
264
-
265
- const beforeUserinfo = await request(app)
266
- .get("/oauth/userinfo")
267
- .set("Authorization", `Bearer ${accessToken}`);
268
- expect(beforeUserinfo.status).toBe(200);
269
-
270
- // The logout the BFF topology actually performs — no id_token_hint, no
271
- // `/oauth/logout`, just the session endpoint the browser calls.
272
- const logoutRes = await request(app)
273
- .post("/session/logout")
274
- .set("Cookie", cookies)
275
- .set(headerName, csrfToken);
276
- expect(logoutRes.status).toBe(200);
277
- expect(logoutRes.body).toMatchObject({ message: "Logged out successfully" });
278
-
279
- // After it, the same token no longer authorizes anything: the
280
- // `UserSession` record the liveness checks resolve does not outlive
281
- // the logout.
282
- const afterIntrospect = await request(app)
283
- .post("/oauth/introspect")
284
- .set("Authorization", BASIC)
285
- .type("form")
286
- .send({ token: accessToken });
287
- expect(afterIntrospect.status).toBe(200);
288
- expect(afterIntrospect.body.active).toBe(false);
289
-
290
- const afterUserinfo = await request(app)
291
- .get("/oauth/userinfo")
292
- .set("Authorization", `Bearer ${accessToken}`);
293
- expect(afterUserinfo.status).toBe(401);
294
- expect(afterUserinfo.body.error_description).toBe("session_invalid");
295
- });
296
- });
@@ -1,130 +0,0 @@
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
- });