@jimhoyd/urlcode-auth 0.1.0-alpha.1 → 0.1.0-alpha.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/abuse-http.d.ts +8 -0
- package/dist/abuse-http.js +74 -0
- package/dist/abuse-store.d.ts +5 -0
- package/dist/abuse-store.js +40 -0
- package/dist/abuse.d.ts +27 -0
- package/dist/abuse.js +34 -0
- package/dist/admin-account-operations.d.ts +83 -0
- package/dist/admin-account-operations.js +50 -0
- package/dist/admin-account-store.d.ts +22 -0
- package/dist/admin-account-store.js +185 -0
- package/dist/auth-baseline.d.ts +30 -0
- package/dist/auth-baseline.js +153 -0
- package/dist/auth-core.d.ts +655 -0
- package/dist/auth-core.js +1066 -0
- package/dist/auth-flows.d.ts +30 -0
- package/dist/auth-flows.js +228 -0
- package/dist/auth-signup.d.ts +11 -0
- package/dist/auth-signup.js +145 -0
- package/dist/auth-store.d.ts +81 -0
- package/dist/auth-store.js +1601 -0
- package/dist/auth-templates.d.ts +13 -0
- package/dist/auth-templates.js +74 -0
- package/dist/auth-ui.d.ts +106 -0
- package/dist/auth-ui.js +205 -0
- package/dist/auth.d.ts +49 -0
- package/dist/auth.js +503 -0
- package/dist/backup.d.ts +18 -0
- package/dist/backup.js +121 -0
- package/dist/challenge-ui.d.ts +11 -0
- package/dist/challenge-ui.js +18 -0
- package/dist/challenge.d.ts +21 -0
- package/dist/challenge.js +65 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +137 -0
- package/dist/deployment-check.d.ts +16 -0
- package/dist/deployment-check.js +41 -0
- package/dist/disposable-domain-data.d.ts +1 -0
- package/dist/disposable-domain-data.js +8886 -0
- package/dist/disposable-domains.d.ts +3 -0
- package/dist/disposable-domains.js +17 -0
- package/dist/email-copy.d.ts +114 -0
- package/dist/email-copy.js +58 -0
- package/dist/factor-recovery.d.ts +46 -0
- package/dist/factor-recovery.js +71 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.js +18 -0
- package/dist/manual-recovery-store.d.ts +25 -0
- package/dist/manual-recovery-store.js +129 -0
- package/dist/manual-recovery.d.ts +87 -0
- package/dist/manual-recovery.js +35 -0
- package/dist/oidc.d.ts +31 -0
- package/dist/oidc.js +54 -0
- package/dist/passkeys.d.ts +24 -0
- package/dist/passkeys.js +29 -0
- package/dist/password-policy.d.ts +7 -0
- package/dist/password-policy.js +72 -0
- package/dist/presentation.d.ts +15 -0
- package/dist/presentation.js +458 -0
- package/dist/presets.d.ts +18 -0
- package/dist/presets.js +17 -0
- package/dist/providers.d.ts +13 -0
- package/dist/providers.js +15 -0
- package/dist/registration.d.ts +45 -0
- package/dist/registration.js +130 -0
- package/dist/scaffold.d.ts +44 -0
- package/dist/scaffold.js +212 -0
- package/dist/second-factor-flows.d.ts +28 -0
- package/dist/second-factor-flows.js +76 -0
- package/dist/senders.d.ts +86 -0
- package/dist/senders.js +153 -0
- package/dist/user-query.d.ts +28 -0
- package/dist/user-query.js +81 -0
- package/package.json +1 -1
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { fork } from 'node:child_process';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { mkdtemp, mkdir, writeFile, chmod, rm } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { randomBytes } from 'node:crypto';
|
|
7
|
+
const limitations = ['Synthetic local probes are not an independent security assessment or deployment certification.', 'No network, live provider, mail delivery, browser, load or recovery drill is exercised.', 'These fixtures do not inspect an operator deployment, password-breach callback or SMS policy.'];
|
|
8
|
+
const result = (checks) => ({ passed: checks.length > 0 && checks.every(item => item.passed), scope: 'offline-synthetic-runtime', checks, liveProviders: 'unverified', limitations: [...limitations] });
|
|
9
|
+
/** Fixed synthetic fixtures only; the deadline also bounds broken runtime startup. */
|
|
10
|
+
export async function runAuthBaseline(options = {}) {
|
|
11
|
+
const timeoutMs = options.timeoutMs ?? 30000;
|
|
12
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 60000)
|
|
13
|
+
throw new Error('Invalid baseline deadline');
|
|
14
|
+
const root = await mkdtemp(join(options.temporaryDirectory ?? tmpdir(), 'urlcode-auth-baseline-'));
|
|
15
|
+
let child;
|
|
16
|
+
try {
|
|
17
|
+
await chmod(root, 0o700);
|
|
18
|
+
return await new Promise(resolve => {
|
|
19
|
+
let finished = false;
|
|
20
|
+
const finish = (value) => { if (finished)
|
|
21
|
+
return; finished = true; clearTimeout(timer); resolve(value); };
|
|
22
|
+
const timer = setTimeout(() => finish(result([{ name: 'baseline.completed-within-deadline', passed: false }])), timeoutMs);
|
|
23
|
+
try {
|
|
24
|
+
child = fork(fileURLToPath(import.meta.url), [root], { env: { URLCODE_AUTH_BASELINE_CHILD: '1' }, execArgv: ['--max-old-space-size=256', ...process.execArgv.filter(value => value.startsWith('--conditions='))], stdio: ['ignore', 'pipe', 'pipe', 'ipc'] });
|
|
25
|
+
// Fixed probes must not surface guest/runtime diagnostics as credential-bearing output.
|
|
26
|
+
child.stdout?.resume();
|
|
27
|
+
child.stderr?.resume();
|
|
28
|
+
child.once('message', (message) => finish(message));
|
|
29
|
+
child.once('error', () => finish(result([{ name: 'baseline.process-completed', passed: false }])));
|
|
30
|
+
child.once('exit', () => { if (!finished)
|
|
31
|
+
finish(result([{ name: 'baseline.process-completed', passed: false }])); });
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
finish(result([{ name: 'baseline.process-started', passed: false }]));
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
if (child && child.exitCode === null && child.signalCode === null) {
|
|
40
|
+
const exited = new Promise(resolve => child.once('exit', () => resolve()));
|
|
41
|
+
child.kill('SIGKILL');
|
|
42
|
+
await exited;
|
|
43
|
+
}
|
|
44
|
+
await rm(root, { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Checks the already-loaded trusted service; no account mutation or migration is requested. */
|
|
48
|
+
export async function validateAuthService(service) {
|
|
49
|
+
const revision = await service.getConfigurationRevision(), registration = service.getRegistrationMode(), value = service.getSecurityPolicy(), roles = service.getRoles();
|
|
50
|
+
const checks = [
|
|
51
|
+
{ name: 'configuration.revision', passed: typeof revision === 'string' && /^[a-f0-9]{64}$/.test(revision) },
|
|
52
|
+
{ name: 'configuration.registration', passed: ['open', 'invite-only', 'waitlist', 'off'].includes(registration) },
|
|
53
|
+
{ name: 'configuration.security-policy', passed: Boolean(value && typeof value.requireEmailVerification === 'boolean' && typeof value.requireMfa === 'boolean' && Number.isSafeInteger(value.deletionGraceMs) && value.deletionGraceMs >= 86400000 && value.deletionGraceMs <= 2592000000 && (value.trustedDeviceTtlMs === undefined || Number.isSafeInteger(value.trustedDeviceTtlMs) && value.trustedDeviceTtlMs >= 60000 && value.trustedDeviceTtlMs <= 2592000000) && [value.allowPasskeySecondFactor, value.allowEmailFactorRecovery, value.allowManualRecovery].every(flag => flag === undefined || flag === true)) },
|
|
54
|
+
];
|
|
55
|
+
const entries = roles && typeof roles === 'object' && !Array.isArray(roles) ? Object.entries(roles) : [];
|
|
56
|
+
checks.push({ name: 'configuration.roles', passed: entries.length > 0 && entries.length <= 64 && entries.every(([name, permissions]) => /^[a-z][a-z0-9_-]{0,63}$/.test(name) && Array.isArray(permissions) && permissions.length <= 128 && permissions.every(permission => typeof permission === 'string' && /^\*$|^[a-z][a-z0-9_.:-]{0,127}$/.test(permission))) });
|
|
57
|
+
checks.push({ name: 'configuration.revision-stable', passed: await service.getConfigurationRevision() === revision });
|
|
58
|
+
// Explicit allowlist: arbitrary operator callback properties never become diagnostic output.
|
|
59
|
+
const security = { requireEmailVerification: value.requireEmailVerification, requireMfa: value.requireMfa, deletionGraceMs: value.deletionGraceMs, ...(value.allowPasskeySecondFactor === true ? { allowPasskeySecondFactor: true } : {}), ...(value.allowEmailFactorRecovery === true ? { allowEmailFactorRecovery: true } : {}), ...(value.allowManualRecovery === true ? { allowManualRecovery: true } : {}), ...(typeof value.trustedDeviceTtlMs === 'number' ? { trustedDeviceTtlMs: value.trustedDeviceTtlMs } : {}) };
|
|
60
|
+
if (!checks.every(check => check.passed))
|
|
61
|
+
throw new Error('Invalid operator service configuration');
|
|
62
|
+
return { passed: true, checks, revision, registration, security, roles: { count: entries.length, permissionCount: new Set(entries.flatMap(([, permissions]) => permissions)).size }, liveProviders: 'unverified' };
|
|
63
|
+
}
|
|
64
|
+
async function probe(root) {
|
|
65
|
+
const checks = [], add = (name, passed) => checks.push({ name, passed });
|
|
66
|
+
let runtime, service;
|
|
67
|
+
try {
|
|
68
|
+
const { createRuntime } = await import('@jimhoyd/urlcode'), { inspectExtensionRevision } = await import('@jimhoyd/urlcode/extensions'), { createAuthService } = await import("./auth-core.js"), { authExtension } = await import("./auth.js");
|
|
69
|
+
const project = join(root, 'project'), operator = join(root, 'operator'), origin = 'https://baseline.invalid';
|
|
70
|
+
await mkdir(project, { mode: 0o700 });
|
|
71
|
+
await mkdir(operator, { mode: 0o700 });
|
|
72
|
+
await writeFile(join(project, 'urlcode.yaml'), JSON.stringify({ version: '1', extensions: { auth: { version: '1', config: { registration: 'open' } } }, routes: {
|
|
73
|
+
'/account/*': { extension: 'auth', methods: ['GET', 'HEAD', 'POST'] },
|
|
74
|
+
'/protected': { respond: { json: { authorized: true } }, methods: ['GET', 'POST'], policies: { extensions: { auth: { permission: 'baseline.read' } } } },
|
|
75
|
+
'/public-guest': { parameters: [{ name: 'cookie', in: 'header', schema: { type: 'string', default: 'untrusted-default' } }, { name: 'authorization', in: 'header', schema: { type: 'string' } }], function: { source: 'guest.mjs' } },
|
|
76
|
+
} }), { mode: 0o600 });
|
|
77
|
+
await writeFile(join(project, 'guest.mjs'), 'export default (request, context) => Response.json({ cookie: request.headers.get("cookie"), authorization: request.headers.get("authorization"), header: context.inputs.header });', { mode: 0o600 });
|
|
78
|
+
const revision = await inspectExtensionRevision(project), csrfKey = randomBytes(32), encryptionKey = randomBytes(32), password = 'synthetic-baseline-' + randomBytes(16).toString('hex');
|
|
79
|
+
service = await createAuthService({ database: join(operator, 'ordinary.sqlite'), encryptionKey, roles: { member: ['baseline.read'], admin: ['*'] }, defaultRole: 'member' });
|
|
80
|
+
const account = await service.register({ email: 'synthetic-baseline@example.test', password });
|
|
81
|
+
const startRuntime = () => createRuntime(project, { origin, environment: {}, workers: 1, timeoutMs: 1000, extensions: [authExtension({ service: service, csrfKey, projectSha256: revision })], log: () => { } });
|
|
82
|
+
runtime = await startRuntime();
|
|
83
|
+
const text = (response) => typeof response.body === 'string' ? response.body : response.body ? Buffer.from(response.body).toString('utf8') : '';
|
|
84
|
+
const cookies = new Map();
|
|
85
|
+
const cookieValues = (response) => response.headers.filter(([name]) => name.toLowerCase() === 'set-cookie').map(([, value]) => value);
|
|
86
|
+
const cookieSafe = (cookie) => { const parts = cookie.split(';').map(value => value.trim()), attrs = new Map(parts.slice(1).map(value => { const at = value.indexOf('='); return at < 0 ? [value.toLowerCase(), ''] : [value.slice(0, at).toLowerCase(), value.slice(at + 1)]; })); return parts[0].startsWith('__Host-') && attrs.has('secure') && attrs.has('httponly') && attrs.get('path') === '/' && attrs.get('samesite') === 'Strict' && !attrs.has('domain'); };
|
|
87
|
+
const request = async (target, data, requestOrigin = origin, extra = {}) => {
|
|
88
|
+
const headers = new Headers({ accept: 'application/json', ...(cookies.size ? { cookie: [...cookies].map(([name, value]) => name + '=' + value).join('; ') } : {}), ...(data ? { 'content-type': 'application/json', origin: requestOrigin } : {}), ...extra });
|
|
89
|
+
const response = await runtime.handle({ target, origin, method: data ? 'POST' : 'GET', headers, headerCounts: Object.fromEntries([...headers].map(([name]) => [name, 1])), ...(data ? { body: Buffer.from(JSON.stringify(data)) } : {}) });
|
|
90
|
+
for (const value of cookieValues(response)) {
|
|
91
|
+
const first = value.split(';')[0], at = first.indexOf('=');
|
|
92
|
+
if (value.includes('Max-Age=0'))
|
|
93
|
+
cookies.delete(first.slice(0, at));
|
|
94
|
+
else
|
|
95
|
+
cookies.set(first.slice(0, at), first.slice(at + 1));
|
|
96
|
+
}
|
|
97
|
+
return response;
|
|
98
|
+
};
|
|
99
|
+
add('authorization.anonymous-protected-denied', (await request('/protected')).status === 401);
|
|
100
|
+
const loginPage = await request('/account/login');
|
|
101
|
+
add('response.auth-no-store', loginPage.headers.some(([name, value]) => name.toLowerCase() === 'cache-control' && value === 'no-store'));
|
|
102
|
+
const initial = await request('/account/csrf'), csrf = JSON.parse(text(initial)).csrf;
|
|
103
|
+
add('response.preauth-cookie-policy', [...cookieValues(loginPage), ...cookieValues(initial)].length > 0 && [...cookieValues(loginPage), ...cookieValues(initial)].every(cookieSafe));
|
|
104
|
+
add('csrf.missing-denied', (await request('/account/login', { email: account.user.email, password })).status === 403);
|
|
105
|
+
add('csrf.foreign-origin-denied', (await request('/account/login', { email: account.user.email, password, csrf }, 'https://foreign.invalid')).status === 403);
|
|
106
|
+
const login = await request('/account/login', { email: account.user.email, password, csrf }), sessionToken = cookies.get('__Host-urlcode-session');
|
|
107
|
+
add('authentication.password-login', login.status === 200 && typeof sessionToken === 'string');
|
|
108
|
+
add('response.session-cookie-policy', cookieValues(login).some(value => value.startsWith('__Host-urlcode-session=')) && cookieValues(login).every(cookieSafe));
|
|
109
|
+
add('response.credentials-withheld', !text(login).includes(password) && Boolean(sessionToken) && !text(login).includes(sessionToken) && !/"(?:token|passwordHash|encryptionKey)"/.test(text(login)));
|
|
110
|
+
const sessionCsrf = JSON.parse(text(login)).csrf;
|
|
111
|
+
add('authorization.session-protected-allowed', (await request('/protected')).status === 200);
|
|
112
|
+
add('csrf.protected-mutation-denied', (await request('/protected', {})).status === 403);
|
|
113
|
+
add('csrf.protected-mutation-authorized', (await request('/protected', {}, origin, { 'x-csrf-token': sessionCsrf })).status === 200);
|
|
114
|
+
const guest = await request('/public-guest', undefined, origin, { authorization: 'Bearer synthetic-baseline-bearer' }), guestValue = JSON.parse(text(guest));
|
|
115
|
+
add('guest.credentials-and-derived-context-withheld', guest.status === 200 && guestValue.cookie === null && guestValue.authorization === null && JSON.stringify(guestValue.header) === '{}' && !text(guest).includes(sessionToken) && !text(guest).includes('synthetic-baseline-bearer'));
|
|
116
|
+
await service.revokeSessions(account.user.id);
|
|
117
|
+
add('session.revocation-enforced', (await request('/protected')).status === 401);
|
|
118
|
+
await runtime.close();
|
|
119
|
+
runtime = undefined;
|
|
120
|
+
await service.close();
|
|
121
|
+
service = undefined;
|
|
122
|
+
service = await createAuthService({ database: join(operator, 'required.sqlite'), encryptionKey, roles: { member: ['baseline.read'], admin: ['*'] }, defaultRole: 'member', requireEmailVerification: true, requireMfa: true });
|
|
123
|
+
const restricted = await service.bootstrapAdmin({ email: 'synthetic-restricted@example.test', password });
|
|
124
|
+
runtime = await startRuntime();
|
|
125
|
+
cookies.clear();
|
|
126
|
+
cookies.set('__Host-urlcode-session', restricted.token);
|
|
127
|
+
add('enrollment.required-policies-declared', service.getSecurityPolicy().requireEmailVerification && service.getSecurityPolicy().requireMfa);
|
|
128
|
+
add('enrollment.restricted-authority-withheld', restricted.principal.roles.length === 0 && restricted.principal.permissions.length === 0 && restricted.principal.restrictions?.includes('verify-email') === true && restricted.principal.restrictions.includes('enroll-mfa'));
|
|
129
|
+
add('enrollment.protected-route-denied', (await request('/protected')).status === 403);
|
|
130
|
+
add('enrollment.account-page-available', (await request('/account/account')).status === 200);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
add('baseline.probes-completed', false);
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
try {
|
|
137
|
+
await runtime?.close();
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
add('cleanup.runtime-closed', false);
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
await service?.close();
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
add('cleanup.service-closed', false);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return result(checks);
|
|
150
|
+
}
|
|
151
|
+
if (process.env.URLCODE_AUTH_BASELINE_CHILD === '1' && process.send && process.argv[2]) {
|
|
152
|
+
void probe(process.argv[2]).then(value => process.send(value), () => process.send(result([{ name: 'baseline.probes-completed', passed: false }])));
|
|
153
|
+
}
|