339 lines
11 KiB
TypeScript
339 lines
11 KiB
TypeScript
import { dev } from '$app/environment';
|
|
import { getDatabase } from '$lib/server/database';
|
|
import type { Database } from '@tursodatabase/database';
|
|
import type { Cookies } from '@sveltejs/kit';
|
|
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
|
|
export const AUTHENTICATION_TRANSACTION_COOKIE = 'shd_auth_tx';
|
|
export const AUTHENTICATION_TRANSACTION_COOKIE_PATH = '/auth/two-factor';
|
|
export const AUTHENTICATION_TRANSACTION_SECONDS = 5 * 60;
|
|
export const AUTHENTICATION_TRANSACTION_ATTEMPT_LIMIT = 5;
|
|
|
|
export const AUTHENTICATION_TRANSACTION_PROVIDERS = [
|
|
'password',
|
|
'discord',
|
|
'magic_link',
|
|
'passkey',
|
|
] as const;
|
|
export type AuthenticationTransactionProvider =
|
|
(typeof AUTHENTICATION_TRANSACTION_PROVIDERS)[number];
|
|
export type AuthenticationTransactionKind = 'login' | 'step_up';
|
|
export type AuthenticationTransactionDatabase = Pick<
|
|
Database,
|
|
'prepare' | 'transactionAsync'
|
|
>;
|
|
export type AuthenticationTransactionTransaction = Pick<Database, 'prepare'>;
|
|
|
|
export type AuthenticationTransaction = {
|
|
id: string;
|
|
userId: string;
|
|
provider: AuthenticationTransactionProvider | null;
|
|
kind: AuthenticationTransactionKind;
|
|
returnPath: string;
|
|
requiredAal: 2;
|
|
attemptCount: number;
|
|
expiresAt: string;
|
|
};
|
|
|
|
type AuthenticationTransactionRow = {
|
|
id: string;
|
|
user_id: string;
|
|
provider: AuthenticationTransactionProvider | null;
|
|
kind: AuthenticationTransactionKind;
|
|
return_path: string;
|
|
required_aal: 2;
|
|
attempt_count: number;
|
|
expires_at: string;
|
|
consumed_at: string | null;
|
|
};
|
|
|
|
export class AuthenticationTransactionError extends Error {
|
|
constructor(message = 'Unable to create the authentication transaction.') {
|
|
super(message);
|
|
this.name = 'AuthenticationTransactionError';
|
|
}
|
|
}
|
|
|
|
function hashToken(token: string): string {
|
|
return createHash('sha256').update(token, 'utf8').digest('hex');
|
|
}
|
|
|
|
function databaseOrDefault(
|
|
database?: AuthenticationTransactionDatabase,
|
|
): Promise<AuthenticationTransactionDatabase> {
|
|
return database
|
|
? Promise.resolve(database)
|
|
: (getDatabase() as Promise<AuthenticationTransactionDatabase>);
|
|
}
|
|
|
|
function nowDate(value: Date | string | number | undefined): Date {
|
|
const date =
|
|
value instanceof Date ? new Date(value) : new Date(value ?? Date.now());
|
|
if (!Number.isFinite(date.getTime()))
|
|
throw new AuthenticationTransactionError();
|
|
return date;
|
|
}
|
|
|
|
/** Accept only a same-origin path, never an absolute or protocol-relative URL. */
|
|
export function validateAuthenticationReturnPath(value: string): string | null {
|
|
if (
|
|
typeof value !== 'string' ||
|
|
value.length < 1 ||
|
|
value.length > 2048 ||
|
|
!value.startsWith('/') ||
|
|
value.startsWith('//') ||
|
|
value.includes('\\') ||
|
|
value.includes('#') ||
|
|
/[\u0000-\u001f\u007f]/u.test(value)
|
|
)
|
|
return null;
|
|
try {
|
|
const decoded = decodeURIComponent(value);
|
|
if (
|
|
decoded.startsWith('//') ||
|
|
decoded.includes('\\') ||
|
|
/[\u0000-\u001f\u007f]/u.test(decoded)
|
|
)
|
|
return null;
|
|
const parsed = new URL(value, 'https://shd.invalid');
|
|
if (
|
|
parsed.origin !== 'https://shd.invalid' ||
|
|
!parsed.pathname.startsWith('/')
|
|
)
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function cookieOptions() {
|
|
return {
|
|
path: AUTHENTICATION_TRANSACTION_COOKIE_PATH,
|
|
httpOnly: true,
|
|
sameSite: 'lax' as const,
|
|
secure: !dev,
|
|
maxAge: AUTHENTICATION_TRANSACTION_SECONDS,
|
|
};
|
|
}
|
|
|
|
export function clearAuthenticationTransactionCookie(cookies: Cookies): void {
|
|
cookies.delete(AUTHENTICATION_TRANSACTION_COOKIE, {
|
|
path: AUTHENTICATION_TRANSACTION_COOKIE_PATH,
|
|
});
|
|
}
|
|
|
|
function fromRow(row: AuthenticationTransactionRow): AuthenticationTransaction {
|
|
return {
|
|
id: String(row.id),
|
|
userId: String(row.user_id),
|
|
provider: row.provider ?? null,
|
|
kind: row.kind,
|
|
returnPath: String(row.return_path),
|
|
requiredAal: 2,
|
|
attemptCount: Number(row.attempt_count),
|
|
expiresAt: String(row.expires_at),
|
|
};
|
|
}
|
|
|
|
export async function createAuthenticationTransaction(
|
|
cookies: Cookies,
|
|
databaseInput: AuthenticationTransactionDatabase | undefined,
|
|
options: {
|
|
userId: string;
|
|
kind: AuthenticationTransactionKind;
|
|
provider?: AuthenticationTransactionProvider | null;
|
|
returnPath: string;
|
|
now?: Date | string | number;
|
|
},
|
|
): Promise<AuthenticationTransaction> {
|
|
const returnPath = validateAuthenticationReturnPath(options.returnPath);
|
|
if (
|
|
!options.userId ||
|
|
!returnPath ||
|
|
(options.kind === 'login' && !options.provider)
|
|
)
|
|
throw new AuthenticationTransactionError();
|
|
const database = await databaseOrDefault(databaseInput);
|
|
const now = nowDate(options.now);
|
|
const createdAt = now.toISOString();
|
|
const expiresAt = new Date(
|
|
now.getTime() + AUTHENTICATION_TRANSACTION_SECONDS * 1000,
|
|
).toISOString();
|
|
const id = randomUUID();
|
|
const token = randomBytes(32).toString('base64url');
|
|
await (
|
|
await database.prepare(
|
|
`INSERT INTO authentication_transactions(
|
|
id,token_hash,user_id,provider,kind,return_path,required_aal,attempt_count,expires_at,created_at
|
|
) VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
|
)
|
|
).run(
|
|
id,
|
|
hashToken(token),
|
|
options.userId,
|
|
options.provider ?? null,
|
|
options.kind,
|
|
returnPath,
|
|
2,
|
|
0,
|
|
expiresAt,
|
|
createdAt,
|
|
);
|
|
cookies.set(AUTHENTICATION_TRANSACTION_COOKIE, token, cookieOptions());
|
|
return {
|
|
id,
|
|
userId: options.userId,
|
|
provider: options.provider ?? null,
|
|
kind: options.kind,
|
|
returnPath,
|
|
requiredAal: 2,
|
|
attemptCount: 0,
|
|
expiresAt,
|
|
};
|
|
}
|
|
|
|
export const beginAuthenticationTransaction = createAuthenticationTransaction;
|
|
|
|
export async function getAuthenticationTransaction(
|
|
cookies: Cookies,
|
|
databaseInput?: AuthenticationTransactionDatabase,
|
|
nowValue?: Date | string | number,
|
|
): Promise<AuthenticationTransaction | null> {
|
|
const token = cookies.get(AUTHENTICATION_TRANSACTION_COOKIE);
|
|
if (!token) return null;
|
|
const now = nowDate(nowValue).toISOString();
|
|
const database = await databaseOrDefault(databaseInput);
|
|
const row = (await (
|
|
await database.prepare(
|
|
`SELECT id,user_id,provider,kind,return_path,required_aal,attempt_count,expires_at,consumed_at
|
|
FROM authentication_transactions
|
|
WHERE token_hash=? AND consumed_at IS NULL AND expires_at>? AND attempt_count<?`,
|
|
)
|
|
).get(hashToken(token), now, AUTHENTICATION_TRANSACTION_ATTEMPT_LIMIT)) as
|
|
AuthenticationTransactionRow | undefined;
|
|
if (!row) {
|
|
clearAuthenticationTransactionCookie(cookies);
|
|
return null;
|
|
}
|
|
return fromRow(row);
|
|
}
|
|
|
|
/** Atomically account for one failed factor attempt. */
|
|
export async function recordAuthenticationAttempt(
|
|
cookies: Cookies,
|
|
databaseInput?: AuthenticationTransactionDatabase,
|
|
nowValue?: Date | string | number,
|
|
): Promise<{ accepted: boolean; attemptCount: number; locked: boolean }> {
|
|
const token = cookies.get(AUTHENTICATION_TRANSACTION_COOKIE);
|
|
if (!token) return { accepted: false, attemptCount: 0, locked: false };
|
|
const database = await databaseOrDefault(databaseInput);
|
|
const now = nowDate(nowValue).toISOString();
|
|
const attempt = database.transactionAsync(async (transaction) => {
|
|
const row = (await (
|
|
await transaction.prepare(
|
|
'SELECT id,attempt_count FROM authentication_transactions WHERE token_hash=? AND consumed_at IS NULL AND expires_at>? AND attempt_count<?',
|
|
)
|
|
).get(hashToken(token), now, AUTHENTICATION_TRANSACTION_ATTEMPT_LIMIT)) as
|
|
{ id: string; attempt_count: number } | undefined;
|
|
if (!row) return null;
|
|
const updated = await (
|
|
await transaction.prepare(
|
|
'UPDATE authentication_transactions SET attempt_count=attempt_count+1 WHERE id=? AND consumed_at IS NULL AND attempt_count<?',
|
|
)
|
|
).run(row.id, AUTHENTICATION_TRANSACTION_ATTEMPT_LIMIT);
|
|
return updated.changes === 1 ? Number(row.attempt_count) + 1 : null;
|
|
});
|
|
const attemptCount = await attempt.immediate();
|
|
if (attemptCount === null) {
|
|
clearAuthenticationTransactionCookie(cookies);
|
|
return { accepted: false, attemptCount: 0, locked: true };
|
|
}
|
|
return {
|
|
accepted: true,
|
|
attemptCount,
|
|
locked: false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Load a transaction while the caller's immediate transaction holds the
|
|
* write claim. The conditional consume must happen before that transaction
|
|
* commits.
|
|
*/
|
|
export async function claimAuthenticationTransactionInTransaction(
|
|
transaction: AuthenticationTransactionTransaction,
|
|
cookies: Cookies,
|
|
nowValue?: Date | string | number,
|
|
): Promise<AuthenticationTransaction | null> {
|
|
const token = cookies.get(AUTHENTICATION_TRANSACTION_COOKIE);
|
|
if (!token) return null;
|
|
const now = nowDate(nowValue).toISOString();
|
|
const row = (await (
|
|
await transaction.prepare(
|
|
`SELECT id,user_id,provider,kind,return_path,required_aal,attempt_count,expires_at,consumed_at
|
|
FROM authentication_transactions
|
|
WHERE token_hash=? AND consumed_at IS NULL AND expires_at>? AND attempt_count<?`,
|
|
)
|
|
).get(hashToken(token), now, AUTHENTICATION_TRANSACTION_ATTEMPT_LIMIT)) as
|
|
AuthenticationTransactionRow | undefined;
|
|
return row ? fromRow(row) : null;
|
|
}
|
|
|
|
/** Conditionally consume a transaction already loaded in this transaction. */
|
|
export async function consumeAuthenticationTransactionInTransaction(
|
|
transaction: AuthenticationTransactionTransaction,
|
|
cookies: Cookies,
|
|
transactionId: string,
|
|
nowValue?: Date | string | number,
|
|
): Promise<boolean> {
|
|
const token = cookies.get(AUTHENTICATION_TRANSACTION_COOKIE);
|
|
if (!token) return false;
|
|
const now = nowDate(nowValue).toISOString();
|
|
const consumed = await (
|
|
await transaction.prepare(
|
|
`UPDATE authentication_transactions SET consumed_at=?
|
|
WHERE id=? AND token_hash=? AND consumed_at IS NULL AND expires_at>? AND attempt_count<?`,
|
|
)
|
|
).run(
|
|
now,
|
|
transactionId,
|
|
hashToken(token),
|
|
now,
|
|
AUTHENTICATION_TRANSACTION_ATTEMPT_LIMIT,
|
|
);
|
|
return consumed.changes === 1;
|
|
}
|
|
|
|
/** Consume a transaction in one conditional write; replay cannot succeed. */
|
|
export async function consumeAuthenticationTransaction(
|
|
cookies: Cookies,
|
|
databaseInput?: AuthenticationTransactionDatabase,
|
|
nowValue?: Date | string | number,
|
|
): Promise<AuthenticationTransaction | null> {
|
|
const token = cookies.get(AUTHENTICATION_TRANSACTION_COOKIE);
|
|
if (!token) return null;
|
|
const database = await databaseOrDefault(databaseInput);
|
|
const now = nowDate(nowValue).toISOString();
|
|
const consume = database.transactionAsync(async (transaction) => {
|
|
const row = await claimAuthenticationTransactionInTransaction(
|
|
transaction,
|
|
cookies,
|
|
now,
|
|
);
|
|
if (!row) return null;
|
|
const consumed = await consumeAuthenticationTransactionInTransaction(
|
|
transaction,
|
|
cookies,
|
|
row.id,
|
|
now,
|
|
);
|
|
return consumed ? row : null;
|
|
});
|
|
const result = await consume.immediate();
|
|
clearAuthenticationTransactionCookie(cookies);
|
|
return result;
|
|
}
|
|
|
|
export const consumeAuthTransaction = consumeAuthenticationTransaction;
|