@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 +10 -9
- package/SECURITY.md +1 -1
- package/THREAT-MODEL.md +8 -2
- package/dist/admin-account-store.js +1 -1
- package/dist/auth-core.js +1 -1
- package/dist/auth-flows.d.ts +1 -1
- package/dist/auth-flows.js +1 -1
- package/dist/auth-store.js +19 -12
- package/dist/auth.js +3 -3
- package/dist/backup.js +12 -7
- package/dist/challenge.js +1 -1
- package/package.json +7 -5
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
|
-
[](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.
|
|
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
|
-
|
|
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
|
-
--
|
|
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.
|
|
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
|
|
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
|
-
-
|
|
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 = (
|
|
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"()
|
|
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
|
}
|
package/dist/auth-flows.d.ts
CHANGED
|
@@ -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,
|
|
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
|
};
|
package/dist/auth-flows.js
CHANGED
|
@@ -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,
|
|
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));
|
package/dist/auth-store.js
CHANGED
|
@@ -46,19 +46,26 @@ export async function openAuthStore(options) {
|
|
|
46
46
|
};
|
|
47
47
|
worker.on('error', fail);
|
|
48
48
|
worker.on('exit', fail);
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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 || /[
|
|
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
|
+
"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
|
|
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
|
|
61
|
+
"homepage": "https://github.com/jimhoyd-com/urlcode/tree/main/packages/auth#readme",
|
|
60
62
|
"bugs": {
|
|
61
|
-
"url": "https://github.com/jimhoyd-com/urlcode
|
|
63
|
+
"url": "https://github.com/jimhoyd-com/urlcode/issues"
|
|
62
64
|
}
|
|
63
65
|
}
|