@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.
Files changed (73) hide show
  1. package/dist/abuse-http.d.ts +8 -0
  2. package/dist/abuse-http.js +74 -0
  3. package/dist/abuse-store.d.ts +5 -0
  4. package/dist/abuse-store.js +40 -0
  5. package/dist/abuse.d.ts +27 -0
  6. package/dist/abuse.js +34 -0
  7. package/dist/admin-account-operations.d.ts +83 -0
  8. package/dist/admin-account-operations.js +50 -0
  9. package/dist/admin-account-store.d.ts +22 -0
  10. package/dist/admin-account-store.js +185 -0
  11. package/dist/auth-baseline.d.ts +30 -0
  12. package/dist/auth-baseline.js +153 -0
  13. package/dist/auth-core.d.ts +655 -0
  14. package/dist/auth-core.js +1066 -0
  15. package/dist/auth-flows.d.ts +30 -0
  16. package/dist/auth-flows.js +228 -0
  17. package/dist/auth-signup.d.ts +11 -0
  18. package/dist/auth-signup.js +145 -0
  19. package/dist/auth-store.d.ts +81 -0
  20. package/dist/auth-store.js +1601 -0
  21. package/dist/auth-templates.d.ts +13 -0
  22. package/dist/auth-templates.js +74 -0
  23. package/dist/auth-ui.d.ts +106 -0
  24. package/dist/auth-ui.js +205 -0
  25. package/dist/auth.d.ts +49 -0
  26. package/dist/auth.js +503 -0
  27. package/dist/backup.d.ts +18 -0
  28. package/dist/backup.js +121 -0
  29. package/dist/challenge-ui.d.ts +11 -0
  30. package/dist/challenge-ui.js +18 -0
  31. package/dist/challenge.d.ts +21 -0
  32. package/dist/challenge.js +65 -0
  33. package/dist/cli.d.ts +2 -0
  34. package/dist/cli.js +137 -0
  35. package/dist/deployment-check.d.ts +16 -0
  36. package/dist/deployment-check.js +41 -0
  37. package/dist/disposable-domain-data.d.ts +1 -0
  38. package/dist/disposable-domain-data.js +8886 -0
  39. package/dist/disposable-domains.d.ts +3 -0
  40. package/dist/disposable-domains.js +17 -0
  41. package/dist/email-copy.d.ts +114 -0
  42. package/dist/email-copy.js +58 -0
  43. package/dist/factor-recovery.d.ts +46 -0
  44. package/dist/factor-recovery.js +71 -0
  45. package/dist/index.d.ts +42 -0
  46. package/dist/index.js +18 -0
  47. package/dist/manual-recovery-store.d.ts +25 -0
  48. package/dist/manual-recovery-store.js +129 -0
  49. package/dist/manual-recovery.d.ts +87 -0
  50. package/dist/manual-recovery.js +35 -0
  51. package/dist/oidc.d.ts +31 -0
  52. package/dist/oidc.js +54 -0
  53. package/dist/passkeys.d.ts +24 -0
  54. package/dist/passkeys.js +29 -0
  55. package/dist/password-policy.d.ts +7 -0
  56. package/dist/password-policy.js +72 -0
  57. package/dist/presentation.d.ts +15 -0
  58. package/dist/presentation.js +458 -0
  59. package/dist/presets.d.ts +18 -0
  60. package/dist/presets.js +17 -0
  61. package/dist/providers.d.ts +13 -0
  62. package/dist/providers.js +15 -0
  63. package/dist/registration.d.ts +45 -0
  64. package/dist/registration.js +130 -0
  65. package/dist/scaffold.d.ts +44 -0
  66. package/dist/scaffold.js +212 -0
  67. package/dist/second-factor-flows.d.ts +28 -0
  68. package/dist/second-factor-flows.js +76 -0
  69. package/dist/senders.d.ts +86 -0
  70. package/dist/senders.js +153 -0
  71. package/dist/user-query.d.ts +28 -0
  72. package/dist/user-query.js +81 -0
  73. package/package.json +1 -1
@@ -0,0 +1,3 @@
1
+ export declare const disposableDomainsRevision = "be0dd1affed11ea9b1ffcd14da81319f9a080609";
2
+ /** Exact domain or dot-delimited subdomain match, never substring matching. No DNS/network lookup. */
3
+ export declare function isDisposableEmailDomain(value: string): boolean;
@@ -0,0 +1,17 @@
1
+ import { domainToASCII } from 'node:url';
2
+ import { disposableDomainData } from "./disposable-domain-data.js";
3
+ export const disposableDomainsRevision = 'be0dd1affed11ea9b1ffcd14da81319f9a080609';
4
+ const blocked = new Set(disposableDomainData);
5
+ /** Exact domain or dot-delimited subdomain match, never substring matching. No DNS/network lookup. */
6
+ export function isDisposableEmailDomain(value) {
7
+ if (typeof value !== 'string' || value.length > 253 || value.endsWith('.') || /[\s/@\\:]/.test(value))
8
+ throw new Error('Invalid email domain');
9
+ const domain = domainToASCII(value.toLowerCase());
10
+ if (!domain || domain.length > 253 || domain.split('.').some(label => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)))
11
+ throw new Error('Invalid email domain');
12
+ const labels = domain.split('.');
13
+ for (let index = 0; index < labels.length - 1; index++)
14
+ if (blocked.has(labels.slice(index).join('.')))
15
+ return true;
16
+ return false;
17
+ }
@@ -0,0 +1,114 @@
1
+ export declare const englishEmailCatalogue: Readonly<{
2
+ 'admin-verify-email': {
3
+ subject: string;
4
+ text: string;
5
+ };
6
+ 'admin-force-password-reset': {
7
+ subject: string;
8
+ text: string;
9
+ };
10
+ 'admin-schedule-deletion': {
11
+ subject: string;
12
+ text: string;
13
+ };
14
+ 'admin-cancel-deletion': {
15
+ subject: string;
16
+ text: string;
17
+ };
18
+ 'admin-remove-passkey': {
19
+ subject: string;
20
+ text: string;
21
+ };
22
+ 'admin-remove-external': {
23
+ subject: string;
24
+ text: string;
25
+ };
26
+ 'admin-request-email-change': {
27
+ subject: string;
28
+ text: string;
29
+ };
30
+ 'admin-assign-roles': {
31
+ subject: string;
32
+ text: string;
33
+ };
34
+ 'admin-resend-verification': {
35
+ subject: string;
36
+ text: string;
37
+ };
38
+ 'verify-email': {
39
+ subject: string;
40
+ text: string;
41
+ };
42
+ 'reset-password': {
43
+ subject: string;
44
+ text: string;
45
+ };
46
+ 'cancel-deletion': {
47
+ subject: string;
48
+ text: string;
49
+ };
50
+ invitation: {
51
+ subject: string;
52
+ text: string;
53
+ };
54
+ 'verify-email-change': {
55
+ subject: string;
56
+ text: string;
57
+ };
58
+ 'cancel-email-change': {
59
+ subject: string;
60
+ text: string;
61
+ };
62
+ 'sign-in-code': {
63
+ subject: string;
64
+ text: string;
65
+ };
66
+ 'signup-code': {
67
+ subject: string;
68
+ text: string;
69
+ };
70
+ 'factor-recovery': {
71
+ subject: string;
72
+ text: string;
73
+ };
74
+ 'manual-recovery-warning': {
75
+ subject: string;
76
+ text: string;
77
+ };
78
+ 'manual-recovery': {
79
+ subject: string;
80
+ text: string;
81
+ };
82
+ 'new-device': {
83
+ subject: string;
84
+ text: string;
85
+ };
86
+ 'password-changed': {
87
+ subject: string;
88
+ text: string;
89
+ };
90
+ 'email-changed': {
91
+ subject: string;
92
+ text: string;
93
+ };
94
+ 'registration-attempt': {
95
+ subject: string;
96
+ text: string;
97
+ };
98
+ }>;
99
+ export type EmailTemplateKey = keyof typeof englishEmailCatalogue;
100
+ export interface EmailCopyOptions {
101
+ defaultLocale?: string;
102
+ catalogues?: Record<string, Partial<Record<EmailTemplateKey, {
103
+ subject: string;
104
+ text: string;
105
+ }>>>;
106
+ }
107
+ export interface EmailCopy {
108
+ render(key: EmailTemplateKey, values: Readonly<Record<string, string>>, locale?: string): {
109
+ subject: string;
110
+ text: string;
111
+ };
112
+ }
113
+ /** Plain-text notices only. Translations must preserve every required capability placeholder. */
114
+ export declare function createEmailCopy(options?: EmailCopyOptions): EmailCopy;
@@ -0,0 +1,58 @@
1
+ export const englishEmailCatalogue = Object.freeze({
2
+ 'admin-verify-email': { subject: 'Account administration requested', text: 'An administrator requested to verify your account email. This notice does not confirm completion.\n\nReview your account at {link}. If you did not expect this, contact the account operator.' },
3
+ 'admin-force-password-reset': { subject: 'Account administration requested', text: 'An administrator requested to require a new account password. This notice does not confirm completion.\n\nReview your account at {link}. If you did not expect this, contact the account operator.' },
4
+ 'admin-schedule-deletion': { subject: 'Account administration requested', text: 'An administrator requested to schedule account deletion. This notice does not confirm completion.\n\nReview your account at {link}. If you did not expect this, contact the account operator.' },
5
+ 'admin-cancel-deletion': { subject: 'Account administration requested', text: 'An administrator requested to cancel account deletion. This notice does not confirm completion.\n\nReview your account at {link}. If you did not expect this, contact the account operator.' },
6
+ 'admin-remove-passkey': { subject: 'Account administration requested', text: 'An administrator requested to remove an account passkey. This notice does not confirm completion.\n\nReview your account at {link}. If you did not expect this, contact the account operator.' },
7
+ 'admin-remove-external': { subject: 'Account administration requested', text: 'An administrator requested to remove an external sign-in method. This notice does not confirm completion.\n\nReview your account at {link}. If you did not expect this, contact the account operator.' },
8
+ 'admin-request-email-change': { subject: 'Account administration requested', text: 'An administrator requested to change your account email. This notice does not confirm completion.\n\nReview your account at {link}. If you did not expect this, contact the account operator.' },
9
+ 'admin-assign-roles': { subject: 'Account administration requested', text: 'An administrator requested to change your account role assignments. This notice does not confirm completion.\n\nReview your account at {link}. If you did not expect this, contact the account operator.' },
10
+ 'admin-resend-verification': { subject: 'Account administration requested', text: 'An administrator requested to send account email verification. This notice does not confirm completion.\n\nReview your account at {link}. If you did not expect this, contact the account operator.' },
11
+ 'verify-email': { subject: 'Verify your email address', text: 'Verify your email address by opening this link:\n\n{link}\n\nIf you did not request this, ignore this email. Never share this link.' },
12
+ 'reset-password': { subject: 'Reset your password', text: 'Reset your password by opening this link:\n\n{link}\n\nIf you did not request this, ignore this email. Never share this link.' },
13
+ 'cancel-deletion': { subject: 'Cancel account deletion', text: 'Cancel account deletion by opening this link:\n\n{link}\n\nAccount deletion was requested. If you did not request this, use this link to cancel the deletion before the grace period ends. Never share this link.' },
14
+ invitation: { subject: 'Create your invited account', text: 'Create your invited account by opening this link:\n\n{link}\n\nIf you did not request this, ignore this email. Never share this link.' },
15
+ 'verify-email-change': { subject: 'Verify your new email address', text: 'Verify your new email address by opening this link:\n\n{link}\n\nVerification confirms the new address. The change only activates after the 24-hour cooldown. Never share this link.' },
16
+ 'cancel-email-change': { subject: 'Cancel an email address change', text: 'Cancel an email address change by opening this link:\n\n{link}\n\nA change to your account email was requested. Use this cancellation link before completion if this was not you. Never share this link.' },
17
+ 'sign-in-code': { subject: 'Your sign-in code', text: 'Your sign-in code is: {code}\n\nEnter it at {link}\n\nIf you did not request this, ignore this email. Never share this code.' },
18
+ 'signup-code': { subject: 'Verify your email address', text: 'Your signup code is: {code}\n\nEnter it in the browser where you started signing up at {link}\n\nIf you did not request this, ignore this email. Never share this code.' },
19
+ 'factor-recovery': { subject: 'Second-factor recovery requested', text: 'Someone requested recovery of your account second factor. Confirm in the browser where you started recovery:\n\n{link}\n\nConfirmation starts a 24-hour waiting period. After that period, return to the confirmation link to finish recovery and enroll a new second factor. Existing sessions will be revoked when recovery completes.\n\nIf this was not you, cancel the request before it completes:\n\n{cancelLink}\n\nNever share these links.' },
20
+ 'manual-recovery-warning': { subject: 'Manual account recovery approved', text: 'Two administrators approved a manual recovery request for your account. Completing recovery will replace your sign-in methods and revoke existing sessions. If you did not request this, contact the account operator immediately.' },
21
+ 'manual-recovery': { subject: 'Restore account access', text: 'Two administrators approved your account recovery. Choose a new password and enroll a second factor at:\n\n{link}\n\nThis link expires in 30 minutes. Your existing sign-in methods and sessions will be revoked when recovery completes. Never share this link.' },
22
+ 'new-device': { subject: 'Account security notification', text: 'A new device signed in to your account.\n\nReview your account at {link}. If this was not you, contact the account operator.' },
23
+ 'password-changed': { subject: 'Account security notification', text: 'Your account password changed.\n\nReview your account at {link}. If this was not you, contact the account operator.' },
24
+ 'email-changed': { subject: 'Account security notification', text: 'Your account email address changed.\n\nReview your account at {link}. If this was not you, contact the account operator.' },
25
+ 'registration-attempt': { subject: 'Account security notification', text: 'Someone tried to create an account with your email address. Your existing account was not changed.\n\nReview your account at {link}. If this was not you, contact the account operator.' },
26
+ });
27
+ for (const template of Object.values(englishEmailCatalogue))
28
+ Object.freeze(template);
29
+ const slots = (text) => [...new Set([...text.matchAll(/\{([A-Za-z]+)\}/g)].map(match => match[1]))].sort();
30
+ function canonical(value) { if (typeof value !== 'string' || value.length > 64)
31
+ throw new Error('Invalid email locale'); return Intl.getCanonicalLocales(value)[0]; }
32
+ /** Plain-text notices only. Translations must preserve every required capability placeholder. */
33
+ export function createEmailCopy(options = {}) {
34
+ const defaultLocale = canonical(options.defaultLocale ?? 'en'), catalogues = new Map();
35
+ const entries = Object.entries(options.catalogues ?? {});
36
+ if (entries.length > 32)
37
+ throw new Error('Too many email locales');
38
+ for (const [raw, source] of entries) {
39
+ const locale = canonical(raw);
40
+ if (catalogues.has(locale) || !source || typeof source !== 'object' || Array.isArray(source))
41
+ throw new Error('Invalid email catalogue');
42
+ const copy = {};
43
+ let total = 0;
44
+ for (const [key, value] of Object.entries(source)) {
45
+ if (!Object.hasOwn(englishEmailCatalogue, key) || !value || typeof value.subject !== 'string' || typeof value.text !== 'string' || !value.subject || value.subject.length > 160 || /[\r\n\x00-\x1f\x7f]/.test(value.subject) || value.text.length > 16384 || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value.text) || slots(value.subject).length || JSON.stringify(slots(value.text)) !== JSON.stringify(slots(englishEmailCatalogue[key].text)))
46
+ throw new Error('Invalid email template');
47
+ total += value.subject.length + value.text.length;
48
+ if (total > 65536)
49
+ throw new Error('Email catalogue too large');
50
+ copy[key] = Object.freeze({ subject: value.subject, text: value.text });
51
+ }
52
+ catalogues.set(locale, Object.freeze(copy));
53
+ }
54
+ return Object.freeze({ render(key, values, locale) { if (!Object.hasOwn(englishEmailCatalogue, key))
55
+ throw new Error('Unknown email template'); const language = canonical(locale ?? defaultLocale), template = catalogues.get(language)?.[key] ?? catalogues.get(language.split('-')[0])?.[key] ?? catalogues.get(defaultLocale)?.[key] ?? englishEmailCatalogue[key]; const text = template.text.replace(/\{([A-Za-z]+)\}/g, (_match, name) => { const value = values[name]; if (typeof value !== 'string' || value.length > 4096)
56
+ throw new Error('Missing email value'); return value; }); if (text.length > 16384)
57
+ throw new Error('Rendered email too large'); return { subject: template.subject, text }; } });
58
+ }
@@ -0,0 +1,46 @@
1
+ import type { AuthChallenge } from './challenge.ts';
2
+ import type { PresentationContext } from './presentation.ts';
3
+ import type { ExtensionRequest } from '@jimhoyd/urlcode/extensions';
4
+ import type { AuthSessionResult } from './auth-core.ts';
5
+ import { AuthHttp } from './auth-ui.ts';
6
+ import type { AuthHttpResponse, UiHost } from './auth-ui.ts';
7
+ export interface FactorRecoveryService {
8
+ getFactorRecoveryEnabled(): boolean;
9
+ beginFactorRecovery(input: {
10
+ email: string;
11
+ browserToken: string;
12
+ }): Promise<{
13
+ verificationToken: string | null;
14
+ cancelToken: string | null;
15
+ }>;
16
+ confirmFactorRecovery(input: {
17
+ token: string;
18
+ browserToken: string;
19
+ }): Promise<{
20
+ completeAfter: number;
21
+ expires: number;
22
+ }>;
23
+ cancelFactorRecovery(token: string): Promise<void>;
24
+ completeFactorRecovery(input: {
25
+ token: string;
26
+ browserToken: string;
27
+ }): Promise<AuthSessionResult>;
28
+ }
29
+ export interface FactorRecoveryMessage {
30
+ email: string;
31
+ verificationToken: string;
32
+ cancelToken: string;
33
+ locale?: string;
34
+ signal: AbortSignal;
35
+ }
36
+ export interface FactorRecoveryOptions {
37
+ challenge?: AuthChallenge;
38
+ service: FactorRecoveryService;
39
+ sendFactorRecovery?: (message: FactorRecoveryMessage) => Promise<void>;
40
+ ui?: UiHost;
41
+ }
42
+ /** Opt-in email fallback lowers factor assurance; it never creates an unrestricted session. */
43
+ export declare function createFactorRecoveryFlows(options: FactorRecoveryOptions, http: AuthHttp, mount: string): {
44
+ enabled: () => boolean;
45
+ handle(request: ExtensionRequest, presentation?: PresentationContext): Promise<AuthHttpResponse | undefined>;
46
+ };
@@ -0,0 +1,71 @@
1
+ import { createPresentation } from "./presentation.js";
2
+ import { randomBytes } from 'node:crypto';
3
+ import { AuthHttp, AuthHttpError, csrfField, escapeHtml, formField, jsonResponse, readFields, screenResponse, wantsJson } from "./auth-ui.js";
4
+ import { hiddenField, Markup } from '@jimhoyd/urlcode-ui';
5
+ /** Opt-in email fallback lowers factor assurance; it never creates an unrestricted session. */
6
+ export function createFactorRecoveryFlows(options, http, mount) {
7
+ const browserCookie = '__Host-urlcode-factor-recovery';
8
+ const enabled = () => options.service.getFactorRecoveryEnabled() && Boolean(options.sendFactorRecovery);
9
+ const hidden = (token) => hiddenField('token', token);
10
+ return { enabled, async handle(request, presentation = createPresentation().resolve()) {
11
+ const tr = (key, values) => presentation.text(key, values);
12
+ const form = (path, csrf, markup, label) => `<form method="post" action="${escapeHtml(mount + path + '?lang=' + encodeURIComponent(presentation.locale))}">${csrfField(csrf)}${markup}<button type="submit">${escapeHtml(label)}</button></form>`;
13
+ const page = (title, view, status = 200, headers = []) => screenResponse(title, { name: 'auth/recover-factor', view: { intro: view.intro, form: new Markup(view.form) } }, { status, headers, presentation, turnstile: request.path === mount + '/recover-factor' && request.method !== 'POST' ? options.challenge?.widget : undefined, layout: 'compact', ui: options.ui });
14
+ const status = (title, message, headers = []) => screenResponse(title, { name: 'auth/status', view: { alert: false, message, href: mount + '/login', label: presentation.textSource('Back to sign in') } }, { status: 200, headers, presentation, layout: 'compact', ui: options.ui });
15
+ const path = request.path.slice(mount.length);
16
+ if (!['/recover-factor', '/recover-factor/confirm', '/recover-factor/cancel', '/recover-factor/complete'].includes(path))
17
+ return;
18
+ if (!enabled())
19
+ throw new AuthHttpError(404, 'Not found');
20
+ if (!['GET', 'HEAD', 'POST'].includes(request.method))
21
+ throw new AuthHttpError(405, 'GET, HEAD or POST required');
22
+ if (request.method !== 'POST') {
23
+ const prepared = http.prepare(request);
24
+ if (path === '/recover-factor')
25
+ return page(tr('recovery.title'), { intro: tr('recovery.intro'), form: form(path, prepared.csrf, formField('email', presentation.textSource('Email address'), 'email', 'username'), tr('recovery.send')) }, 200, prepared.headers);
26
+ const tokens = request.query.getAll('token');
27
+ if (tokens.length !== 1 || !/^[A-Za-z0-9_-]{43}$/.test(tokens[0]))
28
+ throw new AuthHttpError(400, 'A single recovery token is required');
29
+ if (path === '/recover-factor/cancel')
30
+ return page(tr('recovery.cancelTitle'), { intro: null, form: form(path, prepared.csrf, hidden(tokens[0]), tr('recovery.cancel')) }, 200, prepared.headers);
31
+ return page(tr('recovery.confirmTitle'), { intro: tr('recovery.confirmInfo'), form: form('/recover-factor/confirm', prepared.csrf, hidden(tokens[0]), tr('recovery.confirm')) + form('/recover-factor/complete', prepared.csrf, hidden(tokens[0]), tr('recovery.complete')) }, 200, prepared.headers);
32
+ }
33
+ const fields = readFields(request, ['email', 'token']);
34
+ http.verify(request, fields);
35
+ if (path === '/recover-factor') {
36
+ const browserToken = http.cookie(request, browserCookie) || randomBytes(32).toString('base64url');
37
+ const issued = await options.service.beginFactorRecovery({ email: fields.email || '', browserToken });
38
+ if (issued.verificationToken && issued.cancelToken) {
39
+ const controller = new AbortController();
40
+ let timer;
41
+ try {
42
+ await Promise.race([options.sendFactorRecovery({ email: fields.email || '', verificationToken: issued.verificationToken, cancelToken: issued.cancelToken, locale: presentation.locale, signal: controller.signal }), new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(new Error('Delivery timeout')); }, 5000); })]);
43
+ }
44
+ catch {
45
+ await options.service.cancelFactorRecovery(issued.cancelToken).catch(() => { });
46
+ }
47
+ finally {
48
+ if (timer)
49
+ clearTimeout(timer);
50
+ }
51
+ }
52
+ const responseHeaders = [['set-cookie', http.setCookie(browserCookie, browserToken, 5 * 86400)]];
53
+ return wantsJson(request) ? jsonResponse(200, { message: 'If this account is eligible, recovery instructions will be sent. Continue in this browser.' }, responseHeaders) : status(presentation.textSource('Check your email'), presentation.textSource('If this account is eligible, recovery instructions will be sent. Continue in this browser.'), responseHeaders);
54
+ }
55
+ if (path === '/recover-factor/cancel') {
56
+ await options.service.cancelFactorRecovery(fields.token || '');
57
+ return wantsJson(request) ? jsonResponse(200, { cancelled: true }) : status(presentation.textSource('Recovery cancelled'), presentation.textSource('This recovery request has been cancelled. Your existing sign-in methods are unchanged.'));
58
+ }
59
+ const browserToken = http.cookie(request, browserCookie);
60
+ if (!browserToken)
61
+ throw new AuthHttpError(403, 'Use the browser that requested recovery');
62
+ if (path === '/recover-factor/confirm') {
63
+ const result = await options.service.confirmFactorRecovery({ token: fields.token || '', browserToken });
64
+ return wantsJson(request) ? jsonResponse(200, result) : page(tr('recovery.waitTitle'), { intro: tr('recovery.waitInfo', { time: new Date(result.completeAfter).toISOString() }), form: form('/recover-factor/complete', http.token(http.session(request) || http.cookie(request, http.flowCookie) || ''), hidden(fields.token || ''), tr('recovery.complete')) });
65
+ }
66
+ const result = await options.service.completeFactorRecovery({ token: fields.token || '', browserToken });
67
+ const headers = http.sessionHeaders(result.token);
68
+ headers.push(['set-cookie', http.setCookie(browserCookie, '', 0)]);
69
+ return wantsJson(request) ? jsonResponse(200, { enrollmentRequired: true, user: result.user, csrf: http.token(result.token) }, headers) : jsonResponse(303, { enrollmentRequired: true }, [['location', mount + '/account'], ...headers]);
70
+ } };
71
+ }
@@ -0,0 +1,42 @@
1
+ export { createAuthService, AuthError } from './auth-core.ts';
2
+ export type { AuthService, AuthPrincipal, AuthUser } from './auth-core.ts';
3
+ export { authExtension, hasPermission } from './auth.ts';
4
+ export type { AuthExtensionOptions } from './auth.ts';
5
+ export type { AuthHttpOptions, AuthHttpResponse } from './auth-ui.ts';
6
+ export { AuthHttp, AuthHttpError, csrfField, escapeHtml, formField, httpFailure, jsonResponse, pageResponse, readFields, screenResponse, wantsJson } from './auth-ui.ts';
7
+ export type { Screen, ScreenOptions, UiHost } from './auth-ui.ts';
8
+ export { authTemplates, authTemplateNames, authUiTemplates } from './auth-templates.ts';
9
+ export type { AuthTemplate } from './auth-templates.ts';
10
+ export { createOidcProvider } from './oidc.ts';
11
+ export type { OidcProvider, OidcFlow, OidcIdentity, OidcProviderOptions } from './oidc.ts';
12
+ export { createPasskeyProvider } from './passkeys.ts';
13
+ export type { PasskeyProvider, StoredPasskey, PasskeyProviderOptions } from './passkeys.ts';
14
+ export { createSesSender, createDevelopmentSender } from './senders.ts';
15
+ export { createPresentation, englishCatalogue, englishCatalogue as authCatalogue } from './presentation.ts';
16
+ export type { Presentation, PresentationOptions, PresentationContext, Catalogue, ThemeVariables } from './presentation.ts';
17
+ export * from './registration.ts';
18
+ export { createGoogleProvider, createAppleProvider } from './providers.ts';
19
+ export type { GoogleProviderOptions, AppleProviderOptions } from './providers.ts';
20
+ export { createBackup, restoreBackup } from './backup.ts';
21
+ export type { BackupOptions, BackupResult, RestoreOptions } from './backup.ts';
22
+ export type { AuthOptions, AuthSessionResult, AuthSession, AuthAuditEvent, AuthCase } from './auth-core.ts';
23
+ export { initAuthentication, scaffold } from './scaffold.ts';
24
+ export type { AuthenticationScaffold, ScaffoldRequest, ScaffoldResult, ScaffoldFile } from './scaffold.ts';
25
+ export type { EmailSender, TokenSender, TokenMessage, EmailCodeMessage, FactorRecoveryMessage, SignupCodeMessage, SecurityNotice, SesSenderOptions, DevelopmentSenderOptions } from './senders.ts';
26
+ export { createPasswordBreachChecker } from './password-policy.ts';
27
+ export type { PasswordBreachOptions } from './password-policy.ts';
28
+ export type { AuthDailyMetric, AuthHookStats, AuthLifecycleEvent, AuthCredentials, AuthSecondFactor, AuthDevice, AuthPasskey, AuthProof, ExternalAuthProof, PasskeyAuthProof, SignupBinding, SignupState, SignupStart } from './auth-core.ts';
29
+ export { createAuthPreset } from './presets.ts';
30
+ export type { AuthPreset, AuthPresetOptions } from './presets.ts';
31
+ export type { AuthRestriction, AuthSecurityPolicy } from './auth-core.ts';
32
+ export { verifyDeployment } from './deployment-check.ts';
33
+ export type { DeploymentCheckOptions, DeploymentCheckResult } from './deployment-check.ts';
34
+ export type { ManualRecoveryService, ManualRecoveryCase, ManualRecoveryDelivery, ManualRecoveryEvidence } from './manual-recovery.ts';
35
+ export type { UserQuery, ValidatedUserQuery } from './user-query.ts';
36
+ export { validateUserQuery } from './user-query.ts';
37
+ export { createEmailCopy, englishEmailCatalogue } from './email-copy.ts';
38
+ export type { EmailCopy, EmailCopyOptions, EmailTemplateKey } from './email-copy.ts';
39
+ export type { AdminAccountAction, AdminAccountDelivery, AdminAccountRequest, AdminAuthenticationMethods, AdminAccountService } from './admin-account-operations.ts';
40
+ export type { TurnstileWidget } from './challenge-ui.ts';
41
+ export { createTurnstileChallenge } from './challenge.ts';
42
+ export type { AuthChallenge, AuthChallengeInput, TurnstileChallengeOptions } from './challenge.ts';
package/dist/index.js ADDED
@@ -0,0 +1,18 @@
1
+ export { createAuthService, AuthError } from "./auth-core.js";
2
+ export { authExtension, hasPermission } from "./auth.js";
3
+ export { AuthHttp, AuthHttpError, csrfField, escapeHtml, formField, httpFailure, jsonResponse, pageResponse, readFields, screenResponse, wantsJson } from "./auth-ui.js";
4
+ export { authTemplates, authTemplateNames, authUiTemplates } from "./auth-templates.js";
5
+ export { createOidcProvider } from "./oidc.js";
6
+ export { createPasskeyProvider } from "./passkeys.js";
7
+ export { createSesSender, createDevelopmentSender } from "./senders.js";
8
+ export { createPresentation, englishCatalogue, englishCatalogue as authCatalogue } from "./presentation.js";
9
+ export * from "./registration.js";
10
+ export { createGoogleProvider, createAppleProvider } from "./providers.js";
11
+ export { createBackup, restoreBackup } from "./backup.js";
12
+ export { initAuthentication, scaffold } from "./scaffold.js";
13
+ export { createPasswordBreachChecker } from "./password-policy.js";
14
+ export { createAuthPreset } from "./presets.js";
15
+ export { verifyDeployment } from "./deployment-check.js";
16
+ export { validateUserQuery } from "./user-query.js";
17
+ export { createEmailCopy, englishEmailCatalogue } from "./email-copy.js";
18
+ export { createTurnstileChallenge } from "./challenge.js";
@@ -0,0 +1,25 @@
1
+ import type { DatabaseSync } from 'node:sqlite';
2
+ import type { AuthRecord, SessionRecord } from './auth-store.ts';
3
+ interface Context {
4
+ db: DatabaseSync;
5
+ enabled: boolean;
6
+ now: number;
7
+ account(id: string): AuthRecord | null;
8
+ active(id: string): AuthRecord;
9
+ fresh(hash: string, now: number): {
10
+ user: AuthRecord;
11
+ session: SessionRecord;
12
+ };
13
+ authorizeCase(actor: AuthRecord, target: AuthRecord): void;
14
+ save(user: AuthRecord): void;
15
+ addSession(session: SessionRecord): void;
16
+ audit(actor: string, action: string, subject: string, now: number, reason?: string): void;
17
+ isAdministrator(user: AuthRecord): boolean;
18
+ isRestricted(user: AuthRecord): boolean;
19
+ fail(status: number, code: string): never;
20
+ }
21
+ /** Called only inside the store's revision-checked BEGIN IMMEDIATE dispatch. */
22
+ export declare function manualRecoveryOperation(operation: string, args: Record<string, unknown>, context: Context): {
23
+ value: unknown;
24
+ } | undefined;
25
+ export {};
@@ -0,0 +1,129 @@
1
+ /** Called only inside the store's revision-checked BEGIN IMMEDIATE dispatch. */
2
+ export function manualRecoveryOperation(operation, args, context) {
3
+ if (!operation.startsWith('manualRecovery'))
4
+ return;
5
+ const { db, now } = context;
6
+ const fail = context.fail;
7
+ if (!context.enabled)
8
+ fail(403, 'manual_recovery_disabled');
9
+ const rowCase = (id) => { const row = db.prepare('SELECT data FROM auth_cases WHERE id=?').get(id); if (!row)
10
+ fail(404, 'case_not_found'); const item = JSON.parse(String(row.data)); if (item.action !== 'restore-access')
11
+ fail(400, 'invalid_recovery_case'); return item; };
12
+ const saveCase = (item) => db.prepare('UPDATE auth_cases SET data=? WHERE id=?').run(JSON.stringify(item), item.id);
13
+ const availableEmail = (email, accountId) => { const row = db.prepare('SELECT id FROM auth_accounts WHERE email=?').get(email); if (row && row.id !== accountId)
14
+ fail(409, 'email_unavailable'); };
15
+ const targetFor = (item) => { const target = context.account(item.accountId); if (!target || target.version !== item.targetVersion)
16
+ fail(409, 'case_target_changed'); return target; };
17
+ const authorize = (item, target) => { context.authorizeCase(context.active(item.makerId), target); if (item.approverId)
18
+ context.authorizeCase(context.active(item.approverId), target); };
19
+ switch (operation) {
20
+ case 'manualRecoveryCreate': {
21
+ const maker = context.fresh(String(args.hash), now).user, target = context.account(String(args.accountId));
22
+ if (!target)
23
+ fail(404, 'account_not_found');
24
+ context.authorizeCase(maker, target);
25
+ availableEmail(String(args.email), target.id);
26
+ if (Number(db.prepare('SELECT count(*) AS n FROM auth_cases').get()?.n) >= 10000)
27
+ fail(503, 'auth_capacity_reached');
28
+ const item = { id: String(args.id), accountId: target.id, action: 'restore-access', reason: String(args.reason), makerId: maker.id, status: 'pending', created: now, expires: now + 86400000, targetVersion: target.version, recovery: { email: String(args.email), evidence: args.evidence, state: 'review' } };
29
+ db.prepare('INSERT INTO auth_cases(id,data) VALUES(?,?)').run(item.id, JSON.stringify(item));
30
+ context.audit(maker.id, 'recovery.case_created', target.id, now, item.reason);
31
+ return { value: item };
32
+ }
33
+ case 'manualRecoveryList': return { value: db.prepare("SELECT data FROM auth_cases WHERE id>? AND json_extract(data,'$.action')='restore-access' ORDER BY id LIMIT ?").all(String(args.after), Number(args.limit)).map(row => JSON.parse(String(row.data))) };
34
+ case 'manualRecoveryCheck': {
35
+ const credential = db.prepare('SELECT * FROM auth_manual_recovery WHERE hash=? AND active=1 AND expires>?').get(String(args.tokenHash), now);
36
+ if (!credential)
37
+ fail(401, 'invalid_recovery');
38
+ const item = rowCase(String(credential.case_id)), target = targetFor(item);
39
+ authorize(item, target);
40
+ return { value: undefined };
41
+ }
42
+ case 'manualRecoveryApprove': {
43
+ const checker = context.fresh(String(args.hash), now).user, item = rowCase(String(args.id));
44
+ if (item.status !== 'pending' || item.expires <= now)
45
+ fail(409, 'case_unavailable');
46
+ if (item.makerId === checker.id)
47
+ fail(403, 'distinct_approver_required');
48
+ const target = targetFor(item);
49
+ authorize(item, target);
50
+ context.authorizeCase(checker, target);
51
+ availableEmail(item.recovery.email, target.id);
52
+ db.prepare('INSERT INTO auth_manual_recovery(hash,case_id,account_id,version,approver_id,maker_version,approver_version,active,expires) VALUES(?,?,?,?,?,?,?,0,?)').run(String(args.tokenHash), item.id, target.id, target.version, checker.id, context.active(item.makerId).version, checker.version, now + 1800000);
53
+ item.status = 'applied';
54
+ item.approverId = checker.id;
55
+ item.recovery.state = 'delivery';
56
+ saveCase(item);
57
+ context.audit(checker.id, 'recovery.case_approved', target.id, now, String(args.reason));
58
+ return { value: { case: item, email: item.recovery.email, oldEmail: target.email } };
59
+ }
60
+ case 'manualRecoveryActivate':
61
+ case 'manualRecoveryCancel': {
62
+ const checker = context.fresh(String(args.hash), now).user, item = rowCase(String(args.id)), credential = db.prepare('SELECT * FROM auth_manual_recovery WHERE hash=? AND case_id=?').get(String(args.tokenHash), item.id);
63
+ if (!credential || credential.approver_id !== checker.id || item.approverId !== checker.id)
64
+ fail(409, 'recovery_unavailable');
65
+ if (operation === 'manualRecoveryCancel') {
66
+ db.prepare('DELETE FROM auth_manual_recovery WHERE case_id=?').run(item.id);
67
+ item.recovery.state = 'cancelled';
68
+ item.status = 'closed';
69
+ saveCase(item);
70
+ context.audit(checker.id, 'recovery.delivery_cancelled', item.accountId, now);
71
+ return { value: undefined };
72
+ }
73
+ if (Number(credential.expires) <= now || credential.active !== 0 || item.recovery.state !== 'delivery')
74
+ fail(409, 'recovery_unavailable');
75
+ const target = targetFor(item);
76
+ authorize(item, target);
77
+ if (context.active(item.makerId).version !== credential.maker_version || checker.version !== credential.approver_version)
78
+ fail(409, 'recovery_approval_changed');
79
+ availableEmail(item.recovery.email, target.id);
80
+ db.prepare('UPDATE auth_manual_recovery SET active=1 WHERE hash=?').run(String(args.tokenHash));
81
+ item.recovery.state = 'ready';
82
+ saveCase(item);
83
+ context.audit(checker.id, 'recovery.delivery_confirmed', item.accountId, now);
84
+ return { value: undefined };
85
+ }
86
+ case 'manualRecoveryRedeem': {
87
+ const credential = db.prepare('SELECT * FROM auth_manual_recovery WHERE hash=? AND active=1 AND expires>?').get(String(args.tokenHash), now);
88
+ if (!credential)
89
+ fail(401, 'invalid_recovery');
90
+ const item = rowCase(String(credential.case_id));
91
+ if (item.recovery.state !== 'ready')
92
+ fail(401, 'invalid_recovery');
93
+ const target = targetFor(item);
94
+ authorize(item, target);
95
+ if (context.active(item.makerId).version !== credential.maker_version || context.active(item.approverId).version !== credential.approver_version)
96
+ fail(409, 'recovery_approval_changed');
97
+ availableEmail(item.recovery.email, target.id);
98
+ if (context.isAdministrator(target) && !db.prepare("SELECT data FROM auth_accounts WHERE administrator=1 AND status='active' AND id<>?").all(target.id).some(row => !context.isRestricted(JSON.parse(String(row.data)))))
99
+ fail(409, 'last_administrator_required');
100
+ target.email = item.recovery.email;
101
+ target.emailVerified = true;
102
+ target.passwordHash = String(args.passwordHash);
103
+ target.status = 'active';
104
+ target.mfaRecoveryRequired = true;
105
+ target.version++;
106
+ delete target.totpSecret;
107
+ delete target.totpPending;
108
+ delete target.totpPendingUntil;
109
+ delete target.mfaPasskeys;
110
+ target.totpCounter = -1;
111
+ db.prepare('UPDATE auth_accounts SET email=? WHERE id=?').run(target.email, target.id);
112
+ context.save(target);
113
+ for (const table of ['auth_sessions', 'auth_tokens', 'auth_recovery', 'auth_method_activity', 'auth_passkeys', 'auth_external', 'auth_email_codes', 'auth_email_changes', 'auth_factor_recovery', 'auth_second_factor_proofs', 'auth_trusted_devices', 'auth_manual_recovery'])
114
+ db.prepare(`DELETE FROM ${table} WHERE account_id=?`).run(target.id);
115
+ const session = args.session;
116
+ session.accountId = target.id;
117
+ session.recoveryEnrollment = 1;
118
+ session.primaryMethod = 'recovery';
119
+ session.mfaAuthenticatedAt = 0;
120
+ session.mfaVersion = 0;
121
+ context.addSession(session);
122
+ item.recovery.state = 'redeemed';
123
+ saveCase(item);
124
+ context.audit(target.id, 'recovery.access_restored', target.id, now, item.id);
125
+ return { value: target };
126
+ }
127
+ default: return;
128
+ }
129
+ }
@@ -0,0 +1,87 @@
1
+ import type { ExtensionRequest } from '@jimhoyd/urlcode/extensions';
2
+ import type { AuthSessionResult } from './auth-core.ts';
3
+ import type { PresentationContext } from './presentation.ts';
4
+ import { AuthHttp } from './auth-ui.ts';
5
+ import type { UiHost } from './auth-ui.ts';
6
+ import type { AuthHttpResponse } from './auth-ui.ts';
7
+ /** Evidence is an internal human assessment, never an automatic identity assertion. */
8
+ export interface ManualRecoveryEvidence {
9
+ summary: string;
10
+ reference?: string;
11
+ }
12
+ export interface ManualRecoveryCase {
13
+ id: string;
14
+ accountId: string;
15
+ action: 'restore-access';
16
+ reason: string;
17
+ makerId: string;
18
+ approverId?: string;
19
+ status: 'pending' | 'applied' | 'closed';
20
+ created: number;
21
+ expires: number;
22
+ targetVersion: number;
23
+ recovery: {
24
+ email: string;
25
+ evidence: ManualRecoveryEvidence;
26
+ state: 'review' | 'delivery' | 'ready' | 'redeemed' | 'cancelled';
27
+ };
28
+ notes?: {
29
+ actorId: string;
30
+ note: string;
31
+ created: number;
32
+ }[];
33
+ }
34
+ /** Delivery must send the approved address its link AND warn the old address before resolving. */
35
+ export interface ManualRecoveryDelivery {
36
+ email: string;
37
+ oldEmail: string;
38
+ token: string;
39
+ caseId: string;
40
+ signal: AbortSignal;
41
+ }
42
+ export interface ManualRecoveryService {
43
+ getManualRecoveryEnabled(): boolean;
44
+ createRecoveryCase(input: {
45
+ actorToken: string;
46
+ accountId: string;
47
+ email: string;
48
+ evidence: ManualRecoveryEvidence;
49
+ reason: string;
50
+ }): Promise<ManualRecoveryCase>;
51
+ listRecoveryCases(options?: {
52
+ limit?: number;
53
+ after?: string;
54
+ }): Promise<{
55
+ cases: ManualRecoveryCase[];
56
+ next?: string;
57
+ }>;
58
+ approveRecoveryCase(input: {
59
+ actorToken: string;
60
+ caseId: string;
61
+ reason: string;
62
+ }): Promise<{
63
+ case: ManualRecoveryCase;
64
+ token: string;
65
+ email: string;
66
+ oldEmail: string;
67
+ }>;
68
+ activateRecoveryCase(input: {
69
+ actorToken: string;
70
+ caseId: string;
71
+ token: string;
72
+ }): Promise<void>;
73
+ cancelRecoveryCredential(input: {
74
+ actorToken: string;
75
+ caseId: string;
76
+ token: string;
77
+ }): Promise<void>;
78
+ redeemRecoveryCase(input: {
79
+ token: string;
80
+ password: string;
81
+ }): Promise<AuthSessionResult>;
82
+ }
83
+ export declare function validateRecoveryEvidence(input: ManualRecoveryEvidence): ManualRecoveryEvidence;
84
+ /** Redemption is POST-only and creates an enrollment session, never normal access. */
85
+ export declare function createManualRecoveryFlows(service: ManualRecoveryService, http: AuthHttp, mount: string, ui?: UiHost): {
86
+ handle(request: ExtensionRequest, presentation?: PresentationContext): Promise<AuthHttpResponse | undefined>;
87
+ };