@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.
- package/dist/abuse-http.d.ts +8 -0
- package/dist/abuse-http.js +74 -0
- package/dist/abuse-store.d.ts +5 -0
- package/dist/abuse-store.js +40 -0
- package/dist/abuse.d.ts +27 -0
- package/dist/abuse.js +34 -0
- package/dist/admin-account-operations.d.ts +83 -0
- package/dist/admin-account-operations.js +50 -0
- package/dist/admin-account-store.d.ts +22 -0
- package/dist/admin-account-store.js +185 -0
- package/dist/auth-baseline.d.ts +30 -0
- package/dist/auth-baseline.js +153 -0
- package/dist/auth-core.d.ts +655 -0
- package/dist/auth-core.js +1066 -0
- package/dist/auth-flows.d.ts +30 -0
- package/dist/auth-flows.js +228 -0
- package/dist/auth-signup.d.ts +11 -0
- package/dist/auth-signup.js +145 -0
- package/dist/auth-store.d.ts +81 -0
- package/dist/auth-store.js +1601 -0
- package/dist/auth-templates.d.ts +13 -0
- package/dist/auth-templates.js +74 -0
- package/dist/auth-ui.d.ts +106 -0
- package/dist/auth-ui.js +205 -0
- package/dist/auth.d.ts +49 -0
- package/dist/auth.js +503 -0
- package/dist/backup.d.ts +18 -0
- package/dist/backup.js +121 -0
- package/dist/challenge-ui.d.ts +11 -0
- package/dist/challenge-ui.js +18 -0
- package/dist/challenge.d.ts +21 -0
- package/dist/challenge.js +65 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +137 -0
- package/dist/deployment-check.d.ts +16 -0
- package/dist/deployment-check.js +41 -0
- package/dist/disposable-domain-data.d.ts +1 -0
- package/dist/disposable-domain-data.js +8886 -0
- package/dist/disposable-domains.d.ts +3 -0
- package/dist/disposable-domains.js +17 -0
- package/dist/email-copy.d.ts +114 -0
- package/dist/email-copy.js +58 -0
- package/dist/factor-recovery.d.ts +46 -0
- package/dist/factor-recovery.js +71 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.js +18 -0
- package/dist/manual-recovery-store.d.ts +25 -0
- package/dist/manual-recovery-store.js +129 -0
- package/dist/manual-recovery.d.ts +87 -0
- package/dist/manual-recovery.js +35 -0
- package/dist/oidc.d.ts +31 -0
- package/dist/oidc.js +54 -0
- package/dist/passkeys.d.ts +24 -0
- package/dist/passkeys.js +29 -0
- package/dist/password-policy.d.ts +7 -0
- package/dist/password-policy.js +72 -0
- package/dist/presentation.d.ts +15 -0
- package/dist/presentation.js +458 -0
- package/dist/presets.d.ts +18 -0
- package/dist/presets.js +17 -0
- package/dist/providers.d.ts +13 -0
- package/dist/providers.js +15 -0
- package/dist/registration.d.ts +45 -0
- package/dist/registration.js +130 -0
- package/dist/scaffold.d.ts +44 -0
- package/dist/scaffold.js +212 -0
- package/dist/second-factor-flows.d.ts +28 -0
- package/dist/second-factor-flows.js +76 -0
- package/dist/senders.d.ts +86 -0
- package/dist/senders.js +153 -0
- package/dist/user-query.d.ts +28 -0
- package/dist/user-query.js +81 -0
- package/package.json +1 -1
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
const forbidden = new Set(['__proto__', 'prototype', 'constructor', 'role', 'roles', 'permission', 'permissions', 'session', 'sessions', 'sessionid', 'token', 'password', 'passwordhash', 'secret', 'secrets', 'emailverified', 'status', 'administrator', 'id', 'userid', 'accountid', 'authenticatedat', 'totp', 'totpsecret', 'recoverycodes', 'claims', 'auth', 'security']);
|
|
2
|
+
function object(value) { return Boolean(value && typeof value === 'object' && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)); }
|
|
3
|
+
function locale(value) {
|
|
4
|
+
if (typeof value !== 'string' || value.length > 64 || !/^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/.test(value))
|
|
5
|
+
throw new Error('Invalid profile locale');
|
|
6
|
+
try {
|
|
7
|
+
return Intl.getCanonicalLocales(value)[0];
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
throw new Error('Invalid profile locale');
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function validValue(value, field) {
|
|
14
|
+
if (typeof value !== field.type)
|
|
15
|
+
return false;
|
|
16
|
+
if (typeof value === 'string' && (value.length > (field.maxLength ?? 1024) || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value)))
|
|
17
|
+
return false;
|
|
18
|
+
if (typeof value === 'number' && (!Number.isFinite(value) || Math.abs(value) > 1e12 || field.minimum !== undefined && value < field.minimum || field.maximum !== undefined && value > field.maximum))
|
|
19
|
+
return false;
|
|
20
|
+
return !field.enum || field.enum.includes(value);
|
|
21
|
+
}
|
|
22
|
+
export function createRegistrationPolicy(options = {}) {
|
|
23
|
+
if (!object(options) || Object.keys(options).some(key => !['termsVersion', 'metadata', 'locales'].includes(key)))
|
|
24
|
+
throw new Error('Unknown registration option');
|
|
25
|
+
const terms = options.termsVersion;
|
|
26
|
+
if (terms !== undefined && (typeof terms !== 'string' || !terms || terms.length > 128 || /[\x00-\x1f\x7f]/.test(terms)))
|
|
27
|
+
throw new Error('Invalid terms version');
|
|
28
|
+
if (options.locales !== undefined && (!Array.isArray(options.locales) || !options.locales.length || options.locales.length > 32))
|
|
29
|
+
throw new Error('Invalid profile locales');
|
|
30
|
+
const locales = options.locales?.map(locale);
|
|
31
|
+
if (locales && new Set(locales).size !== locales.length)
|
|
32
|
+
throw new Error('Duplicate profile locale');
|
|
33
|
+
const source = options.metadata ?? {};
|
|
34
|
+
if (!object(source) || Object.keys(source).length > 32)
|
|
35
|
+
throw new Error('Invalid metadata schema');
|
|
36
|
+
const fields = Object.create(null);
|
|
37
|
+
for (const [name, input] of Object.entries(source)) {
|
|
38
|
+
if (!/^[a-zA-Z][a-zA-Z0-9_]{0,63}$/.test(name) || forbidden.has(name.replaceAll('_', '').toLowerCase()))
|
|
39
|
+
throw new Error('Reserved or invalid metadata field');
|
|
40
|
+
if (!object(input) || Object.keys(input).some(key => !['type', 'scope', 'required', 'default', 'enum', 'maxLength', 'minimum', 'maximum'].includes(key)) || !['string', 'number', 'boolean'].includes(String(input.type)) || !['public', 'private', 'unsafe'].includes(String(input.scope)))
|
|
41
|
+
throw new Error('Invalid metadata field schema');
|
|
42
|
+
const field = structuredClone(input);
|
|
43
|
+
if (field.required !== undefined && typeof field.required !== 'boolean')
|
|
44
|
+
throw new Error('Invalid required flag');
|
|
45
|
+
if (field.maxLength !== undefined && (field.type !== 'string' || !Number.isInteger(field.maxLength) || field.maxLength < 1 || field.maxLength > 4096))
|
|
46
|
+
throw new Error('Invalid string bound');
|
|
47
|
+
for (const bound of [field.minimum, field.maximum])
|
|
48
|
+
if (bound !== undefined && (field.type !== 'number' || !Number.isFinite(bound) || Math.abs(bound) > 1e12))
|
|
49
|
+
throw new Error('Invalid numeric bound');
|
|
50
|
+
if (field.minimum !== undefined && field.maximum !== undefined && field.minimum > field.maximum)
|
|
51
|
+
throw new Error('Inverted numeric bounds');
|
|
52
|
+
if (field.enum !== undefined && (!Array.isArray(field.enum) || !field.enum.length || field.enum.length > 64 || field.enum.some(value => !validValue(value, { type: field.type, scope: field.scope, ...(field.maxLength !== undefined ? { maxLength: field.maxLength } : {}), ...(field.minimum !== undefined ? { minimum: field.minimum } : {}), ...(field.maximum !== undefined ? { maximum: field.maximum } : {}) }))))
|
|
53
|
+
throw new Error('Invalid metadata enum');
|
|
54
|
+
if (Object.hasOwn(field, 'default') && !validValue(field.default, field))
|
|
55
|
+
throw new Error('Invalid metadata default');
|
|
56
|
+
if (field.enum)
|
|
57
|
+
Object.freeze(field.enum);
|
|
58
|
+
fields[name] = Object.freeze(field);
|
|
59
|
+
}
|
|
60
|
+
Object.freeze(fields);
|
|
61
|
+
const validate = (input, context) => {
|
|
62
|
+
if (!object(input) || Object.keys(input).some(key => !['displayName', 'locale', 'metadata', 'termsAccepted'].includes(key)))
|
|
63
|
+
throw new Error('Unknown profile field');
|
|
64
|
+
if (!Number.isSafeInteger(context.now) || context.now < 0)
|
|
65
|
+
throw new Error('Invalid acceptance time');
|
|
66
|
+
const existing = context.existing;
|
|
67
|
+
const result = { metadata: Object.create(null) };
|
|
68
|
+
const name = input.displayName ?? existing?.displayName;
|
|
69
|
+
if (name !== undefined) {
|
|
70
|
+
if (typeof name !== 'string' || [...name].length > 100 || /[\x00-\x1f\x7f]/.test(name))
|
|
71
|
+
throw new Error('Invalid display name');
|
|
72
|
+
result.displayName = name;
|
|
73
|
+
}
|
|
74
|
+
const language = input.locale ?? existing?.locale;
|
|
75
|
+
if (language !== undefined) {
|
|
76
|
+
const normalized = locale(language);
|
|
77
|
+
if (locales && !locales.includes(normalized))
|
|
78
|
+
throw new Error('Profile locale is not enabled');
|
|
79
|
+
result.locale = normalized;
|
|
80
|
+
}
|
|
81
|
+
if (input.metadata !== undefined && !object(input.metadata))
|
|
82
|
+
throw new Error('Invalid metadata values');
|
|
83
|
+
for (const [name, value] of Object.entries(existing?.metadata ?? {})) {
|
|
84
|
+
const field = fields[name];
|
|
85
|
+
if (!field || !validValue(value, field))
|
|
86
|
+
throw new Error('Stored metadata does not match schema');
|
|
87
|
+
result.metadata[name] = value;
|
|
88
|
+
}
|
|
89
|
+
for (const [name, value] of Object.entries(input.metadata ?? {})) {
|
|
90
|
+
const field = fields[name];
|
|
91
|
+
if (!field)
|
|
92
|
+
throw new Error('Unknown metadata field');
|
|
93
|
+
if (field.scope === 'private' && context.operator !== true)
|
|
94
|
+
throw new Error('Private metadata requires operator authority');
|
|
95
|
+
if (!validValue(value, field))
|
|
96
|
+
throw new Error('Invalid metadata value');
|
|
97
|
+
result.metadata[name] = value;
|
|
98
|
+
}
|
|
99
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
100
|
+
if (!Object.hasOwn(result.metadata, name) && Object.hasOwn(field, 'default'))
|
|
101
|
+
result.metadata[name] = field.default;
|
|
102
|
+
if (field.required && !Object.hasOwn(result.metadata, name))
|
|
103
|
+
throw new Error('Required metadata is missing');
|
|
104
|
+
}
|
|
105
|
+
if (JSON.stringify(result.metadata).length > 16384)
|
|
106
|
+
throw new Error('Metadata exceeds total bound');
|
|
107
|
+
if (input.termsAccepted !== undefined && typeof input.termsAccepted !== 'boolean')
|
|
108
|
+
throw new Error('Invalid terms acceptance');
|
|
109
|
+
if (existing?.terms)
|
|
110
|
+
result.terms = { ...existing.terms };
|
|
111
|
+
if (terms) {
|
|
112
|
+
if (input.termsAccepted === true)
|
|
113
|
+
result.terms = { version: terms, acceptedAt: context.now };
|
|
114
|
+
else if (existing?.terms?.version !== terms)
|
|
115
|
+
throw new Error('Current terms acceptance required');
|
|
116
|
+
}
|
|
117
|
+
return result;
|
|
118
|
+
};
|
|
119
|
+
return Object.freeze({ validate, publicSchema() { return structuredClone({ ...(terms ? { termsVersion: terms } : {}), ...(locales ? { locales } : {}), metadata: Object.fromEntries(Object.entries(fields).filter(([, field]) => field.scope !== 'private')) }); }, publicProfile(profile) {
|
|
120
|
+
const result = { metadata: Object.create(null), ...(profile.displayName !== undefined ? { displayName: profile.displayName } : {}), ...(profile.locale !== undefined ? { locale: profile.locale } : {}), ...(profile.terms ? { terms: { ...profile.terms } } : {}) };
|
|
121
|
+
for (const [name, value] of Object.entries(profile.metadata)) {
|
|
122
|
+
const field = fields[name];
|
|
123
|
+
if (field && field.scope !== 'private' && validValue(value, field))
|
|
124
|
+
result.metadata[name] = value;
|
|
125
|
+
}
|
|
126
|
+
return result;
|
|
127
|
+
} });
|
|
128
|
+
}
|
|
129
|
+
/** Quiet refusal is an HTTP integration decision; this helper never creates a fake account or session. */
|
|
130
|
+
export function isHoneypotFilled(value) { return value !== undefined && value !== ''; }
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export interface AuthenticationScaffold {
|
|
2
|
+
directory: string;
|
|
3
|
+
project: string;
|
|
4
|
+
hostFile: string;
|
|
5
|
+
operatorFile: string;
|
|
6
|
+
}
|
|
7
|
+
/** Shared scaffold contract (core `urlcode init --with`): what the caller is assembling. */
|
|
8
|
+
export interface ScaffoldRequest {
|
|
9
|
+
/** Root of the generated site; file paths in the result are relative to it. */
|
|
10
|
+
directory: string;
|
|
11
|
+
/** Project directory holding urlcode.yaml. */
|
|
12
|
+
project: string;
|
|
13
|
+
/** Path of the combined host module the caller writes. */
|
|
14
|
+
hostFile: string;
|
|
15
|
+
/** Every extension name being scaffolded together, including this one. */
|
|
16
|
+
names: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
export interface ScaffoldFile {
|
|
19
|
+
path: string;
|
|
20
|
+
content: string | Uint8Array;
|
|
21
|
+
mode?: number;
|
|
22
|
+
}
|
|
23
|
+
export interface ScaffoldResult {
|
|
24
|
+
name: string;
|
|
25
|
+
extensions: Record<string, unknown>;
|
|
26
|
+
routes: Record<string, unknown>;
|
|
27
|
+
hostImports: string[];
|
|
28
|
+
hostSetup: string[];
|
|
29
|
+
hostEntries: string[];
|
|
30
|
+
hostClose?: string[];
|
|
31
|
+
files: ScaffoldFile[];
|
|
32
|
+
readme: string;
|
|
33
|
+
nextSteps: string[];
|
|
34
|
+
env?: Record<string, string>;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Generates the auth pieces of a layered project without touching the filesystem.
|
|
38
|
+
* Key material is created in memory and returned as files; the caller writes them with private modes.
|
|
39
|
+
*/
|
|
40
|
+
export declare function scaffold(request: ScaffoldRequest): Promise<ScaffoldResult>;
|
|
41
|
+
/** Deterministic YAML for scaffold fragments: block mappings, flow lists of scalars, {} for empty maps. */
|
|
42
|
+
export declare function renderYaml(value: Record<string, unknown>, indent?: string): string;
|
|
43
|
+
/** Creates a new private directory only; never merges or overwrites an existing project. */
|
|
44
|
+
export declare function initAuthentication(directory: string): Promise<AuthenticationScaffold>;
|
package/dist/scaffold.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { mkdir, open, realpath, rm } from 'node:fs/promises';
|
|
3
|
+
import { resolve, dirname, basename, join, relative, sep } from 'node:path';
|
|
4
|
+
const OPERATOR_FILE = 'operator-service.mjs', ENCRYPTION_KEY = 'data/encryption.key', CSRF_KEY = 'data/csrf.key';
|
|
5
|
+
function moduleReference(from, to) {
|
|
6
|
+
const path = relative(from, to).split(sep).join('/');
|
|
7
|
+
return path.startsWith('.') ? path : './' + path;
|
|
8
|
+
}
|
|
9
|
+
function shellReference(from, to) {
|
|
10
|
+
return relative(from, to).split(sep).join('/');
|
|
11
|
+
}
|
|
12
|
+
function serviceModule(directory) {
|
|
13
|
+
const here = dirname(join(directory, OPERATOR_FILE));
|
|
14
|
+
return `import {readFile} from 'node:fs/promises';
|
|
15
|
+
import {fileURLToPath} from 'node:url';
|
|
16
|
+
import {createAuthService} from '@jimhoyd/urlcode-auth';
|
|
17
|
+
const key = await readFile(new URL('${moduleReference(here, join(directory, ENCRYPTION_KEY))}', import.meta.url));
|
|
18
|
+
let service;
|
|
19
|
+
try {
|
|
20
|
+
service = await createAuthService({
|
|
21
|
+
database: fileURLToPath(new URL('${moduleReference(here, join(directory, 'data/auth.sqlite'))}', import.meta.url)),
|
|
22
|
+
encryptionKey: key,
|
|
23
|
+
roles: {member: [], admin: ['*']},
|
|
24
|
+
defaultRole: 'member',
|
|
25
|
+
registrationMode: 'off',
|
|
26
|
+
...(process.env.AUTH_CONFIG_FROM ? {approveConfigurationChangeFrom: process.env.AUTH_CONFIG_FROM} : {}),
|
|
27
|
+
});
|
|
28
|
+
} finally { key.fill(0); }
|
|
29
|
+
export default service;
|
|
30
|
+
`;
|
|
31
|
+
}
|
|
32
|
+
function readmeSection(request, admin) {
|
|
33
|
+
const operator = shellReference(request.directory, join(request.directory, OPERATOR_FILE)), project = shellReference(request.directory, request.project), host = shellReference(request.directory, request.hostFile);
|
|
34
|
+
return `This directory separates untrusted route files in ${project}/ from trusted operator modules and private data/. Registration is off. No credentials appear in generated source or command output. Keep the whole data/ directory and operator environment private.${admin ? ' This starter includes auth and admin; the admin console is described in its own section.' : ' This starter includes auth only.'}
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
Use a supported patched Node release. From this directory, install the separately built repositories until packages are released:
|
|
39
|
+
|
|
40
|
+
\`\`\`sh
|
|
41
|
+
# First run npm ci && npm run build in each source repository.
|
|
42
|
+
npm install /absolute/path/to/urlcode /absolute/path/to/urlcode-auth${admin ? ' /absolute/path/to/urlcode-admin' : ''}
|
|
43
|
+
\`\`\`
|
|
44
|
+
|
|
45
|
+
Review the operator modules and ${project}/urlcode.yaml before activation. Set an HTTPS origin served by your TLS proxy. The runtime listener itself can remain on loopback behind that proxy.
|
|
46
|
+
|
|
47
|
+
\`\`\`sh
|
|
48
|
+
export AUTH_ORIGIN='https://accounts.example.com'
|
|
49
|
+
# Inspect only; this command does not grant or load operator code.
|
|
50
|
+
node --input-type=module -e 'import {inspectExtensionRevision} from "@jimhoyd/urlcode/extensions"; console.log(await inspectExtensionRevision("./${project}"))'
|
|
51
|
+
# Paste the reviewed hash explicitly. Re-review after any project change.
|
|
52
|
+
export PROJECT_SHA256='paste-reviewed-64-character-sha256'
|
|
53
|
+
\`\`\`
|
|
54
|
+
|
|
55
|
+
Bootstrap the first administrator using the operator service. Pass JSON on stdin from your secret manager or a private terminal; never put the password in argv or commit it. The JSON shape is {"email":"owner@example.com","password":"a unique password of at least 15 characters"}. The command prints account metadata, never the password or session token.
|
|
56
|
+
|
|
57
|
+
\`\`\`sh
|
|
58
|
+
npx urlcode-auth bootstrap --operator-file "$PWD/${operator}"
|
|
59
|
+
# Paste the JSON on stdin, then end input (Ctrl-D in a terminal).
|
|
60
|
+
npx urlcode serve --project "$PWD/${project}" --host-file "$PWD/${host}" --origin "$AUTH_ORIGIN"
|
|
61
|
+
\`\`\`
|
|
62
|
+
|
|
63
|
+
Sign in at /account/login; /private requires a valid session${admin ? ' and /admin requires the admin role' : ''}. Opening registration requires a reviewed operator configuration migration as well as changing the YAML and setting a new explicit project revision pin. Inspect the current database configuration with the configuration CLI command, then supply its exact hash as AUTH_CONFIG_FROM for the first startup with the new operator settings. This revokes old sessions and pending sign-ins. Stop/restart all service instances; old workers refuse requests after migration. Remove the approval environment variable after the migration. Email recovery is unavailable until an operator sender is configured. Do not serve hostile frontend scripts on this origin: browser JavaScript shares ambient session authority even though guest server code cannot read session headers.
|
|
64
|
+
|
|
65
|
+
Back up the SQLite database through the auth backup API, not by copying a live WAL database. Preserve encryption.key and csrf.key separately in your secret backup system, together with the exact operator configuration. Database backup does not include those key files. A restored snapshot can restore historical sessions and tokens: use a planned revocation and recovery procedure. Generated files and key material are local, not published.
|
|
66
|
+
`;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Generates the auth pieces of a layered project without touching the filesystem.
|
|
70
|
+
* Key material is created in memory and returned as files; the caller writes them with private modes.
|
|
71
|
+
*/
|
|
72
|
+
export async function scaffold(request) {
|
|
73
|
+
if (!request || typeof request !== 'object')
|
|
74
|
+
throw new Error('A scaffold request is required');
|
|
75
|
+
for (const key of ['directory', 'project', 'hostFile']) {
|
|
76
|
+
const value = request[key];
|
|
77
|
+
if (typeof value !== 'string' || !value || value.includes('\0'))
|
|
78
|
+
throw new Error(`Scaffold ${key} is required`);
|
|
79
|
+
}
|
|
80
|
+
if (!Array.isArray(request.names) || request.names.some(name => typeof name !== 'string'))
|
|
81
|
+
throw new Error('Scaffold names must be strings');
|
|
82
|
+
const directory = resolve(request.directory), project = resolve(directory, request.project), hostFile = resolve(directory, request.hostFile);
|
|
83
|
+
const normalized = { directory, project, hostFile, names: request.names };
|
|
84
|
+
const admin = request.names.includes('admin'), hostDirectory = dirname(hostFile);
|
|
85
|
+
const operator = shellReference(directory, join(directory, OPERATOR_FILE)), projectPath = shellReference(directory, project), host = shellReference(directory, hostFile);
|
|
86
|
+
return {
|
|
87
|
+
name: 'auth',
|
|
88
|
+
extensions: { auth: { version: '1', config: { registration: 'off' } } },
|
|
89
|
+
routes: {
|
|
90
|
+
'/account/*': { extension: 'auth', methods: ['GET', 'HEAD', 'POST'] },
|
|
91
|
+
'/private': { respond: { text: 'Signed in' }, policies: { extensions: { auth: {} } } },
|
|
92
|
+
},
|
|
93
|
+
hostImports: ["import {readFile} from 'node:fs/promises';", "import {authExtension} from '@jimhoyd/urlcode-auth';"],
|
|
94
|
+
hostSetup: [
|
|
95
|
+
'// Explicit operator approval, not computed from the project at activation.',
|
|
96
|
+
'const projectSha256 = process.env.PROJECT_SHA256;',
|
|
97
|
+
"if (!projectSha256 || !/^[a-f0-9]{64}$/.test(projectSha256)) throw new Error('Set the reviewed PROJECT_SHA256 revision');",
|
|
98
|
+
"const origin = new URL(process.env.AUTH_ORIGIN || '');",
|
|
99
|
+
"if (origin.protocol !== 'https:' || origin.origin !== process.env.AUTH_ORIGIN || origin.username || origin.password) throw new Error('Set a canonical HTTPS AUTH_ORIGIN');",
|
|
100
|
+
`const {default: service} = await import('${moduleReference(hostDirectory, join(directory, OPERATOR_FILE))}');`,
|
|
101
|
+
'let csrfKey;',
|
|
102
|
+
'try {',
|
|
103
|
+
` csrfKey = await readFile(new URL('${moduleReference(hostDirectory, join(directory, CSRF_KEY))}', import.meta.url));`,
|
|
104
|
+
" if (csrfKey.length !== 32) throw new Error('Invalid CSRF key');",
|
|
105
|
+
'} catch (error) { await service.close(); throw error; }',
|
|
106
|
+
],
|
|
107
|
+
hostEntries: ['authExtension({service, csrfKey, projectSha256})'],
|
|
108
|
+
hostClose: ['csrfKey.fill(0);', 'await service.close();'],
|
|
109
|
+
files: [
|
|
110
|
+
{ path: OPERATOR_FILE, content: serviceModule(directory), mode: 0o600 },
|
|
111
|
+
{ path: ENCRYPTION_KEY, content: randomBytes(32), mode: 0o600 },
|
|
112
|
+
{ path: CSRF_KEY, content: randomBytes(32), mode: 0o600 },
|
|
113
|
+
],
|
|
114
|
+
readme: readmeSection(normalized, admin),
|
|
115
|
+
nextSteps: [
|
|
116
|
+
"export AUTH_ORIGIN='https://accounts.example.com'",
|
|
117
|
+
"export PROJECT_SHA256='paste-reviewed-64-character-sha256'",
|
|
118
|
+
`npx urlcode-auth bootstrap --operator-file "$PWD/${operator}"`,
|
|
119
|
+
`npx urlcode serve --project "$PWD/${projectPath}" --host-file "$PWD/${host}" --origin "$AUTH_ORIGIN"`,
|
|
120
|
+
],
|
|
121
|
+
env: {
|
|
122
|
+
AUTH_ORIGIN: 'Canonical HTTPS origin of the site, as served by the TLS proxy.',
|
|
123
|
+
PROJECT_SHA256: 'Reviewed project revision from inspectExtensionRevision; re-review after any project change.',
|
|
124
|
+
AUTH_CONFIG_FROM: 'Optional: current configuration revision hash approving an operator configuration migration.',
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const PLAIN = /^[A-Za-z_/][A-Za-z0-9_ ./*-]*$/, RESERVED = /^(true|false|yes|no|on|off|null|~)$/i;
|
|
129
|
+
function scalar(value) {
|
|
130
|
+
if (typeof value === 'string')
|
|
131
|
+
return PLAIN.test(value) && !RESERVED.test(value) && !value.endsWith(' ') ? value : `'${value.replace(/'/g, "''")}'`;
|
|
132
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
133
|
+
return String(value);
|
|
134
|
+
throw new Error('Unsupported YAML scalar');
|
|
135
|
+
}
|
|
136
|
+
/** Deterministic YAML for scaffold fragments: block mappings, flow lists of scalars, {} for empty maps. */
|
|
137
|
+
export function renderYaml(value, indent = '') {
|
|
138
|
+
let out = '';
|
|
139
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
140
|
+
const name = scalar(key);
|
|
141
|
+
if (Array.isArray(entry))
|
|
142
|
+
out += `${indent}${name}: [${entry.map(scalar).join(', ')}]\n`;
|
|
143
|
+
else if (entry && typeof entry === 'object') {
|
|
144
|
+
const nested = entry;
|
|
145
|
+
out += Object.keys(nested).length ? `${indent}${name}:\n${renderYaml(nested, indent + ' ')}` : `${indent}${name}: {}\n`;
|
|
146
|
+
}
|
|
147
|
+
else
|
|
148
|
+
out += `${indent}${name}: ${scalar(entry)}\n`;
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
function hostModule(result) {
|
|
153
|
+
return [
|
|
154
|
+
...result.hostImports,
|
|
155
|
+
...result.hostSetup,
|
|
156
|
+
'export default {',
|
|
157
|
+
` extensions: [${result.hostEntries.join(', ')}],`,
|
|
158
|
+
` async close() { ${(result.hostClose ?? []).join(' ')} },`,
|
|
159
|
+
'};',
|
|
160
|
+
].join('\n') + '\n';
|
|
161
|
+
}
|
|
162
|
+
/** Creates a new private directory only; never merges or overwrites an existing project. */
|
|
163
|
+
export async function initAuthentication(directory) {
|
|
164
|
+
if (typeof directory !== 'string' || !directory || directory.includes('\0'))
|
|
165
|
+
throw new Error('An output directory is required');
|
|
166
|
+
const requested = resolve(directory), parent = await realpath(dirname(requested)), root = join(parent, basename(requested));
|
|
167
|
+
const project = join(root, 'app'), hostFile = join(root, 'host.mjs');
|
|
168
|
+
const result = await scaffold({ directory: root, project, hostFile, names: ['auth'] });
|
|
169
|
+
try {
|
|
170
|
+
await mkdir(root, { mode: 0o700 });
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
for (const file of result.files)
|
|
174
|
+
if (file.content instanceof Uint8Array)
|
|
175
|
+
file.content.fill(0);
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
async function write(path, value, mode = 0o600) {
|
|
179
|
+
const file = await open(join(root, path), 'wx', mode);
|
|
180
|
+
try {
|
|
181
|
+
await file.writeFile(value);
|
|
182
|
+
await file.sync();
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
await file.close();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
await mkdir(project, { mode: 0o700 });
|
|
190
|
+
await mkdir(join(root, 'data'), { mode: 0o700 });
|
|
191
|
+
await write('app/urlcode.yaml', renderYaml({ version: '1', extensions: result.extensions, routes: result.routes }));
|
|
192
|
+
await write('host.mjs', hostModule(result));
|
|
193
|
+
await write('README.md', `# Auth project and operator host\n\n${result.readme}`);
|
|
194
|
+
await write('package.json', JSON.stringify({ name: 'urlcode-auth-site', private: true, type: 'module' }, null, 2) + '\n');
|
|
195
|
+
await write('.gitignore', 'node_modules/\ndata/\n.env\n.env.*\n');
|
|
196
|
+
for (const file of result.files) {
|
|
197
|
+
if (file.path.includes('\0') || resolve(root, file.path) !== join(root, file.path) || relative(root, resolve(root, file.path)).startsWith('..'))
|
|
198
|
+
throw new Error('Invalid scaffold file path');
|
|
199
|
+
await write(file.path, file.content, file.mode);
|
|
200
|
+
}
|
|
201
|
+
return { directory: root, project, hostFile, operatorFile: join(root, OPERATOR_FILE) };
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
await rm(root, { recursive: true, force: true });
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
for (const file of result.files)
|
|
209
|
+
if (file.content instanceof Uint8Array)
|
|
210
|
+
file.content.fill(0);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ExtensionRequest } from '@jimhoyd/urlcode/extensions';
|
|
2
|
+
import type { AuthService, PasskeyAuthProof } from './auth-core.ts';
|
|
3
|
+
import type { PasskeyProvider } from './passkeys.ts';
|
|
4
|
+
import { AuthHttp } from './auth-ui.ts';
|
|
5
|
+
import type { AuthHttpResponse } from './auth-ui.ts';
|
|
6
|
+
export interface SecondFactorInput {
|
|
7
|
+
token: string;
|
|
8
|
+
browserHash: string;
|
|
9
|
+
}
|
|
10
|
+
export interface SecondFactorFlowService extends Pick<AuthService, 'putFlow' | 'consumeFlow' | 'getPasskey'> {
|
|
11
|
+
getSecurityPolicy(): {
|
|
12
|
+
allowPasskeySecondFactor?: boolean;
|
|
13
|
+
};
|
|
14
|
+
createSecondFactorProof(input: {
|
|
15
|
+
browserHash: string;
|
|
16
|
+
proof: PasskeyAuthProof;
|
|
17
|
+
}): Promise<string>;
|
|
18
|
+
}
|
|
19
|
+
/** Obtains UV evidence only. The domain consumes the resulting opaque proof together
|
|
20
|
+
* with the independent primary proof and counter update in the final transaction. */
|
|
21
|
+
export declare function createSecondFactorFlows(options: {
|
|
22
|
+
service: SecondFactorFlowService;
|
|
23
|
+
passkeys?: PasskeyProvider;
|
|
24
|
+
now?: () => number;
|
|
25
|
+
}, http: AuthHttp, mount: string): {
|
|
26
|
+
proof(request: ExtensionRequest, token: string): SecondFactorInput;
|
|
27
|
+
handle(request: ExtensionRequest): Promise<AuthHttpResponse | undefined>;
|
|
28
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { AuthHttp, AuthHttpError, jsonResponse } from "./auth-ui.js";
|
|
3
|
+
const hash = (value) => createHash('sha256').update(value).digest('hex');
|
|
4
|
+
const opaque = (value) => typeof value === 'string' && /^[A-Za-z0-9_-]{43}$/.test(value);
|
|
5
|
+
function record(value) {
|
|
6
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
7
|
+
throw new AuthHttpError(400, 'Invalid second-factor payload');
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
function payload(request) {
|
|
11
|
+
if (request.body.byteLength > 16384)
|
|
12
|
+
throw new AuthHttpError(413, 'Request body too large');
|
|
13
|
+
if (request.headers.get('content-type')?.split(';')[0] !== 'application/json')
|
|
14
|
+
throw new AuthHttpError(415, 'JSON required');
|
|
15
|
+
try {
|
|
16
|
+
return record(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(request.body)));
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw new AuthHttpError(400, 'Invalid second-factor payload');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Obtains UV evidence only. The domain consumes the resulting opaque proof together
|
|
23
|
+
* with the independent primary proof and counter update in the final transaction. */
|
|
24
|
+
export function createSecondFactorFlows(options, http, mount) {
|
|
25
|
+
const { service, passkeys } = options, now = options.now ?? Date.now;
|
|
26
|
+
const enabled = () => Boolean(passkeys && service.getSecurityPolicy().allowPasskeySecondFactor);
|
|
27
|
+
const browserHash = (request) => {
|
|
28
|
+
const browser = http.session(request) || http.cookie(request, http.flowCookie);
|
|
29
|
+
if (!browser)
|
|
30
|
+
throw new AuthHttpError(403, 'Authentication browser binding required');
|
|
31
|
+
return hash(browser);
|
|
32
|
+
};
|
|
33
|
+
return {
|
|
34
|
+
proof(request, token) {
|
|
35
|
+
if (!enabled() || !opaque(token))
|
|
36
|
+
throw new AuthHttpError(400, 'Invalid second-factor proof');
|
|
37
|
+
return { token, browserHash: browserHash(request) };
|
|
38
|
+
},
|
|
39
|
+
async handle(request) {
|
|
40
|
+
if (!enabled() || ![mount + '/second-factor/options', mount + '/second-factor/verify'].includes(request.path))
|
|
41
|
+
return undefined;
|
|
42
|
+
if (request.method !== 'POST')
|
|
43
|
+
throw new AuthHttpError(405, 'POST required');
|
|
44
|
+
const body = payload(request), begin = request.path.endsWith('/options');
|
|
45
|
+
if (Object.keys(body).some(key => !(begin ? ['csrf'] : ['csrf', 'flowId', 'response']).includes(key)) || body.csrf !== undefined && typeof body.csrf !== 'string')
|
|
46
|
+
throw new AuthHttpError(400, 'Invalid second-factor payload');
|
|
47
|
+
http.verify(request, { csrf: typeof body.csrf === 'string' ? body.csrf : '' });
|
|
48
|
+
const binding = browserHash(request);
|
|
49
|
+
if (begin) {
|
|
50
|
+
const authentication = await passkeys.beginAuthentication(), flowId = randomBytes(32).toString('base64url');
|
|
51
|
+
await service.putFlow({ id: flowId, kind: 'passkey-second-factor', expires: now() + 300000, data: { challenge: authentication.challenge, browserHash: binding, expires: now() + 300000 } });
|
|
52
|
+
return jsonResponse(200, { flowId, options: authentication });
|
|
53
|
+
}
|
|
54
|
+
if (!opaque(body.flowId))
|
|
55
|
+
throw new AuthHttpError(400, 'Invalid second-factor flow');
|
|
56
|
+
const flow = record(await service.consumeFlow(body.flowId, 'passkey-second-factor'));
|
|
57
|
+
if (flow.browserHash !== binding || typeof flow.challenge !== 'string' || typeof flow.expires !== 'number' || flow.expires <= now())
|
|
58
|
+
throw new AuthHttpError(403, 'Invalid second-factor flow');
|
|
59
|
+
const response = record(body.response);
|
|
60
|
+
if (typeof response.id !== 'string' || response.id.length > 2048)
|
|
61
|
+
throw new AuthHttpError(400, 'Invalid second-factor assertion');
|
|
62
|
+
const stored = await service.getPasskey(response.id);
|
|
63
|
+
if (!stored)
|
|
64
|
+
throw new AuthHttpError(400, 'Invalid second-factor assertion');
|
|
65
|
+
let counter;
|
|
66
|
+
try {
|
|
67
|
+
({ counter } = await passkeys.verifyAuthentication(response, flow.challenge, stored.credential));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new AuthHttpError(400, 'Invalid second-factor assertion');
|
|
71
|
+
}
|
|
72
|
+
const secondFactorToken = await service.createSecondFactorProof({ browserHash: binding, proof: { ...stored.proof, newCounter: counter } });
|
|
73
|
+
return jsonResponse(200, { secondFactorToken });
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { AdminAccountDelivery } from './admin-account-operations.ts';
|
|
2
|
+
import type { EmailCopy } from './email-copy.ts';
|
|
3
|
+
import type { ManualRecoveryDelivery } from './manual-recovery.ts';
|
|
4
|
+
import { SendEmailCommand } from '@aws-sdk/client-sesv2';
|
|
5
|
+
import type { SESv2ClientConfig } from '@aws-sdk/client-sesv2';
|
|
6
|
+
export interface TokenMessage {
|
|
7
|
+
email: string;
|
|
8
|
+
token: string;
|
|
9
|
+
purpose: 'verify-email' | 'reset-password' | 'cancel-deletion' | 'invitation' | 'verify-email-change' | 'cancel-email-change';
|
|
10
|
+
signal: AbortSignal;
|
|
11
|
+
locale?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface EmailCodeMessage {
|
|
14
|
+
email: string;
|
|
15
|
+
flowId: string;
|
|
16
|
+
code: string;
|
|
17
|
+
signal: AbortSignal;
|
|
18
|
+
locale?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface SignupCodeMessage {
|
|
21
|
+
email: string;
|
|
22
|
+
code: string;
|
|
23
|
+
signal: AbortSignal;
|
|
24
|
+
locale?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface FactorRecoveryMessage {
|
|
27
|
+
email: string;
|
|
28
|
+
verificationToken: string;
|
|
29
|
+
cancelToken: string;
|
|
30
|
+
signal: AbortSignal;
|
|
31
|
+
locale?: string;
|
|
32
|
+
}
|
|
33
|
+
export type TokenSender = (message: TokenMessage) => Promise<void>;
|
|
34
|
+
export interface SecurityNotice {
|
|
35
|
+
email: string;
|
|
36
|
+
event: 'new-device' | 'password-changed' | 'email-changed' | 'registration-attempt';
|
|
37
|
+
signal: AbortSignal;
|
|
38
|
+
locale?: string;
|
|
39
|
+
}
|
|
40
|
+
/** A callable sendToken adapter; notify carries no credential or arbitrary markup. */
|
|
41
|
+
export interface EmailSender extends TokenSender {
|
|
42
|
+
sendEmailCode(message: EmailCodeMessage): Promise<void>;
|
|
43
|
+
sendSignupCode(message: SignupCodeMessage): Promise<void>;
|
|
44
|
+
sendFactorRecovery(message: FactorRecoveryMessage): Promise<void>;
|
|
45
|
+
sendManualRecovery(message: ManualRecoveryDelivery & {
|
|
46
|
+
locale?: string;
|
|
47
|
+
}): Promise<void>;
|
|
48
|
+
notify(message: SecurityNotice): Promise<void>;
|
|
49
|
+
sendAccountAdministration(message: AdminAccountDelivery & {
|
|
50
|
+
signal: AbortSignal;
|
|
51
|
+
locale?: string;
|
|
52
|
+
}): Promise<void>;
|
|
53
|
+
close(): void;
|
|
54
|
+
}
|
|
55
|
+
interface SenderLocation {
|
|
56
|
+
origin: string;
|
|
57
|
+
authMount: string;
|
|
58
|
+
emailCopy?: EmailCopy;
|
|
59
|
+
}
|
|
60
|
+
export interface SesSenderOptions extends SenderLocation {
|
|
61
|
+
region: string;
|
|
62
|
+
from: string;
|
|
63
|
+
credentials?: SESv2ClientConfig['credentials'];
|
|
64
|
+
/** Trusted injection for tests; production uses the AWS SDK with bounded retries. */
|
|
65
|
+
transport?: (command: SendEmailCommand, options: {
|
|
66
|
+
abortSignal: AbortSignal;
|
|
67
|
+
}) => Promise<unknown>;
|
|
68
|
+
}
|
|
69
|
+
export declare function createSesSender(options: SesSenderOptions): EmailSender;
|
|
70
|
+
export type DevelopmentSenderOptions = SenderLocation & {
|
|
71
|
+
allowDevelopment: true;
|
|
72
|
+
maxMessages?: number;
|
|
73
|
+
} & ({
|
|
74
|
+
directory: string;
|
|
75
|
+
projectRoot: string;
|
|
76
|
+
allowConsoleTokens?: never;
|
|
77
|
+
write?: never;
|
|
78
|
+
} | {
|
|
79
|
+
allowConsoleTokens: true;
|
|
80
|
+
write?: (message: string) => void;
|
|
81
|
+
directory?: never;
|
|
82
|
+
projectRoot?: never;
|
|
83
|
+
});
|
|
84
|
+
/** Development only. File output uses exclusive 0600 notices in an existing private directory outside the project. */
|
|
85
|
+
export declare function createDevelopmentSender(options: DevelopmentSenderOptions): Promise<EmailSender>;
|
|
86
|
+
export {};
|