@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,30 @@
1
+ import type { PresentationContext } from './presentation.ts';
2
+ import type { Presentation } from './presentation.ts';
3
+ import type { RegistrationInput } from './registration.ts';
4
+ import type { ExtensionRequest } from '@jimhoyd/urlcode/extensions';
5
+ import type { AuthService, AuthSessionResult } from './auth-core.ts';
6
+ import type { OidcProvider } from './oidc.ts';
7
+ import type { PasskeyProvider } from './passkeys.ts';
8
+ import { AuthHttp } from './auth-ui.ts';
9
+ import type { AuthHttpResponse, UiHost } from './auth-ui.ts';
10
+ export interface AuthFlowOptions {
11
+ service: AuthService;
12
+ presentation?: Presentation;
13
+ ui?: UiHost;
14
+ onSession?: (request: ExtensionRequest, result: AuthSessionResult) => Promise<[
15
+ string,
16
+ string
17
+ ][]>;
18
+ providers?: Record<string, OidcProvider>;
19
+ passkeys?: PasskeyProvider;
20
+ enrollment?: {
21
+ required: boolean;
22
+ fields: (presentation?: PresentationContext) => string;
23
+ read: (fields: Record<string, string>) => RegistrationInput;
24
+ names: string[];
25
+ };
26
+ }
27
+ export declare function createAuthFlows(options: AuthFlowOptions, http: AuthHttp, mount: string, registration: boolean): {
28
+ buttons(csrf: string, link?: boolean, text?: (value: string) => string, locale?: string, presentation?: PresentationContext): string;
29
+ handle(request: ExtensionRequest): Promise<AuthHttpResponse | undefined>;
30
+ };
@@ -0,0 +1,228 @@
1
+ import { createSecondFactorFlows } from "./second-factor-flows.js";
2
+ import { createPresentation } from "./presentation.js";
3
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
4
+ import { AuthHttp, AuthHttpError, csrfField, escapeHtml, formField as baseField, jsonResponse, readFields, screenResponse, wantsJson, secondFactorButton } from "./auth-ui.js";
5
+ import { Markup } from '@jimhoyd/urlcode-ui';
6
+ const trustedCookie = '__Host-urlcode-trusted-device';
7
+ const defaultPresentation = createPresentation();
8
+ const id = () => randomBytes(32).toString('base64url');
9
+ const hash = (value) => createHash('sha256').update(value).digest('hex');
10
+ function record(value) {
11
+ if (!value || typeof value !== 'object' || Array.isArray(value))
12
+ throw new AuthHttpError(400, 'Invalid authentication flow');
13
+ return value;
14
+ }
15
+ function checkBinding(data, binding) {
16
+ const expected = typeof data.browserHash === 'string' ? data.browserHash : '';
17
+ if (!binding || !/^[a-f0-9]{64}$/.test(expected) || !timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(hash(binding), 'hex')))
18
+ throw new AuthHttpError(403, 'Authentication flow belongs to another browser');
19
+ }
20
+ function complex(request) {
21
+ if (request.body.byteLength > 16384)
22
+ throw new AuthHttpError(413, 'Request body too large');
23
+ if (request.headers.get('content-type')?.split(';')[0] !== 'application/json')
24
+ throw new AuthHttpError(415, 'JSON required');
25
+ try {
26
+ return record(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(request.body)));
27
+ }
28
+ catch {
29
+ throw new AuthHttpError(400, 'Invalid authentication payload');
30
+ }
31
+ }
32
+ function fresh(authenticatedAt) {
33
+ if (Date.now() - authenticatedAt > 300000)
34
+ throw new AuthHttpError(403, 'Confirm your identity first');
35
+ }
36
+ export function createAuthFlows(options, http, mount, registration) {
37
+ const service = options.service, providers = options.providers || {}, flowCookie = '__Host-urlcode-oidc';
38
+ const secondFactors = createSecondFactorFlows(options, http, mount);
39
+ const trusted = (request) => { const token = service.getSecurityPolicy().trustedDeviceTtlMs ? http.cookie(request, trustedCookie) : undefined; return token ? { trustedDevice: token } : {}; };
40
+ if (Object.keys(providers).length > 16 || Object.keys(providers).some(name => !/^[a-z][a-z0-9-]{0,31}$/.test(name)))
41
+ throw new Error('Invalid provider names');
42
+ const cookie = (value, maxAge = 600) => ['set-cookie', `${flowCookie}=${value}; Path=/; Secure; HttpOnly; SameSite=None; Max-Age=${maxAge}`];
43
+ const finish = async (request, result) => { const headers = options.onSession ? await options.onSession(request, result) : []; return wantsJson(request) ? jsonResponse(200, { user: result.user, csrf: http.token(result.token), ...(result.principal.restrictions ? { restrictions: result.principal.restrictions } : {}) }, [...http.sessionHeaders(result.token), cookie('', 0), ...headers]) : jsonResponse(303, { redirect: mount + '/account' }, [['location', mount + '/account'], ...http.sessionHeaders(result.token), cookie('', 0), ...headers]); };
44
+ return {
45
+ buttons(csrf, link = false, text = value => value, locale, presentation) { return Object.keys(providers).map(name => `<form method="post" action="${escapeHtml(mount + '/providers/' + name + (link ? '/link' : '/start') + (locale ? '?lang=' + encodeURIComponent(locale) : ''))}">${csrfField(csrf)}<button type="submit">${escapeHtml((presentation ?? defaultPresentation.resolve()).text(link ? 'provider.link' : 'provider.signIn', { provider: name }))}</button></form>`).join(''); },
46
+ async handle(request) {
47
+ let presentation = (options.presentation ?? defaultPresentation).resolve({ ...(request.query.get('lang') ? { queryLocale: request.query.get('lang') } : {}), ...(request.headers.get('accept-language') ? { acceptLanguage: request.headers.get('accept-language') } : {}) });
48
+ const tr = (key, values) => escapeHtml(presentation.text(key, values));
49
+ const formScreen = (title, name, form, headers, scriptPath) => screenResponse(title, { name: 'auth/' + name, view: { form: new Markup(form) } }, { status: 200, headers, scriptPath, presentation, layout: 'compact', ui: options.ui });
50
+ const formField = (name, label, type = 'text', autocomplete = 'off', required = true) => baseField(name, presentation?.textSource(label) ?? label, type, autocomplete, required);
51
+ const path = request.path.slice(mount.length), match = /^\/providers\/([a-z][a-z0-9-]{0,31})\/(start|link|callback)$/.exec(path);
52
+ if (match) {
53
+ const name = match[1], operation = match[2], provider = providers[name];
54
+ if (!provider)
55
+ throw new AuthHttpError(404, 'Not found');
56
+ if (operation !== 'callback') {
57
+ if (request.method !== 'POST')
58
+ throw new AuthHttpError(405, 'POST required');
59
+ const fields = readFields(request, []);
60
+ http.verify(request, fields);
61
+ let actorToken;
62
+ if (operation === 'link') {
63
+ actorToken = http.session(request);
64
+ const actor = actorToken ? await service.authenticate(actorToken) : null;
65
+ if (!actor)
66
+ throw new AuthHttpError(401, 'Sign in required');
67
+ fresh(actor.authenticatedAt);
68
+ }
69
+ const started = await provider.start(), browser = id(), destination = new URL(started.url);
70
+ if (destination.protocol !== 'https:' || destination.username || destination.password)
71
+ throw new AuthHttpError(502, 'Invalid provider response');
72
+ await service.putFlow({ id: started.flow.state, kind: 'oidc', expires: Date.now() + 600000, data: { name, locale: presentation.locale, flow: started.flow, browserHash: hash(browser), ...(actorToken ? { actorToken } : {}) } });
73
+ return jsonResponse(303, { redirect: destination.href }, [['location', destination.href], cookie(browser)]);
74
+ }
75
+ if (!['GET', 'POST'].includes(request.method))
76
+ throw new AuthHttpError(405, 'GET or POST required');
77
+ if (request.body.byteLength > 16384)
78
+ throw new AuthHttpError(413, 'Request body too large');
79
+ const callback = new URL(request.target, http.origin), parameters = request.method === 'POST' ? new URLSearchParams(new TextDecoder('utf-8', { fatal: true }).decode(request.body)) : callback.searchParams;
80
+ if (request.method === 'POST' && request.headers.get('content-type')?.split(';')[0] !== 'application/x-www-form-urlencoded')
81
+ throw new AuthHttpError(415, 'Form callback required');
82
+ const states = parameters.getAll('state');
83
+ if (states.length !== 1 || states[0].length > 256)
84
+ throw new AuthHttpError(400, 'Invalid provider state');
85
+ const data = record(await service.consumeFlow(states[0], 'oidc'));
86
+ if (typeof data.locale === 'string')
87
+ presentation = (options.presentation ?? defaultPresentation).resolve({ queryLocale: data.locale });
88
+ checkBinding(data, http.cookie(request, flowCookie));
89
+ if (data.name !== name)
90
+ throw new AuthHttpError(400, 'Provider flow mismatch');
91
+ const identity = await provider.complete(request.method === 'POST' ? new Request(callback, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new Uint8Array(request.body).buffer }) : callback, data.flow);
92
+ let issuer;
93
+ try {
94
+ issuer = new URL(identity.issuer);
95
+ }
96
+ catch {
97
+ throw new AuthHttpError(401, 'Invalid identity issuer');
98
+ }
99
+ if (issuer.protocol !== 'https:' || issuer.username || issuer.password || identity.issuer.length > 2048)
100
+ throw new AuthHttpError(401, 'Invalid identity issuer');
101
+ const identityProvider = 'oidc-' + createHash('sha256').update(identity.issuer).digest('hex').slice(0, 56);
102
+ if (typeof data.actorToken === 'string') {
103
+ await service.linkExternal({ actorToken: data.actorToken, provider: identityProvider, subject: identity.subject });
104
+ return jsonResponse(303, { linked: true }, [['location', mount + '/account'], cookie('', 0), ...http.sessionHeaders(data.actorToken)]);
105
+ }
106
+ let externalProof = await service.getExternalProof(identityProvider, identity.subject), user = externalProof?.user;
107
+ if (user?.profile?.locale)
108
+ presentation = (options.presentation ?? defaultPresentation).resolve({ accountLocale: user.profile.locale, queryLocale: presentation.locale });
109
+ if (!user) {
110
+ if (!registration || !identity.email || !identity.emailVerified)
111
+ throw new AuthHttpError(403, 'An existing linked account is required');
112
+ if (options.enrollment?.required) {
113
+ const enrollment = id();
114
+ await service.putFlow({ id: enrollment, kind: 'oidc-enrollment', expires: Date.now() + 600000, data: { email: identity.email, provider: identityProvider, subject: identity.subject, browserHash: data.browserHash, locale: presentation.locale } });
115
+ const browser = http.prepare(request);
116
+ return formScreen('Complete your account', 'provider-enroll', `<form method="post" action="${escapeHtml(mount + '/providers/enroll?lang=' + encodeURIComponent(presentation.locale))}">${csrfField(browser.csrf)}<input type="hidden" name="flowId" value="${escapeHtml(enrollment)}">${options.enrollment.fields(presentation)}<button type="submit">${tr("action.register")}</button></form>`, browser.headers);
117
+ }
118
+ user = await service.createExternalAccount({ email: identity.email, emailVerified: true, provider: identityProvider, subject: identity.subject });
119
+ externalProof = await service.getExternalProof(identityProvider, identity.subject);
120
+ }
121
+ if (!externalProof || externalProof.user.id !== user.id)
122
+ throw new AuthHttpError(401, 'Identity changed during sign in');
123
+ if (user.totpEnabled || user.passkeyMfaEnabled) {
124
+ const extraHeaders = [];
125
+ if (trusted(request).trustedDevice) {
126
+ try {
127
+ return finish(request, await service.issueSession(user.id, { device: http.device(request), ...trusted(request), method: 'oidc', proof: externalProof.proof }));
128
+ }
129
+ catch (error) {
130
+ if (!(error instanceof Error) || !('code' in error) || error.code !== 'invalid_trusted_device')
131
+ throw error;
132
+ extraHeaders.push(['set-cookie', http.setCookie(trustedCookie, '', 0)]);
133
+ }
134
+ }
135
+ const pending = id();
136
+ await service.putFlow({ id: pending, kind: 'oidc-mfa', expires: Date.now() + 300000, data: { accountId: user.id, proof: externalProof.proof, browserHash: data.browserHash, locale: presentation.locale } });
137
+ const browser = http.prepare(request);
138
+ return formScreen('Confirm second factor', 'provider-second-factor', `<form method="post" action="${escapeHtml(mount + '/providers/complete?lang=' + encodeURIComponent(presentation.locale))}">${csrfField(browser.csrf)}<input type="hidden" name="flowId" value="${escapeHtml(pending)}">${formField('totp', 'Authenticator code', 'text', 'one-time-code', false)}${formField('recoveryCode', 'Recovery code (instead of authenticator code)', 'text', 'off', false)}${service.getSecurityPolicy().allowPasskeySecondFactor && options.passkeys ? secondFactorButton(mount, value => presentation.textSource(value)) : ''}<button type="submit">${tr("action.completeSignIn")}</button></form>`, [...browser.headers, ...extraHeaders], options.passkeys ? mount + '/assets/passkeys.js' : undefined);
139
+ }
140
+ return finish(request, await service.issueSession(user.id, { device: http.device(request), ...trusted(request), method: 'oidc', proof: externalProof.proof }));
141
+ }
142
+ if (path === '/providers/enroll') {
143
+ if (request.method !== 'POST' || !registration || !options.enrollment)
144
+ throw new AuthHttpError(404, 'Not found');
145
+ const fields = readFields(request, ['flowId', ...options.enrollment.names]);
146
+ http.verify(request, fields);
147
+ const data = record(await service.consumeFlow(fields.flowId || '', 'oidc-enrollment'));
148
+ checkBinding(data, http.cookie(request, flowCookie));
149
+ if (typeof data.email !== 'string' || typeof data.provider !== 'string' || typeof data.subject !== 'string')
150
+ throw new AuthHttpError(400, 'Invalid enrollment');
151
+ const user = await service.createExternalAccount({ email: data.email, emailVerified: true, provider: data.provider, subject: data.subject, profile: options.enrollment.read(fields) });
152
+ const externalProof = await service.getExternalProof(data.provider, data.subject);
153
+ if (!externalProof || externalProof.user.id !== user.id)
154
+ throw new AuthHttpError(401, 'Identity changed during enrollment');
155
+ return finish(request, await service.issueSession(user.id, { device: http.device(request), ...trusted(request), method: 'oidc', proof: externalProof.proof }));
156
+ }
157
+ if (path === '/providers/complete') {
158
+ if (request.method !== 'POST')
159
+ throw new AuthHttpError(405, 'POST required');
160
+ const fields = readFields(request, ['flowId', 'totp', 'recoveryCode', 'secondFactorToken']);
161
+ http.verify(request, fields);
162
+ const data = record(await service.consumeFlow(fields.flowId || '', 'oidc-mfa'));
163
+ checkBinding(data, http.cookie(request, flowCookie));
164
+ if (typeof data.accountId !== 'string')
165
+ throw new AuthHttpError(400, 'Invalid authentication flow');
166
+ return finish(request, await service.issueSession(data.accountId, { device: http.device(request), method: 'oidc', proof: record(data.proof), ...(fields.totp ? { totp: fields.totp } : {}), ...(fields.recoveryCode ? { recoveryCode: fields.recoveryCode } : {}), ...(fields.secondFactorToken ? { secondFactor: secondFactors.proof(request, fields.secondFactorToken) } : {}), ...trusted(request) }));
167
+ }
168
+ const ceremony = /^\/passkeys\/(register|login|step-up)\/(options|verify)$/.exec(path);
169
+ if (!ceremony)
170
+ return undefined;
171
+ if (!options.passkeys)
172
+ throw new AuthHttpError(404, 'Not found');
173
+ if (request.method !== 'POST')
174
+ throw new AuthHttpError(405, 'POST required');
175
+ const body = complex(request);
176
+ if (Object.keys(body).some(key => !['csrf', 'flowId', 'response', 'totp', 'recoveryCode', 'secondFactorToken'].includes(key)))
177
+ throw new AuthHttpError(400, 'Unknown authentication field');
178
+ http.verify(request, typeof body.csrf === 'string' ? { csrf: body.csrf } : {});
179
+ const session = http.session(request), binding = session || http.cookie(request, http.flowCookie);
180
+ if (!binding)
181
+ throw new AuthHttpError(403, 'Browser flow required');
182
+ const kind = ceremony[1], phase = ceremony[2], actor = session ? await service.authenticate(session) : null;
183
+ if (kind === 'step-up' && (!session || !actor || actor.impersonatorId))
184
+ throw new AuthHttpError(401, 'Sign in required');
185
+ if (kind === 'register') {
186
+ if (!session || !actor)
187
+ throw new AuthHttpError(401, 'Sign in required');
188
+ fresh(actor.authenticatedAt);
189
+ }
190
+ if (phase === 'options') {
191
+ const generated = kind === 'register' ? await options.passkeys.beginRegistration({ id: actor.id, email: actor.email }, await service.listPasskeys(actor.id)) : await options.passkeys.beginAuthentication();
192
+ const flowId = id();
193
+ await service.putFlow({ id: flowId, kind: 'passkey-' + kind, expires: Date.now() + 300000, data: { challenge: generated.challenge, browserHash: hash(binding), ...(actor ? { accountId: actor.id } : {}) } });
194
+ return jsonResponse(200, { options: generated, flowId });
195
+ }
196
+ if (typeof body.flowId !== 'string')
197
+ throw new AuthHttpError(400, 'Flow ID required');
198
+ const data = record(await service.consumeFlow(body.flowId, 'passkey-' + kind));
199
+ checkBinding(data, binding);
200
+ if (typeof data.challenge !== 'string')
201
+ throw new AuthHttpError(400, 'Invalid challenge');
202
+ const response = record(body.response);
203
+ if (typeof response.id !== 'string')
204
+ throw new AuthHttpError(400, 'Invalid credential');
205
+ if (kind === 'register') {
206
+ if (data.accountId !== actor.id)
207
+ throw new AuthHttpError(403, 'Account changed during ceremony');
208
+ const credential = await options.passkeys.verifyRegistration(response, data.challenge);
209
+ await service.addPasskey({ actorToken: session, credential });
210
+ return jsonResponse(200, { registered: true });
211
+ }
212
+ const stored = await service.getPasskey(response.id);
213
+ if (!stored)
214
+ throw new AuthHttpError(401, 'Passkey authentication failed');
215
+ if (kind === 'step-up' && (stored.accountId !== actor.id || data.accountId !== actor.id))
216
+ throw new AuthHttpError(403, 'Passkey belongs to another account');
217
+ const verified = await options.passkeys.verifyAuthentication(response, data.challenge, stored.credential);
218
+ const proof = { ...stored.proof, newCounter: verified.counter };
219
+ if (body.secondFactorToken !== undefined && typeof body.secondFactorToken !== 'string')
220
+ throw new AuthHttpError(400, 'Invalid second-factor proof');
221
+ if (body.totp !== undefined && typeof body.totp !== 'string' || body.recoveryCode !== undefined && typeof body.recoveryCode !== 'string')
222
+ throw new AuthHttpError(400, 'Invalid second factor');
223
+ if (kind === 'step-up')
224
+ return finish(request, await service.completeStepUp({ token: session, accountId: stored.accountId, method: 'passkey', proof, ...(typeof body.totp === 'string' && body.totp ? { totp: body.totp } : {}), ...(typeof body.recoveryCode === 'string' && body.recoveryCode ? { recoveryCode: body.recoveryCode } : {}), ...(typeof body.secondFactorToken === 'string' && body.secondFactorToken ? { secondFactor: secondFactors.proof(request, body.secondFactorToken) } : {}) }));
225
+ return finish(request, await service.issueSession(stored.accountId, { device: http.device(request), ...trusted(request), method: 'passkey', proof, ...(typeof body.totp === 'string' && body.totp ? { totp: body.totp } : {}), ...(typeof body.recoveryCode === 'string' && body.recoveryCode ? { recoveryCode: body.recoveryCode } : {}), ...(typeof body.secondFactorToken === 'string' && body.secondFactorToken ? { secondFactor: secondFactors.proof(request, body.secondFactorToken) } : {}) }));
226
+ },
227
+ };
228
+ }
@@ -0,0 +1,11 @@
1
+ import type { ExtensionRequest } from '@jimhoyd/urlcode/extensions';
2
+ import type { AuthExtensionOptions } from './auth.ts';
3
+ import type { PresentationContext } from './presentation.ts';
4
+ import type { RegistrationInput } from './registration.ts';
5
+ import { AuthHttp } from './auth-ui.ts';
6
+ /** Operator-owned signup orchestration. Only opaque browser-bound state is held in cookies. */
7
+ export declare function createSignup(options: AuthExtensionOptions, http: AuthHttp, mount: string, profile: {
8
+ fields(p: PresentationContext): string;
9
+ read(fields: Record<string, string>): RegistrationInput;
10
+ names: string[];
11
+ }): (request: ExtensionRequest, presentation: PresentationContext) => Promise<import("./auth-ui.ts").AuthHttpResponse | undefined>;
@@ -0,0 +1,145 @@
1
+ import { field as uiField, icon } from '@jimhoyd/urlcode-ui';
2
+ import { createHash, randomBytes } from 'node:crypto';
3
+ import { AuthHttp, AuthHttpError, csrfField, escapeHtml, formField, jsonResponse, readFields, screenResponse, wantsJson } from "./auth-ui.js";
4
+ import { Markup } from '@jimhoyd/urlcode-ui';
5
+ /** Operator-owned signup orchestration. Only opaque browser-bound state is held in cookies. */
6
+ export function createSignup(options, http, mount, profile) {
7
+ const service = options.service, browserCookie = '__Host-urlcode-signup-browser', flowCookie = '__Host-urlcode-signup';
8
+ const clear = () => [['set-cookie', http.setCookie(flowCookie, '', 0)]];
9
+ async function delivery(message, locale) {
10
+ const controller = new AbortController();
11
+ let timer;
12
+ try {
13
+ const operation = message.kind === 'signup-code' ? options.sendSignupCode?.({ email: message.email, code: message.code, locale, signal: controller.signal }) : options.sendNotice?.({ email: message.email, event: message.kind === 'new-device' ? 'new-device' : 'registration-attempt', locale, signal: controller.signal });
14
+ if (!operation && message.kind === 'signup-code')
15
+ throw new AuthHttpError(503, 'Email delivery is not configured');
16
+ if (operation)
17
+ await Promise.race([operation, new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(new Error('Delivery timeout')); }, 5000); })]);
18
+ }
19
+ catch { /* The same public result is returned for every eligible identifier. */ }
20
+ finally {
21
+ if (timer)
22
+ clearTimeout(timer);
23
+ }
24
+ }
25
+ return async function handle(request, presentation) {
26
+ const path = request.path.slice(mount.length);
27
+ if (path !== '/signup' && !path.startsWith('/signup/'))
28
+ return undefined;
29
+ if (!['open', 'invite-only', 'waitlist'].includes(service.getRegistrationMode()))
30
+ throw new AuthHttpError(403, 'Registration is disabled');
31
+ if (!['GET', 'HEAD', 'POST'].includes(request.method))
32
+ return jsonResponse(405, { error: 'Method not allowed' }, [['allow', 'GET, HEAD, POST']]);
33
+ const existingBrowser = http.cookie(request, browserCookie), browser = existingBrowser || randomBytes(32).toString('base64url'), browserHash = createHash('sha256').update(browser).digest('hex');
34
+ const flowId = http.cookie(request, flowCookie), binding = flowId ? { flowId, browserHash } : undefined;
35
+ const prepared = http.prepare(request), headers = [...prepared.headers, ['set-cookie', http.setCookie(browserCookie, browser, 1800)]];
36
+ const text = (source) => presentation.textSource(source), e = (source) => escapeHtml(text(source));
37
+ const route = mount + '/signup?lang=' + encodeURIComponent(presentation.locale);
38
+ const redirect = (extra = []) => (jsonResponse(303, { redirect: route }, [['location', route], ...headers, ...extra]));
39
+ const form = (action, fields, button) => `<form class="ui-stack" method="post" action="${escapeHtml(mount + '/signup/' + action + '?lang=' + encodeURIComponent(presentation.locale))}">${csrfField(prepared.csrf)}${fields}<button>${action === 'begin' || action === 'password' ? icon('arrow-right') : action === 'verify' ? icon('check') : action === 'complete' ? icon('user') : ''}${e(button)}</button></form>`;
40
+ const field = (name, label, type = 'text', autocomplete = 'off') => formField(name, text(label), type, autocomplete);
41
+ if (request.method !== 'POST') {
42
+ if (path === '/signup/pending')
43
+ return wantsJson(request) ? jsonResponse(200, { pending: true }, headers) : screenResponse('Request an account', { name: 'auth/status', view: { alert: false, message: text('Your request has been received. If eligible, an administrator will review it before you can sign in.'), href: null, label: null } }, { status: 200, headers, presentation, layout: 'compact', ui: options.ui });
44
+ let state;
45
+ if (binding) {
46
+ state = await service.getSignup(binding);
47
+ if (!state)
48
+ headers.push(...clear());
49
+ }
50
+ if (wantsJson(request))
51
+ return jsonResponse(200, { step: state?.step ?? 'identifier', csrf: prepared.csrf, ...(state ? { expires: state.expires } : {}) }, headers);
52
+ const verificationRequired = service.getSecurityPolicy().requireEmailVerification;
53
+ const steps = ['Email address', ...(verificationRequired ? ['Verify email'] : []), 'Secure your account', 'Your details'];
54
+ const currentStep = !state ? 0 : state.step === 'verify-email' ? 1 : state.step === 'credential' ? (verificationRequired ? 2 : 1) : steps.length - 1;
55
+ const progress = steps.map((label, index) => ({ number: index + 1, label: text(label), current: index === currentStep }));
56
+ const title = !state ? (service.getRegistrationMode() === 'waitlist' ? 'Request an account' : 'Create account') : state.step === 'verify-email' ? 'Check your email' : state.step === 'credential' ? (options.passkeys ? 'Secure your account' : 'Create a password') : 'Your details';
57
+ let intro, markup, passkey = '', identifier = null;
58
+ if (!state) {
59
+ const invitations = request.query.getAll('token');
60
+ if (invitations.length > 1 || (invitations[0] && !/^[A-Za-z0-9_-]{43}$/.test(invitations[0])))
61
+ throw new AuthHttpError(400, 'Invalid invitation');
62
+ intro = '';
63
+ markup = form('begin', field('email', 'Email address', 'email', 'email') + '<div hidden><label>Leave empty<input name="website" tabindex="-1" autocomplete="off"></label></div>' + (service.getRegistrationMode() === 'invite-only' ? (invitations[0] ? `<input type="hidden" name="invitationToken" value="${escapeHtml(invitations[0])}">` : field('invitationToken', 'Invitation token')) : ''), 'Continue');
64
+ }
65
+ else if (state.step === 'verify-email') {
66
+ intro = text('If this address is eligible, a signup code has been sent. Enter the code to continue.');
67
+ identifier = state.email;
68
+ markup = form('verify', field('code', 'Email code', 'text', 'one-time-code'), 'Verify email');
69
+ }
70
+ else if (state.step === 'credential') {
71
+ intro = options.passkeys ? text('Use a password or passkey.') : '';
72
+ markup = form('password', uiField({ name: 'password', label: text('Password'), type: 'password', autocomplete: 'new-password', required: true, description: text('At least 15 characters. Use a unique password.') }), 'Continue');
73
+ passkey = options.passkeys ? `<button class="ui-button-secondary" type="button" data-passkey="signup" data-base="${escapeHtml(mount)}" data-failed="${e('Passkey request failed')}" data-unavailable="${e('Passkeys are unavailable in this browser. Use another sign-in method.')}" data-cancelled="${e('Passkey ceremony cancelled')}">${e('Create a passkey')}</button><p role="status" aria-live="polite" data-passkey-status></p>` : '';
74
+ }
75
+ else {
76
+ intro = service.getRegistrationMode() === 'waitlist' ? text('An administrator must approve your request.') : '';
77
+ markup = form('complete', profile.fields(presentation), service.getRegistrationMode() === 'waitlist' ? 'Request account' : 'Create account');
78
+ }
79
+ const restart = state ? { summary: text('Use a different email address'), help: text('Starting again clears this signup progress. No account is created until you finish.'), form: new Markup(form('restart', '', 'Start again').replace('<button>', '<button class="ui-button-secondary">')) } : null;
80
+ const view = { progressLabel: presentation.text('ux.signupProgress', { current: currentStep + 1, total: steps.length }), stepsLabel: text('Account setup progress'), steps: progress, email: state && state.step !== 'verify-email' ? state.email : null, changeLabel: text('Change'), intro, identifier, form: new Markup(markup), passkey: new Markup(passkey), restart, signInPrompt: text('Already have an account?'), signInHref: mount + '/login?lang=' + encodeURIComponent(presentation.locale), signInLabel: text('Sign in') };
81
+ return screenResponse(title, { name: 'auth/signup', view }, { status: 200, headers, scriptPath: state?.step === 'credential' && options.passkeys ? mount + '/assets/passkeys.js' : undefined, presentation, turnstile: !state ? options.challenge?.widget : undefined, layout: 'compact', ui: options.ui });
82
+ }
83
+ if (!existingBrowser)
84
+ throw new AuthHttpError(403, 'Signup browser binding required');
85
+ // WebAuthn returns nested JSON; parse its bounded envelope separately from ordinary form fields.
86
+ if (path === '/signup/passkeys/verify') {
87
+ http.verify(request, {});
88
+ if (!binding || !options.passkeys || request.body.byteLength > 16384 || request.headers.get('content-type')?.split(';')[0] !== 'application/json')
89
+ throw new AuthHttpError(400, 'Invalid passkey request');
90
+ let payload;
91
+ try {
92
+ payload = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(request.body));
93
+ }
94
+ catch {
95
+ throw new AuthHttpError(400, 'Invalid passkey request');
96
+ }
97
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload) || Object.keys(payload).some(key => !['response', 'flowId'].includes(key)))
98
+ throw new AuthHttpError(400, 'Invalid passkey request');
99
+ const pending = await service.getSignupPasskeyChallenge(binding), credential = await options.passkeys.verifyRegistration(payload.response, pending.challenge);
100
+ await service.setSignupPasskey({ ...binding, challenge: pending.challenge, credential });
101
+ return jsonResponse(200, { step: 'profile' }, headers);
102
+ }
103
+ const fields = readFields(request, ['email', 'invitationToken', 'code', 'password', 'website', ...profile.names]);
104
+ http.verify(request, fields);
105
+ if (path === '/signup/restart')
106
+ return redirect(clear());
107
+ if (path === '/signup/begin') {
108
+ if (service.getSecurityPolicy().requireEmailVerification && !options.sendSignupCode)
109
+ throw new AuthHttpError(503, 'Email delivery is not configured');
110
+ const started = await service.beginSignup({ email: fields.email || '', browserHash, ...(fields.invitationToken ? { invitationToken: fields.invitationToken } : {}) });
111
+ if (started.delivery)
112
+ await delivery(started.delivery, presentation.locale);
113
+ const cookies = [['set-cookie', http.setCookie(flowCookie, started.flowId, 1800)]];
114
+ return wantsJson(request) ? jsonResponse(200, { step: started.step, expires: started.expires }, [...headers, ...cookies]) : redirect(cookies);
115
+ }
116
+ if (!binding)
117
+ throw new AuthHttpError(400, 'Restart signup');
118
+ if (path === '/signup/verify')
119
+ await service.verifySignup({ ...binding, code: fields.code || '' });
120
+ else if (path === '/signup/password')
121
+ await service.setSignupPassword({ ...binding, password: fields.password || '' });
122
+ else if (path === '/signup/passkeys/options') {
123
+ if (!options.passkeys)
124
+ throw new AuthHttpError(404, 'Passkeys are not configured');
125
+ const state = await service.getSignup(binding);
126
+ if (!state || state.step !== 'credential')
127
+ throw new AuthHttpError(400, 'Complete the previous signup step');
128
+ const passkey = await options.passkeys.beginRegistration({ id: state.accountId, email: state.email });
129
+ await service.setSignupPasskeyChallenge({ ...binding, challenge: passkey.challenge });
130
+ return jsonResponse(200, { options: passkey }, headers);
131
+ }
132
+ else if (path === '/signup/complete') {
133
+ const device = http.device(request), result = await service.completeSignup({ ...binding, profile: profile.read(fields), device: { id: device.id, label: device.label } });
134
+ const resultHeaders = [...headers, ...clear(), ...(result ? http.sessionHeaders(result.token) : [])];
135
+ if (result?.newDevice)
136
+ await delivery({ kind: 'new-device', email: result.user.email }, options.presentation?.resolve({ ...(result.user.profile?.locale ? { accountLocale: result.user.profile.locale } : {}), queryLocale: presentation.locale }).locale ?? presentation.locale);
137
+ // Existing-account attempts finish at sign-in; no existing credentials are replaced.
138
+ const target = mount + (result ? '/account' : service.getRegistrationMode() === 'waitlist' ? '/signup/pending' : '/login');
139
+ return wantsJson(request) ? jsonResponse(200, { complete: true, redirect: target, ...(result ? { csrf: http.token(result.token) } : {}) }, resultHeaders) : jsonResponse(303, { redirect: target }, [['location', target], ...resultHeaders]);
140
+ }
141
+ else
142
+ throw new AuthHttpError(404, 'Page not found');
143
+ return wantsJson(request) ? jsonResponse(200, { step: (await service.getSignup(binding))?.step }, headers) : redirect();
144
+ };
145
+ }
@@ -0,0 +1,81 @@
1
+ import type { AuthAbusePolicy } from './abuse.ts';
2
+ import type { RegistrationProfile } from './registration.ts';
3
+ export declare class AuthError extends Error {
4
+ readonly status: number;
5
+ readonly code: string;
6
+ constructor(status: number, code: string);
7
+ }
8
+ export interface AuthRecord {
9
+ mfaPasskeys?: string[];
10
+ mfaRecoveryRequired?: boolean;
11
+ id: string;
12
+ email: string;
13
+ emailVerified: boolean;
14
+ status: 'active' | 'locked' | 'pending-delete';
15
+ deleteAfter?: number;
16
+ roles: string[];
17
+ created: number;
18
+ passwordHash: string;
19
+ version: number;
20
+ totpSecret?: string;
21
+ totpPending?: string;
22
+ totpPendingUntil?: number;
23
+ totpCounter: number;
24
+ profile?: RegistrationProfile;
25
+ newDevice?: boolean;
26
+ }
27
+ export interface SessionRecord {
28
+ primaryMethod?: string;
29
+ primaryCredentialId?: string;
30
+ mfaAuthenticatedAt?: number;
31
+ mfaVersion?: number;
32
+ recoveryEnrollment?: number;
33
+ id: string;
34
+ hash: string;
35
+ accountId: string;
36
+ created: number;
37
+ authenticatedAt: number;
38
+ expires: number;
39
+ lastSeen?: number;
40
+ deviceLabel?: string;
41
+ deviceHash?: string;
42
+ impersonatorId?: string;
43
+ actorVersion?: number;
44
+ }
45
+ export interface StoreOptions {
46
+ approveConfigurationChangeFrom?: string;
47
+ configurationTag?: string;
48
+ configurationChangeAt: number;
49
+ database: string;
50
+ roles: Record<string, string[]>;
51
+ defaultRole: string;
52
+ sessionIdleMs: number;
53
+ sessionTtlMs: number;
54
+ securityPolicy: {
55
+ abuse?: AuthAbusePolicy;
56
+ allowPasskeySecondFactor?: true;
57
+ trustedDeviceTtlMs?: number;
58
+ allowEmailFactorRecovery?: true;
59
+ allowManualRecovery?: true;
60
+ requireEmailVerification: boolean;
61
+ requireMfa: boolean;
62
+ deletionGraceMs: number;
63
+ };
64
+ registration: {
65
+ disposableDomainsRevision?: string;
66
+ mode: string;
67
+ allowed: string[];
68
+ blocked: string[];
69
+ allowedEmails: string[];
70
+ blockedEmails: string[];
71
+ allowImpersonation: boolean;
72
+ };
73
+ activeKey: string;
74
+ keyFingerprints: Record<string, string>;
75
+ }
76
+ export interface AuthStore {
77
+ call<T = unknown>(operation: string, args?: Record<string, unknown>): Promise<T>;
78
+ close(): Promise<void>;
79
+ }
80
+ export declare function patched(version: string): boolean;
81
+ export declare function openAuthStore(options: StoreOptions): Promise<AuthStore>;