@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
package/dist/auth.js ADDED
@@ -0,0 +1,503 @@
1
+ import { icon, hiddenField, postForm, Markup } from '@jimhoyd/urlcode-ui';
2
+ import { createAbuseGuard } from "./abuse-http.js";
3
+ import { createManualRecoveryFlows } from "./manual-recovery.js";
4
+ import { createFactorRecoveryFlows } from "./factor-recovery.js";
5
+ import { createPresentation } from "./presentation.js";
6
+ import { isHoneypotFilled } from "./registration.js";
7
+ import { createSecondFactorFlows } from "./second-factor-flows.js";
8
+ import { createSignup } from "./auth-signup.js";
9
+ import { createAuthFlows } from "./auth-flows.js";
10
+ import { AuthHttp, AuthHttpError, csrfField, escapeHtml, formField as baseField, httpFailure, jsonResponse, presentationSource, readFields, screenResponse, wantsJson, passkeyScript, secondFactorButton } from "./auth-ui.js";
11
+ const defaultPresentation = createPresentation();
12
+ function enrollmentRequired(principal) { return Boolean(principal.restrictions?.length); }
13
+ export function hasPermission(principal, permission) { return !enrollmentRequired(principal) && (principal.permissions.includes('*') || principal.permissions.includes(permission)); }
14
+ const schema = { type: 'object', additionalProperties: false, properties: { registration: { enum: ['open', 'invite-only', 'waitlist', 'off'] } } };
15
+ const policySchema = { type: 'object', additionalProperties: false, properties: { role: { type: 'string', minLength: 1, maxLength: 64 }, permission: { type: 'string', minLength: 1, maxLength: 128 }, verified: { type: 'boolean' }, freshWithinSeconds: { type: 'integer', minimum: 1, maximum: 3600 }, onDeny: { enum: [401, 403, 404, 'sign-in'] } }, minProperties: 0 };
16
+ const actionIcons = { identify: 'arrow-right', login: 'arrow-right', 'step-up': 'shield', logout: 'log-out', export: 'download' };
17
+ const hidden = hiddenField;
18
+ const m = (html) => new Markup(html);
19
+ export function authExtension(options) {
20
+ return { name: 'auth', version: '1', projectSha256: options.projectSha256, targets: ['node'], schema, policySchema, credentialHeaders: ['cookie', 'authorization', 'x-csrf-token'],
21
+ activate(config, context) {
22
+ if (context.mounts.length !== 1)
23
+ throw new Error('Auth requires exactly one mount');
24
+ const mount = context.mounts[0], http = new AuthHttp({ origin: context.origin, csrfKey: options.csrfKey }), service = options.service, registrationMode = String(config.registration || 'off'), registration = registrationMode === 'open';
25
+ // The runtime activates `ui` before auth, but its kit is read per request, never captured at activation.
26
+ const source = () => presentationSource(options.presentation, options.ui, defaultPresentation), localized = Boolean(options.presentation || options.ui);
27
+ const lazyPresentation = { get locales() { return source().locales; }, get defaultLocale() { return source().defaultLocale; }, get english() { return source().english; }, resolve: preferences => source().resolve(preferences), coverage: locale => source().coverage(locale) };
28
+ if (registrationMode !== service.getRegistrationMode())
29
+ throw new Error('Project registration mode must match operator auth service mode');
30
+ const registrationSchema = service.getRegistrationSchema();
31
+ const metadataFields = Object.entries(registrationSchema.metadata ?? {});
32
+ function profileInput(fields) {
33
+ const metadata = {};
34
+ for (const [name, field] of metadataFields) {
35
+ const value = fields['meta.' + name];
36
+ if (value === undefined || value === '')
37
+ continue;
38
+ if (field.type === 'number') {
39
+ if (!Number.isFinite(Number(value)))
40
+ throw new AuthHttpError(400, 'Invalid numeric metadata');
41
+ metadata[name] = Number(value);
42
+ }
43
+ else if (field.type === 'boolean') {
44
+ if (value !== 'true' && value !== 'false')
45
+ throw new AuthHttpError(400, 'Invalid boolean metadata');
46
+ metadata[name] = value === 'true';
47
+ }
48
+ else
49
+ metadata[name] = value;
50
+ }
51
+ return { ...(fields.displayName !== undefined ? { displayName: fields.displayName } : {}), ...(fields.locale ? { locale: fields.locale } : {}), ...(Object.keys(metadata).length ? { metadata } : {}), ...(fields.termsAccepted !== undefined ? { termsAccepted: fields.termsAccepted === 'true' } : {}) };
52
+ }
53
+ const profileMarkup = (formField = baseField, presentation) => formField('displayName', 'Display name', 'text', 'nickname', false) + formField('locale', 'Preferred language', 'text', 'language', false) + metadataFields.map(([name, field]) => baseField('meta.' + name, name + (field.type === 'boolean' ? ' (' + (presentation?.text('field.booleanHint') ?? 'true or false') + ')' : ''), field.type === 'number' ? 'number' : 'text', 'off', field.required === true)).join('') + (registrationSchema.termsVersion ? `<label><input type="checkbox" name="termsAccepted" value="true" required> ${escapeHtml((presentation ?? source().resolve()).text('message.acceptTerms', { version: registrationSchema.termsVersion }))}</label>` : '');
54
+ const abuseGuard = createAbuseGuard(service, http, mount, options.challenge, options.ui);
55
+ const secondFactors = createSecondFactorFlows(options, http, mount);
56
+ const trustedCookie = '__Host-urlcode-trusted-device';
57
+ const trusted = (request) => { const token = service.getSecurityPolicy().trustedDeviceTtlMs ? http.cookie(request, trustedCookie) : undefined; return token ? { trustedDevice: token } : {}; };
58
+ const noticeLocale = (request, user) => source().resolve({ ...(user.profile?.locale ? { accountLocale: user.profile.locale } : {}), ...(request.query.get('lang') ? { queryLocale: request.query.get('lang') } : {}), ...(request.headers.get('accept-language') ? { acceptLanguage: request.headers.get('accept-language') } : {}) }).locale;
59
+ const flows = createAuthFlows({ ...options, presentation: lazyPresentation, onSession: async (request, result) => {
60
+ if (result.newDevice)
61
+ await notice(result.user.email, 'new-device', noticeLocale(request, result.user));
62
+ return http.device(request).headers;
63
+ }, enrollment: { required: !!registrationSchema.termsVersion || metadataFields.some(([, field]) => field.required), fields: (presentation) => profileMarkup((name, label, ...rest) => baseField(name, presentation?.textSource(label) ?? label, ...rest), presentation), read: profileInput, names: ['displayName', 'locale', 'termsAccepted', ...metadataFields.map(([name]) => 'meta.' + name)] } }, http, mount, registration);
64
+ const factorRecovery = createFactorRecoveryFlows(options, http, mount);
65
+ const manualRecovery = createManualRecoveryFlows(service, http, mount, options.ui);
66
+ const signup = createSignup({ ...options, presentation: lazyPresentation }, http, mount, { fields: p => profileMarkup((name, label, ...rest) => baseField(name, p.textSource(label), ...rest), p), read: profileInput, names: ['displayName', 'locale', 'termsAccepted', ...metadataFields.map(([name]) => 'meta.' + name)] });
67
+ const passkeyButton = (kind, text = value => value) => options.passkeys ? `<button type="button" data-passkey="${kind}" data-base="${escapeHtml(mount)}" data-unavailable="${escapeHtml(text('Passkeys are unavailable in this browser. Use another sign-in method.'))}" data-failed="${escapeHtml(text('Passkey request failed'))}" data-cancelled="${escapeHtml(text('Passkey ceremony cancelled'))}">${escapeHtml(text(kind === 'register' ? 'Add a passkey' : kind === 'step-up' ? 'Confirm identity with a passkey' : 'Sign in with a passkey'))}</button><p role="status" aria-live="polite" data-passkey-status></p>` : '';
68
+ async function principal(request) {
69
+ const token = http.session(request);
70
+ const found = token ? await service.authenticate(token) : null;
71
+ if (!token || !found)
72
+ throw new AuthHttpError(401, 'Sign in required');
73
+ return { token, principal: found };
74
+ }
75
+ function redirect(path, headers = []) { return jsonResponse(303, { redirect: path }, [['location', path], ...headers]); }
76
+ const navigationIcons = { account: 'user', sessions: 'monitor', 'step-up': 'shield', methods: 'key', 'second-factors': 'shield', 'trusted-devices': 'monitor' };
77
+ const createNavigation = (text, currentPath) => `<nav class="ui-tabs" aria-label="${escapeHtml(text('Account'))}">${[['account', 'Account'], ['sessions', 'Sessions'], ['step-up', 'Confirm identity'], ['methods', 'Sign-in methods'], ...(service.getSecurityPolicy().allowPasskeySecondFactor ? [['second-factors', 'Second factors']] : []), ...(service.getSecurityPolicy().trustedDeviceTtlMs ? [['trusted-devices', 'Remembered devices']] : [])].map(([path, label]) => `<a href="${escapeHtml(mount + '/' + path)}"${currentPath === '/' + path ? ' aria-current="page"' : ''}>${icon(navigationIcons[path])}${escapeHtml(text(label))}</a>`).join('')}</nav>`;
78
+ async function deliver(email, token, purpose, strict = false, locale) {
79
+ if (!options.sendToken)
80
+ throw new AuthHttpError(503, 'Email delivery is not configured');
81
+ const controller = new AbortController();
82
+ let timer;
83
+ try {
84
+ await Promise.race([options.sendToken({ email, token, purpose, ...(locale ? { locale } : {}), signal: controller.signal }), new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(new Error('Delivery timeout')); }, 5000); })]);
85
+ }
86
+ catch (error) {
87
+ if (strict)
88
+ throw error;
89
+ }
90
+ finally {
91
+ if (timer)
92
+ clearTimeout(timer);
93
+ }
94
+ }
95
+ async function notify(email, purpose, locale) {
96
+ if (!options.sendToken)
97
+ throw new AuthHttpError(503, 'Email delivery is not configured');
98
+ const issued = await service.issueToken({ email, purpose });
99
+ if (!issued.token)
100
+ return;
101
+ await deliver(email, issued.token, purpose, false, locale);
102
+ }
103
+ async function notice(email, event = 'password-changed', locale) {
104
+ if (!options.sendNotice)
105
+ return;
106
+ const controller = new AbortController();
107
+ let timer;
108
+ try {
109
+ await Promise.race([options.sendNotice({ email, event, ...(locale ? { locale } : {}), signal: controller.signal }), new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(new Error('timeout')); }, 5000); })]);
110
+ }
111
+ catch { }
112
+ finally {
113
+ if (timer)
114
+ clearTimeout(timer);
115
+ }
116
+ }
117
+ return {
118
+ async authorize(requirement, request) {
119
+ let presentation = source().resolve({ ...(request.query.get('lang') ? { queryLocale: request.query.get('lang') } : {}), ...(request.headers.get('accept-language') ? { acceptLanguage: request.headers.get('accept-language') } : {}) });
120
+ try {
121
+ const token = http.session(request), user = token ? await service.authenticate(token) : null;
122
+ const locale = user && localized ? (await service.getUser(user.id))?.profile?.locale : undefined;
123
+ if (locale)
124
+ presentation = source().resolve({ accountLocale: locale });
125
+ const allowed = user && !enrollmentRequired(user) && (!requirement.role || user.roles.includes(String(requirement.role))) && (!requirement.permission || hasPermission(user, String(requirement.permission))) && (!requirement.verified || user.emailVerified) && (!requirement.freshWithinSeconds || Date.now() - user.authenticatedAt <= Number(requirement.freshWithinSeconds) * 1000);
126
+ if (allowed) {
127
+ if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method))
128
+ http.verify(request, {});
129
+ return undefined;
130
+ }
131
+ if (requirement.onDeny === 'sign-in' && ['GET', 'HEAD'].includes(request.method))
132
+ return redirect(mount + (user && enrollmentRequired(user) ? '/account' : '/login'));
133
+ return jsonResponse(typeof requirement.onDeny === 'number' ? requirement.onDeny : user ? 403 : 401, { error: presentation.text('message.accessDenied') });
134
+ }
135
+ catch (error) {
136
+ return httpFailure(error, request, presentation, undefined, options.ui);
137
+ }
138
+ },
139
+ async handle(request) {
140
+ let accountLocale;
141
+ if (localized) {
142
+ try {
143
+ const session = http.session(request), actor = session ? await service.authenticate(session) : null;
144
+ accountLocale = actor ? (await service.getUser(actor.id))?.profile?.locale : undefined;
145
+ }
146
+ catch { }
147
+ }
148
+ const presentation = source().resolve({ ...(accountLocale ? { accountLocale } : {}), ...(request.query.get('lang') ? { queryLocale: request.query.get('lang') } : {}), ...(request.headers.get('accept-language') ? { acceptLanguage: request.headers.get('accept-language') } : {}) });
149
+ const tr = (key, values) => escapeHtml(presentation.text(key, values));
150
+ const text = (value) => presentation?.textSource(value) ?? value;
151
+ const navigation = createNavigation(source => presentation?.textSource(source) ?? source, request.path.slice(mount.length));
152
+ const screen = (title, name, view, status = 200, headers = [], scriptPath) => screenResponse(title, { name: 'auth/' + name, view }, { status, headers, scriptPath: scriptPath ?? (service.getSecurityPolicy().allowPasskeySecondFactor && options.passkeys ? mount + '/assets/passkeys.js' : undefined), presentation, turnstile: options.challenge?.widget && (['/', '/login', '/identify', '/forgot-password'].includes(request.path.slice(mount.length) || '/') || request.path.slice(mount.length) === '/email-code' && !request.query.has('flowId')) ? options.challenge.widget : undefined, layout: ['/account', '/sessions', '/methods', '/second-factors', '/trusted-devices'].includes(request.path.slice(mount.length)) ? 'default' : 'compact', ui: options.ui });
153
+ const lang = (path) => mount + path + '?lang=' + encodeURIComponent(presentation.locale);
154
+ const formField = (name, label, type = 'text', autocomplete = 'off', required = true) => baseField(name, presentation?.textSource(label) ?? label, type, autocomplete, required);
155
+ const form = (action, csrf, fields, button) => { const actionName = action.split('?')[0].split('/').at(-1); return postForm({ action: action + (action.includes('?') ? '&' : '?') + 'lang=' + encodeURIComponent(presentation.locale), csrf, fields, label: presentation?.textSource(button) ?? button, ...(actionIcons[actionName] ? { icon: actionIcons[actionName] } : {}) }); };
156
+ const profileFields = () => profileMarkup(formField, presentation);
157
+ const factors = () => `<details class="ui-disclosure"><summary>${tr('ux.twoStep')}</summary><p class="ui-muted">${tr('ux.twoStepHelp')}</p>` + formField('totp', 'Authenticator code (if enabled)', 'text', 'one-time-code', false) + formField('recoveryCode', 'Recovery code (instead of authenticator code)', 'text', 'off', false) + (service.getSecurityPolicy().allowPasskeySecondFactor && options.passkeys ? secondFactorButton(mount, text) : '') + '</details>';
158
+ const passkeyLogin = (csrf) => options.passkeys ? `<form method="post" action="${escapeHtml(mount + '/login')}">${csrfField(csrf)}<fieldset><legend>${escapeHtml(text('Passkey sign-in'))}</legend><p>${escapeHtml(text('If your account uses a second factor, confirm it before choosing your sign-in passkey.'))}</p>${factors()}${passkeyButton('login', text)}</fieldset></form>` : '';
159
+ const completed = (value, title, message, headers = [], destination = '/account') => wantsJson(request) ? jsonResponse(200, value, headers) : screen(title, 'status', { alert: false, message: text(message), href: lang(destination), label: presentation.text(destination === '/login' ? 'ux.backSignIn' : 'copy.continueToYourAccount') }, 200, headers);
160
+ let submittedEmail;
161
+ const passwordPage = (email, csrf, failed = false) => {
162
+ return screen('Enter your password', 'password', { failed: failed ? presentation.text('ux.signInFailed') : null, intro: '', email, changeHref: lang('/login'), changeLabel: presentation.text('ux.change'), form: m(form(mount + '/login', csrf, hidden('email', email) + formField('password', 'Password', 'password', 'current-password') + (options.sendToken ? `<p class="ui-link-list"><a href="${escapeHtml(lang('/forgot-password'))}">${tr('nav.forgotPassword')}</a></p>` : '') + factors(), 'Sign in')) }, failed ? 401 : 200, [], options.passkeys ? mount + '/assets/passkeys.js' : undefined);
163
+ };
164
+ try {
165
+ const path = request.path.slice(mount.length) || '/';
166
+ const abuseDenied = await abuseGuard(request, presentation);
167
+ if (abuseDenied)
168
+ return abuseDenied;
169
+ const restored = await manualRecovery.handle(request, presentation);
170
+ if (restored)
171
+ return restored;
172
+ const recovered = await factorRecovery.handle(request, presentation);
173
+ if (recovered)
174
+ return recovered;
175
+ const sessionToken = http.session(request), sessionPrincipal = sessionToken ? await service.authenticate(sessionToken) : null;
176
+ if (sessionPrincipal && enrollmentRequired(sessionPrincipal)) {
177
+ const enrollmentPaths = new Set(['/account', '/csrf', '/logout', '/verify', '/send-verification', '/totp/begin', '/totp/confirm', '/login', '/identify', '/step-up', '/assets/passkeys.js', '/email-code', '/send-email-code', '/providers/complete', '/second-factors', '/passkeys/second-factor', '/second-factor/options', '/second-factor/verify']);
178
+ const proofRenewal = /^\/passkeys\/(?:login|step-up|register)\/(?:options|verify)$/.test(path) || /^\/providers\/[a-z][a-z0-9-]{0,31}\/(?:start|callback)$/.test(path);
179
+ if (!enrollmentPaths.has(path) && !proofRenewal) {
180
+ if (['GET', 'HEAD'].includes(request.method) && !wantsJson(request))
181
+ return redirect(mount + '/account');
182
+ return jsonResponse(403, { error: 'Complete required account enrollment', restrictions: sessionPrincipal.restrictions });
183
+ }
184
+ if (sessionPrincipal.restrictions?.includes('verify-email') && (path === '/totp/begin' || path === '/totp/confirm' || path === '/second-factors' || path === '/passkeys/second-factor' || path.startsWith('/passkeys/register/')))
185
+ throw new AuthHttpError(403, 'Verify your email before enrolling an authenticator');
186
+ }
187
+ if (path === '/register' && request.method !== 'POST' && ['open', 'invite-only', 'waitlist'].includes(registrationMode)) {
188
+ const query = new URLSearchParams();
189
+ if (request.query.get('lang'))
190
+ query.set('lang', request.query.get('lang'));
191
+ const invitations = request.query.getAll('token');
192
+ if (invitations.length > 1 || (invitations[0] && !/^[A-Za-z0-9_-]{43}$/.test(invitations[0])))
193
+ throw new AuthHttpError(400, 'Invalid invitation');
194
+ if (registrationMode === 'invite-only' && invitations[0])
195
+ query.set('token', invitations[0]);
196
+ return redirect(mount + '/signup' + (query.size ? '?' + query : ''));
197
+ }
198
+ if (path === '/register' && request.method === 'POST' && service.getSecurityPolicy().requireEmailVerification)
199
+ throw new AuthHttpError(403, 'Complete verified signup first');
200
+ const secondFactorResult = await secondFactors.handle(request);
201
+ if (secondFactorResult)
202
+ return secondFactorResult;
203
+ const signupResult = await signup(request, presentation);
204
+ if (signupResult)
205
+ return signupResult;
206
+ const flowResult = await flows.handle(request);
207
+ if (flowResult)
208
+ return flowResult;
209
+ if (path === '/assets/passkeys.js' && ['GET', 'HEAD'].includes(request.method) && options.passkeys)
210
+ return { status: 200, headers: [['content-type', 'text/javascript; charset=utf-8'], ['cache-control', 'no-store'], ['x-content-type-options', 'nosniff']], body: new TextEncoder().encode(passkeyScript) };
211
+ if (!['GET', 'HEAD', 'POST'].includes(request.method))
212
+ return jsonResponse(405, { error: 'Method not allowed' }, [['allow', 'GET, HEAD, POST']]);
213
+ if (request.method !== 'POST') {
214
+ const { csrf, headers } = http.prepare(request);
215
+ if (path === '/csrf')
216
+ return jsonResponse(200, { csrf }, headers);
217
+ if (path === '/' || path === '/login')
218
+ return screen('Sign in', 'sign-in', { intro: '', form: m(form(mount + '/identify', csrf, formField('email', 'Email address', 'email', 'username'), 'Continue')), passkey: m(passkeyLogin(csrf)), providers: m(flows.buttons(csrf, false, text, presentation.locale, presentation)), linksLabel: text('Sign-in methods'), links: [...(registrationMode !== 'off' ? [{ href: mount + '/register', label: presentation.text('action.register') }] : []), ...(factorRecovery.enabled() ? [{ href: mount + '/recover-factor', label: presentation.text('recovery.lost') }] : []), ...(options.sendToken ? [{ href: mount + '/forgot-password', label: presentation.text('nav.forgotPassword') }] : []), ...(options.sendEmailCode ? [{ href: mount + '/email-code', label: presentation.text('copy.emailSignIn') }] : [])] }, 200, headers, options.passkeys ? mount + '/assets/passkeys.js' : undefined);
219
+ if (path === '/register') {
220
+ const invitations = request.query.getAll('token');
221
+ if (invitations.length > 1 || invitations.some(token => token.length > 512))
222
+ throw new AuthHttpError(400, 'Invalid invitation');
223
+ if (registrationMode === 'off')
224
+ throw new AuthHttpError(404, 'Not found');
225
+ return screen(registrationMode === 'waitlist' ? 'Request an account' : 'Create account', 'register', { form: m(form(mount + '/register', csrf, formField('email', 'Email address', 'email', 'username') + formField('password', 'Password (at least 15 characters)', 'password', 'new-password') + profileFields() + `<div hidden><label>${tr("copy.leaveEmpty")}<input name="website" tabindex="-1" autocomplete="off"></label></div>` + (registrationMode === 'invite-only' ? (invitations.length ? hidden('invitationToken', invitations[0]) : formField('invitationToken', 'Invitation token')) : ''), registrationMode === 'waitlist' ? 'Request account' : 'Create account')) }, 200, headers);
226
+ }
227
+ if (path === '/forgot-password') {
228
+ if (!options.sendToken)
229
+ throw new AuthHttpError(404, 'Not found');
230
+ return screen('Reset password', 'forgot-password', { intro: presentation.text('ux.resetIntro'), form: m(form(mount + '/forgot-password', csrf, formField('email', 'Email address', 'email', 'username'), 'Send reset link')) }, 200, headers);
231
+ }
232
+ if (path === '/email-code') {
233
+ if (!options.sendEmailCode)
234
+ throw new AuthHttpError(404, 'Not found');
235
+ const flows = request.query.getAll('flowId');
236
+ if (flows.length > 1 || flows.some(value => !/^[A-Za-z0-9_-]{43}$/.test(value)))
237
+ throw new AuthHttpError(400, 'Invalid email flow');
238
+ return screen('Sign in by email', 'email-code', { form: m(flows.length ? form(mount + '/email-code', csrf, hidden('flowId', flows[0]) + formField('code', 'Six-digit email code', 'text', 'one-time-code') + factors(), 'Sign in') : form(mount + '/send-email-code', csrf, formField('email', 'Email address', 'email', 'username'), 'Send sign-in code')) }, 200, headers);
239
+ }
240
+ if (path === '/verify-email-change' || path === '/cancel-email-change') {
241
+ const tokens = request.query.getAll('token');
242
+ if (tokens.length !== 1 || tokens[0].length > 512)
243
+ throw new AuthHttpError(400, 'A single token is required');
244
+ return screen(path === '/verify-email-change' ? 'Confirm new email after the 24-hour cooling period' : 'Cancel email change', 'confirm-token', { form: m(form(mount + path, csrf, hidden('token', tokens[0]), 'Confirm')) }, 200, headers);
245
+ }
246
+ if (path === '/cancel-deletion') {
247
+ const tokens = request.query.getAll('token');
248
+ if (tokens.length !== 1 || tokens[0].length > 512)
249
+ throw new AuthHttpError(400, 'A single token is required');
250
+ return screen('Cancel account deletion', 'confirm-token', { form: m(form(mount + path, csrf, hidden('token', tokens[0]), 'Keep my account')) }, 200, headers);
251
+ }
252
+ if (path === '/verify' || path === '/reset') {
253
+ const tokens = request.query.getAll('token');
254
+ if (tokens.length !== 1 || tokens[0].length > 512)
255
+ throw new AuthHttpError(400, 'A single token is required');
256
+ return screen(path === '/verify' ? 'Verify email' : 'Choose a new password', 'confirm-token', { form: m(form(mount + path, csrf, (path === '/verify' ? `<p>${tr("copy.confirmOnlyAnAccountYouCreatedVerificationConfirmsThisEmailAddressItDoesNotSetOrResetAPassword")}</p>` : '') + hidden('token', tokens[0]) + (path === '/reset' ? formField('password', 'New password', 'password', 'new-password') : ''), path === '/verify' ? 'Verify email' : 'Reset password')) }, 200, headers);
257
+ }
258
+ const current = await principal(request);
259
+ if (path === '/account') {
260
+ const user = await service.getUser(current.principal.id);
261
+ if (!user)
262
+ throw new AuthHttpError(401, 'Sign in required');
263
+ if (wantsJson(request))
264
+ return jsonResponse(200, { user, csrf, ...(current.principal.restrictions ? { restrictions: current.principal.restrictions } : {}), ...(current.principal.impersonatorId ? { impersonatorId: current.principal.impersonatorId } : {}) }, headers);
265
+ if (enrollmentRequired(current.principal)) {
266
+ const needsEmail = current.principal.restrictions.includes('verify-email');
267
+ const passkeyOffer = !needsEmail && service.getSecurityPolicy().allowPasskeySecondFactor && options.passkeys;
268
+ return screen('Complete account enrollment', 'enrollment', { status: presentation.text('copy.applicationAccessRemainsBlockedUntilAllRequiredEnrollmentStepsAreComplete'), email: user.email, heading: presentation.text(needsEmail ? 'copy.verifyYourEmailFirst' : 'copy.enrollAnAuthenticator'), form: needsEmail ? (options.sendToken ? m(form(mount + '/send-verification', csrf, '', 'Send verification email')) : null) : m(form(mount + '/totp/begin', csrf, '', 'Set up authenticator')), unavailable: needsEmail && !options.sendToken ? presentation.text('copy.emailDeliveryIsUnavailableContactTheSiteOperator') : null, passkeyHref: passkeyOffer ? mount + '/second-factors' : null, passkeyLabel: passkeyOffer ? text('Set up a passkey second factor') : null, stepUpHref: mount + '/step-up', stepUpLabel: presentation.text('page.stepUp'), stepUpHelp: presentation.text('copy.ifYourRecentSignInHasExpired'), signOut: m(form(mount + '/logout', csrf, '', 'Sign out')) }, 200, headers);
269
+ }
270
+ if (current.principal.impersonatorId)
271
+ return screen('Support impersonation', 'impersonation', { navigation: m(navigation), alert: presentation.text('copy.youAreViewingThisAccountAsASupportAdministratorAccountSecurityChangesAreDisabledEndImpersonationToSignInAsYour'), form: m(form(mount + '/logout', csrf, '', 'End impersonation')) }, 200, headers);
272
+ const section = (heading, content, danger = false) => ({ heading: text(heading), content: m(content), danger });
273
+ const profile = section('Profile', form(mount + '/profile', csrf, profileFields(), 'Update profile'));
274
+ const password = section('Password', form(mount + '/change-password', csrf, formField('currentPassword', 'Current password', 'password', 'current-password') + formField('password', 'New password', 'password', 'new-password') + factors(), 'Change password and sign out all sessions'));
275
+ const authenticator = section('Authenticator', user.totpEnabled ? `<details class="ui-disclosure"><summary>${tr('ux.disableAuthenticator')}</summary>` + form(mount + '/totp/disable', csrf, formField('password', 'Password (if configured)', 'password', 'current-password', false) + formField('code', 'Authenticator code', 'text', 'one-time-code', false) + (service.getSecurityPolicy().allowPasskeySecondFactor && options.passkeys ? secondFactorButton(mount, text) : ''), 'Disable authenticator') + '</details>' : form(mount + '/totp/begin', csrf, '', 'Set up authenticator'));
276
+ const email = options.sendToken ? section('Email address', form(mount + '/change-email', csrf, formField('email', 'New email address', 'email', 'email') + formField('password', 'Current password (if configured)', 'password', 'current-password', false) + factors(), 'Request email change (24-hour cooling period)')) : undefined;
277
+ const methods = passkeyButton('register', text) + flows.buttons(csrf, true, text, presentation.locale, presentation);
278
+ const data = section('Account data', form(mount + '/export', csrf, '', 'Export account data'));
279
+ const deletion = options.sendToken ? section('Delete account', form(mount + '/delete', csrf, `<p>${tr('message.deletionGrace', { days: service.getSecurityPolicy().deletionGraceMs / 86400000 })}</p>` + formField('confirmation', 'Type DELETE to confirm') + formField('password', 'Password (if configured)', 'password', 'current-password', false) + factors(), 'Schedule account deletion'), true) : undefined;
280
+ return screen('Your account', 'account', { navigation: m(navigation), overviewLabel: presentation.text('page.account'), email: user.email, signOut: m(form(mount + '/logout', csrf, '', 'Sign out')), state: presentation.text('message.accountState', { email: presentation.text(user.emailVerified ? 'state.verified' : 'state.unverified'), authenticator: presentation.text(user.totpEnabled ? 'state.enabled' : 'state.disabled') }), verification: m(options.sendToken && !user.emailVerified ? form(mount + '/send-verification', csrf, '', 'Send verification email') : ''), sections: [profile, password, authenticator, ...(email ? [email] : []), ...(methods ? [section('Sign-in methods', methods)] : []), data, ...(deletion ? [deletion] : [])] }, 200, headers, options.passkeys ? mount + '/assets/passkeys.js' : undefined);
281
+ }
282
+ if (path === '/second-factors') {
283
+ if (!service.getSecurityPolicy().allowPasskeySecondFactor || !options.passkeys)
284
+ throw new AuthHttpError(404, 'Not found');
285
+ const keys = await service.listPasskeys(current.principal.id);
286
+ const passkeys = keys.map(key => ({ id: key.id, secondFactor: key.secondFactor === true }));
287
+ if (wantsJson(request))
288
+ return jsonResponse(200, { passkeys, csrf }, headers);
289
+ return screen('Second factors', 'second-factors', { navigation: m(navigation), csrf: m(csrfField(csrf)), intro: text('A second-factor passkey must be different from the passkey used for primary sign-in.'), register: m(passkeyButton('register', text)), passkeys: passkeys.map(key => ({ id: key.id, state: text(key.secondFactor ? 'Enabled' : 'Disabled'), form: m(form(mount + '/passkeys/second-factor', csrf, hidden('credentialId', key.id) + hidden('enabled', key.secondFactor ? 'false' : 'true') + (key.secondFactor ? '' : secondFactorButton(mount, text)), key.secondFactor ? 'Disable passkey second factor' : 'Enable passkey second factor')) })) }, 200, headers, mount + '/assets/passkeys.js');
290
+ }
291
+ if (path === '/trusted-devices') {
292
+ if (!service.getSecurityPolicy().trustedDeviceTtlMs)
293
+ throw new AuthHttpError(404, 'Not found');
294
+ const devices = await service.listTrustedDevices(current.token);
295
+ if (wantsJson(request))
296
+ return jsonResponse(200, { devices, csrf }, headers);
297
+ return screen('Remembered devices', 'trusted-devices', { navigation: m(navigation), intro: text('Remembering a device requires a real second factor. Sensitive actions still require fresh verification.'), form: m(form(mount + '/trusted-devices/remember', csrf, formField('label', 'Device label', 'text', 'off', false), 'Remember this device')), devices: devices.map(device => ({ label: device.label, expires: new Date(device.expires).toISOString(), form: m(form(mount + '/trusted-devices/revoke', csrf, hidden('deviceId', device.id), 'Forget device')) })) }, 200, headers);
298
+ }
299
+ if (path === '/sessions') {
300
+ const sessions = await service.listSessions(current.principal.id);
301
+ if (wantsJson(request))
302
+ return jsonResponse(200, { sessions, csrf }, headers);
303
+ return screen('Your sessions', 'sessions', { navigation: m(navigation), sessions: sessions.map(session => ({ summary: presentation.text('message.sessionStarted', { created: new Date(session.created).toISOString(), expires: new Date(session.expires).toISOString() }), form: m(form(mount + '/revoke-session', csrf, hidden('sessionId', session.id), 'Revoke this session')) })), form: m(form(mount + '/revoke-sessions', csrf, '', 'Sign out all sessions')) }, 200, headers);
304
+ }
305
+ if (path === '/methods') {
306
+ const methods = await service.exportAccount(current.token);
307
+ if (wantsJson(request))
308
+ return jsonResponse(200, { passkeys: methods.passkeys, identities: methods.identities, csrf }, headers);
309
+ return screen('Sign-in methods', 'methods', { navigation: m(navigation), passkeysHeading: presentation.text('copy.passkeys'), passkeys: methods.passkeys.map(key => ({ form: m(form(mount + '/passkeys/remove', csrf, hidden('credentialId', key.id) + `<p>${escapeHtml(key.id)}</p>`, 'Remove passkey')) })), providersHeading: presentation.text('copy.linkedProviders'), identities: methods.identities.map(identity => ({ form: m(form(mount + '/providers/unlink', csrf, hidden('provider', identity.provider) + hidden('subject', identity.subject) + `<p>${escapeHtml(identity.provider)}: ${escapeHtml(identity.subject)}</p>`, 'Unlink provider')) })), note: presentation.text('copy.theLastSignInMethodCannotBeRemoved') }, 200, headers);
310
+ }
311
+ if (path === '/step-up')
312
+ return screen('Confirm your identity', 'step-up', { navigation: m(navigation), form: m(form(mount + '/step-up', csrf, formField('password', 'Password', 'password', 'current-password') + factors(), 'Confirm identity')), passkey: m(passkeyButton('step-up', text)) }, 200, headers, options.passkeys ? mount + '/assets/passkeys.js' : undefined);
313
+ throw new AuthHttpError(404, 'Not found');
314
+ }
315
+ const fields = readFields(request, ['email', 'password', 'currentPassword', 'confirmation', 'invitationToken', 'totp', 'recoveryCode', 'token', 'code', 'sessionId', 'credentialId', 'provider', 'subject', 'flowId', 'displayName', 'locale', 'termsAccepted', 'website', 'secondFactorToken', 'enabled', 'deviceId', 'label', ...metadataFields.map(([name]) => 'meta.' + name)]);
316
+ http.verify(request, fields);
317
+ submittedEmail = fields.email && fields.email.length <= 320 ? fields.email : undefined;
318
+ const secondFactor = fields.secondFactorToken ? { secondFactor: secondFactors.proof(request, fields.secondFactorToken) } : {};
319
+ if (path === '/identify') {
320
+ const email = fields.email || '';
321
+ if (email.length > 320 || !email.includes('@'))
322
+ throw new AuthHttpError(400, 'Enter an email address');
323
+ return passwordPage(email, fields.csrf || '');
324
+ }
325
+ if (path === '/login' || path === '/register') {
326
+ if (path === '/register' && isHoneypotFilled(fields.website))
327
+ return jsonResponse(202, { message: presentation.textSource('Registration request received.') });
328
+ if (path === '/register' && registrationMode === 'off')
329
+ throw new AuthHttpError(404, 'Not found');
330
+ if (path === '/register' && registrationMode === 'waitlist') {
331
+ await service.requestRegistration({ email: fields.email || '', password: fields.password || '', profile: profileInput(fields) });
332
+ return jsonResponse(202, { message: presentation.textSource('Registration request received.') });
333
+ }
334
+ const device = http.device(request);
335
+ const result = path === '/register' ? await service.register({ email: fields.email || '', password: fields.password || '', device: { id: device.id, label: device.label }, profile: profileInput(fields), ...(fields.invitationToken ? { invitationToken: fields.invitationToken } : {}) }) : await service.login({ ...trusted(request), email: fields.email || '', password: fields.password || '', device: { id: device.id, label: device.label }, ...(fields.totp ? { totp: fields.totp } : {}), ...(fields.recoveryCode ? { recoveryCode: fields.recoveryCode } : {}), ...secondFactor });
336
+ if (result.newDevice)
337
+ await notice(result.user.email, 'new-device', noticeLocale(request, result.user));
338
+ return wantsJson(request) ? jsonResponse(path === '/register' ? 201 : 200, { user: result.user, csrf: http.token(result.token), ...(result.principal.restrictions ? { restrictions: result.principal.restrictions } : {}) }, [...http.sessionHeaders(result.token), ...device.headers]) : redirect(mount + '/account', [...http.sessionHeaders(result.token), ...device.headers]);
339
+ }
340
+ if (path === '/send-email-code') {
341
+ if (!options.sendEmailCode)
342
+ throw new AuthHttpError(404, 'Not found');
343
+ const email = fields.email || '', issued = await service.issueEmailCode({ email });
344
+ if (issued.code) {
345
+ const controller = new AbortController();
346
+ let timer;
347
+ try {
348
+ await Promise.race([options.sendEmailCode({ email, flowId: issued.flowId, code: issued.code, locale: presentation.locale, signal: controller.signal }), new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(new Error('timeout')); }, 5000); })]);
349
+ }
350
+ catch { }
351
+ finally {
352
+ if (timer)
353
+ clearTimeout(timer);
354
+ }
355
+ }
356
+ return wantsJson(request) ? jsonResponse(200, { message: presentation.textSource('If this account is eligible, a sign-in code will be sent.'), flowId: issued.flowId }) : screen('Enter your email code', 'email-code', { form: m(form(mount + '/email-code', fields.csrf || '', hidden('flowId', issued.flowId) + formField('code', 'Six-digit email code', 'text', 'one-time-code') + factors(), 'Sign in')) });
357
+ }
358
+ if (path === '/email-code') {
359
+ if (!options.sendEmailCode)
360
+ throw new AuthHttpError(404, 'Not found');
361
+ const device = http.device(request);
362
+ const result = await service.consumeEmailCode({ ...trusted(request), device: { id: device.id, label: device.label }, flowId: fields.flowId || '', code: fields.code || '', ...(fields.totp ? { totp: fields.totp } : {}), ...(fields.recoveryCode ? { recoveryCode: fields.recoveryCode } : {}), ...secondFactor });
363
+ if (result.newDevice)
364
+ await notice(result.user.email, 'new-device', noticeLocale(request, result.user));
365
+ return wantsJson(request) ? jsonResponse(200, { user: result.user, csrf: http.token(result.token), ...(result.principal.restrictions ? { restrictions: result.principal.restrictions } : {}) }, [...http.sessionHeaders(result.token), ...device.headers]) : redirect(mount + '/account', [...http.sessionHeaders(result.token), ...device.headers]);
366
+ }
367
+ if (path === '/forgot-password') {
368
+ await notify(fields.email || '', 'reset-password', presentation.locale);
369
+ return wantsJson(request) ? jsonResponse(200, { message: presentation.textSource('If this account is eligible, a reset message will be sent.') }) : screen('Check your email', 'status', { alert: false, message: presentation.text('ux.resetSent'), href: mount + '/login', label: presentation.text('ux.backSignIn') });
370
+ }
371
+ if (path === '/verify-email-change') {
372
+ const changed = await service.confirmEmailChange(fields.token || '');
373
+ await notice(changed.email, 'email-changed', noticeLocale(request, changed));
374
+ return wantsJson(request) ? jsonResponse(200, { changed: true }, http.clearSession()) : redirect(mount + '/login', http.clearSession());
375
+ }
376
+ if (path === '/cancel-email-change') {
377
+ await service.cancelEmailChange(fields.token || '');
378
+ return completed({ cancelled: true }, 'Request cancelled', 'Your request has been cancelled.');
379
+ }
380
+ if (path === '/cancel-deletion') {
381
+ await service.cancelDeletion(fields.token || '');
382
+ return completed({ cancelled: true }, 'Request cancelled', 'Your request has been cancelled.');
383
+ }
384
+ if (path === '/verify') {
385
+ await service.consumeVerification(fields.token || '');
386
+ if (service.getSecurityPolicy().requireEmailVerification)
387
+ return wantsJson(request) ? jsonResponse(200, { verified: true, signInRequired: true }, http.clearSession()) : redirect(mount + '/login', http.clearSession());
388
+ return completed({ verified: true }, 'Email verified', 'Your email address has been verified.');
389
+ }
390
+ if (path === '/reset') {
391
+ const changed = await service.resetPassword({ token: fields.token || '', password: fields.password || '' });
392
+ await notice(changed.email, 'password-changed', noticeLocale(request, changed));
393
+ return wantsJson(request) ? jsonResponse(200, { reset: true }, http.clearSession()) : screen('Password updated', 'status', { alert: false, message: presentation.text('ux.passwordUpdated'), href: mount + '/login', label: presentation.text('action.signIn') }, 200, http.clearSession());
394
+ }
395
+ const current = await principal(request);
396
+ if (current.principal.impersonatorId && path !== '/logout')
397
+ throw new AuthHttpError(403, 'Security changes are disabled during impersonation');
398
+ if (path === '/passkeys/second-factor') {
399
+ if (fields.enabled !== 'true' && fields.enabled !== 'false')
400
+ throw new AuthHttpError(400, 'Invalid factor setting');
401
+ await service.setPasskeySecondFactor({ token: current.token, credentialId: fields.credentialId || '', enabled: fields.enabled === 'true', ...secondFactor });
402
+ return wantsJson(request) ? jsonResponse(200, { updated: true }) : redirect(mount + '/second-factors');
403
+ }
404
+ if (path === '/trusted-devices/remember') {
405
+ const device = await service.rememberDevice({ token: current.token, ...(fields.label ? { label: fields.label } : {}) });
406
+ const headers = [['set-cookie', http.setCookie(trustedCookie, device.token, Math.max(0, Math.min(2592000, Math.floor((device.expires - Date.now()) / 1000))))]];
407
+ return wantsJson(request) ? jsonResponse(200, { remembered: true, expires: device.expires }, headers) : redirect(mount + '/trusted-devices', headers);
408
+ }
409
+ if (path === '/trusted-devices/revoke') {
410
+ await service.revokeTrustedDevice({ token: current.token, deviceId: fields.deviceId || '' });
411
+ const headers = [['set-cookie', http.setCookie(trustedCookie, '', 0)]];
412
+ return wantsJson(request) ? jsonResponse(200, { revoked: true }, headers) : redirect(mount + '/trusted-devices', headers);
413
+ }
414
+ if (path === '/passkeys/remove') {
415
+ await service.removePasskey({ token: current.token, credentialId: fields.credentialId || '' });
416
+ return wantsJson(request) ? jsonResponse(200, { removed: true }) : redirect(mount + '/methods');
417
+ }
418
+ if (path === '/providers/unlink') {
419
+ await service.unlinkExternal({ token: current.token, provider: fields.provider || '', subject: fields.subject || '' });
420
+ return wantsJson(request) ? jsonResponse(200, { unlinked: true }) : redirect(mount + '/methods');
421
+ }
422
+ if (path === '/change-email') {
423
+ if (!options.sendToken)
424
+ throw new AuthHttpError(503, 'Email delivery required');
425
+ const change = await service.requestEmailChange({ token: current.token, email: fields.email || '', ...(fields.password ? { password: fields.password } : {}), ...(fields.totp ? { totp: fields.totp } : {}), ...(fields.recoveryCode ? { recoveryCode: fields.recoveryCode } : {}), ...secondFactor });
426
+ try {
427
+ await deliver(change.oldEmail, change.cancelToken, 'cancel-email-change', true, presentation.locale);
428
+ await deliver(change.newEmail, change.verificationToken, 'verify-email-change', true, presentation.locale);
429
+ }
430
+ catch {
431
+ await service.cancelEmailChange(change.cancelToken);
432
+ throw new AuthHttpError(503, 'Email delivery failed; change cancelled');
433
+ }
434
+ return completed({ requested: true, activateAfter: change.activateAfter }, 'Check your email', 'Check your new email for confirmation instructions. The change can only finish after the 24-hour cooling period.');
435
+ }
436
+ if (path === '/revoke-session') {
437
+ await service.revokeSession({ token: current.token, sessionId: fields.sessionId || '' });
438
+ return completed({ revoked: true }, 'Session signed out', 'The selected session has been signed out.');
439
+ }
440
+ if (path === '/profile') {
441
+ const profile = await service.updateProfile({ token: current.token, profile: profileInput(fields) });
442
+ return wantsJson(request) ? jsonResponse(200, { profile }) : redirect(mount + '/account');
443
+ }
444
+ if (path === '/export')
445
+ return jsonResponse(200, await service.exportAccount(current.token), [['content-disposition', 'attachment; filename="account.json"']]);
446
+ if (path === '/change-password') {
447
+ await service.changePassword({ token: current.token, currentPassword: fields.currentPassword || '', password: fields.password || '', ...(fields.totp ? { totp: fields.totp } : {}), ...(fields.recoveryCode ? { recoveryCode: fields.recoveryCode } : {}), ...secondFactor });
448
+ await notice(current.principal.email, 'password-changed', presentation.locale);
449
+ return wantsJson(request) ? jsonResponse(200, { changed: true }, http.clearSession()) : redirect(mount + '/login', http.clearSession());
450
+ }
451
+ if (path === '/delete') {
452
+ if (!options.sendToken)
453
+ throw new AuthHttpError(503, 'Email delivery is required for deletion recovery');
454
+ if (fields.confirmation !== 'DELETE')
455
+ throw new AuthHttpError(400, 'Deletion confirmation required');
456
+ const result = await service.deleteAccount({ token: current.token, ...(fields.password ? { password: fields.password } : {}), ...(fields.totp ? { totp: fields.totp } : {}), ...(fields.recoveryCode ? { recoveryCode: fields.recoveryCode } : {}), ...secondFactor });
457
+ await deliver(current.principal.email, result.cancelToken, 'cancel-deletion', false, presentation.locale);
458
+ return completed({ deletionScheduled: true, deleteAfter: result.deleteAfter, cancellationDays: service.getSecurityPolicy().deletionGraceMs / 86400000 }, 'Account deletion scheduled', 'Your account deletion is scheduled. Check your email for cancellation instructions if you change your mind.', http.clearSession(), '/login');
459
+ }
460
+ if (path === '/logout') {
461
+ await service.logout(current.token);
462
+ return wantsJson(request) ? jsonResponse(200, { signedOut: true }, http.clearSession()) : redirect(mount + '/login', http.clearSession());
463
+ }
464
+ if (path === '/revoke-sessions') {
465
+ await service.revokeSessions(current.principal.id);
466
+ return wantsJson(request) ? jsonResponse(200, { signedOut: true }, http.clearSession()) : redirect(mount + '/login', http.clearSession());
467
+ }
468
+ if (path === '/step-up') {
469
+ const result = await service.stepUp({ token: current.token, password: fields.password || '', ...(fields.totp ? { totp: fields.totp } : {}), ...(fields.recoveryCode ? { recoveryCode: fields.recoveryCode } : {}), ...secondFactor });
470
+ return wantsJson(request) ? jsonResponse(200, { confirmed: true, csrf: http.token(result.token), ...(result.principal.restrictions ? { restrictions: result.principal.restrictions } : {}) }, http.sessionHeaders(result.token)) : redirect(mount + '/account', http.sessionHeaders(result.token));
471
+ }
472
+ if (path === '/send-verification') {
473
+ await notify(current.principal.email, 'verify-email', presentation.locale);
474
+ return completed({ message: presentation.textSource('If this account is eligible, a verification message will be sent.') }, 'Check your email', 'If this account is eligible, a verification message will be sent.');
475
+ }
476
+ if (path === '/totp/begin') {
477
+ const enrollment = await service.beginTotp(current.token);
478
+ if (wantsJson(request))
479
+ return jsonResponse(200, enrollment);
480
+ return screen('Set up authenticator', 'totp-setup', { intro: presentation.text('copy.addThisKeyToYourAuthenticator'), secret: enrollment.secret, form: m(form(mount + '/totp/confirm', http.token(current.token), formField('code', 'Authenticator code', 'text', 'one-time-code'), 'Confirm authenticator')) });
481
+ }
482
+ if (path === '/totp/confirm') {
483
+ const enrolled = await service.confirmTotp({ token: current.token, code: fields.code || '' });
484
+ return wantsJson(request) ? jsonResponse(200, enrolled) : screen('Save your recovery codes', 'recovery-codes', { intro: presentation.text('copy.storeTheseCodesSecurelyEachCanBeUsedOnce'), codes: enrolled.recoveryCodes, href: mount + '/account', label: presentation.text('copy.continueToYourAccount') });
485
+ }
486
+ if (path === '/totp/disable') {
487
+ await service.disableTotp({ token: current.token, password: fields.password || '', code: fields.code || '', ...secondFactor });
488
+ return wantsJson(request) ? jsonResponse(200, { disabled: true }) : redirect(mount + '/account');
489
+ }
490
+ throw new AuthHttpError(404, 'Not found');
491
+ }
492
+ catch (error) {
493
+ const path = request.path.slice(mount.length);
494
+ if (!wantsJson(request) && path === '/login' && submittedEmail && error instanceof Error && 'status' in error && error.status === 401) {
495
+ return passwordPage(submittedEmail, http.prepare(request).csrf, true);
496
+ }
497
+ const retryPath = error instanceof Error && 'status' in error && error.status === 401 ? '/login' : path.startsWith('/signup') ? '/signup' : ['/login', '/identify', '/forgot-password', '/recover-factor'].includes(path) ? path === '/identify' ? '/login' : path : '/account';
498
+ return httpFailure(error, request, presentation, { href: mount + retryPath + '?lang=' + encodeURIComponent(presentation.locale), label: text('Try again') }, options.ui);
499
+ }
500
+ },
501
+ };
502
+ } };
503
+ }
@@ -0,0 +1,18 @@
1
+ export interface BackupOptions {
2
+ database: string;
3
+ destination: string;
4
+ projectRoot: string;
5
+ }
6
+ export interface RestoreOptions {
7
+ backup: string;
8
+ destination: string;
9
+ projectRoot: string;
10
+ }
11
+ export interface BackupResult {
12
+ format: 'urlcode-auth-sqlite-v1';
13
+ bytes: number;
14
+ }
15
+ /** Consistent online SQLite snapshot, including committed WAL pages. Never copies a live database file. */
16
+ export declare function createBackup(options: BackupOptions): Promise<BackupResult>;
17
+ /** Restores to a new isolated path only. Operator retains matching keys and static configuration separately. */
18
+ export declare function restoreBackup(options: RestoreOptions): Promise<BackupResult>;