@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,8 @@
|
|
|
1
|
+
import type { ExtensionRequest } from '@jimhoyd/urlcode/extensions';
|
|
2
|
+
import type { AuthService } from './auth-core.ts';
|
|
3
|
+
import type { AuthChallenge } from './challenge.ts';
|
|
4
|
+
import { AuthHttp } from './auth-ui.ts';
|
|
5
|
+
import type { UiHost } from './auth-ui.ts';
|
|
6
|
+
import type { PresentationContext } from './presentation.ts';
|
|
7
|
+
/** Entry requests only: callback and code/token redemption keep their own bound proofs. */
|
|
8
|
+
export declare function createAbuseGuard(service: AuthService, http: AuthHttp, mount: string, challenge?: AuthChallenge, ui?: UiHost): (request: ExtensionRequest, presentation?: PresentationContext) => Promise<import("./auth-ui.ts").AuthHttpResponse | undefined>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { AuthHttp, AuthHttpError, jsonResponse, screenResponse, wantsJson } from "./auth-ui.js";
|
|
2
|
+
import { isHoneypotFilled } from "./registration.js";
|
|
3
|
+
/** Entry requests only: callback and code/token redemption keep their own bound proofs. */
|
|
4
|
+
export function createAbuseGuard(service, http, mount, challenge, ui) {
|
|
5
|
+
const policy = service.getAbusePolicy();
|
|
6
|
+
if (policy?.challengeAfter !== undefined && !challenge)
|
|
7
|
+
throw new Error('Challenge policy requires an operator verifier');
|
|
8
|
+
let active = 0;
|
|
9
|
+
return async (request, presentation) => {
|
|
10
|
+
const path = request.path.slice(mount.length), signup = path === '/register' || path === '/signup/begin';
|
|
11
|
+
const entry = ['/login', '/register', '/signup/begin', '/forgot-password', '/send-email-code', '/recover-factor', '/passkeys/login/options'].includes(path) || /^\/providers\/[a-z][a-z0-9-]{0,31}\/start$/.test(path);
|
|
12
|
+
if (request.method !== 'POST' || !entry)
|
|
13
|
+
return;
|
|
14
|
+
if (request.body.byteLength > 16384)
|
|
15
|
+
throw new AuthHttpError(413, 'Request body too large');
|
|
16
|
+
let fields = {};
|
|
17
|
+
const type = request.headers.get('content-type')?.split(';')[0];
|
|
18
|
+
try {
|
|
19
|
+
const raw = new TextDecoder('utf-8', { fatal: true }).decode(request.body);
|
|
20
|
+
if (type === 'application/json') {
|
|
21
|
+
const value = JSON.parse(raw);
|
|
22
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
23
|
+
throw new Error();
|
|
24
|
+
fields = value;
|
|
25
|
+
}
|
|
26
|
+
else if (type === 'application/x-www-form-urlencoded') {
|
|
27
|
+
const entries = [...new URLSearchParams(raw)];
|
|
28
|
+
if (new Set(entries.map(([key]) => key)).size !== entries.length)
|
|
29
|
+
throw new Error();
|
|
30
|
+
fields = Object.fromEntries(entries);
|
|
31
|
+
}
|
|
32
|
+
else
|
|
33
|
+
throw new Error();
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw new AuthHttpError(400, 'Invalid authentication request');
|
|
37
|
+
}
|
|
38
|
+
if (Object.keys(fields).length > 64 || fields.csrf !== undefined && typeof fields.csrf !== 'string' || fields.challengeToken !== undefined && (typeof fields.challengeToken !== 'string' || fields.challengeToken.length > 2048))
|
|
39
|
+
throw new AuthHttpError(400, 'Invalid authentication request');
|
|
40
|
+
http.verify(request, { csrf: typeof fields.csrf === 'string' ? fields.csrf : '' });
|
|
41
|
+
if (signup && isHoneypotFilled(fields.website))
|
|
42
|
+
return jsonResponse(202, { message: 'Registration request received.' });
|
|
43
|
+
if (!policy)
|
|
44
|
+
return;
|
|
45
|
+
const admission = await service.admitAuthRequest({ client: request.client, ...(signup ? { signupEmail: typeof fields.email === 'string' ? fields.email : '' } : {}) });
|
|
46
|
+
if (!admission.challengeRequired)
|
|
47
|
+
return;
|
|
48
|
+
let passed = false;
|
|
49
|
+
if (challenge && typeof fields.challengeToken === 'string' && fields.challengeToken && request.client && active < 32) {
|
|
50
|
+
active++;
|
|
51
|
+
const controller = new AbortController();
|
|
52
|
+
let timer;
|
|
53
|
+
const verification = Promise.resolve().then(() => challenge.verify({ token: fields.challengeToken, client: request.client, signal: controller.signal }));
|
|
54
|
+
void verification.finally(() => { active--; }).catch(() => { });
|
|
55
|
+
try {
|
|
56
|
+
passed = await Promise.race([verification, new Promise(resolve => { timer = setTimeout(() => { controller.abort(); resolve(false); }, 5000); })]);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
passed = false;
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
if (timer)
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (passed !== true) {
|
|
67
|
+
const source = 'Challenge required. Return to the form and try again.', message = presentation?.textSource(source) ?? source;
|
|
68
|
+
if (wantsJson(request))
|
|
69
|
+
return jsonResponse(403, { error: message, challengeRequired: true });
|
|
70
|
+
const retry = signup ? '/signup' : path === '/forgot-password' || path === '/recover-factor' ? path : path === '/send-email-code' ? '/email-code' : '/login';
|
|
71
|
+
return screenResponse('Verification required', { name: 'auth/status', view: { alert: true, message, href: mount + retry, label: presentation?.textSource('Try again') ?? 'Try again' } }, { status: 403, presentation, ui });
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import type { AuthAbusePolicy } from './abuse.ts';
|
|
3
|
+
export declare function abuseOperation(operation: string, args: Record<string, unknown>, db: DatabaseSync, policy: AuthAbusePolicy | undefined, fail: (status: number, code: string) => never): {
|
|
4
|
+
value: unknown;
|
|
5
|
+
} | undefined;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export function abuseOperation(operation, args, db, policy, fail) {
|
|
2
|
+
if (!operation.startsWith('abuse'))
|
|
3
|
+
return;
|
|
4
|
+
const now = Number(args.now), key = String(args.key);
|
|
5
|
+
const ensure = (count = 1) => { db.prepare('DELETE FROM auth_abuse WHERE key IN (SELECT key FROM auth_abuse WHERE expires<=? LIMIT 1000)').run(now); if (Number(db.prepare('SELECT count(*) AS n FROM auth_abuse').get()?.n) + count > 100000)
|
|
6
|
+
fail(503, 'auth_capacity_reached'); };
|
|
7
|
+
if (operation === 'abuseBackoffCheck') {
|
|
8
|
+
const row = db.prepare('SELECT blocked_until FROM auth_abuse WHERE key=? AND expires>?').get(key, now);
|
|
9
|
+
return { value: Boolean(row && Number(row.blocked_until) > now) };
|
|
10
|
+
}
|
|
11
|
+
if (operation === 'abuseFailure') {
|
|
12
|
+
const config = policy?.passwordBackoff;
|
|
13
|
+
if (!config)
|
|
14
|
+
return { value: undefined };
|
|
15
|
+
const row = db.prepare('SELECT count FROM auth_abuse WHERE key=? AND expires>?').get(key, now);
|
|
16
|
+
if (!row)
|
|
17
|
+
ensure();
|
|
18
|
+
const count = Math.min(64, Number(row?.count ?? 0) + 1), delay = count < config.threshold ? 0 : Math.min(config.maxDelayMs, config.initialDelayMs * 2 ** Math.min(30, count - config.threshold));
|
|
19
|
+
db.prepare('INSERT INTO auth_abuse VALUES(?,?,?,?) ON CONFLICT(key) DO UPDATE SET count=excluded.count,expires=excluded.expires,blocked_until=excluded.blocked_until').run(key, count, now + config.resetAfterMs, now + delay);
|
|
20
|
+
return { value: undefined };
|
|
21
|
+
}
|
|
22
|
+
if (operation === 'abuseAdmit') {
|
|
23
|
+
const limits = args.limits;
|
|
24
|
+
let challengeRequired = false;
|
|
25
|
+
const rows = limits.map(item => ({ item, row: db.prepare('SELECT count,expires FROM auth_abuse WHERE key=? AND expires>?').get(item.key, now) }));
|
|
26
|
+
if (rows.some(({ item, row }) => Number(row?.count ?? 0) >= item.limit))
|
|
27
|
+
fail(429, 'auth_rate_limited');
|
|
28
|
+
const added = rows.filter(({ row }) => !row).length;
|
|
29
|
+
if (added)
|
|
30
|
+
ensure(added);
|
|
31
|
+
for (const { item, row } of rows) {
|
|
32
|
+
const count = Number(row?.count ?? 0) + 1;
|
|
33
|
+
db.prepare('INSERT INTO auth_abuse VALUES(?,?,?,0) ON CONFLICT(key) DO UPDATE SET count=excluded.count,expires=excluded.expires,blocked_until=0').run(item.key, count, row ? Number(row.expires) : now + item.windowMs);
|
|
34
|
+
if (item.challengeAfter !== undefined && count > item.challengeAfter)
|
|
35
|
+
challengeRequired = true;
|
|
36
|
+
}
|
|
37
|
+
return { value: { challengeRequired } };
|
|
38
|
+
}
|
|
39
|
+
return;
|
|
40
|
+
}
|
package/dist/abuse.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface VelocityLimit {
|
|
2
|
+
limit: number;
|
|
3
|
+
windowMs: number;
|
|
4
|
+
}
|
|
5
|
+
export interface AuthAbuseOptions {
|
|
6
|
+
passwordBackoff?: {
|
|
7
|
+
threshold?: number;
|
|
8
|
+
initialDelayMs?: number;
|
|
9
|
+
maxDelayMs?: number;
|
|
10
|
+
resetAfterMs?: number;
|
|
11
|
+
};
|
|
12
|
+
client?: VelocityLimit;
|
|
13
|
+
signupClient?: VelocityLimit;
|
|
14
|
+
signupDomain?: VelocityLimit;
|
|
15
|
+
challengeAfter?: number;
|
|
16
|
+
}
|
|
17
|
+
export interface AuthAbusePolicy extends Omit<AuthAbuseOptions, 'passwordBackoff'> {
|
|
18
|
+
passwordBackoff?: {
|
|
19
|
+
threshold: number;
|
|
20
|
+
initialDelayMs: number;
|
|
21
|
+
maxDelayMs: number;
|
|
22
|
+
resetAfterMs: number;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export declare function normalizeAbusePolicy(value: AuthAbuseOptions | undefined): AuthAbusePolicy | undefined;
|
|
26
|
+
/** Pseudonymous, bounded counter keys. No raw address, email or domain is stored. */
|
|
27
|
+
export declare const abuseKey: (scope: string, value: string) => string;
|
package/dist/abuse.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
const integer = (value, min, max) => typeof value === 'number' && Number.isSafeInteger(value) && value >= min && value <= max;
|
|
3
|
+
export function normalizeAbusePolicy(value) {
|
|
4
|
+
if (value === undefined)
|
|
5
|
+
return;
|
|
6
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !['passwordBackoff', 'client', 'signupClient', 'signupDomain', 'challengeAfter'].includes(key)))
|
|
7
|
+
throw new Error('Invalid abuse policy');
|
|
8
|
+
const policy = {};
|
|
9
|
+
for (const name of ['client', 'signupClient', 'signupDomain']) {
|
|
10
|
+
const item = value[name];
|
|
11
|
+
if (item !== undefined) {
|
|
12
|
+
if (!item || Object.keys(item).some(key => !['limit', 'windowMs'].includes(key)) || !integer(item.limit, 1, 100000) || !integer(item.windowMs, 1000, 86400000))
|
|
13
|
+
throw new Error('Invalid velocity limit');
|
|
14
|
+
policy[name] = { limit: item.limit, windowMs: item.windowMs };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
if (value.passwordBackoff !== undefined) {
|
|
18
|
+
const item = value.passwordBackoff;
|
|
19
|
+
if (!item || Object.keys(item).some(key => !['threshold', 'initialDelayMs', 'maxDelayMs', 'resetAfterMs'].includes(key)))
|
|
20
|
+
throw new Error('Invalid backoff policy');
|
|
21
|
+
const backoff = { threshold: item.threshold ?? 5, initialDelayMs: item.initialDelayMs ?? 1000, maxDelayMs: item.maxDelayMs ?? 900000, resetAfterMs: item.resetAfterMs ?? 86400000 };
|
|
22
|
+
if (!integer(backoff.threshold, 1, 20) || !integer(backoff.initialDelayMs, 100, 60000) || !integer(backoff.maxDelayMs, backoff.initialDelayMs, 86400000) || !integer(backoff.resetAfterMs, backoff.maxDelayMs, 604800000))
|
|
23
|
+
throw new Error('Invalid backoff policy');
|
|
24
|
+
policy.passwordBackoff = backoff;
|
|
25
|
+
}
|
|
26
|
+
if (value.challengeAfter !== undefined) {
|
|
27
|
+
if (!policy.client || !integer(value.challengeAfter, 1, policy.client.limit - 1))
|
|
28
|
+
throw new Error('Challenge threshold requires a client limit');
|
|
29
|
+
policy.challengeAfter = value.challengeAfter;
|
|
30
|
+
}
|
|
31
|
+
return Object.freeze(policy);
|
|
32
|
+
}
|
|
33
|
+
/** Pseudonymous, bounded counter keys. No raw address, email or domain is stored. */
|
|
34
|
+
export const abuseKey = (scope, value) => createHash('sha256').update('urlcode-auth-abuse:' + scope + '\0' + value).digest('hex');
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { AuthStore } from './auth-store.ts';
|
|
2
|
+
export type AdminAccountAction = 'verify-email' | 'force-password-reset' | 'schedule-deletion' | 'cancel-deletion' | 'remove-passkey' | 'remove-external' | 'request-email-change' | 'assign-roles' | 'resend-verification';
|
|
3
|
+
export type AdminAccountDelivery = {
|
|
4
|
+
kind: 'token';
|
|
5
|
+
accountId: string;
|
|
6
|
+
email: string;
|
|
7
|
+
locale?: string;
|
|
8
|
+
purpose: 'verify-email' | 'reset-password' | 'cancel-deletion' | 'verify-email-change' | 'cancel-email-change';
|
|
9
|
+
token: string;
|
|
10
|
+
} | {
|
|
11
|
+
kind: 'notice';
|
|
12
|
+
accountId: string;
|
|
13
|
+
email: string;
|
|
14
|
+
locale?: string;
|
|
15
|
+
action: AdminAccountAction;
|
|
16
|
+
};
|
|
17
|
+
export type AdminAccountRequest = {
|
|
18
|
+
actorToken: string;
|
|
19
|
+
accountIds: string[];
|
|
20
|
+
reason: string;
|
|
21
|
+
} & ({
|
|
22
|
+
action: 'assign-roles';
|
|
23
|
+
roles: string[];
|
|
24
|
+
} | {
|
|
25
|
+
action: 'request-email-change';
|
|
26
|
+
email: string;
|
|
27
|
+
} | {
|
|
28
|
+
action: 'remove-passkey';
|
|
29
|
+
credentialId: string;
|
|
30
|
+
} | {
|
|
31
|
+
action: 'remove-external';
|
|
32
|
+
externalId: string;
|
|
33
|
+
} | {
|
|
34
|
+
action: 'verify-email' | 'force-password-reset' | 'schedule-deletion' | 'cancel-deletion' | 'resend-verification';
|
|
35
|
+
});
|
|
36
|
+
export interface AdminAuthenticationMethods {
|
|
37
|
+
accountId: string;
|
|
38
|
+
password: boolean;
|
|
39
|
+
totp: boolean;
|
|
40
|
+
passkeys: {
|
|
41
|
+
id: string;
|
|
42
|
+
secondFactor: boolean;
|
|
43
|
+
added?: number;
|
|
44
|
+
lastUsed?: number;
|
|
45
|
+
}[];
|
|
46
|
+
external: {
|
|
47
|
+
id: string;
|
|
48
|
+
provider: string;
|
|
49
|
+
added?: number;
|
|
50
|
+
lastUsed?: number;
|
|
51
|
+
}[];
|
|
52
|
+
}
|
|
53
|
+
export interface AdminAccountService {
|
|
54
|
+
inspectAccountAuthentication(input: {
|
|
55
|
+
actorToken: string;
|
|
56
|
+
accountId: string;
|
|
57
|
+
reason: string;
|
|
58
|
+
}): Promise<AdminAuthenticationMethods>;
|
|
59
|
+
stageAccountAdministration(input: AdminAccountRequest): Promise<{
|
|
60
|
+
operationId: string;
|
|
61
|
+
deliveries: AdminAccountDelivery[];
|
|
62
|
+
}>;
|
|
63
|
+
completeAccountAdministration(input: {
|
|
64
|
+
actorToken: string;
|
|
65
|
+
operationId: string;
|
|
66
|
+
}): Promise<{
|
|
67
|
+
affected: number;
|
|
68
|
+
}>;
|
|
69
|
+
cancelAccountAdministration(input: {
|
|
70
|
+
actorToken: string;
|
|
71
|
+
operationId: string;
|
|
72
|
+
}): Promise<void>;
|
|
73
|
+
}
|
|
74
|
+
interface Dependencies {
|
|
75
|
+
store: AuthStore;
|
|
76
|
+
check(): void;
|
|
77
|
+
now(): number;
|
|
78
|
+
roles: Record<string, string[]>;
|
|
79
|
+
permittedEmail(value: string): string;
|
|
80
|
+
}
|
|
81
|
+
/** Operator-side service. Only declared delivery callbacks receive staged tokens. */
|
|
82
|
+
export declare function createAdminAccountOperations(deps: Dependencies): AdminAccountService;
|
|
83
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { AuthError } from "./auth-store.js";
|
|
3
|
+
const hash = (value) => createHash('sha256').update(value).digest('hex');
|
|
4
|
+
const valid = (value, max) => typeof value === 'string' && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
|
|
5
|
+
/** Operator-side service. Only declared delivery callbacks receive staged tokens. */
|
|
6
|
+
export function createAdminAccountOperations(deps) {
|
|
7
|
+
const fail = () => { throw new AuthError(400, 'invalid_administration'); };
|
|
8
|
+
const actor = (value) => { deps.check(); if (!/^[A-Za-z0-9_-]{43}$/.test(value))
|
|
9
|
+
fail(); return hash(value); };
|
|
10
|
+
const reason = (value) => { if (!valid(value, 256) || !value.trim())
|
|
11
|
+
fail(); return value.trim(); };
|
|
12
|
+
return {
|
|
13
|
+
async inspectAccountAuthentication(input) { const actorHash = actor(input.actorToken); if (!valid(input.accountId, 256))
|
|
14
|
+
fail(); return deps.store.call('adminAccountInspect', { hash: actorHash, accountId: input.accountId, reason: reason(input.reason), now: deps.now() }); },
|
|
15
|
+
async stageAccountAdministration(input) {
|
|
16
|
+
const actorHash = actor(input.actorToken), why = reason(input.reason);
|
|
17
|
+
if (!Array.isArray(input.accountIds) || input.accountIds.length < 1 || input.accountIds.length > 50 || new Set(input.accountIds).size !== input.accountIds.length || input.accountIds.some(value => !valid(value, 256)))
|
|
18
|
+
fail();
|
|
19
|
+
if (!['verify-email', 'force-password-reset', 'schedule-deletion', 'cancel-deletion', 'remove-passkey', 'remove-external', 'request-email-change', 'assign-roles', 'resend-verification'].includes(input.action))
|
|
20
|
+
fail();
|
|
21
|
+
if (input.accountIds.length > 1 && !['assign-roles', 'resend-verification'].includes(input.action))
|
|
22
|
+
fail();
|
|
23
|
+
const parameters = {};
|
|
24
|
+
if (input.action === 'assign-roles') {
|
|
25
|
+
if (!Array.isArray(input.roles) || input.roles.length < 1 || input.roles.length > 32 || new Set(input.roles).size !== input.roles.length || input.roles.some(value => !Object.hasOwn(deps.roles, value)))
|
|
26
|
+
fail();
|
|
27
|
+
parameters.roles = [...input.roles];
|
|
28
|
+
}
|
|
29
|
+
if (input.action === 'request-email-change')
|
|
30
|
+
parameters.email = deps.permittedEmail(input.email);
|
|
31
|
+
if (input.action === 'remove-passkey') {
|
|
32
|
+
if (!valid(input.credentialId, 1024))
|
|
33
|
+
fail();
|
|
34
|
+
parameters.credentialId = input.credentialId;
|
|
35
|
+
}
|
|
36
|
+
if (input.action === 'remove-external') {
|
|
37
|
+
if (!/^[a-f0-9]{64}$/.test(input.externalId))
|
|
38
|
+
fail();
|
|
39
|
+
parameters.externalId = input.externalId;
|
|
40
|
+
}
|
|
41
|
+
const raw = new Map(input.accountIds.map(id => [id, { primary: randomBytes(32).toString('base64url'), secondary: randomBytes(32).toString('base64url') }]));
|
|
42
|
+
const result = await deps.store.call('adminAccountStage', { hash: actorHash, operationId: randomUUID(), accountIds: input.accountIds, action: input.action, reason: why, parameters, tokens: input.accountIds.map(accountId => ({ accountId, primaryHash: hash(raw.get(accountId).primary), secondaryHash: hash(raw.get(accountId).secondary) })), now: deps.now() });
|
|
43
|
+
return { operationId: result.operationId, deliveries: result.deliveries.map(delivery => delivery.kind === 'notice' ? delivery : { kind: 'token', accountId: delivery.accountId, email: delivery.email, ...(delivery.locale ? { locale: delivery.locale } : {}), purpose: delivery.purpose, token: raw.get(delivery.accountId)[delivery.tokenSlot] }) };
|
|
44
|
+
},
|
|
45
|
+
async completeAccountAdministration(input) { const actorHash = actor(input.actorToken); if (!valid(input.operationId, 256))
|
|
46
|
+
fail(); return deps.store.call('adminAccountComplete', { hash: actorHash, operationId: input.operationId, now: deps.now() }); },
|
|
47
|
+
async cancelAccountAdministration(input) { const actorHash = actor(input.actorToken); if (!valid(input.operationId, 256))
|
|
48
|
+
fail(); await deps.store.call('adminAccountCancel', { hash: actorHash, operationId: input.operationId, now: deps.now() }); },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import type { AuthRecord, SessionRecord } from './auth-store.ts';
|
|
3
|
+
interface Context {
|
|
4
|
+
db: DatabaseSync;
|
|
5
|
+
now: number;
|
|
6
|
+
deletionGraceMs: number;
|
|
7
|
+
roles: Record<string, string[]>;
|
|
8
|
+
account(id: string): AuthRecord | null;
|
|
9
|
+
fresh(hash: string, now: number): {
|
|
10
|
+
user: AuthRecord;
|
|
11
|
+
session: SessionRecord;
|
|
12
|
+
};
|
|
13
|
+
isRestricted(user: AuthRecord): boolean;
|
|
14
|
+
save(user: AuthRecord): void;
|
|
15
|
+
audit(actor: string, action: string, subject: string, now: number, reason?: string): void;
|
|
16
|
+
fail(status: number, code: string): never;
|
|
17
|
+
}
|
|
18
|
+
/** All calls are inside the existing store revision/active-key checked transaction. */
|
|
19
|
+
export declare function adminAccountOperation(operation: string, args: Record<string, unknown>, context: Context): {
|
|
20
|
+
value: unknown;
|
|
21
|
+
} | undefined;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
const externalId = (provider, subject) => createHash('sha256').update(provider + '\0' + subject).digest('hex');
|
|
3
|
+
/** All calls are inside the existing store revision/active-key checked transaction. */
|
|
4
|
+
export function adminAccountOperation(operation, args, context) {
|
|
5
|
+
if (!operation.startsWith('adminAccount'))
|
|
6
|
+
return;
|
|
7
|
+
const { db, now } = context, fail = context.fail;
|
|
8
|
+
const permissions = (roles) => [...new Set(roles.flatMap(role => context.roles[role] ?? []))];
|
|
9
|
+
const administrator = (user) => permissions(user.roles).includes('*');
|
|
10
|
+
const authorize = (actor, target, permission, nextRoles) => {
|
|
11
|
+
const granted = permissions(actor.roles);
|
|
12
|
+
if (context.isRestricted(actor) || !granted.includes('*') && !granted.includes(permission))
|
|
13
|
+
fail(403, 'permission_denied');
|
|
14
|
+
if (actor.id === target.id)
|
|
15
|
+
fail(403, 'self_administration_denied');
|
|
16
|
+
for (const permission of [...permissions(target.roles), ...permissions(nextRoles ?? target.roles)])
|
|
17
|
+
if (!granted.includes('*') && !granted.includes(permission))
|
|
18
|
+
fail(403, 'delegation_ceiling_exceeded');
|
|
19
|
+
};
|
|
20
|
+
const activity = (kind, id) => { const row = db.prepare('SELECT added,last_used FROM auth_method_activity WHERE kind=? AND method_id=?').get(kind, id); return { ...(typeof row?.added === 'number' ? { added: row.added } : {}), ...(typeof row?.last_used === 'number' ? { lastUsed: row.last_used } : {}) }; };
|
|
21
|
+
const identities = (id) => db.prepare('SELECT provider,subject FROM auth_external WHERE account_id=? LIMIT 16').all(id).map(row => ({ provider: String(row.provider), subject: String(row.subject), id: externalId(String(row.provider), String(row.subject)) }));
|
|
22
|
+
const validate = (actor, plan) => {
|
|
23
|
+
const targets = plan.targets.map(entry => {
|
|
24
|
+
const user = context.account(entry.id);
|
|
25
|
+
if (!user)
|
|
26
|
+
fail(404, 'account_not_found');
|
|
27
|
+
if (user.version !== entry.version)
|
|
28
|
+
fail(409, 'account_changed');
|
|
29
|
+
authorize(actor, user, 'auth.users.manage', plan.parameters.roles);
|
|
30
|
+
if (plan.action === 'cancel-deletion') {
|
|
31
|
+
if (user.status !== 'pending-delete' || !user.deleteAfter || user.deleteAfter <= now)
|
|
32
|
+
fail(409, 'deletion_unavailable');
|
|
33
|
+
}
|
|
34
|
+
else if (user.status === 'pending-delete')
|
|
35
|
+
fail(409, 'account_pending_deletion');
|
|
36
|
+
if (['force-password-reset', 'resend-verification', 'request-email-change'].includes(plan.action) && user.status !== 'active')
|
|
37
|
+
fail(409, 'account_not_active');
|
|
38
|
+
if (plan.action === 'resend-verification' && user.emailVerified)
|
|
39
|
+
fail(409, 'email_already_verified');
|
|
40
|
+
if (plan.action === 'remove-passkey') {
|
|
41
|
+
if (!db.prepare('SELECT id FROM auth_passkeys WHERE id=? AND account_id=?').get(plan.parameters.credentialId, user.id))
|
|
42
|
+
fail(404, 'method_not_found');
|
|
43
|
+
if (user.mfaPasskeys?.includes(plan.parameters.credentialId))
|
|
44
|
+
fail(409, 'factor_reset_case_required');
|
|
45
|
+
if (!user.passwordHash && identities(user.id).length === 0 && Number(db.prepare('SELECT count(*) AS n FROM auth_passkeys WHERE account_id=?').get(user.id)?.n) <= 1)
|
|
46
|
+
fail(409, 'last_sign_in_method');
|
|
47
|
+
}
|
|
48
|
+
if (plan.action === 'remove-external') {
|
|
49
|
+
const methods = identities(user.id);
|
|
50
|
+
if (!methods.some(method => method.id === plan.parameters.externalId))
|
|
51
|
+
fail(404, 'method_not_found');
|
|
52
|
+
if (!user.passwordHash && methods.length <= 1 && Number(db.prepare('SELECT count(*) AS n FROM auth_passkeys WHERE account_id=?').get(user.id)?.n) === 0)
|
|
53
|
+
fail(409, 'last_sign_in_method');
|
|
54
|
+
}
|
|
55
|
+
if (['remove-passkey', 'remove-external'].includes(plan.action) && !user.passwordHash && !user.totpSecret && user.mfaPasskeys?.length) {
|
|
56
|
+
const remainingKeys = db.prepare('SELECT id FROM auth_passkeys WHERE account_id=?').all(user.id).map(row => String(row.id)).filter(id => plan.action !== 'remove-passkey' || id !== plan.parameters.credentialId);
|
|
57
|
+
const remainingExternal = identities(user.id).filter(method => plan.action !== 'remove-external' || method.id !== plan.parameters.externalId);
|
|
58
|
+
if (!remainingExternal.length && !remainingKeys.some(primary => user.mfaPasskeys.some(factor => factor !== primary && remainingKeys.includes(factor))))
|
|
59
|
+
fail(409, 'last_sign_in_method');
|
|
60
|
+
}
|
|
61
|
+
if (plan.action === 'request-email-change') {
|
|
62
|
+
if (user.email === plan.parameters.email)
|
|
63
|
+
fail(400, 'email_unchanged');
|
|
64
|
+
if (db.prepare('SELECT id FROM auth_accounts WHERE email=?').get(plan.parameters.email))
|
|
65
|
+
fail(409, 'email_unavailable');
|
|
66
|
+
if (db.prepare('SELECT account_id FROM auth_email_changes WHERE account_id=? AND expires>?').get(user.id, now))
|
|
67
|
+
fail(409, 'email_change_pending');
|
|
68
|
+
}
|
|
69
|
+
return user;
|
|
70
|
+
});
|
|
71
|
+
const disabling = (user) => ['schedule-deletion', 'force-password-reset'].includes(plan.action) || plan.action === 'assign-roles' && !permissions(plan.parameters.roles).includes('*');
|
|
72
|
+
if (targets.some(user => administrator(user) && user.status === 'active' && !context.isRestricted(user) && disabling(user))) {
|
|
73
|
+
const disabled = new Set(targets.filter(disabling).map(user => user.id));
|
|
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); });
|
|
75
|
+
if (!remaining)
|
|
76
|
+
fail(409, 'last_administrator_required');
|
|
77
|
+
}
|
|
78
|
+
return targets;
|
|
79
|
+
};
|
|
80
|
+
const revoke = (id) => { for (const table of ['auth_sessions', 'auth_tokens', 'auth_email_codes', 'auth_email_changes', 'auth_factor_recovery', 'auth_second_factor_proofs', 'auth_trusted_devices', 'auth_manual_recovery'])
|
|
81
|
+
db.prepare(`DELETE FROM ${table} WHERE account_id=?`).run(id); };
|
|
82
|
+
if (operation === 'adminAccountInspect') {
|
|
83
|
+
const actor = context.fresh(String(args.hash), now).user, target = context.account(String(args.accountId));
|
|
84
|
+
if (!target)
|
|
85
|
+
fail(404, 'account_not_found');
|
|
86
|
+
authorize(actor, target, 'auth.users.read');
|
|
87
|
+
context.audit(actor.id, 'admin.authentication_inspected', target.id, now, String(args.reason));
|
|
88
|
+
return { value: { accountId: target.id, password: !!target.passwordHash, totp: !!target.totpSecret, passkeys: db.prepare('SELECT id FROM auth_passkeys WHERE account_id=? LIMIT 16').all(target.id).map(row => ({ id: String(row.id), ...activity('passkey', String(row.id)), secondFactor: target.mfaPasskeys?.includes(String(row.id)) ?? false })), external: identities(target.id).map(({ id, provider, subject }) => ({ id, provider, ...activity('oidc', provider + '\0' + subject) })) } };
|
|
89
|
+
}
|
|
90
|
+
if (operation === 'adminAccountStage') {
|
|
91
|
+
const actor = context.fresh(String(args.hash), now).user, targets = args.accountIds.map(id => { const user = context.account(id); if (!user)
|
|
92
|
+
fail(404, 'account_not_found'); const tokens = args.tokens.find(item => item.accountId === id); return { id, version: user.version, primaryHash: tokens.primaryHash, secondaryHash: tokens.secondaryHash }; });
|
|
93
|
+
const plan = { actorId: actor.id, actorVersion: actor.version, action: args.action, reason: String(args.reason), parameters: args.parameters, targets };
|
|
94
|
+
const users = validate(actor, plan);
|
|
95
|
+
db.prepare('DELETE FROM auth_admin_operations WHERE expires<=?').run(now);
|
|
96
|
+
if (Number(db.prepare('SELECT count(*) AS n FROM auth_admin_operations').get()?.n) >= 1000)
|
|
97
|
+
fail(503, 'auth_capacity_reached');
|
|
98
|
+
db.prepare('INSERT INTO auth_admin_operations(id,data,expires) VALUES(?,?,?)').run(String(args.operationId), JSON.stringify(plan), now + 300000);
|
|
99
|
+
const deliveries = [];
|
|
100
|
+
for (const user of users) {
|
|
101
|
+
const base = { accountId: user.id, email: user.email, ...(user.profile?.locale ? { locale: user.profile.locale } : {}) };
|
|
102
|
+
if (['remove-passkey', 'remove-external'].includes(plan.action) && !user.passwordHash && !user.totpSecret && user.mfaPasskeys?.length) {
|
|
103
|
+
const remainingKeys = db.prepare('SELECT id FROM auth_passkeys WHERE account_id=?').all(user.id).map(row => String(row.id)).filter(id => plan.action !== 'remove-passkey' || id !== plan.parameters.credentialId);
|
|
104
|
+
const remainingExternal = identities(user.id).filter(method => plan.action !== 'remove-external' || method.id !== plan.parameters.externalId);
|
|
105
|
+
if (!remainingExternal.length && !remainingKeys.some(primary => user.mfaPasskeys.some(factor => factor !== primary && remainingKeys.includes(factor))))
|
|
106
|
+
fail(409, 'last_sign_in_method');
|
|
107
|
+
}
|
|
108
|
+
if (plan.action === 'request-email-change') {
|
|
109
|
+
deliveries.push({ ...base, kind: 'token', purpose: 'cancel-email-change', tokenSlot: 'secondary' }, { ...base, email: plan.parameters.email, kind: 'token', purpose: 'verify-email-change', tokenSlot: 'primary' });
|
|
110
|
+
}
|
|
111
|
+
else if (['force-password-reset', 'resend-verification', 'schedule-deletion'].includes(plan.action))
|
|
112
|
+
deliveries.push({ ...base, kind: 'token', purpose: plan.action === 'force-password-reset' ? 'reset-password' : plan.action === 'resend-verification' ? 'verify-email' : 'cancel-deletion', tokenSlot: 'primary' });
|
|
113
|
+
else
|
|
114
|
+
deliveries.push({ ...base, kind: 'notice', action: plan.action });
|
|
115
|
+
context.audit(actor.id, 'admin.' + plan.action + '.staged', user.id, now, plan.reason);
|
|
116
|
+
}
|
|
117
|
+
return { value: { operationId: String(args.operationId), deliveries } };
|
|
118
|
+
}
|
|
119
|
+
if (operation === 'adminAccountComplete' || operation === 'adminAccountCancel') {
|
|
120
|
+
const actor = context.fresh(String(args.hash), now).user, row = db.prepare('SELECT data FROM auth_admin_operations WHERE id=? AND expires>?').get(String(args.operationId), now);
|
|
121
|
+
if (!row)
|
|
122
|
+
fail(409, 'administration_unavailable');
|
|
123
|
+
const plan = JSON.parse(String(row.data));
|
|
124
|
+
if (plan.actorId !== actor.id || plan.actorVersion !== actor.version)
|
|
125
|
+
fail(409, 'administration_actor_changed');
|
|
126
|
+
if (operation === 'adminAccountCancel') {
|
|
127
|
+
db.prepare('DELETE FROM auth_admin_operations WHERE id=?').run(String(args.operationId));
|
|
128
|
+
for (const target of plan.targets)
|
|
129
|
+
context.audit(actor.id, 'admin.' + plan.action + '.cancelled', target.id, now, plan.reason);
|
|
130
|
+
return { value: undefined };
|
|
131
|
+
}
|
|
132
|
+
const users = validate(actor, plan);
|
|
133
|
+
for (const user of users) {
|
|
134
|
+
const tokens = plan.targets.find(target => target.id === user.id);
|
|
135
|
+
if (!['resend-verification', 'request-email-change'].includes(plan.action)) {
|
|
136
|
+
user.version++;
|
|
137
|
+
revoke(user.id);
|
|
138
|
+
}
|
|
139
|
+
switch (plan.action) {
|
|
140
|
+
case 'verify-email':
|
|
141
|
+
user.emailVerified = true;
|
|
142
|
+
break;
|
|
143
|
+
case 'force-password-reset':
|
|
144
|
+
user.passwordHash = '';
|
|
145
|
+
db.prepare("INSERT INTO auth_tokens VALUES(?,?,'reset-password',?,?)").run(tokens.primaryHash, user.id, now + 1800000, user.version);
|
|
146
|
+
break;
|
|
147
|
+
case 'schedule-deletion':
|
|
148
|
+
user.status = 'pending-delete';
|
|
149
|
+
user.deleteAfter = now + context.deletionGraceMs;
|
|
150
|
+
db.prepare("INSERT INTO auth_tokens VALUES(?,?,'cancel-deletion',?,?)").run(tokens.primaryHash, user.id, user.deleteAfter, user.version);
|
|
151
|
+
break;
|
|
152
|
+
case 'cancel-deletion':
|
|
153
|
+
user.status = 'active';
|
|
154
|
+
delete user.deleteAfter;
|
|
155
|
+
break;
|
|
156
|
+
case 'remove-passkey':
|
|
157
|
+
db.prepare("DELETE FROM auth_method_activity WHERE kind='passkey' AND method_id=? AND account_id=?").run(plan.parameters.credentialId, user.id);
|
|
158
|
+
db.prepare('DELETE FROM auth_passkeys WHERE id=? AND account_id=?').run(plan.parameters.credentialId, user.id);
|
|
159
|
+
break;
|
|
160
|
+
case 'remove-external': {
|
|
161
|
+
const method = identities(user.id).find(item => item.id === plan.parameters.externalId);
|
|
162
|
+
db.prepare('DELETE FROM auth_external WHERE provider=? AND subject=? AND account_id=?').run(method.provider, method.subject, user.id);
|
|
163
|
+
db.prepare("DELETE FROM auth_method_activity WHERE kind='oidc' AND method_id=? AND account_id=?").run(method.provider + '\0' + method.subject, user.id);
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
case 'assign-roles':
|
|
167
|
+
user.roles = plan.parameters.roles;
|
|
168
|
+
break;
|
|
169
|
+
case 'resend-verification':
|
|
170
|
+
db.prepare("DELETE FROM auth_tokens WHERE account_id=? AND purpose='verify-email'").run(user.id);
|
|
171
|
+
db.prepare("INSERT INTO auth_tokens VALUES(?,?,'verify-email',?,?)").run(tokens.primaryHash, user.id, now + 1800000, user.version);
|
|
172
|
+
break;
|
|
173
|
+
case 'request-email-change':
|
|
174
|
+
db.prepare('DELETE FROM auth_email_changes WHERE account_id=?').run(user.id);
|
|
175
|
+
db.prepare('INSERT INTO auth_email_changes VALUES(?,?,?,?,?,?,?)').run(user.id, plan.parameters.email, tokens.primaryHash, tokens.secondaryHash, now + 86400000, now + 172800000, user.version);
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
context.save(user);
|
|
179
|
+
context.audit(actor.id, 'admin.' + plan.action, user.id, now, plan.reason);
|
|
180
|
+
}
|
|
181
|
+
db.prepare('DELETE FROM auth_admin_operations WHERE id=?').run(String(args.operationId));
|
|
182
|
+
return { value: { affected: users.length } };
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { AuthService, AuthSecurityPolicy } from './auth-core.ts';
|
|
2
|
+
export interface BaselineCheck {
|
|
3
|
+
name: string;
|
|
4
|
+
passed: boolean;
|
|
5
|
+
}
|
|
6
|
+
export interface AuthBaselineResult {
|
|
7
|
+
passed: boolean;
|
|
8
|
+
scope: 'offline-synthetic-runtime';
|
|
9
|
+
checks: BaselineCheck[];
|
|
10
|
+
liveProviders: 'unverified';
|
|
11
|
+
limitations: string[];
|
|
12
|
+
}
|
|
13
|
+
/** Fixed synthetic fixtures only; the deadline also bounds broken runtime startup. */
|
|
14
|
+
export declare function runAuthBaseline(options?: {
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
temporaryDirectory?: string;
|
|
17
|
+
}): Promise<AuthBaselineResult>;
|
|
18
|
+
/** Checks the already-loaded trusted service; no account mutation or migration is requested. */
|
|
19
|
+
export declare function validateAuthService(service: AuthService): Promise<{
|
|
20
|
+
passed: boolean;
|
|
21
|
+
checks: BaselineCheck[];
|
|
22
|
+
revision: string;
|
|
23
|
+
registration: string;
|
|
24
|
+
security: AuthSecurityPolicy;
|
|
25
|
+
roles: {
|
|
26
|
+
count: number;
|
|
27
|
+
permissionCount: number;
|
|
28
|
+
};
|
|
29
|
+
liveProviders: 'unverified';
|
|
30
|
+
}>;
|