fix(ui): restore centered auth card layout, serve auth-client assets, and fix root redirects

This commit is contained in:
Tyler Gillispie 2026-08-27 21:11:44 -07:00
parent a8111f6dc3
commit aa9760b1e1
12 changed files with 910 additions and 70 deletions

278
public/auth-client.js Normal file
View File

@ -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;

Binary file not shown.

View File

@ -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<any>}
*/
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 };

View File

@ -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 =")) {

View File

@ -1,9 +1,9 @@
import { LayoutFragment } from "../../shared/ui/fragments.tsx";
import { AuthLayoutFragment } from "../../shared/ui/fragments.tsx";
export const LoginPageFragment = () => {
return (
<LayoutFragment title="Sign In">
<div data-ignore>
<AuthLayoutFragment title="Sign In">
<div>
<div class="brand-header">
<div class="brand-logo">
<svg
@ -132,6 +132,6 @@ export const LoginPageFragment = () => {
<script src="/public/auth-client.js?v=6"></script>
<script src="/public/webauthn-login.js"></script>
</div>
</LayoutFragment>
</AuthLayoutFragment>
);
};

View File

@ -1,9 +1,9 @@
import { LayoutFragment } from "../../shared/ui/fragments.tsx";
import { AuthLayoutFragment } from "../../shared/ui/fragments.tsx";
export const RecoveryPageFragment = () => {
return (
<LayoutFragment title="Account Recovery">
<div data-ignore>
<AuthLayoutFragment title="Account Recovery">
<div>
<div class="brand-header">
<div class="brand-logo">
<svg
@ -88,6 +88,6 @@ export const RecoveryPageFragment = () => {
</script>
<script type="module" src="/public/webauthn-recovery.js"></script>
</div>
</LayoutFragment>
</AuthLayoutFragment>
);
};

View File

@ -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 (
<LayoutFragment title="Create Account">
<div data-ignore>
<AuthLayoutFragment title="Create Account">
<div>
<div class="brand-header">
<div class="brand-logo">
<svg
@ -215,6 +215,6 @@ export const RegisterPageFragment = (
<script src="/public/auth-client.js?v=6"></script>
<script type="module" src="/public/webauthn-register.js"></script>
</div>
</LayoutFragment>
</AuthLayoutFragment>
);
};

View File

@ -1,9 +1,9 @@
import { LayoutFragment } from "../../shared/ui/fragments.tsx";
import { AuthLayoutFragment } from "../../shared/ui/fragments.tsx";
export const EventJoinPageFragment = () => {
return (
<LayoutFragment title="Join Event">
<div class="auth-card">
<AuthLayoutFragment title="Join Event">
<div>
<div class="brand-header">
<div
class="brand-logo"
@ -73,6 +73,6 @@ export const EventJoinPageFragment = () => {
</a>
</div>
</div>
</LayoutFragment>
</AuthLayoutFragment>
);
};

View File

@ -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);

View File

@ -1,5 +1,6 @@
export {
AuthenticatedLayoutFragment,
AuthLayoutFragment,
LayoutFragment,
} from "./layout_fragments.tsx";
export { NavbarFragment } from "./navbar_fragments.tsx";

View File

@ -247,7 +247,11 @@ export const LayoutFragment = ({
`}
</style>
{/* Datastar script */}
<script src="/public/datastar-v1.x.js" type="module"></script>
<script
type="module"
src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.0-beta.9/bundles/datastar.js"
>
</script>
{/* SimpleWebAuthn included on all Layout pages to be available for auth/recovery */}
<script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js">
</script>
@ -260,6 +264,24 @@ export const LayoutFragment = ({
);
};
export const AuthLayoutFragment = ({
children,
title,
}: {
children: any;
title: string;
}) => {
return (
<LayoutFragment title={title}>
<div class="auth-layout-wrapper">
<div class="auth-card" data-ignore>
{children}
</div>
</div>
</LayoutFragment>
);
};
export const AuthenticatedLayoutFragment = ({
children,
title,

View File

@ -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;
}
});