@o3co/create-auth-provider 0.17.0-rc.4 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/templates/standalone/README.ja.md +2 -3
- package/templates/standalone/README.md +2 -3
- package/templates/standalone/config/clients.yaml.example +11 -5
- package/templates/standalone/config/reference.conf +5 -2
- package/templates/standalone/package.json +2 -1
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +6 -10
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +2 -2
- package/templates/standalone/src/__tests__/audit-sink.test.mts +13 -4
- package/templates/standalone/src/__tests__/backchannel-peer.fixture.mts +64 -0
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +11 -10
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +7 -4
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +7 -4
- package/templates/standalone/src/__tests__/frontchannel-logout-csp.test.mts +168 -0
- package/templates/standalone/src/__tests__/google-session.fixture.mts +17 -7
- package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +13 -12
- package/templates/standalone/src/__tests__/own-modules.test.mts +3 -35
- package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +73 -4
- package/templates/standalone/src/__tests__/redis-stand-in.fixture.mts +98 -0
- package/templates/standalone/src/__tests__/replica-safety.test.mts +7 -4
- package/templates/standalone/src/__tests__/sections.test.mts +15 -3
- package/templates/standalone/src/__tests__/session-lifecycle.test.mts +8 -10
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +13 -4
- package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +7 -7
- package/templates/standalone/src/__tests__/smoke.test.mts +13 -5
- package/templates/standalone/src/__tests__/startup-log-lines.test.mts +24 -8
- package/templates/standalone/src/__tests__/store-credential.test.mts +7 -5
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +7 -4
- package/templates/standalone/src/app.mts +2 -11
- package/templates/standalone/src/sections.mts +3 -2
- package/templates/standalone/src/securityHeaders.mts +36 -0
- package/templates/versions.json +18 -18
|
@@ -54,6 +54,7 @@ import { expect } from "vitest";
|
|
|
54
54
|
import { buildModules } from "#/buildModules.mjs";
|
|
55
55
|
import { resolveConfigPaths } from "#/configPath.mjs";
|
|
56
56
|
import { templateReference } from "../modules.mjs";
|
|
57
|
+
import { keyStoreSectionSchema } from "../sections.mjs";
|
|
57
58
|
import {
|
|
58
59
|
type BothPhases,
|
|
59
60
|
bothPhasesOf,
|
|
@@ -179,16 +180,18 @@ const testRepositoriesModule = defineModule({
|
|
|
179
180
|
} as never,
|
|
180
181
|
});
|
|
181
182
|
|
|
183
|
+
// The template's `key-store` section, read by its own schema, without the
|
|
184
|
+
// section's relocation bridge.
|
|
182
185
|
const testKeyStoreModule = defineModule({
|
|
183
|
-
name: "
|
|
184
|
-
|
|
186
|
+
name: "key-store",
|
|
187
|
+
section: { schema: keyStoreSectionSchema },
|
|
185
188
|
provides: {
|
|
186
|
-
keyStore: async ({
|
|
189
|
+
keyStore: async ({ section }) => {
|
|
187
190
|
const factory = createKeyStoreFactory();
|
|
188
191
|
registerBuiltinKeyStores(factory);
|
|
189
192
|
return factory.create({
|
|
190
193
|
type: "local",
|
|
191
|
-
...(
|
|
194
|
+
...(section.local ?? {}),
|
|
192
195
|
});
|
|
193
196
|
},
|
|
194
197
|
},
|
|
@@ -216,11 +219,18 @@ export const idTokenHint = (sid: string): string => signed("JWT", { aud: CLIENT_
|
|
|
216
219
|
|
|
217
220
|
/**
|
|
218
221
|
* An access token for the session as the authorization-code grant issues it
|
|
219
|
-
* to the client: `azp`, `sid` and a
|
|
220
|
-
* grant's token names no family, and
|
|
222
|
+
* to the client, for the client itself: `aud` and `azp`, `sid` and a
|
|
223
|
+
* refresh-token `family_id`. The `session` grant's token names no family, and
|
|
224
|
+
* the federation token route asks for one.
|
|
221
225
|
*/
|
|
222
226
|
export const accessTokenWithFamily = (sid: string): string =>
|
|
223
|
-
signed("at+jwt", {
|
|
227
|
+
signed("at+jwt", {
|
|
228
|
+
aud: CLIENT_ID,
|
|
229
|
+
azp: CLIENT_ID,
|
|
230
|
+
sid,
|
|
231
|
+
family_id: "family-1",
|
|
232
|
+
scope: "openid",
|
|
233
|
+
});
|
|
224
234
|
|
|
225
235
|
const claimsOf = (jwt: string): Record<string, unknown> =>
|
|
226
236
|
JSON.parse(Buffer.from(jwt.split(".")[1] ?? "", "base64url").toString("utf8")) as Record<
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
redeem,
|
|
34
34
|
WEB,
|
|
35
35
|
} from "./all-modules-composition.fixture.mjs";
|
|
36
|
+
import { backchannelPeer, withLoopbackRelyingParties } from "./backchannel-peer.fixture.mjs";
|
|
36
37
|
|
|
37
38
|
afterEach(() => {
|
|
38
39
|
vi.restoreAllMocks();
|
|
@@ -44,7 +45,9 @@ const claimsOf = (token: string): Record<string, unknown> =>
|
|
|
44
45
|
|
|
45
46
|
describe("a session whose close is pending on a failed relying-party notice", () => {
|
|
46
47
|
it("is refused by introspection, userinfo and the session grant, its user session still there", async () => {
|
|
48
|
+
const peer = await backchannelPeer(503);
|
|
47
49
|
const { app, handle } = await compose({
|
|
50
|
+
config: withLoopbackRelyingParties,
|
|
48
51
|
extraClients: {
|
|
49
52
|
"rp-down": {
|
|
50
53
|
tokenEndpointAuthMethod: "client_secret_basic",
|
|
@@ -52,7 +55,7 @@ describe("a session whose close is pending on a failed relying-party notice", ()
|
|
|
52
55
|
allowedRedirectUris: ["https://rp-down.test/cb"],
|
|
53
56
|
allowedScopes: ["openid"],
|
|
54
57
|
allowedGrantTypes: ["authorization_code"],
|
|
55
|
-
backchannelLogoutUri:
|
|
58
|
+
backchannelLogoutUri: peer.uri,
|
|
56
59
|
},
|
|
57
60
|
},
|
|
58
61
|
});
|
|
@@ -89,7 +92,7 @@ describe("a session whose close is pending on a failed relying-party notice", ()
|
|
|
89
92
|
await lifecycle.join(sid, {
|
|
90
93
|
rp: {
|
|
91
94
|
clientId: "rp-down",
|
|
92
|
-
backchannelLogoutUri:
|
|
95
|
+
backchannelLogoutUri: peer.uri,
|
|
93
96
|
backchannelLogoutSessionRequired: true,
|
|
94
97
|
frontchannelLogoutUri: undefined,
|
|
95
98
|
frontchannelLogoutSessionRequired: undefined,
|
|
@@ -98,11 +101,8 @@ describe("a session whose close is pending on a failed relying-party notice", ()
|
|
|
98
101
|
familyId: "family-down",
|
|
99
102
|
}),
|
|
100
103
|
).toEqual({ outcome: "joined" });
|
|
101
|
-
const posted = vi
|
|
102
|
-
.spyOn(globalThis, "fetch")
|
|
103
|
-
.mockImplementation(async () => new Response(null, { status: 503 }));
|
|
104
104
|
expect((await lifecycle.close(sid, "rp_logout")).outcome).toBe("pending");
|
|
105
|
-
expect(
|
|
105
|
+
expect(peer.tokens.length).toBeGreaterThan(0);
|
|
106
106
|
expect(await sessions.get(sid)).not.toBeNull();
|
|
107
107
|
|
|
108
108
|
const afterIntrospect = await introspect();
|
|
@@ -121,11 +121,14 @@ describe("a session whose close is pending on a failed relying-party notice", ()
|
|
|
121
121
|
});
|
|
122
122
|
} finally {
|
|
123
123
|
await handle.dispose();
|
|
124
|
+
await peer.close();
|
|
124
125
|
}
|
|
125
126
|
});
|
|
126
127
|
|
|
127
128
|
it("an RP-initiated logout whose notice fails answers success, and its session is refused by introspection, userinfo and the session grant", async () => {
|
|
129
|
+
const peer = await backchannelPeer(503);
|
|
128
130
|
const { app, handle } = await compose({
|
|
131
|
+
config: withLoopbackRelyingParties,
|
|
129
132
|
extraClients: {
|
|
130
133
|
"rp-down": {
|
|
131
134
|
tokenEndpointAuthMethod: "client_secret_basic",
|
|
@@ -133,7 +136,7 @@ describe("a session whose close is pending on a failed relying-party notice", ()
|
|
|
133
136
|
allowedRedirectUris: ["https://rp-down.test/cb"],
|
|
134
137
|
allowedScopes: ["openid"],
|
|
135
138
|
allowedGrantTypes: ["authorization_code"],
|
|
136
|
-
backchannelLogoutUri:
|
|
139
|
+
backchannelLogoutUri: peer.uri,
|
|
137
140
|
},
|
|
138
141
|
},
|
|
139
142
|
});
|
|
@@ -166,7 +169,7 @@ describe("a session whose close is pending on a failed relying-party notice", ()
|
|
|
166
169
|
await lifecycle.join(sid, {
|
|
167
170
|
rp: {
|
|
168
171
|
clientId: "rp-down",
|
|
169
|
-
backchannelLogoutUri:
|
|
172
|
+
backchannelLogoutUri: peer.uri,
|
|
170
173
|
backchannelLogoutSessionRequired: true,
|
|
171
174
|
frontchannelLogoutUri: undefined,
|
|
172
175
|
frontchannelLogoutSessionRequired: undefined,
|
|
@@ -174,9 +177,6 @@ describe("a session whose close is pending on a failed relying-party notice", ()
|
|
|
174
177
|
},
|
|
175
178
|
}),
|
|
176
179
|
).toEqual({ outcome: "joined" });
|
|
177
|
-
const posted = vi
|
|
178
|
-
.spyOn(globalThis, "fetch")
|
|
179
|
-
.mockImplementation(async () => new Response(null, { status: 503 }));
|
|
180
180
|
|
|
181
181
|
const logout = await request(app)
|
|
182
182
|
.post("/oauth/logout")
|
|
@@ -186,7 +186,7 @@ describe("a session whose close is pending on a failed relying-party notice", ()
|
|
|
186
186
|
// The close committed with the notice pending: the logout succeeded.
|
|
187
187
|
expect(logout.status).toBe(200);
|
|
188
188
|
expect(logout.body).toEqual({ logged_out: true });
|
|
189
|
-
expect(
|
|
189
|
+
expect(peer.tokens.length).toBeGreaterThan(0);
|
|
190
190
|
expect(await sessions.get(sid)).not.toBeNull();
|
|
191
191
|
|
|
192
192
|
const introspected = await request(app)
|
|
@@ -211,6 +211,7 @@ describe("a session whose close is pending on a failed relying-party notice", ()
|
|
|
211
211
|
});
|
|
212
212
|
} finally {
|
|
213
213
|
await handle.dispose();
|
|
214
|
+
await peer.close();
|
|
214
215
|
}
|
|
215
216
|
});
|
|
216
217
|
});
|
|
@@ -27,9 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { generateKeyPairSync } from "node:crypto";
|
|
30
|
-
import { once } from "node:events";
|
|
31
30
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
32
|
-
import { type AddressInfo, createServer, type Socket } from "node:net";
|
|
33
31
|
import { tmpdir } from "node:os";
|
|
34
32
|
import { join } from "node:path";
|
|
35
33
|
import { fileURLToPath } from "node:url";
|
|
@@ -47,8 +45,9 @@ import {
|
|
|
47
45
|
moduleReferences,
|
|
48
46
|
supportsMfaEnrollmentWitness,
|
|
49
47
|
} from "@o3co/auth-provider-core";
|
|
50
|
-
import {
|
|
48
|
+
import { packageReferenceProblems } from "@o3co/auth-provider-core/testing";
|
|
51
49
|
import { HttpUserRepository } from "@o3co/auth-provider-foundation";
|
|
50
|
+
import { httpSettingsContract } from "@o3co/auth-provider-test-kit";
|
|
52
51
|
import { parseFile } from "@o3co/ts.hocon";
|
|
53
52
|
import express from "express";
|
|
54
53
|
import request from "supertest";
|
|
@@ -74,6 +73,7 @@ import {
|
|
|
74
73
|
standaloneRedisClientsModule,
|
|
75
74
|
} from "../modules.mjs";
|
|
76
75
|
import { repositoriesSectionSchema } from "../sections.mjs";
|
|
76
|
+
import { listeningRedis } from "./redis-stand-in.fixture.mjs";
|
|
77
77
|
|
|
78
78
|
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
79
79
|
/** The template's own defaults: what its modules declare as their sections' reference. */
|
|
@@ -990,38 +990,6 @@ describe("key-store", () => {
|
|
|
990
990
|
});
|
|
991
991
|
});
|
|
992
992
|
|
|
993
|
-
/**
|
|
994
|
-
* A TCP server standing in for Redis: it records what each connection sends
|
|
995
|
-
* and answers nothing, so a client dialled at it connects and writes its
|
|
996
|
-
* handshake.
|
|
997
|
-
*/
|
|
998
|
-
async function listeningRedis(): Promise<{
|
|
999
|
-
readonly port: number;
|
|
1000
|
-
readonly received: () => string;
|
|
1001
|
-
readonly close: () => Promise<void>;
|
|
1002
|
-
}> {
|
|
1003
|
-
const sockets = new Set<Socket>();
|
|
1004
|
-
let received = "";
|
|
1005
|
-
const server = createServer((socket) => {
|
|
1006
|
-
sockets.add(socket);
|
|
1007
|
-
socket.on("close", () => sockets.delete(socket));
|
|
1008
|
-
socket.on("data", (chunk) => {
|
|
1009
|
-
received += chunk.toString("utf8");
|
|
1010
|
-
});
|
|
1011
|
-
});
|
|
1012
|
-
server.listen(0, "127.0.0.1");
|
|
1013
|
-
await once(server, "listening");
|
|
1014
|
-
const { port } = server.address() as AddressInfo;
|
|
1015
|
-
return {
|
|
1016
|
-
port,
|
|
1017
|
-
received: () => received,
|
|
1018
|
-
close: async () => {
|
|
1019
|
-
for (const socket of sockets) socket.destroy();
|
|
1020
|
-
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
1021
|
-
},
|
|
1022
|
-
};
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
993
|
describe("redis-clients", () => {
|
|
1026
994
|
it("owns redis-clients, and reads it as its section rather than the configuration", () => {
|
|
1027
995
|
expect(standaloneRedisClientsModule.name).toBe("redis-clients");
|
|
@@ -107,8 +107,11 @@ const MFA_ON_REDIS: Readonly<Record<string, string>> = {
|
|
|
107
107
|
STANDARD_SMTP_MAIL_SENDER_FROM: "auth@auth.test",
|
|
108
108
|
};
|
|
109
109
|
|
|
110
|
-
const composing = (
|
|
111
|
-
|
|
110
|
+
const composing = (
|
|
111
|
+
env: Readonly<Record<string, string>>,
|
|
112
|
+
shippedRefreshTokenFamilyStore = true,
|
|
113
|
+
): Promise<unknown> =>
|
|
114
|
+
compose({ env, environment: "production", shippedRefreshTokenFamilyStore }).then(
|
|
112
115
|
(composition) => {
|
|
113
116
|
current = composition;
|
|
114
117
|
return undefined;
|
|
@@ -157,9 +160,75 @@ describe("a Redis that will not report its eviction policy", () => {
|
|
|
157
160
|
});
|
|
158
161
|
});
|
|
159
162
|
|
|
163
|
+
describe("the shipped refresh-token family store on a Redis that will not report its eviction policy", () => {
|
|
164
|
+
it.each([
|
|
165
|
+
["unset", SINGLE_ENV],
|
|
166
|
+
["false", { ...SINGLE_ENV, REDIS_CLIENTS_ASSUME_NO_EVICTION: "false" }],
|
|
167
|
+
])(
|
|
168
|
+
"refuses the boot with REDIS_CLIENTS_ASSUME_NO_EVICTION %s, naming the store",
|
|
169
|
+
async (_label, env) => {
|
|
170
|
+
expect(await composing(env)).toMatchObject({
|
|
171
|
+
reason: "provides-factory-failed",
|
|
172
|
+
details: {
|
|
173
|
+
module: "redis-refresh-token-family-store",
|
|
174
|
+
componentKey: "refreshTokenFamilyStore",
|
|
175
|
+
},
|
|
176
|
+
cause: {
|
|
177
|
+
name: "RedisStoreEvictableError",
|
|
178
|
+
reason: "refresh-token-family-store-evictable",
|
|
179
|
+
maxmemoryPolicy: undefined,
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
it("boots with REDIS_CLIENTS_ASSUME_NO_EVICTION=true", async () => {
|
|
186
|
+
expect(
|
|
187
|
+
await composing({ ...SINGLE_ENV, REDIS_CLIENTS_ASSUME_NO_EVICTION: "true" }),
|
|
188
|
+
).toBeUndefined();
|
|
189
|
+
expect(current?.handle.components.refreshTokenFamilyStore?.kind).toBe("redis");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("the replay seen-set on a Redis that will not report its eviction policy", () => {
|
|
194
|
+
/** Only the seen-set on Redis: a refusal can come from no other store. */
|
|
195
|
+
const REPLAY_ONLY = {
|
|
196
|
+
...SINGLE_ENV,
|
|
197
|
+
ADAPTERS_REPLAY_SEEN_SET: "redis",
|
|
198
|
+
REDIS_CLIENTS_URL: "redis://redis.test:6379",
|
|
199
|
+
};
|
|
200
|
+
const composingReplayOnly = (env: Readonly<Record<string, string>>) => composing(env, false);
|
|
201
|
+
|
|
202
|
+
it.each([
|
|
203
|
+
["unset", REPLAY_ONLY],
|
|
204
|
+
["false", { ...REPLAY_ONLY, REDIS_CLIENTS_ASSUME_NO_EVICTION: "false" }],
|
|
205
|
+
])(
|
|
206
|
+
"refuses the boot with REDIS_CLIENTS_ASSUME_NO_EVICTION %s, naming the store",
|
|
207
|
+
async (_label, env) => {
|
|
208
|
+
expect(await composingReplayOnly(env)).toMatchObject({
|
|
209
|
+
reason: "provides-factory-failed",
|
|
210
|
+
details: { module: "redis-replay-seen-set", componentKey: "replaySeenSet" },
|
|
211
|
+
cause: {
|
|
212
|
+
name: "RedisStoreEvictableError",
|
|
213
|
+
reason: "replay-seen-set-evictable",
|
|
214
|
+
maxmemoryPolicy: undefined,
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
},
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
it("boots with REDIS_CLIENTS_ASSUME_NO_EVICTION=true", async () => {
|
|
221
|
+
expect(
|
|
222
|
+
await composingReplayOnly({ ...REPLAY_ONLY, REDIS_CLIENTS_ASSUME_NO_EVICTION: "true" }),
|
|
223
|
+
).toBeUndefined();
|
|
224
|
+
expect(current?.handle.components.replaySeenSet?.kind).toBe("redis");
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
160
228
|
describe("the MFA stores on a Redis that will not report its eviction policy", () => {
|
|
161
229
|
/** Only the MFA stores on Redis: a refusal can come from no other store. */
|
|
162
230
|
const MFA_ONLY = { ...SINGLE_ENV, ...MFA_ON_REDIS };
|
|
231
|
+
const composingMfaOnly = (env: Readonly<Record<string, string>>) => composing(env, false);
|
|
163
232
|
|
|
164
233
|
it.each([
|
|
165
234
|
["unset", MFA_ONLY],
|
|
@@ -167,7 +236,7 @@ describe("the MFA stores on a Redis that will not report its eviction policy", (
|
|
|
167
236
|
])(
|
|
168
237
|
"refuses the boot with REDIS_CLIENTS_ASSUME_NO_EVICTION %s, naming an MFA store",
|
|
169
238
|
async (_label, env) => {
|
|
170
|
-
const err = await
|
|
239
|
+
const err = await composingMfaOnly(env);
|
|
171
240
|
expect(err).toBeInstanceOf(BootError);
|
|
172
241
|
expect(err).toMatchObject({
|
|
173
242
|
reason: "provides-factory-failed",
|
|
@@ -182,7 +251,7 @@ describe("the MFA stores on a Redis that will not report its eviction policy", (
|
|
|
182
251
|
|
|
183
252
|
it("boots both MFA stores with REDIS_CLIENTS_ASSUME_NO_EVICTION=true", async () => {
|
|
184
253
|
expect(
|
|
185
|
-
await
|
|
254
|
+
await composingMfaOnly({ ...MFA_ONLY, REDIS_CLIENTS_ASSUME_NO_EVICTION: "true" }),
|
|
186
255
|
).toBeUndefined();
|
|
187
256
|
expect(current?.modules.map((module) => module.name)).toEqual(
|
|
188
257
|
expect.arrayContaining(["redis-mfa-factor-store", "redis-mfa-transaction-store"]),
|
|
@@ -0,0 +1,98 @@
|
|
|
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
|
+
* A TCP stand-in for a Redis server, for the tests that boot the template
|
|
19
|
+
* against a socket and need no real server: it records what each connection
|
|
20
|
+
* sends and answers only what boot needs — the handshake (`HELLO` refused, so
|
|
21
|
+
* the client speaks RESP2), `INFO` reporting `maxmemory-policy noeviction`,
|
|
22
|
+
* and `+OK` to anything else. It stores nothing and is not a Redis for data:
|
|
23
|
+
* a test that reads or writes a store needs a real server.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { once } from "node:events";
|
|
27
|
+
import { type AddressInfo, createServer, type Socket } from "node:net";
|
|
28
|
+
|
|
29
|
+
/** The first command `buffer` holds in full, as RESP sends it, and its length; `undefined` until it does. */
|
|
30
|
+
function nextCommand(
|
|
31
|
+
buffer: string,
|
|
32
|
+
): { readonly name: string; readonly length: number } | undefined {
|
|
33
|
+
const header = /^\*(\d+)\r\n/.exec(buffer);
|
|
34
|
+
if (header === null) return undefined;
|
|
35
|
+
let at = header[0].length;
|
|
36
|
+
const args: string[] = [];
|
|
37
|
+
for (let i = 0; i < Number(header[1]); i++) {
|
|
38
|
+
const bulk = /^\$(\d+)\r\n/.exec(buffer.slice(at));
|
|
39
|
+
if (bulk === null) return undefined;
|
|
40
|
+
const start = at + bulk[0].length;
|
|
41
|
+
const end = start + Number(bulk[1]);
|
|
42
|
+
if (buffer.length < end + 2) return undefined;
|
|
43
|
+
args.push(buffer.slice(start, end));
|
|
44
|
+
at = end + 2;
|
|
45
|
+
}
|
|
46
|
+
return { name: (args[0] ?? "").toLowerCase(), length: at };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What a server that runs `noeviction` and speaks RESP2 answers `name`. */
|
|
50
|
+
function replyTo(name: string): string {
|
|
51
|
+
if (name === "hello") return "-NOPROTO unsupported protocol version\r\n";
|
|
52
|
+
if (name === "info") {
|
|
53
|
+
const info =
|
|
54
|
+
"# Memory\r\nmaxmemory_policy:noeviction\r\n# Persistence\r\naof_enabled:1\r\nloading:0\r\n";
|
|
55
|
+
return `$${Buffer.byteLength(info)}\r\n${info}\r\n`;
|
|
56
|
+
}
|
|
57
|
+
return "+OK\r\n";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A TCP server standing in for Redis: it records what each connection sends,
|
|
62
|
+
* and answers as a server running `noeviction` does — `HELLO` refused, so the
|
|
63
|
+
* client speaks RESP2, `INFO` with the policy, anything else `OK` — so a
|
|
64
|
+
* client dialled at it writes its handshake and the stores whose factories
|
|
65
|
+
* read the policy build.
|
|
66
|
+
*/
|
|
67
|
+
export async function listeningRedis(): Promise<{
|
|
68
|
+
readonly port: number;
|
|
69
|
+
readonly received: () => string;
|
|
70
|
+
readonly close: () => Promise<void>;
|
|
71
|
+
}> {
|
|
72
|
+
const sockets = new Set<Socket>();
|
|
73
|
+
let received = "";
|
|
74
|
+
const server = createServer((socket) => {
|
|
75
|
+
sockets.add(socket);
|
|
76
|
+
let pending = "";
|
|
77
|
+
socket.on("close", () => sockets.delete(socket));
|
|
78
|
+
socket.on("data", (chunk) => {
|
|
79
|
+
received += chunk.toString("utf8");
|
|
80
|
+
pending += chunk.toString("utf8");
|
|
81
|
+
for (let command = nextCommand(pending); command; command = nextCommand(pending)) {
|
|
82
|
+
pending = pending.slice(command.length);
|
|
83
|
+
socket.write(replyTo(command.name));
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
server.listen(0, "127.0.0.1");
|
|
88
|
+
await once(server, "listening");
|
|
89
|
+
const { port } = server.address() as AddressInfo;
|
|
90
|
+
return {
|
|
91
|
+
port,
|
|
92
|
+
received: () => received,
|
|
93
|
+
close: async () => {
|
|
94
|
+
for (const socket of sockets) socket.destroy();
|
|
95
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -56,6 +56,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
56
56
|
import { buildModules } from "../buildModules.mjs";
|
|
57
57
|
import { resolveConfigPaths } from "../configPath.mjs";
|
|
58
58
|
import { templateReference } from "../modules.mjs";
|
|
59
|
+
import { keyStoreSectionSchema } from "../sections.mjs";
|
|
59
60
|
import {
|
|
60
61
|
type BothPhases,
|
|
61
62
|
bothPhasesOf,
|
|
@@ -198,16 +199,18 @@ const testRepositoriesModule = defineModule({
|
|
|
198
199
|
},
|
|
199
200
|
});
|
|
200
201
|
|
|
202
|
+
// The template's `key-store` section, read by its own schema, without the
|
|
203
|
+
// section's relocation bridge.
|
|
201
204
|
const testKeyStoreModule = defineModule({
|
|
202
|
-
name: "
|
|
203
|
-
|
|
205
|
+
name: "key-store",
|
|
206
|
+
section: { schema: keyStoreSectionSchema },
|
|
204
207
|
provides: {
|
|
205
|
-
keyStore: async ({
|
|
208
|
+
keyStore: async ({ section }) => {
|
|
206
209
|
const factory = createKeyStoreFactory();
|
|
207
210
|
registerBuiltinKeyStores(factory);
|
|
208
211
|
return factory.create({
|
|
209
212
|
type: "local",
|
|
210
|
-
...(
|
|
213
|
+
...(section.local ?? {}),
|
|
211
214
|
});
|
|
212
215
|
},
|
|
213
216
|
},
|
|
@@ -440,12 +440,20 @@ describe("key-store", () => {
|
|
|
440
440
|
|
|
441
441
|
describe("redis-clients", () => {
|
|
442
442
|
it("accepts a URL, and a password beside it", () => {
|
|
443
|
-
expect(
|
|
443
|
+
expect(
|
|
444
|
+
redisClientsSectionSchema.parse({
|
|
445
|
+
url: "redis://r:6379",
|
|
446
|
+
password: "p",
|
|
447
|
+
assumeNoEviction: false,
|
|
448
|
+
}),
|
|
449
|
+
).toEqual({
|
|
444
450
|
url: "redis://r:6379",
|
|
445
451
|
password: "p",
|
|
446
452
|
assumeNoEviction: false,
|
|
447
453
|
});
|
|
448
|
-
expect(
|
|
454
|
+
expect(
|
|
455
|
+
redisClientsSectionSchema.parse({ url: "redis://r:6379", assumeNoEviction: false }),
|
|
456
|
+
).toEqual({
|
|
449
457
|
url: "redis://r:6379",
|
|
450
458
|
assumeNoEviction: false,
|
|
451
459
|
});
|
|
@@ -476,7 +484,11 @@ describe("redis-clients", () => {
|
|
|
476
484
|
it.each([
|
|
477
485
|
["no URL", {}],
|
|
478
486
|
["a null URL", { url: null }],
|
|
479
|
-
[
|
|
487
|
+
[
|
|
488
|
+
"a key the section does not declare",
|
|
489
|
+
{ url: "redis://r:6379", db: 2, assumeNoEviction: false },
|
|
490
|
+
],
|
|
491
|
+
["no assumeNoEviction, which the template's reference.conf ships", { url: "redis://r:6379" }],
|
|
480
492
|
])("refuses %s", (_what, section) => {
|
|
481
493
|
expect(redisClientsSectionSchema.safeParse(section).success).toBe(false);
|
|
482
494
|
});
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
WEB,
|
|
42
42
|
webTokens,
|
|
43
43
|
} from "./all-modules-composition.fixture.mjs";
|
|
44
|
+
import { backchannelPeer, withLoopbackRelyingParties } from "./backchannel-peer.fixture.mjs";
|
|
44
45
|
|
|
45
46
|
afterEach(() => {
|
|
46
47
|
vi.restoreAllMocks();
|
|
@@ -68,7 +69,9 @@ describe("the template, which composes the session lifecycle module", () => {
|
|
|
68
69
|
});
|
|
69
70
|
|
|
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();
|
|
71
73
|
const { handle } = await compose({
|
|
74
|
+
config: withLoopbackRelyingParties,
|
|
72
75
|
extraClients: {
|
|
73
76
|
"rp-bc": {
|
|
74
77
|
tokenEndpointAuthMethod: "client_secret_basic",
|
|
@@ -76,7 +79,7 @@ describe("the template, which composes the session lifecycle module", () => {
|
|
|
76
79
|
allowedRedirectUris: ["https://rp-bc.test/cb"],
|
|
77
80
|
allowedScopes: ["openid"],
|
|
78
81
|
allowedGrantTypes: ["authorization_code"],
|
|
79
|
-
backchannelLogoutUri:
|
|
82
|
+
backchannelLogoutUri: peer.uri,
|
|
80
83
|
},
|
|
81
84
|
},
|
|
82
85
|
});
|
|
@@ -99,7 +102,7 @@ describe("the template, which composes the session lifecycle module", () => {
|
|
|
99
102
|
});
|
|
100
103
|
const rp = {
|
|
101
104
|
clientId: "rp-bc",
|
|
102
|
-
backchannelLogoutUri:
|
|
105
|
+
backchannelLogoutUri: peer.uri,
|
|
103
106
|
backchannelLogoutSessionRequired: true,
|
|
104
107
|
frontchannelLogoutUri: undefined,
|
|
105
108
|
frontchannelLogoutSessionRequired: undefined,
|
|
@@ -108,15 +111,9 @@ describe("the template, which composes the session lifecycle module", () => {
|
|
|
108
111
|
expect(await lifecycle.join("sid-bc", { rp, familyId: "family-bc" })).toEqual({
|
|
109
112
|
outcome: "joined",
|
|
110
113
|
});
|
|
111
|
-
const posted = vi
|
|
112
|
-
.spyOn(globalThis, "fetch")
|
|
113
|
-
.mockImplementation(async () => new Response(null, { status: 200 }));
|
|
114
114
|
expect((await lifecycle.close("sid-bc", "rp_logout")).outcome).toBe("done");
|
|
115
|
-
expect(
|
|
116
|
-
|
|
117
|
-
expect(url).toBe("https://rp-bc.test/logout");
|
|
118
|
-
const token = new URLSearchParams(String(init.body)).get("logout_token");
|
|
119
|
-
expect(claimsOf(token ?? "")).toMatchObject({
|
|
115
|
+
expect(peer.tokens).toHaveLength(1);
|
|
116
|
+
expect(claimsOf(peer.tokens[0] ?? "")).toMatchObject({
|
|
120
117
|
iss: ISSUER,
|
|
121
118
|
aud: "rp-bc",
|
|
122
119
|
sub: "u-bc",
|
|
@@ -124,6 +121,7 @@ describe("the template, which composes the session lifecycle module", () => {
|
|
|
124
121
|
});
|
|
125
122
|
} finally {
|
|
126
123
|
await handle.dispose();
|
|
124
|
+
await peer.close();
|
|
127
125
|
}
|
|
128
126
|
});
|
|
129
127
|
|
|
@@ -48,6 +48,7 @@ import request from "supertest";
|
|
|
48
48
|
import { afterEach, describe, expect, it } from "vitest";
|
|
49
49
|
import { buildModules } from "../buildModules.mjs";
|
|
50
50
|
import type { Switches } from "../configPath.mjs";
|
|
51
|
+
import { keyStoreSectionSchema } from "../sections.mjs";
|
|
51
52
|
import { capturedRenames, inProcessAdapters } from "./library-references.fixture.mjs";
|
|
52
53
|
|
|
53
54
|
const keyPair = generateKeyPairSync("ed25519", {
|
|
@@ -117,6 +118,12 @@ const config: AppConfig & Record<string, unknown> = {
|
|
|
117
118
|
},
|
|
118
119
|
rateLimit: { failMode: "open" },
|
|
119
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
|
+
},
|
|
120
127
|
} as unknown as AppConfig;
|
|
121
128
|
|
|
122
129
|
/** What phase one hands `buildModules`: the configuration, and every store in process. */
|
|
@@ -155,16 +162,18 @@ const testRepositoriesModule = defineModule({
|
|
|
155
162
|
} as never,
|
|
156
163
|
});
|
|
157
164
|
|
|
165
|
+
// The template's `key-store` section, read by its own schema, without the
|
|
166
|
+
// section's relocation bridge.
|
|
158
167
|
const testKeyStoreModule = defineModule({
|
|
159
|
-
name: "
|
|
160
|
-
|
|
168
|
+
name: "key-store",
|
|
169
|
+
section: { schema: keyStoreSectionSchema },
|
|
161
170
|
provides: {
|
|
162
|
-
keyStore: async ({
|
|
171
|
+
keyStore: async ({ section }) => {
|
|
163
172
|
const factory = createKeyStoreFactory();
|
|
164
173
|
registerBuiltinKeyStores(factory);
|
|
165
174
|
return factory.create({
|
|
166
175
|
type: "local",
|
|
167
|
-
...(
|
|
176
|
+
...(section.local ?? {}),
|
|
168
177
|
});
|
|
169
178
|
},
|
|
170
179
|
},
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
import request from "supertest";
|
|
32
32
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
33
33
|
import { authorize, codeFrom, compose, login, redeem } from "./all-modules-composition.fixture.mjs";
|
|
34
|
+
import { backchannelPeer, withLoopbackRelyingParties } from "./backchannel-peer.fixture.mjs";
|
|
34
35
|
|
|
35
36
|
afterEach(() => {
|
|
36
37
|
vi.restoreAllMocks();
|
|
@@ -49,7 +50,9 @@ const csrfTokenOf = (cookies: readonly string[]): string => {
|
|
|
49
50
|
|
|
50
51
|
describe("POST /session/logout with the session lifecycle module", () => {
|
|
51
52
|
it("revokes the session's families, tells its relying parties and closes its record", async () => {
|
|
53
|
+
const peer = await backchannelPeer();
|
|
52
54
|
const { app, handle } = await compose({
|
|
55
|
+
config: withLoopbackRelyingParties,
|
|
53
56
|
extraClients: {
|
|
54
57
|
"rp-bc": {
|
|
55
58
|
tokenEndpointAuthMethod: "client_secret_basic",
|
|
@@ -57,7 +60,7 @@ describe("POST /session/logout with the session lifecycle module", () => {
|
|
|
57
60
|
allowedRedirectUris: ["https://rp-bc.test/cb"],
|
|
58
61
|
allowedScopes: ["openid"],
|
|
59
62
|
allowedGrantTypes: ["authorization_code"],
|
|
60
|
-
backchannelLogoutUri:
|
|
63
|
+
backchannelLogoutUri: peer.uri,
|
|
61
64
|
},
|
|
62
65
|
},
|
|
63
66
|
});
|
|
@@ -76,7 +79,7 @@ describe("POST /session/logout with the session lifecycle module", () => {
|
|
|
76
79
|
await lifecycle.join(sid, {
|
|
77
80
|
rp: {
|
|
78
81
|
clientId: "rp-bc",
|
|
79
|
-
backchannelLogoutUri:
|
|
82
|
+
backchannelLogoutUri: peer.uri,
|
|
80
83
|
backchannelLogoutSessionRequired: true,
|
|
81
84
|
frontchannelLogoutUri: undefined,
|
|
82
85
|
frontchannelLogoutSessionRequired: undefined,
|
|
@@ -84,10 +87,6 @@ describe("POST /session/logout with the session lifecycle module", () => {
|
|
|
84
87
|
},
|
|
85
88
|
}),
|
|
86
89
|
).toEqual({ outcome: "joined" });
|
|
87
|
-
const posted = vi
|
|
88
|
-
.spyOn(globalThis, "fetch")
|
|
89
|
-
.mockImplementation(async () => new Response(null, { status: 200 }));
|
|
90
|
-
|
|
91
90
|
const csrfToken = csrfTokenOf(cookies);
|
|
92
91
|
const res = await request(app)
|
|
93
92
|
.post("/session/logout")
|
|
@@ -96,12 +95,13 @@ describe("POST /session/logout with the session lifecycle module", () => {
|
|
|
96
95
|
|
|
97
96
|
expect(res.status).toBe(200);
|
|
98
97
|
expect(await families.isFamilyRevoked(familyId)).toBe(true);
|
|
99
|
-
expect(
|
|
98
|
+
expect(peer.tokens).toHaveLength(1);
|
|
100
99
|
expect(await sessions.get(sid)).toBeNull();
|
|
101
100
|
const store = components.sessionLifecycleStore as SessionLifecycleStore;
|
|
102
101
|
expect(readVersionedSessionLifecycle(await store.read(sid))?.value.state).toBe("closed");
|
|
103
102
|
} finally {
|
|
104
103
|
await handle.dispose();
|
|
104
|
+
await peer.close();
|
|
105
105
|
}
|
|
106
106
|
});
|
|
107
107
|
|