@o3co/create-auth-provider 0.17.0-rc.3 → 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 +3 -3
- package/templates/standalone/README.ja.md +5 -4
- package/templates/standalone/README.md +23 -4
- package/templates/standalone/config/clients.yaml.example +11 -5
- package/templates/standalone/config/reference.conf +5 -2
- package/templates/standalone/package.json +7 -6
- 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 +14 -12
- 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__/logout-session-cookie.test.mts +113 -0
- 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__/repositories-logger.test.mts +119 -0
- 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/modules.mts +9 -3
- package/templates/standalone/src/sections.mts +3 -2
- package/templates/standalone/src/securityHeaders.mts +36 -0
- package/templates/standalone/tests/package-lock.json +95 -95
- package/templates/standalone/tests/package.json +1 -1
- package/templates/versions.json +18 -18
|
@@ -0,0 +1,168 @@
|
|
|
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 front-channel logout page, served through the template behind its
|
|
19
|
+
* security headers, works under the one policy the response carries: its
|
|
20
|
+
* relying party's frame and its redirect are allowed, and nothing else is.
|
|
21
|
+
* Every other answer keeps the template's policy.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
25
|
+
import request from "supertest";
|
|
26
|
+
import { describe, expect, it } from "vitest";
|
|
27
|
+
import {
|
|
28
|
+
authorize,
|
|
29
|
+
basic,
|
|
30
|
+
codeFrom,
|
|
31
|
+
compose,
|
|
32
|
+
login,
|
|
33
|
+
PKCE,
|
|
34
|
+
} from "./all-modules-composition.fixture.mjs";
|
|
35
|
+
|
|
36
|
+
const RP = {
|
|
37
|
+
id: "rp-fc",
|
|
38
|
+
secret: "rp-fc-secret-long-enough",
|
|
39
|
+
redirectUri: "https://rp-fc.test/cb",
|
|
40
|
+
frontchannelLogoutUri: "https://rp-fc.test:8443/frontchannel-logout",
|
|
41
|
+
postLogoutRedirectUri: "https://rp-fc.test/signed-out",
|
|
42
|
+
} as const;
|
|
43
|
+
|
|
44
|
+
/** A serialized policy as directive name → sources; a directive named twice fails. */
|
|
45
|
+
const parsePolicy = (policy: string): Map<string, string[]> => {
|
|
46
|
+
const directives = new Map<string, string[]>();
|
|
47
|
+
for (const part of policy.split(";")) {
|
|
48
|
+
const [name, ...sources] = part.trim().split(/\s+/);
|
|
49
|
+
if (name === undefined || name === "") continue;
|
|
50
|
+
if (directives.has(name)) throw new Error(`directive ${name} appears twice`);
|
|
51
|
+
directives.set(name, sources);
|
|
52
|
+
}
|
|
53
|
+
return directives;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** The sources a fetch directive resolves to: its own, else `default-src`'s. */
|
|
57
|
+
const effective = (policy: Map<string, string[]>, directive: string): string[] =>
|
|
58
|
+
policy.get(directive) ?? policy.get("default-src") ?? [];
|
|
59
|
+
|
|
60
|
+
const decode = (value: string): string =>
|
|
61
|
+
value
|
|
62
|
+
.replace(/"/g, '"')
|
|
63
|
+
.replace(/'/g, "'")
|
|
64
|
+
.replace(/</g, "<")
|
|
65
|
+
.replace(/>/g, ">")
|
|
66
|
+
.replace(/&/g, "&");
|
|
67
|
+
|
|
68
|
+
/** How many `Content-Security-Policy` header lines the response carried, as sent. */
|
|
69
|
+
const policyHeaderCount = (response: object): number => {
|
|
70
|
+
const raw = (response as { res?: { rawHeaders?: unknown } }).res?.rawHeaders;
|
|
71
|
+
if (!Array.isArray(raw)) throw new Error("the response carries no raw headers");
|
|
72
|
+
return raw.filter(
|
|
73
|
+
(name, i) => i % 2 === 0 && String(name).toLowerCase() === "content-security-policy",
|
|
74
|
+
).length;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
describe("the front-channel logout page under the template's security headers", () => {
|
|
78
|
+
it("is allowed its relying party's frame and its redirect by the one policy it is served with", async () => {
|
|
79
|
+
const { app, handle } = await compose({
|
|
80
|
+
extraClients: {
|
|
81
|
+
[RP.id]: {
|
|
82
|
+
tokenEndpointAuthMethod: "client_secret_basic",
|
|
83
|
+
clientSecret: RP.secret,
|
|
84
|
+
allowedRedirectUris: [RP.redirectUri],
|
|
85
|
+
allowedScopes: ["openid"],
|
|
86
|
+
allowedGrantTypes: ["authorization_code"],
|
|
87
|
+
frontchannelLogoutUri: RP.frontchannelLogoutUri,
|
|
88
|
+
postLogoutRedirectUris: [RP.postLogoutRedirectUri],
|
|
89
|
+
firstParty: true,
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
try {
|
|
94
|
+
// A session the relying party joined by exchanging a code.
|
|
95
|
+
const { cookies } = await login(app);
|
|
96
|
+
const exchanged = await request(app)
|
|
97
|
+
.post("/oauth/token")
|
|
98
|
+
.set("Authorization", basic(RP))
|
|
99
|
+
.type("form")
|
|
100
|
+
.send({
|
|
101
|
+
grant_type: "authorization_code",
|
|
102
|
+
code: codeFrom(await authorize(app, cookies, RP)),
|
|
103
|
+
redirect_uri: RP.redirectUri,
|
|
104
|
+
code_verifier: PKCE.verifier,
|
|
105
|
+
});
|
|
106
|
+
expect(exchanged.status).toBe(200);
|
|
107
|
+
|
|
108
|
+
const res = await request(app)
|
|
109
|
+
.get("/oauth/logout")
|
|
110
|
+
.set("Accept", "text/html")
|
|
111
|
+
.set("Cookie", cookies)
|
|
112
|
+
.query({
|
|
113
|
+
id_token_hint: exchanged.body.id_token as string,
|
|
114
|
+
post_logout_redirect_uri: RP.postLogoutRedirectUri,
|
|
115
|
+
state: "s-1",
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
expect(res.status).toBe(200);
|
|
119
|
+
expect(res.headers["content-type"]).toBe("text/html; charset=utf-8");
|
|
120
|
+
expect(policyHeaderCount(res)).toBe(1);
|
|
121
|
+
const policy = parsePolicy(res.headers["content-security-policy"] as string);
|
|
122
|
+
|
|
123
|
+
// The frame: the relying party's, from the one origin the policy allows.
|
|
124
|
+
const srcs = [...res.text.matchAll(/<iframe src="([^"]*)"/g)].map((m) => decode(m[1] ?? ""));
|
|
125
|
+
expect(srcs).toHaveLength(1);
|
|
126
|
+
expect(new URL(srcs[0] ?? "").origin).toBe("https://rp-fc.test:8443");
|
|
127
|
+
expect(effective(policy, "frame-src")).toEqual(["https://rp-fc.test:8443"]);
|
|
128
|
+
|
|
129
|
+
// The redirect: one inline script, allowed by its hash, sending the
|
|
130
|
+
// browser to the registered URI with the state.
|
|
131
|
+
const scripts = [
|
|
132
|
+
...res.text.matchAll(/<script data-target="([^"]*)" data-delay="(\d+)">([^<]*)<\/script>/g),
|
|
133
|
+
];
|
|
134
|
+
expect(scripts).toHaveLength(1);
|
|
135
|
+
expect(res.text.match(/<script/g)).toHaveLength(1);
|
|
136
|
+
const [, target, , text] = scripts[0] ?? [];
|
|
137
|
+
expect(decode(target ?? "")).toBe(`${RP.postLogoutRedirectUri}?state=s-1`);
|
|
138
|
+
const hash = createHash("sha256")
|
|
139
|
+
.update(text ?? "", "utf8")
|
|
140
|
+
.digest("base64");
|
|
141
|
+
expect(effective(policy, "script-src")).toEqual([`'sha256-${hash}'`]);
|
|
142
|
+
|
|
143
|
+
// Nothing else.
|
|
144
|
+
expect(policy.get("default-src")).toEqual(["'none'"]);
|
|
145
|
+
expect(effective(policy, "style-src")).toEqual(["'none'"]);
|
|
146
|
+
expect(effective(policy, "connect-src")).toEqual(["'none'"]);
|
|
147
|
+
expect(policy.get("frame-ancestors")).toEqual(["'none'"]);
|
|
148
|
+
expect(res.headers["cache-control"]).toBe("no-store");
|
|
149
|
+
} finally {
|
|
150
|
+
await handle.dispose();
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("leaves the template's policy on every other answer", async () => {
|
|
155
|
+
const { app, handle } = await compose();
|
|
156
|
+
try {
|
|
157
|
+
const res = await request(app).get("/.well-known/openid-configuration");
|
|
158
|
+
|
|
159
|
+
expect(policyHeaderCount(res)).toBe(1);
|
|
160
|
+
const policy = parsePolicy(res.headers["content-security-policy"] as string);
|
|
161
|
+
expect(policy.get("default-src")).toEqual(["'none'"]);
|
|
162
|
+
expect(policy.get("frame-ancestors")).toEqual(["'none'"]);
|
|
163
|
+
expect(effective(policy, "frame-src")).toEqual(["'none'"]);
|
|
164
|
+
} finally {
|
|
165
|
+
await handle.dispose();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -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
|
});
|
|
@@ -0,0 +1,113 @@
|
|
|
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's two logout endpoints and the session cookie: the session
|
|
19
|
+
* store module expires the cookie of a session destroyed during the request,
|
|
20
|
+
* whichever route destroyed it, so `POST /oauth/logout` — whose package knows
|
|
21
|
+
* no cookie name — and `POST /session/logout` both answer with the cookie
|
|
22
|
+
* expired, with the attributes the login set it with.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { SessionCookiePolicy } from "@o3co/auth-provider-core";
|
|
26
|
+
import request from "supertest";
|
|
27
|
+
import { describe, expect, it } from "vitest";
|
|
28
|
+
import { authorize, codeFrom, compose, login, redeem } from "./all-modules-composition.fixture.mjs";
|
|
29
|
+
|
|
30
|
+
/** The `Set-Cookie` lines among `lines` for the cookie `name`. */
|
|
31
|
+
const linesNamed = (lines: readonly string[], name: string): string[] =>
|
|
32
|
+
lines.filter((line) => line.startsWith(`${name}=`));
|
|
33
|
+
|
|
34
|
+
/** A response's `Set-Cookie` lines. */
|
|
35
|
+
const setCookies = (res: request.Response): string[] =>
|
|
36
|
+
([] as string[]).concat(res.headers["set-cookie"] ?? []);
|
|
37
|
+
|
|
38
|
+
/** A `Set-Cookie` line's attributes, lower-cased, with its value and expiry left out. */
|
|
39
|
+
const attributesOf = (line: string): string[] =>
|
|
40
|
+
line
|
|
41
|
+
.split(";")
|
|
42
|
+
.slice(1)
|
|
43
|
+
.map((part) => part.trim().toLowerCase())
|
|
44
|
+
.filter((part) => !part.startsWith("expires=") && !part.startsWith("max-age="))
|
|
45
|
+
.sort();
|
|
46
|
+
|
|
47
|
+
/** The CSRF token a login reissued, read off its double-submit cookie. */
|
|
48
|
+
const csrfTokenOf = (cookies: readonly string[]): string => {
|
|
49
|
+
const cookie = cookies.find((c) => /^[^=]*\.csrf=/.test(c));
|
|
50
|
+
if (cookie === undefined) throw new Error("the login reissued no CSRF cookie");
|
|
51
|
+
return decodeURIComponent(cookie.slice(cookie.indexOf("=") + 1).split(";")[0] ?? "");
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The one `Set-Cookie` line of `res` for the session cookie, checked to
|
|
56
|
+
* expire it with the attributes of `setLine`, the line the login set it with.
|
|
57
|
+
*/
|
|
58
|
+
function expectExpired(res: request.Response, name: string, setLine: string): void {
|
|
59
|
+
const lines = linesNamed(setCookies(res), name);
|
|
60
|
+
expect(lines).toHaveLength(1);
|
|
61
|
+
const [line] = lines as [string];
|
|
62
|
+
expect(line.startsWith(`${name}=;`)).toBe(true);
|
|
63
|
+
expect(line).toContain("Expires=Thu, 01 Jan 1970 00:00:00 GMT");
|
|
64
|
+
expect(attributesOf(line)).toEqual(attributesOf(setLine));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe("the session cookie at the template's logout endpoints", () => {
|
|
68
|
+
it("POST /oauth/logout expires the session cookie, with the attributes the login set it with", async () => {
|
|
69
|
+
const { app, handle } = await compose();
|
|
70
|
+
try {
|
|
71
|
+
const policy = handle.components.sessionCookiePolicy as SessionCookiePolicy;
|
|
72
|
+
const { res: signedIn, cookies } = await login(app);
|
|
73
|
+
expect(signedIn.status).toBe(200);
|
|
74
|
+
const [setLine] = linesNamed(cookies, policy.name);
|
|
75
|
+
expect(setLine).toBeDefined();
|
|
76
|
+
const tokens = await redeem(app, codeFrom(await authorize(app, cookies)));
|
|
77
|
+
expect(tokens.status).toBe(200);
|
|
78
|
+
|
|
79
|
+
const res = await request(app)
|
|
80
|
+
.post("/oauth/logout")
|
|
81
|
+
.set("Cookie", cookies)
|
|
82
|
+
.type("form")
|
|
83
|
+
.send({ id_token_hint: tokens.body.id_token as string });
|
|
84
|
+
|
|
85
|
+
expect(res.status).toBe(200);
|
|
86
|
+
expect(res.body).toEqual({ logged_out: true });
|
|
87
|
+
expectExpired(res, policy.name, setLine as string);
|
|
88
|
+
} finally {
|
|
89
|
+
await handle.dispose();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("POST /session/logout expires the session cookie, with the attributes the login set it with", async () => {
|
|
94
|
+
const { app, handle } = await compose();
|
|
95
|
+
try {
|
|
96
|
+
const policy = handle.components.sessionCookiePolicy as SessionCookiePolicy;
|
|
97
|
+
const { res: signedIn, cookies } = await login(app);
|
|
98
|
+
expect(signedIn.status).toBe(200);
|
|
99
|
+
const [setLine] = linesNamed(cookies, policy.name);
|
|
100
|
+
expect(setLine).toBeDefined();
|
|
101
|
+
|
|
102
|
+
const res = await request(app)
|
|
103
|
+
.post("/session/logout")
|
|
104
|
+
.set("Cookie", cookies)
|
|
105
|
+
.set("x-csrf-token", csrfTokenOf(cookies));
|
|
106
|
+
|
|
107
|
+
expect(res.status).toBe(200);
|
|
108
|
+
expectExpired(res, policy.name, setLine as string);
|
|
109
|
+
} finally {
|
|
110
|
+
await handle.dispose();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -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"]),
|