@o3co/create-auth-provider 0.17.0 → 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 (114) 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 -912
  19. package/templates/standalone/README.md +0 -1536
  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 -124
  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 -427
  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 -56
  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 -1188
  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 -355
  39. package/templates/standalone/src/__tests__/backchannel-peer.fixture.mts +0 -64
  40. package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
  41. package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
  42. package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
  43. package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
  44. package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
  45. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
  46. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1291
  47. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -787
  48. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -464
  49. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
  50. package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
  51. package/templates/standalone/src/__tests__/frontchannel-logout-csp.test.mts +0 -168
  52. package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -318
  53. package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
  54. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
  55. package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
  56. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -217
  57. package/templates/standalone/src/__tests__/listen.test.mts +0 -121
  58. package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
  59. package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
  60. package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
  61. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
  62. package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
  63. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
  64. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
  65. package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
  66. package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1480
  67. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
  68. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -283
  69. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
  70. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
  71. package/templates/standalone/src/__tests__/redis-stand-in.fixture.mts +0 -98
  72. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
  73. package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -657
  74. package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
  75. package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
  76. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
  77. package/templates/standalone/src/__tests__/sections.test.mts +0 -516
  78. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -213
  79. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -305
  80. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
  81. package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
  82. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
  83. package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
  84. package/templates/standalone/src/__tests__/smoke.test.mts +0 -642
  85. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -164
  86. package/templates/standalone/src/__tests__/store-credential.test.mts +0 -314
  87. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
  88. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
  89. package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
  90. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -385
  91. package/templates/standalone/src/adapters.mts +0 -162
  92. package/templates/standalone/src/app.mts +0 -127
  93. package/templates/standalone/src/bootRefusal.mts +0 -117
  94. package/templates/standalone/src/buildModules.mts +0 -358
  95. package/templates/standalone/src/configPath.mts +0 -487
  96. package/templates/standalone/src/listen.mts +0 -60
  97. package/templates/standalone/src/logger.mts +0 -80
  98. package/templates/standalone/src/metrics.mts +0 -167
  99. package/templates/standalone/src/mfaSwitch.mts +0 -284
  100. package/templates/standalone/src/modules.mts +0 -705
  101. package/templates/standalone/src/rootRenames.mts +0 -95
  102. package/templates/standalone/src/routes.mts +0 -89
  103. package/templates/standalone/src/sections.mts +0 -378
  104. package/templates/standalone/src/securityHeaders.mts +0 -36
  105. package/templates/standalone/src/shutdown.mts +0 -213
  106. package/templates/standalone/tests/Makefile +0 -5
  107. package/templates/standalone/tests/index.test.js +0 -283
  108. package/templates/standalone/tests/package-lock.json +0 -1617
  109. package/templates/standalone/tests/package.json +0 -16
  110. package/templates/standalone/tests/vitest.config.mjs +0 -26
  111. package/templates/standalone/tsconfig.json +0 -31
  112. package/templates/standalone/vitest.config.mts +0 -31
  113. package/templates/standalone/vitest.supertest-loopback.mts +0 -278
  114. package/templates/versions.json +0 -20
@@ -1,213 +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
- import { backchannelPeer, withLoopbackRelyingParties } from "./backchannel-peer.fixture.mjs";
45
-
46
- afterEach(() => {
47
- vi.restoreAllMocks();
48
- });
49
-
50
- /** A JWT's claims, unverified. */
51
- const claimsOf = (token: string): Record<string, unknown> =>
52
- JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
53
-
54
- describe("the template, which composes the session lifecycle module", () => {
55
- it("boots, the oauth module's notifier contributed", async () => {
56
- const { handle } = await compose();
57
- try {
58
- const lifecycle = handle.components.sessionLifecycle as SessionLifecycle | undefined;
59
- expect(lifecycle).toBeDefined();
60
- expect(handle.components.sessionCloseNotifierResolver?.get()).toBeDefined();
61
- expect(await lifecycle?.close("no-such-session", "expiry")).toEqual({
62
- outcome: "done",
63
- rps: [],
64
- federations: [],
65
- });
66
- } finally {
67
- await handle.dispose();
68
- }
69
- });
70
-
71
- it("tells a relying party that joined, through the oauth module's notifier, when the session closes", async () => {
72
- const peer = await backchannelPeer();
73
- const { handle } = await compose({
74
- config: withLoopbackRelyingParties,
75
- extraClients: {
76
- "rp-bc": {
77
- tokenEndpointAuthMethod: "client_secret_basic",
78
- clientSecret: "rp-bc-secret-long-enough",
79
- allowedRedirectUris: ["https://rp-bc.test/cb"],
80
- allowedScopes: ["openid"],
81
- allowedGrantTypes: ["authorization_code"],
82
- backchannelLogoutUri: peer.uri,
83
- },
84
- },
85
- });
86
- try {
87
- const components = handle.components as Record<string, unknown>;
88
- const lifecycle = components.sessionLifecycle as SessionLifecycle;
89
- const sessions = components.userSessionStore as UserSessionStore;
90
- const expiresAt = new Date(Date.now() + 3_600_000);
91
- await sessions.create({
92
- sid: "sid-bc",
93
- sub: "u-bc",
94
- authTime: new Date(),
95
- expiresAt,
96
- claims: {},
97
- amr: ["pwd"],
98
- authentication: undefined,
99
- });
100
- expect(await lifecycle.open("sid-bc", { sub: "u-bc", expiresAt })).toEqual({
101
- outcome: "opened",
102
- });
103
- const rp = {
104
- clientId: "rp-bc",
105
- backchannelLogoutUri: peer.uri,
106
- backchannelLogoutSessionRequired: true,
107
- frontchannelLogoutUri: undefined,
108
- frontchannelLogoutSessionRequired: undefined,
109
- registeredAt: new Date(),
110
- };
111
- expect(await lifecycle.join("sid-bc", { rp, familyId: "family-bc" })).toEqual({
112
- outcome: "joined",
113
- });
114
- expect((await lifecycle.close("sid-bc", "rp_logout")).outcome).toBe("done");
115
- expect(peer.tokens).toHaveLength(1);
116
- expect(claimsOf(peer.tokens[0] ?? "")).toMatchObject({
117
- iss: ISSUER,
118
- aud: "rp-bc",
119
- sub: "u-bc",
120
- sid: "sid-bc",
121
- });
122
- } finally {
123
- await handle.dispose();
124
- await peer.close();
125
- }
126
- });
127
-
128
- it("opens a password login's session in the lifecycle, active with nothing joined yet", async () => {
129
- const { app, handle } = await compose();
130
- try {
131
- expect((await login(app)).res.status).toBe(200);
132
- const components = handle.components as Record<string, unknown>;
133
- const [sid] = await (components.subjectSessionIndex as SubjectSessionIndex).listSids(
134
- ALICE.sub,
135
- );
136
- const store = components.sessionLifecycleStore as SessionLifecycleStore;
137
- const record = readVersionedSessionLifecycle(await store.read(String(sid)));
138
- expect(record?.value).toMatchObject({ sub: ALICE.sub, state: "active", participants: [] });
139
- } finally {
140
- await handle.dispose();
141
- }
142
- });
143
-
144
- it("joins a federated login's federation to the session's lifecycle record, beside its tokens, through the session module's federation routes", async () => {
145
- const { app, handle, upstreams } = await compose();
146
- try {
147
- const callback = await (await federatedCallback(app, "oidc", upstreams.oidc))();
148
- expect(callback.status).toBe(302);
149
- expect(cookiesOf(callback)).not.toEqual([]);
150
- const components = handle.components as Record<string, unknown>;
151
- const [sid] = await (components.subjectSessionIndex as SubjectSessionIndex).listSids(
152
- ALICE.sub,
153
- );
154
- const store = components.sessionLifecycleStore as SessionLifecycleStore;
155
- const record = readVersionedSessionLifecycle(await store.read(String(sid)));
156
- expect(record?.value).toMatchObject({ sub: ALICE.sub, state: "active" });
157
- expect(record?.value.participants.map((p) => `${p.kind}:${p.id}`)).toEqual([
158
- "federation:oidc",
159
- ]);
160
- const tokens = components.federationTokenStore as FederationTokenStore;
161
- expect(await tokens.get(String(sid), "oidc")).not.toBeNull();
162
- } finally {
163
- await handle.dispose();
164
- }
165
- });
166
-
167
- it("joins a code exchange's relying party and family to the session's lifecycle record", async () => {
168
- const { app, handle } = await compose();
169
- try {
170
- const tokens = await webTokens(app);
171
- const claims = claimsOf(tokens.refresh_token ?? "");
172
- const sid = String(claims.sid);
173
- const store = (handle.components as Record<string, unknown>)
174
- .sessionLifecycleStore as SessionLifecycleStore;
175
- const record = readVersionedSessionLifecycle(await store.read(sid));
176
- expect(record?.value.state).toBe("active");
177
- expect(record?.value.participants.map((p) => `${p.kind}:${p.id}`)).toEqual([
178
- `rp:${WEB.id}`,
179
- `family:${String(claims.family_id)}`,
180
- ]);
181
- } finally {
182
- await handle.dispose();
183
- }
184
- });
185
-
186
- it("closes the session through the lifecycle at /oauth/logout: the record closed, the session and its refresh token gone", async () => {
187
- const { app, handle } = await compose();
188
- try {
189
- const tokens = await webTokens(app);
190
- const sid = String(claimsOf(tokens.refresh_token ?? "").sid);
191
- const components = handle.components as Record<string, unknown>;
192
-
193
- const res = await request(app)
194
- .post("/oauth/logout")
195
- .type("form")
196
- .send({ id_token_hint: tokens.id_token });
197
-
198
- expect(res.status).toBe(200);
199
- expect(res.body).toEqual({ logged_out: true });
200
- const store = components.sessionLifecycleStore as SessionLifecycleStore;
201
- expect(readVersionedSessionLifecycle(await store.read(sid))?.value.state).toBe("closed");
202
- expect(await (components.userSessionStore as UserSessionStore).get(sid)).toBeNull();
203
- const refreshed = await request(app)
204
- .post("/oauth/token")
205
- .set("Authorization", basic(WEB))
206
- .type("form")
207
- .send({ grant_type: "refresh_token", refresh_token: tokens.refresh_token });
208
- expect(refreshed.status).toBe(400);
209
- } finally {
210
- await handle.dispose();
211
- }
212
- });
213
- });
@@ -1,305 +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 { keyStoreSectionSchema } from "../sections.mjs";
52
- import { capturedRenames, inProcessAdapters } from "./library-references.fixture.mjs";
53
-
54
- const keyPair = generateKeyPairSync("ed25519", {
55
- publicKeyEncoding: { type: "spki", format: "pem" },
56
- privateKeyEncoding: { type: "pkcs8", format: "pem" },
57
- });
58
-
59
- const CLIENT_ID = "bff-client";
60
- const CLIENT_SECRET = "bff-secret";
61
- const BASIC = `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")}`;
62
- const USERNAME = "alice";
63
- const PASSWORD = "correct-horse-battery-staple";
64
-
65
- /** The `http` module's section, as the hand-built configuration below carries it. */
66
- const HTTP = {
67
- port: 0,
68
- trustProxy: false,
69
- readinessTimeoutMs: 1000,
70
- cors: { allowedOrigins: [] as string[] },
71
- };
72
-
73
- /** The template modules' sections sit beside core's, so the configuration is wider than `AppConfig`. */
74
- const config: AppConfig & Record<string, unknown> = {
75
- // What a resolution under an environment that sets none captures of
76
- // core's renamed variables.
77
- ...{
78
- "renamed-variables": capturedRenames({}),
79
- },
80
- http: HTTP,
81
- logging: { level: "silent" },
82
- "key-store": {
83
- provider: "local",
84
- local: {
85
- algorithm: "EdDSA",
86
- kid: "v0",
87
- privateKey: keyPair.privateKey,
88
- publicKey: keyPair.publicKey,
89
- previousKeys: [],
90
- },
91
- },
92
- // The session requirements this composition expects, as the shipped
93
- // `application.conf` does (ADR 2026-09-28-session-admission): none.
94
- ...coreConfigForTests({ federations: { google: { enabled: false, type: "google" } } }),
95
- oauth: {
96
- jwt: {
97
- issuer: "https://auth.test",
98
- },
99
- accessToken: { defaultExpiresIn: 3600 },
100
- refreshToken: { expiresIn: 86400 },
101
- oidcMode: "oidc-required",
102
- },
103
- // The grant this whole test is about: it mints straight from an
104
- // authenticated browser session, which is the BFF topology.
105
- "oauth-session": { enabled: true },
106
- "session-store": {
107
- secret: "test-session-secret.at-least-32-bytes.ok",
108
- name: "auth.sid",
109
- maxAge: 3600000,
110
- secure: false,
111
- sameSite: "lax",
112
- domain: null,
113
- storage: { type: "memory", redis: { url: "redis://localhost:6379" } },
114
- },
115
- session: {
116
- loginPage: { url: "/login" },
117
- rateLimit: { login: { windowMs: 60000, limit: 100 } },
118
- },
119
- rateLimit: { failMode: "open" },
120
- "standalone-in-memory-code-repository": { defaultExpiresIn: 600 },
121
- // The in-process limiter's section as core's `reference.conf` ships it.
122
- "core-rate-limiter-memory": {
123
- maxBuckets: 10000,
124
- defaultLimit: { limit: 60, windowSeconds: 60 },
125
- limits: {},
126
- },
127
- } as unknown as AppConfig;
128
-
129
- /** What phase one hands `buildModules`: the configuration, and every store in process. */
130
- const switches = {
131
- ...config,
132
- adapters: inProcessAdapters(),
133
- mfaMode: "off",
134
- } as unknown as Switches;
135
-
136
- const testRepositoriesModule = defineModule({
137
- name: "test:repositories",
138
- provides: {
139
- clientRepository: () =>
140
- new InMemoryClientRepository(
141
- new Map([
142
- [
143
- CLIENT_ID,
144
- {
145
- tokenEndpointAuthMethod: "client_secret_basic",
146
- clientSecret: CLIENT_SECRET,
147
- allowedRedirectUris: [],
148
- allowedScopes: ["openid", "profile"],
149
- allowedAudiences: [],
150
- allowedGrantTypes: ["session"],
151
- backchannelLogoutSessionRequired: true,
152
- frontchannelLogoutSessionRequired: true,
153
- allowedAzpForFederationToken: false,
154
- },
155
- ],
156
- ]),
157
- ),
158
- userRepository: () =>
159
- new InMemoryUserRepository(
160
- new Map([[USERNAME, { id: "u-alice", password: PASSWORD, email: "alice@example.com" }]]),
161
- ),
162
- } as never,
163
- });
164
-
165
- // The template's `key-store` section, read by its own schema, without the
166
- // section's relocation bridge.
167
- const testKeyStoreModule = defineModule({
168
- name: "key-store",
169
- section: { schema: keyStoreSectionSchema },
170
- provides: {
171
- keyStore: async ({ section }) => {
172
- const factory = createKeyStoreFactory();
173
- registerBuiltinKeyStores(factory);
174
- return factory.create({
175
- type: "local",
176
- ...(section.local ?? {}),
177
- });
178
- },
179
- },
180
- });
181
-
182
- describe("POST /session/logout invalidates the session grant's access token", () => {
183
- let handleRef: Awaited<ReturnType<typeof createApp>> | undefined;
184
-
185
- afterEach(async () => {
186
- await handleRef?.dispose();
187
- handleRef = undefined;
188
- });
189
-
190
- async function buildApp() {
191
- const handle = await createApp({
192
- modules: buildModules(switches, {
193
- keyStoreModule: testKeyStoreModule,
194
- repositoriesModule: testRepositoriesModule,
195
- refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
196
- }),
197
- bootstrapComponents: { config, pathResolver: (s) => s },
198
- });
199
- handleRef = handle;
200
-
201
- const app = express();
202
- app.use(
203
- createReadinessRouter(express, {
204
- probes: handle.readinessProbes,
205
- timeoutMs: HTTP.readinessTimeoutMs,
206
- }),
207
- );
208
- app.use(handle.router);
209
- return app;
210
- }
211
-
212
- /**
213
- * Drives the real browser half: fetch a CSRF pair, log in, and return the
214
- * cookies plus the token the login response reissued. `/session/login`
215
- * hands back a fresh CSRF cookie precisely so the follow-up logout needs no
216
- * second round trip.
217
- */
218
- async function login(app: express.Express) {
219
- const csrfRes = await request(app).get("/session/csrf");
220
- expect(csrfRes.status).toBe(200);
221
- const csrfToken = csrfRes.body.csrf_token as string;
222
- const headerName = csrfRes.body.header_name as string;
223
- const csrfCookies = csrfRes.headers["set-cookie"] as unknown as string[];
224
-
225
- const loginRes = await request(app)
226
- .post("/session/login")
227
- .set("Cookie", csrfCookies)
228
- .set(headerName, csrfToken)
229
- .type("form")
230
- .send({ username: USERNAME, password: PASSWORD });
231
- expect(loginRes.status).toBe(200);
232
-
233
- const loginCookies = loginRes.headers["set-cookie"] as unknown as string[];
234
- // The mechanism is a signed double-submit, so the reissued cookie's
235
- // value IS the token the header has to echo; the pre-login token no
236
- // longer matches.
237
- const csrfCookiePrefix = `${(config["session-store"] as { name: string }).name}.csrf=`;
238
- const reissued = loginCookies
239
- .find((c) => c.startsWith(csrfCookiePrefix))
240
- ?.slice(csrfCookiePrefix.length)
241
- .split(";")[0];
242
- expect(reissued).toBeDefined();
243
- return {
244
- cookies: loginCookies,
245
- csrfToken: decodeURIComponent(reissued as string),
246
- headerName,
247
- };
248
- }
249
-
250
- it("a token minted from the session stops introspecting active after the session logs out", async () => {
251
- const app = await buildApp();
252
- const { cookies, csrfToken, headerName } = await login(app);
253
-
254
- // The BFF exchanges the browser session for an access token.
255
- const tokenRes = await request(app)
256
- .post("/oauth/token")
257
- .set("Authorization", BASIC)
258
- .set("Cookie", cookies)
259
- .type("form")
260
- .send({ grant_type: "session", scope: "openid profile" });
261
- expect(tokenRes.status).toBe(200);
262
- const accessToken = tokenRes.body.access_token as string;
263
- expect(typeof accessToken).toBe("string");
264
-
265
- // Before the logout the token is a live credential at both surfaces.
266
- const beforeIntrospect = await request(app)
267
- .post("/oauth/introspect")
268
- .set("Authorization", BASIC)
269
- .type("form")
270
- .send({ token: accessToken });
271
- expect(beforeIntrospect.status).toBe(200);
272
- expect(beforeIntrospect.body.active).toBe(true);
273
-
274
- const beforeUserinfo = await request(app)
275
- .get("/oauth/userinfo")
276
- .set("Authorization", `Bearer ${accessToken}`);
277
- expect(beforeUserinfo.status).toBe(200);
278
-
279
- // The logout the BFF topology actually performs — no id_token_hint, no
280
- // `/oauth/logout`, just the session endpoint the browser calls.
281
- const logoutRes = await request(app)
282
- .post("/session/logout")
283
- .set("Cookie", cookies)
284
- .set(headerName, csrfToken);
285
- expect(logoutRes.status).toBe(200);
286
- expect(logoutRes.body).toMatchObject({ message: "Logged out successfully" });
287
-
288
- // After it, the same token no longer authorizes anything: the
289
- // `UserSession` record the liveness checks resolve does not outlive
290
- // the logout.
291
- const afterIntrospect = await request(app)
292
- .post("/oauth/introspect")
293
- .set("Authorization", BASIC)
294
- .type("form")
295
- .send({ token: accessToken });
296
- expect(afterIntrospect.status).toBe(200);
297
- expect(afterIntrospect.body.active).toBe(false);
298
-
299
- const afterUserinfo = await request(app)
300
- .get("/oauth/userinfo")
301
- .set("Authorization", `Bearer ${accessToken}`);
302
- expect(afterUserinfo.status).toBe(401);
303
- expect(afterUserinfo.body.error_description).toBe("session_invalid");
304
- });
305
- });
@@ -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
- import { backchannelPeer, withLoopbackRelyingParties } from "./backchannel-peer.fixture.mjs";
35
-
36
- afterEach(() => {
37
- vi.restoreAllMocks();
38
- });
39
-
40
- /** A JWT's claims, unverified. */
41
- const claimsOf = (token: string): Record<string, unknown> =>
42
- JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
43
-
44
- /** The CSRF token a login reissued, read off its double-submit cookie. */
45
- const csrfTokenOf = (cookies: readonly string[]): string => {
46
- const cookie = cookies.find((c) => /^[^=]*\.csrf=/.test(c));
47
- if (cookie === undefined) throw new Error("the login reissued no CSRF cookie");
48
- return decodeURIComponent(cookie.slice(cookie.indexOf("=") + 1).split(";")[0] ?? "");
49
- };
50
-
51
- describe("POST /session/logout with the session lifecycle module", () => {
52
- it("revokes the session's families, tells its relying parties and closes its record", async () => {
53
- const peer = await backchannelPeer();
54
- const { app, handle } = await compose({
55
- config: withLoopbackRelyingParties,
56
- extraClients: {
57
- "rp-bc": {
58
- tokenEndpointAuthMethod: "client_secret_basic",
59
- clientSecret: "rp-bc-secret-long-enough",
60
- allowedRedirectUris: ["https://rp-bc.test/cb"],
61
- allowedScopes: ["openid"],
62
- allowedGrantTypes: ["authorization_code"],
63
- backchannelLogoutUri: peer.uri,
64
- },
65
- },
66
- });
67
- try {
68
- const components = handle.components as Record<string, unknown>;
69
- const lifecycle = components.sessionLifecycle as SessionLifecycle;
70
- const sessions = components.userSessionStore as UserSessionStore;
71
- const families = components.refreshTokenFamilyRevocation as RefreshTokenFamilyRevocation;
72
- const { cookies } = await login(app);
73
- const exchanged = await redeem(app, codeFrom(await authorize(app, cookies)));
74
- expect(exchanged.status).toBe(200);
75
- const claims = claimsOf(exchanged.body.refresh_token as string);
76
- const sid = String(claims.sid);
77
- const familyId = String(claims.family_id);
78
- expect(
79
- await lifecycle.join(sid, {
80
- rp: {
81
- clientId: "rp-bc",
82
- backchannelLogoutUri: peer.uri,
83
- backchannelLogoutSessionRequired: true,
84
- frontchannelLogoutUri: undefined,
85
- frontchannelLogoutSessionRequired: undefined,
86
- registeredAt: new Date(),
87
- },
88
- }),
89
- ).toEqual({ outcome: "joined" });
90
- const csrfToken = csrfTokenOf(cookies);
91
- const res = await request(app)
92
- .post("/session/logout")
93
- .set("Cookie", cookies)
94
- .set("x-csrf-token", csrfToken);
95
-
96
- expect(res.status).toBe(200);
97
- expect(await families.isFamilyRevoked(familyId)).toBe(true);
98
- expect(peer.tokens).toHaveLength(1);
99
- expect(await sessions.get(sid)).toBeNull();
100
- const store = components.sessionLifecycleStore as SessionLifecycleStore;
101
- expect(readVersionedSessionLifecycle(await store.read(sid))?.value.state).toBe("closed");
102
- } finally {
103
- await handle.dispose();
104
- await peer.close();
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
- });