@o3co/create-auth-provider 0.13.0 → 0.15.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 +2 -2
- package/templates/standalone/.env.example +27 -0
- package/templates/standalone/Dockerfile +1 -1
- package/templates/standalone/README.ja.md +80 -2
- package/templates/standalone/README.md +160 -4
- package/templates/standalone/config/application.conf +56 -1
- package/templates/standalone/docker-compose.production.yml +7 -0
- package/templates/standalone/docker-compose.yml +7 -0
- package/templates/standalone/package.json +4 -3
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +101 -0
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +62 -3
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +384 -0
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +431 -0
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +57 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +14 -2
- package/templates/standalone/src/__tests__/replica-safety.test.mts +58 -0
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +37 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +96 -2
- package/templates/standalone/src/__tests__/smoke.test.mts +1 -0
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +125 -0
- package/templates/standalone/src/app.mts +9 -1
- package/templates/standalone/src/buildModules.mts +110 -6
- package/templates/standalone/src/modules.mts +96 -6
- package/templates/standalone/src/shutdown.mts +70 -4
- package/templates/standalone/vitest.config.mts +6 -0
- package/templates/standalone/vitest.supertest-loopback.mts +250 -0
- package/templates/versions.json +15 -14
|
@@ -0,0 +1,431 @@
|
|
|
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
|
+
* #593 acceptance criterion 8, at the two HTTP logout endpoints (D14).
|
|
19
|
+
*
|
|
20
|
+
* A federation grant outlives the session it was agreed through. The proof of
|
|
21
|
+
* that used to be a direct call to `cascadeLogout()` — which is the cascade
|
|
22
|
+
* `/oauth/logout` runs, and not what `/session/logout` runs: that endpoint has
|
|
23
|
+
* its own record hygiene (`invalidateSessionRecords`) and reaches the grant
|
|
24
|
+
* store on no path, by construction. A proof of the helper is not a proof of
|
|
25
|
+
* the endpoints. So this drives both, on the standalone, as a deployment
|
|
26
|
+
* composes them: a grant seeded beside a live browser session, the session's
|
|
27
|
+
* records gone afterwards, the grant and its credential exactly as they were,
|
|
28
|
+
* and `/token` still disclosing.
|
|
29
|
+
*
|
|
30
|
+
* The seeded grant's access token is fresh, so the disclosure is the cached
|
|
31
|
+
* one and no upstream is asked — the OIDC federation the connection names is
|
|
32
|
+
* configured with `discovery = false` and hand-typed endpoints so that the
|
|
33
|
+
* deployment boots without a network.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { createHmac } from "node:crypto";
|
|
37
|
+
import { fileURLToPath } from "node:url";
|
|
38
|
+
import {
|
|
39
|
+
type AppConfig,
|
|
40
|
+
AppConfigSchema,
|
|
41
|
+
createApp,
|
|
42
|
+
createKeyStoreFactory,
|
|
43
|
+
defineModule,
|
|
44
|
+
type FederationTokenStore,
|
|
45
|
+
federationGrantAuthorizationRevision,
|
|
46
|
+
federationGrantIdentityRevision,
|
|
47
|
+
InMemoryClientRepository,
|
|
48
|
+
InMemoryUserRepository,
|
|
49
|
+
type MemoryFederationGrantStore,
|
|
50
|
+
memoryRefreshTokenFamilyStoreModule,
|
|
51
|
+
registerBuiltinKeyStores,
|
|
52
|
+
type UserSessionStore,
|
|
53
|
+
} from "@o3co/auth-provider-core";
|
|
54
|
+
import { parseFile } from "@o3co/ts.hocon";
|
|
55
|
+
import { validate } from "@o3co/ts.hocon/zod";
|
|
56
|
+
import express from "express";
|
|
57
|
+
import request from "supertest";
|
|
58
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
59
|
+
import { buildModules } from "../buildModules.mjs";
|
|
60
|
+
import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
|
|
61
|
+
|
|
62
|
+
const DAY = 86_400_000;
|
|
63
|
+
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
64
|
+
|
|
65
|
+
const ISSUER = "https://auth.test";
|
|
66
|
+
const JWT_SECRET = "federation-grants-survive-logout.at-least-32-bytes.ok";
|
|
67
|
+
const UPSTREAM = "https://issuer.example";
|
|
68
|
+
const UPSTREAM_CLIENT_ID = "provider-client";
|
|
69
|
+
const ENCRYPTION_KEY = Buffer.alloc(32, 9).toString("base64");
|
|
70
|
+
|
|
71
|
+
const CLIENT_ID = "worker";
|
|
72
|
+
const CLIENT_SECRET = "worker-secret-value";
|
|
73
|
+
const BASIC = `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")}`;
|
|
74
|
+
const USERNAME = "alice";
|
|
75
|
+
const PASSWORD = "correct-horse-battery-staple";
|
|
76
|
+
const SUB = "u-alice";
|
|
77
|
+
|
|
78
|
+
const CONNECTION = "calendar";
|
|
79
|
+
const SCOPES: readonly string[] = ["openid", "offline_access", "calendar.read"];
|
|
80
|
+
const BOUNDARY = "production";
|
|
81
|
+
const GRANT_ID = "g-1";
|
|
82
|
+
const REFRESH_TOKEN = "SENTINEL-refresh-token";
|
|
83
|
+
const ACCESS_TOKEN = "upstream-access-token";
|
|
84
|
+
|
|
85
|
+
const ENV: Readonly<Record<string, string>> = {
|
|
86
|
+
OAUTH_JWT_ALGORITHM: "HS256",
|
|
87
|
+
OAUTH_JWT_SECRET: JWT_SECRET,
|
|
88
|
+
OAUTH_JWT_ISSUER: ISSUER,
|
|
89
|
+
SESSION_SECRET: "federation-grants-survive-logout-session.at-least-32-bytes.ok",
|
|
90
|
+
SESSION_SECURE: "false",
|
|
91
|
+
SESSION_NAME: "auth.session",
|
|
92
|
+
SESSION_STORAGE_TYPE: "memory",
|
|
93
|
+
CLIENT_USER_TYPE: "yaml",
|
|
94
|
+
REFRESH_TOKEN_FAMILY_STORE_REDIS_URL: "redis://redis.test:6379",
|
|
95
|
+
USER_SESSION_STORES_ADAPTER: "memory",
|
|
96
|
+
RATE_LIMITER_ADAPTER: "memory",
|
|
97
|
+
OAUTH_CODE_ADAPTER: "memory",
|
|
98
|
+
ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
|
|
99
|
+
REPLAY_SEEN_SET_ADAPTER: "memory",
|
|
100
|
+
FEDERATION_TOKEN_STORE_TYPE: "memory",
|
|
101
|
+
CONSENT_STORE_ADAPTER: "none",
|
|
102
|
+
FEDERATION_GRANT_STORE_ADAPTER: "memory",
|
|
103
|
+
FEDERATION_GRANT_INTENT_STORE_ADAPTER: "memory",
|
|
104
|
+
FEDERATION_GRANTS_ENABLED: "true",
|
|
105
|
+
FEDERATION_GRANTS_CONSENT_URL: "/consent/grants",
|
|
106
|
+
// The connect flow is not driven here, and the bundled repository has no
|
|
107
|
+
// identity lookup; "unsupported" is the declaration a deployment without one
|
|
108
|
+
// makes (#613), and what lets a connection be configured at all.
|
|
109
|
+
FEDERATION_GRANTS_IDENTITY_LOOKUP: "unsupported",
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The shipped configuration under the environment above, with the two lists
|
|
114
|
+
* that have no environment form (comment 9 on #593) written over it: the
|
|
115
|
+
* upstream federation the connection names, and the connection.
|
|
116
|
+
*/
|
|
117
|
+
function resolveConfig(): AppConfig {
|
|
118
|
+
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
|
|
119
|
+
const config = validate(
|
|
120
|
+
parseFile(envConfPath, { env: ENV })
|
|
121
|
+
.withFallback(parseFile(applicationConfPath, { env: ENV }))
|
|
122
|
+
.withFallback(parseFile(resolveLibraryReferenceConfPath(), { env: ENV })),
|
|
123
|
+
AppConfigSchema,
|
|
124
|
+
);
|
|
125
|
+
return {
|
|
126
|
+
...config,
|
|
127
|
+
federations: {
|
|
128
|
+
...config.federations,
|
|
129
|
+
upstream: {
|
|
130
|
+
enabled: true,
|
|
131
|
+
type: "oidc",
|
|
132
|
+
issuer: UPSTREAM,
|
|
133
|
+
clientId: UPSTREAM_CLIENT_ID,
|
|
134
|
+
clientSecret: "provider-secret",
|
|
135
|
+
callbackURL: `${ISSUER}/session/oauth/federation/upstream/callback`,
|
|
136
|
+
scopes: [...SCOPES],
|
|
137
|
+
discovery: false,
|
|
138
|
+
endpoints: {
|
|
139
|
+
authorizationEndpoint: `${UPSTREAM}/authorize`,
|
|
140
|
+
tokenEndpoint: `${UPSTREAM}/token`,
|
|
141
|
+
jwksUri: `${UPSTREAM}/jwks`,
|
|
142
|
+
},
|
|
143
|
+
redirectAllowlist: [],
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
federationGrants: {
|
|
147
|
+
...config.federationGrants,
|
|
148
|
+
encryptionKeys: [{ id: "k-test", key: ENCRYPTION_KEY }],
|
|
149
|
+
connections: {
|
|
150
|
+
[CONNECTION]: {
|
|
151
|
+
federation: "upstream",
|
|
152
|
+
scopes: [...SCOPES],
|
|
153
|
+
boundary: BOUNDARY,
|
|
154
|
+
maxAccessTokenLifetime: 3600,
|
|
155
|
+
callbackURL: `${ISSUER}/session/federation-grants/callback/${CONNECTION}`,
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
} as AppConfig;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const testRepositoriesModule = defineModule({
|
|
163
|
+
name: "test:repositories",
|
|
164
|
+
provides: {
|
|
165
|
+
clientRepository: () =>
|
|
166
|
+
new InMemoryClientRepository(
|
|
167
|
+
new Map([
|
|
168
|
+
[
|
|
169
|
+
CLIENT_ID,
|
|
170
|
+
{
|
|
171
|
+
tokenEndpointAuthMethod: "client_secret_basic",
|
|
172
|
+
clientSecret: CLIENT_SECRET,
|
|
173
|
+
allowedRedirectUris: [],
|
|
174
|
+
allowedScopes: ["openid", "profile"],
|
|
175
|
+
allowedAudiences: [],
|
|
176
|
+
allowedGrantTypes: ["session"],
|
|
177
|
+
backchannelLogoutSessionRequired: true,
|
|
178
|
+
frontchannelLogoutSessionRequired: true,
|
|
179
|
+
allowedAzpForFederationToken: false,
|
|
180
|
+
allowedFederationGrantConnections: [CONNECTION],
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
]),
|
|
184
|
+
),
|
|
185
|
+
userRepository: () =>
|
|
186
|
+
new InMemoryUserRepository(
|
|
187
|
+
new Map([[USERNAME, { id: SUB, password: PASSWORD, email: "alice@example.com" }]]),
|
|
188
|
+
),
|
|
189
|
+
} as never,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const testKeyStoreModule = defineModule({
|
|
193
|
+
name: "test:key-store",
|
|
194
|
+
requires: ["config"] as const,
|
|
195
|
+
provides: {
|
|
196
|
+
keyStore: async ({ config: c }) => {
|
|
197
|
+
const factory = createKeyStoreFactory();
|
|
198
|
+
registerBuiltinKeyStores(factory);
|
|
199
|
+
return factory.create({
|
|
200
|
+
type: "local",
|
|
201
|
+
...((c as AppConfig).oauth.jwt.signingKey.local ?? {}),
|
|
202
|
+
});
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
/** The grant as the connect flow would have left it, a fresh upstream token in hand. */
|
|
208
|
+
async function seedGrant(store: MemoryFederationGrantStore): Promise<void> {
|
|
209
|
+
const at = new Date();
|
|
210
|
+
// The revisions as `connections.mts` resolves them from the configuration
|
|
211
|
+
// above: the federation's issuer and client, the connection's scopes and
|
|
212
|
+
// boundary, no resource, no authorization parameters.
|
|
213
|
+
const resolved = {
|
|
214
|
+
upstreamIssuer: UPSTREAM,
|
|
215
|
+
upstreamClientId: UPSTREAM_CLIENT_ID,
|
|
216
|
+
scopes: SCOPES,
|
|
217
|
+
boundary: BOUNDARY,
|
|
218
|
+
authorizationParams: {},
|
|
219
|
+
};
|
|
220
|
+
await store.createPending({
|
|
221
|
+
id: GRANT_ID,
|
|
222
|
+
subject: SUB,
|
|
223
|
+
clientId: CLIENT_ID,
|
|
224
|
+
connection: CONNECTION,
|
|
225
|
+
intent: { handle: "h", expiresAt: new Date(at.getTime() + 600_000) },
|
|
226
|
+
now: at,
|
|
227
|
+
});
|
|
228
|
+
const written = await store.activate({
|
|
229
|
+
grantId: GRANT_ID,
|
|
230
|
+
intentHandle: "h",
|
|
231
|
+
authorization: {
|
|
232
|
+
identityRevision: federationGrantIdentityRevision(resolved),
|
|
233
|
+
authorizationRevision: federationGrantAuthorizationRevision(resolved),
|
|
234
|
+
upstream: { issuer: UPSTREAM, subject: "upstream-alice" },
|
|
235
|
+
scopes: [...SCOPES],
|
|
236
|
+
consent: { at, sid: "sid-consent", scopes: [...SCOPES] },
|
|
237
|
+
authorizedAt: at,
|
|
238
|
+
expiresAt: new Date(at.getTime() + 30 * DAY),
|
|
239
|
+
},
|
|
240
|
+
credentials: {
|
|
241
|
+
refreshToken: REFRESH_TOKEN,
|
|
242
|
+
accessToken: {
|
|
243
|
+
value: ACCESS_TOKEN,
|
|
244
|
+
tokenType: "Bearer",
|
|
245
|
+
obtainedAt: at,
|
|
246
|
+
issuedLifetime: 3600,
|
|
247
|
+
scopes: [...SCOPES],
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
now: at,
|
|
251
|
+
});
|
|
252
|
+
if (!written.ok) throw new Error("fixture: the grant was not activated");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const base64url = (value: unknown): string =>
|
|
256
|
+
Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* An id_token for the session, signed with the deployment's own HS256 key, as
|
|
260
|
+
* the RP would present it: `/oauth/logout` reads `sid` from the hint and from
|
|
261
|
+
* nothing else.
|
|
262
|
+
*/
|
|
263
|
+
function idTokenHint(sid: string): string {
|
|
264
|
+
const header = base64url({ alg: "HS256", kid: "v0", typ: "JWT" });
|
|
265
|
+
const now = Math.floor(Date.now() / 1000);
|
|
266
|
+
const payload = base64url({
|
|
267
|
+
iss: ISSUER,
|
|
268
|
+
sub: SUB,
|
|
269
|
+
aud: CLIENT_ID,
|
|
270
|
+
sid,
|
|
271
|
+
iat: now,
|
|
272
|
+
exp: now + 3600,
|
|
273
|
+
});
|
|
274
|
+
const signature = createHmac("sha256", JWT_SECRET)
|
|
275
|
+
.update(`${header}.${payload}`)
|
|
276
|
+
.digest("base64url");
|
|
277
|
+
return `${header}.${payload}.${signature}`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const claimsOf = (jwt: string): Record<string, unknown> =>
|
|
281
|
+
JSON.parse(Buffer.from(jwt.split(".")[1] ?? "", "base64url").toString("utf8")) as Record<
|
|
282
|
+
string,
|
|
283
|
+
unknown
|
|
284
|
+
>;
|
|
285
|
+
|
|
286
|
+
describe("#593 AC8: a grant outlives the browser session at both logout endpoints (D14)", () => {
|
|
287
|
+
let handleRef: Awaited<ReturnType<typeof createApp>> | undefined;
|
|
288
|
+
|
|
289
|
+
afterEach(async () => {
|
|
290
|
+
await handleRef?.dispose();
|
|
291
|
+
handleRef = undefined;
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
async function boot() {
|
|
295
|
+
const config = resolveConfig();
|
|
296
|
+
const handle = await createApp({
|
|
297
|
+
modules: buildModules(config, {
|
|
298
|
+
keyStoreModule: testKeyStoreModule,
|
|
299
|
+
repositoriesModule: testRepositoriesModule,
|
|
300
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
301
|
+
}),
|
|
302
|
+
bootstrapComponents: { config, pathResolver: (s) => s },
|
|
303
|
+
});
|
|
304
|
+
handleRef = handle;
|
|
305
|
+
const components = handle.components as {
|
|
306
|
+
federationGrantStore: MemoryFederationGrantStore;
|
|
307
|
+
userSessionStore: UserSessionStore;
|
|
308
|
+
federationTokenStore: FederationTokenStore;
|
|
309
|
+
};
|
|
310
|
+
await seedGrant(components.federationGrantStore);
|
|
311
|
+
const app = express();
|
|
312
|
+
app.use(handle.router);
|
|
313
|
+
return { app, config, ...components };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** The real browser half: a CSRF pair, a login, and the cookies plus the token the login reissued. */
|
|
317
|
+
async function login(app: express.Express, config: AppConfig) {
|
|
318
|
+
const csrfRes = await request(app).get("/session/csrf");
|
|
319
|
+
expect(csrfRes.status).toBe(200);
|
|
320
|
+
const csrfToken = csrfRes.body.csrf_token as string;
|
|
321
|
+
const headerName = csrfRes.body.header_name as string;
|
|
322
|
+
const csrfCookies = csrfRes.headers["set-cookie"] as unknown as string[];
|
|
323
|
+
|
|
324
|
+
const loginRes = await request(app)
|
|
325
|
+
.post("/session/login")
|
|
326
|
+
.set("Cookie", csrfCookies)
|
|
327
|
+
.set(headerName, csrfToken)
|
|
328
|
+
.type("form")
|
|
329
|
+
.send({ username: USERNAME, password: PASSWORD });
|
|
330
|
+
expect(loginRes.status).toBe(200);
|
|
331
|
+
|
|
332
|
+
const cookies = loginRes.headers["set-cookie"] as unknown as string[];
|
|
333
|
+
const csrfCookiePrefix = `${config.session.name}.csrf=`;
|
|
334
|
+
const reissued = cookies
|
|
335
|
+
.find((c) => c.startsWith(csrfCookiePrefix))
|
|
336
|
+
?.slice(csrfCookiePrefix.length)
|
|
337
|
+
.split(";")[0];
|
|
338
|
+
expect(reissued).toBeDefined();
|
|
339
|
+
return { cookies, csrfToken: decodeURIComponent(reissued as string), headerName };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** The session's `sid`, read off the access token the session grant mints for it. */
|
|
343
|
+
async function sidOf(app: express.Express, cookies: string[]): Promise<string> {
|
|
344
|
+
const res = await request(app)
|
|
345
|
+
.post("/oauth/token")
|
|
346
|
+
.set("Authorization", BASIC)
|
|
347
|
+
.set("Cookie", cookies)
|
|
348
|
+
.type("form")
|
|
349
|
+
.send({ grant_type: "session" });
|
|
350
|
+
expect(res.status).toBe(200);
|
|
351
|
+
const sid = claimsOf(res.body.access_token as string).sid;
|
|
352
|
+
expect(typeof sid).toBe("string");
|
|
353
|
+
return sid as string;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const disclose = (app: express.Express) =>
|
|
357
|
+
request(app)
|
|
358
|
+
.post(`/oauth/federation-grants/${GRANT_ID}/token`)
|
|
359
|
+
.set("Authorization", BASIC)
|
|
360
|
+
.send({ sub: SUB });
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* What a logout has to leave: the record, its credential — the values, not
|
|
364
|
+
* only a readable state, since a rotated or dropped refresh token beside an
|
|
365
|
+
* untouched access token would still read `"ok"` — and a disclosure that
|
|
366
|
+
* still answers.
|
|
367
|
+
*/
|
|
368
|
+
async function expectGrantUntouched(app: express.Express, store: MemoryFederationGrantStore) {
|
|
369
|
+
expect((await store.find(GRANT_ID, new Date()))?.status).toBe("active");
|
|
370
|
+
const opened = await store.open(GRANT_ID, new Date());
|
|
371
|
+
if (opened?.credentials.state !== "ok")
|
|
372
|
+
throw new Error("the grant's credential is not readable");
|
|
373
|
+
expect(opened.credentials.value).toMatchObject({
|
|
374
|
+
refreshToken: REFRESH_TOKEN,
|
|
375
|
+
accessToken: { value: ACCESS_TOKEN },
|
|
376
|
+
});
|
|
377
|
+
const response = await disclose(app);
|
|
378
|
+
expect(response.status).toBe(200);
|
|
379
|
+
expect(response.body.access_token).toBe(ACCESS_TOKEN);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
it("POST /session/logout ends the session's own records and leaves the grant spendable", async () => {
|
|
383
|
+
const { app, config, federationGrantStore, userSessionStore, federationTokenStore } =
|
|
384
|
+
await boot();
|
|
385
|
+
const { cookies, csrfToken, headerName } = await login(app, config);
|
|
386
|
+
const sid = await sidOf(app, cookies);
|
|
387
|
+
// A session-bound upstream token beside the grant: the record #276's
|
|
388
|
+
// guarantee is about, keyed by this session.
|
|
389
|
+
await federationTokenStore.attach(sid, "upstream", {
|
|
390
|
+
accessToken: "session-bound-upstream-token",
|
|
391
|
+
expiresAt: null,
|
|
392
|
+
});
|
|
393
|
+
expect(await userSessionStore.get(sid)).not.toBeNull();
|
|
394
|
+
await expectGrantUntouched(app, federationGrantStore);
|
|
395
|
+
|
|
396
|
+
const logout = await request(app)
|
|
397
|
+
.post("/session/logout")
|
|
398
|
+
.set("Cookie", cookies)
|
|
399
|
+
.set(headerName, csrfToken);
|
|
400
|
+
expect([200, 204]).toContain(logout.status);
|
|
401
|
+
|
|
402
|
+
// The session-bound records are gone (#276 holds) ...
|
|
403
|
+
expect(await userSessionStore.get(sid)).toBeNull();
|
|
404
|
+
expect(await federationTokenStore.get(sid, "upstream")).toBeNull();
|
|
405
|
+
// ... and the grant is not among them.
|
|
406
|
+
await expectGrantUntouched(app, federationGrantStore);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("POST /oauth/logout runs the cascade and leaves the grant spendable", async () => {
|
|
410
|
+
const { app, config, federationGrantStore, userSessionStore, federationTokenStore } =
|
|
411
|
+
await boot();
|
|
412
|
+
const { cookies } = await login(app, config);
|
|
413
|
+
const sid = await sidOf(app, cookies);
|
|
414
|
+
await federationTokenStore.attach(sid, "upstream", {
|
|
415
|
+
accessToken: "session-bound-upstream-token",
|
|
416
|
+
expiresAt: null,
|
|
417
|
+
});
|
|
418
|
+
expect(await userSessionStore.get(sid)).not.toBeNull();
|
|
419
|
+
await expectGrantUntouched(app, federationGrantStore);
|
|
420
|
+
|
|
421
|
+
const logout = await request(app)
|
|
422
|
+
.post("/oauth/logout")
|
|
423
|
+
.type("form")
|
|
424
|
+
.send({ id_token_hint: idTokenHint(sid) });
|
|
425
|
+
expect(logout.status).toBe(200);
|
|
426
|
+
|
|
427
|
+
expect(await userSessionStore.get(sid)).toBeNull();
|
|
428
|
+
expect(await federationTokenStore.get(sid, "upstream")).toBeNull();
|
|
429
|
+
await expectGrantUntouched(app, federationGrantStore);
|
|
430
|
+
});
|
|
431
|
+
});
|
|
@@ -112,3 +112,60 @@ describe("googleFederationConfigModule — redirect-policy plumbing (#278)", ()
|
|
|
112
112
|
expect(out.redirectAllowlist).toEqual(["https://app.example.com/dashboard"]);
|
|
113
113
|
});
|
|
114
114
|
});
|
|
115
|
+
|
|
116
|
+
describe("googleFederationConfigModule — requireAuthorizationResponseIss (#597)", () => {
|
|
117
|
+
it("is absent by default, so the provider's own default (required) applies", () => {
|
|
118
|
+
expect("requireAuthorizationResponseIss" in buildConfig(credentials)).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("forwards the operator's escape hatch", () => {
|
|
122
|
+
expect(
|
|
123
|
+
buildConfig({ ...credentials, requireAuthorizationResponseIss: false })
|
|
124
|
+
.requireAuthorizationResponseIss,
|
|
125
|
+
).toBe(false);
|
|
126
|
+
expect(
|
|
127
|
+
buildConfig({ ...credentials, requireAuthorizationResponseIss: true })
|
|
128
|
+
.requireAuthorizationResponseIss,
|
|
129
|
+
).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("reads the strings an environment override produces", () => {
|
|
133
|
+
expect(
|
|
134
|
+
buildConfig({ ...credentials, requireAuthorizationResponseIss: "false" })
|
|
135
|
+
.requireAuthorizationResponseIss,
|
|
136
|
+
).toBe(false);
|
|
137
|
+
expect(
|
|
138
|
+
buildConfig({ ...credentials, requireAuthorizationResponseIss: "true" })
|
|
139
|
+
.requireAuthorizationResponseIss,
|
|
140
|
+
).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("reads the spellings every other env-overridable boolean accepts", () => {
|
|
144
|
+
for (const [raw, expected] of [
|
|
145
|
+
["False", false],
|
|
146
|
+
[" false ", false],
|
|
147
|
+
["0", false],
|
|
148
|
+
["TRUE", true],
|
|
149
|
+
["1", true],
|
|
150
|
+
] as const) {
|
|
151
|
+
expect(
|
|
152
|
+
buildConfig({ ...credentials, requireAuthorizationResponseIss: raw })
|
|
153
|
+
.requireAuthorizationResponseIss,
|
|
154
|
+
).toBe(expected);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("refuses an empty value, which elsewhere reads as false: exported-but-empty must not switch this check off", () => {
|
|
159
|
+
expect(() => buildConfig({ ...credentials, requireAuthorizationResponseIss: "" })).toThrow(
|
|
160
|
+
/federations\.google\.requireAuthorizationResponseIss/,
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("refuses anything else: a typo must not silently switch the check off, or leave it on", () => {
|
|
165
|
+
for (const bad of ["no", "off", 0, 1, []]) {
|
|
166
|
+
expect(() => buildConfig({ ...credentials, requireAuthorizationResponseIss: bad })).toThrow(
|
|
167
|
+
/federations\.google\.requireAuthorizationResponseIss/,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -37,6 +37,18 @@ function buildApp(probes: Parameters<ReturnType<typeof createMetrics>["route"]>[
|
|
|
37
37
|
return app;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Starts `app` on 127.0.0.1, the address the raw-socket tests dial. A hostless
|
|
42
|
+
* `listen(0)` binds `[::]`, and on macOS the port it gets can be one another
|
|
43
|
+
* process holds on 127.0.0.1 — the request then reaches that process and can
|
|
44
|
+
* hang until the test times out (#556).
|
|
45
|
+
*/
|
|
46
|
+
function listenOnLoopback(app: express.Express): Promise<ReturnType<express.Express["listen"]>> {
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
const server = app.listen(0, "127.0.0.1", () => resolve(server));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
40
52
|
describe("GET /metrics", () => {
|
|
41
53
|
it("serves the Prometheus text exposition format", async () => {
|
|
42
54
|
const res = await request(buildApp()).get("/metrics");
|
|
@@ -95,7 +107,7 @@ describe("GET /metrics", () => {
|
|
|
95
107
|
// route label, reachable without any access to /metrics. Sent over a real
|
|
96
108
|
// socket because supertest cannot express a non-standard method.
|
|
97
109
|
const app = buildApp();
|
|
98
|
-
const server = app
|
|
110
|
+
const server = await listenOnLoopback(app);
|
|
99
111
|
const port = (server.address() as { port: number }).port;
|
|
100
112
|
try {
|
|
101
113
|
for (const method of ["FOO", "M000001", "PURGE"]) {
|
|
@@ -135,7 +147,7 @@ describe("GET /metrics", () => {
|
|
|
135
147
|
void res;
|
|
136
148
|
});
|
|
137
149
|
|
|
138
|
-
const server = app
|
|
150
|
+
const server = await listenOnLoopback(app);
|
|
139
151
|
const port = (server.address() as { port: number }).port;
|
|
140
152
|
try {
|
|
141
153
|
const controller = new AbortController();
|
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
defineModule,
|
|
61
61
|
InMemoryClientRepository,
|
|
62
62
|
InMemoryUserRepository,
|
|
63
|
+
memoryRefreshTokenFamilyStoreModule,
|
|
63
64
|
registerBuiltinKeyStores,
|
|
64
65
|
replicaUnsafeReason,
|
|
65
66
|
} from "@o3co/auth-provider-core";
|
|
@@ -162,6 +163,9 @@ const ALL_REDIS_ENV: Readonly<Record<string, string>> = {
|
|
|
162
163
|
REPLAY_SEEN_SET_ADAPTER: "redis",
|
|
163
164
|
FEDERATION_TOKEN_STORE_TYPE: "redis",
|
|
164
165
|
REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: ENCRYPTION_KEY,
|
|
166
|
+
// #561: the consent step for clients that are not first-party, on the
|
|
167
|
+
// shared store — the one switch value `multi` accepts besides `none`.
|
|
168
|
+
CONSENT_STORE_ADAPTER: "redis",
|
|
165
169
|
};
|
|
166
170
|
|
|
167
171
|
function resolveConfig(env: Record<string, string>): AppConfig {
|
|
@@ -397,6 +401,60 @@ describe('#456: federationTokenStore.type = "redis" in the standalone', () => {
|
|
|
397
401
|
});
|
|
398
402
|
});
|
|
399
403
|
|
|
404
|
+
describe('#561: consentStore.adapter = "redis" in the standalone', () => {
|
|
405
|
+
let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
|
|
406
|
+
|
|
407
|
+
afterEach(async () => {
|
|
408
|
+
await handleRef?.dispose();
|
|
409
|
+
handleRef = undefined;
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("selects the Redis consent module, providing both slots, and not the memory one", () => {
|
|
413
|
+
const modules = modulesFor(resolveConfig(ALL_REDIS_ENV));
|
|
414
|
+
const names = modules.map((m) => m.name);
|
|
415
|
+
expect(names).toContain("redis-consent-store");
|
|
416
|
+
expect(names).not.toContain("core-consent-store-memory");
|
|
417
|
+
// Exactly one provider for each slot: the consent step refuses a
|
|
418
|
+
// composition with one and not the other, and two would collide.
|
|
419
|
+
for (const slot of ["consentStore", "pendingConsentStore"]) {
|
|
420
|
+
const providers = modules.filter((m) => Object.keys(m.provides ?? {}).includes(slot));
|
|
421
|
+
expect(
|
|
422
|
+
providers.map((m) => m.name),
|
|
423
|
+
slot,
|
|
424
|
+
).toEqual(["redis-consent-store"]);
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("pulls the shared clients module in for the consent stores alone", () => {
|
|
429
|
+
// Every other store on memory, single replica: the consent module still
|
|
430
|
+
// needs its two client slots, which only the shared clients module
|
|
431
|
+
// provides — the #439 shape otherwise.
|
|
432
|
+
const config = resolveConfig({
|
|
433
|
+
...ALL_REDIS_ENV,
|
|
434
|
+
DEPLOYMENT_MODE: "single",
|
|
435
|
+
USER_SESSION_STORES_ADAPTER: "memory",
|
|
436
|
+
OAUTH_CODE_ADAPTER: "memory",
|
|
437
|
+
RATE_LIMITER_ADAPTER: "memory",
|
|
438
|
+
ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
|
|
439
|
+
REPLAY_SEEN_SET_ADAPTER: "memory",
|
|
440
|
+
FEDERATION_TOKEN_STORE_TYPE: "memory",
|
|
441
|
+
});
|
|
442
|
+
const names = buildModules(config, {
|
|
443
|
+
keyStoreModule: testKeyStoreModule,
|
|
444
|
+
repositoriesModule: testRepositoriesModule,
|
|
445
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
446
|
+
}).map((m) => m.name);
|
|
447
|
+
expect(names).toContain("redis-consent-store");
|
|
448
|
+
expect(names).toContain("standalone:redis-clients");
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("boots under DEPLOYMENT_MODE=multi with both slots resolved to the Redis adapters", async () => {
|
|
452
|
+
handleRef = await boot(resolveConfig(ALL_REDIS_ENV));
|
|
453
|
+
expect(handleRef.components.consentStore?.kind).toBe("redis");
|
|
454
|
+
expect(handleRef.components.pendingConsentStore?.kind).toBe("redis");
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
400
458
|
describe("#473: the Redis federation store's plaintext guard, booted from the shipped config", () => {
|
|
401
459
|
let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
|
|
402
460
|
let warnSpy: ReturnType<typeof vi.spyOn>;
|
|
@@ -324,6 +324,29 @@ describe("#512 — the shipped suite is green outside this repository", () => {
|
|
|
324
324
|
).toBe(false);
|
|
325
325
|
}
|
|
326
326
|
});
|
|
327
|
+
|
|
328
|
+
it("ships every setup file vitest.config.mts loads, into the `test` image as well (#556)", async () => {
|
|
329
|
+
// vitest refuses to start when a `setupFiles` entry is missing, so a setup
|
|
330
|
+
// file the image never copied fails the whole of `make test`, not one test.
|
|
331
|
+
const configPath = fileURLToPath(new URL("../../vitest.config.mts", import.meta.url));
|
|
332
|
+
const { default: config } = (await import(configPath)) as {
|
|
333
|
+
default: { test?: { setupFiles?: string | readonly string[] } };
|
|
334
|
+
};
|
|
335
|
+
const setupFiles = [config.test?.setupFiles ?? []].flat();
|
|
336
|
+
expect(setupFiles, "the #556 supertest loopback guard is not wired in").not.toHaveLength(0);
|
|
337
|
+
|
|
338
|
+
const copied = copiedIntoStage(read("/Dockerfile"), "test");
|
|
339
|
+
const dockerignore = read("/.dockerignore");
|
|
340
|
+
for (const entry of setupFiles) {
|
|
341
|
+
const file = entry.replace(/^\.\//, "");
|
|
342
|
+
expect(existsSync(`${standaloneDir}${file}`), `${file} does not exist`).toBe(true);
|
|
343
|
+
expect(copied.has(file), `${file} is not copied into the test image`).toBe(true);
|
|
344
|
+
expect(
|
|
345
|
+
dockerignoreExcludes(dockerignore, file),
|
|
346
|
+
`${file} is excluded from the build context`,
|
|
347
|
+
).toBe(false);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
327
350
|
});
|
|
328
351
|
|
|
329
352
|
/**
|
|
@@ -389,3 +412,17 @@ function dockerignoreExcludes(dockerignore: string, file: string): boolean {
|
|
|
389
412
|
}
|
|
390
413
|
return excluded;
|
|
391
414
|
}
|
|
415
|
+
|
|
416
|
+
describe("#593 slice 7 — the compose files give the process time to finish a shutdown", () => {
|
|
417
|
+
it.each(["/docker-compose.yml", "/docker-compose.production.yml"])(
|
|
418
|
+
"%s declares a stop_grace_period covering the HTTP drain, the grants cleanup and an exit margin",
|
|
419
|
+
(rel) => {
|
|
420
|
+
// compose's own default is ten seconds — the drain budget alone. With
|
|
421
|
+
// federation grants on, the cleanup that drains a rotated credential's
|
|
422
|
+
// write gets 45 more, and SIGKILL must arrive after that, not during.
|
|
423
|
+
const match = /^\s+stop_grace_period:\s*(\d+)s\s*$/m.exec(read(rel));
|
|
424
|
+
expect(match, `${rel} has no stop_grace_period`).not.toBeNull();
|
|
425
|
+
expect(Number(match?.[1])).toBeGreaterThanOrEqual(60);
|
|
426
|
+
},
|
|
427
|
+
);
|
|
428
|
+
});
|