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.
package/dist/config.js ADDED
@@ -0,0 +1,157 @@
1
+ import { readFileSync, statSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { parse } from 'yaml';
4
+ import { z } from 'zod';
5
+ /** `3600`, `"90s"`, `"15m"`, `"1h"`, `"30d"` – always resolved to seconds. */
6
+ const duration = z.union([
7
+ z.number().int().positive(),
8
+ z
9
+ .string()
10
+ .regex(/^\d+\s*[smhd]$/, 'expected a number of seconds or a string like "15m", "1h", "30d"')
11
+ .transform((value) => {
12
+ const amount = Number.parseInt(value, 10);
13
+ const unit = value.trim().at(-1);
14
+ return amount * { s: 1, m: 60, h: 3600, d: 86400 }[unit];
15
+ })
16
+ ]);
17
+ const userSchema = z.object({
18
+ /** The `sub` claim. Stable, so the app sees the same account on every login. */
19
+ sub: z.string().min(1),
20
+ /** Button text on the login page. Defaults to `name`, then the email, then `sub`. */
21
+ label: z.string().optional(),
22
+ /** Second line on the button, e.g. what this user is meant to test. */
23
+ description: z.string().optional(),
24
+ /** Everything that ends up in the id token, the access token and userinfo. */
25
+ claims: z.record(z.string(), z.unknown()).default({})
26
+ });
27
+ const clientSchema = z.object({
28
+ client_id: z.string().min(1),
29
+ /** When set, the token endpoint requires it. Leave it out for public clients. */
30
+ client_secret: z.string().optional(),
31
+ /** Allowed redirect URIs. `*` matches anything, e.g. `https://*:5174/*`. Empty allows all. */
32
+ redirect_uris: z.array(z.string()).default([])
33
+ });
34
+ const configSchema = z.object({
35
+ /** Where the provider listens. The issuer is built from these unless `issuer` is set. */
36
+ host: z.string().default('127.0.0.1'),
37
+ port: z.number().int().min(0).max(65535).default(8090),
38
+ /** Path prefix of every endpoint. The Vite plugin mounts the login page under the same path. */
39
+ base_path: z
40
+ .string()
41
+ .default('/oidc')
42
+ .transform((path) => '/' + path.replace(/^\/+|\/+$/g, ''))
43
+ .transform((path) => (path === '/' ? '' : path)),
44
+ /** Overrides the issuer, e.g. when the provider sits behind a reverse proxy. */
45
+ issuer: z.url().optional(),
46
+ /** Signing key, created on first start. Relative to this file. */
47
+ key_file: z.string().default('node_modules/.cache/oidc-mock/signing-key.json'),
48
+ /** No `clients` means every `client_id` is accepted without a secret. */
49
+ clients: z.array(clientSchema).default([]),
50
+ tokens: z
51
+ .object({
52
+ access_token_ttl: duration.default(3600),
53
+ id_token_ttl: duration.default(3600),
54
+ refresh_token_ttl: duration.default(30 * 86400)
55
+ })
56
+ .prefault({}),
57
+ /** Show the free-form “custom claims” box on the login page. */
58
+ custom_login: z.boolean().default(true),
59
+ users: z.array(userSchema).default([])
60
+ });
61
+ export class ConfigError extends Error {
62
+ }
63
+ export function resolveConfig(input, file) {
64
+ const result = configSchema.safeParse(input ?? {});
65
+ if (!result.success) {
66
+ const where = file ? ` in ${file}` : '';
67
+ throw new ConfigError(`Invalid oidc-mock config${where}:\n${z.prettifyError(result.error)}`);
68
+ }
69
+ const config = result.data;
70
+ const subs = new Set();
71
+ for (const user of config.users) {
72
+ if (subs.has(user.sub))
73
+ throw new ConfigError(`Duplicate user sub "${user.sub}"`);
74
+ subs.add(user.sub);
75
+ }
76
+ const issuer = (config.issuer ?? `http://${formatHost(config.host)}:${config.port}${config.base_path}`).replace(/\/+$/, '');
77
+ const keyFile = resolve(file ? dirname(file) : process.cwd(), config.key_file);
78
+ return { ...config, issuer, key_file: keyFile, file };
79
+ }
80
+ function formatHost(host) {
81
+ if (host === '0.0.0.0' || host === '::')
82
+ return '127.0.0.1';
83
+ return host.includes(':') ? `[${host}]` : host;
84
+ }
85
+ export function loadConfigFile(path) {
86
+ const file = resolve(path);
87
+ const text = readFileSync(file, 'utf8');
88
+ let parsed;
89
+ try {
90
+ parsed = parse(text);
91
+ }
92
+ catch (error) {
93
+ throw new ConfigError(`Could not parse ${file}: ${error.message}`);
94
+ }
95
+ return resolveConfig(parsed, file);
96
+ }
97
+ /**
98
+ * Returns the current config and re-reads the file whenever it changed, so edits to users and
99
+ * claims apply to the next login without a restart. `host`, `port`, `base_path`, `issuer` and
100
+ * `key_file` stay as they were at start – changing them needs a restart, and a warning says so.
101
+ */
102
+ export function watchConfig(initial) {
103
+ if (!initial.file)
104
+ return () => initial;
105
+ const file = initial.file;
106
+ let current = initial;
107
+ let lastMtime = statSync(file).mtimeMs;
108
+ return () => {
109
+ let mtime;
110
+ try {
111
+ mtime = statSync(file).mtimeMs;
112
+ }
113
+ catch {
114
+ return current;
115
+ }
116
+ if (mtime === lastMtime)
117
+ return current;
118
+ lastMtime = mtime;
119
+ try {
120
+ const next = loadConfigFile(file);
121
+ const fixed = ['host', 'port', 'base_path', 'issuer', 'key_file'];
122
+ const changed = fixed.filter((key) => next[key] !== initial[key]);
123
+ if (changed.length) {
124
+ console.warn(`[oidc-mock] ${changed.join(', ')} changed – restart to apply.`);
125
+ }
126
+ current = { ...next, ...Object.fromEntries(fixed.map((key) => [key, initial[key]])) };
127
+ console.info(`[oidc-mock] Reloaded ${file}`);
128
+ }
129
+ catch (error) {
130
+ console.error(`[oidc-mock] ${error.message}\nKeeping the previous config.`);
131
+ }
132
+ return current;
133
+ };
134
+ }
135
+ export function findClient(config, clientId) {
136
+ if (!config.clients.length)
137
+ return { client_id: clientId, redirect_uris: [] };
138
+ return config.clients.find((client) => client.client_id === clientId);
139
+ }
140
+ export function redirectUriAllowed(client, redirectUri) {
141
+ if (!client.redirect_uris.length)
142
+ return true;
143
+ return client.redirect_uris.some((pattern) => {
144
+ const regex = new RegExp('^' + pattern.split('*').map(escapeRegex).join('.*') + '$');
145
+ return regex.test(redirectUri);
146
+ });
147
+ }
148
+ function escapeRegex(text) {
149
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
150
+ }
151
+ export function userLabel(user) {
152
+ const name = user.claims.name ?? [user.claims.given_name, user.claims.family_name].join(' ');
153
+ return (user.label ??
154
+ (typeof name === 'string' && name.trim() ? name.trim() : undefined) ??
155
+ (typeof user.claims.email === 'string' ? user.claims.email : undefined) ??
156
+ user.sub);
157
+ }
@@ -0,0 +1,4 @@
1
+ export { startServer, type MockServer, type StartOptions } from './server.js';
2
+ export { createProvider, type Provider, type ProviderOptions } from './provider.js';
3
+ export { loadConfigFile, resolveConfig, ConfigError, type MockConfig, type MockConfigInput, type MockUser, type MockClient } from './config.js';
4
+ export { loadOrCreateKey, type SigningKey } from './keys.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { startServer } from './server.js';
2
+ export { createProvider } from './provider.js';
3
+ export { loadConfigFile, resolveConfig, ConfigError } from './config.js';
4
+ export { loadOrCreateKey } from './keys.js';
package/dist/keys.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { type CryptoKey, type JWK } from 'jose';
2
+ export type SigningKey = {
3
+ kid: string;
4
+ privateKey: CryptoKey;
5
+ publicKey: CryptoKey;
6
+ publicJwk: JWK;
7
+ };
8
+ /**
9
+ * Loads the signing key from `file`, or creates and stores one. Keeping it across restarts means
10
+ * the sessions of the app survive a restart of the mock – a fresh key would invalidate every
11
+ * token the app still holds.
12
+ */
13
+ export declare function loadOrCreateKey(file: string): Promise<SigningKey>;
package/dist/keys.js ADDED
@@ -0,0 +1,36 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { calculateJwkThumbprint, exportJWK, generateKeyPair, importJWK } from 'jose';
4
+ const alg = 'RS256';
5
+ /**
6
+ * Loads the signing key from `file`, or creates and stores one. Keeping it across restarts means
7
+ * the sessions of the app survive a restart of the mock – a fresh key would invalidate every
8
+ * token the app still holds.
9
+ */
10
+ export async function loadOrCreateKey(file) {
11
+ let privateJwk;
12
+ try {
13
+ privateJwk = JSON.parse(readFileSync(file, 'utf8'));
14
+ }
15
+ catch {
16
+ const { privateKey } = await generateKeyPair(alg, { extractable: true });
17
+ privateJwk = await exportJWK(privateKey);
18
+ try {
19
+ mkdirSync(dirname(file), { recursive: true });
20
+ writeFileSync(file, JSON.stringify(privateJwk), { mode: 0o600 });
21
+ }
22
+ catch (error) {
23
+ console.warn(`[oidc-mock] Could not store the signing key in ${file} (${error.message}). ` +
24
+ 'Tokens will not survive a restart.');
25
+ }
26
+ }
27
+ const { d, p, q, dp, dq, qi, ...publicPart } = privateJwk;
28
+ const kid = await calculateJwkThumbprint(publicPart);
29
+ const publicJwk = { ...publicPart, kid, alg, use: 'sig' };
30
+ return {
31
+ kid,
32
+ privateKey: (await importJWK({ ...privateJwk, alg }, alg)),
33
+ publicKey: (await importJWK(publicJwk, alg)),
34
+ publicJwk
35
+ };
36
+ }
@@ -0,0 +1,10 @@
1
+ import { type MockConfig } from './config.js';
2
+ export declare function escapeHtml(value: unknown): string;
3
+ export declare function loginPage({ config, params, error, customSub, customClaims }: {
4
+ config: MockConfig;
5
+ params: Record<string, string>;
6
+ error?: string;
7
+ customSub?: string;
8
+ customClaims?: string;
9
+ }): string;
10
+ export declare function messagePage(title: string, message: string): string;
package/dist/pages.js ADDED
@@ -0,0 +1,134 @@
1
+ import { userLabel } from './config.js';
2
+ export function escapeHtml(value) {
3
+ return String(value)
4
+ .replaceAll('&', '&amp;')
5
+ .replaceAll('<', '&lt;')
6
+ .replaceAll('>', '&gt;')
7
+ .replaceAll('"', '&quot;')
8
+ .replaceAll("'", '&#39;');
9
+ }
10
+ const styles = /* css */ `
11
+ :root {
12
+ --bg: #f4f5f7; --card: #fff; --text: #1d2330; --muted: #667085; --border: #dde1e8;
13
+ --accent: #2f5bea; --accent-text: #fff; --hover: #eef2ff; --error-bg: #fdecec; --error: #b42318;
14
+ --code: #f1f3f6;
15
+ color-scheme: light;
16
+ }
17
+ @media (prefers-color-scheme: dark) {
18
+ :root {
19
+ --bg: #12151b; --card: #1b1f27; --text: #e6e9ef; --muted: #98a2b3; --border: #2c323d;
20
+ --accent: #7c9cff; --accent-text: #0b1020; --hover: #232a38; --error-bg: #3a1a1a; --error: #ffb4ab;
21
+ --code: #151920;
22
+ color-scheme: dark;
23
+ }
24
+ }
25
+ * { box-sizing: border-box; }
26
+ body {
27
+ margin: 0; min-height: 100vh; background: var(--bg); color: var(--text);
28
+ font: 15px/1.45 system-ui, -apple-system, 'Segoe UI', sans-serif;
29
+ display: flex; justify-content: center; align-items: flex-start; padding: 48px 16px;
30
+ }
31
+ main { width: 100%; max-width: 440px; }
32
+ .card { background: var(--card); border: 1px solid var(--border); border-radius: 14px; padding: 24px; }
33
+ h1 { font-size: 18px; margin: 0 0 4px; }
34
+ .meta { color: var(--muted); font-size: 13px; margin: 0 0 20px; word-break: break-all; }
35
+ .meta code { font-size: 12px; }
36
+ .users { display: grid; gap: 8px; }
37
+ .user { display: flex; align-items: stretch; gap: 6px; }
38
+ .user button[name=sub] {
39
+ flex: 1; text-align: left; padding: 10px 14px; border-radius: 10px; cursor: pointer;
40
+ border: 1px solid var(--border); background: transparent; color: inherit; font: inherit;
41
+ }
42
+ .user button[name=sub]:hover, .user button[name=sub]:focus-visible { background: var(--hover); border-color: var(--accent); outline: none; }
43
+ .user small { display: block; color: var(--muted); font-size: 13px; }
44
+ .edit {
45
+ width: 40px; border-radius: 10px; border: 1px solid var(--border); background: transparent;
46
+ color: var(--muted); cursor: pointer; font-size: 16px;
47
+ }
48
+ .edit:hover { color: var(--text); background: var(--hover); }
49
+ details { margin-top: 20px; border-top: 1px solid var(--border); padding-top: 16px; }
50
+ summary { cursor: pointer; color: var(--muted); font-size: 14px; }
51
+ label { display: block; font-size: 13px; color: var(--muted); margin: 14px 0 4px; }
52
+ input[type=text], textarea {
53
+ width: 100%; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--border);
54
+ background: var(--code); color: inherit; font: 13px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
55
+ }
56
+ textarea { min-height: 220px; resize: vertical; }
57
+ .primary {
58
+ margin-top: 12px; width: 100%; padding: 10px; border: 0; border-radius: 10px; cursor: pointer;
59
+ background: var(--accent); color: var(--accent-text); font: inherit; font-weight: 600;
60
+ }
61
+ .cancel { display: inline-block; margin-top: 16px; color: var(--muted); font-size: 13px; }
62
+ .error { background: var(--error-bg); color: var(--error); padding: 10px 12px; border-radius: 8px; margin-bottom: 16px; font-size: 14px; }
63
+ .empty { color: var(--muted); font-size: 14px; }
64
+ `;
65
+ function layout(title, body) {
66
+ return `<!doctype html>
67
+ <html lang="en">
68
+ <head>
69
+ <meta charset="utf-8">
70
+ <meta name="viewport" content="width=device-width, initial-scale=1">
71
+ <meta name="robots" content="noindex">
72
+ <title>${escapeHtml(title)}</title>
73
+ <style>${styles}</style>
74
+ </head>
75
+ <body><main><div class="card">${body}</div></main></body>
76
+ </html>`;
77
+ }
78
+ export function loginPage({ config, params, error, customSub, customClaims }) {
79
+ const hidden = `<input type="hidden" name="params" value="${escapeHtml(JSON.stringify(params))}">`;
80
+ const cancel = new URL(params.redirect_uri);
81
+ cancel.searchParams.set('error', 'access_denied');
82
+ if (params.state)
83
+ cancel.searchParams.set('state', params.state);
84
+ const users = config.users.length
85
+ ? config.users
86
+ .map((user) => `<div class="user">
87
+ <button type="submit" name="sub" value="${escapeHtml(user.sub)}">
88
+ ${escapeHtml(userLabel(user))}
89
+ ${user.description ? `<small>${escapeHtml(user.description)}</small>` : ''}
90
+ </button>
91
+ ${config.custom_login
92
+ ? `<button type="button" class="edit" title="Edit claims before signing in" data-sub="${escapeHtml(user.sub)}" data-claims="${escapeHtml(JSON.stringify(user.claims, null, 2))}">✎</button>`
93
+ : ''}
94
+ </div>`)
95
+ .join('\n')
96
+ : `<p class="empty">No users configured${config.file ? ` in <code>${escapeHtml(config.file)}</code>` : ''}.</p>`;
97
+ const custom = config.custom_login
98
+ ? `<details${error || customClaims ? ' open' : ''}>
99
+ <summary>Sign in with custom claims</summary>
100
+ <form method="post">
101
+ ${hidden}
102
+ <label for="custom_sub">sub</label>
103
+ <input type="text" id="custom_sub" name="custom_sub" value="${escapeHtml(customSub ?? '')}" placeholder="any-subject" autocomplete="off">
104
+ <label for="custom_claims">Claims (JSON object)</label>
105
+ <textarea id="custom_claims" name="custom_claims" spellcheck="false" placeholder='{\n "email": "someone@example.org"\n}'>${escapeHtml(customClaims ?? '')}</textarea>
106
+ <button type="submit" class="primary" name="custom" value="1">Sign in</button>
107
+ </form>
108
+ </details>
109
+ <script>
110
+ for (const button of document.querySelectorAll('.edit')) {
111
+ button.addEventListener('click', () => {
112
+ document.getElementById('custom_sub').value = button.dataset.sub;
113
+ document.getElementById('custom_claims').value = button.dataset.claims;
114
+ const details = document.querySelector('details');
115
+ details.open = true;
116
+ details.scrollIntoView({ behavior: 'smooth' });
117
+ document.getElementById('custom_claims').focus();
118
+ });
119
+ }
120
+ </script>`
121
+ : '';
122
+ return layout('Sign in – oidc-mock', `<h1>Sign in</h1>
123
+ <p class="meta">Client <code>${escapeHtml(params.client_id)}</code> · returns to <code>${escapeHtml(cancel.origin)}</code></p>
124
+ ${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
125
+ <form method="post" class="users">
126
+ ${hidden}
127
+ ${users}
128
+ </form>
129
+ ${custom}
130
+ <a class="cancel" href="${escapeHtml(cancel.toString())}">Cancel</a>`);
131
+ }
132
+ export function messagePage(title, message) {
133
+ return layout(`${title} – oidc-mock`, `<h1>${escapeHtml(title)}</h1><p class="meta">${escapeHtml(message)}</p>`);
134
+ }
@@ -0,0 +1,14 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { type MockConfig } from './config.js';
3
+ import type { SigningKey } from './keys.js';
4
+ export type ProviderOptions = {
5
+ /** Called on every request, so a watched config applies without a restart. */
6
+ config: () => MockConfig;
7
+ key: SigningKey;
8
+ };
9
+ export type Provider = {
10
+ /** Handles the request if its path is below `base_path` and returns whether it did. */
11
+ handle(req: IncomingMessage, res: ServerResponse): Promise<boolean>;
12
+ metadata(): Record<string, unknown>;
13
+ };
14
+ export declare function createProvider({ config: getConfig, key }: ProviderOptions): Provider;