@o3co/create-auth-provider 0.14.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 +34 -0
- package/templates/standalone/Dockerfile +1 -1
- package/templates/standalone/README.ja.md +565 -130
- package/templates/standalone/README.md +356 -92
- package/templates/standalone/config/application.conf +77 -2
- package/templates/standalone/docker-compose.production.yml +7 -0
- package/templates/standalone/docker-compose.yml +7 -0
- package/templates/standalone/gitignore +3 -2
- package/templates/standalone/package.json +3 -2
- 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 +40 -5
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +445 -0
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +446 -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 +85 -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__/scaffold-assets.test.mts +14 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +253 -22
- 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 +30 -51
- package/templates/standalone/src/buildModules.mts +94 -12
- package/templates/standalone/src/listen.mts +66 -0
- package/templates/standalone/src/logger.mts +7 -4
- package/templates/standalone/src/modules.mts +118 -12
- package/templates/standalone/src/routes.mts +95 -0
- package/templates/standalone/src/shutdown.mts +90 -18
- package/templates/standalone/vitest.supertest-loopback.mts +22 -3
- package/templates/versions.json +15 -14
- package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
- package/templates/standalone/src/terminalError.mts +0 -60
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
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 shared Redis connection logs when the server refuses
|
|
19
|
+
* it: the real ioredis the template ships, against a TCP server that answers
|
|
20
|
+
* the way Redis answers a password it no longer accepts. ioredis puts the
|
|
21
|
+
* handshake it sent — `AUTH` and the password — on the error it emits, so
|
|
22
|
+
* the `error` listener must log core's `loggableError` projection, never
|
|
23
|
+
* the error. The logger here serialises every own property of what it is
|
|
24
|
+
* handed, as a deployment's logger may.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { once } from "node:events";
|
|
28
|
+
import { type AddressInfo, createServer, type Socket } from "node:net";
|
|
29
|
+
import type { LifecycleRegistrar, Logger } from "@o3co/auth-provider-core";
|
|
30
|
+
import { describe, expect, it, vi } from "vitest";
|
|
31
|
+
import { standaloneRedisClientsModule } from "#/modules.mjs";
|
|
32
|
+
|
|
33
|
+
/** Redis's reply to a handshake whose password it does not accept. */
|
|
34
|
+
const WRONGPASS = "-WRONGPASS invalid username-password pair or user is disabled.\r\n";
|
|
35
|
+
|
|
36
|
+
/** The password the deployment is configured with, and the server no longer takes. */
|
|
37
|
+
const STALE_PASSWORD = "stale-s3cret-redis-password";
|
|
38
|
+
|
|
39
|
+
/** A TCP server that answers everything sent to it as Redis answers a refused password. */
|
|
40
|
+
async function refusingRedis(): Promise<{ port: number; close: () => Promise<void> }> {
|
|
41
|
+
const sockets = new Set<Socket>();
|
|
42
|
+
const server = createServer((socket) => {
|
|
43
|
+
sockets.add(socket);
|
|
44
|
+
socket.on("close", () => sockets.delete(socket));
|
|
45
|
+
socket.on("data", () => {
|
|
46
|
+
socket.write(WRONGPASS);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
server.listen(0, "127.0.0.1");
|
|
50
|
+
await once(server, "listening");
|
|
51
|
+
const { port } = server.address() as AddressInfo;
|
|
52
|
+
return {
|
|
53
|
+
port,
|
|
54
|
+
close: async () => {
|
|
55
|
+
for (const socket of sockets) socket.destroy();
|
|
56
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A logger that serialises every own property of what it is handed, `cause`
|
|
63
|
+
* and non-enumerable fields included. `lines` is what it wrote; its levels
|
|
64
|
+
* are spies.
|
|
65
|
+
*/
|
|
66
|
+
const serialiseEverythingLogger = () => {
|
|
67
|
+
const lines: string[] = [];
|
|
68
|
+
const walk = (value: unknown, seen = new WeakSet<object>()): unknown => {
|
|
69
|
+
if (typeof value !== "object" || value === null) return value;
|
|
70
|
+
if (seen.has(value)) return "[circular]";
|
|
71
|
+
seen.add(value);
|
|
72
|
+
const out: Record<string, unknown> = {};
|
|
73
|
+
for (const key of Object.getOwnPropertyNames(value)) {
|
|
74
|
+
out[key] = walk((value as Record<string, unknown>)[key], seen);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
};
|
|
78
|
+
const record = (level: string) =>
|
|
79
|
+
vi.fn((...args: unknown[]): void => {
|
|
80
|
+
lines.push(JSON.stringify({ level, args: walk(args) }));
|
|
81
|
+
});
|
|
82
|
+
const logger = {
|
|
83
|
+
trace: record("trace"),
|
|
84
|
+
debug: record("debug"),
|
|
85
|
+
info: record("info"),
|
|
86
|
+
warn: record("warn"),
|
|
87
|
+
error: record("error"),
|
|
88
|
+
fatal: record("fatal"),
|
|
89
|
+
child: () => logger,
|
|
90
|
+
};
|
|
91
|
+
return { logger: logger as typeof logger & Logger, lines };
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
describe("standaloneRedisClientsModule: the shared connection refused by the server", () => {
|
|
95
|
+
it("logs the connection's error as loggableError's projection, never the AUTH arguments ioredis puts on it", async () => {
|
|
96
|
+
const redis = await refusingRedis();
|
|
97
|
+
const cleanups: Array<() => Promise<void>> = [];
|
|
98
|
+
const lifecycleRegistrar: LifecycleRegistrar = {
|
|
99
|
+
register: (cleanup) => {
|
|
100
|
+
cleanups.push(cleanup);
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
const { logger, lines } = serialiseEverythingLogger();
|
|
104
|
+
const provides = (
|
|
105
|
+
standaloneRedisClientsModule as unknown as {
|
|
106
|
+
provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
|
|
107
|
+
}
|
|
108
|
+
).provides;
|
|
109
|
+
try {
|
|
110
|
+
await provides.refreshTokenFamilyClient({
|
|
111
|
+
config: {
|
|
112
|
+
refreshTokenFamilyStore: {
|
|
113
|
+
redis: { url: `redis://127.0.0.1:${redis.port}`, password: STALE_PASSWORD },
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
lifecycleRegistrar,
|
|
117
|
+
logger,
|
|
118
|
+
});
|
|
119
|
+
await vi.waitFor(() => expect(logger.error).toHaveBeenCalled(), { timeout: 10_000 });
|
|
120
|
+
} finally {
|
|
121
|
+
// `io.quit()`, as `handle.dispose()` runs it.
|
|
122
|
+
await Promise.all(cleanups.map((cleanup) => cleanup().catch(() => {})));
|
|
123
|
+
await redis.close();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
127
|
+
{
|
|
128
|
+
err: expect.objectContaining({
|
|
129
|
+
name: "ReplyError",
|
|
130
|
+
detail: expect.stringMatching(/^WRONGPASS /),
|
|
131
|
+
// Which command the server refused, and nothing it carried.
|
|
132
|
+
command: { name: "hello" },
|
|
133
|
+
}),
|
|
134
|
+
},
|
|
135
|
+
"standalone_redis_clients_error",
|
|
136
|
+
);
|
|
137
|
+
for (const line of lines) expect(line).not.toContain(STALE_PASSWORD);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -510,12 +510,22 @@ describe("#473: the Redis federation store's plaintext guard, booted from the sh
|
|
|
510
510
|
const config = resolveConfig({ ...PLAINTEXT_ENV, DEPLOYMENT_MODE: "single" });
|
|
511
511
|
handleRef = await boot(config, "development");
|
|
512
512
|
expect(handleRef.components.federationTokenStore?.kind).toBe("redis");
|
|
513
|
-
expect(warnSpy).toHaveBeenCalledWith(
|
|
513
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
514
|
+
{ store: "federation-tokens", mode: "allow-plaintext" },
|
|
515
|
+
"federation_store_plaintext",
|
|
516
|
+
);
|
|
514
517
|
});
|
|
515
518
|
|
|
516
|
-
it("keeps the FEDERATION_TOKENS_ALLOW_INSECURE=1 escape hatch under multi, at
|
|
519
|
+
it("keeps the FEDERATION_TOKENS_ALLOW_INSECURE=1 escape hatch under multi, logged at error", async () => {
|
|
517
520
|
process.env.FEDERATION_TOKENS_ALLOW_INSECURE = "1";
|
|
518
521
|
handleRef = await boot(resolveConfig(PLAINTEXT_ENV), "development");
|
|
519
|
-
expect(errorSpy).toHaveBeenCalledWith(
|
|
522
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
523
|
+
expect.objectContaining({
|
|
524
|
+
store: "federation-tokens",
|
|
525
|
+
deploymentMode: "multi",
|
|
526
|
+
override: "FEDERATION_TOKENS_ALLOW_INSECURE",
|
|
527
|
+
}),
|
|
528
|
+
"federation_store_plaintext_override",
|
|
529
|
+
);
|
|
520
530
|
});
|
|
521
531
|
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 1o1 Co. Ltd.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The template's composition, booted through core's `createApp` and mounted
|
|
19
|
+
* with no terminal handler of the template's after it — the router alone, as
|
|
20
|
+
* a composition root that copies nothing of `app.mts` mounts it.
|
|
21
|
+
*
|
|
22
|
+
* The OAuth and session routers parse their own bodies and have no error
|
|
23
|
+
* handler: a body parser's refusal on `/oauth/token`, `/oauth/introspect` or
|
|
24
|
+
* `/session/login`, and any error a route let through, went on to whatever
|
|
25
|
+
* the host had after the router. Only `app.mts` had a handler there, so any
|
|
26
|
+
* other host answered with Express's final handler — an HTML page, with the
|
|
27
|
+
* stack outside production, where V8's JSON error quotes the body it could
|
|
28
|
+
* not parse. The router core returns now answers them itself.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { defineModule } from "@o3co/auth-provider-core";
|
|
32
|
+
import { Router } from "express";
|
|
33
|
+
import request from "supertest";
|
|
34
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
35
|
+
import {
|
|
36
|
+
basic,
|
|
37
|
+
type Composition,
|
|
38
|
+
compose,
|
|
39
|
+
JSON_TYPE,
|
|
40
|
+
KIB,
|
|
41
|
+
M2M,
|
|
42
|
+
padJson,
|
|
43
|
+
} from "./all-modules-composition.fixture.mjs";
|
|
44
|
+
|
|
45
|
+
let current: Composition | undefined;
|
|
46
|
+
|
|
47
|
+
afterEach(async () => {
|
|
48
|
+
await current?.handle.dispose();
|
|
49
|
+
current = undefined;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** A route that lets an error through, contributed as a deployment adds one. */
|
|
53
|
+
const throwingRouteModule = defineModule({
|
|
54
|
+
name: "test:throwing-route",
|
|
55
|
+
contributes: {
|
|
56
|
+
routes: [
|
|
57
|
+
() => {
|
|
58
|
+
const router = Router();
|
|
59
|
+
router.get("/", () => {
|
|
60
|
+
throw new Error("a store said something: escaped-error-marker");
|
|
61
|
+
});
|
|
62
|
+
return { id: "test:throwing-route", mountPath: "/throws", handler: router };
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/** The composition, the router alone mounted. */
|
|
69
|
+
const boot = async (): Promise<Composition> => {
|
|
70
|
+
current = await compose({
|
|
71
|
+
terminalErrorHandler: false,
|
|
72
|
+
extraModules: () => [throwingRouteModule],
|
|
73
|
+
});
|
|
74
|
+
return current;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** The routes that parse a body, and what each needs to reach its parser. */
|
|
78
|
+
const PARSING_ROUTES = [
|
|
79
|
+
["/oauth/token", { authorization: basic(M2M) }],
|
|
80
|
+
["/oauth/introspect", { authorization: basic(M2M) }],
|
|
81
|
+
["/session/login", {}],
|
|
82
|
+
] as const;
|
|
83
|
+
|
|
84
|
+
const errorLines = (c: Composition) => c.logger.lines.filter((line) => line.level === "error");
|
|
85
|
+
|
|
86
|
+
describe("the composed router, mounted alone, answers a body parser's refusal", () => {
|
|
87
|
+
it.each(PARSING_ROUTES)(
|
|
88
|
+
"%s: malformed JSON is 400 in the envelope, nothing logged",
|
|
89
|
+
async (path, headers) => {
|
|
90
|
+
const c = await boot();
|
|
91
|
+
const res = await request(c.app)
|
|
92
|
+
.post(path)
|
|
93
|
+
.set(headers)
|
|
94
|
+
.set("Content-Type", JSON_TYPE)
|
|
95
|
+
.send('{"password":"body-secret-marker');
|
|
96
|
+
|
|
97
|
+
expect(res.status).toBe(400);
|
|
98
|
+
expect(res.headers["content-type"]).toMatch(/^application\/json/);
|
|
99
|
+
expect(res.body).toEqual({ error: "invalid_request", error_description: "malformed_body" });
|
|
100
|
+
expect(res.text).not.toContain("body-secret-marker");
|
|
101
|
+
expect(res.text).not.toMatch(/ at /);
|
|
102
|
+
expect(errorLines(c)).toEqual([]);
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
it.each(PARSING_ROUTES)(
|
|
107
|
+
"%s: a body over the parser's limit is 413 in the envelope, nothing logged",
|
|
108
|
+
async (path, headers) => {
|
|
109
|
+
const c = await boot();
|
|
110
|
+
const res = await request(c.app)
|
|
111
|
+
.post(path)
|
|
112
|
+
.set(headers)
|
|
113
|
+
.set("Content-Type", JSON_TYPE)
|
|
114
|
+
.send(padJson(200 * KIB));
|
|
115
|
+
|
|
116
|
+
expect(res.status).toBe(413);
|
|
117
|
+
expect(res.headers["content-type"]).toMatch(/^application\/json/);
|
|
118
|
+
expect(res.body).toEqual({ error: "invalid_request", error_description: "body_too_large" });
|
|
119
|
+
expect(errorLines(c)).toEqual([]);
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("the composed router, mounted alone, answers an error a route let through", () => {
|
|
125
|
+
it("is 500 server_error in the envelope, logged once at error", async () => {
|
|
126
|
+
const c = await boot();
|
|
127
|
+
const res = await request(c.app).get("/throws");
|
|
128
|
+
|
|
129
|
+
expect(res.status).toBe(500);
|
|
130
|
+
expect(res.headers["content-type"]).toMatch(/^application\/json/);
|
|
131
|
+
expect(res.body).toEqual({ error: "server_error", error_description: "unexpected_error" });
|
|
132
|
+
expect(res.text).not.toContain("escaped-error-marker");
|
|
133
|
+
expect(errorLines(c)).toEqual([
|
|
134
|
+
{
|
|
135
|
+
level: "error",
|
|
136
|
+
args: [
|
|
137
|
+
{
|
|
138
|
+
endpoint: "/throws",
|
|
139
|
+
err: expect.objectContaining({
|
|
140
|
+
name: "Error",
|
|
141
|
+
detail: "a store said something: escaped-error-marker",
|
|
142
|
+
}),
|
|
143
|
+
},
|
|
144
|
+
"unhandled_request_error",
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
]);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -412,3 +412,17 @@ function dockerignoreExcludes(dockerignore: string, file: string): boolean {
|
|
|
412
412
|
}
|
|
413
413
|
return excluded;
|
|
414
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
|
+
});
|