@o3co/create-auth-provider 0.16.0 → 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.
Files changed (100) hide show
  1. package/README.ja.md +66 -21
  2. package/README.md +76 -31
  3. package/dist/index.d.mts +33 -10
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +118 -59
  6. package/dist/internal/template-filter.d.mts +7 -10
  7. package/dist/internal/template-filter.d.mts.map +1 -1
  8. package/dist/internal/template-filter.mjs +7 -10
  9. package/package.json +6 -4
  10. package/templates/standalone/.dockerignore +15 -2
  11. package/templates/standalone/.env.example +59 -29
  12. package/templates/standalone/Dockerfile +6 -2
  13. package/templates/standalone/README.ja.md +324 -169
  14. package/templates/standalone/README.md +618 -208
  15. package/templates/standalone/config/application.conf +211 -352
  16. package/templates/standalone/config/clients.yaml +2 -0
  17. package/templates/standalone/config/clients.yaml.example +4 -0
  18. package/templates/standalone/config/development.conf +13 -4
  19. package/templates/standalone/config/mailpit.conf +8 -0
  20. package/templates/standalone/config/production.conf +5 -6
  21. package/templates/standalone/config/reference.conf +418 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +63 -32
  24. package/templates/standalone/docker-compose.yml +13 -3
  25. package/templates/standalone/gitignore +1 -0
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/adapters.test.mts +419 -0
  28. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +287 -198
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +71 -35
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +148 -94
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +84 -81
  33. package/templates/standalone/src/__tests__/config-notices.test.mts +154 -0
  34. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  35. package/templates/standalone/src/__tests__/core-federations.test.mts +249 -0
  36. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  37. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +10 -15
  38. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +970 -288
  39. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +421 -86
  40. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +87 -72
  41. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +16 -8
  42. package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
  43. package/templates/standalone/src/__tests__/google-session.fixture.mts +97 -93
  44. package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
  45. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
  46. package/templates/standalone/src/__tests__/library-references.fixture.mts +188 -0
  47. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
  48. package/templates/standalone/src/__tests__/listen.test.mts +6 -13
  49. package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
  50. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  51. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +326 -0
  52. package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
  53. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
  54. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  55. package/templates/standalone/src/__tests__/mfa-switch.test.mts +825 -0
  56. package/templates/standalone/src/__tests__/own-modules.test.mts +1486 -0
  57. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +10 -14
  58. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
  59. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +116 -81
  60. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  61. package/templates/standalone/src/__tests__/replica-safety.test.mts +265 -146
  62. package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
  63. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +278 -103
  64. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  65. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
  66. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +69 -61
  67. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
  68. package/templates/standalone/src/__tests__/session-requirements.test.mts +382 -0
  69. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +270 -0
  70. package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
  71. package/templates/standalone/src/__tests__/smoke.test.mts +176 -258
  72. package/templates/standalone/src/__tests__/store-credential.test.mts +88 -52
  73. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  74. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +131 -102
  75. package/templates/standalone/src/__tests__/two-phase-config.test.mts +604 -0
  76. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +382 -0
  77. package/templates/standalone/src/adapters.mts +162 -0
  78. package/templates/standalone/src/app.mts +65 -79
  79. package/templates/standalone/src/bootRefusal.mts +117 -0
  80. package/templates/standalone/src/buildModules.mts +220 -309
  81. package/templates/standalone/src/configPath.mts +449 -29
  82. package/templates/standalone/src/listen.mts +12 -18
  83. package/templates/standalone/src/logger.mts +31 -60
  84. package/templates/standalone/src/metrics.mts +24 -47
  85. package/templates/standalone/src/mfaSwitch.mts +285 -0
  86. package/templates/standalone/src/modules.mts +423 -629
  87. package/templates/standalone/src/rootRenames.mts +96 -0
  88. package/templates/standalone/src/routes.mts +8 -14
  89. package/templates/standalone/src/sections.mts +367 -0
  90. package/templates/standalone/src/shutdown.mts +45 -123
  91. package/templates/standalone/tests/index.test.js +6 -5
  92. package/templates/standalone/tests/package-lock.json +1615 -2223
  93. package/templates/standalone/tests/package.json +2 -2
  94. package/templates/standalone/tests/vitest.config.mjs +26 -0
  95. package/templates/standalone/vitest.config.mts +8 -7
  96. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  97. package/templates/versions.json +18 -15
  98. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -135
  99. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -199
  100. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -138
@@ -18,22 +18,16 @@
18
18
  * `POST /session/logout` must invalidate the token the `session` grant minted
19
19
  * from the same browser session.
20
20
  *
21
- * This lives here rather than in `packages/session` or `packages/oauth`
22
- * because it is the one assertion neither package can make alone: the logout
23
- * endpoint is in `@o3co/auth-provider-session`, the introspection and userinfo
24
- * endpoints are in `@o3co/auth-provider-oauth`, and the two are siblings that
25
- * may not import each other. The standalone template is the composition root
26
- * where both are mounted on one app, so it is where the contract between them
27
- * is testable.
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.
28
25
  *
29
- * What it pins: #506 stamped `sid` on the `session` grant's access token and
30
- * gave `/oauth/introspect` the session-liveness check `/oauth/userinfo`
31
- * already ran. Both resolve the `UserSession` record — which `/session/logout`
32
- * used to leave alive, because it destroyed the cookie and nothing else. So in
33
- * the BFF / `auth.proxy` injection topology, whose logout IS this endpoint, an
34
- * access token minted from the session kept introspecting `active: true` and
35
- * kept answering at `/userinfo` for its full lifetime after the user logged
36
- * out. Only `/oauth/logout` with an `id_token_hint` was ever closed by #506.
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.
37
31
  */
38
32
 
39
33
  import { generateKeyPairSync } from "node:crypto";
@@ -48,10 +42,13 @@ import {
48
42
  memoryRefreshTokenFamilyStoreModule,
49
43
  registerBuiltinKeyStores,
50
44
  } from "@o3co/auth-provider-core";
45
+ import { coreConfigForTests } from "@o3co/auth-provider-core/testing";
51
46
  import express from "express";
52
47
  import request from "supertest";
53
48
  import { afterEach, describe, expect, it } from "vitest";
54
49
  import { buildModules } from "../buildModules.mjs";
50
+ import type { Switches } from "../configPath.mjs";
51
+ import { capturedRenames, inProcessAdapters } from "./library-references.fixture.mjs";
55
52
 
56
53
  const keyPair = generateKeyPairSync("ed25519", {
57
54
  publicKeyEncoding: { type: "spki", format: "pem" },
@@ -64,36 +61,48 @@ const BASIC = `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("ba
64
61
  const USERNAME = "alice";
65
62
  const PASSWORD = "correct-horse-battery-staple";
66
63
 
67
- const config: AppConfig = {
68
- http: { port: 0, trustProxy: false, readinessTimeoutMs: 1000 },
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,
69
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" } } }),
70
94
  oauth: {
71
95
  jwt: {
72
96
  issuer: "https://auth.test",
73
- signingKey: {
74
- provider: "local",
75
- local: {
76
- algorithm: "EdDSA",
77
- kid: "v0",
78
- privateKey: keyPair.privateKey,
79
- publicKey: keyPair.publicKey,
80
- previousKeys: [],
81
- },
82
- },
83
- },
84
- accessToken: { expiresIn: 3600 },
85
- refreshToken: {
86
- expiresIn: 86400,
87
- unknownFamilyPolicy: "reject" as const,
88
- legacyRtPolicy: "reject" as const,
89
97
  },
90
- // The grant this whole test is about: it mints straight from an
91
- // authenticated browser session, which is the BFF topology.
92
- grants: { session: { enabled: true } },
98
+ accessToken: { defaultExpiresIn: 3600 },
99
+ refreshToken: { expiresIn: 86400 },
93
100
  oidcMode: "oidc-required",
94
- code: { adapter: "memory" as const },
95
101
  },
96
- session: {
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": {
97
106
  secret: "test-session-secret.at-least-32-bytes.ok",
98
107
  name: "auth.sid",
99
108
  maxAge: 3600000,
@@ -102,20 +111,21 @@ const config: AppConfig = {
102
111
  domain: null,
103
112
  storage: { type: "memory", redis: { url: "redis://localhost:6379" } },
104
113
  },
105
- rateLimit: {
106
- login: { windowMs: 60000, limit: 100 },
107
- failMode: "open",
108
- },
109
- federations: { google: { enabled: false } },
110
- repositories: {
111
- client: { type: "yaml", path: "./config/clients.yaml" },
112
- user: { type: "yaml", path: "./config/users.yaml", timeout: 5000 },
113
- code: { type: "memory", defaultExpiresIn: 600 },
114
+ session: {
115
+ loginPage: { url: "/login" },
116
+ rateLimit: { login: { windowMs: 60000, limit: 100 } },
114
117
  },
115
- endpoints: { login: { url: "/login" } },
116
- cors: { allowedOrigins: [] },
118
+ rateLimit: { failMode: "open" },
119
+ "standalone-in-memory-code-repository": { defaultExpiresIn: 600 },
117
120
  } as unknown as AppConfig;
118
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
+
119
129
  const testRepositoriesModule = defineModule({
120
130
  name: "test:repositories",
121
131
  provides: {
@@ -154,7 +164,7 @@ const testKeyStoreModule = defineModule({
154
164
  registerBuiltinKeyStores(factory);
155
165
  return factory.create({
156
166
  type: "local",
157
- ...((c as AppConfig).oauth.jwt.signingKey.local ?? {}),
167
+ ...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
158
168
  });
159
169
  },
160
170
  },
@@ -170,7 +180,7 @@ describe("POST /session/logout invalidates the session grant's access token", ()
170
180
 
171
181
  async function buildApp() {
172
182
  const handle = await createApp({
173
- modules: buildModules(config, {
183
+ modules: buildModules(switches, {
174
184
  keyStoreModule: testKeyStoreModule,
175
185
  repositoriesModule: testRepositoriesModule,
176
186
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
@@ -183,7 +193,7 @@ describe("POST /session/logout invalidates the session grant's access token", ()
183
193
  app.use(
184
194
  createReadinessRouter(express, {
185
195
  probes: handle.readinessProbes,
186
- timeoutMs: config.http.readinessTimeoutMs,
196
+ timeoutMs: HTTP.readinessTimeoutMs,
187
197
  }),
188
198
  );
189
199
  app.use(handle.router);
@@ -212,12 +222,10 @@ describe("POST /session/logout invalidates the session grant's access token", ()
212
222
  expect(loginRes.status).toBe(200);
213
223
 
214
224
  const loginCookies = loginRes.headers["set-cookie"] as unknown as string[];
215
- // The session cookie comes from the login response, and so does a FRESH
216
- // CSRF cookie — the login handler reissues one on the regenerated
217
- // session so the follow-up logout needs no second round trip. The
218
- // mechanism is a signed double-submit, so the cookie's value IS the
219
- // token the header has to echo; the pre-login token no longer matches.
220
- const csrfCookiePrefix = `${config.session.name}.csrf=`;
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=`;
221
229
  const reissued = loginCookies
222
230
  .find((c) => c.startsWith(csrfCookiePrefix))
223
231
  ?.slice(csrfCookiePrefix.length)
@@ -268,9 +276,9 @@ describe("POST /session/logout invalidates the session grant's access token", ()
268
276
  expect(logoutRes.status).toBe(200);
269
277
  expect(logoutRes.body).toMatchObject({ message: "Logged out successfully" });
270
278
 
271
- // After it, the same token no longer authorizes anything. This is the
272
- // assertion that failed before the fix: the `UserSession` record the
273
- // liveness checks resolve outlived the logout.
279
+ // After it, the same token no longer authorizes anything: the
280
+ // `UserSession` record the liveness checks resolve does not outlive
281
+ // the logout.
274
282
  const afterIntrospect = await request(app)
275
283
  .post("/oauth/introspect")
276
284
  .set("Authorization", BASIC)
@@ -0,0 +1,130 @@
1
+ /*
2
+ * Copyright 2026 1o1 Co. Ltd.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /**
18
+ * The template with core's session lifecycle module: `POST /session/logout`
19
+ * closes the session through it, so the browser's own logout revokes the
20
+ * session's refresh-token families and tells its relying parties, as
21
+ * `/oauth/logout` does.
22
+ */
23
+
24
+ import {
25
+ type RefreshTokenFamilyRevocation,
26
+ readVersionedSessionLifecycle,
27
+ type SessionLifecycle,
28
+ type SessionLifecycleStore,
29
+ type UserSessionStore,
30
+ } from "@o3co/auth-provider-core";
31
+ import request from "supertest";
32
+ import { afterEach, describe, expect, it, vi } from "vitest";
33
+ import { authorize, codeFrom, compose, login, redeem } from "./all-modules-composition.fixture.mjs";
34
+
35
+ afterEach(() => {
36
+ vi.restoreAllMocks();
37
+ });
38
+
39
+ /** A JWT's claims, unverified. */
40
+ const claimsOf = (token: string): Record<string, unknown> =>
41
+ JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
42
+
43
+ /** The CSRF token a login reissued, read off its double-submit cookie. */
44
+ const csrfTokenOf = (cookies: readonly string[]): string => {
45
+ const cookie = cookies.find((c) => /^[^=]*\.csrf=/.test(c));
46
+ if (cookie === undefined) throw new Error("the login reissued no CSRF cookie");
47
+ return decodeURIComponent(cookie.slice(cookie.indexOf("=") + 1).split(";")[0] ?? "");
48
+ };
49
+
50
+ describe("POST /session/logout with the session lifecycle module", () => {
51
+ it("revokes the session's families, tells its relying parties and closes its record", async () => {
52
+ const { app, handle } = await compose({
53
+ extraClients: {
54
+ "rp-bc": {
55
+ tokenEndpointAuthMethod: "client_secret_basic",
56
+ clientSecret: "rp-bc-secret-long-enough",
57
+ allowedRedirectUris: ["https://rp-bc.test/cb"],
58
+ allowedScopes: ["openid"],
59
+ allowedGrantTypes: ["authorization_code"],
60
+ backchannelLogoutUri: "https://rp-bc.test/logout",
61
+ },
62
+ },
63
+ });
64
+ try {
65
+ const components = handle.components as Record<string, unknown>;
66
+ const lifecycle = components.sessionLifecycle as SessionLifecycle;
67
+ const sessions = components.userSessionStore as UserSessionStore;
68
+ const families = components.refreshTokenFamilyRevocation as RefreshTokenFamilyRevocation;
69
+ const { cookies } = await login(app);
70
+ const exchanged = await redeem(app, codeFrom(await authorize(app, cookies)));
71
+ expect(exchanged.status).toBe(200);
72
+ const claims = claimsOf(exchanged.body.refresh_token as string);
73
+ const sid = String(claims.sid);
74
+ const familyId = String(claims.family_id);
75
+ expect(
76
+ await lifecycle.join(sid, {
77
+ rp: {
78
+ clientId: "rp-bc",
79
+ backchannelLogoutUri: "https://rp-bc.test/logout",
80
+ backchannelLogoutSessionRequired: true,
81
+ frontchannelLogoutUri: undefined,
82
+ frontchannelLogoutSessionRequired: undefined,
83
+ registeredAt: new Date(),
84
+ },
85
+ }),
86
+ ).toEqual({ outcome: "joined" });
87
+ const posted = vi
88
+ .spyOn(globalThis, "fetch")
89
+ .mockImplementation(async () => new Response(null, { status: 200 }));
90
+
91
+ const csrfToken = csrfTokenOf(cookies);
92
+ const res = await request(app)
93
+ .post("/session/logout")
94
+ .set("Cookie", cookies)
95
+ .set("x-csrf-token", csrfToken);
96
+
97
+ expect(res.status).toBe(200);
98
+ expect(await families.isFamilyRevoked(familyId)).toBe(true);
99
+ expect(posted.mock.calls.map(([url]) => String(url))).toEqual(["https://rp-bc.test/logout"]);
100
+ expect(await sessions.get(sid)).toBeNull();
101
+ const store = components.sessionLifecycleStore as SessionLifecycleStore;
102
+ expect(readVersionedSessionLifecycle(await store.read(sid))?.value.state).toBe("closed");
103
+ } finally {
104
+ await handle.dispose();
105
+ }
106
+ });
107
+
108
+ it("answers 503 when the close cannot commit, and the cookie still admits the session for a retry", async () => {
109
+ const { app, handle } = await compose();
110
+ try {
111
+ const { cookies } = await login(app);
112
+ const components = handle.components as Record<string, unknown>;
113
+ const store = components.sessionLifecycleStore as SessionLifecycleStore;
114
+ vi.spyOn(store, "beginClose").mockRejectedValue(new Error("lifecycle store down"));
115
+
116
+ const res = await request(app)
117
+ .post("/session/logout")
118
+ .set("Cookie", cookies)
119
+ .set("x-csrf-token", csrfTokenOf(cookies));
120
+
121
+ expect(res.status).toBe(503);
122
+ expect(res.body.error).toBe("temporarily_unavailable");
123
+ // The session is still live and its cookie still admits it.
124
+ const authorized = await authorize(app, cookies);
125
+ expect(codeFrom(authorized)).toEqual(expect.any(String));
126
+ } finally {
127
+ await handle.dispose();
128
+ }
129
+ });
130
+ });