@thotischner/observability-mcp 1.7.1 → 1.8.1
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/config/products.yaml.example +48 -0
- package/dist/audit/log.d.ts +99 -0
- package/dist/audit/log.js +180 -0
- package/dist/audit/log.test.d.ts +1 -0
- package/dist/audit/log.test.js +147 -0
- package/dist/audit/middleware.d.ts +20 -0
- package/dist/audit/middleware.js +50 -0
- package/dist/auth/credentials.d.ts +18 -0
- package/dist/auth/credentials.js +26 -1
- package/dist/auth/credentials.test.js +26 -1
- package/dist/auth/local-users.d.ts +62 -0
- package/dist/auth/local-users.js +143 -0
- package/dist/auth/local-users.test.d.ts +1 -0
- package/dist/auth/local-users.test.js +80 -0
- package/dist/auth/middleware.d.ts +48 -0
- package/dist/auth/middleware.js +65 -0
- package/dist/auth/middleware.test.d.ts +1 -0
- package/dist/auth/middleware.test.js +90 -0
- package/dist/auth/oidc/client.d.ts +73 -0
- package/dist/auth/oidc/client.js +104 -0
- package/dist/auth/oidc/client.test.d.ts +1 -0
- package/dist/auth/oidc/client.test.js +121 -0
- package/dist/auth/oidc/discovery.d.ts +38 -0
- package/dist/auth/oidc/discovery.js +48 -0
- package/dist/auth/oidc/discovery.test.d.ts +1 -0
- package/dist/auth/oidc/discovery.test.js +68 -0
- package/dist/auth/oidc/endpoints.d.ts +20 -0
- package/dist/auth/oidc/endpoints.js +124 -0
- package/dist/auth/oidc/endpoints.test.d.ts +7 -0
- package/dist/auth/oidc/endpoints.test.js +304 -0
- package/dist/auth/oidc/flow-cookie.d.ts +57 -0
- package/dist/auth/oidc/flow-cookie.js +142 -0
- package/dist/auth/oidc/flow-cookie.test.d.ts +1 -0
- package/dist/auth/oidc/flow-cookie.test.js +0 -0
- package/dist/auth/oidc/index.d.ts +7 -0
- package/dist/auth/oidc/index.js +6 -0
- package/dist/auth/oidc/jwks.d.ts +36 -0
- package/dist/auth/oidc/jwks.js +69 -0
- package/dist/auth/oidc/jwks.test.d.ts +1 -0
- package/dist/auth/oidc/jwks.test.js +65 -0
- package/dist/auth/oidc/jwt.d.ts +62 -0
- package/dist/auth/oidc/jwt.js +113 -0
- package/dist/auth/oidc/jwt.test.d.ts +1 -0
- package/dist/auth/oidc/jwt.test.js +141 -0
- package/dist/auth/oidc/pkce.d.ts +19 -0
- package/dist/auth/oidc/pkce.js +43 -0
- package/dist/auth/oidc/pkce.test.d.ts +1 -0
- package/dist/auth/oidc/pkce.test.js +55 -0
- package/dist/auth/oidc/runtime.d.ts +63 -0
- package/dist/auth/oidc/runtime.js +129 -0
- package/dist/auth/oidc/runtime.test.d.ts +1 -0
- package/dist/auth/oidc/runtime.test.js +180 -0
- package/dist/auth/policy/engine.d.ts +48 -0
- package/dist/auth/policy/engine.js +73 -0
- package/dist/auth/policy/engine.test.d.ts +1 -0
- package/dist/auth/policy/engine.test.js +98 -0
- package/dist/auth/policy/loader.d.ts +35 -0
- package/dist/auth/policy/loader.js +100 -0
- package/dist/auth/policy/opa.d.ts +69 -0
- package/dist/auth/policy/opa.js +162 -0
- package/dist/auth/policy/opa.test.d.ts +1 -0
- package/dist/auth/policy/opa.test.js +158 -0
- package/dist/auth/rbac.d.ts +40 -0
- package/dist/auth/rbac.js +120 -0
- package/dist/auth/rbac.test.d.ts +1 -0
- package/dist/auth/rbac.test.js +121 -0
- package/dist/auth/session.d.ts +66 -0
- package/dist/auth/session.js +146 -0
- package/dist/auth/session.test.d.ts +1 -0
- package/dist/auth/session.test.js +90 -0
- package/dist/catalog/loader.d.ts +67 -0
- package/dist/catalog/loader.js +122 -0
- package/dist/catalog/loader.test.d.ts +1 -0
- package/dist/catalog/loader.test.js +108 -0
- package/dist/context.d.ts +13 -1
- package/dist/context.js +5 -1
- package/dist/index.js +1012 -29
- package/dist/net/egress-policy.js +2 -0
- package/dist/openapi.js +440 -0
- package/dist/openapi.test.d.ts +1 -0
- package/dist/openapi.test.js +64 -0
- package/dist/policy/redact.d.ts +44 -0
- package/dist/policy/redact.js +144 -0
- package/dist/policy/redact.test.d.ts +1 -0
- package/dist/policy/redact.test.js +172 -0
- package/dist/products/loader.d.ts +84 -0
- package/dist/products/loader.js +216 -0
- package/dist/products/loader.test.d.ts +1 -0
- package/dist/products/loader.test.js +168 -0
- package/dist/quota/limiter.d.ts +72 -0
- package/dist/quota/limiter.js +105 -0
- package/dist/quota/limiter.test.d.ts +1 -0
- package/dist/quota/limiter.test.js +119 -0
- package/dist/quota/token-budget.d.ts +119 -0
- package/dist/quota/token-budget.js +297 -0
- package/dist/quota/token-budget.test.d.ts +1 -0
- package/dist/quota/token-budget.test.js +215 -0
- package/dist/tenancy/context.d.ts +45 -0
- package/dist/tenancy/context.js +97 -0
- package/dist/tenancy/context.test.d.ts +1 -0
- package/dist/tenancy/context.test.js +72 -0
- package/dist/tenancy/migration.test.d.ts +7 -0
- package/dist/tenancy/migration.test.js +75 -0
- package/dist/ui/index.html +1454 -88
- package/package.json +20 -3
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test for the three OIDC HTTP endpoints. Boots a real
|
|
3
|
+
* Express app, registers the routes against a stubbed OidcClient
|
|
4
|
+
* (mock fetcher) so the IdP round-trip is in-process, and walks
|
|
5
|
+
* through the redirect → callback → session-cookie flow end-to-end.
|
|
6
|
+
*/
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import express from "express";
|
|
10
|
+
import { createServer } from "node:http";
|
|
11
|
+
import { generateKeyPairSync, createPublicKey, createSign } from "node:crypto";
|
|
12
|
+
import { registerOidcRoutes } from "./endpoints.js";
|
|
13
|
+
import { buildOidcRuntime } from "./runtime.js";
|
|
14
|
+
import { OidcClient } from "./client.js";
|
|
15
|
+
const SECRET = "x".repeat(32);
|
|
16
|
+
const ISSUER = "https://idp.test";
|
|
17
|
+
const CLIENT_ID = "c-1";
|
|
18
|
+
const REDIRECT_URI = "http://app.test/api/auth/oidc/callback";
|
|
19
|
+
function b64u(s) {
|
|
20
|
+
const b = typeof s === "string" ? Buffer.from(s, "utf8") : s;
|
|
21
|
+
return b.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
22
|
+
}
|
|
23
|
+
function rsaKey() {
|
|
24
|
+
const { publicKey, privateKey } = generateKeyPairSync("rsa", {
|
|
25
|
+
modulusLength: 2048,
|
|
26
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
27
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
28
|
+
});
|
|
29
|
+
const jwk = createPublicKey(publicKey).export({ format: "jwk" });
|
|
30
|
+
jwk.kid = "k-1";
|
|
31
|
+
return { jwk, privateKeyPem: privateKey };
|
|
32
|
+
}
|
|
33
|
+
function signRs256(payload, pem, kid) {
|
|
34
|
+
const h = b64u(JSON.stringify({ alg: "RS256", typ: "JWT", kid }));
|
|
35
|
+
const b = b64u(JSON.stringify(payload));
|
|
36
|
+
const s = createSign("RSA-SHA256");
|
|
37
|
+
s.update(`${h}.${b}`);
|
|
38
|
+
s.end();
|
|
39
|
+
return `${h}.${b}.${b64u(s.sign(pem))}`;
|
|
40
|
+
}
|
|
41
|
+
async function listen(app) {
|
|
42
|
+
const server = createServer(app);
|
|
43
|
+
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
|
44
|
+
const addr = server.address();
|
|
45
|
+
return {
|
|
46
|
+
server,
|
|
47
|
+
base: `http://127.0.0.1:${addr.port}`,
|
|
48
|
+
close: () => new Promise((r) => server.close(() => r())),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function discoveryDoc() {
|
|
52
|
+
return {
|
|
53
|
+
issuer: ISSUER,
|
|
54
|
+
authorization_endpoint: `${ISSUER}/auth`,
|
|
55
|
+
token_endpoint: `${ISSUER}/token`,
|
|
56
|
+
jwks_uri: `${ISSUER}/jwks`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function configForTest() {
|
|
60
|
+
return {
|
|
61
|
+
issuer: ISSUER,
|
|
62
|
+
clientId: CLIENT_ID,
|
|
63
|
+
clientSecret: undefined,
|
|
64
|
+
redirectUri: REDIRECT_URI,
|
|
65
|
+
scopes: "openid profile email",
|
|
66
|
+
rolesClaim: "groups",
|
|
67
|
+
roleMap: { "omcp-admin": "admin", "omcp-ops": "operator" },
|
|
68
|
+
logoutRedirect: "/",
|
|
69
|
+
tenantClaim: "",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
test("GET /api/auth/oidc/login — 302 to IdP and sets flow cookie", async () => {
|
|
73
|
+
const cfg = configForTest();
|
|
74
|
+
const fetcher = async (url) => {
|
|
75
|
+
if (url.endsWith("/.well-known/openid-configuration"))
|
|
76
|
+
return new Response(JSON.stringify(discoveryDoc()), { status: 200 });
|
|
77
|
+
return new Response("nf", { status: 404 });
|
|
78
|
+
};
|
|
79
|
+
const client = new OidcClient({ issuer: cfg.issuer, clientId: cfg.clientId, redirectUri: cfg.redirectUri, fetcher });
|
|
80
|
+
const oidc = buildOidcRuntime(cfg, { client });
|
|
81
|
+
const app = express();
|
|
82
|
+
registerOidcRoutes(app, { sessionCfg: { secret: SECRET }, oidc });
|
|
83
|
+
const { base, close } = await listen(app);
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetch(`${base}/api/auth/oidc/login`, { redirect: "manual" });
|
|
86
|
+
assert.equal(res.status, 302);
|
|
87
|
+
const loc = res.headers.get("location") ?? "";
|
|
88
|
+
assert.ok(loc.startsWith(`${ISSUER}/auth?`), `unexpected redirect target: ${loc}`);
|
|
89
|
+
const u = new URL(loc);
|
|
90
|
+
assert.equal(u.searchParams.get("client_id"), CLIENT_ID);
|
|
91
|
+
assert.equal(u.searchParams.get("response_type"), "code");
|
|
92
|
+
assert.ok(u.searchParams.get("code_challenge"));
|
|
93
|
+
const setCookie = res.headers.get("set-cookie") ?? "";
|
|
94
|
+
assert.match(setCookie, /^omcp_oidc_flow=/, "must set the flow cookie");
|
|
95
|
+
assert.match(setCookie, /HttpOnly/);
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
await close();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
test("GET /api/auth/oidc/login — honours safe ?return_to=, ignores hostile", async () => {
|
|
102
|
+
const cfg = configForTest();
|
|
103
|
+
const fetcher = async (_url) => new Response(JSON.stringify(discoveryDoc()), { status: 200 });
|
|
104
|
+
const client = new OidcClient({ issuer: cfg.issuer, clientId: cfg.clientId, redirectUri: cfg.redirectUri, fetcher });
|
|
105
|
+
const oidc = buildOidcRuntime(cfg, { client });
|
|
106
|
+
const app = express();
|
|
107
|
+
registerOidcRoutes(app, { sessionCfg: { secret: SECRET }, oidc });
|
|
108
|
+
const { base, close } = await listen(app);
|
|
109
|
+
try {
|
|
110
|
+
// Safe path goes into the cookie payload (we'll re-decode on
|
|
111
|
+
// callback). We can't easily inspect cookie content from outside
|
|
112
|
+
// here without re-implementing the verify; we trust the
|
|
113
|
+
// unit-test coverage of issueFlowCookie/isSafeReturnTo for that.
|
|
114
|
+
const safe = await fetch(`${base}/api/auth/oidc/login?return_to=/dashboard`, { redirect: "manual" });
|
|
115
|
+
assert.equal(safe.status, 302);
|
|
116
|
+
const hostile = await fetch(`${base}/api/auth/oidc/login?return_to=https://evil.example`, { redirect: "manual" });
|
|
117
|
+
assert.equal(hostile.status, 302);
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
await close();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
test("GET /api/auth/oidc/callback — end-to-end happy path mints session cookie + redirects to returnTo", async () => {
|
|
124
|
+
const { jwk, privateKeyPem } = rsaKey();
|
|
125
|
+
const now = Math.floor(Date.now() / 1000);
|
|
126
|
+
// Capture state/nonce/verifier the login mints, so we can sign a
|
|
127
|
+
// matching id_token before issuing the callback.
|
|
128
|
+
let mintedFlow = null;
|
|
129
|
+
const cfg = configForTest();
|
|
130
|
+
const fetcher = async (url) => {
|
|
131
|
+
if (url.endsWith("/.well-known/openid-configuration"))
|
|
132
|
+
return new Response(JSON.stringify(discoveryDoc()), { status: 200 });
|
|
133
|
+
if (url === `${ISSUER}/jwks`)
|
|
134
|
+
return new Response(JSON.stringify({ keys: [jwk] }), { status: 200 });
|
|
135
|
+
if (url === `${ISSUER}/token`) {
|
|
136
|
+
// Sign id_token with the actual nonce we minted in /login.
|
|
137
|
+
const idToken = signRs256({
|
|
138
|
+
iss: ISSUER, aud: CLIENT_ID, sub: "alice", name: "Alice", email: "alice@example.test",
|
|
139
|
+
exp: now + 60, iat: now, nonce: mintedFlow.nonce, groups: ["omcp-admin", "omcp-ops", "ignored"],
|
|
140
|
+
}, privateKeyPem, jwk.kid);
|
|
141
|
+
return new Response(JSON.stringify({ id_token: idToken, access_token: "AT", token_type: "Bearer" }), { status: 200 });
|
|
142
|
+
}
|
|
143
|
+
return new Response("nf", { status: 404 });
|
|
144
|
+
};
|
|
145
|
+
// Patched client that also exposes the minted flow.
|
|
146
|
+
const baseClient = new OidcClient({ issuer: cfg.issuer, clientId: cfg.clientId, redirectUri: cfg.redirectUri, fetcher });
|
|
147
|
+
const wrapped = Object.create(baseClient);
|
|
148
|
+
wrapped.start = async () => {
|
|
149
|
+
const out = await baseClient.start();
|
|
150
|
+
mintedFlow = out.flow;
|
|
151
|
+
return out;
|
|
152
|
+
};
|
|
153
|
+
const oidc = buildOidcRuntime(cfg, { client: wrapped });
|
|
154
|
+
const app = express();
|
|
155
|
+
registerOidcRoutes(app, { sessionCfg: { secret: SECRET }, oidc });
|
|
156
|
+
const { base, close } = await listen(app);
|
|
157
|
+
try {
|
|
158
|
+
// 1) /login → grab the flow cookie + the IdP's `state` from the URL
|
|
159
|
+
const loginRes = await fetch(`${base}/api/auth/oidc/login?return_to=/audit`, { redirect: "manual" });
|
|
160
|
+
assert.equal(loginRes.status, 302);
|
|
161
|
+
const flowCookie = (loginRes.headers.get("set-cookie") ?? "").split(";")[0];
|
|
162
|
+
const idpRedirect = new URL(loginRes.headers.get("location"));
|
|
163
|
+
const state = idpRedirect.searchParams.get("state");
|
|
164
|
+
assert.equal(state, mintedFlow.state, "state in URL must match flow cookie payload");
|
|
165
|
+
// 2) Simulate IdP redirect back: GET /callback?code=ABC&state=...
|
|
166
|
+
// Send the flow cookie back to the server.
|
|
167
|
+
const cbRes = await fetch(`${base}/api/auth/oidc/callback?code=ABC&state=${state}`, {
|
|
168
|
+
redirect: "manual",
|
|
169
|
+
headers: { cookie: flowCookie },
|
|
170
|
+
});
|
|
171
|
+
assert.equal(cbRes.status, 302, `callback should 302; got ${cbRes.status}, body=${await cbRes.text()}`);
|
|
172
|
+
assert.equal(cbRes.headers.get("location"), "/audit", "callback should redirect to the returnTo");
|
|
173
|
+
// Inspect cookies individually — undici joins multi-Set-Cookie
|
|
174
|
+
// headers with `, ` when read via .get("set-cookie"), which makes
|
|
175
|
+
// a naive regex match the wrong adjacent cookie's attributes.
|
|
176
|
+
const cookies = cbRes.headers.getSetCookie();
|
|
177
|
+
assert.ok(cookies.some((c) => /^omcp_session=[^;]+;/.test(c)), `session cookie should be set, got ${JSON.stringify(cookies)}`);
|
|
178
|
+
const cleared = cookies.find((c) => c.startsWith("omcp_oidc_flow="));
|
|
179
|
+
assert.ok(cleared, `flow cookie should appear in Set-Cookie, got ${JSON.stringify(cookies)}`);
|
|
180
|
+
assert.match(cleared, /^omcp_oidc_flow=;/, "flow cookie value must be empty (cleared)");
|
|
181
|
+
assert.match(cleared, /Max-Age=0/, "flow cookie must carry Max-Age=0");
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
await close();
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
test("GET /api/auth/oidc/callback — 400 when flow cookie missing", async () => {
|
|
188
|
+
const cfg = configForTest();
|
|
189
|
+
const fetcher = async (_url) => new Response(JSON.stringify(discoveryDoc()), { status: 200 });
|
|
190
|
+
const client = new OidcClient({ issuer: cfg.issuer, clientId: cfg.clientId, redirectUri: cfg.redirectUri, fetcher });
|
|
191
|
+
const oidc = buildOidcRuntime(cfg, { client });
|
|
192
|
+
const app = express();
|
|
193
|
+
registerOidcRoutes(app, { sessionCfg: { secret: SECRET }, oidc });
|
|
194
|
+
const { base, close } = await listen(app);
|
|
195
|
+
try {
|
|
196
|
+
const res = await fetch(`${base}/api/auth/oidc/callback?code=ABC&state=XYZ`, { redirect: "manual" });
|
|
197
|
+
assert.equal(res.status, 400);
|
|
198
|
+
const body = await res.json();
|
|
199
|
+
assert.equal(body.error, "oidc_flow_cookie_missing");
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
await close();
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
test("GET /api/auth/oidc/callback — surfaces IdP-side error parameter", async () => {
|
|
206
|
+
const cfg = configForTest();
|
|
207
|
+
const fetcher = async (_url) => new Response(JSON.stringify(discoveryDoc()), { status: 200 });
|
|
208
|
+
const client = new OidcClient({ issuer: cfg.issuer, clientId: cfg.clientId, redirectUri: cfg.redirectUri, fetcher });
|
|
209
|
+
const oidc = buildOidcRuntime(cfg, { client });
|
|
210
|
+
const app = express();
|
|
211
|
+
registerOidcRoutes(app, { sessionCfg: { secret: SECRET }, oidc });
|
|
212
|
+
const { base, close } = await listen(app);
|
|
213
|
+
try {
|
|
214
|
+
const res = await fetch(`${base}/api/auth/oidc/callback?error=access_denied`, { redirect: "manual" });
|
|
215
|
+
assert.equal(res.status, 400);
|
|
216
|
+
const body = await res.json();
|
|
217
|
+
assert.equal(body.error, "oidc_idp_error");
|
|
218
|
+
assert.match(body.message, /access_denied/);
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
await close();
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
test("GET /api/auth/oidc/callback — persists verified email; drops unverified email", async () => {
|
|
225
|
+
// Build two end-to-end runs against the same setup, varying only
|
|
226
|
+
// the `email_verified` claim. Inspect the resulting session cookie
|
|
227
|
+
// payload by parsing it client-side (the cookie shape is documented
|
|
228
|
+
// in src/auth/session.ts).
|
|
229
|
+
async function runWith(emailVerified) {
|
|
230
|
+
const { jwk, privateKeyPem } = rsaKey();
|
|
231
|
+
const now = Math.floor(Date.now() / 1000);
|
|
232
|
+
let mintedFlow = null;
|
|
233
|
+
const cfg = configForTest();
|
|
234
|
+
const fetcher = async (url) => {
|
|
235
|
+
if (url.endsWith("/.well-known/openid-configuration"))
|
|
236
|
+
return new Response(JSON.stringify(discoveryDoc()), { status: 200 });
|
|
237
|
+
if (url === `${ISSUER}/jwks`)
|
|
238
|
+
return new Response(JSON.stringify({ keys: [jwk] }), { status: 200 });
|
|
239
|
+
if (url === `${ISSUER}/token`) {
|
|
240
|
+
const claims = {
|
|
241
|
+
iss: ISSUER, aud: CLIENT_ID, sub: "alice", name: "Alice",
|
|
242
|
+
email: "alice@example.test", exp: now + 60, iat: now, nonce: mintedFlow.nonce,
|
|
243
|
+
};
|
|
244
|
+
if (emailVerified !== undefined)
|
|
245
|
+
claims.email_verified = emailVerified;
|
|
246
|
+
const idToken = signRs256(claims, privateKeyPem, jwk.kid);
|
|
247
|
+
return new Response(JSON.stringify({ id_token: idToken }), { status: 200 });
|
|
248
|
+
}
|
|
249
|
+
return new Response("nf", { status: 404 });
|
|
250
|
+
};
|
|
251
|
+
const baseClient = new OidcClient({ issuer: cfg.issuer, clientId: cfg.clientId, redirectUri: cfg.redirectUri, fetcher });
|
|
252
|
+
const wrapped = Object.create(baseClient);
|
|
253
|
+
wrapped.start = async () => { const o = await baseClient.start(); mintedFlow = o.flow; return o; };
|
|
254
|
+
const oidc = buildOidcRuntime(cfg, { client: wrapped });
|
|
255
|
+
const app = express();
|
|
256
|
+
registerOidcRoutes(app, { sessionCfg: { secret: SECRET }, oidc });
|
|
257
|
+
const { base, close } = await listen(app);
|
|
258
|
+
try {
|
|
259
|
+
const login = await fetch(`${base}/api/auth/oidc/login`, { redirect: "manual" });
|
|
260
|
+
const flowCookie = (login.headers.get("set-cookie") ?? "").split(";")[0];
|
|
261
|
+
const state = new URL(login.headers.get("location")).searchParams.get("state");
|
|
262
|
+
const cb = await fetch(`${base}/api/auth/oidc/callback?code=ABC&state=${state}`, { redirect: "manual", headers: { cookie: flowCookie } });
|
|
263
|
+
const setCookies = cb.headers.getSetCookie();
|
|
264
|
+
const session = setCookies.find((c) => c.startsWith("omcp_session="));
|
|
265
|
+
const value = session.slice("omcp_session=".length).split(";")[0];
|
|
266
|
+
const payloadB64 = value.split(".")[0];
|
|
267
|
+
const pad = payloadB64.length % 4 === 0 ? "" : "=".repeat(4 - (payloadB64.length % 4));
|
|
268
|
+
return JSON.parse(Buffer.from(payloadB64.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64").toString("utf8"));
|
|
269
|
+
}
|
|
270
|
+
finally {
|
|
271
|
+
await close();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// email_verified absent → trust the email
|
|
275
|
+
const absent = await runWith(undefined);
|
|
276
|
+
assert.equal(absent.email, "alice@example.test");
|
|
277
|
+
// email_verified=true → persist
|
|
278
|
+
const verified = await runWith(true);
|
|
279
|
+
assert.equal(verified.email, "alice@example.test");
|
|
280
|
+
// email_verified=false → drop
|
|
281
|
+
const unverified = await runWith(false);
|
|
282
|
+
assert.equal(unverified.email, undefined);
|
|
283
|
+
});
|
|
284
|
+
test("POST /api/auth/oidc/logout — 204 and clears the session cookie", async () => {
|
|
285
|
+
const cfg = configForTest();
|
|
286
|
+
const fetcher = async (_url) => new Response("nf", { status: 404 });
|
|
287
|
+
const client = new OidcClient({ issuer: cfg.issuer, clientId: cfg.clientId, redirectUri: cfg.redirectUri, fetcher });
|
|
288
|
+
const oidc = buildOidcRuntime(cfg, { client });
|
|
289
|
+
const app = express();
|
|
290
|
+
registerOidcRoutes(app, { sessionCfg: { secret: SECRET }, oidc });
|
|
291
|
+
const { base, close } = await listen(app);
|
|
292
|
+
try {
|
|
293
|
+
const res = await fetch(`${base}/api/auth/oidc/logout`, { method: "POST" });
|
|
294
|
+
assert.equal(res.status, 204);
|
|
295
|
+
const cookies = res.headers.getSetCookie();
|
|
296
|
+
const cleared = cookies.find((c) => c.startsWith("omcp_session="));
|
|
297
|
+
assert.ok(cleared, `logout should clear the session cookie; got ${JSON.stringify(cookies)}`);
|
|
298
|
+
assert.match(cleared, /^omcp_session=;/);
|
|
299
|
+
assert.match(cleared, /Max-Age=0/);
|
|
300
|
+
}
|
|
301
|
+
finally {
|
|
302
|
+
await close();
|
|
303
|
+
}
|
|
304
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Short-lived signed cookie that carries the OIDC code-flow state
|
|
3
|
+
* (state, nonce, code_verifier, returnTo) between the /login redirect
|
|
4
|
+
* and the /callback handler.
|
|
5
|
+
*
|
|
6
|
+
* Signed with HMAC-SHA256 using the same session secret the OMCP
|
|
7
|
+
* session cookie uses — keeps the trust boundary single and obviates
|
|
8
|
+
* a separate key. The payload is a JSON object; the cookie is
|
|
9
|
+
* `<base64url-payload>.<base64url-sig>` like the main session cookie.
|
|
10
|
+
*
|
|
11
|
+
* TTL is intentionally short (5 minutes by default). The auth-code
|
|
12
|
+
* flow is interactive: an IdP that takes longer than that to redirect
|
|
13
|
+
* back is broken, not slow. Short TTL also bounds the window during
|
|
14
|
+
* which a leaked state cookie is exploitable.
|
|
15
|
+
*/
|
|
16
|
+
export interface FlowState {
|
|
17
|
+
state: string;
|
|
18
|
+
nonce: string;
|
|
19
|
+
codeVerifier: string;
|
|
20
|
+
/** Where to 302 after a successful callback. Always a same-origin
|
|
21
|
+
* path that begins with `/`; verified at consume time. */
|
|
22
|
+
returnTo: string;
|
|
23
|
+
/** Issued-at, seconds since epoch. */
|
|
24
|
+
iat: number;
|
|
25
|
+
/** Expiry, seconds since epoch. */
|
|
26
|
+
exp: number;
|
|
27
|
+
}
|
|
28
|
+
export declare const DEFAULT_FLOW_COOKIE_NAME = "omcp_oidc_flow";
|
|
29
|
+
export declare const DEFAULT_FLOW_TTL_SECONDS = 300;
|
|
30
|
+
export interface FlowCookieConfig {
|
|
31
|
+
secret: string;
|
|
32
|
+
ttlSeconds?: number;
|
|
33
|
+
cookieName?: string;
|
|
34
|
+
}
|
|
35
|
+
/** Build the cookie value carrying the flow state. */
|
|
36
|
+
export declare function issueFlowCookie(flow: {
|
|
37
|
+
state: string;
|
|
38
|
+
nonce: string;
|
|
39
|
+
codeVerifier: string;
|
|
40
|
+
returnTo: string;
|
|
41
|
+
}, cfg: FlowCookieConfig, now?: number): string;
|
|
42
|
+
/** Decode + verify a cookie value. Returns the flow state on success,
|
|
43
|
+
* null on any failure (signature mismatch, expired, malformed). */
|
|
44
|
+
export declare function verifyFlowCookie(cookieValue: string | undefined | null, cfg: FlowCookieConfig, now?: number): FlowState | null;
|
|
45
|
+
export declare function setFlowCookieHeader(value: string, cfg: FlowCookieConfig, opts?: {
|
|
46
|
+
secure?: boolean;
|
|
47
|
+
}): string;
|
|
48
|
+
export declare function clearFlowCookieHeader(cfg: FlowCookieConfig, opts?: {
|
|
49
|
+
secure?: boolean;
|
|
50
|
+
}): string;
|
|
51
|
+
/** Validate a returnTo before using it for the post-callback redirect.
|
|
52
|
+
* Defends against an attacker stuffing a hostile absolute URL into
|
|
53
|
+
* the login link (open-redirect). */
|
|
54
|
+
export declare function isSafeReturnTo(path: unknown): path is string;
|
|
55
|
+
/** Parse a Cookie header for the flow cookie's value. Tolerates other
|
|
56
|
+
* cookies (session, CSRF, etc.) sharing the header. */
|
|
57
|
+
export declare function readFlowCookie(cookieHeader: string | undefined | null, name?: string): string | null;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Short-lived signed cookie that carries the OIDC code-flow state
|
|
3
|
+
* (state, nonce, code_verifier, returnTo) between the /login redirect
|
|
4
|
+
* and the /callback handler.
|
|
5
|
+
*
|
|
6
|
+
* Signed with HMAC-SHA256 using the same session secret the OMCP
|
|
7
|
+
* session cookie uses — keeps the trust boundary single and obviates
|
|
8
|
+
* a separate key. The payload is a JSON object; the cookie is
|
|
9
|
+
* `<base64url-payload>.<base64url-sig>` like the main session cookie.
|
|
10
|
+
*
|
|
11
|
+
* TTL is intentionally short (5 minutes by default). The auth-code
|
|
12
|
+
* flow is interactive: an IdP that takes longer than that to redirect
|
|
13
|
+
* back is broken, not slow. Short TTL also bounds the window during
|
|
14
|
+
* which a leaked state cookie is exploitable.
|
|
15
|
+
*/
|
|
16
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
17
|
+
export const DEFAULT_FLOW_COOKIE_NAME = "omcp_oidc_flow";
|
|
18
|
+
export const DEFAULT_FLOW_TTL_SECONDS = 300; // 5 min
|
|
19
|
+
const MAX_COOKIE_BYTES = 4096;
|
|
20
|
+
function b64u(buf) {
|
|
21
|
+
return buf.toString("base64url");
|
|
22
|
+
}
|
|
23
|
+
function unb64u(s) {
|
|
24
|
+
try {
|
|
25
|
+
return Buffer.from(s, "base64url");
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function sign(secret, payload) {
|
|
32
|
+
return createHmac("sha256", secret).update(payload).digest("base64url");
|
|
33
|
+
}
|
|
34
|
+
/** Build the cookie value carrying the flow state. */
|
|
35
|
+
export function issueFlowCookie(flow, cfg, now = Math.floor(Date.now() / 1000)) {
|
|
36
|
+
if (!cfg.secret || cfg.secret.length < 32)
|
|
37
|
+
throw new Error("flow-cookie secret must be ≥ 32 chars");
|
|
38
|
+
const ttl = cfg.ttlSeconds ?? DEFAULT_FLOW_TTL_SECONDS;
|
|
39
|
+
const payload = { ...flow, iat: now, exp: now + ttl };
|
|
40
|
+
const payloadStr = b64u(Buffer.from(JSON.stringify(payload)));
|
|
41
|
+
const sig = sign(cfg.secret, payloadStr);
|
|
42
|
+
return `${payloadStr}.${sig}`;
|
|
43
|
+
}
|
|
44
|
+
/** Decode + verify a cookie value. Returns the flow state on success,
|
|
45
|
+
* null on any failure (signature mismatch, expired, malformed). */
|
|
46
|
+
export function verifyFlowCookie(cookieValue, cfg, now = Math.floor(Date.now() / 1000)) {
|
|
47
|
+
if (!cookieValue)
|
|
48
|
+
return null;
|
|
49
|
+
if (cookieValue.length > MAX_COOKIE_BYTES)
|
|
50
|
+
return null;
|
|
51
|
+
if (!cfg.secret || cfg.secret.length < 32)
|
|
52
|
+
return null;
|
|
53
|
+
const dot = cookieValue.indexOf(".");
|
|
54
|
+
if (dot <= 0 || dot === cookieValue.length - 1)
|
|
55
|
+
return null;
|
|
56
|
+
const payloadStr = cookieValue.slice(0, dot);
|
|
57
|
+
const sig = cookieValue.slice(dot + 1);
|
|
58
|
+
const expected = sign(cfg.secret, payloadStr);
|
|
59
|
+
const a = Buffer.from(sig);
|
|
60
|
+
const b = Buffer.from(expected);
|
|
61
|
+
if (a.length !== b.length || !timingSafeEqual(a, b))
|
|
62
|
+
return null;
|
|
63
|
+
const raw = unb64u(payloadStr);
|
|
64
|
+
if (!raw)
|
|
65
|
+
return null;
|
|
66
|
+
let parsed;
|
|
67
|
+
try {
|
|
68
|
+
parsed = JSON.parse(raw.toString("utf8"));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
if (!isFlowState(parsed))
|
|
74
|
+
return null;
|
|
75
|
+
if (parsed.exp <= now)
|
|
76
|
+
return null;
|
|
77
|
+
return parsed;
|
|
78
|
+
}
|
|
79
|
+
function isFlowState(v) {
|
|
80
|
+
if (!v || typeof v !== "object")
|
|
81
|
+
return false;
|
|
82
|
+
const o = v;
|
|
83
|
+
return typeof o.state === "string"
|
|
84
|
+
&& typeof o.nonce === "string"
|
|
85
|
+
&& typeof o.codeVerifier === "string"
|
|
86
|
+
&& typeof o.returnTo === "string"
|
|
87
|
+
&& typeof o.iat === "number"
|
|
88
|
+
&& typeof o.exp === "number";
|
|
89
|
+
}
|
|
90
|
+
export function setFlowCookieHeader(value, cfg, opts = {}) {
|
|
91
|
+
const ttl = cfg.ttlSeconds ?? DEFAULT_FLOW_TTL_SECONDS;
|
|
92
|
+
const name = cfg.cookieName ?? DEFAULT_FLOW_COOKIE_NAME;
|
|
93
|
+
const parts = [`${name}=${value}`, `Max-Age=${ttl}`, "Path=/", "HttpOnly", "SameSite=Lax"];
|
|
94
|
+
if (opts.secure !== false)
|
|
95
|
+
parts.push("Secure");
|
|
96
|
+
return parts.join("; ");
|
|
97
|
+
}
|
|
98
|
+
export function clearFlowCookieHeader(cfg, opts = {}) {
|
|
99
|
+
const name = cfg.cookieName ?? DEFAULT_FLOW_COOKIE_NAME;
|
|
100
|
+
const parts = [`${name}=`, "Max-Age=0", "Path=/", "HttpOnly", "SameSite=Lax"];
|
|
101
|
+
if (opts.secure !== false)
|
|
102
|
+
parts.push("Secure");
|
|
103
|
+
return parts.join("; ");
|
|
104
|
+
}
|
|
105
|
+
/** Validate a returnTo before using it for the post-callback redirect.
|
|
106
|
+
* Defends against an attacker stuffing a hostile absolute URL into
|
|
107
|
+
* the login link (open-redirect). */
|
|
108
|
+
export function isSafeReturnTo(path) {
|
|
109
|
+
if (typeof path !== "string")
|
|
110
|
+
return false;
|
|
111
|
+
if (path.length === 0 || path.length > 2048)
|
|
112
|
+
return false;
|
|
113
|
+
// Reject any absolute / scheme-relative shape — only same-origin
|
|
114
|
+
// paths beginning with `/` and NOT `//` are accepted.
|
|
115
|
+
if (!path.startsWith("/"))
|
|
116
|
+
return false;
|
|
117
|
+
if (path.startsWith("//"))
|
|
118
|
+
return false;
|
|
119
|
+
if (path.startsWith("/\\"))
|
|
120
|
+
return false;
|
|
121
|
+
// Reject control characters — a CRLF would be folded into the
|
|
122
|
+
// eventual Location header (header-injection / response-splitting
|
|
123
|
+
// defence in depth; Express also sanitises, but two layers cost
|
|
124
|
+
// nothing).
|
|
125
|
+
if (/[\x00-\x1f\x7f]/.test(path))
|
|
126
|
+
return false;
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
/** Parse a Cookie header for the flow cookie's value. Tolerates other
|
|
130
|
+
* cookies (session, CSRF, etc.) sharing the header. */
|
|
131
|
+
export function readFlowCookie(cookieHeader, name = DEFAULT_FLOW_COOKIE_NAME) {
|
|
132
|
+
if (!cookieHeader)
|
|
133
|
+
return null;
|
|
134
|
+
for (const part of cookieHeader.split(";")) {
|
|
135
|
+
const eq = part.indexOf("=");
|
|
136
|
+
if (eq <= 0)
|
|
137
|
+
continue;
|
|
138
|
+
if (part.slice(0, eq).trim() === name)
|
|
139
|
+
return part.slice(eq + 1).trim();
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
Binary file
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Public barrel for the OIDC core library. */
|
|
2
|
+
export { OidcClient } from "./client.js";
|
|
3
|
+
export type { OidcConfig, StartResult, CompleteOpts, CompleteResult } from "./client.js";
|
|
4
|
+
export { DiscoveryClient, type DiscoveryDocument, type Fetcher } from "./discovery.js";
|
|
5
|
+
export { JwksClient, type Jwks } from "./jwks.js";
|
|
6
|
+
export { verifyIdToken, JwtVerifyError, type Jwk, type JwtPayload } from "./jwt.js";
|
|
7
|
+
export { generatePkcePair, generateCodeVerifier, challengeFromVerifier } from "./pkce.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Public barrel for the OIDC core library. */
|
|
2
|
+
export { OidcClient } from "./client.js";
|
|
3
|
+
export { DiscoveryClient } from "./discovery.js";
|
|
4
|
+
export { JwksClient } from "./jwks.js";
|
|
5
|
+
export { verifyIdToken, JwtVerifyError } from "./jwt.js";
|
|
6
|
+
export { generatePkcePair, generateCodeVerifier, challengeFromVerifier } from "./pkce.js";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JWKS fetcher + cache for OIDC ID-token signature verification.
|
|
3
|
+
*
|
|
4
|
+
* One cache per (issuer, jwks_uri). TTL defaults to 1 hour; a cache
|
|
5
|
+
* miss on an unknown `kid` triggers a single refresh in case the IdP
|
|
6
|
+
* rotated the key — this is the standard "kid not found → refresh
|
|
7
|
+
* once, then fail" pattern recommended by jose, openid-client etc.
|
|
8
|
+
*/
|
|
9
|
+
import type { Jwk } from "./jwt.js";
|
|
10
|
+
import type { Fetcher } from "./discovery.js";
|
|
11
|
+
export interface Jwks {
|
|
12
|
+
keys: Jwk[];
|
|
13
|
+
}
|
|
14
|
+
export interface JwksClientOpts {
|
|
15
|
+
fetcher?: Fetcher;
|
|
16
|
+
ttlMs?: number;
|
|
17
|
+
/** Cooldown between forced refresh attempts on cache miss; default 60s. */
|
|
18
|
+
refreshCooldownMs?: number;
|
|
19
|
+
now?: () => number;
|
|
20
|
+
}
|
|
21
|
+
export declare class JwksClient {
|
|
22
|
+
private readonly fetcher;
|
|
23
|
+
private readonly ttlMs;
|
|
24
|
+
private readonly cooldownMs;
|
|
25
|
+
private readonly now;
|
|
26
|
+
private readonly cache;
|
|
27
|
+
constructor(opts?: JwksClientOpts);
|
|
28
|
+
/** Return the JWKS for the given URI, fetching if missing or expired. */
|
|
29
|
+
get(jwksUri: string): Promise<Jwks>;
|
|
30
|
+
/** Look up a single key by kid. On miss, refresh once (subject to
|
|
31
|
+
* the cooldown) — IdPs rotate keys without warning and the
|
|
32
|
+
* discovery doc rarely changes when they do. */
|
|
33
|
+
findKey(jwksUri: string, kid: string | undefined): Promise<Jwk | undefined>;
|
|
34
|
+
invalidate(jwksUri?: string): void;
|
|
35
|
+
private refresh;
|
|
36
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JWKS fetcher + cache for OIDC ID-token signature verification.
|
|
3
|
+
*
|
|
4
|
+
* One cache per (issuer, jwks_uri). TTL defaults to 1 hour; a cache
|
|
5
|
+
* miss on an unknown `kid` triggers a single refresh in case the IdP
|
|
6
|
+
* rotated the key — this is the standard "kid not found → refresh
|
|
7
|
+
* once, then fail" pattern recommended by jose, openid-client etc.
|
|
8
|
+
*/
|
|
9
|
+
export class JwksClient {
|
|
10
|
+
fetcher;
|
|
11
|
+
ttlMs;
|
|
12
|
+
cooldownMs;
|
|
13
|
+
now;
|
|
14
|
+
cache = new Map();
|
|
15
|
+
constructor(opts = {}) {
|
|
16
|
+
this.fetcher = opts.fetcher ?? ((u, i) => fetch(u, i));
|
|
17
|
+
this.ttlMs = opts.ttlMs ?? 3_600_000;
|
|
18
|
+
this.cooldownMs = opts.refreshCooldownMs ?? 60_000;
|
|
19
|
+
this.now = opts.now ?? Date.now;
|
|
20
|
+
}
|
|
21
|
+
/** Return the JWKS for the given URI, fetching if missing or expired. */
|
|
22
|
+
async get(jwksUri) {
|
|
23
|
+
const cached = this.cache.get(jwksUri);
|
|
24
|
+
if (cached && cached.expiresAt > this.now())
|
|
25
|
+
return cached.jwks;
|
|
26
|
+
return await this.refresh(jwksUri);
|
|
27
|
+
}
|
|
28
|
+
/** Look up a single key by kid. On miss, refresh once (subject to
|
|
29
|
+
* the cooldown) — IdPs rotate keys without warning and the
|
|
30
|
+
* discovery doc rarely changes when they do. */
|
|
31
|
+
async findKey(jwksUri, kid) {
|
|
32
|
+
let jwks = await this.get(jwksUri);
|
|
33
|
+
let key = pickKey(jwks, kid);
|
|
34
|
+
if (key)
|
|
35
|
+
return key;
|
|
36
|
+
const cached = this.cache.get(jwksUri);
|
|
37
|
+
if (cached && cached.lastForceRefresh + this.cooldownMs > this.now())
|
|
38
|
+
return undefined;
|
|
39
|
+
jwks = await this.refresh(jwksUri, /*forced*/ true);
|
|
40
|
+
key = pickKey(jwks, kid);
|
|
41
|
+
return key;
|
|
42
|
+
}
|
|
43
|
+
invalidate(jwksUri) {
|
|
44
|
+
if (jwksUri)
|
|
45
|
+
this.cache.delete(jwksUri);
|
|
46
|
+
else
|
|
47
|
+
this.cache.clear();
|
|
48
|
+
}
|
|
49
|
+
async refresh(jwksUri, forced = false) {
|
|
50
|
+
const res = await this.fetcher(jwksUri);
|
|
51
|
+
if (!res.ok)
|
|
52
|
+
throw new Error(`JWKS fetch failed for ${jwksUri}: HTTP ${res.status}`);
|
|
53
|
+
const body = (await res.json());
|
|
54
|
+
if (!body || !Array.isArray(body.keys))
|
|
55
|
+
throw new Error(`JWKS body for ${jwksUri} is not a valid JWKS document`);
|
|
56
|
+
const entry = {
|
|
57
|
+
jwks: body,
|
|
58
|
+
expiresAt: this.now() + this.ttlMs,
|
|
59
|
+
lastForceRefresh: forced ? this.now() : (this.cache.get(jwksUri)?.lastForceRefresh ?? 0),
|
|
60
|
+
};
|
|
61
|
+
this.cache.set(jwksUri, entry);
|
|
62
|
+
return body;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function pickKey(jwks, kid) {
|
|
66
|
+
if (!kid)
|
|
67
|
+
return jwks.keys.find((k) => !!k.n || !!k.x) ?? jwks.keys[0];
|
|
68
|
+
return jwks.keys.find((k) => k.kid === kid);
|
|
69
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|