@o3co/create-auth-provider 0.15.0 → 0.16.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/README.ja.md +93 -47
- package/README.md +92 -46
- package/package.json +1 -1
- package/templates/standalone/.env.example +12 -0
- package/templates/standalone/Dockerfile +1 -1
- package/templates/standalone/README.ja.md +516 -154
- package/templates/standalone/README.md +218 -91
- package/templates/standalone/config/application.conf +26 -3
- package/templates/standalone/gitignore +3 -2
- package/templates/standalone/package.json +1 -1
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +49 -15
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1103 -0
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +241 -0
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1100 -0
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +19 -5
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +64 -3
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +15 -0
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +133 -0
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +28 -0
- package/templates/standalone/src/__tests__/google-session.fixture.mts +304 -0
- package/templates/standalone/src/__tests__/host-routes.test.mts +141 -0
- package/templates/standalone/src/__tests__/listen.test.mts +128 -0
- package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +20 -5
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +187 -0
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +139 -0
- package/templates/standalone/src/__tests__/replica-safety.test.mts +13 -3
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +149 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +157 -20
- package/templates/standalone/src/__tests__/smoke.test.mts +39 -20
- package/templates/standalone/src/__tests__/store-credential.test.mts +276 -0
- package/templates/standalone/src/app.mts +21 -50
- package/templates/standalone/src/buildModules.mts +33 -16
- package/templates/standalone/src/listen.mts +66 -0
- package/templates/standalone/src/logger.mts +7 -4
- package/templates/standalone/src/modules.mts +42 -8
- package/templates/standalone/src/routes.mts +95 -0
- package/templates/standalone/src/shutdown.mts +20 -14
- package/templates/versions.json +15 -15
- package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
- package/templates/standalone/src/terminalError.mts +0 -60
|
@@ -52,6 +52,7 @@ import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPa
|
|
|
52
52
|
const standaloneDir = fileURLToPath(new URL("../..", import.meta.url));
|
|
53
53
|
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
54
54
|
const readmePath = fileURLToPath(new URL("../../README.md", import.meta.url));
|
|
55
|
+
const readmeJaPath = fileURLToPath(new URL("../../README.ja.md", import.meta.url));
|
|
55
56
|
|
|
56
57
|
/**
|
|
57
58
|
* Every environment variable the shipped artifact documents, with a value in
|
|
@@ -194,6 +195,7 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
|
|
|
194
195
|
FEDERATIONS_GOOGLE_CLIENT_ID: "google-client-id",
|
|
195
196
|
FEDERATIONS_GOOGLE_CLIENT_SECRET: "google-client-secret",
|
|
196
197
|
FEDERATIONS_GOOGLE_CALLBACK_URL: "https://auth.test/session/oauth/federation/google/callback",
|
|
198
|
+
FEDERATIONS_GOOGLE_ACCESS_TYPE: "online",
|
|
197
199
|
// #524: the generic OIDC federation the template ships disabled.
|
|
198
200
|
FEDERATIONS_OIDC_ENABLED: "true",
|
|
199
201
|
FEDERATIONS_OIDC_ISSUER: "https://idp.test",
|
|
@@ -214,6 +216,8 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
|
|
|
214
216
|
"https://users.example.com/find-subject-by-federated-identity",
|
|
215
217
|
CLIENT_USER_TIMEOUT: "5000",
|
|
216
218
|
CLIENT_USER_MAX_RESPONSE_BYTES: "1048576",
|
|
219
|
+
// The credential the http user adapter presents to the Store; >= 32 bytes.
|
|
220
|
+
CLIENT_USER_BEARER_TOKEN: "0328d706529061d93abd6d826e09ef0f0a1e71a12af813b29e5cd2977b7dc63a",
|
|
217
221
|
CLIENT_CODE_TYPE: "redis",
|
|
218
222
|
CLIENT_CODE_DEFAULT_EXPIRES_IN: "600",
|
|
219
223
|
CLIENT_CODE_ENDPOINT_URI: "redis://redis:6379",
|
|
@@ -306,11 +310,9 @@ function substitutionsIn(path: string): Set<string> {
|
|
|
306
310
|
* The underscore is what separates an environment variable from the HTTP
|
|
307
311
|
* methods in the endpoint table, which share the shouting-case shape.
|
|
308
312
|
*/
|
|
309
|
-
function documentedInReadme(): Set<string> {
|
|
313
|
+
function documentedInReadme(path: string = readmePath): Set<string> {
|
|
310
314
|
const found = new Set<string>();
|
|
311
|
-
for (const match of readFileSync(
|
|
312
|
-
/^\|\s*`([A-Z][A-Z0-9_]*)`\s*\|/gm,
|
|
313
|
-
)) {
|
|
315
|
+
for (const match of readFileSync(path, "utf8").matchAll(/^\|\s*`([A-Z][A-Z0-9_]*)`\s*\|/gm)) {
|
|
314
316
|
const name = match[1] as string;
|
|
315
317
|
if (name.includes("_")) found.add(name);
|
|
316
318
|
}
|
|
@@ -351,6 +353,7 @@ describe("#288: the shipped config boots with every documented override supplied
|
|
|
351
353
|
expect(config.http.port).toBe(3000);
|
|
352
354
|
expect(config.http.readinessTimeoutMs).toBe(1500);
|
|
353
355
|
expect(config.http.trustProxy).toEqual(["10.0.0.0/8", "loopback"]);
|
|
356
|
+
expect(config.federations.google?.accessType).toBe("online");
|
|
354
357
|
// The new default wins over the deprecated variable, and the parsed
|
|
355
358
|
// config mirrors it onto the old key for readers that predate the split.
|
|
356
359
|
expect(resolveAccessTokenLifetime(config)).toEqual({
|
|
@@ -522,12 +525,23 @@ describe("#288: the shipped config boots with every documented override supplied
|
|
|
522
525
|
});
|
|
523
526
|
|
|
524
527
|
it("covers every environment variable the README documents", () => {
|
|
525
|
-
const uncovered = [...documentedInReadme()].filter(
|
|
528
|
+
const uncovered = [...documentedInReadme(), ...documentedInReadme(readmeJaPath)].filter(
|
|
526
529
|
(name) => !(name in DOCUMENTED_ENV) && !(name in DELIBERATELY_UNSET),
|
|
527
530
|
);
|
|
528
531
|
expect(uncovered).toEqual([]);
|
|
529
532
|
});
|
|
530
533
|
|
|
534
|
+
it("documents the same variables in README.md and README.ja.md", () => {
|
|
535
|
+
// The Japanese README carries the same facts (AGENTS.md): a row added
|
|
536
|
+
// to one and not the other is a variable one audience never hears of.
|
|
537
|
+
const en = documentedInReadme();
|
|
538
|
+
const ja = documentedInReadme(readmeJaPath);
|
|
539
|
+
expect({
|
|
540
|
+
onlyInEnglish: [...en].filter((name) => !ja.has(name)).sort(),
|
|
541
|
+
onlyInJapanese: [...ja].filter((name) => !en.has(name)).sort(),
|
|
542
|
+
}).toEqual({ onlyInEnglish: [], onlyInJapanese: [] });
|
|
543
|
+
});
|
|
544
|
+
|
|
531
545
|
it("exercises no variable the config layers no longer substitute", () => {
|
|
532
546
|
// Catches the reverse drift: a README row (and a test entry) left
|
|
533
547
|
// behind by a key that was removed from the HOCON.
|
|
@@ -257,9 +257,10 @@ describe("#593 slice 7: the standalone composes federation grants from its confi
|
|
|
257
257
|
);
|
|
258
258
|
expect(installed).not.toContain("redis-federation-grant-store");
|
|
259
259
|
expect(installed).not.toContain("redis-federation-grant-intent-store");
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
260
|
+
// Listed ahead of oauthModule, as the template writes them — the
|
|
261
|
+
// template's choice, since each module parses its own bodies and the
|
|
262
|
+
// order no longer changes that; the browser half sits after the
|
|
263
|
+
// session middleware by its own `after`.
|
|
263
264
|
expect(installed.indexOf("federation-grants")).toBeLessThan(installed.indexOf("oauth"));
|
|
264
265
|
|
|
265
266
|
handleRef = await boot(config, true);
|
|
@@ -382,3 +383,63 @@ describe("#593 slice 7: the standalone composes federation grants from its confi
|
|
|
382
383
|
expect(names(config)).toContain("core-federation-grant-intent-store-memory");
|
|
383
384
|
});
|
|
384
385
|
});
|
|
386
|
+
|
|
387
|
+
describe("the browser consent route parses its own body, with sessionModule listed ahead of it", () => {
|
|
388
|
+
// `sessionModule`'s routers are mounted at `/session`, the prefix the
|
|
389
|
+
// federation grants browser half mounts under too. Their parsers ran for
|
|
390
|
+
// every request beneath `/session`, so with `sessionModule` listed first
|
|
391
|
+
// the consent route's body arrived parsed — past its 16 KiB bound, its
|
|
392
|
+
// throttle-then-parse order and its JSON refusals.
|
|
393
|
+
let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
|
|
394
|
+
|
|
395
|
+
afterEach(async () => {
|
|
396
|
+
await handleRef?.dispose();
|
|
397
|
+
handleRef = undefined;
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
/** The standalone's modules, with `sessionModule` moved ahead of the grant modules. */
|
|
401
|
+
const sessionFirst = (config: AppConfig) => {
|
|
402
|
+
const modules = modulesFor(config, true);
|
|
403
|
+
const session = modules.find((m) => m.name === "session");
|
|
404
|
+
if (session === undefined) throw new Error("sessionModule is not in the standalone's list");
|
|
405
|
+
const rest = modules.filter((m) => m !== session);
|
|
406
|
+
const at = rest.findIndex((m) => m.name.startsWith("federation-grant"));
|
|
407
|
+
return [...rest.slice(0, at), session, ...rest.slice(at)];
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const bootSessionFirst = async () => {
|
|
411
|
+
const config = resolveConfig({ ...BASE_ENV, ...GRANTS_ON });
|
|
412
|
+
handleRef = await createApp({
|
|
413
|
+
modules: sessionFirst(config),
|
|
414
|
+
bootstrapComponents: { config, pathResolver: (s) => s },
|
|
415
|
+
});
|
|
416
|
+
return express().use(handleRef.router);
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const CONSENT = "/session/federation-grants/consent";
|
|
420
|
+
|
|
421
|
+
it("refuses a body over 16 KiB with the route's own 413", async () => {
|
|
422
|
+
const app = await bootSessionFirst();
|
|
423
|
+
|
|
424
|
+
const res = await request(app)
|
|
425
|
+
.post(CONSENT)
|
|
426
|
+
.set("Content-Type", "application/json")
|
|
427
|
+
.send(JSON.stringify({ challenge: "c", decision: "accept", pad: "a".repeat(40 * 1024) }));
|
|
428
|
+
|
|
429
|
+
expect(res.status).toBe(413);
|
|
430
|
+
expect(res.body).toEqual({ error: "invalid_request", error_description: "body_too_large" });
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("refuses malformed JSON with the route's own 400", async () => {
|
|
434
|
+
const app = await bootSessionFirst();
|
|
435
|
+
|
|
436
|
+
const res = await request(app)
|
|
437
|
+
.post(CONSENT)
|
|
438
|
+
.set("Content-Type", "application/json")
|
|
439
|
+
.send("{not json");
|
|
440
|
+
|
|
441
|
+
expect(res.status).toBe(400);
|
|
442
|
+
expect(res.body).toEqual({ error: "invalid_request", error_description: "malformed_body" });
|
|
443
|
+
expect(res.headers["cache-control"]).toContain("no-store");
|
|
444
|
+
});
|
|
445
|
+
});
|
|
@@ -236,6 +236,7 @@ async function seedGrant(store: MemoryFederationGrantStore): Promise<void> {
|
|
|
236
236
|
consent: { at, sid: "sid-consent", scopes: [...SCOPES] },
|
|
237
237
|
authorizedAt: at,
|
|
238
238
|
expiresAt: new Date(at.getTime() + 30 * DAY),
|
|
239
|
+
resource: undefined,
|
|
239
240
|
},
|
|
240
241
|
credentials: {
|
|
241
242
|
refreshToken: REFRESH_TOKEN,
|
|
@@ -389,6 +390,13 @@ describe("#593 AC8: a grant outlives the browser session at both logout endpoint
|
|
|
389
390
|
await federationTokenStore.attach(sid, "upstream", {
|
|
390
391
|
accessToken: "session-bound-upstream-token",
|
|
391
392
|
expiresAt: null,
|
|
393
|
+
// Every field is a required key since the #645 follow-up; this
|
|
394
|
+
// record carries only the access token.
|
|
395
|
+
refreshToken: undefined,
|
|
396
|
+
idToken: undefined,
|
|
397
|
+
tokenType: undefined,
|
|
398
|
+
scope: undefined,
|
|
399
|
+
grantedScope: undefined,
|
|
392
400
|
});
|
|
393
401
|
expect(await userSessionStore.get(sid)).not.toBeNull();
|
|
394
402
|
await expectGrantUntouched(app, federationGrantStore);
|
|
@@ -414,6 +422,13 @@ describe("#593 AC8: a grant outlives the browser session at both logout endpoint
|
|
|
414
422
|
await federationTokenStore.attach(sid, "upstream", {
|
|
415
423
|
accessToken: "session-bound-upstream-token",
|
|
416
424
|
expiresAt: null,
|
|
425
|
+
// Every field is a required key since the #645 follow-up; this
|
|
426
|
+
// record carries only the access token.
|
|
427
|
+
refreshToken: undefined,
|
|
428
|
+
idToken: undefined,
|
|
429
|
+
tokenType: undefined,
|
|
430
|
+
scope: undefined,
|
|
431
|
+
grantedScope: undefined,
|
|
417
432
|
});
|
|
418
433
|
expect(await userSessionStore.get(sid)).not.toBeNull();
|
|
419
434
|
await expectGrantUntouched(app, federationGrantStore);
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 1o1 Co. Ltd.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `POST /oauth/federation/:name/token` ends a session's upstream tokens only
|
|
19
|
+
* on the upstream's own verdict that the refresh token is bad — a structured
|
|
20
|
+
* `invalid_grant` under a 4xx — and never while the upstream is down, whatever
|
|
21
|
+
* the body of its 5xx says. An outage is `503`, and the tokens are kept for
|
|
22
|
+
* the retry.
|
|
23
|
+
*
|
|
24
|
+
* Driven through the real Google adapter and its library (openid-client)
|
|
25
|
+
* against a fake Google (`createFakeIdp`), so the errors the route classifies
|
|
26
|
+
* are the ones the library raises: a 4xx whose body names a code is a
|
|
27
|
+
* `ResponseBodyError` carrying `error` and `status`; a 5xx is an
|
|
28
|
+
* `OperationProcessingError` raised over the `Response`, whose body the
|
|
29
|
+
* library does not read — and which the route used to answer `500
|
|
30
|
+
* refresh_failed`, as if the upstream had said something it could not place.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { createFakeIdp, type FakeIdp } from "@o3co/auth-provider-core/testing";
|
|
34
|
+
import request from "supertest";
|
|
35
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
36
|
+
import {
|
|
37
|
+
accessTokenWithFamily,
|
|
38
|
+
GOOGLE_CLIENT_ID,
|
|
39
|
+
type GoogleSession,
|
|
40
|
+
signInLinkedToGoogle,
|
|
41
|
+
} from "./google-session.fixture.mjs";
|
|
42
|
+
|
|
43
|
+
const GOOGLE = {
|
|
44
|
+
issuer: "https://accounts.google.com",
|
|
45
|
+
authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
46
|
+
tokenEndpoint: "https://oauth2.googleapis.com/token",
|
|
47
|
+
jwksUri: "https://www.googleapis.com/oauth2/v3/certs",
|
|
48
|
+
userinfoEndpoint: "https://www.googleapis.com/oauth2/v3/userinfo",
|
|
49
|
+
};
|
|
50
|
+
const STORED_REFRESH_TOKEN = "stored-refresh-token";
|
|
51
|
+
|
|
52
|
+
describe("POST /oauth/federation/google/token: an upstream outage never ends the session's Google tokens", () => {
|
|
53
|
+
let session: GoogleSession | undefined;
|
|
54
|
+
|
|
55
|
+
afterEach(async () => {
|
|
56
|
+
await session?.dispose();
|
|
57
|
+
session = undefined;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Alice's session with Google linked, its access token expired so the
|
|
62
|
+
* route refreshes, and the fake Google answering the refresh with `status`
|
|
63
|
+
* and `body`.
|
|
64
|
+
*/
|
|
65
|
+
async function refreshAnswered(status: number, body: Record<string, unknown>) {
|
|
66
|
+
const idp: FakeIdp = await createFakeIdp({ ...GOOGLE, clientId: GOOGLE_CLIENT_ID });
|
|
67
|
+
idp.tokenStatus = status;
|
|
68
|
+
idp.refusal = body;
|
|
69
|
+
session = await signInLinkedToGoogle({
|
|
70
|
+
google: { fetch: idp.fetch },
|
|
71
|
+
tokens: {
|
|
72
|
+
accessToken: "expired-upstream-access-token",
|
|
73
|
+
expiresAt: new Date(Date.now() - 60_000),
|
|
74
|
+
refreshToken: STORED_REFRESH_TOKEN,
|
|
75
|
+
idToken: undefined,
|
|
76
|
+
tokenType: "Bearer",
|
|
77
|
+
scope: "openid profile email",
|
|
78
|
+
grantedScope: "openid profile email",
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
const res = await request(session.app)
|
|
82
|
+
.post("/oauth/federation/google/token")
|
|
83
|
+
.set("Authorization", `Bearer ${accessTokenWithFamily(session.sid)}`)
|
|
84
|
+
.send();
|
|
85
|
+
// The library did ask Google: what follows is its reading of the answer.
|
|
86
|
+
expect(
|
|
87
|
+
idp.requests.filter(
|
|
88
|
+
(r) =>
|
|
89
|
+
`${r.url.origin}${r.url.pathname}` === GOOGLE.tokenEndpoint &&
|
|
90
|
+
r.body?.get("grant_type") === "refresh_token",
|
|
91
|
+
),
|
|
92
|
+
).toHaveLength(1);
|
|
93
|
+
return { res, ...session };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
it("keeps them and answers 503 when Google answers 503 with a body naming invalid_grant", async () => {
|
|
97
|
+
const { res, sid, federationTokenStore, sessionFederationIndex } = await refreshAnswered(503, {
|
|
98
|
+
error: "invalid_grant",
|
|
99
|
+
error_description: "Backend unavailable",
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(res.status).toBe(503);
|
|
103
|
+
expect(res.body).toEqual({
|
|
104
|
+
error: "temporarily_unavailable",
|
|
105
|
+
error_description: "upstream federation provider temporarily unavailable",
|
|
106
|
+
});
|
|
107
|
+
expect((await federationTokenStore.get(sid, "google"))?.refreshToken).toBe(
|
|
108
|
+
STORED_REFRESH_TOKEN,
|
|
109
|
+
);
|
|
110
|
+
expect(await sessionFederationIndex.listFederations(sid)).toContain("google");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("keeps them and answers 503 when Google answers 502 with no OAuth body at all", async () => {
|
|
114
|
+
const { res, sid, federationTokenStore } = await refreshAnswered(502, {});
|
|
115
|
+
|
|
116
|
+
expect(res.status).toBe(503);
|
|
117
|
+
expect((await federationTokenStore.get(sid, "google"))?.refreshToken).toBe(
|
|
118
|
+
STORED_REFRESH_TOKEN,
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("ends them on Google's structured 400 invalid_grant", async () => {
|
|
123
|
+
const { res, sid, federationTokenStore, sessionFederationIndex } = await refreshAnswered(400, {
|
|
124
|
+
error: "invalid_grant",
|
|
125
|
+
error_description: "Token has been expired or revoked.",
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(res.status).toBe(410);
|
|
129
|
+
expect(res.body.error).toBe("re_authentication_required");
|
|
130
|
+
expect(await federationTokenStore.get(sid, "google")).toBeNull();
|
|
131
|
+
expect(await sessionFederationIndex.listFederations(sid)).not.toContain("google");
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -169,3 +169,31 @@ describe("googleFederationConfigModule — requireAuthorizationResponseIss (#597
|
|
|
169
169
|
}
|
|
170
170
|
});
|
|
171
171
|
});
|
|
172
|
+
|
|
173
|
+
describe("googleFederationConfigModule — accessType", () => {
|
|
174
|
+
// federation-google asks every sign-in for consent with offline access, so
|
|
175
|
+
// every session gets a refresh token; "online" is the operator's way to
|
|
176
|
+
// keep Google's consent screen to the first sign-in, at the price of no
|
|
177
|
+
// refresh token at all. A bridge that drops the field leaves the operator
|
|
178
|
+
// without that choice.
|
|
179
|
+
it("is absent by default, so the provider's own default (offline) applies", () => {
|
|
180
|
+
expect("accessType" in buildConfig(credentials)).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("leaves a null accessType absent, never forwarded as null, which the provider refuses", () => {
|
|
184
|
+
expect("accessType" in buildConfig({ ...credentials, accessType: null })).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("forwards offline and online", () => {
|
|
188
|
+
expect(buildConfig({ ...credentials, accessType: "online" }).accessType).toBe("online");
|
|
189
|
+
expect(buildConfig({ ...credentials, accessType: "offline" }).accessType).toBe("offline");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("refuses anything else at boot, naming the key", () => {
|
|
193
|
+
for (const bad of ["", "offine", "Online", "true", false, 1]) {
|
|
194
|
+
expect(() => buildConfig({ ...credentials, accessType: bad })).toThrow(
|
|
195
|
+
/federations\.google\.accessType/,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,304 @@
|
|
|
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 browser session with Google linked, on the standalone as a deployment
|
|
19
|
+
* composes it: `oauthModule`, the session module and the real Google adapter,
|
|
20
|
+
* booted through `createApp` under the shipped configuration.
|
|
21
|
+
*
|
|
22
|
+
* The routes that act on a session's federation — the two logout routes and
|
|
23
|
+
* `POST /oauth/federation/:name/token` — live in `@o3co/auth-provider-oauth`,
|
|
24
|
+
* which depends on no adapter, so its own tests stand one in. What they hand
|
|
25
|
+
* the adapter, and what they make of what its library raises, can only be
|
|
26
|
+
* seen here.
|
|
27
|
+
*
|
|
28
|
+
* Not `all-modules-composition.fixture.mts`: that one switches every module
|
|
29
|
+
* on and signs with a key it keeps to itself. The tests here present an
|
|
30
|
+
* `id_token_hint` and an access token of their own — a caller's, with the
|
|
31
|
+
* claims the case needs — so the deployment signs with an HS256 secret this
|
|
32
|
+
* file holds, and nothing but Google is federated.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { createHmac } from "node:crypto";
|
|
36
|
+
import { fileURLToPath } from "node:url";
|
|
37
|
+
import {
|
|
38
|
+
type AppConfig,
|
|
39
|
+
AppConfigSchema,
|
|
40
|
+
createApp,
|
|
41
|
+
createKeyStoreFactory,
|
|
42
|
+
defineModule,
|
|
43
|
+
type FederationTokenStore,
|
|
44
|
+
type FederationTokens,
|
|
45
|
+
InMemoryClientRepository,
|
|
46
|
+
InMemoryUserRepository,
|
|
47
|
+
memoryRefreshTokenFamilyStoreModule,
|
|
48
|
+
registerBuiltinKeyStores,
|
|
49
|
+
type SessionFederationIndex,
|
|
50
|
+
type UserSessionStore,
|
|
51
|
+
} from "@o3co/auth-provider-core";
|
|
52
|
+
import {
|
|
53
|
+
type GoogleProviderConfig,
|
|
54
|
+
googleFederationModule,
|
|
55
|
+
} from "@o3co/auth-provider-federation-google";
|
|
56
|
+
import { parseFile } from "@o3co/ts.hocon";
|
|
57
|
+
import { validate } from "@o3co/ts.hocon/zod";
|
|
58
|
+
import express from "express";
|
|
59
|
+
import request from "supertest";
|
|
60
|
+
import { expect } from "vitest";
|
|
61
|
+
import { buildModules } from "#/buildModules.mjs";
|
|
62
|
+
import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "#/configPath.mjs";
|
|
63
|
+
|
|
64
|
+
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
65
|
+
|
|
66
|
+
export const ISSUER = "https://auth.test";
|
|
67
|
+
const JWT_SECRET = "google-session.fixture.at-least-32-bytes.ok";
|
|
68
|
+
|
|
69
|
+
/** The one client: first-party, allowed federation tokens, one post-logout URI registered. */
|
|
70
|
+
export const CLIENT_ID = "rp";
|
|
71
|
+
const CLIENT_SECRET = "rp-secret-value";
|
|
72
|
+
const BASIC = `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")}`;
|
|
73
|
+
export const REGISTERED_POST_LOGOUT_REDIRECT_URI = "https://rp.example/signed-out";
|
|
74
|
+
|
|
75
|
+
const USERNAME = "alice";
|
|
76
|
+
const PASSWORD = "correct-horse-battery-staple";
|
|
77
|
+
const SUB = "u-alice";
|
|
78
|
+
|
|
79
|
+
/** The Google client the adapter is configured with. */
|
|
80
|
+
export const GOOGLE_CLIENT_ID = "google-client";
|
|
81
|
+
const GOOGLE_CLIENT_SECRET = "google-secret";
|
|
82
|
+
const GOOGLE_CALLBACK = `${ISSUER}/session/oauth/federation/google/callback`;
|
|
83
|
+
|
|
84
|
+
const ENV: Readonly<Record<string, string>> = {
|
|
85
|
+
OAUTH_JWT_ALGORITHM: "HS256",
|
|
86
|
+
OAUTH_JWT_SECRET: JWT_SECRET,
|
|
87
|
+
OAUTH_JWT_ISSUER: ISSUER,
|
|
88
|
+
SESSION_SECRET: "google-session.fixture-session.at-least-32-bytes.ok",
|
|
89
|
+
SESSION_SECURE: "false",
|
|
90
|
+
SESSION_NAME: "auth.session",
|
|
91
|
+
SESSION_STORAGE_TYPE: "memory",
|
|
92
|
+
CLIENT_USER_TYPE: "yaml",
|
|
93
|
+
REFRESH_TOKEN_FAMILY_STORE_REDIS_URL: "redis://redis.test:6379",
|
|
94
|
+
USER_SESSION_STORES_ADAPTER: "memory",
|
|
95
|
+
RATE_LIMITER_ADAPTER: "memory",
|
|
96
|
+
OAUTH_CODE_ADAPTER: "memory",
|
|
97
|
+
ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
|
|
98
|
+
REPLAY_SEEN_SET_ADAPTER: "memory",
|
|
99
|
+
FEDERATION_TOKEN_STORE_TYPE: "memory",
|
|
100
|
+
CONSENT_STORE_ADAPTER: "none",
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* `"shipped"`: Google switched on the way an operator does, through its
|
|
105
|
+
* environment variables and the template's own configuration bridge — which
|
|
106
|
+
* has no setting for an end-session endpoint or a `fetch`. Otherwise the
|
|
107
|
+
* adapter's configuration beyond its credentials, composed with
|
|
108
|
+
* `googleFederationModule` directly.
|
|
109
|
+
*/
|
|
110
|
+
export type GoogleWiring =
|
|
111
|
+
| "shipped"
|
|
112
|
+
| Omit<GoogleProviderConfig, "clientId" | "clientSecret" | "callbackURL">;
|
|
113
|
+
|
|
114
|
+
function resolveConfig(google: GoogleWiring): AppConfig {
|
|
115
|
+
const env: Record<string, string> =
|
|
116
|
+
google === "shipped"
|
|
117
|
+
? {
|
|
118
|
+
...ENV,
|
|
119
|
+
FEDERATIONS_GOOGLE_ENABLED: "true",
|
|
120
|
+
FEDERATIONS_GOOGLE_CLIENT_ID: GOOGLE_CLIENT_ID,
|
|
121
|
+
FEDERATIONS_GOOGLE_CLIENT_SECRET: GOOGLE_CLIENT_SECRET,
|
|
122
|
+
FEDERATIONS_GOOGLE_CALLBACK_URL: GOOGLE_CALLBACK,
|
|
123
|
+
}
|
|
124
|
+
: { ...ENV };
|
|
125
|
+
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
|
|
126
|
+
return validate(
|
|
127
|
+
parseFile(envConfPath, { env })
|
|
128
|
+
.withFallback(parseFile(applicationConfPath, { env }))
|
|
129
|
+
.withFallback(parseFile(resolveLibraryReferenceConfPath(), { env })),
|
|
130
|
+
AppConfigSchema,
|
|
131
|
+
) as AppConfig;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const testRepositoriesModule = defineModule({
|
|
135
|
+
name: "test:repositories",
|
|
136
|
+
provides: {
|
|
137
|
+
clientRepository: () =>
|
|
138
|
+
new InMemoryClientRepository(
|
|
139
|
+
new Map([
|
|
140
|
+
[
|
|
141
|
+
CLIENT_ID,
|
|
142
|
+
{
|
|
143
|
+
tokenEndpointAuthMethod: "client_secret_basic",
|
|
144
|
+
clientSecret: CLIENT_SECRET,
|
|
145
|
+
allowedRedirectUris: [],
|
|
146
|
+
allowedScopes: ["openid", "profile"],
|
|
147
|
+
allowedAudiences: [],
|
|
148
|
+
allowedGrantTypes: ["session"],
|
|
149
|
+
postLogoutRedirectUris: [REGISTERED_POST_LOGOUT_REDIRECT_URI],
|
|
150
|
+
backchannelLogoutSessionRequired: true,
|
|
151
|
+
frontchannelLogoutSessionRequired: true,
|
|
152
|
+
allowedAzpForFederationToken: true,
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
]),
|
|
156
|
+
),
|
|
157
|
+
userRepository: () =>
|
|
158
|
+
new InMemoryUserRepository(
|
|
159
|
+
new Map([[USERNAME, { id: SUB, password: PASSWORD, email: "alice@example.com" }]]),
|
|
160
|
+
),
|
|
161
|
+
} as never,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const testKeyStoreModule = defineModule({
|
|
165
|
+
name: "test:key-store",
|
|
166
|
+
requires: ["config"] as const,
|
|
167
|
+
provides: {
|
|
168
|
+
keyStore: async ({ config: c }) => {
|
|
169
|
+
const factory = createKeyStoreFactory();
|
|
170
|
+
registerBuiltinKeyStores(factory);
|
|
171
|
+
return factory.create({
|
|
172
|
+
type: "local",
|
|
173
|
+
...((c as AppConfig).oauth.jwt.signingKey.local ?? {}),
|
|
174
|
+
});
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const base64url = (value: unknown): string =>
|
|
180
|
+
Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
181
|
+
|
|
182
|
+
/** A JWT signed with the deployment's own HS256 key: what its keystore verifies. */
|
|
183
|
+
function signed(typ: string, claims: Record<string, unknown>): string {
|
|
184
|
+
const header = base64url({ alg: "HS256", kid: "v0", typ });
|
|
185
|
+
const now = Math.floor(Date.now() / 1000);
|
|
186
|
+
const payload = base64url({ iss: ISSUER, sub: SUB, iat: now, exp: now + 3600, ...claims });
|
|
187
|
+
const signature = createHmac("sha256", JWT_SECRET)
|
|
188
|
+
.update(`${header}.${payload}`)
|
|
189
|
+
.digest("base64url");
|
|
190
|
+
return `${header}.${payload}.${signature}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* A fresh id_token for the session, issued to the client: what an RP — or
|
|
195
|
+
* anyone holding an id_token of their own — presents as `id_token_hint`.
|
|
196
|
+
*/
|
|
197
|
+
export const idTokenHint = (sid: string): string => signed("JWT", { aud: CLIENT_ID, sid });
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* An access token for the session as the authorization-code grant issues it
|
|
201
|
+
* to the client: `azp`, `sid` and a refresh-token `family_id`. The `session`
|
|
202
|
+
* grant's token names no family, and the federation token route asks for one.
|
|
203
|
+
*/
|
|
204
|
+
export const accessTokenWithFamily = (sid: string): string =>
|
|
205
|
+
signed("at+jwt", { azp: CLIENT_ID, sid, family_id: "family-1", scope: "openid" });
|
|
206
|
+
|
|
207
|
+
const claimsOf = (jwt: string): Record<string, unknown> =>
|
|
208
|
+
JSON.parse(Buffer.from(jwt.split(".")[1] ?? "", "base64url").toString("utf8")) as Record<
|
|
209
|
+
string,
|
|
210
|
+
unknown
|
|
211
|
+
>;
|
|
212
|
+
|
|
213
|
+
export interface GoogleSession {
|
|
214
|
+
readonly app: express.Express;
|
|
215
|
+
readonly sid: string;
|
|
216
|
+
/** The access token the `session` grant minted for the session; it carries `azp`. */
|
|
217
|
+
readonly accessToken: string;
|
|
218
|
+
readonly userSessionStore: UserSessionStore;
|
|
219
|
+
readonly sessionFederationIndex: SessionFederationIndex;
|
|
220
|
+
readonly federationTokenStore: FederationTokenStore;
|
|
221
|
+
readonly dispose: () => Promise<void>;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Boots the deployment, signs alice in with her password, and links Google to
|
|
226
|
+
* her session as a federated sign-in would: the session's federation index
|
|
227
|
+
* names it and the token store holds `tokens`.
|
|
228
|
+
*/
|
|
229
|
+
export async function signInLinkedToGoogle(options: {
|
|
230
|
+
readonly google: GoogleWiring;
|
|
231
|
+
readonly tokens: FederationTokens;
|
|
232
|
+
}): Promise<GoogleSession> {
|
|
233
|
+
const { google, tokens } = options;
|
|
234
|
+
const config = resolveConfig(google);
|
|
235
|
+
const googleModules =
|
|
236
|
+
google === "shipped"
|
|
237
|
+
? []
|
|
238
|
+
: [
|
|
239
|
+
googleFederationModule,
|
|
240
|
+
defineModule({
|
|
241
|
+
name: "test:google-federation-config",
|
|
242
|
+
provides: {
|
|
243
|
+
googleFederationConfig: (): GoogleProviderConfig => ({
|
|
244
|
+
clientId: GOOGLE_CLIENT_ID,
|
|
245
|
+
clientSecret: GOOGLE_CLIENT_SECRET,
|
|
246
|
+
callbackURL: GOOGLE_CALLBACK,
|
|
247
|
+
...google,
|
|
248
|
+
}),
|
|
249
|
+
},
|
|
250
|
+
}),
|
|
251
|
+
];
|
|
252
|
+
const handle = await createApp({
|
|
253
|
+
modules: [
|
|
254
|
+
...buildModules(config, {
|
|
255
|
+
keyStoreModule: testKeyStoreModule,
|
|
256
|
+
repositoriesModule: testRepositoriesModule,
|
|
257
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
258
|
+
}),
|
|
259
|
+
...googleModules,
|
|
260
|
+
],
|
|
261
|
+
bootstrapComponents: { config, pathResolver: (s) => s },
|
|
262
|
+
});
|
|
263
|
+
try {
|
|
264
|
+
const components = handle.components as {
|
|
265
|
+
userSessionStore: UserSessionStore;
|
|
266
|
+
sessionFederationIndex: SessionFederationIndex;
|
|
267
|
+
federationTokenStore: FederationTokenStore;
|
|
268
|
+
};
|
|
269
|
+
const app = express();
|
|
270
|
+
app.use(handle.router);
|
|
271
|
+
|
|
272
|
+
const csrfRes = await request(app).get("/session/csrf");
|
|
273
|
+
expect(csrfRes.status).toBe(200);
|
|
274
|
+
const loginRes = await request(app)
|
|
275
|
+
.post("/session/login")
|
|
276
|
+
.set("Cookie", csrfRes.headers["set-cookie"] as unknown as string[])
|
|
277
|
+
.set(csrfRes.body.header_name as string, csrfRes.body.csrf_token as string)
|
|
278
|
+
.type("form")
|
|
279
|
+
.send({ username: USERNAME, password: PASSWORD });
|
|
280
|
+
expect(loginRes.status).toBe(200);
|
|
281
|
+
|
|
282
|
+
const tokenRes = await request(app)
|
|
283
|
+
.post("/oauth/token")
|
|
284
|
+
.set("Authorization", BASIC)
|
|
285
|
+
.set("Cookie", loginRes.headers["set-cookie"] as unknown as string[])
|
|
286
|
+
.type("form")
|
|
287
|
+
.send({ grant_type: "session" });
|
|
288
|
+
expect(tokenRes.status).toBe(200);
|
|
289
|
+
const accessToken = tokenRes.body.access_token as string;
|
|
290
|
+
const claims = claimsOf(accessToken);
|
|
291
|
+
expect(claims.azp).toBe(CLIENT_ID);
|
|
292
|
+
const sid = claims.sid as string;
|
|
293
|
+
expect(typeof sid).toBe("string");
|
|
294
|
+
|
|
295
|
+
const session = await components.userSessionStore.get(sid);
|
|
296
|
+
if (session === null) throw new Error("fixture: the sign-in left no session");
|
|
297
|
+
await components.sessionFederationIndex.addFederation(sid, "google", session.expiresAt);
|
|
298
|
+
await components.federationTokenStore.attach(sid, "google", tokens);
|
|
299
|
+
return { app, sid, accessToken, ...components, dispose: () => handle.dispose() };
|
|
300
|
+
} catch (error) {
|
|
301
|
+
await handle.dispose();
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
}
|