diff --git a/public/auth-client.js b/public/auth-client.js new file mode 100644 index 0000000..56be01b --- /dev/null +++ b/public/auth-client.js @@ -0,0 +1,278 @@ +// deno-lint-ignore-file +function getSimpleWebAuthn() { + return globalThis.SimpleWebAuthnBrowser || window.SimpleWebAuthnBrowser || {}; +} + +function setStatus(msg, isError = false) { + const el = document.getElementById("statusMessage"); + if (el) { + el.textContent = msg; + el.className = isError ? "error" : "success"; + el.style.display = msg ? "block" : "none"; + } +} + +async function startWebAuthnConditionalLogin() { + const { startAuthentication } = getSimpleWebAuthn(); + if (!startAuthentication) return; + + try { + const resp = await fetch("/api/login/challenge", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: "" }), + }); + if (!resp.ok) return; + const data = await resp.json(); + + const asseResp = await startAuthentication({ + optionsJSON: data.options, + useBrowserAutofill: true, + verifyBrowserAutofillInput: true, + }); + + if (!asseResp) return; + + const verificationResp = await fetch("/api/login/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ response: asseResp }), + }); + + const verificationJSON = await verificationResp.json(); + if (verificationJSON.success) { + setStatus("Autofill login successful! Redirecting..."); + let targetRedirect = "/dashboard"; + try { + const params = new URLSearchParams(window.location.search); + const rawRedirect = params.get("redirect"); + if ( + rawRedirect && + (rawRedirect.startsWith("/") || rawRedirect.includes(".atyg.org")) + ) { + targetRedirect = rawRedirect; + } + } catch (_e) {} + window.location.replace(targetRedirect); + } + } catch (err) { + // Conditional UI errors (e.g. user canceled autofill prompt) should fail silently + console.debug("[WebAuthn Conditional UI]", err); + } +} + +async function startWebAuthnLogin(username) { + const { startAuthentication } = getSimpleWebAuthn(); + if (!startAuthentication) { + setStatus("SimpleWebAuthn library not loaded yet. Please refresh.", true); + return; + } + + setStatus(""); + + try { + // 1. Fetch challenge + const resp = await fetch("/api/login/challenge", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ username: username || "" }), + }); + + let data; + try { + data = await resp.json(); + } catch { + const text = await resp.text().catch(() => ""); + setStatus(`Login challenge failed (${resp.status}): ${text}`, true); + return; + } + + if (!resp.ok) { + setStatus(data.error || "Failed to get login challenge", true); + return; + } + + // 2. Pass challenge to authenticator + let asseResp; + try { + asseResp = await startAuthentication({ optionsJSON: data.options }); + } catch (error) { + setStatus(error.message || "Authentication failed on device", true); + throw error; + } + + // Extract PRF extension results + let extensionResults; + if (typeof asseResp.getClientExtensionResults === "function") { + extensionResults = asseResp.getClientExtensionResults(); + } else { + extensionResults = asseResp.clientExtensionResults || {}; + } + + // 3. Send response back to verify + const verificationResp = await fetch("/api/login/verify", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + response: asseResp, + }), + }); + + let verificationJSON; + try { + verificationJSON = await verificationResp.json(); + } catch { + const text = await verificationResp.text().catch(() => ""); + setStatus( + `Login verification failed (${verificationResp.status}): ${text}`, + true, + ); + return; + } + + if (verificationJSON.success) { + setStatus("Login successful! Redirecting..."); + let targetRedirect = "/dashboard"; + try { + const params = new URLSearchParams(window.location.search); + const rawRedirect = params.get("redirect"); + if (rawRedirect) { + if (rawRedirect.startsWith("/") && !rawRedirect.startsWith("//")) { + targetRedirect = rawRedirect; + } else { + const parsed = new URL(rawRedirect); + if ( + parsed.hostname.endsWith(".atyg.org") || + parsed.hostname === "atyg.org" || + parsed.hostname === "localhost" + ) { + targetRedirect = rawRedirect; + } + } + } + } catch (_e) { + // Fallback to default + } + window.location.replace(targetRedirect); + await new Promise((resolve) => setTimeout(resolve, 5000)); + } else { + setStatus(verificationJSON.error || "Login verification failed", true); + } + } catch (err) { + console.error("[WebAuthn Login]", err); + setStatus( + err.message || "An unexpected error occurred during authentication", + true, + ); + } +} + +async function startWebAuthnRegistration(username, inviteCode) { + const { startRegistration } = getSimpleWebAuthn(); + if (!startRegistration) { + setStatus("SimpleWebAuthn library not loaded yet. Please refresh.", true); + return null; + } + + setStatus(""); + if (!username || !inviteCode) { + setStatus("Username and Invite Code are required.", true); + return null; + } + + try { + // 1. Fetch challenge from API + const resp = await fetch("/api/register/challenge", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ username, inviteCode }), + }); + + let data; + try { + data = await resp.json(); + } catch { + const text = await resp.text().catch(() => ""); + setStatus(`Challenge request failed (${resp.status}): ${text}`, true); + return null; + } + + if (!resp.ok) { + setStatus(data.error || "Failed to get registration challenge", true); + return null; + } + + // 2. Pass challenge to authenticator + let attResp; + try { + attResp = await startRegistration({ optionsJSON: data.options }); + } catch (error) { + if (error.name === "InvalidStateError") { + setStatus("Authenticator was probably already registered.", true); + } else { + setStatus(error.message || "Registration failed on device", true); + } + throw error; + } + + // Extract PRF client extension result + let extensionResults; + if (typeof attResp.getClientExtensionResults === "function") { + extensionResults = attResp.getClientExtensionResults(); + } else { + extensionResults = attResp.clientExtensionResults || {}; + } + + // 3. Send response back to verify + const verificationResp = await fetch("/api/register/verify", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username, + inviteCode, + response: { + ...attResp, + clientExtensionResults: extensionResults, + }, + }), + }); + + let verificationJSON; + try { + verificationJSON = await verificationResp.json(); + } catch { + const text = await verificationResp.text().catch(() => ""); + setStatus( + `Verification failed (${verificationResp.status}): ${text}`, + true, + ); + return null; + } + + if (verificationJSON.success) { + return verificationJSON; + } else { + setStatus( + verificationJSON.error || "Registration verification failed", + true, + ); + return null; + } + } catch (err) { + console.error(err); + return null; + } +} + +globalThis.startWebAuthnLogin = startWebAuthnLogin; +globalThis.startWebAuthnRegistration = startWebAuthnRegistration; +globalThis.startWebAuthnConditionalLogin = startWebAuthnConditionalLogin; +globalThis.setStatus = setStatus; diff --git a/public/wasm/sss_recovery_bg.wasm b/public/wasm/sss_recovery_bg.wasm new file mode 100644 index 0000000..90f343d Binary files /dev/null and b/public/wasm/sss_recovery_bg.wasm differ diff --git a/public/wasm/sss_recovery_bg.wasm.js b/public/wasm/sss_recovery_bg.wasm.js new file mode 100644 index 0000000..d7fe22e --- /dev/null +++ b/public/wasm/sss_recovery_bg.wasm.js @@ -0,0 +1,416 @@ +// deno-lint-ignore-file +/* @ts-self-types="./sss_recovery.d.ts" */ + +export class Share { + static __wrap(ptr) { + const obj = Object.create(Share.prototype); + obj.__wbg_ptr = ptr; + ShareFinalization.register(obj, obj.__wbg_ptr, obj); + return obj; + } + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + ShareFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_share_free(ptr, 0); + } + /** + * @returns {Uint8Array} + */ + get data() { + const ret = wasm.share_data(this.__wbg_ptr); + var v1 = getArrayU8FromWasm0(ret[0], ret[1]).slice(); + wasm.__wbindgen_free(ret[0], ret[1] * 1, 1); + return v1; + } + /** + * @param {number} x + * @param {Uint8Array} data + */ + constructor(x, data) { + const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.share_new(x, ptr0, len0); + this.__wbg_ptr = ret; + ShareFinalization.register(this, this.__wbg_ptr, this); + return this; + } + /** + * @returns {number} + */ + get x() { + const ret = wasm.share_x(this.__wbg_ptr); + return ret; + } +} +if (Symbol.dispose) Share.prototype[Symbol.dispose] = Share.prototype.free; + +export function initialize() { + wasm.initialize(); +} + +/** + * @param {Share} share1 + * @param {Share} share2 + * @returns {Uint8Array} + */ +export function reconstruct_secret(share1, share2) { + _assertClass(share1, Share); + _assertClass(share2, Share); + const ret = wasm.reconstruct_secret(share1.__wbg_ptr, share2.__wbg_ptr); + if (ret[3]) { + throw takeFromExternrefTable0(ret[2]); + } + var v1 = getArrayU8FromWasm0(ret[0], ret[1]).slice(); + wasm.__wbindgen_free(ret[0], ret[1] * 1, 1); + return v1; +} + +/** + * @param {Uint8Array} secret + * @returns {Array} + */ +export function split_secret(secret) { + const ptr0 = passArray8ToWasm0(secret, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.split_secret(ptr0, len0); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return takeFromExternrefTable0(ret[0]); +} +function __wbg_get_imports() { + const import0 = { + __proto__: null, + __wbg___wbindgen_is_function_5e4570eb24ffa122: function (arg0) { + const ret = typeof arg0 === "function"; + return ret; + }, + __wbg___wbindgen_is_object_a2790eb24c211ea0: function (arg0) { + const val = arg0; + const ret = typeof val === "object" && val !== null; + return ret; + }, + __wbg___wbindgen_is_string_e6f02f0ea5f20a32: function (arg0) { + const ret = typeof arg0 === "string"; + return ret; + }, + __wbg___wbindgen_is_undefined_6cff064c44e0d823: function (arg0) { + const ret = arg0 === undefined; + return ret; + }, + __wbg___wbindgen_throw_bb96b2010945f0bc: function (arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)); + }, + __wbg_call_35dba3c747ad7521: function () { + return handleError(function (arg0, arg1, arg2) { + const ret = arg0.call(arg1, arg2); + return ret; + }, arguments); + }, + __wbg_crypto_38df2bab126b63dc: function (arg0) { + const ret = arg0.crypto; + return ret; + }, + __wbg_getRandomValues_c44a50d8cfdaebeb: function () { + return handleError(function (arg0, arg1) { + arg0.getRandomValues(arg1); + }, arguments); + }, + __wbg_length_36bd29c6848c2144: function (arg0) { + const ret = arg0.length; + return ret; + }, + __wbg_msCrypto_bd5a034af96bcba6: function (arg0) { + const ret = arg0.msCrypto; + return ret; + }, + __wbg_new_116be93542d39019: function () { + const ret = new Array(); + return ret; + }, + __wbg_new_with_length_3ffc1c56427c525c: function (arg0) { + const ret = new Uint8Array(arg0 >>> 0); + return ret; + }, + __wbg_node_84ea875411254db1: function (arg0) { + const ret = arg0.node; + return ret; + }, + __wbg_process_44c7a14e11e9f69e: function (arg0) { + const ret = arg0.process; + return ret; + }, + __wbg_prototypesetcall_de8e0d9553586985: function (arg0, arg1, arg2) { + Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2); + }, + __wbg_push_adb0107829f02d75: function (arg0, arg1) { + const ret = arg0.push(arg1); + return ret; + }, + __wbg_randomFillSync_6c25eac9869eb53c: function () { + return handleError(function (arg0, arg1) { + arg0.randomFillSync(arg1); + }, arguments); + }, + __wbg_require_b4edbdcf3e2a1ef0: function () { + return handleError(function () { + const ret = module.require; + return ret; + }, arguments); + }, + __wbg_share_new: function (arg0) { + const ret = Share.__wrap(arg0); + return ret; + }, + __wbg_static_accessor_GLOBAL_THIS_466428f93b4eaa76: function () { + const ret = typeof globalThis === "undefined" ? null : globalThis; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_static_accessor_GLOBAL_c7aea38d4de089bc: function () { + const ret = typeof global === "undefined" ? null : global; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_static_accessor_SELF_42d4fae05e59267a: function () { + const ret = typeof self === "undefined" ? null : self; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_static_accessor_WINDOW_e0db14a0eba6a812: function () { + const ret = typeof window === "undefined" ? null : window; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_subarray_a4cc58201c7359fd: function (arg0, arg1, arg2) { + const ret = arg0.subarray(arg1 >>> 0, arg2 >>> 0); + return ret; + }, + __wbg_versions_276b2795b1c6a219: function (arg0) { + const ret = arg0.versions; + return ret; + }, + __wbindgen_cast_0000000000000001: function (arg0, arg1) { + // Cast intrinsic for `Ref(Slice(U8)) -> NamedExternref("Uint8Array")`. + const ret = getArrayU8FromWasm0(arg0, arg1); + return ret; + }, + __wbindgen_cast_0000000000000002: function (arg0, arg1) { + // Cast intrinsic for `Ref(String) -> Externref`. + const ret = getStringFromWasm0(arg0, arg1); + return ret; + }, + __wbindgen_init_externref_table: function () { + const table = wasm.__wbindgen_externrefs; + const offset = table.grow(4); + table.set(0, undefined); + table.set(offset + 0, undefined); + table.set(offset + 1, null); + table.set(offset + 2, true); + table.set(offset + 3, false); + }, + }; + return { + __proto__: null, + "./sss_recovery_bg.js": import0, + }; +} + +const ShareFinalization = (typeof FinalizationRegistry === "undefined") + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry((ptr) => wasm.__wbg_share_free(ptr, 1)); + +function addToExternrefTable0(obj) { + const idx = wasm.__externref_table_alloc(); + wasm.__wbindgen_externrefs.set(idx, obj); + return idx; +} + +function _assertClass(instance, klass) { + if (!(instance instanceof klass)) { + throw new Error(`expected instance of ${klass.name}`); + } +} + +function getArrayU8FromWasm0(ptr, len) { + ptr = ptr >>> 0; + return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len); +} + +function getStringFromWasm0(ptr, len) { + return decodeText(ptr >>> 0, len); +} + +let cachedUint8ArrayMemory0 = null; +function getUint8ArrayMemory0() { + if ( + cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0 + ) { + cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); + } + return cachedUint8ArrayMemory0; +} + +function handleError(f, args) { + try { + return f.apply(this, args); + } catch (e) { + const idx = addToExternrefTable0(e); + wasm.__wbindgen_exn_store(idx); + } +} + +function isLikeNone(x) { + return x === undefined || x === null; +} + +function passArray8ToWasm0(arg, malloc) { + const ptr = malloc(arg.length * 1, 1) >>> 0; + getUint8ArrayMemory0().set(arg, ptr / 1); + WASM_VECTOR_LEN = arg.length; + return ptr; +} + +function takeFromExternrefTable0(idx) { + const value = wasm.__wbindgen_externrefs.get(idx); + wasm.__externref_table_dealloc(idx); + return value; +} + +let cachedTextDecoder = new TextDecoder("utf-8", { + ignoreBOM: true, + fatal: true, +}); +cachedTextDecoder.decode(); +const MAX_SAFARI_DECODE_BYTES = 2146435072; +let numBytesDecoded = 0; +function decodeText(ptr, len) { + numBytesDecoded += len; + if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) { + cachedTextDecoder = new TextDecoder("utf-8", { + ignoreBOM: true, + fatal: true, + }); + cachedTextDecoder.decode(); + numBytesDecoded = len; + } + return cachedTextDecoder.decode( + getUint8ArrayMemory0().subarray(ptr, ptr + len), + ); +} + +let WASM_VECTOR_LEN = 0; + +let wasmModule, wasmInstance, wasm; +function __wbg_finalize_init(instance, module) { + wasmInstance = instance; + wasm = instance.exports; + wasmModule = module; + cachedUint8ArrayMemory0 = null; + wasm.__wbindgen_start(); + return wasm; +} + +async function __wbg_load(module, imports) { + if (typeof Response === "function" && module instanceof Response) { + if (!module.ok) { + throw new Error( + `failed to fetch Wasm: ${module.status} ${module.statusText} fetching '${module.url}'`, + ); + } + + if (typeof WebAssembly.instantiateStreaming === "function") { + try { + return await WebAssembly.instantiateStreaming(module, imports); + } catch (e) { + const validResponse = expectedResponseType(module.type); + + if ( + validResponse && + module.headers.get("Content-Type") !== "application/wasm" + ) { + console.warn( + "`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", + e, + ); + } else throw e; + } + } + + const bytes = await module.arrayBuffer(); + return await WebAssembly.instantiate(bytes, imports); + } else { + const instance = await WebAssembly.instantiate(module, imports); + + if (instance instanceof WebAssembly.Instance) { + return { instance, module }; + } else { + return instance; + } + } + + function expectedResponseType(type) { + switch (type) { + case "basic": + case "cors": + case "default": + return true; + } + return false; + } +} + +function initSync(module) { + if (wasm !== undefined) return wasm; + + if (module !== undefined) { + if (Object.getPrototypeOf(module) === Object.prototype) { + ({ module } = module); + } else { + console.warn( + "using deprecated parameters for `initSync()`; pass a single object instead", + ); + } + } + + const imports = __wbg_get_imports(); + if (!(module instanceof WebAssembly.Module)) { + module = new WebAssembly.Module(module); + } + const instance = new WebAssembly.Instance(module, imports); + return __wbg_finalize_init(instance, module); +} + +async function __wbg_init(module_or_path) { + if (wasm !== undefined) return wasm; + + if (module_or_path !== undefined) { + if (Object.getPrototypeOf(module_or_path) === Object.prototype) { + ({ module_or_path } = module_or_path); + } else { + console.warn( + "using deprecated parameters for the initialization function; pass a single object instead", + ); + } + } + + if (module_or_path === undefined) { + module_or_path = new URL("sss_recovery_bg.wasm", import.meta.url); + } + const imports = __wbg_get_imports(); + + if ( + typeof module_or_path === "string" || + (typeof Request === "function" && module_or_path instanceof Request) || + (typeof URL === "function" && module_or_path instanceof URL) + ) { + module_or_path = fetch(module_or_path); + } + + const { instance, module } = await __wbg_load(await module_or_path, imports); + + return __wbg_finalize_init(instance, module); +} + +export { __wbg_init as default, initSync }; diff --git a/src/features/auth/auth.test.ts b/src/features/auth/auth.test.ts index 4a6b4f1..5017dc5 100644 --- a/src/features/auth/auth.test.ts +++ b/src/features/auth/auth.test.ts @@ -54,7 +54,9 @@ test("login challenge generates valid WebAuthn options for user with passkey", a sqlWrapper.sql = ((strings: any, ..._values: any[]) => { const query = strings.join("?"); - if (query.includes("SELECT id, account_status FROM users WHERE username =")) { + if ( + query.includes("SELECT id, account_status FROM users WHERE username =") + ) { return Promise.resolve([mockUser]); } if (query.includes("FROM passkeys WHERE user_id =")) { diff --git a/src/features/auth/login_fragments.tsx b/src/features/auth/login_fragments.tsx index 1b38a67..419f4dc 100644 --- a/src/features/auth/login_fragments.tsx +++ b/src/features/auth/login_fragments.tsx @@ -1,9 +1,9 @@ -import { LayoutFragment } from "../../shared/ui/fragments.tsx"; +import { AuthLayoutFragment } from "../../shared/ui/fragments.tsx"; export const LoginPageFragment = () => { return ( - -
+ +
- + ); }; diff --git a/src/features/auth/recovery_fragments.tsx b/src/features/auth/recovery_fragments.tsx index 4796bc2..e90a25c 100644 --- a/src/features/auth/recovery_fragments.tsx +++ b/src/features/auth/recovery_fragments.tsx @@ -1,9 +1,9 @@ -import { LayoutFragment } from "../../shared/ui/fragments.tsx"; +import { AuthLayoutFragment } from "../../shared/ui/fragments.tsx"; export const RecoveryPageFragment = () => { return ( - -
+ +
- + ); }; diff --git a/src/features/auth/register_fragments.tsx b/src/features/auth/register_fragments.tsx index 7279652..c108a56 100644 --- a/src/features/auth/register_fragments.tsx +++ b/src/features/auth/register_fragments.tsx @@ -1,11 +1,11 @@ -import { LayoutFragment } from "../../shared/ui/fragments.tsx"; +import { AuthLayoutFragment } from "../../shared/ui/fragments.tsx"; export const RegisterPageFragment = ( { initialCode = "" }: { initialCode?: string }, ) => { return ( - -
+ +
- + ); }; diff --git a/src/features/events/join_fragments.tsx b/src/features/events/join_fragments.tsx index d79d8f6..933c39e 100644 --- a/src/features/events/join_fragments.tsx +++ b/src/features/events/join_fragments.tsx @@ -1,9 +1,9 @@ -import { LayoutFragment } from "../../shared/ui/fragments.tsx"; +import { AuthLayoutFragment } from "../../shared/ui/fragments.tsx"; export const EventJoinPageFragment = () => { return ( - -
+ +
- + ); }; diff --git a/src/main.ts b/src/main.ts index e4656d9..e95abc4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,12 +1,20 @@ import { Hono } from "jsr:@hono/hono@4"; import { serveStatic } from "jsr:@hono/hono@4/deno"; +import { deleteCookie } from "jsr:@hono/hono@4/cookie"; import { MetadataService } from "jsr:@simplewebauthn/server@13"; -import { initDb } from "./core/db.ts"; -import { pingValkey } from "./core/valkey.ts"; +import { initDb, sqlWrapper } from "./core/db.ts"; +import { pingValkey, valkey } from "./core/valkey.ts"; import { contentNegotiation } from "./core/content_negotiation.ts"; import { payloadCapGuard } from "./core/auth_guards.ts"; import { startConnectRpcServer } from "./core/rpc.ts"; +import { + extractAllSessionIds, + getAuthenticatedUser, + getCookieDomain, +} from "./core/session.ts"; +import { isSafeRedirectUrl } from "./core/forward_auth.ts"; +import { auditWrapper } from "./core/audit.ts"; import { authRoutes } from "./features/auth/routes.tsx"; import { adminRoutes } from "./features/admin/routes.tsx"; @@ -20,9 +28,68 @@ const app: Hono = new Hono(); app.use("*", payloadCapGuard); app.use("*", contentNegotiation()); -// Serve static assets (specifically Datastar and client scripts) +// Serve static assets (specifically Datastar, WebAuthn scripts, and stylesheets) app.use("/public/*", serveStatic({ root: "./" })); +// Root, Navigation & Logout Endpoints +app.get("/", (c) => { + c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0"); + return c.redirect("/login"); +}); + +app.get("/dashboard", (c) => { + return c.redirect("/dashboard/sessions"); +}); + +app.get("/logout", async (c) => { + const sessionIds = extractAllSessionIds(c); + const rawRedirect = c.req.query("redirect"); + let safeRedirect = null; + const userIp = c.req.header("x-forwarded-for") || "127.0.0.1"; + let userId = null; + + if (sessionIds.length > 0) { + try { + const authUser = await getAuthenticatedUser(c); + if (authUser) { + userId = authUser.userId; + } + for (const sId of sessionIds) { + try { + await valkey.del(sId); + await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${sId}`; + } catch (_e) {} + } + } catch (_e) {} + } + + if (rawRedirect && isSafeRedirectUrl(rawRedirect)) { + safeRedirect = rawRedirect; + } + + auditWrapper.auditLog(userId, "logout_success", "session", null, userIp); + + const cookieDomain = getCookieDomain(); + if (cookieDomain) { + deleteCookie(c, "session_id", { + domain: cookieDomain, + path: "/", + httpOnly: true, + secure: true, + sameSite: "Lax", + }); + } + deleteCookie(c, "session_id", { + path: "/", + httpOnly: true, + secure: true, + sameSite: "Lax", + }); + + c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0"); + return c.redirect(safeRedirect || "/login"); +}); + // Wire Sub-routers and Vertical Slices app.route("/pass", passRoutes); app.route("/", forwardAuthRoutes); diff --git a/src/shared/ui/fragments.tsx b/src/shared/ui/fragments.tsx index d4ba8bf..414913e 100644 --- a/src/shared/ui/fragments.tsx +++ b/src/shared/ui/fragments.tsx @@ -1,5 +1,6 @@ export { AuthenticatedLayoutFragment, + AuthLayoutFragment, LayoutFragment, } from "./layout_fragments.tsx"; export { NavbarFragment } from "./navbar_fragments.tsx"; diff --git a/src/shared/ui/layout_fragments.tsx b/src/shared/ui/layout_fragments.tsx index 11940fe..0b08a91 100644 --- a/src/shared/ui/layout_fragments.tsx +++ b/src/shared/ui/layout_fragments.tsx @@ -247,7 +247,11 @@ export const LayoutFragment = ({ `} {/* Datastar script */} - + {/* SimpleWebAuthn included on all Layout pages to be available for auth/recovery */} @@ -260,6 +264,24 @@ export const LayoutFragment = ({ ); }; +export const AuthLayoutFragment = ({ + children, + title, +}: { + children: any; + title: string; +}) => { + return ( + +
+
+ {children} +
+
+
+ ); +}; + export const AuthenticatedLayoutFragment = ({ children, title, diff --git a/src/tests/smoke_test.ts b/src/tests/smoke_test.ts index 3b7075c..1b7d678 100644 --- a/src/tests/smoke_test.ts +++ b/src/tests/smoke_test.ts @@ -1,60 +1,114 @@ -import { assertEquals } from "jsr:@std/assert@1"; +import { assertEquals, assertStringIncludes } from "jsr:@std/assert@1"; import app from "../main.ts"; +import { auditWrapper } from "../core/audit.ts"; Deno.test("[Smoke Test] Core Server Routing & Hypermedia Endpoints", async () => { - // 1. Health check - const resHealth = await app.fetch(new Request("http://localhost/healthz")); - assertEquals(resHealth.status, 200); - assertEquals(await resHealth.text(), "OK"); + const origAudit = auditWrapper.auditLog; + auditWrapper.auditLog = () => {}; - // 2. Public Login page returns HTML - const resLogin = await app.fetch(new Request("http://localhost/login")); - assertEquals(resLogin.status, 200); - assertEquals( - resLogin.headers.get("content-type")?.includes("text/html"), - true, - ); + try { + // 1. Health check + const resHealth = await app.fetch(new Request("http://localhost/healthz")); + assertEquals(resHealth.status, 200); + assertEquals(await resHealth.text(), "OK"); - // 3. Public Register page returns HTML - const resRegister = await app.fetch(new Request("http://localhost/register")); - assertEquals(resRegister.status, 200); - assertEquals( - resRegister.headers.get("content-type")?.includes("text/html"), - true, - ); + // 2. Root / redirects to /login + const resRoot = await app.fetch(new Request("http://localhost/")); + assertEquals(resRoot.status, 302); + assertEquals(resRoot.headers.get("location"), "/login"); - // 4. Public Join page returns HTML - const resJoin = await app.fetch(new Request("http://localhost/join")); - assertEquals(resJoin.status, 200); - assertEquals( - resJoin.headers.get("content-type")?.includes("text/html"), - true, - ); + // 3. /dashboard redirects to /dashboard/sessions + const resDashRoot = await app.fetch( + new Request("http://localhost/dashboard"), + ); + assertEquals(resDashRoot.status, 302); + assertEquals(resDashRoot.headers.get("location"), "/dashboard/sessions"); - // 5. Unauthenticated Dashboard redirects to /login - const resDash = await app.fetch( - new Request("http://localhost/dashboard/sessions"), - ); - assertEquals(resDash.status, 302); - assertEquals(resDash.headers.get("location")?.includes("/login"), true); + // 4. /logout redirects to /login + const resLogout = await app.fetch(new Request("http://localhost/logout")); + assertEquals(resLogout.status, 302); + assertEquals(resLogout.headers.get("location"), "/login"); - // 6. Magic Link Pass without token redirects to /login - const resPass = await app.fetch(new Request("http://localhost/pass")); - assertEquals(resPass.status, 302); - assertEquals( - resPass.headers.get("location")?.includes( - "/login?error=invalid_or_expired_pass", - ), - true, - ); + // 5. Public Login page returns HTML with card wrapper + const resLogin = await app.fetch(new Request("http://localhost/login")); + assertEquals(resLogin.status, 200); + assertEquals( + resLogin.headers.get("content-type")?.includes("text/html"), + true, + ); + const loginHtml = await resLogin.text(); + assertStringIncludes(loginHtml, "auth-layout-wrapper"); + assertStringIncludes(loginHtml, "auth-card"); + assertStringIncludes(loginHtml, "auth-client.js"); - // 7. Forward Auth check without host returns 400 - const resForwardAuth = await app.fetch( - new Request("http://localhost/api/forward-auth"), - ); - assertEquals(resForwardAuth.status, 400); + // 6. Public Register page returns HTML with card wrapper + const resRegister = await app.fetch( + new Request("http://localhost/register"), + ); + assertEquals(resRegister.status, 200); + assertEquals( + resRegister.headers.get("content-type")?.includes("text/html"), + true, + ); + const registerHtml = await resRegister.text(); + assertStringIncludes(registerHtml, "auth-layout-wrapper"); + assertStringIncludes(registerHtml, "auth-card"); - // 8. Admin route unauthenticated redirects or denies - const resAdmin = await app.fetch(new Request("http://localhost/admin/users")); - assertEquals(resAdmin.status === 302 || resAdmin.status === 401, true); + // 7. Public Join page returns HTML with card wrapper + const resJoin = await app.fetch(new Request("http://localhost/join")); + assertEquals(resJoin.status, 200); + assertEquals( + resJoin.headers.get("content-type")?.includes("text/html"), + true, + ); + const joinHtml = await resJoin.text(); + assertStringIncludes(joinHtml, "auth-layout-wrapper"); + assertStringIncludes(joinHtml, "auth-card"); + + // 8. Static assets are served with HTTP 200 + const resAuthClient = await app.fetch( + new Request("http://localhost/public/auth-client.js"), + ); + assertEquals(resAuthClient.status, 200); + assertStringIncludes( + await resAuthClient.text(), + "startWebAuthnLogin", + ); + + const resLoginScript = await app.fetch( + new Request("http://localhost/public/webauthn-login.js"), + ); + assertEquals(resLoginScript.status, 200); + + // 9. Unauthenticated Dashboard redirects to /login + const resDash = await app.fetch( + new Request("http://localhost/dashboard/sessions"), + ); + assertEquals(resDash.status, 302); + assertEquals(resDash.headers.get("location")?.includes("/login"), true); + + // 10. Magic Link Pass without token redirects to /login + const resPass = await app.fetch(new Request("http://localhost/pass")); + assertEquals(resPass.status, 302); + assertEquals( + resPass.headers.get("location")?.includes( + "/login?error=invalid_or_expired_pass", + ), + true, + ); + + // 11. Forward Auth check without host returns 400 + const resForwardAuth = await app.fetch( + new Request("http://localhost/api/forward-auth"), + ); + assertEquals(resForwardAuth.status, 400); + + // 12. Admin route unauthenticated redirects or denies + const resAdmin = await app.fetch( + new Request("http://localhost/admin/users"), + ); + assertEquals(resAdmin.status === 302 || resAdmin.status === 401, true); + } finally { + auditWrapper.auditLog = origAudit; + } });