431 lines
12 KiB
TypeScript
431 lines
12 KiB
TypeScript
import { 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 { spireWrapper } from "./spire_ffi.ts";
|
|
import { auditWrapper } from "./audit.ts";
|
|
import { rateLimitWrapper } from "./ratelimit.ts";
|
|
|
|
const originalSql = sqlWrapper.sql;
|
|
|
|
function setMockSql(mockImpl: () => Promise<any>) {
|
|
sqlWrapper.sql = mockImpl as any;
|
|
}
|
|
|
|
function restoreMockSql() {
|
|
sqlWrapper.sql = originalSql;
|
|
}
|
|
|
|
// Bypass rate limit in tests to prevent Valkey Multi errors
|
|
|
|
rateLimitWrapper.checkRateLimit = () => Promise.resolve(true);
|
|
|
|
Deno.test("Tier 1 & 2: GET /api/forward-auth - Valid session", async () => {
|
|
const mockUser = {
|
|
id: "user-id-1",
|
|
username: "alice",
|
|
account_status: "active",
|
|
};
|
|
|
|
setMockSql(() => Promise.resolve([mockUser]));
|
|
|
|
let queryCount = 0;
|
|
|
|
setMockSql(() => {
|
|
queryCount++;
|
|
if (queryCount === 1) return Promise.resolve([mockUser]); // User lookup
|
|
return Promise.resolve([]); // isGlobalAdmin lookup
|
|
});
|
|
|
|
const valkeyStub = stub(valkey, "get", (key: any) => {
|
|
const k = String(key);
|
|
if (k.startsWith("auth:app_by_host:")) {
|
|
return Promise.resolve(
|
|
JSON.stringify({ id: "app-id-1", name: "test-app" }),
|
|
);
|
|
}
|
|
if (k.startsWith("auth:grants:")) {
|
|
return Promise.resolve("viewer");
|
|
}
|
|
return Promise.resolve(
|
|
JSON.stringify({ uuid: "user-id-1", username: "alice" }),
|
|
);
|
|
});
|
|
|
|
const req = new Request("http://localhost/api/forward-auth", {
|
|
headers: {
|
|
Cookie: "session_id=mock-session-id",
|
|
"X-Forwarded-Host": "test.app.local",
|
|
},
|
|
});
|
|
|
|
const res = await app.request(req);
|
|
assertEquals(res.status, 200);
|
|
assertEquals(res.headers.get("X-Forwarded-User"), "alice");
|
|
assertEquals(res.headers.get("X-Forwarded-User-Id"), "user-id-1");
|
|
assertEquals(res.headers.get("X-Forwarded-Scopes"), "viewer");
|
|
assertEquals(res.headers.get("X-Forwarded-App-Id"), "app-id-1");
|
|
|
|
restoreMockSql();
|
|
valkeyStub.restore();
|
|
});
|
|
|
|
Deno.test("Tier 1 & 2: GET /api/forward-auth - Missing session (API request)", async () => {
|
|
const req = new Request("http://localhost/api/forward-auth", {
|
|
headers: { "X-Forwarded-Host": "test.app.local" },
|
|
});
|
|
|
|
const valkeyStub = stub(valkey, "get", (key: any) => {
|
|
const k = String(key);
|
|
if (k.startsWith("auth:app_by_host:")) {
|
|
return Promise.resolve(
|
|
JSON.stringify({ id: "app-id-1", name: "test-app" }),
|
|
);
|
|
}
|
|
return Promise.resolve(null);
|
|
});
|
|
|
|
const res = await app.request(req);
|
|
assertEquals(res.status, 401);
|
|
valkeyStub.restore();
|
|
});
|
|
|
|
Deno.test("Tier 1 & 2: GET /api/forward-auth - Missing session (Browser request with text/html)", async () => {
|
|
const req = new Request("http://localhost/api/forward-auth", {
|
|
headers: {
|
|
"X-Forwarded-Host": "ed-droid.atyg.org",
|
|
"X-Forwarded-Uri": "/control-panel",
|
|
"X-Forwarded-Proto": "https",
|
|
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
},
|
|
});
|
|
|
|
const valkeyStub = stub(valkey, "get", (key: any) => {
|
|
const k = String(key);
|
|
if (k.startsWith("auth:app_by_host:")) {
|
|
return Promise.resolve(
|
|
JSON.stringify({ id: "app-id-1", name: "ed-droid" }),
|
|
);
|
|
}
|
|
return Promise.resolve(null);
|
|
});
|
|
|
|
const res = await app.request(req);
|
|
assertEquals(res.status, 302);
|
|
const location = res.headers.get("Location");
|
|
assertExists(location);
|
|
assertEquals(
|
|
location?.includes(
|
|
"login?redirect=https%3A%2F%2Fed-droid.atyg.org%2Fcontrol-panel",
|
|
),
|
|
true,
|
|
);
|
|
valkeyStub.restore();
|
|
});
|
|
|
|
Deno.test("Tier 1 & 2: GET /api/forward-auth - Expired session", async () => {
|
|
const valkeyStub = stub(valkey, "get", (key: any) => {
|
|
const k = String(key);
|
|
if (k.startsWith("auth:app_by_host:")) {
|
|
return Promise.resolve(
|
|
JSON.stringify({ id: "app-id-1", name: "test-app" }),
|
|
);
|
|
}
|
|
return Promise.resolve(null);
|
|
});
|
|
|
|
const req = new Request("http://localhost/api/forward-auth", {
|
|
headers: {
|
|
Cookie: "session_id=expired-session-id",
|
|
"X-Forwarded-Host": "test.app.local",
|
|
},
|
|
});
|
|
const res = await app.request(req);
|
|
assertEquals(res.status, 401);
|
|
valkeyStub.restore();
|
|
});
|
|
|
|
Deno.test("Tier 1 & 2: GET /api/forward-auth - Suspended account", async () => {
|
|
const mockUser = {
|
|
id: "user-id-1",
|
|
username: "alice",
|
|
account_status: "suspended",
|
|
};
|
|
|
|
setMockSql(() => Promise.resolve([mockUser]));
|
|
|
|
const valkeyStub = stub(valkey, "get", (key: any) => {
|
|
const k = String(key);
|
|
if (k.startsWith("auth:app_by_host:")) {
|
|
return Promise.resolve(
|
|
JSON.stringify({ id: "app-id-1", name: "test-app" }),
|
|
);
|
|
}
|
|
return Promise.resolve(
|
|
JSON.stringify({ uuid: "user-id-1", username: "alice" }),
|
|
);
|
|
});
|
|
|
|
const req = new Request("http://localhost/api/forward-auth", {
|
|
headers: {
|
|
Cookie: "session_id=mock-session-id",
|
|
"X-Forwarded-Host": "test.app.local",
|
|
},
|
|
});
|
|
const res = await app.request(req);
|
|
assertEquals(res.status, 403);
|
|
assertEquals(await res.text(), "Forbidden: Account inactive");
|
|
|
|
restoreMockSql();
|
|
valkeyStub.restore();
|
|
});
|
|
|
|
Deno.test("Tier 3: ValidateSession ConnectRPC - Default-Deny", async () => {
|
|
const originalExtract = spireWrapper.extractSpiffeIdFromCert;
|
|
spireWrapper.extractSpiffeIdFromCert = () => "spiffe://system.local/ed-droid";
|
|
|
|
let queryCount = 0;
|
|
setMockSql(() => {
|
|
queryCount++;
|
|
if (queryCount === 1) {
|
|
return Promise.resolve([{ id: "app-1" }]);
|
|
} else {
|
|
return Promise.resolve([]);
|
|
}
|
|
});
|
|
|
|
// Re-define valkey stub
|
|
const valkeyStub = stub(valkey, "get", () => {
|
|
return Promise.resolve(
|
|
JSON.stringify({ uuid: "user-1", username: "alice" }),
|
|
);
|
|
});
|
|
|
|
let auditCalled = false;
|
|
const originalAudit = auditWrapper.auditLog;
|
|
auditWrapper.auditLog = (..._args: any[]) => {
|
|
auditCalled = true;
|
|
};
|
|
|
|
const req = new Request(
|
|
"http://localhost/auth.v1.AuthService/ValidateSession",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"x-peer-cert": "cert-data",
|
|
},
|
|
// The Connect protocol expects token under a specific format or we can just send it,
|
|
// actually our handler looks at `req.token` via connect protocol, which translates to the json body for unary requests in Connect HTTP POST.
|
|
// However, the `ValidateSession` requires an argument structured as `{ "token": "..." }`.
|
|
body: JSON.stringify({ token: "some-session-token" }),
|
|
},
|
|
);
|
|
|
|
const res = await app.request(req);
|
|
const data = await res.json();
|
|
|
|
// The data structure from universal handler might wrap the response.
|
|
// Connect responses might look like just the json object.
|
|
// We'll check if valid is false.
|
|
|
|
if (data.valid !== undefined) {
|
|
assertEquals(data.valid, false);
|
|
} else {
|
|
// If not direct format, just ensure valid is falsy or check for error
|
|
assertEquals(!!data.valid, false);
|
|
}
|
|
|
|
assertEquals(auditCalled, true);
|
|
|
|
spireWrapper.extractSpiffeIdFromCert = originalExtract;
|
|
restoreMockSql();
|
|
valkeyStub.restore();
|
|
auditWrapper.auditLog = originalAudit;
|
|
});
|
|
|
|
Deno.test("Tier 3: ValidateSession ConnectRPC - Valid RBAC Grant", async () => {
|
|
const originalExtract = spireWrapper.extractSpiffeIdFromCert;
|
|
spireWrapper.extractSpiffeIdFromCert = () => "spiffe://system.local/ed-droid";
|
|
|
|
let queryCount = 0;
|
|
setMockSql(() => {
|
|
queryCount++;
|
|
if (queryCount === 1) {
|
|
return Promise.resolve([{ id: "app-1" }]);
|
|
} else {
|
|
return Promise.resolve([{ role: "viewer" }]);
|
|
}
|
|
});
|
|
|
|
const valkeyStub = stub(valkey, "get", () => {
|
|
return Promise.resolve(
|
|
JSON.stringify({ uuid: "user-1", username: "alice" }),
|
|
);
|
|
});
|
|
|
|
const req = new Request(
|
|
"http://localhost/auth.v1.AuthService/ValidateSession",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"x-peer-cert": "cert-data",
|
|
},
|
|
body: JSON.stringify({ token: "some-session-token" }),
|
|
},
|
|
);
|
|
|
|
const res = await app.request(req);
|
|
const data = await res.json();
|
|
|
|
if (data.valid !== undefined) {
|
|
assertEquals(data.valid, true);
|
|
assertEquals(data.uuid, "user-1");
|
|
assertEquals(data.scopes[0], "viewer");
|
|
}
|
|
|
|
spireWrapper.extractSpiffeIdFromCert = originalExtract;
|
|
restoreMockSql();
|
|
valkeyStub.restore();
|
|
});
|
|
|
|
Deno.test("Tier 3: ValidateSession ConnectRPC - SPIFFE Attestation Failure", async () => {
|
|
const originalExtract = spireWrapper.extractSpiffeIdFromCert;
|
|
spireWrapper.extractSpiffeIdFromCert = () => null;
|
|
|
|
const req = new Request(
|
|
"http://localhost/auth.v1.AuthService/ValidateSession",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"x-peer-cert": "invalid-cert",
|
|
},
|
|
body: JSON.stringify({ token: "some-session-token" }),
|
|
},
|
|
);
|
|
|
|
const res = await app.request(req);
|
|
const data = await res.json();
|
|
|
|
if (data.valid !== undefined) {
|
|
assertEquals(data.valid, false);
|
|
} else {
|
|
assertEquals(!!data.valid, false);
|
|
}
|
|
|
|
spireWrapper.extractSpiffeIdFromCert = originalExtract;
|
|
});
|
|
|
|
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[] = [];
|
|
const originalAudit = auditWrapper.auditLog;
|
|
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();
|
|
auditWrapper.auditLog = originalAudit;
|
|
});
|
|
|
|
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 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(
|
|
"SELECT credential_id, prf_enabled, prf_salt 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("Cookie Domain Scoping - getCookieDomain derives wildcard parent domain", async () => {
|
|
const { getCookieDomain } = await import("./auth-session.ts");
|
|
|
|
assertEquals(getCookieDomain("auth.atyg.org"), ".atyg.org");
|
|
assertEquals(getCookieDomain("ed-droid.atyg.org"), ".atyg.org");
|
|
assertEquals(getCookieDomain("atyg.org"), ".atyg.org");
|
|
assertEquals(getCookieDomain("localhost"), undefined);
|
|
assertEquals(getCookieDomain(""), undefined);
|
|
});
|