auth-yes/server/tests/auth.test.ts

404 lines
12 KiB
TypeScript

import { assert, assertEquals, assertExists } from "jsr:@std/assert";
import { stub } from "jsr:@std/testing/mock";
import { app } from "../main.ts";
import { sqlWrapper } from "../db.ts";
import { valkey } from "../valkey.ts";
import { auditWrapper } from "../audit.ts";
import { rateLimitWrapper } from "../ratelimit.ts";
const originalSql = sqlWrapper.sql;
const originalAudit = auditWrapper.auditLog;
function setMockSql(mockImpl: () => Promise<any>) {
sqlWrapper.sql = mockImpl as any;
}
function restoreMockSql() {
sqlWrapper.sql = originalSql;
}
rateLimitWrapper.checkRateLimit = () => Promise.resolve(true);
Deno.test("Phase 4: Audit Ledger Verification - Login failed", async () => {
const mockUser = {
id: "user-2",
username: "bob",
account_status: "suspended",
};
let queryCount = 0;
setMockSql(() => {
queryCount++;
if (queryCount === 1) {
return Promise.resolve([{ user_id: "user-2" }]);
} else {
return Promise.resolve([mockUser]);
}
});
let auditArgs: any[] = [];
auditWrapper.auditLog = (...args: any[]) => {
auditArgs = args;
};
const req = new Request("http://localhost/api/login/verify", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Cookie": "expected_authentication_challenge=challenge",
},
body: JSON.stringify({ response: { id: "passkey-id" } }),
});
const res = await app.request(req);
assertEquals(res.status, 403);
assertEquals(auditArgs.length > 0, true);
assertEquals(auditArgs[1], "login_failed");
restoreMockSql();
});
Deno.test("WebAuthn - /api/register/verify extracts PRF", async () => {
const { app } = await import("../main.ts");
const req = new Request("http://localhost/api/register/verify", {
method: "POST",
body: JSON.stringify({}),
});
const res = await app.fetch(req);
assertEquals(res.status, 400);
const json = await res.json();
assertEquals(json.error, "inviteCode or upgrade_session required");
});
Deno.test("WebAuthn - /api/login/challenge handles username for PRF", async () => {
const { app } = await import("../main.ts");
const { sqlWrapper } = await import("../db.ts");
const originalSql = sqlWrapper.sql;
try {
const mockSql = (strings: any, ..._values: any[]) => {
const query = strings.join("?");
if (query.includes("SELECT id FROM users WHERE username =")) {
return Promise.resolve([{ id: "mock-user-id" }]);
}
if (query.includes("FROM passkeys WHERE user_id =")) {
return Promise.resolve([{
credential_id: "mock-cred",
prf_enabled: true,
prf_salt: "bW9jay1zYWx0", // "mock-salt"
}]);
}
return Promise.resolve([]);
};
sqlWrapper.sql = mockSql as any;
const req = new Request("http://localhost/api/login/challenge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "testuser" }),
});
const res = await app.fetch(req);
assertEquals(res.status, 200);
const json = await res.json();
assertExists(json.options);
assertExists(json.options.extensions);
assertExists(json.options.extensions.prf);
assertExists(json.options.extensions.prf.evalByCredential);
assertExists(json.options.extensions.prf.evalByCredential["mock-cred"]);
} finally {
sqlWrapper.sql = originalSql;
}
});
Deno.test("Logout Return-Path Validation", async (t) => {
await t.step("GET /logout preserves valid redirect", async () => {
const req = new Request(
"http://localhost/logout?redirect=https://ed-droid.atyg.org/",
);
const res = await app.fetch(req);
assertEquals(res.status, 302);
assertEquals(
res.headers.get("Location"),
"https://ed-droid.atyg.org/",
);
});
await t.step("GET /logout intercepts malicious redirect", async () => {
const res = await app.request("/logout?redirect=https://evil.com", {
method: "GET",
});
assertEquals(res.status, 302);
const location = res.headers.get("location");
assertExists(location);
// Malicious redirect should be discarded, so it just redirects to /login
assertEquals(location, "/login");
});
});
Deno.test("App Launchpad Zero-Knowledge Query Logic", async (t) => {
await t.step(
"GET /dashboard redirects to /login if unauthenticated",
async () => {
const res = await app.request("/dashboard", { method: "GET" });
assertEquals(res.status, 302);
assertEquals(res.headers.get("location"), "/login");
},
);
await t.step("GET /dashboard renders for admin", async () => {
const valkeyStub = stub(valkey, "get", (key: any) => {
if (String(key) === "valid_session") {
return Promise.resolve(
JSON.stringify({ uuid: "admin-uuid", username: "admin" }),
);
}
return Promise.resolve(null);
});
const originalSql = sqlWrapper.sql;
sqlWrapper.sql = (strings: any, ..._values: any[]) => {
const query = strings.join("?");
if (query.includes("g.role = 'admin'")) {
return Promise.resolve([{ id: "grant-id" }]);
}
if (
query.includes("SELECT id, name, description, domain, 'Admin' as role")
) {
return Promise.resolve([
{
id: "app1",
name: "App 1",
description: "Desc",
domain: "app1.com",
role: "Admin",
},
{
id: "app2",
name: "App 2",
description: "Desc",
domain: "app2.com",
role: "Admin",
},
]);
}
return Promise.resolve([]);
};
try {
const res = await app.request("/dashboard", {
method: "GET",
headers: { Cookie: "session_id=valid_session" },
});
assertEquals(res.status, 200);
const text = await res.text();
assert(text.includes("App 1"));
assert(text.includes("App 2"));
assert(text.includes("Admin Console")); // Layout link
} finally {
sqlWrapper.sql = originalSql;
valkeyStub.restore();
}
});
await t.step("GET /dashboard renders for regular user", async () => {
const valkeyStub = stub(valkey, "get", (key: any) => {
if (String(key) === "valid_session") {
return Promise.resolve(
JSON.stringify({ uuid: "user-uuid", username: "user" }),
);
}
return Promise.resolve(null);
});
const originalSql = sqlWrapper.sql;
sqlWrapper.sql = (strings: any, ..._values: any[]) => {
const query = strings.join("?");
if (query.includes("g.role = 'admin'")) {
return Promise.resolve([]); // Not admin
}
if (query.includes("ORDER BY created_at ASC LIMIT 1")) {
return Promise.resolve([{ id: "different-user" }]);
}
if (
query.includes("SELECT a.id, a.name, a.description, a.domain, g.role")
) {
return Promise.resolve([
{
id: "app1",
name: "App 1",
description: "Desc",
domain: "app1.com",
role: "Viewer",
},
]);
}
return Promise.resolve([]);
};
try {
const res = await app.request("/dashboard", {
method: "GET",
headers: { Cookie: "session_id=valid_session" },
});
assertEquals(res.status, 200);
const text = await res.text();
assert(text.includes("App 1"));
assert(!text.includes("App 2"));
assert(!text.includes("Admin Console")); // Layout link should be missing
} finally {
sqlWrapper.sql = originalSql;
valkeyStub.restore();
}
});
});
Deno.test("Cookie Shadowing & Multi-Cookie Iteration", async (t) => {
await t.step(
"GET /dashboard authenticates when fresh session is shadowed by stale host-only cookie",
async () => {
const valkeyStub = stub(valkey, "get", (key: any) => {
if (String(key) === "fresh_wildcard_session") {
return Promise.resolve(
JSON.stringify({ uuid: "user-uuid", username: "tylerg" }),
);
}
return Promise.resolve(null);
});
const originalSql = sqlWrapper.sql;
sqlWrapper.sql = () => Promise.resolve([]);
try {
const res = await app.request("/dashboard", {
method: "GET",
headers: {
// Android Chrome sends stale host cookie first, then wildcard cookie
Cookie:
"session_id=stale_host_cookie; session_id=fresh_wildcard_session",
},
});
assertEquals(res.status, 200);
const text = await res.text();
assert(text.includes("Application Launchpad"));
// Verify Set-Cookie header attempts to delete the host-only cookie
const setCookieHeader = res.headers.get("set-cookie") || "";
assert(
setCookieHeader.includes("session_id=") &&
(setCookieHeader.includes("Max-Age=0") ||
setCookieHeader.includes("Expires=")),
);
} finally {
sqlWrapper.sql = originalSql;
valkeyStub.restore();
}
},
);
});
Deno.test("Ephemeral 1-Click Magic Link Redemption (/pass)", async (t) => {
await t.step("GET /pass with missing token redirects to login", async () => {
const res = await app.request("/pass", { method: "GET" });
assertEquals(res.status, 302);
assertEquals(
res.headers.get("location"),
"/login?error=invalid_or_expired_pass",
);
});
await t.step("GET /pass with invalid token redirects to login", async () => {
const valkeyStub = stub(valkey, "get", () => Promise.resolve(null));
const originalSql = sqlWrapper.sql;
sqlWrapper.sql = (() => Promise.resolve([])) as any;
try {
const res = await app.request("/pass?token=invalid", { method: "GET" });
assertEquals(res.status, 302);
assertEquals(
res.headers.get("location"),
"/login?error=invalid_or_expired_pass",
);
} finally {
sqlWrapper.sql = originalSql;
valkeyStub.restore();
}
});
await t.step(
"GET /pass with valid token sets cookies and redirects to app domain",
async () => {
const sessionToken = "ay_sess_valid_app";
const valkeyGetStub = stub(valkey, "get", (key: any) => {
if (String(key) === sessionToken) {
return Promise.resolve(JSON.stringify({
uuid: "user-uuid",
username: "testuser",
customScopes: ["app:ed-droid"],
}));
}
return Promise.resolve(null);
});
const valkeyTtlStub = stub(valkey, "ttl", () => Promise.resolve(3600));
const originalSql = sqlWrapper.sql;
sqlWrapper.sql = ((strings: any, ..._values: any[]) => {
const query = Array.isArray(strings)
? strings.join("?")
: String(strings);
if (query.includes("SELECT domain FROM apps WHERE name =")) {
return Promise.resolve([{ domain: "ed-droid.atyg.org" }]);
}
return Promise.resolve([]);
}) as any;
try {
const res = await app.request(`/pass?token=${sessionToken}`, {
method: "GET",
});
assertEquals(res.status, 302);
assertEquals(res.headers.get("location"), "https://ed-droid.atyg.org");
const cookies = res.headers.get("set-cookie");
assertExists(cookies);
assert(cookies.includes(`session_id=${sessionToken};`));
} finally {
sqlWrapper.sql = originalSql;
valkeyGetStub.restore();
valkeyTtlStub.restore();
}
},
);
await t.step(
"GET /pass with valid token defaults to /dashboard if no app scope",
async () => {
const sessionToken = "ay_sess_valid_dashboard";
const valkeyGetStub = stub(valkey, "get", (key: any) => {
if (String(key) === sessionToken) {
return Promise.resolve(JSON.stringify({
uuid: "user-uuid",
username: "testuser",
customScopes: ["read:audit"],
}));
}
return Promise.resolve(null);
});
const valkeyTtlStub = stub(valkey, "ttl", () => Promise.resolve(3600));
try {
const res = await app.request(`/pass?token=${sessionToken}`, {
method: "GET",
});
assertEquals(res.status, 302);
assertEquals(res.headers.get("location"), "/dashboard");
} finally {
valkeyGetStub.restore();
valkeyTtlStub.restore();
auditWrapper.auditLog = originalAudit;
}
},
);
});