oidc-mock 0.1.2

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.
@@ -0,0 +1,412 @@
1
+ import { findClient, redirectUriAllowed } from './config.js';
2
+ import { loginPage, messagePage } from './pages.js';
3
+ import { sign, verify, verifyAny, withoutReserved } from './tokens.js';
4
+ import { createHash, timingSafeEqual } from 'node:crypto';
5
+ class OAuthError extends Error {
6
+ error;
7
+ status;
8
+ constructor(error, description, status = 400) {
9
+ super(description);
10
+ this.error = error;
11
+ this.status = status;
12
+ }
13
+ }
14
+ /** Metadata fields that go into the access token but are no user claims. */
15
+ const tokenMeta = new Set(['client_id', 'scope', 'auth_time', 'azp', 'nonce', 'sid']);
16
+ const codeTtl = 120;
17
+ export function createProvider({ config: getConfig, key }) {
18
+ /** Codes are single-use. Stateless JWTs otherwise, so only redeemed ones need remembering. */
19
+ const redeemedCodes = new Map();
20
+ function metadata() {
21
+ const config = getConfig();
22
+ const { issuer } = config;
23
+ const claims = new Set(['sub']);
24
+ for (const user of config.users)
25
+ for (const claim of Object.keys(user.claims))
26
+ claims.add(claim);
27
+ return {
28
+ issuer,
29
+ authorization_endpoint: `${issuer}/authorize`,
30
+ token_endpoint: `${issuer}/token`,
31
+ userinfo_endpoint: `${issuer}/userinfo`,
32
+ jwks_uri: `${issuer}/jwks`,
33
+ end_session_endpoint: `${issuer}/end_session`,
34
+ introspection_endpoint: `${issuer}/introspect`,
35
+ revocation_endpoint: `${issuer}/revoke`,
36
+ response_types_supported: ['code'],
37
+ response_modes_supported: ['query'],
38
+ grant_types_supported: ['authorization_code', 'refresh_token'],
39
+ subject_types_supported: ['public'],
40
+ id_token_signing_alg_values_supported: ['RS256'],
41
+ token_endpoint_auth_methods_supported: ['none', 'client_secret_basic', 'client_secret_post'],
42
+ code_challenge_methods_supported: ['S256', 'plain'],
43
+ scopes_supported: ['openid', 'profile', 'email', 'phone', 'address', 'offline_access'],
44
+ claims_supported: [...claims],
45
+ authorization_response_iss_parameter_supported: true
46
+ };
47
+ }
48
+ async function handle(req, res) {
49
+ const config = getConfig();
50
+ const url = new URL(req.url ?? '/', 'http://oidc-mock');
51
+ const base = config.base_path;
52
+ if (url.pathname !== base && !url.pathname.startsWith(base + '/'))
53
+ return false;
54
+ const path = url.pathname.slice(base.length) || '/';
55
+ try {
56
+ if (req.method === 'OPTIONS') {
57
+ cors(res);
58
+ res.writeHead(204).end();
59
+ return true;
60
+ }
61
+ const route = `${req.method} ${path}`;
62
+ switch (route) {
63
+ case 'GET /.well-known/openid-configuration':
64
+ case 'GET /.well-known/oauth-authorization-server':
65
+ return json(res, 200, metadata());
66
+ case 'GET /jwks':
67
+ return json(res, 200, { keys: [key.publicJwk] });
68
+ case 'GET /authorize':
69
+ return authorizeGet(config, url.searchParams, res);
70
+ case 'POST /authorize':
71
+ return await authorizePost(config, await readForm(req), res);
72
+ case 'POST /token':
73
+ return await token(config, req, await readForm(req), res);
74
+ case 'GET /userinfo':
75
+ case 'POST /userinfo':
76
+ return await userinfo(config, req, res);
77
+ case 'POST /introspect':
78
+ return await introspect(config, await readForm(req), res);
79
+ case 'POST /revoke':
80
+ cors(res);
81
+ res.writeHead(200).end();
82
+ return true;
83
+ case 'GET /end_session':
84
+ case 'POST /end_session': {
85
+ const params = req.method === 'GET' ? url.searchParams : new URLSearchParams(await readBody(req));
86
+ return endSession(params, res);
87
+ }
88
+ default:
89
+ return json(res, 404, { error: 'not_found', error_description: `${route} is no endpoint` });
90
+ }
91
+ }
92
+ catch (error) {
93
+ if (error instanceof OAuthError) {
94
+ if (error.status === 401)
95
+ res.setHeader('WWW-Authenticate', 'Basic realm="oidc-mock"');
96
+ return json(res, error.status, { error: error.error, error_description: error.message });
97
+ }
98
+ console.error('[oidc-mock]', error);
99
+ return json(res, 500, { error: 'server_error', error_description: String(error) });
100
+ }
101
+ }
102
+ /** Checks client and redirect URI. Without both there is nowhere safe to send an error. */
103
+ function validateAuthorizeRequest(config, params) {
104
+ if (!params.client_id)
105
+ return 'The request has no client_id.';
106
+ const client = findClient(config, params.client_id);
107
+ if (!client)
108
+ return `Unknown client_id "${params.client_id}". Add it under "clients" in the config.`;
109
+ if (!params.redirect_uri)
110
+ return 'The request has no redirect_uri.';
111
+ try {
112
+ new URL(params.redirect_uri);
113
+ }
114
+ catch {
115
+ return `redirect_uri "${params.redirect_uri}" is no absolute URL.`;
116
+ }
117
+ if (!redirectUriAllowed(client, params.redirect_uri)) {
118
+ return `redirect_uri "${params.redirect_uri}" is not allowed for client "${client.client_id}".`;
119
+ }
120
+ return undefined;
121
+ }
122
+ function authorizeGet(config, search, res) {
123
+ const params = Object.fromEntries(search);
124
+ const invalid = validateAuthorizeRequest(config, params);
125
+ if (invalid)
126
+ return html(res, 400, messagePage('Invalid request', invalid));
127
+ if (params.response_type !== 'code') {
128
+ return redirectWithError(res, config, params, 'unsupported_response_type', 'Only "code" is supported.');
129
+ }
130
+ if (params.prompt?.split(' ').includes('none')) {
131
+ return redirectWithError(res, config, params, 'login_required', 'oidc-mock keeps no sessions.');
132
+ }
133
+ return html(res, 200, loginPage({ config, params }));
134
+ }
135
+ async function authorizePost(config, form, res) {
136
+ let params;
137
+ try {
138
+ params = JSON.parse(form.get('params') ?? '');
139
+ }
140
+ catch {
141
+ return html(res, 400, messagePage('Invalid request', 'The login form lost its parameters.'));
142
+ }
143
+ const invalid = validateAuthorizeRequest(config, params);
144
+ if (invalid)
145
+ return html(res, 400, messagePage('Invalid request', invalid));
146
+ let sub;
147
+ let claims;
148
+ let preset;
149
+ if (form.has('custom')) {
150
+ const customSub = form.get('custom_sub')?.trim() ?? '';
151
+ const customClaims = form.get('custom_claims') ?? '';
152
+ const retry = (error) => html(res, 400, loginPage({ config, params, error, customSub, customClaims }));
153
+ if (!config.custom_login)
154
+ return retry('Custom claims are disabled in the config.');
155
+ let parsed = {};
156
+ if (customClaims.trim()) {
157
+ try {
158
+ parsed = JSON.parse(customClaims);
159
+ }
160
+ catch (error) {
161
+ return retry(`The claims are no valid JSON: ${error.message}`);
162
+ }
163
+ }
164
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
165
+ return retry('The claims must be a JSON object.');
166
+ }
167
+ claims = parsed;
168
+ sub = customSub || (typeof claims.sub === 'string' ? claims.sub : '');
169
+ if (!sub)
170
+ return retry('Enter a sub.');
171
+ preset = false;
172
+ }
173
+ else {
174
+ const user = config.users.find((candidate) => candidate.sub === form.get('sub'));
175
+ if (!user) {
176
+ return html(res, 400, loginPage({ config, params, error: `Unknown user "${form.get('sub')}".` }));
177
+ }
178
+ ({ sub, claims } = user);
179
+ preset = true;
180
+ }
181
+ const grant = {
182
+ sub,
183
+ claims: withoutReserved(claims),
184
+ preset,
185
+ client_id: params.client_id,
186
+ scope: params.scope ?? '',
187
+ auth_time: Math.floor(Date.now() / 1000),
188
+ nonce: params.nonce,
189
+ redirect_uri: params.redirect_uri,
190
+ code_challenge: params.code_challenge,
191
+ code_challenge_method: params.code_challenge
192
+ ? (params.code_challenge_method ?? 'plain')
193
+ : undefined
194
+ };
195
+ const code = await sign(key, 'code', { grant }, {
196
+ issuer: config.issuer,
197
+ audience: grant.client_id,
198
+ subject: sub,
199
+ ttl: codeTtl
200
+ });
201
+ const target = new URL(params.redirect_uri);
202
+ target.searchParams.set('code', code);
203
+ if (params.state !== undefined)
204
+ target.searchParams.set('state', params.state);
205
+ target.searchParams.set('iss', config.issuer);
206
+ return redirect(res, target.toString());
207
+ }
208
+ function redirectWithError(res, config, params, error, description) {
209
+ const target = new URL(params.redirect_uri);
210
+ target.searchParams.set('error', error);
211
+ target.searchParams.set('error_description', description);
212
+ if (params.state !== undefined)
213
+ target.searchParams.set('state', params.state);
214
+ target.searchParams.set('iss', config.issuer);
215
+ return redirect(res, target.toString());
216
+ }
217
+ function authenticateClient(config, req, form) {
218
+ let clientId = form.get('client_id') ?? undefined;
219
+ let secret = form.get('client_secret') ?? undefined;
220
+ const header = req.headers.authorization;
221
+ if (header?.startsWith('Basic ')) {
222
+ const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8');
223
+ const colon = decoded.indexOf(':');
224
+ clientId = decodeURIComponent(decoded.slice(0, colon));
225
+ secret = decodeURIComponent(decoded.slice(colon + 1));
226
+ }
227
+ if (!clientId)
228
+ throw new OAuthError('invalid_client', 'No client_id given.', 401);
229
+ const client = findClient(config, clientId);
230
+ if (!client)
231
+ throw new OAuthError('invalid_client', `Unknown client_id "${clientId}".`, 401);
232
+ if (client.client_secret !== undefined && !safeEqual(client.client_secret, secret ?? '')) {
233
+ throw new OAuthError('invalid_client', 'Wrong client_secret.', 401);
234
+ }
235
+ return client;
236
+ }
237
+ async function token(config, req, form, res) {
238
+ cors(res);
239
+ const client = authenticateClient(config, req, form);
240
+ const grantType = form.get('grant_type');
241
+ let grant;
242
+ if (grantType === 'authorization_code') {
243
+ const code = form.get('code') ?? '';
244
+ let payload;
245
+ try {
246
+ payload = await verify(key, 'code', code, config.issuer);
247
+ }
248
+ catch {
249
+ throw new OAuthError('invalid_grant', 'The code is invalid or expired.');
250
+ }
251
+ grant = payload.grant;
252
+ if (grant.client_id !== client.client_id) {
253
+ throw new OAuthError('invalid_grant', 'The code was issued to another client.');
254
+ }
255
+ pruneRedeemed();
256
+ if (redeemedCodes.has(payload.jti))
257
+ throw new OAuthError('invalid_grant', 'The code was already used.');
258
+ redeemedCodes.set(payload.jti, payload.exp);
259
+ if (grant.redirect_uri !== form.get('redirect_uri')) {
260
+ throw new OAuthError('invalid_grant', 'redirect_uri does not match the authorization request.');
261
+ }
262
+ if (grant.code_challenge) {
263
+ const verifier = form.get('code_verifier');
264
+ if (!verifier)
265
+ throw new OAuthError('invalid_grant', 'code_verifier is missing.');
266
+ const expected = grant.code_challenge_method === 'S256'
267
+ ? createHash('sha256').update(verifier).digest('base64url')
268
+ : verifier;
269
+ if (expected !== grant.code_challenge) {
270
+ throw new OAuthError('invalid_grant', 'code_verifier does not match the code_challenge.');
271
+ }
272
+ }
273
+ }
274
+ else if (grantType === 'refresh_token') {
275
+ let payload;
276
+ try {
277
+ payload = await verify(key, 'refresh', form.get('refresh_token') ?? '', config.issuer);
278
+ }
279
+ catch {
280
+ throw new OAuthError('invalid_grant', 'The refresh token is invalid or expired.');
281
+ }
282
+ grant = payload.grant;
283
+ if (grant.client_id !== client.client_id) {
284
+ throw new OAuthError('invalid_grant', 'The refresh token was issued to another client.');
285
+ }
286
+ // Edits in the YAML apply on the next refresh, not only on the next login.
287
+ if (grant.preset) {
288
+ const user = config.users.find((candidate) => candidate.sub === grant.sub);
289
+ if (!user)
290
+ throw new OAuthError('invalid_grant', `User "${grant.sub}" was removed from the config.`);
291
+ grant = { ...grant, claims: withoutReserved(user.claims) };
292
+ }
293
+ grant = { ...grant, nonce: undefined };
294
+ }
295
+ else {
296
+ throw new OAuthError('unsupported_grant_type', `grant_type "${grantType}" is not supported.`);
297
+ }
298
+ const scopes = grant.scope.split(' ').filter(Boolean);
299
+ const common = { issuer: config.issuer, audience: client.client_id, subject: grant.sub };
300
+ const response = {
301
+ token_type: 'Bearer',
302
+ expires_in: config.tokens.access_token_ttl,
303
+ scope: grant.scope,
304
+ access_token: await sign(key, 'access', { ...grant.claims, client_id: client.client_id, scope: grant.scope, auth_time: grant.auth_time }, { ...common, ttl: config.tokens.access_token_ttl })
305
+ };
306
+ if (scopes.includes('openid')) {
307
+ response.id_token = await sign(key, 'id', { ...grant.claims, azp: client.client_id, auth_time: grant.auth_time, nonce: grant.nonce }, { ...common, ttl: config.tokens.id_token_ttl });
308
+ }
309
+ if (scopes.includes('offline_access')) {
310
+ const { redirect_uri, code_challenge, code_challenge_method, ...kept } = grant;
311
+ response.refresh_token = await sign(key, 'refresh', { grant: kept }, {
312
+ ...common,
313
+ ttl: config.tokens.refresh_token_ttl
314
+ });
315
+ }
316
+ res.setHeader('Cache-Control', 'no-store');
317
+ return json(res, 200, response);
318
+ }
319
+ async function userinfo(config, req, res) {
320
+ cors(res);
321
+ const header = req.headers.authorization;
322
+ let accessToken = header?.match(/^Bearer\s+(.+)$/i)?.[1];
323
+ if (!accessToken && req.method === 'POST')
324
+ accessToken = (await readForm(req)).get('access_token') ?? undefined;
325
+ try {
326
+ const payload = await verify(key, 'access', accessToken ?? '', config.issuer);
327
+ const claims = Object.fromEntries(Object.entries(withoutReserved(payload)).filter(([claim]) => !tokenMeta.has(claim)));
328
+ return json(res, 200, { ...claims, sub: payload.sub });
329
+ }
330
+ catch {
331
+ res.setHeader('WWW-Authenticate', 'Bearer error="invalid_token"');
332
+ return json(res, 401, { error: 'invalid_token', error_description: 'Missing or invalid access token.' });
333
+ }
334
+ }
335
+ async function introspect(config, form, res) {
336
+ cors(res);
337
+ const result = await verifyAny(key, form.get('token') ?? '', config.issuer);
338
+ if (!result)
339
+ return json(res, 200, { active: false });
340
+ const { grant, ...payload } = result.payload;
341
+ const refresh = result.kind === 'refresh' ? grant : undefined;
342
+ return json(res, 200, {
343
+ ...payload,
344
+ ...(refresh ? { client_id: refresh.client_id, scope: refresh.scope } : {}),
345
+ active: true,
346
+ token_type: result.kind === 'refresh' ? 'refresh_token' : 'Bearer'
347
+ });
348
+ }
349
+ function endSession(params, res) {
350
+ const target = params.get('post_logout_redirect_uri');
351
+ if (target) {
352
+ try {
353
+ const url = new URL(target);
354
+ const state = params.get('state');
355
+ if (state !== null)
356
+ url.searchParams.set('state', state);
357
+ return redirect(res, url.toString());
358
+ }
359
+ catch {
360
+ return html(res, 400, messagePage('Invalid request', 'post_logout_redirect_uri is no absolute URL.'));
361
+ }
362
+ }
363
+ return html(res, 200, messagePage('Signed out', 'You can close this page.'));
364
+ }
365
+ function pruneRedeemed() {
366
+ const now = Date.now() / 1000;
367
+ for (const [jti, exp] of redeemedCodes)
368
+ if (exp < now)
369
+ redeemedCodes.delete(jti);
370
+ }
371
+ return { handle, metadata };
372
+ }
373
+ function safeEqual(a, b) {
374
+ const left = Buffer.from(a);
375
+ const right = Buffer.from(b);
376
+ return left.length === right.length && timingSafeEqual(left, right);
377
+ }
378
+ function cors(res) {
379
+ res.setHeader('Access-Control-Allow-Origin', '*');
380
+ res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type');
381
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
382
+ }
383
+ function json(res, status, body) {
384
+ cors(res);
385
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
386
+ res.end(JSON.stringify(body));
387
+ return true;
388
+ }
389
+ function html(res, status, body) {
390
+ res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
391
+ res.end(body);
392
+ return true;
393
+ }
394
+ function redirect(res, location) {
395
+ res.writeHead(302, { Location: location, 'Cache-Control': 'no-store' });
396
+ res.end();
397
+ return true;
398
+ }
399
+ async function readBody(req) {
400
+ const chunks = [];
401
+ for await (const chunk of req)
402
+ chunks.push(chunk);
403
+ return Buffer.concat(chunks).toString('utf8');
404
+ }
405
+ async function readForm(req) {
406
+ const body = await readBody(req);
407
+ if (req.headers['content-type']?.includes('application/json')) {
408
+ const parsed = JSON.parse(body || '{}');
409
+ return new URLSearchParams(Object.entries(parsed).map(([k, v]) => [k, String(v)]));
410
+ }
411
+ return new URLSearchParams(body);
412
+ }
@@ -0,0 +1,34 @@
1
+ import { type Server } from 'node:http';
2
+ import { type MockConfig, type MockConfigInput } from './config.js';
3
+ import { type Provider } from './provider.js';
4
+ export type StartOptions = {
5
+ /** Path to the YAML file. Watched: edits to users and claims apply without a restart. */
6
+ config?: string;
7
+ /** Inline config instead of a file, e.g. for tests. Ignored when `config` is set. */
8
+ inline?: MockConfigInput;
9
+ /** Overrides for the values from the file. */
10
+ host?: string;
11
+ port?: number;
12
+ };
13
+ export type MockServer = {
14
+ /** Issuer URL – the part before `/.well-known/openid-configuration`. */
15
+ issuer: string;
16
+ /** The discovery URL, what an OIDC client wants as “authority”. */
17
+ discoveryUrl: string;
18
+ config: () => MockConfig;
19
+ provider: Provider;
20
+ /** Missing when another process owns the port, see `attachToRunning`. */
21
+ server?: Server;
22
+ close(): Promise<void>;
23
+ };
24
+ export declare function loadInitialConfig(options: StartOptions): MockConfig;
25
+ export declare function startServer(options?: StartOptions): Promise<MockServer>;
26
+ /**
27
+ * For when the port is taken by another oidc-mock with the same issuer – typically a second dev
28
+ * server in the same checkout. Codes and refresh tokens are self-contained JWTs, and both
29
+ * processes read the same key file, so a code issued here is redeemed there without shared
30
+ * state. This process then only serves the front channel.
31
+ *
32
+ * Returns `undefined` if the port belongs to something else.
33
+ */
34
+ export declare function attachToRunning(options: StartOptions): Promise<MockServer | undefined>;
package/dist/server.js ADDED
@@ -0,0 +1,77 @@
1
+ import { createServer } from 'node:http';
2
+ import { loadConfigFile, resolveConfig, watchConfig } from './config.js';
3
+ import { loadOrCreateKey } from './keys.js';
4
+ import { createProvider } from './provider.js';
5
+ export function loadInitialConfig(options) {
6
+ const config = options.config ? loadConfigFile(options.config) : resolveConfig(options.inline ?? {});
7
+ if (options.host === undefined && options.port === undefined)
8
+ return config;
9
+ return withAddress(config, options.host ?? config.host, options.port ?? config.port);
10
+ }
11
+ /** Re-resolves the config for another address, so the default issuer follows it. */
12
+ function withAddress(config, host, port) {
13
+ const { file, key_file, issuer, ...rest } = config;
14
+ const explicitIssuer = issuer !== resolveConfig({ ...rest, issuer: undefined }).issuer;
15
+ return {
16
+ ...resolveConfig({ ...rest, host, port, issuer: explicitIssuer ? issuer : undefined }, file),
17
+ key_file
18
+ };
19
+ }
20
+ export async function startServer(options = {}) {
21
+ let initial = loadInitialConfig(options);
22
+ let handler = (_req, res) => res.writeHead(503).end();
23
+ const server = createServer((req, res) => handler(req, res));
24
+ await new Promise((resolve, reject) => {
25
+ server.once('error', reject);
26
+ server.listen(initial.port, initial.host, () => resolve());
27
+ });
28
+ // Port 0 picks a free port; the issuer has to name the one we actually got.
29
+ if (initial.port === 0) {
30
+ initial = withAddress(initial, initial.host, server.address().port);
31
+ }
32
+ const config = watchConfig(initial);
33
+ const provider = createProvider({ config, key: await loadOrCreateKey(initial.key_file) });
34
+ handler = (req, res) => {
35
+ provider.handle(req, res).then((handled) => {
36
+ if (handled)
37
+ return;
38
+ res.writeHead(302, { Location: `${config().base_path}/.well-known/openid-configuration` });
39
+ res.end();
40
+ });
41
+ };
42
+ return {
43
+ issuer: initial.issuer,
44
+ discoveryUrl: `${initial.issuer}/.well-known/openid-configuration`,
45
+ config,
46
+ provider,
47
+ server,
48
+ close: () => new Promise((resolve) => {
49
+ server.close(() => resolve());
50
+ server.closeAllConnections();
51
+ })
52
+ };
53
+ }
54
+ /**
55
+ * For when the port is taken by another oidc-mock with the same issuer – typically a second dev
56
+ * server in the same checkout. Codes and refresh tokens are self-contained JWTs, and both
57
+ * processes read the same key file, so a code issued here is redeemed there without shared
58
+ * state. This process then only serves the front channel.
59
+ *
60
+ * Returns `undefined` if the port belongs to something else.
61
+ */
62
+ export async function attachToRunning(options) {
63
+ const initial = loadInitialConfig(options);
64
+ const discoveryUrl = `${initial.issuer}/.well-known/openid-configuration`;
65
+ try {
66
+ const response = await fetch(discoveryUrl, { signal: AbortSignal.timeout(2000) });
67
+ const { issuer } = (await response.json());
68
+ if (issuer !== initial.issuer)
69
+ return undefined;
70
+ }
71
+ catch {
72
+ return undefined;
73
+ }
74
+ const config = watchConfig(initial);
75
+ const provider = createProvider({ config, key: await loadOrCreateKey(initial.key_file) });
76
+ return { issuer: initial.issuer, discoveryUrl, config, provider, close: async () => { } };
77
+ }
@@ -0,0 +1,36 @@
1
+ import { type JWTPayload } from 'jose';
2
+ import type { SigningKey } from './keys.js';
3
+ /**
4
+ * Authorization codes and refresh tokens are signed JWTs as well. The mock keeps no session state
5
+ * besides a set of redeemed codes, so a restart loses nothing – the key is on disk.
6
+ */
7
+ export type TokenKind = 'code' | 'access' | 'id' | 'refresh';
8
+ /** What an authorization code and a refresh token carry, so both can be turned into tokens. */
9
+ export type Grant = {
10
+ sub: string;
11
+ claims: Record<string, unknown>;
12
+ /** Came from a user in the YAML: re-read the claims from there on refresh. */
13
+ preset: boolean;
14
+ client_id: string;
15
+ scope: string;
16
+ auth_time: number;
17
+ nonce?: string;
18
+ redirect_uri?: string;
19
+ code_challenge?: string;
20
+ code_challenge_method?: string;
21
+ };
22
+ export declare function withoutReserved(claims: Record<string, unknown>): {
23
+ [k: string]: unknown;
24
+ };
25
+ export declare function sign(key: SigningKey, kind: TokenKind, payload: Record<string, unknown>, { issuer, audience, subject, ttl }: {
26
+ issuer: string;
27
+ audience: string;
28
+ subject: string;
29
+ ttl: number;
30
+ }): Promise<string>;
31
+ export declare function verify(key: SigningKey, kind: TokenKind, token: string, issuer: string): Promise<JWTPayload>;
32
+ /** Tries each kind in turn – for introspection, which does not know what it was handed. */
33
+ export declare function verifyAny(key: SigningKey, token: string, issuer: string): Promise<{
34
+ kind: "access" | "id" | "refresh";
35
+ payload: JWTPayload;
36
+ } | undefined>;
package/dist/tokens.js ADDED
@@ -0,0 +1,44 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { jwtVerify, SignJWT } from 'jose';
3
+ const typ = {
4
+ code: 'oidc-mock-code+jwt',
5
+ access: 'at+jwt',
6
+ id: 'JWT',
7
+ refresh: 'oidc-mock-refresh+jwt'
8
+ };
9
+ /** Registered claims of a JWT, which a user's claims must not override. */
10
+ const reserved = new Set(['iss', 'sub', 'aud', 'exp', 'iat', 'nbf', 'jti']);
11
+ export function withoutReserved(claims) {
12
+ return Object.fromEntries(Object.entries(claims).filter(([key]) => !reserved.has(key)));
13
+ }
14
+ export async function sign(key, kind, payload, { issuer, audience, subject, ttl }) {
15
+ return new SignJWT(payload)
16
+ .setProtectedHeader({ alg: 'RS256', kid: key.kid, typ: typ[kind] })
17
+ .setIssuer(issuer)
18
+ .setAudience(audience)
19
+ .setSubject(subject)
20
+ .setIssuedAt()
21
+ .setExpirationTime(Math.floor(Date.now() / 1000) + ttl)
22
+ .setJti(randomUUID())
23
+ .sign(key.privateKey);
24
+ }
25
+ export async function verify(key, kind, token, issuer) {
26
+ const { payload } = await jwtVerify(token, key.publicKey, {
27
+ issuer,
28
+ typ: typ[kind],
29
+ algorithms: ['RS256']
30
+ });
31
+ return payload;
32
+ }
33
+ /** Tries each kind in turn – for introspection, which does not know what it was handed. */
34
+ export async function verifyAny(key, token, issuer) {
35
+ for (const kind of ['access', 'refresh', 'id']) {
36
+ try {
37
+ return { kind, payload: await verify(key, kind, token, issuer) };
38
+ }
39
+ catch {
40
+ // next
41
+ }
42
+ }
43
+ return undefined;
44
+ }
package/dist/vite.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ import type { Plugin } from 'vite';
2
+ export type OidcMockPluginOptions = {
3
+ /** YAML file, relative to the Vite root. Default: `oidc-mock.yaml`. */
4
+ config?: string;
5
+ /** Overrides `port` from the YAML. */
6
+ port?: number;
7
+ /**
8
+ * Rewrite redirects to the mock into relative ones (default: `true`). This is what makes
9
+ * `vite --host` work, see the README.
10
+ */
11
+ rewriteRedirects?: boolean;
12
+ /** Also run in `vite preview` (default: `true`). */
13
+ preview?: boolean;
14
+ };
15
+ /**
16
+ * Runs the mock provider next to the Vite dev server.
17
+ *
18
+ * The provider listens on its own loopback port (the back channel: discovery, token, JWKS –
19
+ * called by the app's server), and the Vite server serves the same endpoints under `base_path`
20
+ * (the front channel: the login page, called by the browser). Redirects the app sends to the
21
+ * back channel are rewritten to relative URLs, so the browser stays on whatever host it used to
22
+ * reach Vite – `localhost`, a LAN address with `--host`, or a forwarded port.
23
+ */
24
+ export declare function oidcMock(options?: OidcMockPluginOptions): Plugin;
25
+ export default oidcMock;