/** * Single-user passkey auth + favorites. * * - どのパスキーでログインしても同じ「オーナー」としてログインする(ユーザーは1人) * - 未ログイン時のパスキー登録は、DATA_DIR/registration-token に書かれたトークンが必要 * - パスキーが1つも無ければ起動後の初回アクセス時に自動生成される * - 全デバイスを失った場合は、このファイルを手で作れば再登録できる * - ログイン済みなら追加のパスキー(別デバイス)をトークン無しで登録できる */ import crypto from "crypto"; import fs from "fs"; import path from "path"; import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, type AuthenticationResponseJSON, type RegistrationResponseJSON, } from "@simplewebauthn/server"; import { DATA_DIR, getDb, normalizeTerm } from "./db.server"; const SESSION_COOKIE = "sid"; const CHALLENGE_COOKIE = "wa_chal"; const SESSION_DAYS = 90; const CHALLENGE_MINUTES = 5; const RP_NAME = "ライブに行くしかない"; const TOKEN_PATH = path.join(DATA_DIR, "registration-token"); // ---------- Request helpers ---------- /** Origin/RP ID as seen by the browser. Behind Caddy, the app itself receives plain http. */ function relyingParty(request: Request): { origin: string; rpID: string } { const url = new URL(request.url); const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? url.host; const proto = request.headers.get("x-forwarded-proto") ?? url.protocol.replace(/:$/, ""); return { origin: `${proto}://${host}`, rpID: host.replace(/:\d+$/, "") }; } function readCookie(request: Request, name: string): string | null { const header = request.headers.get("cookie"); if (!header) return null; for (const part of header.split(";")) { const [k, ...v] = part.trim().split("="); if (k === name) return decodeURIComponent(v.join("=")); } return null; } function cookie(request: Request, name: string, value: string, maxAgeSec: number): string { const secure = relyingParty(request).origin.startsWith("https:") ? "; Secure" : ""; return `${name}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSec}${secure}`; } function sqlTimeIn(seconds: number): string { return new Date(Date.now() + seconds * 1000).toISOString().replace("T", " ").slice(0, 19); } // ---------- Sessions ---------- export function isLoggedIn(request: Request): boolean { const sid = readCookie(request, SESSION_COOKIE); if (!sid) return false; const row = getDb() .prepare("SELECT 1 FROM sessions WHERE id = ? AND expires_at > datetime('now')") .get(sid); return !!row; } export function requireLogin(request: Request): void { if (!isLoggedIn(request)) { const url = new URL(request.url); throw new Response(null, { status: 302, headers: { Location: `/login?next=${encodeURIComponent(url.pathname + url.search)}` }, }); } } function createSession(request: Request): string { const db = getDb(); db.prepare("DELETE FROM sessions WHERE expires_at <= datetime('now')").run(); const sid = crypto.randomBytes(32).toString("base64url"); const maxAge = SESSION_DAYS * 86400; db.prepare("INSERT INTO sessions (id, expires_at) VALUES (?, ?)").run(sid, sqlTimeIn(maxAge)); return cookie(request, SESSION_COOKIE, sid, maxAge); } export function logout(request: Request): string { const sid = readCookie(request, SESSION_COOKIE); if (sid) getDb().prepare("DELETE FROM sessions WHERE id = ?").run(sid); return cookie(request, SESSION_COOKIE, "", 0); } // ---------- Challenges (one-time, short-lived) ---------- function storeChallenge(request: Request, challenge: string): string { const db = getDb(); db.prepare("DELETE FROM auth_challenges WHERE expires_at <= datetime('now')").run(); const id = crypto.randomBytes(16).toString("base64url"); const maxAge = CHALLENGE_MINUTES * 60; db.prepare("INSERT INTO auth_challenges (id, challenge, expires_at) VALUES (?, ?, ?)").run( id, challenge, sqlTimeIn(maxAge) ); return cookie(request, CHALLENGE_COOKIE, id, maxAge); } function consumeChallenge(request: Request): string | null { const id = readCookie(request, CHALLENGE_COOKIE); if (!id) return null; const db = getDb(); const row = db .prepare("SELECT challenge FROM auth_challenges WHERE id = ? AND expires_at > datetime('now')") .get(id) as { challenge: string } | undefined; db.prepare("DELETE FROM auth_challenges WHERE id = ?").run(id); return row?.challenge ?? null; } // ---------- Registration token ---------- function passkeyCount(): number { return (getDb().prepare("SELECT COUNT(*) AS n FROM passkeys").get() as { n: number }).n; } /** Ensure a registration token exists while no passkey is registered (first-time setup). */ export function ensureSetupToken(): void { if (passkeyCount() > 0 || fs.existsSync(TOKEN_PATH)) return; fs.writeFileSync(TOKEN_PATH, crypto.randomBytes(24).toString("base64url") + "\n", { mode: 0o600 }); console.log(`[auth] No passkey registered. Registration token written to ${TOKEN_PATH}`); } function checkToken(token: string | null): boolean { if (!token || !fs.existsSync(TOKEN_PATH)) return false; const expected = Buffer.from(fs.readFileSync(TOKEN_PATH, "utf8").trim()); const given = Buffer.from(token.trim()); return expected.length === given.length && crypto.timingSafeEqual(expected, given); } export function canRegister(request: Request, token: string | null): boolean { return isLoggedIn(request) || checkToken(token); } // ---------- WebAuthn ceremonies ---------- interface PasskeyRow { id: string; public_key: Buffer; counter: number; transports: string | null; } const OWNER_ID = new TextEncoder().encode("owner"); export async function registrationOptions(request: Request) { const { rpID } = relyingParty(request); const existing = getDb().prepare("SELECT id, transports FROM passkeys").all() as PasskeyRow[]; const options = await generateRegistrationOptions({ rpName: RP_NAME, rpID, userID: OWNER_ID, userName: "owner", userDisplayName: "オーナー", attestationType: "none", excludeCredentials: existing.map((p) => ({ id: p.id, transports: p.transports ? JSON.parse(p.transports) : undefined, })), authenticatorSelection: { residentKey: "required", userVerification: "preferred" }, }); return { options, setCookie: storeChallenge(request, options.challenge) }; } export async function verifyRegistration( request: Request, response: RegistrationResponseJSON, label: string | null, usedToken: boolean ): Promise { const expectedChallenge = consumeChallenge(request); if (!expectedChallenge) throw new Error("challenge expired"); const { origin, rpID } = relyingParty(request); const result = await verifyRegistrationResponse({ response, expectedChallenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: false, }); if (!result.verified) throw new Error("registration not verified"); const { credential } = result.registrationInfo; getDb() .prepare("INSERT INTO passkeys (id, public_key, counter, transports, label) VALUES (?, ?, ?, ?, ?)") .run( credential.id, Buffer.from(credential.publicKey), credential.counter, credential.transports ? JSON.stringify(credential.transports) : null, label?.slice(0, 60) || null ); // Token is single-use if (usedToken) fs.rmSync(TOKEN_PATH, { force: true }); return createSession(request); } export async function authenticationOptions(request: Request) { const { rpID } = relyingParty(request); const options = await generateAuthenticationOptions({ rpID, userVerification: "preferred" }); return { options, setCookie: storeChallenge(request, options.challenge) }; } export async function verifyAuthentication( request: Request, response: AuthenticationResponseJSON ): Promise { const expectedChallenge = consumeChallenge(request); if (!expectedChallenge) throw new Error("challenge expired"); const db = getDb(); const passkey = db.prepare("SELECT * FROM passkeys WHERE id = ?").get(response.id) as | PasskeyRow | undefined; if (!passkey) throw new Error("unknown passkey"); const { origin, rpID } = relyingParty(request); const result = await verifyAuthenticationResponse({ response, expectedChallenge, expectedOrigin: origin, expectedRPID: rpID, credential: { id: passkey.id, publicKey: new Uint8Array(passkey.public_key), counter: passkey.counter, transports: passkey.transports ? JSON.parse(passkey.transports) : undefined, }, requireUserVerification: false, }); if (!result.verified) throw new Error("authentication not verified"); db.prepare("UPDATE passkeys SET counter = ?, last_used_at = datetime('now') WHERE id = ?").run( result.authenticationInfo.newCounter, passkey.id ); return createSession(request); } // ---------- Passkey management ---------- export interface PasskeyInfo { id: string; label: string | null; created_at: string; last_used_at: string | null; } export function listPasskeys(): PasskeyInfo[] { return getDb() .prepare("SELECT id, label, created_at, last_used_at FROM passkeys ORDER BY created_at") .all() as PasskeyInfo[]; } /** Refuses to delete the last passkey so the owner can't lock themselves out. */ export function deletePasskey(id: string): boolean { if (passkeyCount() <= 1) return false; return getDb().prepare("DELETE FROM passkeys WHERE id = ?").run(id).changes > 0; } // ---------- Favorites ---------- export interface Favorite { id: number; term: string; upcoming_count: number; } export function listFavorites(): Favorite[] { return getDb() .prepare( `SELECT f.id, f.term, (SELECT COUNT(*) FROM events e WHERE e.date >= date('now', 'localtime') AND instr(nfkc_lower(e.title || ' ' || COALESCE(e.artist, '')), f.term_norm) > 0 ) AS upcoming_count FROM favorites f ORDER BY f.term COLLATE NOCASE` ) .all() as Favorite[]; } export function addFavorite(term: string): boolean { const trimmed = term.trim().slice(0, 100); const norm = normalizeTerm(trimmed); if (!norm) return false; return ( getDb() .prepare("INSERT OR IGNORE INTO favorites (term, term_norm) VALUES (?, ?)") .run(trimmed, norm).changes > 0 ); } export function deleteFavorite(id: number): void { getDb().prepare("DELETE FROM favorites WHERE id = ?").run(id); }