@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,35 @@
1
+ import { createPresentation } from "./presentation.js";
2
+ import { AuthHttp, AuthHttpError, csrfField, escapeHtml, formField, jsonResponse, readFields, screenResponse, wantsJson } from "./auth-ui.js";
3
+ import { Markup } from '@jimhoyd/urlcode-ui';
4
+ export function validateRecoveryEvidence(input) {
5
+ if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some(key => !['summary', 'reference'].includes(key)))
6
+ throw new Error('Invalid recovery evidence');
7
+ const bounded = (value, max) => typeof value === 'string' && value.trim().length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
8
+ if (!bounded(input.summary, 2000) || input.reference !== undefined && !bounded(input.reference, 256))
9
+ throw new Error('Invalid recovery evidence');
10
+ return { summary: input.summary.trim(), ...(input.reference !== undefined ? { reference: input.reference.trim() } : {}) };
11
+ }
12
+ /** Redemption is POST-only and creates an enrollment session, never normal access. */
13
+ export function createManualRecoveryFlows(service, http, mount, ui) {
14
+ return { async handle(request, presentation = createPresentation().resolve()) {
15
+ const tr = (key) => presentation.text('manualRecovery.' + key);
16
+ if (request.path.slice(mount.length) !== '/restore-access')
17
+ return;
18
+ if (!service.getManualRecoveryEnabled())
19
+ throw new AuthHttpError(404, 'Not found');
20
+ if (!['GET', 'HEAD', 'POST'].includes(request.method))
21
+ throw new AuthHttpError(405, 'GET, HEAD or POST required');
22
+ if (request.method !== 'POST') {
23
+ const tokens = request.query.getAll('token');
24
+ if (tokens.length !== 1 || !/^[A-Za-z0-9_-]{43}$/.test(tokens[0]))
25
+ throw new AuthHttpError(400, 'A single restoration token is required');
26
+ const prepared = http.prepare(request);
27
+ return screenResponse(tr('restoreTitle'), { name: 'auth/restore-access', view: { intro: tr('restoreIntro'), form: new Markup(`<form method="post" action="${escapeHtml(mount + '/restore-access?lang=' + encodeURIComponent(presentation.locale))}">${csrfField(prepared.csrf)}<input type="hidden" name="token" value="${escapeHtml(tokens[0])}">${formField('password', tr('newPassword'), 'password', 'new-password')}<button type="submit">${escapeHtml(tr('replace'))}</button></form>`) } }, { status: 200, headers: prepared.headers, presentation, layout: 'compact', ui });
28
+ }
29
+ const fields = readFields(request, ['token', 'password']);
30
+ http.verify(request, fields);
31
+ const result = await service.redeemRecoveryCase({ token: fields.token || '', password: fields.password || '' });
32
+ const headers = http.sessionHeaders(result.token);
33
+ return wantsJson(request) ? jsonResponse(200, { enrollmentRequired: true, user: result.user, csrf: http.token(result.token) }, headers) : jsonResponse(303, { enrollmentRequired: true }, [['location', mount + '/account?lang=' + encodeURIComponent(presentation.locale)], ...headers]);
34
+ } };
35
+ }
package/dist/oidc.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ /** Secrets in this flow stay in encrypted, single-use server storage. */
2
+ export interface OidcFlow {
3
+ state: string;
4
+ nonce: string;
5
+ verifier: string;
6
+ }
7
+ export interface OidcIdentity {
8
+ issuer: string;
9
+ subject: string;
10
+ email?: string;
11
+ emailVerified: boolean;
12
+ }
13
+ export interface OidcProviderOptions {
14
+ issuer: string;
15
+ clientId: string;
16
+ clientSecret: string;
17
+ redirectUri: string;
18
+ /** Trusted operator transport, useful for offline provider fixtures. */
19
+ fetch?: typeof globalThis.fetch;
20
+ scope?: 'openid email' | 'name email';
21
+ responseMode?: 'query' | 'form_post';
22
+ appleEmailClaim?: boolean;
23
+ }
24
+ export interface OidcProvider {
25
+ start(): Promise<{
26
+ url: string;
27
+ flow: OidcFlow;
28
+ }>;
29
+ complete(callback: URL | Request, flow: OidcFlow): Promise<OidcIdentity>;
30
+ }
31
+ export declare function createOidcProvider(options: OidcProviderOptions): Promise<OidcProvider>;
package/dist/oidc.js ADDED
@@ -0,0 +1,54 @@
1
+ import * as oidc from 'openid-client';
2
+ export async function createOidcProvider(options) {
3
+ const issuer = new URL(options.issuer), redirect = new URL(options.redirectUri);
4
+ if (issuer.protocol !== 'https:' || issuer.username || issuer.password || issuer.hash || issuer.search)
5
+ throw new Error('OIDC issuer must be an operator-supplied HTTPS URL');
6
+ if (redirect.protocol !== 'https:' || redirect.username || redirect.password || redirect.hash || redirect.search)
7
+ throw new Error('OIDC callback must be a fixed HTTPS URL');
8
+ if (!options.clientId || !options.clientSecret)
9
+ throw new Error('OIDC client credentials are required');
10
+ const transport = async (url, init) => {
11
+ const endpoint = new URL(String(url));
12
+ if (endpoint.protocol !== 'https:' || endpoint.username || endpoint.password)
13
+ throw new Error('OIDC endpoint must use HTTPS');
14
+ const response = await (options.fetch ?? globalThis.fetch)(url, { ...init, body: init.body instanceof Uint8Array ? new Uint8Array(init.body) : init.body ?? null, redirect: 'error', signal: AbortSignal.any([...(init?.signal ? [init.signal] : []), AbortSignal.timeout(10000)]) });
15
+ if (response.status >= 300 && response.status < 400)
16
+ throw new Error('OIDC redirects are not accepted');
17
+ const reader = response.body?.getReader();
18
+ const chunks = [];
19
+ let size = 0;
20
+ if (reader)
21
+ try {
22
+ for (;;) {
23
+ const part = await reader.read();
24
+ if (part.done)
25
+ break;
26
+ size += part.value.byteLength;
27
+ if (size > 1048576)
28
+ throw new Error('OIDC response exceeds limit');
29
+ chunks.push(part.value);
30
+ }
31
+ }
32
+ finally {
33
+ await reader.cancel().catch(() => { });
34
+ reader.releaseLock();
35
+ }
36
+ return new Response(Buffer.concat(chunks), { status: response.status, headers: response.headers });
37
+ };
38
+ const config = await oidc.discovery(issuer, options.clientId, options.clientSecret, undefined, { [oidc.customFetch]: transport, timeout: 10, execute: [oidc.enableNonRepudiationChecks] });
39
+ return {
40
+ async start() { const flow = { state: oidc.randomState(), nonce: oidc.randomNonce(), verifier: oidc.randomPKCECodeVerifier() }; const url = oidc.buildAuthorizationUrl(config, { redirect_uri: redirect.href, scope: options.scope ?? 'openid email', ...(options.responseMode ? { response_mode: options.responseMode } : {}), response_type: 'code', state: flow.state, nonce: flow.nonce, code_challenge: await oidc.calculatePKCECodeChallenge(flow.verifier), code_challenge_method: 'S256' }); return { url: url.href, flow }; },
41
+ async complete(callback, flow) {
42
+ const url = callback instanceof URL ? callback : new URL(callback.url);
43
+ if (url.origin !== redirect.origin || url.pathname !== redirect.pathname)
44
+ throw new Error('OIDC callback mismatch');
45
+ if (!flow || ![flow.state, flow.nonce, flow.verifier].every(value => typeof value === 'string' && value.length >= 32 && value.length <= 256))
46
+ throw new Error('Invalid OIDC flow');
47
+ const tokens = await oidc.authorizationCodeGrant(config, callback, { expectedState: flow.state, expectedNonce: flow.nonce, pkceCodeVerifier: flow.verifier, idTokenExpected: true });
48
+ const claims = tokens.claims();
49
+ if (!claims || typeof claims.sub !== 'string' || !claims.sub || claims.sub.length > 1024)
50
+ throw new Error('Invalid OIDC identity');
51
+ return { issuer: config.serverMetadata().issuer, subject: claims.sub, ...(typeof claims.email === 'string' && claims.email.length <= 320 ? { email: claims.email } : {}), emailVerified: claims.email_verified === true || (options.appleEmailClaim === true && claims.email_verified === 'true') };
52
+ }
53
+ };
54
+ }
@@ -0,0 +1,24 @@
1
+ import type { RegistrationResponseJSON, AuthenticationResponseJSON } from '@simplewebauthn/server';
2
+ export interface StoredPasskey {
3
+ id: string;
4
+ publicKey: string;
5
+ counter: number;
6
+ transports?: string[];
7
+ }
8
+ export interface PasskeyProviderOptions {
9
+ origin: string;
10
+ rpId: string;
11
+ rpName: string;
12
+ }
13
+ export declare function createPasskeyProvider({ origin, rpId, rpName }: PasskeyProviderOptions): {
14
+ beginRegistration(user: {
15
+ id: string;
16
+ email: string;
17
+ }, exclude?: StoredPasskey[]): Promise<import("@simplewebauthn/server").PublicKeyCredentialCreationOptionsJSON>;
18
+ verifyRegistration(response: RegistrationResponseJSON, challenge: string): Promise<StoredPasskey>;
19
+ beginAuthentication(): Promise<import("@simplewebauthn/server").PublicKeyCredentialRequestOptionsJSON>;
20
+ verifyAuthentication(response: AuthenticationResponseJSON, challenge: string, stored: StoredPasskey): Promise<{
21
+ counter: number;
22
+ }>;
23
+ };
24
+ export type PasskeyProvider = ReturnType<typeof createPasskeyProvider>;
@@ -0,0 +1,29 @@
1
+ import { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse } from '@simplewebauthn/server';
2
+ export function createPasskeyProvider({ origin, rpId, rpName }) {
3
+ const url = new URL(origin);
4
+ // Exact hostname deliberately avoids accidental sibling-site credential sharing.
5
+ if (url.origin !== origin || url.protocol !== 'https:' || rpId !== url.hostname || !rpName || rpName.length > 128)
6
+ throw new Error('Passkeys require a canonical HTTPS origin and matching RP hostname');
7
+ return {
8
+ beginRegistration(user, exclude = []) {
9
+ if (!user.id || Buffer.byteLength(user.id) > 64 || exclude.length > 100)
10
+ throw new Error('Invalid passkey registration');
11
+ return generateRegistrationOptions({ rpID: rpId, rpName, userID: new TextEncoder().encode(user.id), userName: user.email, attestationType: 'none', authenticatorSelection: { residentKey: 'required', userVerification: 'required' }, excludeCredentials: exclude.map(item => ({ id: item.id, ...(item.transports ? { transports: item.transports } : {}) })) });
12
+ },
13
+ async verifyRegistration(response, challenge) {
14
+ const result = await verifyRegistrationResponse({ response, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpId, requireUserVerification: true });
15
+ if (!result.verified)
16
+ throw new Error('Passkey registration refused');
17
+ const credential = result.registrationInfo.credential;
18
+ return { id: credential.id, publicKey: Buffer.from(credential.publicKey).toString('base64url'), counter: credential.counter, ...(credential.transports ? { transports: credential.transports } : {}) };
19
+ },
20
+ beginAuthentication() { return generateAuthenticationOptions({ rpID: rpId, userVerification: 'required' }); },
21
+ async verifyAuthentication(response, challenge, stored) {
22
+ const credential = { id: stored.id, publicKey: new Uint8Array(Buffer.from(stored.publicKey, 'base64url')), counter: stored.counter, ...(stored.transports ? { transports: stored.transports } : {}) };
23
+ const result = await verifyAuthenticationResponse({ response, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpId, credential, requireUserVerification: true });
24
+ if (!result.verified || !result.authenticationInfo.userVerified)
25
+ throw new Error('Passkey authentication refused');
26
+ return { counter: result.authenticationInfo.newCounter };
27
+ }
28
+ };
29
+ }
@@ -0,0 +1,7 @@
1
+ export interface PasswordBreachOptions {
2
+ fetch?: typeof fetch;
3
+ timeoutMs?: number;
4
+ }
5
+ /** Opt-in operator transport. SHA-1 is the range lookup protocol, never password storage.
6
+ * https://haveibeenpwned.com/API/v3#PwnedPasswords */
7
+ export declare function createPasswordBreachChecker(options?: PasswordBreachOptions): (password: string) => Promise<void>;
@@ -0,0 +1,72 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { AuthError } from "./auth-core.js";
3
+ /** Opt-in operator transport. SHA-1 is the range lookup protocol, never password storage.
4
+ * https://haveibeenpwned.com/API/v3#PwnedPasswords */
5
+ export function createPasswordBreachChecker(options = {}) {
6
+ const transport = options.fetch ?? fetch, timeout = options.timeoutMs ?? 3000;
7
+ let active = 0;
8
+ if (!Number.isSafeInteger(timeout) || timeout < 10 || timeout > 10000)
9
+ throw new Error('Invalid breach-check deadline');
10
+ return async (password) => {
11
+ if (typeof password !== 'string' || !password || Buffer.byteLength(password) > 1024)
12
+ throw new AuthError(400, 'password_length_invalid');
13
+ if (active >= 4)
14
+ throw new AuthError(503, 'password_check_unavailable');
15
+ const digest = createHash('sha1').update(password, 'utf8').digest('hex').toUpperCase();
16
+ const controller = new AbortController();
17
+ let timer;
18
+ active++;
19
+ const work = (async () => {
20
+ const response = await transport('https://api.pwnedpasswords.com/range/' + digest.slice(0, 5), { method: 'GET', redirect: 'error', headers: { 'Add-Padding': 'true', 'User-Agent': 'URLCode-auth', 'Accept': 'text/plain' }, signal: controller.signal });
21
+ if (response.status !== 200 || !response.body) {
22
+ await response.body?.cancel();
23
+ throw new Error('Unavailable');
24
+ }
25
+ const reader = response.body.getReader(), chunks = [];
26
+ let bytes = 0;
27
+ try {
28
+ while (true) {
29
+ const chunk = await reader.read();
30
+ if (chunk.done)
31
+ break;
32
+ bytes += chunk.value.byteLength;
33
+ if (bytes > 1048576)
34
+ throw new Error('Oversized response');
35
+ chunks.push(chunk.value);
36
+ }
37
+ }
38
+ finally {
39
+ await reader.cancel().catch(() => { });
40
+ reader.releaseLock();
41
+ }
42
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks));
43
+ const lines = text.trim().split(/\r?\n/);
44
+ if (!text.trim() || lines.length > 20000)
45
+ throw new Error('Invalid response');
46
+ let breached = false;
47
+ for (const line of lines) {
48
+ const entry = /^([A-F0-9]{35}):([0-9]{1,12})$/.exec(line);
49
+ if (!entry)
50
+ throw new Error('Invalid response');
51
+ if (entry[1] === digest.slice(5) && Number(entry[2]) > 0)
52
+ breached = true;
53
+ }
54
+ return breached;
55
+ })().finally(() => { active--; });
56
+ try {
57
+ const breached = await Promise.race([work, new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(new Error('Timeout')); }, timeout); })]);
58
+ if (breached)
59
+ throw new AuthError(400, 'password_compromised');
60
+ }
61
+ catch (error) {
62
+ if (error instanceof AuthError)
63
+ throw error;
64
+ throw new AuthError(503, 'password_check_unavailable');
65
+ }
66
+ finally {
67
+ if (timer)
68
+ clearTimeout(timer);
69
+ controller.abort();
70
+ }
71
+ };
72
+ }
@@ -0,0 +1,15 @@
1
+ import type { Catalogue, Presentation, PresentationOptions as UiPresentationOptions, ThemeVariables as UiThemeVariables } from '@jimhoyd/urlcode-ui';
2
+ export type { PluralMessage, Catalogue, LocalePreferences, PresentationContext, Presentation } from '@jimhoyd/urlcode-ui';
3
+ export declare const englishCatalogue: Readonly<Catalogue>;
4
+ export interface ThemeVariables extends UiThemeVariables {
5
+ '--auth-background'?: string;
6
+ '--auth-foreground'?: string;
7
+ '--auth-accent'?: string;
8
+ '--auth-border'?: string;
9
+ '--auth-radius'?: string;
10
+ }
11
+ export interface PresentationOptions extends Omit<UiPresentationOptions, 'theme' | 'defaults'> {
12
+ theme?: ThemeVariables;
13
+ }
14
+ /** Legacy auth theme names are adapted here; the shared UI package knows no auth fields. */
15
+ export declare function createPresentation(options?: PresentationOptions): Presentation;