@jimhoyd/urlcode-auth 0.1.0-alpha.1 → 0.1.0-alpha.3

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.
Files changed (79) hide show
  1. package/IMPLEMENTATION-STATUS.md +3 -1
  2. package/README.md +56 -0
  3. package/SECURITY.md +1 -1
  4. package/THREAT-MODEL.md +4 -2
  5. package/dist/abuse-http.d.ts +8 -0
  6. package/dist/abuse-http.js +74 -0
  7. package/dist/abuse-store.d.ts +5 -0
  8. package/dist/abuse-store.js +40 -0
  9. package/dist/abuse.d.ts +27 -0
  10. package/dist/abuse.js +34 -0
  11. package/dist/admin-account-operations.d.ts +83 -0
  12. package/dist/admin-account-operations.js +50 -0
  13. package/dist/admin-account-store.d.ts +22 -0
  14. package/dist/admin-account-store.js +185 -0
  15. package/dist/auth-baseline.d.ts +30 -0
  16. package/dist/auth-baseline.js +153 -0
  17. package/dist/auth-core.d.ts +655 -0
  18. package/dist/auth-core.js +1066 -0
  19. package/dist/auth-flows.d.ts +30 -0
  20. package/dist/auth-flows.js +228 -0
  21. package/dist/auth-signup.d.ts +12 -0
  22. package/dist/auth-signup.js +154 -0
  23. package/dist/auth-store.d.ts +81 -0
  24. package/dist/auth-store.js +1601 -0
  25. package/dist/auth-templates.d.ts +13 -0
  26. package/dist/auth-templates.js +74 -0
  27. package/dist/auth-ui.d.ts +106 -0
  28. package/dist/auth-ui.js +205 -0
  29. package/dist/auth.d.ts +49 -0
  30. package/dist/auth.js +532 -0
  31. package/dist/backup.d.ts +18 -0
  32. package/dist/backup.js +121 -0
  33. package/dist/challenge-ui.d.ts +11 -0
  34. package/dist/challenge-ui.js +18 -0
  35. package/dist/challenge.d.ts +21 -0
  36. package/dist/challenge.js +65 -0
  37. package/dist/cli.d.ts +2 -0
  38. package/dist/cli.js +137 -0
  39. package/dist/deployment-check.d.ts +16 -0
  40. package/dist/deployment-check.js +41 -0
  41. package/dist/disposable-domain-data.d.ts +1 -0
  42. package/dist/disposable-domain-data.js +8886 -0
  43. package/dist/disposable-domains.d.ts +3 -0
  44. package/dist/disposable-domains.js +17 -0
  45. package/dist/email-copy.d.ts +114 -0
  46. package/dist/email-copy.js +58 -0
  47. package/dist/factor-recovery.d.ts +46 -0
  48. package/dist/factor-recovery.js +71 -0
  49. package/dist/index.d.ts +42 -0
  50. package/dist/index.js +18 -0
  51. package/dist/lifecycle-hooks.d.ts +76 -0
  52. package/dist/lifecycle-hooks.js +106 -0
  53. package/dist/manual-recovery-store.d.ts +25 -0
  54. package/dist/manual-recovery-store.js +129 -0
  55. package/dist/manual-recovery.d.ts +87 -0
  56. package/dist/manual-recovery.js +35 -0
  57. package/dist/oidc.d.ts +31 -0
  58. package/dist/oidc.js +54 -0
  59. package/dist/passkeys.d.ts +24 -0
  60. package/dist/passkeys.js +29 -0
  61. package/dist/password-policy.d.ts +7 -0
  62. package/dist/password-policy.js +72 -0
  63. package/dist/presentation.d.ts +15 -0
  64. package/dist/presentation.js +458 -0
  65. package/dist/presets.d.ts +18 -0
  66. package/dist/presets.js +17 -0
  67. package/dist/providers.d.ts +13 -0
  68. package/dist/providers.js +15 -0
  69. package/dist/registration.d.ts +45 -0
  70. package/dist/registration.js +130 -0
  71. package/dist/scaffold.d.ts +44 -0
  72. package/dist/scaffold.js +212 -0
  73. package/dist/second-factor-flows.d.ts +28 -0
  74. package/dist/second-factor-flows.js +76 -0
  75. package/dist/senders.d.ts +86 -0
  76. package/dist/senders.js +153 -0
  77. package/dist/user-query.d.ts +28 -0
  78. package/dist/user-query.js +81 -0
  79. package/package.json +2 -2
@@ -0,0 +1,185 @@
1
+ import { createHash } from 'node:crypto';
2
+ const externalId = (provider, subject) => createHash('sha256').update(provider + '\0' + subject).digest('hex');
3
+ /** All calls are inside the existing store revision/active-key checked transaction. */
4
+ export function adminAccountOperation(operation, args, context) {
5
+ if (!operation.startsWith('adminAccount'))
6
+ return;
7
+ const { db, now } = context, fail = context.fail;
8
+ const permissions = (roles) => [...new Set(roles.flatMap(role => context.roles[role] ?? []))];
9
+ const administrator = (user) => permissions(user.roles).includes('*');
10
+ const authorize = (actor, target, permission, nextRoles) => {
11
+ const granted = permissions(actor.roles);
12
+ if (context.isRestricted(actor) || !granted.includes('*') && !granted.includes(permission))
13
+ fail(403, 'permission_denied');
14
+ if (actor.id === target.id)
15
+ fail(403, 'self_administration_denied');
16
+ for (const permission of [...permissions(target.roles), ...permissions(nextRoles ?? target.roles)])
17
+ if (!granted.includes('*') && !granted.includes(permission))
18
+ fail(403, 'delegation_ceiling_exceeded');
19
+ };
20
+ const activity = (kind, id) => { const row = db.prepare('SELECT added,last_used FROM auth_method_activity WHERE kind=? AND method_id=?').get(kind, id); return { ...(typeof row?.added === 'number' ? { added: row.added } : {}), ...(typeof row?.last_used === 'number' ? { lastUsed: row.last_used } : {}) }; };
21
+ const identities = (id) => db.prepare('SELECT provider,subject FROM auth_external WHERE account_id=? LIMIT 16').all(id).map(row => ({ provider: String(row.provider), subject: String(row.subject), id: externalId(String(row.provider), String(row.subject)) }));
22
+ const validate = (actor, plan) => {
23
+ const targets = plan.targets.map(entry => {
24
+ const user = context.account(entry.id);
25
+ if (!user)
26
+ fail(404, 'account_not_found');
27
+ if (user.version !== entry.version)
28
+ fail(409, 'account_changed');
29
+ authorize(actor, user, 'auth.users.manage', plan.parameters.roles);
30
+ if (plan.action === 'cancel-deletion') {
31
+ if (user.status !== 'pending-delete' || !user.deleteAfter || user.deleteAfter <= now)
32
+ fail(409, 'deletion_unavailable');
33
+ }
34
+ else if (user.status === 'pending-delete')
35
+ fail(409, 'account_pending_deletion');
36
+ if (['force-password-reset', 'resend-verification', 'request-email-change'].includes(plan.action) && user.status !== 'active')
37
+ fail(409, 'account_not_active');
38
+ if (plan.action === 'resend-verification' && user.emailVerified)
39
+ fail(409, 'email_already_verified');
40
+ if (plan.action === 'remove-passkey') {
41
+ if (!db.prepare('SELECT id FROM auth_passkeys WHERE id=? AND account_id=?').get(plan.parameters.credentialId, user.id))
42
+ fail(404, 'method_not_found');
43
+ if (user.mfaPasskeys?.includes(plan.parameters.credentialId))
44
+ fail(409, 'factor_reset_case_required');
45
+ if (!user.passwordHash && identities(user.id).length === 0 && Number(db.prepare('SELECT count(*) AS n FROM auth_passkeys WHERE account_id=?').get(user.id)?.n) <= 1)
46
+ fail(409, 'last_sign_in_method');
47
+ }
48
+ if (plan.action === 'remove-external') {
49
+ const methods = identities(user.id);
50
+ if (!methods.some(method => method.id === plan.parameters.externalId))
51
+ fail(404, 'method_not_found');
52
+ if (!user.passwordHash && methods.length <= 1 && Number(db.prepare('SELECT count(*) AS n FROM auth_passkeys WHERE account_id=?').get(user.id)?.n) === 0)
53
+ fail(409, 'last_sign_in_method');
54
+ }
55
+ if (['remove-passkey', 'remove-external'].includes(plan.action) && !user.passwordHash && !user.totpSecret && user.mfaPasskeys?.length) {
56
+ const remainingKeys = db.prepare('SELECT id FROM auth_passkeys WHERE account_id=?').all(user.id).map(row => String(row.id)).filter(id => plan.action !== 'remove-passkey' || id !== plan.parameters.credentialId);
57
+ const remainingExternal = identities(user.id).filter(method => plan.action !== 'remove-external' || method.id !== plan.parameters.externalId);
58
+ if (!remainingExternal.length && !remainingKeys.some(primary => user.mfaPasskeys.some(factor => factor !== primary && remainingKeys.includes(factor))))
59
+ fail(409, 'last_sign_in_method');
60
+ }
61
+ if (plan.action === 'request-email-change') {
62
+ if (user.email === plan.parameters.email)
63
+ fail(400, 'email_unchanged');
64
+ if (db.prepare('SELECT id FROM auth_accounts WHERE email=?').get(plan.parameters.email))
65
+ fail(409, 'email_unavailable');
66
+ if (db.prepare('SELECT account_id FROM auth_email_changes WHERE account_id=? AND expires>?').get(user.id, now))
67
+ fail(409, 'email_change_pending');
68
+ }
69
+ return user;
70
+ });
71
+ const disabling = (user) => ['schedule-deletion', 'force-password-reset'].includes(plan.action) || plan.action === 'assign-roles' && !permissions(plan.parameters.roles).includes('*');
72
+ if (targets.some(user => administrator(user) && user.status === 'active' && !context.isRestricted(user) && disabling(user))) {
73
+ const disabled = new Set(targets.filter(disabling).map(user => user.id));
74
+ const remaining = db.prepare("SELECT data FROM auth_accounts WHERE administrator=1 AND status='active'").all().some(row => { const user = JSON.parse(String(row.data)); return !disabled.has(user.id) && !context.isRestricted(user); });
75
+ if (!remaining)
76
+ fail(409, 'last_administrator_required');
77
+ }
78
+ return targets;
79
+ };
80
+ const revoke = (id) => { for (const table of ['auth_sessions', 'auth_tokens', 'auth_email_codes', 'auth_email_changes', 'auth_factor_recovery', 'auth_second_factor_proofs', 'auth_trusted_devices', 'auth_manual_recovery'])
81
+ db.prepare(`DELETE FROM ${table} WHERE account_id=?`).run(id); };
82
+ if (operation === 'adminAccountInspect') {
83
+ const actor = context.fresh(String(args.hash), now).user, target = context.account(String(args.accountId));
84
+ if (!target)
85
+ fail(404, 'account_not_found');
86
+ authorize(actor, target, 'auth.users.read');
87
+ context.audit(actor.id, 'admin.authentication_inspected', target.id, now, String(args.reason));
88
+ return { value: { accountId: target.id, password: !!target.passwordHash, totp: !!target.totpSecret, passkeys: db.prepare('SELECT id FROM auth_passkeys WHERE account_id=? LIMIT 16').all(target.id).map(row => ({ id: String(row.id), ...activity('passkey', String(row.id)), secondFactor: target.mfaPasskeys?.includes(String(row.id)) ?? false })), external: identities(target.id).map(({ id, provider, subject }) => ({ id, provider, ...activity('oidc', provider + '\0' + subject) })) } };
89
+ }
90
+ if (operation === 'adminAccountStage') {
91
+ const actor = context.fresh(String(args.hash), now).user, targets = args.accountIds.map(id => { const user = context.account(id); if (!user)
92
+ fail(404, 'account_not_found'); const tokens = args.tokens.find(item => item.accountId === id); return { id, version: user.version, primaryHash: tokens.primaryHash, secondaryHash: tokens.secondaryHash }; });
93
+ const plan = { actorId: actor.id, actorVersion: actor.version, action: args.action, reason: String(args.reason), parameters: args.parameters, targets };
94
+ const users = validate(actor, plan);
95
+ db.prepare('DELETE FROM auth_admin_operations WHERE expires<=?').run(now);
96
+ if (Number(db.prepare('SELECT count(*) AS n FROM auth_admin_operations').get()?.n) >= 1000)
97
+ fail(503, 'auth_capacity_reached');
98
+ db.prepare('INSERT INTO auth_admin_operations(id,data,expires) VALUES(?,?,?)').run(String(args.operationId), JSON.stringify(plan), now + 300000);
99
+ const deliveries = [];
100
+ for (const user of users) {
101
+ const base = { accountId: user.id, email: user.email, ...(user.profile?.locale ? { locale: user.profile.locale } : {}) };
102
+ if (['remove-passkey', 'remove-external'].includes(plan.action) && !user.passwordHash && !user.totpSecret && user.mfaPasskeys?.length) {
103
+ const remainingKeys = db.prepare('SELECT id FROM auth_passkeys WHERE account_id=?').all(user.id).map(row => String(row.id)).filter(id => plan.action !== 'remove-passkey' || id !== plan.parameters.credentialId);
104
+ const remainingExternal = identities(user.id).filter(method => plan.action !== 'remove-external' || method.id !== plan.parameters.externalId);
105
+ if (!remainingExternal.length && !remainingKeys.some(primary => user.mfaPasskeys.some(factor => factor !== primary && remainingKeys.includes(factor))))
106
+ fail(409, 'last_sign_in_method');
107
+ }
108
+ if (plan.action === 'request-email-change') {
109
+ deliveries.push({ ...base, kind: 'token', purpose: 'cancel-email-change', tokenSlot: 'secondary' }, { ...base, email: plan.parameters.email, kind: 'token', purpose: 'verify-email-change', tokenSlot: 'primary' });
110
+ }
111
+ else if (['force-password-reset', 'resend-verification', 'schedule-deletion'].includes(plan.action))
112
+ deliveries.push({ ...base, kind: 'token', purpose: plan.action === 'force-password-reset' ? 'reset-password' : plan.action === 'resend-verification' ? 'verify-email' : 'cancel-deletion', tokenSlot: 'primary' });
113
+ else
114
+ deliveries.push({ ...base, kind: 'notice', action: plan.action });
115
+ context.audit(actor.id, 'admin.' + plan.action + '.staged', user.id, now, plan.reason);
116
+ }
117
+ return { value: { operationId: String(args.operationId), deliveries } };
118
+ }
119
+ if (operation === 'adminAccountComplete' || operation === 'adminAccountCancel') {
120
+ const actor = context.fresh(String(args.hash), now).user, row = db.prepare('SELECT data FROM auth_admin_operations WHERE id=? AND expires>?').get(String(args.operationId), now);
121
+ if (!row)
122
+ fail(409, 'administration_unavailable');
123
+ const plan = JSON.parse(String(row.data));
124
+ if (plan.actorId !== actor.id || plan.actorVersion !== actor.version)
125
+ fail(409, 'administration_actor_changed');
126
+ if (operation === 'adminAccountCancel') {
127
+ db.prepare('DELETE FROM auth_admin_operations WHERE id=?').run(String(args.operationId));
128
+ for (const target of plan.targets)
129
+ context.audit(actor.id, 'admin.' + plan.action + '.cancelled', target.id, now, plan.reason);
130
+ return { value: undefined };
131
+ }
132
+ const users = validate(actor, plan);
133
+ for (const user of users) {
134
+ const tokens = plan.targets.find(target => target.id === user.id);
135
+ if (!['resend-verification', 'request-email-change'].includes(plan.action)) {
136
+ user.version++;
137
+ revoke(user.id);
138
+ }
139
+ switch (plan.action) {
140
+ case 'verify-email':
141
+ user.emailVerified = true;
142
+ break;
143
+ case 'force-password-reset':
144
+ user.passwordHash = '';
145
+ db.prepare("INSERT INTO auth_tokens VALUES(?,?,'reset-password',?,?)").run(tokens.primaryHash, user.id, now + 1800000, user.version);
146
+ break;
147
+ case 'schedule-deletion':
148
+ user.status = 'pending-delete';
149
+ user.deleteAfter = now + context.deletionGraceMs;
150
+ db.prepare("INSERT INTO auth_tokens VALUES(?,?,'cancel-deletion',?,?)").run(tokens.primaryHash, user.id, user.deleteAfter, user.version);
151
+ break;
152
+ case 'cancel-deletion':
153
+ user.status = 'active';
154
+ delete user.deleteAfter;
155
+ break;
156
+ case 'remove-passkey':
157
+ db.prepare("DELETE FROM auth_method_activity WHERE kind='passkey' AND method_id=? AND account_id=?").run(plan.parameters.credentialId, user.id);
158
+ db.prepare('DELETE FROM auth_passkeys WHERE id=? AND account_id=?').run(plan.parameters.credentialId, user.id);
159
+ break;
160
+ case 'remove-external': {
161
+ const method = identities(user.id).find(item => item.id === plan.parameters.externalId);
162
+ db.prepare('DELETE FROM auth_external WHERE provider=? AND subject=? AND account_id=?').run(method.provider, method.subject, user.id);
163
+ db.prepare("DELETE FROM auth_method_activity WHERE kind='oidc' AND method_id=? AND account_id=?").run(method.provider + '\0' + method.subject, user.id);
164
+ break;
165
+ }
166
+ case 'assign-roles':
167
+ user.roles = plan.parameters.roles;
168
+ break;
169
+ case 'resend-verification':
170
+ db.prepare("DELETE FROM auth_tokens WHERE account_id=? AND purpose='verify-email'").run(user.id);
171
+ db.prepare("INSERT INTO auth_tokens VALUES(?,?,'verify-email',?,?)").run(tokens.primaryHash, user.id, now + 1800000, user.version);
172
+ break;
173
+ case 'request-email-change':
174
+ db.prepare('DELETE FROM auth_email_changes WHERE account_id=?').run(user.id);
175
+ db.prepare('INSERT INTO auth_email_changes VALUES(?,?,?,?,?,?,?)').run(user.id, plan.parameters.email, tokens.primaryHash, tokens.secondaryHash, now + 86400000, now + 172800000, user.version);
176
+ break;
177
+ }
178
+ context.save(user);
179
+ context.audit(actor.id, 'admin.' + plan.action, user.id, now, plan.reason);
180
+ }
181
+ db.prepare('DELETE FROM auth_admin_operations WHERE id=?').run(String(args.operationId));
182
+ return { value: { affected: users.length } };
183
+ }
184
+ return;
185
+ }
@@ -0,0 +1,30 @@
1
+ import type { AuthService, AuthSecurityPolicy } from './auth-core.ts';
2
+ export interface BaselineCheck {
3
+ name: string;
4
+ passed: boolean;
5
+ }
6
+ export interface AuthBaselineResult {
7
+ passed: boolean;
8
+ scope: 'offline-synthetic-runtime';
9
+ checks: BaselineCheck[];
10
+ liveProviders: 'unverified';
11
+ limitations: string[];
12
+ }
13
+ /** Fixed synthetic fixtures only; the deadline also bounds broken runtime startup. */
14
+ export declare function runAuthBaseline(options?: {
15
+ timeoutMs?: number;
16
+ temporaryDirectory?: string;
17
+ }): Promise<AuthBaselineResult>;
18
+ /** Checks the already-loaded trusted service; no account mutation or migration is requested. */
19
+ export declare function validateAuthService(service: AuthService): Promise<{
20
+ passed: boolean;
21
+ checks: BaselineCheck[];
22
+ revision: string;
23
+ registration: string;
24
+ security: AuthSecurityPolicy;
25
+ roles: {
26
+ count: number;
27
+ permissionCount: number;
28
+ };
29
+ liveProviders: 'unverified';
30
+ }>;
@@ -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
+ }