@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
|
@@ -0,0 +1,141 @@
|
|
|
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 host's own routes — liveness, readiness, the metrics scrape — mounted
|
|
19
|
+
* as the process mounts them (`mountRoutes`), and what an error one of them
|
|
20
|
+
* lets through is answered with.
|
|
21
|
+
*
|
|
22
|
+
* The composed router answers its own errors with core's terminal handler.
|
|
23
|
+
* The host's routes sat in front of a handler of the template's own, which
|
|
24
|
+
* answered in other words ("Internal server error"), took any 4xx `status`
|
|
25
|
+
* for the client's mistake and answered it as one, handed an error on to
|
|
26
|
+
* Express once the headers were out (whose final handler prints the stack),
|
|
27
|
+
* and logged the path as it arrived. Core's handler is now the one after
|
|
28
|
+
* them too, so a deployment answers every route's error the same way.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { Logger, ReadinessProbe } from "@o3co/auth-provider-core";
|
|
32
|
+
import express, { Router } from "express";
|
|
33
|
+
import request from "supertest";
|
|
34
|
+
import { describe, expect, it, vi } from "vitest";
|
|
35
|
+
import { createMetrics } from "#/metrics.mjs";
|
|
36
|
+
import { mountRoutes } from "#/routes.mjs";
|
|
37
|
+
|
|
38
|
+
const spyLogger = () => {
|
|
39
|
+
const logger = {
|
|
40
|
+
trace: vi.fn(),
|
|
41
|
+
debug: vi.fn(),
|
|
42
|
+
info: vi.fn(),
|
|
43
|
+
warn: vi.fn(),
|
|
44
|
+
error: vi.fn(),
|
|
45
|
+
fatal: vi.fn(),
|
|
46
|
+
child: () => logger,
|
|
47
|
+
};
|
|
48
|
+
return logger;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** A probe that breaks its own contract: reading its `name` throws. */
|
|
52
|
+
const probeWhoseNameThrows = (thrown: Error): ReadinessProbe =>
|
|
53
|
+
({
|
|
54
|
+
get name(): string {
|
|
55
|
+
throw thrown;
|
|
56
|
+
},
|
|
57
|
+
check: async () => undefined,
|
|
58
|
+
}) as ReadinessProbe;
|
|
59
|
+
|
|
60
|
+
const host = (probes: readonly ReadinessProbe[]) => {
|
|
61
|
+
const logger = spyLogger();
|
|
62
|
+
const app = express();
|
|
63
|
+
mountRoutes(app, {
|
|
64
|
+
router: Router(),
|
|
65
|
+
probes,
|
|
66
|
+
readinessTimeoutMs: 1_000,
|
|
67
|
+
metrics: createMetrics(),
|
|
68
|
+
logger: logger as unknown as Logger,
|
|
69
|
+
});
|
|
70
|
+
return { app, logger };
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Every call on a level other than `error`. */
|
|
74
|
+
const otherLevels = (logger: ReturnType<typeof spyLogger>) =>
|
|
75
|
+
[logger.trace, logger.debug, logger.info, logger.warn, logger.fatal].flatMap(
|
|
76
|
+
(level) => level.mock.calls,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
describe("the host's routes, mounted as the process mounts them", () => {
|
|
80
|
+
it("/_healthcheck answers 200, and nothing is logged", async () => {
|
|
81
|
+
const { app, logger } = host([]);
|
|
82
|
+
const res = await request(app).get("/_healthcheck");
|
|
83
|
+
expect(res.status).toBe(200);
|
|
84
|
+
expect(res.body).toEqual({ status: "ok" });
|
|
85
|
+
expect(logger.error).not.toHaveBeenCalled();
|
|
86
|
+
expect(otherLevels(logger)).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it.each(["/readyz", "/metrics"])(
|
|
90
|
+
"%s: an error it lets through is 500 server_error in the envelope, no-store, logged once with its projection",
|
|
91
|
+
async (path) => {
|
|
92
|
+
const { app, logger } = host([
|
|
93
|
+
probeWhoseNameThrows(new Error("probe registry at internal-host-marker is gone")),
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
const res = await request(app).get(path);
|
|
97
|
+
|
|
98
|
+
expect(res.status).toBe(500);
|
|
99
|
+
expect(res.headers["content-type"]).toMatch(/^application\/json/);
|
|
100
|
+
expect(res.headers["cache-control"]).toBe("no-store");
|
|
101
|
+
expect(res.body).toEqual({ error: "server_error", error_description: "unexpected_error" });
|
|
102
|
+
expect(res.text).not.toContain("internal-host-marker");
|
|
103
|
+
expect(logger.error).toHaveBeenCalledTimes(1);
|
|
104
|
+
const [fields, event, ...rest] = logger.error.mock.calls[0] as unknown[];
|
|
105
|
+
expect(event).toBe("unhandled_request_error");
|
|
106
|
+
expect(rest).toEqual([]);
|
|
107
|
+
expect(fields).toEqual({
|
|
108
|
+
endpoint: path,
|
|
109
|
+
err: expect.objectContaining({
|
|
110
|
+
name: "Error",
|
|
111
|
+
detail: "probe registry at internal-host-marker is gone",
|
|
112
|
+
}),
|
|
113
|
+
});
|
|
114
|
+
expect((fields as { err: unknown }).err).not.toBeInstanceOf(Error);
|
|
115
|
+
expect(otherLevels(logger)).toEqual([]);
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
it("takes a 4xx status an error did not mark as the client's for the server's: 500, logged", async () => {
|
|
120
|
+
// A store's or a library's error can carry an HTTP status of its own —
|
|
121
|
+
// the answer it got — which is not this server's answer to the client.
|
|
122
|
+
const { app, logger } = host([
|
|
123
|
+
probeWhoseNameThrows(
|
|
124
|
+
Object.assign(new Error("the probe registry refused this deployment"), { status: 403 }),
|
|
125
|
+
),
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
const res = await request(app).get("/readyz");
|
|
129
|
+
|
|
130
|
+
expect(res.status).toBe(500);
|
|
131
|
+
expect(res.body).toEqual({ error: "server_error", error_description: "unexpected_error" });
|
|
132
|
+
expect(logger.error).toHaveBeenCalledTimes(1);
|
|
133
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
134
|
+
{
|
|
135
|
+
endpoint: "/readyz",
|
|
136
|
+
err: expect.objectContaining({ name: "Error", status: 403 }),
|
|
137
|
+
},
|
|
138
|
+
"unhandled_request_error",
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
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
|
+
* Starting the listener: one object-first `server_listening` line once the
|
|
19
|
+
* socket is bound — and none for a port that was never bound.
|
|
20
|
+
*
|
|
21
|
+
* The line used to be a template string, `Server is running on
|
|
22
|
+
* http://localhost:<port>`, which a log pipeline indexes as free text rather
|
|
23
|
+
* than as an event with a port. And the standalone called Express 5's
|
|
24
|
+
* `app.listen`, which hands its callback the server's `error` as well as its
|
|
25
|
+
* `listening`: a callback that ignored its argument announced a server on a
|
|
26
|
+
* port another process held, while the error that should have ended the
|
|
27
|
+
* process was swallowed. `listen` now builds the server and wires its events
|
|
28
|
+
* itself.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { createServer, type Server } from "node:http";
|
|
32
|
+
import type { AddressInfo } from "node:net";
|
|
33
|
+
import type { Logger } from "@o3co/auth-provider-core";
|
|
34
|
+
import express from "express";
|
|
35
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
36
|
+
import { listen } from "#/listen.mjs";
|
|
37
|
+
|
|
38
|
+
/** A logger that records what each level is handed. */
|
|
39
|
+
function recordingLogger(): { logger: Logger; calls: Array<{ level: string; args: unknown[] }> } {
|
|
40
|
+
const calls: Array<{ level: string; args: unknown[] }> = [];
|
|
41
|
+
const record =
|
|
42
|
+
(level: string) =>
|
|
43
|
+
(...args: unknown[]): void => {
|
|
44
|
+
calls.push({ level, args });
|
|
45
|
+
};
|
|
46
|
+
const logger: Logger = {
|
|
47
|
+
trace: record("trace"),
|
|
48
|
+
debug: record("debug"),
|
|
49
|
+
info: record("info"),
|
|
50
|
+
warn: record("warn"),
|
|
51
|
+
error: record("error"),
|
|
52
|
+
fatal: record("fatal"),
|
|
53
|
+
child: () => logger,
|
|
54
|
+
};
|
|
55
|
+
return { logger, calls };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const opened: Server[] = [];
|
|
59
|
+
|
|
60
|
+
afterEach(async () => {
|
|
61
|
+
await Promise.all(
|
|
62
|
+
opened.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))),
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("listen", () => {
|
|
67
|
+
it("says server_listening once, object-first, with the port the socket holds", async () => {
|
|
68
|
+
const { logger, calls } = recordingLogger();
|
|
69
|
+
const server = await listen(express(), 0, logger);
|
|
70
|
+
opened.push(server);
|
|
71
|
+
const { port } = server.address() as AddressInfo;
|
|
72
|
+
expect(port).toBeGreaterThan(0);
|
|
73
|
+
expect(calls).toEqual([{ level: "info", args: [{ port }, "server_listening"] }]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("logs a server error after the bind — server_error, the projection — rather than swallowing it", async () => {
|
|
77
|
+
// Once the socket is bound, a later `error` (accept EMFILE) went to
|
|
78
|
+
// Express 5's spent listen callback and was lost — and the one after
|
|
79
|
+
// it, with no listener left, threw out of the process.
|
|
80
|
+
const { logger, calls } = recordingLogger();
|
|
81
|
+
const server = await listen(express(), 0, logger);
|
|
82
|
+
opened.push(server);
|
|
83
|
+
calls.length = 0;
|
|
84
|
+
|
|
85
|
+
const failure = Object.assign(new Error("accept EMFILE"), { code: "EMFILE" });
|
|
86
|
+
server.emit("error", failure);
|
|
87
|
+
server.emit("error", failure);
|
|
88
|
+
|
|
89
|
+
expect(calls).toEqual([
|
|
90
|
+
{
|
|
91
|
+
level: "error",
|
|
92
|
+
args: [{ err: expect.objectContaining({ name: "Error", code: "EMFILE" }) }, "server_error"],
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
level: "error",
|
|
96
|
+
args: [{ err: expect.objectContaining({ name: "Error", code: "EMFILE" }) }, "server_error"],
|
|
97
|
+
},
|
|
98
|
+
]);
|
|
99
|
+
const line = calls[0]?.args[0] as { err: unknown };
|
|
100
|
+
expect(line.err).not.toBeInstanceOf(Error);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("leaves the bound server one error listener, server_error's: the bind's own is removed", async () => {
|
|
104
|
+
// The listener that turns a bind failure into a rejection has done its
|
|
105
|
+
// job once the socket is bound. Left attached, it would take every
|
|
106
|
+
// later error beside server_error's — a callback already spent.
|
|
107
|
+
const { logger } = recordingLogger();
|
|
108
|
+
const server = await listen(express(), 0, logger);
|
|
109
|
+
opened.push(server);
|
|
110
|
+
|
|
111
|
+
expect(server.listenerCount("error")).toBe(1);
|
|
112
|
+
// And nothing of `listen`'s own left on `listening`: an http.Server
|
|
113
|
+
// keeps one there itself (its connection tracking).
|
|
114
|
+
expect(server.listenerCount("listening")).toBe(createServer().listenerCount("listening"));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("rejects with the server's error for a port already bound, and announces nothing", async () => {
|
|
118
|
+
const holder = createServer();
|
|
119
|
+
// Every interface, as `server.listen(port)` binds, so the two collide.
|
|
120
|
+
await new Promise<void>((resolve) => holder.listen(0, resolve));
|
|
121
|
+
opened.push(holder);
|
|
122
|
+
const { port } = holder.address() as AddressInfo;
|
|
123
|
+
|
|
124
|
+
const { logger, calls } = recordingLogger();
|
|
125
|
+
await expect(listen(express(), port, logger)).rejects.toMatchObject({ code: "EADDRINUSE" });
|
|
126
|
+
expect(calls).toEqual([]);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
* What the template's own logger writes for a projected error — the line on
|
|
19
|
+
* stdout, from `createAppLogger` in a process of its own, as the service
|
|
20
|
+
* writes it. Every field of core's `loggableError` projection must reach
|
|
21
|
+
* it, at every level of the cause chain: the logger's `err` serializer must
|
|
22
|
+
* not take the projection for an Error and fold its causes into one message.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { execFileSync } from "node:child_process";
|
|
26
|
+
import { createRequire } from "node:module";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
import { describe, expect, it } from "vitest";
|
|
29
|
+
|
|
30
|
+
const templateRoot = fileURLToPath(new URL("../..", import.meta.url));
|
|
31
|
+
const tsxCli = createRequire(import.meta.url).resolve("tsx/cli");
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A request failure caused by a Store failure caused by a parse failure,
|
|
35
|
+
* logged through `createAppLogger` in a child process; the line it wrote.
|
|
36
|
+
*/
|
|
37
|
+
const SCRIPT = `
|
|
38
|
+
const { createAppLogger } = await import("./src/logger.mts");
|
|
39
|
+
const { loggableError } = await import("@o3co/auth-provider-core");
|
|
40
|
+
let parse;
|
|
41
|
+
try { JSON.parse('{"a":1,'); } catch (err) { parse = err; }
|
|
42
|
+
const store = Object.assign(new Error("the Store could not be read", { cause: parse }), {
|
|
43
|
+
name: "StoreTransportError", code: "ECONNREFUSED", reason: "unreachable", storeStatus: 503,
|
|
44
|
+
});
|
|
45
|
+
const failed = Object.assign(new Error("request failed", { cause: store }), {
|
|
46
|
+
type: "upstream.failed", status: 502,
|
|
47
|
+
});
|
|
48
|
+
createAppLogger({ logging: { level: "info" } }).error({ err: loggableError(failed) }, "unhandled_request_error");
|
|
49
|
+
`;
|
|
50
|
+
|
|
51
|
+
describe("createAppLogger writes a projected error whole", () => {
|
|
52
|
+
it("keeps every field of every level of the cause chain on the line", () => {
|
|
53
|
+
const stdout = execFileSync(
|
|
54
|
+
process.execPath,
|
|
55
|
+
[tsxCli, "--input-type=module", "--eval", SCRIPT],
|
|
56
|
+
{ cwd: templateRoot, encoding: "utf8" },
|
|
57
|
+
);
|
|
58
|
+
const line = JSON.parse(stdout.trim().split("\n").at(-1) ?? "{}") as {
|
|
59
|
+
msg?: string;
|
|
60
|
+
err?: Record<string, unknown>;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
expect(line.msg).toBe("unhandled_request_error");
|
|
64
|
+
expect(line.err).toMatchObject({
|
|
65
|
+
name: "Error",
|
|
66
|
+
type: "upstream.failed",
|
|
67
|
+
status: 502,
|
|
68
|
+
cause: {
|
|
69
|
+
name: "StoreTransportError",
|
|
70
|
+
code: "ECONNREFUSED",
|
|
71
|
+
reason: "unreachable",
|
|
72
|
+
storeStatus: 503,
|
|
73
|
+
cause: { name: "SyntaxError", position: 7 },
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -138,13 +138,26 @@ describe("GET /metrics", () => {
|
|
|
138
138
|
// `close` fires without `finish` when a client or proxy gives up
|
|
139
139
|
// mid-handler, and those are disproportionately the slow and failing
|
|
140
140
|
// requests RED metrics exist to surface.
|
|
141
|
+
//
|
|
142
|
+
// Sequenced on the server's own events, never on a fixed wait: the abort
|
|
143
|
+
// goes out once the handler holds the request, and /metrics is read once
|
|
144
|
+
// the response has closed. A 30 ms sleep either side raced a loaded
|
|
145
|
+
// machine — an abort sent before the request reached its route was
|
|
146
|
+
// counted as `unmatched`, or not at all, and one whose close had not yet
|
|
147
|
+
// been processed was read as nothing.
|
|
141
148
|
const metrics = createMetrics();
|
|
142
149
|
const app = express();
|
|
143
150
|
app.use(metrics.middleware);
|
|
144
151
|
app.use(metrics.route(express, { probes: [], probeTimeoutMs: 100 }));
|
|
152
|
+
const handling = Promise.withResolvers<void>();
|
|
153
|
+
const closed = Promise.withResolvers<void>();
|
|
145
154
|
app.get("/slow", (_req, res) => {
|
|
146
|
-
// Never responds; the test aborts the socket instead.
|
|
147
|
-
|
|
155
|
+
// Never responds; the test aborts the socket instead. This listener is
|
|
156
|
+
// added after the metrics middleware's, and a response emits `close`
|
|
157
|
+
// to its listeners in the order they were added, so once it runs the
|
|
158
|
+
// request has been observed.
|
|
159
|
+
res.once("close", () => closed.resolve());
|
|
160
|
+
handling.resolve();
|
|
148
161
|
});
|
|
149
162
|
|
|
150
163
|
const server = await listenOnLoopback(app);
|
|
@@ -154,17 +167,19 @@ describe("GET /metrics", () => {
|
|
|
154
167
|
const pending = fetch(`http://127.0.0.1:${port}/slow`, {
|
|
155
168
|
signal: controller.signal,
|
|
156
169
|
}).catch(() => undefined);
|
|
157
|
-
await
|
|
170
|
+
await handling.promise;
|
|
158
171
|
controller.abort();
|
|
159
172
|
await pending;
|
|
160
|
-
await
|
|
173
|
+
await closed.promise;
|
|
161
174
|
|
|
162
175
|
const res = await request(app).get("/metrics");
|
|
163
176
|
const slow = res.text
|
|
164
177
|
.split("\n")
|
|
165
178
|
.filter((l) => l.startsWith("http_request_duration_seconds_count"))
|
|
166
179
|
.filter((l) => l.includes('route="/slow"'));
|
|
167
|
-
|
|
180
|
+
// One request, counted once, under the route it had reached.
|
|
181
|
+
expect(slow).toHaveLength(1);
|
|
182
|
+
expect(slow[0]?.trim().endsWith(" 1")).toBe(true);
|
|
168
183
|
} finally {
|
|
169
184
|
server.close();
|
|
170
185
|
}
|
|
@@ -0,0 +1,187 @@
|
|
|
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 logout's `post_logout_redirect_uri` reaches the upstream federation's
|
|
19
|
+
* end-session call only once it has matched one of the client's registered
|
|
20
|
+
* `postLogoutRedirectUris` — at `/oauth/logout` and at
|
|
21
|
+
* `POST /oauth/federation/:name/logout` alike.
|
|
22
|
+
*
|
|
23
|
+
* Google publishes no end-session endpoint, and without one configured its
|
|
24
|
+
* adapter sends the browser straight to the `postLogoutRedirectUri` it is
|
|
25
|
+
* handed. Both logout routes used to hand it the caller's value unchecked,
|
|
26
|
+
* ahead of the allowlist check that guarded their own redirect, so a fresh
|
|
27
|
+
* id_token of the caller's own and `post_logout_redirect_uri=https://evil…`
|
|
28
|
+
* made this provider's origin answer `303` to any site. With an end-session
|
|
29
|
+
* endpoint configured, the unchecked value was forwarded to the upstream
|
|
30
|
+
* instead.
|
|
31
|
+
*
|
|
32
|
+
* The redirect asserted here is the one a browser would follow: the real
|
|
33
|
+
* Google adapter, composed as a deployment composes it
|
|
34
|
+
* (`google-session.fixture.mts`).
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import type express from "express";
|
|
38
|
+
import request from "supertest";
|
|
39
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
40
|
+
import {
|
|
41
|
+
type GoogleSession,
|
|
42
|
+
type GoogleWiring,
|
|
43
|
+
idTokenHint,
|
|
44
|
+
REGISTERED_POST_LOGOUT_REDIRECT_URI as REGISTERED,
|
|
45
|
+
signInLinkedToGoogle,
|
|
46
|
+
} from "./google-session.fixture.mjs";
|
|
47
|
+
|
|
48
|
+
const UNREGISTERED = "https://evil.example/landing";
|
|
49
|
+
const GOOGLE_LOGOUT = "https://accounts.google.com/Logout";
|
|
50
|
+
const UPSTREAM_END_SESSION = "https://idp-logout.example/end-session";
|
|
51
|
+
const UPSTREAM_ID_TOKEN = "upstream-id-token";
|
|
52
|
+
|
|
53
|
+
/** Google's end-session endpoint, where a deployment names one. */
|
|
54
|
+
const WITH_END_SESSION: GoogleWiring = { endSessionEndpoint: UPSTREAM_END_SESSION };
|
|
55
|
+
|
|
56
|
+
describe("a logout's post_logout_redirect_uri reaches the upstream only once it is registered", () => {
|
|
57
|
+
let session: GoogleSession | undefined;
|
|
58
|
+
|
|
59
|
+
afterEach(async () => {
|
|
60
|
+
await session?.dispose();
|
|
61
|
+
session = undefined;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** Alice signed in, with Google linked and its id_token held for the end-session call. */
|
|
65
|
+
async function signedInWithGoogle(google: GoogleWiring): Promise<GoogleSession> {
|
|
66
|
+
session = await signInLinkedToGoogle({
|
|
67
|
+
google,
|
|
68
|
+
tokens: {
|
|
69
|
+
accessToken: "upstream-access-token",
|
|
70
|
+
expiresAt: null,
|
|
71
|
+
refreshToken: undefined,
|
|
72
|
+
idToken: UPSTREAM_ID_TOKEN,
|
|
73
|
+
tokenType: "Bearer",
|
|
74
|
+
scope: undefined,
|
|
75
|
+
grantedScope: undefined,
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
return session;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const rpLogout = (app: express.Express, sid: string, postLogoutRedirectUri: string) =>
|
|
82
|
+
request(app)
|
|
83
|
+
.get("/oauth/logout")
|
|
84
|
+
.query({
|
|
85
|
+
id_token_hint: idTokenHint(sid),
|
|
86
|
+
post_logout_redirect_uri: postLogoutRedirectUri,
|
|
87
|
+
state: "s-1",
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const federationLogout = (
|
|
91
|
+
app: express.Express,
|
|
92
|
+
accessToken: string,
|
|
93
|
+
postLogoutRedirectUri: string,
|
|
94
|
+
) =>
|
|
95
|
+
request(app)
|
|
96
|
+
.post("/oauth/federation/google/logout")
|
|
97
|
+
.set("Authorization", `Bearer ${accessToken}`)
|
|
98
|
+
.type("form")
|
|
99
|
+
.send({ post_logout_redirect_uri: postLogoutRedirectUri, state: "s-1" });
|
|
100
|
+
|
|
101
|
+
describe("GET /oauth/logout, Google with no end-session endpoint", () => {
|
|
102
|
+
it("does not send the browser to an unregistered post_logout_redirect_uri", async () => {
|
|
103
|
+
const { app, sid, userSessionStore } = await signedInWithGoogle("shipped");
|
|
104
|
+
|
|
105
|
+
const res = await rpLogout(app, sid, UNREGISTERED);
|
|
106
|
+
|
|
107
|
+
expect(res.status).toBe(303);
|
|
108
|
+
expect(new URL(res.headers.location as string).origin).not.toBe("https://evil.example");
|
|
109
|
+
// Google's own logout page: the adapter's answer when it is handed no
|
|
110
|
+
// redirect target, which is what an unregistered one now is.
|
|
111
|
+
expect(res.headers.location).toBe(`${GOOGLE_LOGOUT}?state=s-1`);
|
|
112
|
+
// The logout itself still happened.
|
|
113
|
+
expect(await userSessionStore.get(sid)).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("still returns the browser to a registered post_logout_redirect_uri, with state", async () => {
|
|
117
|
+
const { app, sid, userSessionStore } = await signedInWithGoogle("shipped");
|
|
118
|
+
|
|
119
|
+
const res = await rpLogout(app, sid, REGISTERED);
|
|
120
|
+
|
|
121
|
+
expect(res.status).toBe(303);
|
|
122
|
+
expect(res.headers.location).toBe(`${REGISTERED}?state=s-1`);
|
|
123
|
+
expect(await userSessionStore.get(sid)).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("GET /oauth/logout, Google with a configured end-session endpoint", () => {
|
|
128
|
+
it("does not forward an unregistered post_logout_redirect_uri to the upstream", async () => {
|
|
129
|
+
const { app, sid } = await signedInWithGoogle(WITH_END_SESSION);
|
|
130
|
+
|
|
131
|
+
const res = await rpLogout(app, sid, UNREGISTERED);
|
|
132
|
+
|
|
133
|
+
expect(res.status).toBe(303);
|
|
134
|
+
const location = new URL(res.headers.location as string);
|
|
135
|
+
expect(`${location.origin}${location.pathname}`).toBe(UPSTREAM_END_SESSION);
|
|
136
|
+
expect(location.searchParams.get("id_token_hint")).toBe(UPSTREAM_ID_TOKEN);
|
|
137
|
+
expect(location.searchParams.has("post_logout_redirect_uri")).toBe(false);
|
|
138
|
+
expect(res.headers.location).not.toContain("evil.example");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("forwards a registered one", async () => {
|
|
142
|
+
const { app, sid } = await signedInWithGoogle(WITH_END_SESSION);
|
|
143
|
+
|
|
144
|
+
const res = await rpLogout(app, sid, REGISTERED);
|
|
145
|
+
|
|
146
|
+
expect(res.status).toBe(303);
|
|
147
|
+
const location = new URL(res.headers.location as string);
|
|
148
|
+
expect(`${location.origin}${location.pathname}`).toBe(UPSTREAM_END_SESSION);
|
|
149
|
+
expect(location.searchParams.get("post_logout_redirect_uri")).toBe(REGISTERED);
|
|
150
|
+
expect(location.searchParams.get("state")).toBe("s-1");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("POST /oauth/federation/google/logout", () => {
|
|
155
|
+
it("does not send the browser to an unregistered post_logout_redirect_uri", async () => {
|
|
156
|
+
const { app, sid, accessToken, sessionFederationIndex } = await signedInWithGoogle("shipped");
|
|
157
|
+
|
|
158
|
+
const res = await federationLogout(app, accessToken, UNREGISTERED);
|
|
159
|
+
|
|
160
|
+
expect(res.status).toBe(303);
|
|
161
|
+
expect(new URL(res.headers.location as string).origin).not.toBe("https://evil.example");
|
|
162
|
+
expect(res.headers.location).toBe(`${GOOGLE_LOGOUT}?state=s-1`);
|
|
163
|
+
// The federation was disconnected all the same.
|
|
164
|
+
expect(await sessionFederationIndex.listFederations(sid)).not.toContain("google");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("still returns the browser to one registered for the token's client", async () => {
|
|
168
|
+
const { app, accessToken } = await signedInWithGoogle("shipped");
|
|
169
|
+
|
|
170
|
+
const res = await federationLogout(app, accessToken, REGISTERED);
|
|
171
|
+
|
|
172
|
+
expect(res.status).toBe(303);
|
|
173
|
+
expect(res.headers.location).toBe(`${REGISTERED}?state=s-1`);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("does not forward an unregistered one to a configured end-session endpoint", async () => {
|
|
177
|
+
const { app, accessToken } = await signedInWithGoogle(WITH_END_SESSION);
|
|
178
|
+
|
|
179
|
+
const res = await federationLogout(app, accessToken, UNREGISTERED);
|
|
180
|
+
|
|
181
|
+
expect(res.status).toBe(303);
|
|
182
|
+
const location = new URL(res.headers.location as string);
|
|
183
|
+
expect(`${location.origin}${location.pathname}`).toBe(UPSTREAM_END_SESSION);
|
|
184
|
+
expect(location.searchParams.has("post_logout_redirect_uri")).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
});
|