@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.
- package/README.md +2 -198
- package/main.js +4 -0
- package/package.json +8 -30
- package/README.ja.md +0 -200
- package/dist/cli.d.mts +0 -3
- package/dist/cli.d.mts.map +0 -1
- package/dist/cli.mjs +0 -18
- package/dist/index.d.mts +0 -48
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs +0 -325
- package/dist/internal/template-filter.d.mts +0 -14
- package/dist/internal/template-filter.d.mts.map +0 -1
- package/dist/internal/template-filter.mjs +0 -42
- package/templates/standalone/.dockerignore +0 -23
- package/templates/standalone/.env.example +0 -152
- package/templates/standalone/Dockerfile +0 -125
- package/templates/standalone/Makefile +0 -12
- package/templates/standalone/README.ja.md +0 -912
- package/templates/standalone/README.md +0 -1536
- package/templates/standalone/config/application.conf +0 -351
- package/templates/standalone/config/clients.yaml +0 -4
- package/templates/standalone/config/clients.yaml.example +0 -124
- package/templates/standalone/config/development.conf +0 -17
- package/templates/standalone/config/mailpit.conf +0 -13
- package/templates/standalone/config/production.conf +0 -15
- package/templates/standalone/config/reference.conf +0 -427
- package/templates/standalone/docker-compose.mailpit.yml +0 -39
- package/templates/standalone/docker-compose.production.yml +0 -229
- package/templates/standalone/docker-compose.test.yml +0 -16
- package/templates/standalone/docker-compose.yml +0 -67
- package/templates/standalone/gitignore +0 -23
- package/templates/standalone/package.json +0 -56
- package/templates/standalone/src/__tests__/adapters.test.mts +0 -425
- package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +0 -87
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +0 -1188
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +0 -285
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +0 -1154
- package/templates/standalone/src/__tests__/audit-sink.test.mts +0 -355
- package/templates/standalone/src/__tests__/backchannel-peer.fixture.mts +0 -64
- package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
- package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
- package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
- package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
- package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1291
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -787
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -464
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
- package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
- package/templates/standalone/src/__tests__/frontchannel-logout-csp.test.mts +0 -168
- package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -318
- package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
- package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
- package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
- package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -217
- package/templates/standalone/src/__tests__/listen.test.mts +0 -121
- package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
- package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
- package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
- package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
- package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
- package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1480
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
- package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -283
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
- package/templates/standalone/src/__tests__/redis-stand-in.fixture.mts +0 -98
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
- package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -657
- package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
- package/templates/standalone/src/__tests__/sections.test.mts +0 -516
- package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -213
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -305
- package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
- package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
- package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
- package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
- package/templates/standalone/src/__tests__/smoke.test.mts +0 -642
- package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -164
- package/templates/standalone/src/__tests__/store-credential.test.mts +0 -314
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -385
- package/templates/standalone/src/adapters.mts +0 -162
- package/templates/standalone/src/app.mts +0 -127
- package/templates/standalone/src/bootRefusal.mts +0 -117
- package/templates/standalone/src/buildModules.mts +0 -358
- package/templates/standalone/src/configPath.mts +0 -487
- package/templates/standalone/src/listen.mts +0 -60
- package/templates/standalone/src/logger.mts +0 -80
- package/templates/standalone/src/metrics.mts +0 -167
- package/templates/standalone/src/mfaSwitch.mts +0 -284
- package/templates/standalone/src/modules.mts +0 -705
- package/templates/standalone/src/rootRenames.mts +0 -95
- package/templates/standalone/src/routes.mts +0 -89
- package/templates/standalone/src/sections.mts +0 -378
- package/templates/standalone/src/securityHeaders.mts +0 -36
- package/templates/standalone/src/shutdown.mts +0 -213
- package/templates/standalone/tests/Makefile +0 -5
- package/templates/standalone/tests/index.test.js +0 -283
- package/templates/standalone/tests/package-lock.json +0 -1617
- package/templates/standalone/tests/package.json +0 -16
- package/templates/standalone/tests/vitest.config.mjs +0 -26
- package/templates/standalone/tsconfig.json +0 -31
- package/templates/standalone/vitest.config.mts +0 -31
- package/templates/standalone/vitest.supertest-loopback.mts +0 -278
- 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
|
-
});
|