1
23import {
24 createHash,
25 createSign,
26 generateKeyPairSync,
27 randomBytes,
28 type KeyObject
29} from "node:crypto";
30import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
31import type { AddressInfo } from "node:net";
32
33export interface OidcBehavior {
34
35 sub: string;
36
37 email: string;
38
39 name?: string;
40
41 expiresInSec?: number;
42
43 audience?: string;
44
45 nonce?: string | null;
46
47 algNone?: boolean;
48
50 requireSecret?: boolean;
51
52 requirePkce?: boolean;
53
54 failExchange?: boolean;
55
56 extraClaims?: Record<string, unknown>;
57}
58
59interface StoredAuth {
60 code: string;
61 nonce: string;
62 codeChallenge: string;
63 redirectUri: string;
64 clientId: string;
65 scope: string;
66 consumed: boolean;
67}
68
69export interface MockOidc {
70
71 issuer: string;
72 url: string;
73 clientId: string;
74 clientSecret: string;
75 authorizationEndpoint: string;
76 tokenEndpoint: string;
77 jwksUri: string;
78
79 jwks(): { keys: Array<Record<string, string>> };
80
81 behavior: OidcBehavior;
82
83 authorizeCount: () => number;
84
85 tokenCount: () => number;
86 close(): Promise<void>;
87}
88
89const b64url = (buf: Buffer | string) =>
90 Buffer.from(buf).toString("base64url");
91
92function signJwt(
93 header: Record<string, string>,
94 claims: Record<string, unknown>,
95 privateKey: KeyObject
96): string {
97 const head = b64url(JSON.stringify(header));
98 const payload = b64url(JSON.stringify(claims));
99 const signingInput = `${head}.${payload}`;
100 const signer = createSign("RSA-SHA256");
101 signer.update(signingInput);
102 const sig = signer.sign(privateKey);
103 return `${signingInput}.${b64url(sig)}`;
104}
105
106export async function startMockOidc(opts: {
107 clientId: string;
108 clientSecret: string;
109 initialBehavior?: OidcBehavior;
110}): Promise<MockOidc> {
111 const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
112 const jwk = publicKey.export({ format: "jwk" }) as { n: string; e: string };
113 const kid = `e2e-${randomBytes(4).toString("hex")}`;
114 const authorizes = new Map<string, StoredAuth>();
115 let authorizeN = 0;
116 let tokenN = 0;
117
118 const behavior: OidcBehavior = { sub: "sub-1", email: "x@y.z", ...opts.initialBehavior };
119
120 const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
121 const url = new URL(req.url ?? "/", "http://localhost");
122
123
124 if (req.method === "GET" && url.pathname === "/.well-known/openid-configuration") {
125 res.writeHead(200, { "content-type": "application/json" });
126 res.end(
127 JSON.stringify({
128 issuer: `${serverUrl}`,
129 authorization_endpoint: `${serverUrl}/authorize`,
130 token_endpoint: `${serverUrl}/token`,
131 jwks_uri: `${serverUrl}/.well-known/jwks.json`,
132 response_types_supported: ["code"],
133 subject_types_supported: ["public"],
134 id_token_signing_alg_values_supported: ["RS256"],
135 scopes_supported: ["openid", "email", "profile"]
136 })
137 );
138 return;
139 }
140
141
142 if (req.method === "GET" && url.pathname === "/.well-known/jwks.json") {
143 res.writeHead(200, { "content-type": "application/json" });
144 res.end(JSON.stringify({ keys: [{ kty: "RSA", n: jwk.n, e: jwk.e, kid, alg: "RS256", use: "sig" }] }));
145 return;
146 }
147
148
149 if (req.method === "GET" && url.pathname === "/authorize") {
150 authorizeN += 1;
151 const redirectUri = url.searchParams.get("redirect_uri") ?? "";
152 const state = url.searchParams.get("state") ?? "";
153 const code = `oc_${randomBytes(6).toString("hex")}`;
154 authorizes.set(code, {
155 code,
156 nonce: url.searchParams.get("nonce") ?? "",
157 codeChallenge: url.searchParams.get("code_challenge") ?? "",
158 redirectUri,
159 clientId: url.searchParams.get("client_id") ?? "",
160 scope: url.searchParams.get("scope") ?? "",
161 consumed: false
162 });
163 if (!redirectUri || !state) {
164 res.writeHead(400, { "content-type": "text/plain" });
165 res.end("missing redirect_uri or state");
166 return;
167 }
168 const target = new URL(redirectUri);
169 target.searchParams.set("code", code);
170 target.searchParams.set("state", state);
171 res.writeHead(302, { location: target.toString() });
172 res.end();
173 return;
174 }
175
176
177 if (req.method === "POST" && url.pathname === "/token") {
178 let body = "";
179 req.on("data", (c) => (body += c));
180 req.on("end", () => {
181 const form = new URLSearchParams(body);
182 const code = form.get("code") ?? "";
183 const auth = authorizes.get(code);
184 const ok =
185 form.get("grant_type") === "authorization_code" &&
186 auth &&
187 !auth.consumed &&
188 form.get("redirect_uri") === auth.redirectUri &&
189 form.get("client_id") === auth.clientId &&
190 (behavior.requireSecret === false || form.get("client_secret") === opts.clientSecret) &&
191 (behavior.requirePkce === false ||
192 b64url(createHash("sha256").update(form.get("code_verifier") ?? "").digest()) ===
193 auth.codeChallenge);
194
195 if (behavior.failExchange || !ok) {
196 res.writeHead(400, { "content-type": "application/json" });
197 res.end(JSON.stringify({ error: "invalid_grant", error_description: "mock: exchange rejected" }));
198 return;
199 }
200 auth.consumed = true;
201 tokenN += 1;
202
203 const now = Math.floor(Date.now() / 1000);
204 const claims: Record<string, unknown> = {
205 iss: serverUrl,
206 sub: behavior.sub,
207 aud: behavior.audience ?? opts.clientId,
208 exp: now + (behavior.expiresInSec ?? 900),
209 iat: now,
210 nbf: now - 5,
211 nonce: behavior.nonce === undefined ? auth.nonce : behavior.nonce ?? null,
212 email: behavior.email,
213 ...(behavior.name ? { name: behavior.name } : {})
214 };
215 if (behavior.extraClaims) Object.assign(claims, behavior.extraClaims);
216 const header: Record<string, string> = behavior.algNone
217 ? { alg: "none", typ: "JWT" }
218 : { alg: "RS256", typ: "JWT", kid };
219 const idToken = behavior.algNone
220 ? `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(claims))}.`
221 : signJwt(header, claims, privateKey);
222
223 res.writeHead(200, { "content-type": "application/json" });
224 res.end(
225 JSON.stringify({
226 access_token: `at_${randomBytes(8).toString("hex")}`,
227 id_token: idToken,
228 token_type: "Bearer",
229 expires_in: 900,
230 scope: auth.scope
231 })
232 );
233 });
234 return;
235 }
236
237 res.writeHead(404, { "content-type": "text/plain" });
238 res.end("not found");
239 });
240
241 await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
242 const { port } = server.address() as AddressInfo;
243 const serverUrl = `http://127.0.0.1:${port}`;
244
245 return {
246 issuer: serverUrl,
247 url: serverUrl,
248 clientId: opts.clientId,
249 clientSecret: opts.clientSecret,
250 authorizationEndpoint: `${serverUrl}/authorize`,
251 tokenEndpoint: `${serverUrl}/token`,
252 jwksUri: `${serverUrl}/.well-known/jwks.json`,
253 jwks: () => ({ keys: [{ kty: "RSA", n: jwk.n, e: jwk.e, kid, alg: "RS256", use: "sig" }] }),
254 behavior,
255 authorizeCount: () => authorizeN,
256 tokenCount: () => tokenN,
257 close: () => new Promise((resolve, reject) => server.close((e) => (e ? reject(e) : resolve())))
258 };
259}
260