fix(ui): restore centered auth card layout, serve auth-client assets, and fix root redirects
This commit is contained in:
parent
a8111f6dc3
commit
aa9760b1e1
278
public/auth-client.js
Normal file
278
public/auth-client.js
Normal 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;
|
||||||
BIN
public/wasm/sss_recovery_bg.wasm
Normal file
BIN
public/wasm/sss_recovery_bg.wasm
Normal file
Binary file not shown.
416
public/wasm/sss_recovery_bg.wasm.js
Normal file
416
public/wasm/sss_recovery_bg.wasm.js
Normal 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 };
|
||||||
@ -54,7 +54,9 @@ test("login challenge generates valid WebAuthn options for user with passkey", a
|
|||||||
|
|
||||||
sqlWrapper.sql = ((strings: any, ..._values: any[]) => {
|
sqlWrapper.sql = ((strings: any, ..._values: any[]) => {
|
||||||
const query = strings.join("?");
|
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]);
|
return Promise.resolve([mockUser]);
|
||||||
}
|
}
|
||||||
if (query.includes("FROM passkeys WHERE user_id =")) {
|
if (query.includes("FROM passkeys WHERE user_id =")) {
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { LayoutFragment } from "../../shared/ui/fragments.tsx";
|
import { AuthLayoutFragment } from "../../shared/ui/fragments.tsx";
|
||||||
|
|
||||||
export const LoginPageFragment = () => {
|
export const LoginPageFragment = () => {
|
||||||
return (
|
return (
|
||||||
<LayoutFragment title="Sign In">
|
<AuthLayoutFragment title="Sign In">
|
||||||
<div data-ignore>
|
<div>
|
||||||
<div class="brand-header">
|
<div class="brand-header">
|
||||||
<div class="brand-logo">
|
<div class="brand-logo">
|
||||||
<svg
|
<svg
|
||||||
@ -132,6 +132,6 @@ export const LoginPageFragment = () => {
|
|||||||
<script src="/public/auth-client.js?v=6"></script>
|
<script src="/public/auth-client.js?v=6"></script>
|
||||||
<script src="/public/webauthn-login.js"></script>
|
<script src="/public/webauthn-login.js"></script>
|
||||||
</div>
|
</div>
|
||||||
</LayoutFragment>
|
</AuthLayoutFragment>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { LayoutFragment } from "../../shared/ui/fragments.tsx";
|
import { AuthLayoutFragment } from "../../shared/ui/fragments.tsx";
|
||||||
|
|
||||||
export const RecoveryPageFragment = () => {
|
export const RecoveryPageFragment = () => {
|
||||||
return (
|
return (
|
||||||
<LayoutFragment title="Account Recovery">
|
<AuthLayoutFragment title="Account Recovery">
|
||||||
<div data-ignore>
|
<div>
|
||||||
<div class="brand-header">
|
<div class="brand-header">
|
||||||
<div class="brand-logo">
|
<div class="brand-logo">
|
||||||
<svg
|
<svg
|
||||||
@ -88,6 +88,6 @@ export const RecoveryPageFragment = () => {
|
|||||||
</script>
|
</script>
|
||||||
<script type="module" src="/public/webauthn-recovery.js"></script>
|
<script type="module" src="/public/webauthn-recovery.js"></script>
|
||||||
</div>
|
</div>
|
||||||
</LayoutFragment>
|
</AuthLayoutFragment>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { LayoutFragment } from "../../shared/ui/fragments.tsx";
|
import { AuthLayoutFragment } from "../../shared/ui/fragments.tsx";
|
||||||
|
|
||||||
export const RegisterPageFragment = (
|
export const RegisterPageFragment = (
|
||||||
{ initialCode = "" }: { initialCode?: string },
|
{ initialCode = "" }: { initialCode?: string },
|
||||||
) => {
|
) => {
|
||||||
return (
|
return (
|
||||||
<LayoutFragment title="Create Account">
|
<AuthLayoutFragment title="Create Account">
|
||||||
<div data-ignore>
|
<div>
|
||||||
<div class="brand-header">
|
<div class="brand-header">
|
||||||
<div class="brand-logo">
|
<div class="brand-logo">
|
||||||
<svg
|
<svg
|
||||||
@ -215,6 +215,6 @@ export const RegisterPageFragment = (
|
|||||||
<script src="/public/auth-client.js?v=6"></script>
|
<script src="/public/auth-client.js?v=6"></script>
|
||||||
<script type="module" src="/public/webauthn-register.js"></script>
|
<script type="module" src="/public/webauthn-register.js"></script>
|
||||||
</div>
|
</div>
|
||||||
</LayoutFragment>
|
</AuthLayoutFragment>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { LayoutFragment } from "../../shared/ui/fragments.tsx";
|
import { AuthLayoutFragment } from "../../shared/ui/fragments.tsx";
|
||||||
|
|
||||||
export const EventJoinPageFragment = () => {
|
export const EventJoinPageFragment = () => {
|
||||||
return (
|
return (
|
||||||
<LayoutFragment title="Join Event">
|
<AuthLayoutFragment title="Join Event">
|
||||||
<div class="auth-card">
|
<div>
|
||||||
<div class="brand-header">
|
<div class="brand-header">
|
||||||
<div
|
<div
|
||||||
class="brand-logo"
|
class="brand-logo"
|
||||||
@ -73,6 +73,6 @@ export const EventJoinPageFragment = () => {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</LayoutFragment>
|
</AuthLayoutFragment>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
73
src/main.ts
73
src/main.ts
@ -1,12 +1,20 @@
|
|||||||
import { Hono } from "jsr:@hono/hono@4";
|
import { Hono } from "jsr:@hono/hono@4";
|
||||||
import { serveStatic } from "jsr:@hono/hono@4/deno";
|
import { serveStatic } from "jsr:@hono/hono@4/deno";
|
||||||
|
import { deleteCookie } from "jsr:@hono/hono@4/cookie";
|
||||||
import { MetadataService } from "jsr:@simplewebauthn/server@13";
|
import { MetadataService } from "jsr:@simplewebauthn/server@13";
|
||||||
|
|
||||||
import { initDb } from "./core/db.ts";
|
import { initDb, sqlWrapper } from "./core/db.ts";
|
||||||
import { pingValkey } from "./core/valkey.ts";
|
import { pingValkey, valkey } from "./core/valkey.ts";
|
||||||
import { contentNegotiation } from "./core/content_negotiation.ts";
|
import { contentNegotiation } from "./core/content_negotiation.ts";
|
||||||
import { payloadCapGuard } from "./core/auth_guards.ts";
|
import { payloadCapGuard } from "./core/auth_guards.ts";
|
||||||
import { startConnectRpcServer } from "./core/rpc.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 { authRoutes } from "./features/auth/routes.tsx";
|
||||||
import { adminRoutes } from "./features/admin/routes.tsx";
|
import { adminRoutes } from "./features/admin/routes.tsx";
|
||||||
@ -20,9 +28,68 @@ const app: Hono = new Hono();
|
|||||||
app.use("*", payloadCapGuard);
|
app.use("*", payloadCapGuard);
|
||||||
app.use("*", contentNegotiation());
|
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: "./" }));
|
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
|
// Wire Sub-routers and Vertical Slices
|
||||||
app.route("/pass", passRoutes);
|
app.route("/pass", passRoutes);
|
||||||
app.route("/", forwardAuthRoutes);
|
app.route("/", forwardAuthRoutes);
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
export {
|
export {
|
||||||
AuthenticatedLayoutFragment,
|
AuthenticatedLayoutFragment,
|
||||||
|
AuthLayoutFragment,
|
||||||
LayoutFragment,
|
LayoutFragment,
|
||||||
} from "./layout_fragments.tsx";
|
} from "./layout_fragments.tsx";
|
||||||
export { NavbarFragment } from "./navbar_fragments.tsx";
|
export { NavbarFragment } from "./navbar_fragments.tsx";
|
||||||
|
|||||||
@ -247,7 +247,11 @@ export const LayoutFragment = ({
|
|||||||
`}
|
`}
|
||||||
</style>
|
</style>
|
||||||
{/* Datastar script */}
|
{/* 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 */}
|
{/* 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 src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js">
|
||||||
</script>
|
</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 = ({
|
export const AuthenticatedLayoutFragment = ({
|
||||||
children,
|
children,
|
||||||
title,
|
title,
|
||||||
|
|||||||
@ -1,44 +1,93 @@
|
|||||||
import { assertEquals } from "jsr:@std/assert@1";
|
import { assertEquals, assertStringIncludes } from "jsr:@std/assert@1";
|
||||||
import app from "../main.ts";
|
import app from "../main.ts";
|
||||||
|
import { auditWrapper } from "../core/audit.ts";
|
||||||
|
|
||||||
Deno.test("[Smoke Test] Core Server Routing & Hypermedia Endpoints", async () => {
|
Deno.test("[Smoke Test] Core Server Routing & Hypermedia Endpoints", async () => {
|
||||||
|
const origAudit = auditWrapper.auditLog;
|
||||||
|
auditWrapper.auditLog = () => {};
|
||||||
|
|
||||||
|
try {
|
||||||
// 1. Health check
|
// 1. Health check
|
||||||
const resHealth = await app.fetch(new Request("http://localhost/healthz"));
|
const resHealth = await app.fetch(new Request("http://localhost/healthz"));
|
||||||
assertEquals(resHealth.status, 200);
|
assertEquals(resHealth.status, 200);
|
||||||
assertEquals(await resHealth.text(), "OK");
|
assertEquals(await resHealth.text(), "OK");
|
||||||
|
|
||||||
// 2. Public Login page returns HTML
|
// 2. Root / redirects to /login
|
||||||
|
const resRoot = await app.fetch(new Request("http://localhost/"));
|
||||||
|
assertEquals(resRoot.status, 302);
|
||||||
|
assertEquals(resRoot.headers.get("location"), "/login");
|
||||||
|
|
||||||
|
// 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");
|
||||||
|
|
||||||
|
// 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");
|
||||||
|
|
||||||
|
// 5. Public Login page returns HTML with card wrapper
|
||||||
const resLogin = await app.fetch(new Request("http://localhost/login"));
|
const resLogin = await app.fetch(new Request("http://localhost/login"));
|
||||||
assertEquals(resLogin.status, 200);
|
assertEquals(resLogin.status, 200);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
resLogin.headers.get("content-type")?.includes("text/html"),
|
resLogin.headers.get("content-type")?.includes("text/html"),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
const loginHtml = await resLogin.text();
|
||||||
|
assertStringIncludes(loginHtml, "auth-layout-wrapper");
|
||||||
|
assertStringIncludes(loginHtml, "auth-card");
|
||||||
|
assertStringIncludes(loginHtml, "auth-client.js");
|
||||||
|
|
||||||
// 3. Public Register page returns HTML
|
// 6. Public Register page returns HTML with card wrapper
|
||||||
const resRegister = await app.fetch(new Request("http://localhost/register"));
|
const resRegister = await app.fetch(
|
||||||
|
new Request("http://localhost/register"),
|
||||||
|
);
|
||||||
assertEquals(resRegister.status, 200);
|
assertEquals(resRegister.status, 200);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
resRegister.headers.get("content-type")?.includes("text/html"),
|
resRegister.headers.get("content-type")?.includes("text/html"),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
const registerHtml = await resRegister.text();
|
||||||
|
assertStringIncludes(registerHtml, "auth-layout-wrapper");
|
||||||
|
assertStringIncludes(registerHtml, "auth-card");
|
||||||
|
|
||||||
// 4. Public Join page returns HTML
|
// 7. Public Join page returns HTML with card wrapper
|
||||||
const resJoin = await app.fetch(new Request("http://localhost/join"));
|
const resJoin = await app.fetch(new Request("http://localhost/join"));
|
||||||
assertEquals(resJoin.status, 200);
|
assertEquals(resJoin.status, 200);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
resJoin.headers.get("content-type")?.includes("text/html"),
|
resJoin.headers.get("content-type")?.includes("text/html"),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
const joinHtml = await resJoin.text();
|
||||||
|
assertStringIncludes(joinHtml, "auth-layout-wrapper");
|
||||||
|
assertStringIncludes(joinHtml, "auth-card");
|
||||||
|
|
||||||
// 5. Unauthenticated Dashboard redirects to /login
|
// 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(
|
const resDash = await app.fetch(
|
||||||
new Request("http://localhost/dashboard/sessions"),
|
new Request("http://localhost/dashboard/sessions"),
|
||||||
);
|
);
|
||||||
assertEquals(resDash.status, 302);
|
assertEquals(resDash.status, 302);
|
||||||
assertEquals(resDash.headers.get("location")?.includes("/login"), true);
|
assertEquals(resDash.headers.get("location")?.includes("/login"), true);
|
||||||
|
|
||||||
// 6. Magic Link Pass without token redirects to /login
|
// 10. Magic Link Pass without token redirects to /login
|
||||||
const resPass = await app.fetch(new Request("http://localhost/pass"));
|
const resPass = await app.fetch(new Request("http://localhost/pass"));
|
||||||
assertEquals(resPass.status, 302);
|
assertEquals(resPass.status, 302);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
@ -48,13 +97,18 @@ Deno.test("[Smoke Test] Core Server Routing & Hypermedia Endpoints", async () =>
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 7. Forward Auth check without host returns 400
|
// 11. Forward Auth check without host returns 400
|
||||||
const resForwardAuth = await app.fetch(
|
const resForwardAuth = await app.fetch(
|
||||||
new Request("http://localhost/api/forward-auth"),
|
new Request("http://localhost/api/forward-auth"),
|
||||||
);
|
);
|
||||||
assertEquals(resForwardAuth.status, 400);
|
assertEquals(resForwardAuth.status, 400);
|
||||||
|
|
||||||
// 8. Admin route unauthenticated redirects or denies
|
// 12. Admin route unauthenticated redirects or denies
|
||||||
const resAdmin = await app.fetch(new Request("http://localhost/admin/users"));
|
const resAdmin = await app.fetch(
|
||||||
|
new Request("http://localhost/admin/users"),
|
||||||
|
);
|
||||||
assertEquals(resAdmin.status === 302 || resAdmin.status === 401, true);
|
assertEquals(resAdmin.status === 302 || resAdmin.status === 401, true);
|
||||||
|
} finally {
|
||||||
|
auditWrapper.auditLog = origAudit;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user