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

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  An optional, operator-installed authentication extension for URLCode. This repository contains the Node/SQLite implementation: password and passkey authentication, OpenID Connect, email codes, TOTP, recovery codes, versioned registration profiles, account lifecycle operations, administrative service operations and trusted HTML pages.
4
4
 
5
- [![Verify](https://github.com/jimhoyd-com/urlcode-auth/actions/workflows/verify.yml/badge.svg)](https://github.com/jimhoyd-com/urlcode-auth/actions/workflows/verify.yml)
5
+ [![CI](https://github.com/jimhoyd-com/urlcode/actions/workflows/ci.yml/badge.svg)](https://github.com/jimhoyd-com/urlcode/actions/workflows/ci.yml)
6
6
 
7
7
  The implementation is under active review. Local tests and builds are evidence of those checks, not an independent security assessment, production deployment, provider certification or recovery/soak result. See [SECURITY.md](SECURITY.md) for the trust boundary and [the first-release coverage review](docs/SPIKE-AUTH.md) for the proposal; the proposal is not a list of completed features.
8
8
 
@@ -27,20 +27,21 @@ Every release tarball is attested from the tagged commit: `gh attestation verify
27
27
 
28
28
  Operators who pin exact reviewed commits rather than registry versions can build the same packages locally. A registry version alone does not establish that a revision was reviewed: this implementation requires the core extension contract introduced by [core PR #59](https://github.com/jimhoyd-com/urlcode/pull/59). Use its reviewed implementation or a reviewed successor containing it, pinned to an exact commit. Do not infer approval from the current branch name.
29
29
 
30
- This package also depends on the shared `@jimhoyd/urlcode-ui` peer, which owns document layout, semantic fields, escaping, themes and the locale engine; authentication/administration behavior remains here. Core can use UI without auth/admin. Source CI (`verify.yml`) checks the peers out at the commits in [`peers.json`](peers.json) and needs the narrow `URLCODE_UI_READ_TOKEN`; the release workflow resolves them from the registry instead.
30
+ This package also depends on the shared `@jimhoyd/urlcode-ui` peer, which owns document layout, semantic fields, escaping, themes and the locale engine; authentication/administration behavior remains here. Core can use UI without auth/admin. Both peers are siblings in this repository, so CI builds them from the same commit there is no peer checkout, no `peers.json` and no read token; the release workflow resolves the published versions from the registry instead, to prove the declared ranges are satisfiable.
31
31
 
32
- Each repository has a lockfile. The source packaging helper installs dependencies with lifecycle scripts disabled, builds the reviewed packages (core, then UI, then their consumers), installs local peer tarballs in dependency order and writes package integrity/revision metadata. It does not publish. All source trees must be committed and clean. `--core`, `--auth`, `--ui`, `--core-revision` and `--out` are required. Replace these illustrative paths with your reviewed locations. `--core-revision` defaults to the `urlcode` entry in [`peers.json`](peers.json), the single source of verified peer revisions; pass it explicitly only to override:
32
+ One lockfile governs the workspace. The source packaging helper installs dependencies with lifecycle scripts disabled, builds the reviewed packages in dependency order (core, then UI, then their consumers) and writes package integrity metadata. It does not publish. The tree must be committed and clean, and it re-checks that after every build and pack. `--revision` and `--out` are required; `--revision` is exact and has no default, because the reviewed commit is the thing being asserted:
33
33
 
34
34
  ```sh
35
35
  node scripts/pack-sources.mjs \
36
- --core /absolute/source/urlcode \
37
- --ui /absolute/source/urlcode-ui \
38
- --auth /absolute/source/urlcode-auth \
39
- --admin /absolute/source/urlcode-admin \
40
- --core-revision REVIEWED_40_CHARACTER_COMMIT_SHA \
36
+ --revision REVIEWED_40_CHARACTER_COMMIT_SHA \
41
37
  --out /absolute/new-private-package-directory
42
38
  ```
43
39
 
40
+ One commit identifies every package: core, ui, auth and admin are built from
41
+ the same reviewed revision of this repository. The script refuses to run if the
42
+ checkout is not at that exact commit or has uncommitted changes, and re-checks
43
+ both after each build and pack.
44
+
44
45
  Omit `--admin` for auth only. `--offline` forbids network package resolution and requires a populated dependency cache. `--skip-install` reuses installed third-party dependencies; local peer tarballs are still installed. The script does not alter dependency manifests or lockfiles. Run `npm run verify` in each repository separately; source packaging runs typecheck/build, not the HTTP suite.
45
46
 
46
47
  Install all required local tarballs together (core, UI and auth; admin if built) in an operator-owned directory with a private `package.json`. For example, after checking the manifest:
@@ -183,7 +184,7 @@ Run `urlcode-auth --help` for the current CLI. Operator commands have full datab
183
184
 
184
185
  `--operator-file` is an absolute path to a module that default-exports an `AuthService`.
185
186
 
186
- Backup/restore accepts JSON paths on stdin. `createBackup({database,destination,projectRoot})` uses SQLite's online backup API, including committed WAL pages, with a bounded worker and integrity checks. `restoreBackup({backup,destination,projectRoot})` restores to a **new** path. Both require private operator paths outside the project and refuse overwrite. Never copy only a live `.sqlite` file and assume its WAL is included.
187
+ Backup/restore accepts JSON paths on stdin. `createBackup({database,destination,projectRoot})` uses SQLite's online backup API, including committed WAL pages, with a bounded worker and integrity checks. `restoreBackup({backup,destination,projectRoot})` restores to a **new** path. Both require private operator paths outside the project and refuse overwrite. Never copy only a live `.sqlite` file and assume its WAL is included. See [backup and restore platform guarantees](../../docs/AUTH-BACKUP.md), including Windows ACL and directory durability limits.
187
188
 
188
189
  Back up encryption keys, CSRF keys and reviewed static configuration separately. Database snapshots contain sensitive account/audit data and password hashes, but do not export key files. Restoring historical data also restores historical sessions/tokens and revocation state: plan revocation and recovery before reopening traffic. Rotate keys by adding a new active key, retaining decryption keys while bounded migration reports remaining records, then remove old keys only after completion and backup verification. Old writers fail closed after activation switches. Keep a tested isolated restore procedure.
189
190
 
package/SECURITY.md CHANGED
@@ -4,7 +4,7 @@ This repository is an actively reviewed implementation, not an independent secur
4
4
 
5
5
  ## Trusted and untrusted components
6
6
 
7
- Operator modules, their dependencies, configuration, database directory, encryption/CSRF keys, identity providers and mail transport are trusted. Project routes are trusted and run in-process with full Node access by default; `sandbox: true` opts a route into the isolated QuickJS/WASM worker pool instead. Neither trusted nor sandboxed project routes gain authority to load auth's own host modules that boundary is enforced by the host-file/operator-registration mechanism below, independent of a route's own `sandbox` setting. Core extension activation requires an explicitly supplied registry and a reviewed exact project revision pin. Never turn revision inspection into automatic approval.
7
+ Operator modules, their dependencies, configuration, database directory, encryption/CSRF keys, identity providers and mail transport are trusted. Project routes are trusted and run in-process with full Node access by default; `sandbox: true` opts a route into the isolated QuickJS/WASM worker pool instead. Project YAML cannot register auth's host modules: activation requires the explicit host-file/operator-registration mechanism. This is not a confinement boundary for trusted Node code, which can independently import accessible modules and read process environment/files with the host user's authority. Only an explicitly sandboxed route has the guest isolation boundary. Core extension activation requires an explicitly supplied registry and a reviewed exact project revision pin. Never turn revision inspection into automatic approval.
8
8
 
9
9
  Auth is Node/SQLite only. It refuses unpatched SQLite versions and requires private database files. Keep the database, WAL/SHM, backups, operator modules and key files outside the application project and inaccessible to guest filesystem access. Do not run the host as a shared hostile operating-system user. Filesystem permission and symlink checks do not defend against an attacker who already controls the operator account or its parent directories.
10
10
 
package/THREAT-MODEL.md CHANGED
@@ -27,12 +27,18 @@ passwords are hashed, and reusable browser capabilities are stored as hashes.
27
27
  A same-origin frontend can exercise browser authority even without reading an
28
28
  HttpOnly cookie. Do not host adversarial frontend scripts on this origin. A hostile
29
29
  host process, operator module, dependency or OS user able to read the key material
30
- is outside the WASM boundary. This release does not claim hostile multi-tenant
30
+ is outside the boundary this extension protects: that boundary is the
31
+ operator/host-file boundary, not a sandbox. The QuickJS/WASM sandbox is not an
32
+ ambient boundary — it exists only for routes that declare `sandbox: true`. This release does not claim hostile multi-tenant
31
33
  readiness or independent assessment.
32
34
 
33
35
  ## Required invariants
34
36
 
35
- - Guests cannot select host modules or receive session, provider or recovery secrets.
37
+ - Project declarations cannot register host modules, and the runtime does not
38
+ inject session, provider or recovery secrets into application handlers.
39
+ Trusted Node code still has the host process's ambient authority; host-file
40
+ registration and header filtering do not confine it. Keep hostile code out of
41
+ that process. `sandbox: true` retains the guest isolation boundary and grants.
36
42
  - Required signup verification precedes stored credentials. Existing accounts are
37
43
  never overwritten by a duplicate signup, linked by email alone, or upgraded from
38
44
  untrusted metadata.
@@ -68,7 +68,7 @@ export function adminAccountOperation(operation, args, context) {
68
68
  }
69
69
  return user;
70
70
  });
71
- const disabling = (user) => ['schedule-deletion', 'force-password-reset'].includes(plan.action) || plan.action === 'assign-roles' && !permissions(plan.parameters.roles).includes('*');
71
+ const disabling = (_user) => ['schedule-deletion', 'force-password-reset'].includes(plan.action) || plan.action === 'assign-roles' && !permissions(plan.parameters.roles).includes('*');
72
72
  if (targets.some(user => administrator(user) && user.status === 'active' && !context.isRestricted(user) && disabling(user))) {
73
73
  const disabled = new Set(targets.filter(disabling).map(user => user.id));
74
74
  const remaining = db.prepare("SELECT data FROM auth_accounts WHERE administrator=1 AND status='active'").all().some(row => { const user = JSON.parse(String(row.data)); return !disabled.has(user.id) && !context.isRestricted(user); });
package/dist/auth-core.js CHANGED
@@ -27,7 +27,7 @@ export function normalizeEmail(value) {
27
27
  if (parts.length !== 2)
28
28
  fail(400, 'invalid_email');
29
29
  const local = parts[0].toLowerCase(), domain = domainToASCII(parts[1]).toLowerCase();
30
- if (!local || local.length > 64 || /[\s\x00-\x1f\x7f"(),:;<>\[\]\\]/.test(local) || !domain || domain.length > 253 || domain.split('.').some(label => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)) || local.length + domain.length + 1 > 254)
30
+ if (!local || local.length > 64 || /[\s\x00-\x1f\x7f"(),:;<>[\]\\]/.test(local) || !domain || domain.length > 253 || domain.split('.').some(label => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)) || local.length + domain.length + 1 > 254)
31
31
  fail(400, 'invalid_email');
32
32
  return local + '@' + domain;
33
33
  }
@@ -25,6 +25,6 @@ export interface AuthFlowOptions {
25
25
  };
26
26
  }
27
27
  export declare function createAuthFlows(options: AuthFlowOptions, http: AuthHttp, mount: string, registration: boolean): {
28
- buttons(csrf: string, link?: boolean, text?: (value: string) => string, locale?: string, presentation?: PresentationContext): string;
28
+ buttons(csrf: string, link?: boolean, _text?: (value: string) => string, locale?: string, presentation?: PresentationContext): string;
29
29
  handle(request: ExtensionRequest): Promise<AuthHttpResponse | undefined>;
30
30
  };
@@ -42,7 +42,7 @@ export function createAuthFlows(options, http, mount, registration) {
42
42
  const cookie = (value, maxAge = 600) => ['set-cookie', `${flowCookie}=${value}; Path=/; Secure; HttpOnly; SameSite=None; Max-Age=${maxAge}`];
43
43
  const finish = async (request, result) => { const headers = options.onSession ? await options.onSession(request, result) : []; return wantsJson(request) ? jsonResponse(200, { user: result.user, csrf: http.token(result.token), ...(result.principal.restrictions ? { restrictions: result.principal.restrictions } : {}) }, [...http.sessionHeaders(result.token), cookie('', 0), ...headers]) : jsonResponse(303, { redirect: mount + '/account' }, [['location', mount + '/account'], ...http.sessionHeaders(result.token), cookie('', 0), ...headers]); };
44
44
  return {
45
- buttons(csrf, link = false, text = value => value, locale, presentation) { return Object.keys(providers).map(name => `<form method="post" action="${escapeHtml(mount + '/providers/' + name + (link ? '/link' : '/start') + (locale ? '?lang=' + encodeURIComponent(locale) : ''))}">${csrfField(csrf)}<button type="submit">${escapeHtml((presentation ?? defaultPresentation.resolve()).text(link ? 'provider.link' : 'provider.signIn', { provider: name }))}</button></form>`).join(''); },
45
+ buttons(csrf, link = false, _text = value => value, locale, presentation) { return Object.keys(providers).map(name => `<form method="post" action="${escapeHtml(mount + '/providers/' + name + (link ? '/link' : '/start') + (locale ? '?lang=' + encodeURIComponent(locale) : ''))}">${csrfField(csrf)}<button type="submit">${escapeHtml((presentation ?? defaultPresentation.resolve()).text(link ? 'provider.link' : 'provider.signIn', { provider: name }))}</button></form>`).join(''); },
46
46
  async handle(request) {
47
47
  let presentation = (options.presentation ?? defaultPresentation).resolve({ ...(request.query.get('lang') ? { queryLocale: request.query.get('lang') } : {}), ...(request.headers.get('accept-language') ? { acceptLanguage: request.headers.get('accept-language') } : {}) });
48
48
  const tr = (key, values) => escapeHtml(presentation.text(key, values));
@@ -46,19 +46,26 @@ export async function openAuthStore(options) {
46
46
  };
47
47
  worker.on('error', fail);
48
48
  worker.on('exit', fail);
49
- await new Promise((accept, reject) => {
50
- const timer = setTimeout(() => { void worker.terminate(); reject(new AuthError(503, 'auth_store_unavailable')); }, 15000);
51
- worker.once('message', (message) => {
52
- clearTimeout(timer);
53
- if (message.ready)
54
- accept();
55
- else {
56
- void worker.terminate();
57
- reject(new AuthError(503, ['auth_configuration_changed', 'configuration_approval_mismatch', 'configuration_roles_invalid', 'configuration_admin_required'].includes(message.error ?? '') ? message.error : 'auth_store_unavailable'));
58
- }
49
+ try {
50
+ await new Promise((accept, reject) => {
51
+ const timer = setTimeout(() => { reject(new AuthError(503, 'auth_store_unavailable')); }, 15000);
52
+ worker.once('message', (message) => {
53
+ clearTimeout(timer);
54
+ if (message.ready)
55
+ accept();
56
+ else {
57
+ reject(new AuthError(503, ['auth_configuration_changed', 'configuration_approval_mismatch', 'configuration_roles_invalid', 'configuration_admin_required'].includes(message.error ?? '') ? message.error : 'auth_store_unavailable'));
58
+ }
59
+ });
60
+ worker.once('error', () => { clearTimeout(timer); reject(new AuthError(503, 'auth_store_unavailable')); });
59
61
  });
60
- worker.once('error', () => { clearTimeout(timer); reject(new AuthError(503, 'auth_store_unavailable')); });
61
- });
62
+ }
63
+ catch (error) {
64
+ // A rejected open must release SQLite handles before its caller can
65
+ // retry, restore or remove the database (Windows cannot unlink them).
66
+ await worker.terminate();
67
+ throw error;
68
+ }
62
69
  worker.on('message', (message) => {
63
70
  const p = pending.get(message.id);
64
71
  if (!p)
package/dist/auth.js CHANGED
@@ -125,7 +125,7 @@ export function authExtension(options) {
125
125
  try {
126
126
  await Promise.race([options.sendNotice({ email, event, ...(locale ? { locale } : {}), signal: controller.signal }), new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(new Error('timeout')); }, 5000); })]);
127
127
  }
128
- catch { }
128
+ catch { /* Notification delivery is best-effort: a failed or timed-out notice must not fail the operation that triggered it. */ }
129
129
  finally {
130
130
  if (timer)
131
131
  clearTimeout(timer);
@@ -160,7 +160,7 @@ export function authExtension(options) {
160
160
  const session = http.session(request), actor = session ? await service.authenticate(session) : null;
161
161
  accountLocale = actor ? (await service.getUser(actor.id))?.profile?.locale : undefined;
162
162
  }
163
- catch { }
163
+ catch { /* An unreadable session only means no account locale to prefer; fall back to the request locale. */ }
164
164
  }
165
165
  const presentation = source().resolve({ ...(accountLocale ? { accountLocale } : {}), ...(request.query.get('lang') ? { queryLocale: request.query.get('lang') } : {}), ...(request.headers.get('accept-language') ? { acceptLanguage: request.headers.get('accept-language') } : {}) });
166
166
  const tr = (key, values) => escapeHtml(presentation.text(key, values));
@@ -370,7 +370,7 @@ export function authExtension(options) {
370
370
  try {
371
371
  await Promise.race([options.sendEmailCode({ email, flowId: issued.flowId, code: issued.code, locale: presentation.locale, signal: controller.signal }), new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(new Error('timeout')); }, 5000); })]);
372
372
  }
373
- catch { }
373
+ catch { /* Delivery is best-effort here too: the code is already issued, and the flow continues on the verification step. */ }
374
374
  finally {
375
375
  if (timer)
376
376
  clearTimeout(timer);
package/dist/backup.js CHANGED
@@ -61,7 +61,8 @@ async function snapshot(sourceInput, destinationInput, projectRoot) {
61
61
  const copied = await lstat(file);
62
62
  if (!copied.isFile() || copied.size > 1073741824)
63
63
  throw new Error('Backup exceeds size limit');
64
- const handle = await open(file, 'r');
64
+ // Windows FlushFileBuffers requires a writable handle.
65
+ const handle = await open(file, 'r+');
65
66
  try {
66
67
  await handle.sync();
67
68
  }
@@ -71,12 +72,16 @@ async function snapshot(sourceInput, destinationInput, projectRoot) {
71
72
  // Linking is atomic and refuses any existing destination, including a raced-in symlink.
72
73
  await link(file, destination);
73
74
  await rm(file);
74
- const directory = await open(parent, 'r');
75
- try {
76
- await directory.sync();
77
- }
78
- finally {
79
- await directory.close();
75
+ // Node cannot open/flush directory handles this way on Windows.
76
+ // File bytes were flushed above; POSIX also flushes the new directory entry.
77
+ if (process.platform !== 'win32') {
78
+ const directory = await open(parent, 'r');
79
+ try {
80
+ await directory.sync();
81
+ }
82
+ finally {
83
+ await directory.close();
84
+ }
80
85
  }
81
86
  return { format: 'urlcode-auth-sqlite-v1', bytes: copied.size };
82
87
  }
package/dist/challenge.js CHANGED
@@ -10,7 +10,7 @@ export function createTurnstileChallenge(options) {
10
10
  throw new Error('Invalid Turnstile hostname');
11
11
  }
12
12
  const timeout = options.timeoutMs ?? 5000;
13
- if (!key(options.secret) || !key(options.siteKey) || hostname !== options.hostname || !hostname || /[\/:@?#]/.test(hostname) || !Number.isInteger(timeout) || timeout < 10 || timeout > 5000)
13
+ if (!key(options.secret) || !key(options.siteKey) || hostname !== options.hostname || !hostname || /[/:@?#]/.test(hostname) || !Number.isInteger(timeout) || timeout < 10 || timeout > 5000)
14
14
  throw new Error('Invalid Turnstile configuration');
15
15
  let active = 0;
16
16
  return Object.freeze({ widget: Object.freeze({ siteKey: options.siteKey, action: 'auth' }), async verify(input) {
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@jimhoyd/urlcode-auth",
3
- "version": "0.1.0-alpha.3",
3
+ "version": "0.1.0-alpha.6",
4
4
  "type": "module",
5
5
  "description": "Operator-installed authentication extension for URLCode: accounts, sessions, passkeys, OIDC, TOTP and trusted account pages",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "https://github.com/jimhoyd-com/urlcode-auth.git"
9
+ "url": "git+https://github.com/jimhoyd-com/urlcode.git",
10
+ "directory": "packages/auth"
10
11
  },
11
12
  "publishConfig": {
12
13
  "access": "public"
@@ -18,7 +19,7 @@
18
19
  "build": "tsc -p tsconfig.build.json",
19
20
  "typecheck": "tsc --noEmit",
20
21
  "pretest": "node scripts/check-sqlite.mjs",
21
- "test": "node --conditions=development --test test/*.test.ts",
22
+ "test": "node --conditions=development --test --test-timeout=300000 test/*.test.ts",
22
23
  "verify": "npm run typecheck && npm run build && npm test"
23
24
  },
24
25
  "exports": {
@@ -38,6 +39,7 @@
38
39
  "THREAT-MODEL.md"
39
40
  ],
40
41
  "devDependencies": {
42
+ "@jimhoyd/urlcode": "file:../..",
41
43
  "@types/node": "26.5.1",
42
44
  "typescript": "6.0.3"
43
45
  },
@@ -56,8 +58,8 @@
56
58
  "bin": {
57
59
  "urlcode-auth": "./dist/cli.js"
58
60
  },
59
- "homepage": "https://github.com/jimhoyd-com/urlcode-auth#readme",
61
+ "homepage": "https://github.com/jimhoyd-com/urlcode/tree/main/packages/auth#readme",
60
62
  "bugs": {
61
- "url": "https://github.com/jimhoyd-com/urlcode-auth/issues"
63
+ "url": "https://github.com/jimhoyd-com/urlcode/issues"
62
64
  }
63
65
  }