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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/IMPLEMENTATION-STATUS.md +3 -1
  2. package/README.md +56 -0
  3. package/SECURITY.md +1 -1
  4. package/THREAT-MODEL.md +4 -2
  5. package/dist/abuse-http.d.ts +8 -0
  6. package/dist/abuse-http.js +74 -0
  7. package/dist/abuse-store.d.ts +5 -0
  8. package/dist/abuse-store.js +40 -0
  9. package/dist/abuse.d.ts +27 -0
  10. package/dist/abuse.js +34 -0
  11. package/dist/admin-account-operations.d.ts +83 -0
  12. package/dist/admin-account-operations.js +50 -0
  13. package/dist/admin-account-store.d.ts +22 -0
  14. package/dist/admin-account-store.js +185 -0
  15. package/dist/auth-baseline.d.ts +30 -0
  16. package/dist/auth-baseline.js +153 -0
  17. package/dist/auth-core.d.ts +655 -0
  18. package/dist/auth-core.js +1066 -0
  19. package/dist/auth-flows.d.ts +30 -0
  20. package/dist/auth-flows.js +228 -0
  21. package/dist/auth-signup.d.ts +12 -0
  22. package/dist/auth-signup.js +154 -0
  23. package/dist/auth-store.d.ts +81 -0
  24. package/dist/auth-store.js +1601 -0
  25. package/dist/auth-templates.d.ts +13 -0
  26. package/dist/auth-templates.js +74 -0
  27. package/dist/auth-ui.d.ts +106 -0
  28. package/dist/auth-ui.js +205 -0
  29. package/dist/auth.d.ts +49 -0
  30. package/dist/auth.js +532 -0
  31. package/dist/backup.d.ts +18 -0
  32. package/dist/backup.js +121 -0
  33. package/dist/challenge-ui.d.ts +11 -0
  34. package/dist/challenge-ui.js +18 -0
  35. package/dist/challenge.d.ts +21 -0
  36. package/dist/challenge.js +65 -0
  37. package/dist/cli.d.ts +2 -0
  38. package/dist/cli.js +137 -0
  39. package/dist/deployment-check.d.ts +16 -0
  40. package/dist/deployment-check.js +41 -0
  41. package/dist/disposable-domain-data.d.ts +1 -0
  42. package/dist/disposable-domain-data.js +8886 -0
  43. package/dist/disposable-domains.d.ts +3 -0
  44. package/dist/disposable-domains.js +17 -0
  45. package/dist/email-copy.d.ts +114 -0
  46. package/dist/email-copy.js +58 -0
  47. package/dist/factor-recovery.d.ts +46 -0
  48. package/dist/factor-recovery.js +71 -0
  49. package/dist/index.d.ts +42 -0
  50. package/dist/index.js +18 -0
  51. package/dist/lifecycle-hooks.d.ts +76 -0
  52. package/dist/lifecycle-hooks.js +106 -0
  53. package/dist/manual-recovery-store.d.ts +25 -0
  54. package/dist/manual-recovery-store.js +129 -0
  55. package/dist/manual-recovery.d.ts +87 -0
  56. package/dist/manual-recovery.js +35 -0
  57. package/dist/oidc.d.ts +31 -0
  58. package/dist/oidc.js +54 -0
  59. package/dist/passkeys.d.ts +24 -0
  60. package/dist/passkeys.js +29 -0
  61. package/dist/password-policy.d.ts +7 -0
  62. package/dist/password-policy.js +72 -0
  63. package/dist/presentation.d.ts +15 -0
  64. package/dist/presentation.js +458 -0
  65. package/dist/presets.d.ts +18 -0
  66. package/dist/presets.js +17 -0
  67. package/dist/providers.d.ts +13 -0
  68. package/dist/providers.js +15 -0
  69. package/dist/registration.d.ts +45 -0
  70. package/dist/registration.js +130 -0
  71. package/dist/scaffold.d.ts +44 -0
  72. package/dist/scaffold.js +212 -0
  73. package/dist/second-factor-flows.d.ts +28 -0
  74. package/dist/second-factor-flows.js +76 -0
  75. package/dist/senders.d.ts +86 -0
  76. package/dist/senders.js +153 -0
  77. package/dist/user-query.d.ts +28 -0
  78. package/dist/user-query.js +81 -0
  79. package/package.json +2 -2
@@ -0,0 +1,13 @@
1
+ import type { ViewModel } from '@jimhoyd/urlcode-ui';
2
+ export interface AuthTemplate {
3
+ readonly source: string;
4
+ readonly sample: ViewModel;
5
+ }
6
+ /** Template sources with their view model samples, keyed by full template name. */
7
+ export declare const authTemplates: Readonly<Record<string, AuthTemplate>>;
8
+ export declare const authTemplateNames: readonly string[];
9
+ /** What the host hands to `createUiExtension({ extensions: [authUiTemplates] })`. */
10
+ export declare const authUiTemplates: {
11
+ readonly name: 'auth';
12
+ readonly templates: Readonly<Record<string, string>>;
13
+ };
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The account screens as kit templates, under the `auth` namespace. A
3
+ * template only places a view model the extension computes: every value is
4
+ * escaped by the renderer, and the `Markup` slots (forms with their CSRF
5
+ * field, fields, buttons) are built by trusted package code. A project may
6
+ * shadow any of these by name; it cannot change a flow, what a form
7
+ * validates, what is escaped or what a page sends in headers.
8
+ */
9
+ import { Markup } from '@jimhoyd/urlcode-ui';
10
+ const m = (html) => new Markup(html);
11
+ const declare = (name, body) => `{{!-- viewModel: auth/${name}@1 --}}${body}`;
12
+ const link = '<a href="{{href href}}">{{label}}</a>';
13
+ const screens = {
14
+ 'sign-in': {
15
+ body: `{{#if intro}}<p class="ui-intro">{{intro}}</p>{{/if}}{{form}}{{passkey}}{{providers}}<nav class="ui-link-list" aria-label="{{linksLabel}}">{{#each links}}${link}{{/each}}</nav>`,
16
+ sample: { intro: '', form: m('<form method="post"></form>'), passkey: m(''), providers: m(''), linksLabel: 'Sign-in methods', links: [{ href: '/account/register', label: 'Create account' }] },
17
+ },
18
+ password: {
19
+ body: `{{#if failed}}<p class="error" role="alert">{{failed}}</p>{{/if}}{{#if intro}}<p class="ui-intro">{{intro}}</p>{{/if}}<div class="ui-selected-identity"><span class="ui-identifier">{{email}}</span><a href="{{href changeHref}}">{{changeLabel}}</a></div>{{form}}`,
20
+ sample: { failed: null, intro: '', email: 'ada@example.test', changeHref: '/account/login', changeLabel: 'Change', form: m('<form method="post"></form>') },
21
+ },
22
+ register: { body: `{{form}}`, sample: { form: m('<form method="post"></form>') } },
23
+ 'forgot-password': { body: `{{#if intro}}<p class="ui-intro">{{intro}}</p>{{/if}}{{form}}`, sample: { intro: 'We will send a reset link.', form: m('<form method="post"></form>') } },
24
+ 'email-code': { body: `{{form}}`, sample: { form: m('<form method="post"></form>') } },
25
+ 'confirm-token': { body: `{{form}}`, sample: { form: m('<form method="post"></form>') } },
26
+ enrollment: {
27
+ body: `<p role="status">{{status}}</p><p>{{email}}</p><h2>{{heading}}</h2>{{#if form}}{{form}}{{else}}<p>{{unavailable}}</p>{{/if}}{{#if passkeyHref}}<p><a href="{{href passkeyHref}}">{{passkeyLabel}}</a></p>{{/if}}<p><a href="{{href stepUpHref}}">{{stepUpLabel}}</a> {{stepUpHelp}}</p>{{signOut}}`,
28
+ sample: { status: 'Application access remains blocked until enrollment is complete.', email: 'ada@example.test', heading: 'Verify your email first', form: m('<form method="post"></form>'), unavailable: null, passkeyHref: null, passkeyLabel: null, stepUpHref: '/account/step-up', stepUpLabel: 'Confirm your identity', stepUpHelp: 'if your recent sign-in has expired.', signOut: m('<form method="post"></form>') },
29
+ },
30
+ impersonation: { body: `{{navigation}}<p role="alert">{{alert}}</p>{{form}}`, sample: { navigation: m('<nav></nav>'), alert: 'You are viewing this account as a support administrator.', form: m('<form method="post"></form>') } },
31
+ account: {
32
+ body: `{{navigation}}<section class="ui-card ui-section"><div class="ui-toolbar"><div><p class="ui-identifier">{{email}}</p></div>{{signOut}}</div><p>{{state}}</p>{{verification}}</section><div class="ui-settings-grid">{{#each sections}}<section class="ui-card ui-section{{#if danger}} ui-danger-zone{{/if}}"><h2>{{heading}}</h2>{{content}}</section>{{/each}}</div>`,
33
+ sample: { navigation: m('<nav></nav>'), overviewLabel: 'Your account', email: 'ada@example.test', signOut: m('<form method="post"></form>'), state: 'Email verified. Authenticator disabled.', verification: m(''), sections: [{ heading: 'Profile', content: m('<form method="post"></form>'), danger: false }] },
34
+ },
35
+ 'second-factors': {
36
+ body: `{{navigation}}{{csrf}}<p>{{intro}}</p>{{register}}{{#each passkeys}}<section class="ui-card ui-section"><h2>{{id}}</h2><p>{{state}}</p>{{form}}</section>{{/each}}`,
37
+ sample: { navigation: m('<nav></nav>'), csrf: m('<input type="hidden" name="csrf" value="token">'), intro: 'A second-factor passkey must be different from the sign-in passkey.', register: m('<button type="button">Add a passkey</button>'), passkeys: [{ id: 'credential', state: 'Enabled', form: m('<form method="post"></form>') }] },
38
+ },
39
+ 'trusted-devices': {
40
+ body: `{{navigation}}<p>{{intro}}</p>{{form}}{{#each devices}}<section class="ui-card ui-section"><h2>{{label}}</h2><p>{{expires}}</p>{{form}}</section>{{/each}}`,
41
+ sample: { navigation: m('<nav></nav>'), intro: 'Remembering a device requires a real second factor.', form: m('<form method="post"></form>'), devices: [{ label: 'Laptop', expires: '2030-01-01T00:00:00.000Z', form: m('<form method="post"></form>') }] },
42
+ },
43
+ sessions: {
44
+ body: `{{navigation}}<ul class="ui-card-list">{{#each sessions}}<li class="ui-card ui-section">{{summary}}{{form}}</li>{{/each}}</ul>{{form}}`,
45
+ sample: { navigation: m('<nav></nav>'), sessions: [{ summary: 'Session started 2030-01-01T00:00:00.000Z', form: m('<form method="post"></form>') }], form: m('<form method="post"></form>') },
46
+ },
47
+ methods: {
48
+ body: `{{navigation}}<h2>{{passkeysHeading}}</h2>{{#each passkeys}}{{form}}{{/each}}<h2>{{providersHeading}}</h2>{{#each identities}}{{form}}{{/each}}<p>{{note}}</p>`,
49
+ sample: { navigation: m('<nav></nav>'), passkeysHeading: 'Passkeys', passkeys: [{ form: m('<form method="post"></form>') }], providersHeading: 'Linked providers', identities: [], note: 'The last sign-in method cannot be removed.' },
50
+ },
51
+ 'step-up': { body: `{{navigation}}{{form}}{{passkey}}`, sample: { navigation: m('<nav></nav>'), form: m('<form method="post"></form>'), passkey: m('') } },
52
+ status: {
53
+ body: `<p role="{{#if alert}}alert{{else}}status{{/if}}"{{#if alert}} class="error"{{/if}}>{{message}}</p>{{#if href}}<p><a class="ui-button" href="{{href href}}">{{label}}</a></p>{{/if}}`,
54
+ sample: { alert: false, message: 'Your email address has been verified.', href: '/account', label: 'Continue to your account' },
55
+ },
56
+ 'totp-setup': { body: `<p>{{intro}} <code>{{secret}}</code></p>{{form}}`, sample: { intro: 'Add this key to your authenticator:', secret: 'JBSWY3DPEHPK3PXP', form: m('<form method="post"></form>') } },
57
+ 'recovery-codes': {
58
+ body: `<p>{{intro}}</p><ul>{{#each codes}}<li><code>{{this}}</code></li>{{/each}}</ul><a href="{{href href}}">{{label}}</a>`,
59
+ sample: { intro: 'Store these codes securely. Each can be used once.', codes: ['abcd-efgh', 'ijkl-mnop'], href: '/account', label: 'Continue to your account' },
60
+ },
61
+ signup: {
62
+ body: `<p class="ui-progress">{{progressLabel}}</p>{{#if email}}<div class="ui-selected-identity"><span class="ui-identifier">{{email}}</span></div>{{/if}}{{#if intro}}<p class="ui-intro">{{intro}}</p>{{/if}}{{#if identifier}}<p class="ui-identifier">{{identifier}}</p>{{/if}}{{form}}{{passkey}}{{#if restart}}<details class="ui-disclosure"><summary>{{changeLabel}}</summary><p>{{restart.help}}</p>{{restart.form}}</details>{{/if}}<p class="ui-link-list">{{signInPrompt}} <a href="{{href signInHref}}">{{signInLabel}}</a></p>`,
63
+ sample: { progressLabel: 'Step 1 of 3', stepsLabel: 'Account setup progress', steps: [{ number: 1, label: 'Email address', current: true }, { number: 2, label: 'Secure your account', current: false }], email: null, changeLabel: 'Change', intro: '', identifier: null, form: m('<form method="post"></form>'), passkey: m(''), restart: null, signInPrompt: 'Already have an account?', signInHref: '/account/login', signInLabel: 'Sign in' },
64
+ },
65
+ 'recover-factor': { body: `{{#if intro}}<p>{{intro}}</p>{{/if}}{{form}}`, sample: { intro: 'We will send recovery instructions.', form: m('<form method="post"></form>') } },
66
+ 'restore-access': { body: `<p>{{intro}}</p>{{form}}`, sample: { intro: 'Choose a new password, then enroll a second factor.', form: m('<form method="post"></form>') } },
67
+ 'provider-enroll': { body: `{{form}}`, sample: { form: m('<form method="post"></form>') } },
68
+ 'provider-second-factor': { body: `{{form}}`, sample: { form: m('<form method="post"></form>') } },
69
+ };
70
+ /** Template sources with their view model samples, keyed by full template name. */
71
+ export const authTemplates = Object.freeze(Object.fromEntries(Object.entries(screens).map(([name, screen]) => [`auth/${name}`, Object.freeze({ source: declare(name, screen.body), sample: screen.sample })])));
72
+ export const authTemplateNames = Object.freeze(Object.keys(authTemplates));
73
+ /** What the host hands to `createUiExtension({ extensions: [authUiTemplates] })`. */
74
+ export const authUiTemplates = Object.freeze({ name: 'auth', templates: Object.freeze(Object.fromEntries(Object.entries(authTemplates).map(([name, template]) => [name, template.source]))) });
@@ -0,0 +1,106 @@
1
+ import type { ViewModel, Kit, Presentation } from '@jimhoyd/urlcode-ui';
2
+ export { escapeHtml } from '@jimhoyd/urlcode-ui';
3
+ import type { TurnstileWidget } from './challenge-ui.ts';
4
+ import type { PresentationContext } from './presentation.ts';
5
+ import type { ExtensionRequest } from '@jimhoyd/urlcode/extensions';
6
+ export interface AuthHttpResponse {
7
+ status: number;
8
+ headers: [
9
+ string,
10
+ string
11
+ ][];
12
+ body: Uint8Array;
13
+ }
14
+ export declare class AuthHttpError extends Error {
15
+ readonly status: number;
16
+ constructor(status: number, message: string);
17
+ }
18
+ export declare function jsonResponse(status: number, value: unknown, headers?: [
19
+ string,
20
+ string
21
+ ][]): AuthHttpResponse;
22
+ /** The object `createUiExtension` returns, structurally: the kit once the runtime has activated the `ui` extension. */
23
+ export interface UiHost {
24
+ readonly kit: Kit;
25
+ readonly active: boolean;
26
+ }
27
+ /** One account screen: an `auth/*` template name and the view model the extension computed for it. */
28
+ export interface Screen {
29
+ name: string;
30
+ view: ViewModel;
31
+ }
32
+ export interface ScreenOptions {
33
+ status?: number | undefined;
34
+ headers?: [string, string][] | undefined;
35
+ /** A same-origin script the screen needs (the passkey glue); it is nonce-bound on both render paths. */
36
+ scriptPath?: string | undefined;
37
+ presentation?: PresentationContext | undefined;
38
+ turnstile?: TurnstileWidget | undefined;
39
+ layout?: 'default' | 'compact' | 'application' | undefined;
40
+ /** When present and active, the screen renders through the kit; otherwise through the primitives. */
41
+ ui?: UiHost | undefined;
42
+ }
43
+ /** Test hook: sees every screen before it renders, with the path that renders it. */
44
+ export declare const screenObserver: {
45
+ current?: ((screen: Screen, path: 'primitives' | 'kit') => void) | undefined;
46
+ };
47
+ /** Renders a screen: through `ui.kit` when the host supplied the ui extension and it is active, otherwise through the shared primitives. */
48
+ export declare function screenResponse(title: string, screen: Screen, options?: ScreenOptions): AuthHttpResponse;
49
+ /** The presentation auth resolves copy through: the host's, else the kit's once `ui` is active, else the bundled English catalogue. */
50
+ export declare function presentationSource(presentation: Presentation | undefined, ui: UiHost | undefined, fallback: Presentation): Presentation;
51
+ /** Only trusted package code constructs markup. Project/user values must pass escapeHtml. */
52
+ export declare function pageResponse(title: string, markup: string, status?: number, headers?: [
53
+ string,
54
+ string
55
+ ][], scriptPath?: string, presentation?: PresentationContext, turnstile?: TurnstileWidget, layout?: 'default' | 'compact' | 'application'): AuthHttpResponse;
56
+ export declare function formField(name: string, label: string, type?: string, autocomplete?: string, required?: boolean): string;
57
+ export declare function csrfField(token: string): string;
58
+ export declare function wantsJson(request: ExtensionRequest): boolean;
59
+ export declare function readFields(request: ExtensionRequest, allowed: string[]): Record<string, string>;
60
+ export interface AuthHttpOptions {
61
+ csrfKey: Uint8Array;
62
+ origin: string;
63
+ }
64
+ export declare class AuthHttp {
65
+ #private;
66
+ readonly origin: string;
67
+ readonly sessionCookie = "__Host-urlcode-session";
68
+ readonly flowCookie = "__Host-urlcode-flow";
69
+ constructor(options: AuthHttpOptions);
70
+ cookie(request: ExtensionRequest, name: string): string | undefined;
71
+ device(request: ExtensionRequest): {
72
+ id: string;
73
+ label: string;
74
+ headers: [
75
+ string,
76
+ string
77
+ ][];
78
+ };
79
+ session(request: ExtensionRequest): string | undefined;
80
+ token(binding: string): string;
81
+ prepare(request: ExtensionRequest): {
82
+ csrf: string;
83
+ headers: [
84
+ string,
85
+ string
86
+ ][];
87
+ };
88
+ verify(request: ExtensionRequest, fields: Record<string, string>): void;
89
+ setCookie(name: string, value: string, maxAge?: number): string;
90
+ sessionHeaders(token: string): [
91
+ string,
92
+ string
93
+ ][];
94
+ clearSession(): [
95
+ string,
96
+ string
97
+ ][];
98
+ }
99
+ export declare function httpFailure(error: unknown, request: ExtensionRequest, presentation?: PresentationContext, recovery?: {
100
+ href: string;
101
+ label: string;
102
+ }, ui?: UiHost): AuthHttpResponse;
103
+ /** Proof token stays in the submitting form and is consumed once with the primary proof. */
104
+ export declare function secondFactorButton(base: string, text?: (source: string) => string): string;
105
+ /** Browser glue for maintained server-side WebAuthn verification. No guest scripts. */
106
+ export declare const passkeyScript: string;
@@ -0,0 +1,205 @@
1
+ import { renderDocument, field, escapeHtml, compileTemplate, Markup } from '@jimhoyd/urlcode-ui';
2
+ export { escapeHtml } from '@jimhoyd/urlcode-ui';
3
+ import { authTemplates } from "./auth-templates.js";
4
+ import { addTurnstileWidgets, turnstileOrigin, turnstileScript } from "./challenge-ui.js";
5
+ import { englishCatalogue } from "./presentation.js";
6
+ import { createPresentation } from "./presentation.js";
7
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
8
+ export class AuthHttpError extends Error {
9
+ status;
10
+ constructor(status, message) { super(message); this.status = status; }
11
+ }
12
+ const encoder = new TextEncoder();
13
+ const securityHeaders = [['cache-control', 'no-store'], ['content-security-policy', "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"], ['referrer-policy', 'strict-origin'], ['x-content-type-options', 'nosniff']];
14
+ export function jsonResponse(status, value, headers = []) { return { status, headers: [...securityHeaders, ['content-type', 'application/json; charset=utf-8'], ...headers], body: encoder.encode(JSON.stringify(value)) }; }
15
+ /** Test hook: sees every screen before it renders, with the path that renders it. */
16
+ export const screenObserver = {};
17
+ let defaultContext;
18
+ const localTemplates = new Map();
19
+ function localTemplate(name) {
20
+ let template = localTemplates.get(name);
21
+ if (!template && Object.hasOwn(authTemplates, name)) {
22
+ template = compileTemplate(name, authTemplates[name].source);
23
+ localTemplates.set(name, template);
24
+ }
25
+ return template;
26
+ }
27
+ function pageTitle(title, presentation) {
28
+ const titleKey = Object.entries(englishCatalogue).find(([key, value]) => key.startsWith('page.') && value === title)?.[0];
29
+ return presentation ? (titleKey ? presentation.text(titleKey) : presentation.textSource(title)) : title;
30
+ }
31
+ /** Renders a screen: through `ui.kit` when the host supplied the ui extension and it is active, otherwise through the shared primitives. */
32
+ export function screenResponse(title, screen, options = {}) {
33
+ if (!Object.hasOwn(authTemplates, screen.name))
34
+ throw new Error(`Unknown auth screen: ${screen.name.slice(0, 64)}`);
35
+ const kit = options.ui?.active ? options.ui.kit : undefined;
36
+ screenObserver.current?.(screen, kit ? 'kit' : 'primitives');
37
+ if (!kit) {
38
+ const context = options.presentation ?? (defaultContext ??= createPresentation().resolve());
39
+ const markup = localTemplate(screen.name).render(screen.view, context, localTemplate).html;
40
+ return pageResponse(title, markup, options.status, options.headers, options.scriptPath, options.presentation, options.turnstile, options.layout);
41
+ }
42
+ const context = options.presentation ?? kit.resolveContext();
43
+ const challenge = addTurnstileWidgets(kit.render(screen.name, screen.view, context).html, options.turnstile);
44
+ const page = kit.wrap(new Markup(challenge.markup), { title: pageTitle(title, context), context, ...(options.layout ? { layout: options.layout } : {}), ...(options.status !== undefined ? { status: options.status } : {}), ...(options.headers ? { headers: options.headers } : {}), ...(challenge.enabled ? { csp: { script: [turnstileOrigin], frame: [turnstileOrigin], connect: [turnstileOrigin] } } : {}) });
45
+ const scripts = [...(options.scriptPath ? [{ src: options.scriptPath, async: false }] : []), ...(challenge.enabled ? [{ src: turnstileScript, async: true }] : [])];
46
+ if (!scripts.length)
47
+ return page;
48
+ // The kit binds one nonce per page (its style tag carries it); the extension's own scripts share it, so the page CSP admits them.
49
+ let html = new TextDecoder().decode(page.body);
50
+ const nonce = /<style nonce="([A-Za-z0-9+/=]+)">/.exec(html)?.[1];
51
+ if (!nonce || !html.endsWith('</body></html>'))
52
+ throw new Error('Kit layout lacks the nonce-bound style tag or body end the auth scripts need');
53
+ html = html.slice(0, -'</body></html>'.length) + scripts.map(script => `<script nonce="${nonce}" src="${escapeHtml(script.src)}"${script.async ? ' async' : ' defer'}></script>`).join('') + '</body></html>';
54
+ return { status: page.status, headers: page.headers, body: encoder.encode(html) };
55
+ }
56
+ /** The presentation auth resolves copy through: the host's, else the kit's once `ui` is active, else the bundled English catalogue. */
57
+ export function presentationSource(presentation, ui, fallback) {
58
+ return presentation ?? (ui?.active ? ui.kit.presentation : fallback);
59
+ }
60
+ /** Only trusted package code constructs markup. Project/user values must pass escapeHtml. */
61
+ export function pageResponse(title, markup, status = 200, headers = [], scriptPath, presentation, turnstile, layout = 'default') {
62
+ title = pageTitle(title, presentation);
63
+ const challenge = addTurnstileWidgets(markup, turnstile);
64
+ markup = challenge.markup;
65
+ const nonce = randomBytes(18).toString('base64');
66
+ const scripts = [...(scriptPath ? [{ src: scriptPath, nonce: nonce }] : []), ...(challenge.enabled ? [{ src: turnstileScript, nonce: nonce, async: true }] : [])];
67
+ const html = renderDocument({ title, trustedContent: markup, layout, theme: { nonce }, ...(presentation ? { presentation } : {}), scripts });
68
+ return { status, headers: [...securityHeaders.map(([name, value]) => [name, name === 'content-security-policy' ? value + (presentation?.logo || presentation?.favicon ? "; img-src 'self'" : '') + (nonce ? `; script-src 'nonce-${nonce}'${challenge.enabled ? ' ' + turnstileOrigin : ''}` : '') + (challenge.enabled ? `; frame-src ${turnstileOrigin}; connect-src 'self' ${turnstileOrigin}` : '') : value]), ['content-type', 'text/html; charset=utf-8'], ...headers], body: encoder.encode(html) };
69
+ }
70
+ export function formField(name, label, type = 'text', autocomplete = 'off', required = true) { return field({ name, label, type, autocomplete, required }); }
71
+ export function csrfField(token) { return `<input type="hidden" name="csrf" value="${escapeHtml(token)}">`; }
72
+ export function wantsJson(request) { return (request.headers.get('accept') || '').split(',').some(value => value.trim().split(';')[0] === 'application/json') || request.headers.get('content-type')?.split(';')[0]?.trim() === 'application/json'; }
73
+ export function readFields(request, allowed) {
74
+ if (request.body.byteLength > 16384)
75
+ throw new AuthHttpError(413, 'Request body too large');
76
+ let source;
77
+ try {
78
+ source = new TextDecoder('utf-8', { fatal: true }).decode(request.body);
79
+ }
80
+ catch {
81
+ throw new AuthHttpError(400, 'Invalid request encoding');
82
+ }
83
+ const type = request.headers.get('content-type')?.split(';')[0]?.trim(), result = Object.create(null);
84
+ let entries;
85
+ if (type === 'application/json') {
86
+ let data;
87
+ try {
88
+ data = JSON.parse(source);
89
+ }
90
+ catch {
91
+ throw new AuthHttpError(400, 'Invalid JSON body');
92
+ }
93
+ if (!data || typeof data !== 'object' || Array.isArray(data))
94
+ throw new AuthHttpError(400, 'Expected an object');
95
+ entries = Object.entries(data);
96
+ }
97
+ else if (type === 'application/x-www-form-urlencoded')
98
+ entries = [...new URLSearchParams(source)];
99
+ else
100
+ throw new AuthHttpError(415, 'Use JSON or form data');
101
+ if (entries.length > 64)
102
+ throw new AuthHttpError(400, 'Too many fields');
103
+ for (const [key, value] of entries) {
104
+ if (![...allowed, 'csrf', 'challengeToken'].includes(key) || Object.hasOwn(result, key) || typeof value !== 'string' || value.length > (key === 'challengeToken' ? 2048 : 4096))
105
+ throw new AuthHttpError(400, 'Invalid request field');
106
+ result[key] = value;
107
+ }
108
+ return result;
109
+ }
110
+ export class AuthHttp {
111
+ origin;
112
+ #key;
113
+ #devices = new WeakMap();
114
+ sessionCookie = '__Host-urlcode-session';
115
+ flowCookie = '__Host-urlcode-flow';
116
+ constructor(options) {
117
+ const origin = new URL(options.origin);
118
+ if (origin.origin !== options.origin || origin.username || origin.password || !(origin.protocol === 'https:' || (origin.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(origin.hostname))))
119
+ throw new Error('Auth requires a canonical HTTPS origin (loopback HTTP development only)');
120
+ if (options.csrfKey.byteLength < 32)
121
+ throw new Error('Auth CSRF key requires at least 32 bytes');
122
+ this.origin = options.origin;
123
+ this.#key = Buffer.from(options.csrfKey);
124
+ }
125
+ cookie(request, name) {
126
+ const raw = request.headers.get('cookie') || '';
127
+ if (raw.length > 8192 || (request.headerCounts['cookie'] || 0) > 1)
128
+ throw new AuthHttpError(400, 'Invalid cookies');
129
+ const values = raw.split(';').map(value => value.trim()).filter(value => value.startsWith(name + '='));
130
+ if (values.length > 1)
131
+ throw new AuthHttpError(400, 'Duplicate session cookie');
132
+ const value = values[0]?.slice(name.length + 1);
133
+ if (value !== undefined && !/^[A-Za-z0-9_-]{20,256}$/.test(value))
134
+ throw new AuthHttpError(400, 'Invalid session cookie');
135
+ return value;
136
+ }
137
+ device(request) {
138
+ const cached = this.#devices.get(request);
139
+ if (cached)
140
+ return cached;
141
+ const existing = this.cookie(request, '__Host-urlcode-device');
142
+ const value = { id: existing || randomBytes(32).toString('base64url'), label: (request.headers.get('user-agent') || 'Browser').replace(/[\x00-\x1f\x7f]/g, '').slice(0, 160), headers: [] };
143
+ if (!existing)
144
+ value.headers.push(['set-cookie', this.setCookie('__Host-urlcode-device', value.id, 31536000)]);
145
+ this.#devices.set(request, value);
146
+ return value;
147
+ }
148
+ session(request) { return this.cookie(request, this.sessionCookie); }
149
+ token(binding) { return createHmac('sha256', this.#key).update('urlcode-csrf\0' + this.origin + '\0' + binding).digest('hex'); }
150
+ prepare(request) {
151
+ const session = this.session(request), flow = this.cookie(request, this.flowCookie), binding = session || flow || randomBytes(32).toString('base64url');
152
+ return { csrf: this.token(binding), headers: [...(session || flow ? [] : [['set-cookie', this.setCookie(this.flowCookie, binding, 900)]]), ...this.device(request).headers] };
153
+ }
154
+ verify(request, fields) {
155
+ if (request.origin !== this.origin || request.headers.get('origin') !== this.origin || request.headers.get('sec-fetch-site') === 'cross-site')
156
+ throw new AuthHttpError(403, 'Same-origin request required');
157
+ if ((request.headerCounts['origin'] || 0) > 1 || (request.headerCounts['x-csrf-token'] || 0) > 1)
158
+ throw new AuthHttpError(403, 'Invalid CSRF token');
159
+ const binding = this.session(request) || this.cookie(request, this.flowCookie), provided = request.headers.get('x-csrf-token') || fields.csrf;
160
+ if (!binding || !provided || !/^[a-f0-9]{64}$/.test(provided) || !timingSafeEqual(Buffer.from(this.token(binding), 'hex'), Buffer.from(provided, 'hex')))
161
+ throw new AuthHttpError(403, 'Invalid CSRF token');
162
+ }
163
+ setCookie(name, value, maxAge) { return `${name}=${value}; Path=/; Secure; HttpOnly; SameSite=Strict${maxAge === undefined ? '' : `; Max-Age=${maxAge}`}`; }
164
+ sessionHeaders(token) { return [['set-cookie', this.setCookie(this.sessionCookie, token)], ['set-cookie', this.setCookie(this.flowCookie, '', 0)]]; }
165
+ clearSession() { return [['set-cookie', this.setCookie(this.sessionCookie, '', 0)]]; }
166
+ }
167
+ export function httpFailure(error, request, presentation, recovery, ui) {
168
+ const known = error instanceof AuthHttpError || (error instanceof Error && 'status' in error && typeof error.status === 'number' && error.status >= 400 && error.status < 500);
169
+ const status = known ? error.status : 500;
170
+ const source = error instanceof AuthHttpError ? error.message : status >= 500 ? 'Service unavailable' : 'Request could not be completed';
171
+ const message = presentation?.textSource(source) ?? source;
172
+ return wantsJson(request) ? jsonResponse(status, { error: message }) : screenResponse('Request could not be completed', { name: 'auth/status', view: { alert: true, message: presentation?.textSource(message) ?? message, href: recovery?.href ?? null, label: recovery?.label ?? null } }, { status, presentation, layout: 'compact', ui });
173
+ }
174
+ /** Proof token stays in the submitting form and is consumed once with the primary proof. */
175
+ export function secondFactorButton(base, text = value => value) {
176
+ return `<button type="button" data-passkey="second-factor" data-base="${escapeHtml(base)}" 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'))}" data-confirmed="${escapeHtml(text('Passkey confirmed. Continue signing in.'))}">${escapeHtml(text('Use a passkey as your second factor'))}</button><input type="hidden" name="secondFactorToken" value=""><p role="status" aria-live="polite" data-passkey-status></p>`;
177
+ }
178
+ /** Browser glue for maintained server-side WebAuthn verification. No guest scripts. */
179
+ export const passkeyScript = String.raw `(() => {
180
+ const decode=value=>Uint8Array.from(atob(value.replace(/-/g,'+').replace(/_/g,'/')),c=>c.charCodeAt(0));
181
+ const encode=value=>{let text='';for(const byte of new Uint8Array(value))text+=String.fromCharCode(byte);return btoa(text).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');};
182
+ for(const button of document.querySelectorAll('[data-passkey]'))button.addEventListener('click',async()=>{
183
+ const form=button.closest('form'),status=form?.querySelector('[data-passkey-status]')||document.querySelector('[data-passkey-status]');button.disabled=true;
184
+ try{
185
+ if(!window.PublicKeyCredential||!navigator.credentials)throw new Error(button.dataset.unavailable);
186
+ const base=button.dataset.base,kind=button.dataset.passkey,csrf=(form||document).querySelector('input[name="csrf"]').value;
187
+ const post=async(path,data)=>{const response=await fetch(base+path,{method:'POST',headers:{'content-type':'application/json','x-csrf-token':csrf,accept:'application/json'},body:JSON.stringify(data)});const result=await response.json();if(!response.ok)throw new Error(button.dataset.failed);return result;};
188
+ const prefix=kind==='signup'?'/signup/passkeys':kind==='second-factor'?'/second-factor':'/passkeys/'+kind;
189
+ const challengeToken=kind==='login'?form?.querySelector('input[name="challengeToken"]')?.value:undefined;
190
+ const started=await post(prefix+'/options',challengeToken?{challengeToken}:{}),options=started.options;options.challenge=decode(options.challenge);
191
+ if(options.user)options.user.id=decode(options.user.id);
192
+ for(const item of options.excludeCredentials||options.allowCredentials||[])item.id=decode(item.id);
193
+ const credential=(kind==='register'||kind==='signup')?await navigator.credentials.create({publicKey:options}):await navigator.credentials.get({publicKey:options});
194
+ if(!credential)throw new Error(button.dataset.cancelled);
195
+ const response={id:credential.id,rawId:encode(credential.rawId),type:credential.type,clientExtensionResults:credential.getClientExtensionResults(),response:{clientDataJSON:encode(credential.response.clientDataJSON)}};
196
+ if(credential.authenticatorAttachment)response.authenticatorAttachment=credential.authenticatorAttachment;
197
+ if(kind==='register'||kind==='signup'){response.response.attestationObject=encode(credential.response.attestationObject);response.response.transports=credential.response.getTransports?.()||[];}
198
+ else {response.response.authenticatorData=encode(credential.response.authenticatorData);response.response.signature=encode(credential.response.signature);response.response.userHandle=credential.response.userHandle?encode(credential.response.userHandle):null;}
199
+ const scope=form||document,totp=scope.querySelector('input[name="totp"]')?.value,recoveryCode=scope.querySelector('input[name="recoveryCode"]')?.value,secondFactorToken=scope.querySelector('input[name="secondFactorToken"]')?.value;
200
+ const verified=await post(prefix+'/verify',{...(started.flowId?{flowId:started.flowId}:{}),response,...(kind!=='second-factor'&&totp?{totp}:{}),...(kind!=='second-factor'&&recoveryCode?{recoveryCode}:{}),...(kind!=='second-factor'&&secondFactorToken?{secondFactorToken}:{})});
201
+ if(kind==='second-factor'){if(!form)throw new Error(button.dataset.failed);form.querySelector('input[name="secondFactorToken"]').value=verified.secondFactorToken;status.textContent=button.dataset.confirmed;return;}
202
+ location.assign(base+(kind==='signup'?'/signup':'/account'));
203
+ }catch(error){status.textContent=error instanceof Error&&[button.dataset.unavailable,button.dataset.failed,button.dataset.cancelled].includes(error.message)?error.message:button.dataset.failed;}finally{button.disabled=false;}
204
+ });
205
+ })();`;
package/dist/auth.d.ts ADDED
@@ -0,0 +1,49 @@
1
+ import type { AuthChallenge } from './challenge.ts';
2
+ import type { FactorRecoveryMessage } from './factor-recovery.ts';
3
+ import type { Presentation } from './presentation.ts';
4
+ import type { OidcProvider } from './oidc.ts';
5
+ import type { PasskeyProvider } from './passkeys.ts';
6
+ import type { RuntimeExtension } from '@jimhoyd/urlcode/extensions';
7
+ import type { AuthService, AuthPrincipal, AuthUser } from './auth-core.ts';
8
+ import type { UiHost } from './auth-ui.ts';
9
+ export interface AuthExtensionOptions {
10
+ challenge?: AuthChallenge;
11
+ sendFactorRecovery?: (message: FactorRecoveryMessage) => Promise<void>;
12
+ presentation?: Presentation;
13
+ /** The `ui` extension from `createUiExtension`, declared before auth in the host file. Screens then render through its kit. */
14
+ ui?: UiHost;
15
+ service: AuthService;
16
+ csrfKey: Uint8Array;
17
+ projectSha256: string;
18
+ providers?: Record<string, OidcProvider>;
19
+ passkeys?: PasskeyProvider;
20
+ sendSignupCode?: (message: {
21
+ email: string;
22
+ code: string;
23
+ locale?: string;
24
+ signal: AbortSignal;
25
+ }) => Promise<void>;
26
+ sendEmailCode?: (message: {
27
+ email: string;
28
+ flowId: string;
29
+ code: string;
30
+ locale?: string;
31
+ signal: AbortSignal;
32
+ }) => Promise<void>;
33
+ sendNotice?: (message: {
34
+ email: string;
35
+ event: 'password-changed' | 'new-device' | 'registration-attempt' | 'email-changed';
36
+ locale?: string;
37
+ signal: AbortSignal;
38
+ }) => Promise<void>;
39
+ sendToken?: (message: {
40
+ email: string;
41
+ token: string;
42
+ purpose: 'verify-email' | 'reset-password' | 'cancel-deletion' | 'verify-email-change' | 'cancel-email-change';
43
+ locale?: string;
44
+ signal: AbortSignal;
45
+ }) => Promise<void>;
46
+ }
47
+ export declare function hasPermission(principal: AuthPrincipal, permission: string): boolean;
48
+ export declare function authExtension(options: AuthExtensionOptions): RuntimeExtension;
49
+ export type { AuthUser };