From aa9760b1e13a3162faa50b43107a5445ca40079a Mon Sep 17 00:00:00 2001 From: Tyler Gillispie Date: Thu, 27 Aug 2026 21:11:44 -0700 Subject: [PATCH] fix(ui): restore centered auth card layout, serve auth-client assets, and fix root redirects --- public/auth-client.js | 278 +++++++++++++++ public/wasm/sss_recovery_bg.wasm | Bin 0 -> 28347 bytes public/wasm/sss_recovery_bg.wasm.js | 416 +++++++++++++++++++++++ src/features/auth/auth.test.ts | 4 +- src/features/auth/login_fragments.tsx | 8 +- src/features/auth/recovery_fragments.tsx | 8 +- src/features/auth/register_fragments.tsx | 8 +- src/features/events/join_fragments.tsx | 8 +- src/main.ts | 73 +++- src/shared/ui/fragments.tsx | 1 + src/shared/ui/layout_fragments.tsx | 24 +- src/tests/smoke_test.ts | 152 ++++++--- 12 files changed, 910 insertions(+), 70 deletions(-) create mode 100644 public/auth-client.js create mode 100644 public/wasm/sss_recovery_bg.wasm create mode 100644 public/wasm/sss_recovery_bg.wasm.js 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 0000000000000000000000000000000000000000..90f343dbf9d2f459a0f760fa8912abe9bde91e7b GIT binary patch literal 28347 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 literal 0 HcmV?d00001 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; + } });