mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
656 lines
22 KiB
TypeScript
656 lines
22 KiB
TypeScript
/**
|
|||
|
|
* OIDC authentication integration tests.
|
||
|
|
*
|
||
|
|
* Strategy:
|
||
|
|
* - Tests that don't need OIDC routes (password guards, session fields, users
|
||
|
|
* list) create OIDC users directly in the DB and use the default test app.
|
||
|
|
* - Tests that need OIDC routes (config endpoint, login redirect) mutate the
|
||
|
|
* cached `env` object and spin up a separate Fastify instance with OIDC
|
||
|
|
* routes enabled.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { randomUUID } from "node:crypto";
|
||
|
|
import { createServer, type Server } from "node:http";
|
||
|
|
import { eq } from "drizzle-orm";
|
||
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||
|
|
import { env } from "../../apps/api/src/config.js";
|
||
|
|
import { db, schema } from "../../apps/api/src/db/index.js";
|
||
|
|
import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js";
|
||
|
|
|
||
|
|
// ── Helpers ──────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
let testApp: TestApp;
|
||
|
|
let adminToken: string;
|
||
|
|
|
||
|
|
beforeAll(async () => {
|
||
|
|
testApp = await buildTestApp();
|
||
|
|
adminToken = await loginAsAdmin(testApp.app);
|
||
|
|
}, 30_000);
|
||
|
|
|
||
|
|
afterAll(async () => {
|
||
|
|
await testApp.cleanup();
|
||
|
|
}, 10_000);
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Insert an OIDC-only user directly into the DB (no passwordHash).
|
||
|
|
* Returns a session token for the user.
|
||
|
|
*/
|
||
|
|
function createOidcUser(opts: { username?: string; email?: string; role?: string } = {}): {
|
||
|
|
userId: string;
|
||
|
|
username: string;
|
||
|
|
sessionToken: string;
|
||
|
|
} {
|
||
|
|
const userId = randomUUID();
|
||
|
|
const username = opts.username || `oidc_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||
|
|
|
||
|
|
db.insert(schema.users)
|
||
|
|
.values({
|
||
|
|
id: userId,
|
||
|
|
username,
|
||
|
|
passwordHash: null,
|
||
|
|
role: opts.role || "user",
|
||
|
|
team: "default-team-00000000",
|
||
|
|
mustChangePassword: false,
|
||
|
|
authProvider: "oidc",
|
||
|
|
externalId: `sub-${userId}`,
|
||
|
|
email: opts.email || `${username}@example.com`,
|
||
|
|
})
|
||
|
|
.run();
|
||
|
|
|
||
|
|
// Create a session (simulates what the OIDC callback would do)
|
||
|
|
const sessionToken = randomUUID();
|
||
|
|
db.insert(schema.sessions)
|
||
|
|
.values({
|
||
|
|
id: sessionToken,
|
||
|
|
userId,
|
||
|
|
expiresAt: new Date(Date.now() + 3_600_000),
|
||
|
|
idToken: "mock-id-token-jwt",
|
||
|
|
})
|
||
|
|
.run();
|
||
|
|
|
||
|
|
return { userId, username, sessionToken };
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Insert a "hybrid" user -- has both a local password AND an OIDC link.
|
||
|
|
*/
|
||
|
|
function createHybridUser(
|
||
|
|
passwordHash: string,
|
||
|
|
opts: { username?: string; email?: string } = {},
|
||
|
|
): { userId: string; username: string; sessionToken: string } {
|
||
|
|
const userId = randomUUID();
|
||
|
|
const username =
|
||
|
|
opts.username || `hybrid_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||
|
|
|
||
|
|
db.insert(schema.users)
|
||
|
|
.values({
|
||
|
|
id: userId,
|
||
|
|
username,
|
||
|
|
passwordHash,
|
||
|
|
role: "user",
|
||
|
|
team: "default-team-00000000",
|
||
|
|
mustChangePassword: false,
|
||
|
|
authProvider: "oidc",
|
||
|
|
externalId: `sub-${userId}`,
|
||
|
|
email: opts.email || `${username}@example.com`,
|
||
|
|
})
|
||
|
|
.run();
|
||
|
|
|
||
|
|
const sessionToken = randomUUID();
|
||
|
|
db.insert(schema.sessions)
|
||
|
|
.values({
|
||
|
|
id: sessionToken,
|
||
|
|
userId,
|
||
|
|
expiresAt: new Date(Date.now() + 3_600_000),
|
||
|
|
})
|
||
|
|
.run();
|
||
|
|
|
||
|
|
return { userId, username, sessionToken };
|
||
|
|
}
|
||
|
|
|
||
|
|
// =====================================================================
|
||
|
|
// SESSION RESPONSE FIELDS
|
||
|
|
// =====================================================================
|
||
|
|
describe("Session response fields", () => {
|
||
|
|
it("returns OIDC fields for an OIDC user session", async () => {
|
||
|
|
const { sessionToken, username } = createOidcUser();
|
||
|
|
|
||
|
|
const res = await testApp.app.inject({
|
||
|
|
method: "GET",
|
||
|
|
url: "/api/auth/session",
|
||
|
|
headers: { authorization: `Bearer ${sessionToken}` },
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(res.statusCode).toBe(200);
|
||
|
|
const body = JSON.parse(res.body);
|
||
|
|
expect(body.user.username).toBe(username);
|
||
|
|
expect(body.user.authProvider).toBe("oidc");
|
||
|
|
expect(body.user.loginMethod).toBe("oidc");
|
||
|
|
expect(body.user.hasLocalPassword).toBe(false);
|
||
|
|
expect(body.user.hasOidcLink).toBe(true);
|
||
|
|
expect(body.user.email).toMatch(/@example\.com$/);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("returns local fields for a local user session", async () => {
|
||
|
|
// Admin is a local user
|
||
|
|
const res = await testApp.app.inject({
|
||
|
|
method: "GET",
|
||
|
|
url: "/api/auth/session",
|
||
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(res.statusCode).toBe(200);
|
||
|
|
const body = JSON.parse(res.body);
|
||
|
|
expect(body.user.authProvider).toBe("local");
|
||
|
|
expect(body.user.loginMethod).toBe("local");
|
||
|
|
expect(body.user.hasLocalPassword).toBe(true);
|
||
|
|
expect(body.user.hasOidcLink).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("Bearer token still works for session check", async () => {
|
||
|
|
const res = await testApp.app.inject({
|
||
|
|
method: "GET",
|
||
|
|
url: "/api/auth/session",
|
||
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(res.statusCode).toBe(200);
|
||
|
|
const body = JSON.parse(res.body);
|
||
|
|
expect(body.user.id).toBeTruthy();
|
||
|
|
expect(body.expiresAt).toBeTruthy();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// =====================================================================
|
||
|
|
// PASSWORD GUARDS
|
||
|
|
// =====================================================================
|
||
|
|
describe("Password guards for OIDC users", () => {
|
||
|
|
it("OIDC user (no passwordHash) cannot change password", async () => {
|
||
|
|
const { sessionToken } = createOidcUser();
|
||
|
|
|
||
|
|
const res = await testApp.app.inject({
|
||
|
|
method: "POST",
|
||
|
|
url: "/api/auth/change-password",
|
||
|
|
headers: { authorization: `Bearer ${sessionToken}` },
|
||
|
|
payload: {
|
||
|
|
currentPassword: "anything",
|
||
|
|
newPassword: "NewValid1",
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(res.statusCode).toBe(400);
|
||
|
|
const body = JSON.parse(res.body);
|
||
|
|
expect(body.code).toBe("OIDC_NO_PASSWORD");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("admin cannot reset password for OIDC user", async () => {
|
||
|
|
const { userId } = createOidcUser();
|
||
|
|
|
||
|
|
const res = await testApp.app.inject({
|
||
|
|
method: "POST",
|
||
|
|
url: `/api/auth/users/${userId}/reset-password`,
|
||
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
||
|
|
payload: { newPassword: "NewValid1" },
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(res.statusCode).toBe(400);
|
||
|
|
const body = JSON.parse(res.body);
|
||
|
|
expect(body.code).toBe("OIDC_NO_PASSWORD");
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// =====================================================================
|
||
|
|
// USERS LIST
|
||
|
|
// =====================================================================
|
||
|
|
describe("Users list includes OIDC fields", () => {
|
||
|
|
it("GET /api/auth/users includes authProvider, hasLocalPassword, hasOidcLink", async () => {
|
||
|
|
const { username: oidcUsername } = createOidcUser();
|
||
|
|
|
||
|
|
const res = await testApp.app.inject({
|
||
|
|
method: "GET",
|
||
|
|
url: "/api/auth/users",
|
||
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(res.statusCode).toBe(200);
|
||
|
|
const body = JSON.parse(res.body);
|
||
|
|
|
||
|
|
// Find the OIDC user we just created
|
||
|
|
const oidcEntry = body.users.find((u: any) => u.username === oidcUsername);
|
||
|
|
expect(oidcEntry).toBeDefined();
|
||
|
|
expect(oidcEntry.authProvider).toBe("oidc");
|
||
|
|
expect(oidcEntry.hasLocalPassword).toBe(false);
|
||
|
|
expect(oidcEntry.hasOidcLink).toBe(true);
|
||
|
|
expect(oidcEntry.email).toMatch(/@example\.com$/);
|
||
|
|
|
||
|
|
// The admin user should be local
|
||
|
|
const adminEntry = body.users.find((u: any) => u.username === "admin");
|
||
|
|
expect(adminEntry).toBeDefined();
|
||
|
|
expect(adminEntry.authProvider).toBe("local");
|
||
|
|
expect(adminEntry.hasLocalPassword).toBe(true);
|
||
|
|
expect(adminEntry.hasOidcLink).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("users list does not expose passwordHash or externalId directly", async () => {
|
||
|
|
createOidcUser();
|
||
|
|
|
||
|
|
const res = await testApp.app.inject({
|
||
|
|
method: "GET",
|
||
|
|
url: "/api/auth/users",
|
||
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(res.statusCode).toBe(200);
|
||
|
|
const body = JSON.parse(res.body);
|
||
|
|
|
||
|
|
for (const user of body.users) {
|
||
|
|
expect(user).not.toHaveProperty("passwordHash");
|
||
|
|
expect(user).not.toHaveProperty("externalId");
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// =====================================================================
|
||
|
|
// BACKWARD COMPATIBILITY
|
||
|
|
// =====================================================================
|
||
|
|
describe("Backward compatibility", () => {
|
||
|
|
it("local login still works when OIDC users exist in the DB", async () => {
|
||
|
|
// Create an OIDC user (just to prove it doesn't break local login)
|
||
|
|
createOidcUser();
|
||
|
|
|
||
|
|
const res = await testApp.app.inject({
|
||
|
|
method: "POST",
|
||
|
|
url: "/api/auth/login",
|
||
|
|
payload: { username: "admin", password: "Adminpass1" },
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(res.statusCode).toBe(200);
|
||
|
|
const body = JSON.parse(res.body);
|
||
|
|
expect(body.token).toBeTruthy();
|
||
|
|
expect(body.user.username).toBe("admin");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("OIDC user cannot log in via local login (no passwordHash)", async () => {
|
||
|
|
const { username } = createOidcUser();
|
||
|
|
|
||
|
|
const res = await testApp.app.inject({
|
||
|
|
method: "POST",
|
||
|
|
url: "/api/auth/login",
|
||
|
|
payload: { username, password: "anything" },
|
||
|
|
});
|
||
|
|
|
||
|
|
// Should fail because passwordHash is null
|
||
|
|
expect(res.statusCode).toBe(401);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// =====================================================================
|
||
|
|
// CONFIG ENDPOINT (requires OIDC_ENABLED = true)
|
||
|
|
// =====================================================================
|
||
|
|
describe("Config endpoint with OIDC enabled", () => {
|
||
|
|
let oidcApp: TestApp;
|
||
|
|
|
||
|
|
// Save original env values
|
||
|
|
const origOidcEnabled = env.OIDC_ENABLED;
|
||
|
|
const origExternalUrl = env.EXTERNAL_URL;
|
||
|
|
const origIssuerUrl = env.OIDC_ISSUER_URL;
|
||
|
|
const origClientId = env.OIDC_CLIENT_ID;
|
||
|
|
const origClientSecret = env.OIDC_CLIENT_SECRET;
|
||
|
|
const origProviderName = env.OIDC_PROVIDER_NAME;
|
||
|
|
|
||
|
|
beforeAll(async () => {
|
||
|
|
// Mutate the cached env to enable OIDC for route registration
|
||
|
|
(env as any).OIDC_ENABLED = true;
|
||
|
|
(env as any).EXTERNAL_URL = "http://localhost:9999";
|
||
|
|
(env as any).OIDC_ISSUER_URL = "http://localhost:0";
|
||
|
|
(env as any).OIDC_CLIENT_ID = "test-client-id";
|
||
|
|
(env as any).OIDC_CLIENT_SECRET = "test-client-secret";
|
||
|
|
(env as any).OIDC_PROVIDER_NAME = "TestProvider";
|
||
|
|
|
||
|
|
oidcApp = await buildTestApp();
|
||
|
|
}, 30_000);
|
||
|
|
|
||
|
|
afterAll(async () => {
|
||
|
|
// Restore original env values
|
||
|
|
(env as any).OIDC_ENABLED = origOidcEnabled;
|
||
|
|
(env as any).EXTERNAL_URL = origExternalUrl;
|
||
|
|
(env as any).OIDC_ISSUER_URL = origIssuerUrl;
|
||
|
|
(env as any).OIDC_CLIENT_ID = origClientId;
|
||
|
|
(env as any).OIDC_CLIENT_SECRET = origClientSecret;
|
||
|
|
(env as any).OIDC_PROVIDER_NAME = origProviderName;
|
||
|
|
|
||
|
|
await oidcApp.cleanup();
|
||
|
|
}, 10_000);
|
||
|
|
|
||
|
|
it("config returns OIDC fields when enabled", async () => {
|
||
|
|
const res = await oidcApp.app.inject({
|
||
|
|
method: "GET",
|
||
|
|
url: "/api/v1/config/auth",
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(res.statusCode).toBe(200);
|
||
|
|
const body = JSON.parse(res.body);
|
||
|
|
expect(body.oidcEnabled).toBe(true);
|
||
|
|
expect(body.oidcProviderName).toBe("TestProvider");
|
||
|
|
expect(body.oidcLoginUrl).toBe("/api/auth/oidc/login");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("config does NOT leak OIDC secrets", async () => {
|
||
|
|
const res = await oidcApp.app.inject({
|
||
|
|
method: "GET",
|
||
|
|
url: "/api/v1/config/auth",
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(res.statusCode).toBe(200);
|
||
|
|
const body = JSON.parse(res.body);
|
||
|
|
expect(body).not.toHaveProperty("clientSecret");
|
||
|
|
expect(body).not.toHaveProperty("oidcClientSecret");
|
||
|
|
expect(body).not.toHaveProperty("issuerUrl");
|
||
|
|
expect(body).not.toHaveProperty("oidcIssuerUrl");
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// =====================================================================
|
||
|
|
// CONFIG ENDPOINT (OIDC disabled -- default)
|
||
|
|
// =====================================================================
|
||
|
|
describe("Config endpoint with OIDC disabled", () => {
|
||
|
|
it("config omits OIDC fields when disabled", async () => {
|
||
|
|
// The default test app has OIDC_ENABLED=false
|
||
|
|
const res = await testApp.app.inject({
|
||
|
|
method: "GET",
|
||
|
|
url: "/api/v1/config/auth",
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(res.statusCode).toBe(200);
|
||
|
|
const body = JSON.parse(res.body);
|
||
|
|
expect(body).not.toHaveProperty("oidcEnabled");
|
||
|
|
expect(body).not.toHaveProperty("oidcProviderName");
|
||
|
|
expect(body).not.toHaveProperty("oidcLoginUrl");
|
||
|
|
expect(body.authEnabled).toBe(true);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// =====================================================================
|
||
|
|
// LOGIN REDIRECT (requires OIDC routes + mock OIDC discovery)
|
||
|
|
// =====================================================================
|
||
|
|
describe("OIDC login redirect", () => {
|
||
|
|
let oidcApp: TestApp;
|
||
|
|
let mockServer: Server;
|
||
|
|
let mockPort: number;
|
||
|
|
|
||
|
|
// Save original env values
|
||
|
|
const origOidcEnabled = env.OIDC_ENABLED;
|
||
|
|
const origExternalUrl = env.EXTERNAL_URL;
|
||
|
|
const origIssuerUrl = env.OIDC_ISSUER_URL;
|
||
|
|
const origClientId = env.OIDC_CLIENT_ID;
|
||
|
|
const origClientSecret = env.OIDC_CLIENT_SECRET;
|
||
|
|
|
||
|
|
beforeAll(async () => {
|
||
|
|
// Start a minimal mock OIDC provider that serves discovery only
|
||
|
|
mockServer = createServer((req, res) => {
|
||
|
|
if (req.url === "/.well-known/openid-configuration") {
|
||
|
|
const discovery = {
|
||
|
|
issuer: `http://localhost:${mockPort}`,
|
||
|
|
authorization_endpoint: `http://localhost:${mockPort}/authorize`,
|
||
|
|
token_endpoint: `http://localhost:${mockPort}/token`,
|
||
|
|
jwks_uri: `http://localhost:${mockPort}/jwks`,
|
||
|
|
response_types_supported: ["code"],
|
||
|
|
subject_types_supported: ["public"],
|
||
|
|
id_token_signing_alg_values_supported: ["RS256"],
|
||
|
|
code_challenge_methods_supported: ["S256"],
|
||
|
|
};
|
||
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify(discovery));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (req.url === "/jwks") {
|
||
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify({ keys: [] }));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
res.writeHead(404);
|
||
|
|
res.end();
|
||
|
|
});
|
||
|
|
|
||
|
|
// Bind to random port
|
||
|
|
await new Promise<void>((resolve) => {
|
||
|
|
mockServer.listen(0, "127.0.0.1", () => {
|
||
|
|
const addr = mockServer.address();
|
||
|
|
mockPort = typeof addr === "object" && addr ? addr.port : 0;
|
||
|
|
resolve();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// Mutate the cached env to enable OIDC
|
||
|
|
(env as any).OIDC_ENABLED = true;
|
||
|
|
(env as any).EXTERNAL_URL = "http://localhost:9999";
|
||
|
|
(env as any).OIDC_ISSUER_URL = `http://localhost:${mockPort}`;
|
||
|
|
(env as any).OIDC_CLIENT_ID = "test-client-id";
|
||
|
|
(env as any).OIDC_CLIENT_SECRET = "test-client-secret";
|
||
|
|
|
||
|
|
// Clear the cached OIDC config so discovery hits our mock
|
||
|
|
const oidcModule = await import("../../apps/api/src/plugins/oidc.js");
|
||
|
|
// The module caches config in a module-level variable; rebuild app to get fresh routes
|
||
|
|
oidcApp = await buildTestApp();
|
||
|
|
}, 30_000);
|
||
|
|
|
||
|
|
afterAll(async () => {
|
||
|
|
// Restore original env values
|
||
|
|
(env as any).OIDC_ENABLED = origOidcEnabled;
|
||
|
|
(env as any).EXTERNAL_URL = origExternalUrl;
|
||
|
|
(env as any).OIDC_ISSUER_URL = origIssuerUrl;
|
||
|
|
(env as any).OIDC_CLIENT_ID = origClientId;
|
||
|
|
(env as any).OIDC_CLIENT_SECRET = origClientSecret;
|
||
|
|
|
||
|
|
await oidcApp.cleanup();
|
||
|
|
await new Promise<void>((resolve) => mockServer.close(() => resolve()));
|
||
|
|
}, 10_000);
|
||
|
|
|
||
|
|
it("GET /api/auth/oidc/login returns 302 redirect to IdP", async () => {
|
||
|
|
const res = await oidcApp.app.inject({
|
||
|
|
method: "GET",
|
||
|
|
url: "/api/auth/oidc/login",
|
||
|
|
});
|
||
|
|
|
||
|
|
// Should redirect to the mock IdP's authorization endpoint
|
||
|
|
expect(res.statusCode).toBe(302);
|
||
|
|
const location = res.headers.location as string;
|
||
|
|
expect(location).toBeTruthy();
|
||
|
|
|
||
|
|
const redirectUrl = new URL(location);
|
||
|
|
expect(redirectUrl.origin).toBe(`http://localhost:${mockPort}`);
|
||
|
|
expect(redirectUrl.pathname).toBe("/authorize");
|
||
|
|
|
||
|
|
// Verify required OIDC params
|
||
|
|
expect(redirectUrl.searchParams.get("client_id")).toBe("test-client-id");
|
||
|
|
expect(redirectUrl.searchParams.get("redirect_uri")).toBe(
|
||
|
|
"http://localhost:9999/api/auth/oidc/callback",
|
||
|
|
);
|
||
|
|
expect(redirectUrl.searchParams.get("response_type")).toBe("code");
|
||
|
|
expect(redirectUrl.searchParams.get("scope")).toContain("openid");
|
||
|
|
expect(redirectUrl.searchParams.get("state")).toBeTruthy();
|
||
|
|
expect(redirectUrl.searchParams.get("nonce")).toBeTruthy();
|
||
|
|
expect(redirectUrl.searchParams.get("code_challenge")).toBeTruthy();
|
||
|
|
expect(redirectUrl.searchParams.get("code_challenge_method")).toBe("S256");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("login redirect sets oidc-state cookie", async () => {
|
||
|
|
const res = await oidcApp.app.inject({
|
||
|
|
method: "GET",
|
||
|
|
url: "/api/auth/oidc/login",
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(res.statusCode).toBe(302);
|
||
|
|
|
||
|
|
// Check for oidc-state cookie in Set-Cookie header
|
||
|
|
const cookies = res.headers["set-cookie"];
|
||
|
|
const cookieStr = Array.isArray(cookies) ? cookies.join("; ") : cookies || "";
|
||
|
|
expect(cookieStr).toContain("oidc-state=");
|
||
|
|
expect(cookieStr).toContain("HttpOnly");
|
||
|
|
expect(cookieStr).toContain("SameSite=Lax");
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// =====================================================================
|
||
|
|
// OIDC CALLBACK EDGE CASES (without a full mock provider)
|
||
|
|
// =====================================================================
|
||
|
|
describe("OIDC callback edge cases", () => {
|
||
|
|
let oidcApp: TestApp;
|
||
|
|
let mockServer: Server;
|
||
|
|
let mockPort: number;
|
||
|
|
|
||
|
|
// Save original env values
|
||
|
|
const origOidcEnabled = env.OIDC_ENABLED;
|
||
|
|
const origExternalUrl = env.EXTERNAL_URL;
|
||
|
|
const origIssuerUrl = env.OIDC_ISSUER_URL;
|
||
|
|
const origClientId = env.OIDC_CLIENT_ID;
|
||
|
|
const origClientSecret = env.OIDC_CLIENT_SECRET;
|
||
|
|
|
||
|
|
beforeAll(async () => {
|
||
|
|
// Minimal mock server for discovery
|
||
|
|
mockServer = createServer((req, res) => {
|
||
|
|
if (req.url === "/.well-known/openid-configuration") {
|
||
|
|
const discovery = {
|
||
|
|
issuer: `http://localhost:${mockPort}`,
|
||
|
|
authorization_endpoint: `http://localhost:${mockPort}/authorize`,
|
||
|
|
token_endpoint: `http://localhost:${mockPort}/token`,
|
||
|
|
jwks_uri: `http://localhost:${mockPort}/jwks`,
|
||
|
|
response_types_supported: ["code"],
|
||
|
|
subject_types_supported: ["public"],
|
||
|
|
id_token_signing_alg_values_supported: ["RS256"],
|
||
|
|
code_challenge_methods_supported: ["S256"],
|
||
|
|
};
|
||
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify(discovery));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (req.url === "/jwks") {
|
||
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||
|
|
res.end(JSON.stringify({ keys: [] }));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
res.writeHead(404);
|
||
|
|
res.end();
|
||
|
|
});
|
||
|
|
|
||
|
|
await new Promise<void>((resolve) => {
|
||
|
|
mockServer.listen(0, "127.0.0.1", () => {
|
||
|
|
const addr = mockServer.address();
|
||
|
|
mockPort = typeof addr === "object" && addr ? addr.port : 0;
|
||
|
|
resolve();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
(env as any).OIDC_ENABLED = true;
|
||
|
|
(env as any).EXTERNAL_URL = "http://localhost:9999";
|
||
|
|
(env as any).OIDC_ISSUER_URL = `http://localhost:${mockPort}`;
|
||
|
|
(env as any).OIDC_CLIENT_ID = "test-client-id";
|
||
|
|
(env as any).OIDC_CLIENT_SECRET = "test-client-secret";
|
||
|
|
|
||
|
|
oidcApp = await buildTestApp();
|
||
|
|
}, 30_000);
|
||
|
|
|
||
|
|
afterAll(async () => {
|
||
|
|
(env as any).OIDC_ENABLED = origOidcEnabled;
|
||
|
|
(env as any).EXTERNAL_URL = origExternalUrl;
|
||
|
|
(env as any).OIDC_ISSUER_URL = origIssuerUrl;
|
||
|
|
(env as any).OIDC_CLIENT_ID = origClientId;
|
||
|
|
(env as any).OIDC_CLIENT_SECRET = origClientSecret;
|
||
|
|
|
||
|
|
await oidcApp.cleanup();
|
||
|
|
await new Promise<void>((resolve) => mockServer.close(() => resolve()));
|
||
|
|
}, 10_000);
|
||
|
|
|
||
|
|
it("callback without state cookie redirects to login with error", async () => {
|
||
|
|
const res = await oidcApp.app.inject({
|
||
|
|
method: "GET",
|
||
|
|
url: "/api/auth/oidc/callback?code=abc&state=xyz",
|
||
|
|
});
|
||
|
|
|
||
|
|
// Should redirect to /login?error=oidc_session_expired
|
||
|
|
expect(res.statusCode).toBe(302);
|
||
|
|
const location = res.headers.location as string;
|
||
|
|
expect(location).toContain("/login?error=oidc_session_expired");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("callback with invalid cookie signature redirects to login with error", async () => {
|
||
|
|
const res = await oidcApp.app.inject({
|
||
|
|
method: "GET",
|
||
|
|
url: "/api/auth/oidc/callback?code=abc&state=xyz",
|
||
|
|
cookies: { "oidc-state": "tampered-garbage-value" },
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(res.statusCode).toBe(302);
|
||
|
|
const location = res.headers.location as string;
|
||
|
|
expect(location).toContain("/login?error=oidc_session_expired");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("callback with IdP error redirects to login with oidc_auth_failed", async () => {
|
||
|
|
// First, do a login to get a valid state cookie
|
||
|
|
const loginRes = await oidcApp.app.inject({
|
||
|
|
method: "GET",
|
||
|
|
url: "/api/auth/oidc/login",
|
||
|
|
});
|
||
|
|
expect(loginRes.statusCode).toBe(302);
|
||
|
|
|
||
|
|
// Extract the state cookie and state param from redirect
|
||
|
|
const rawCookies = loginRes.headers["set-cookie"];
|
||
|
|
const cookieStr = Array.isArray(rawCookies) ? rawCookies[0] : rawCookies || "";
|
||
|
|
const cookieMatch = cookieStr.match(/oidc-state=([^;]+)/);
|
||
|
|
expect(cookieMatch).toBeTruthy();
|
||
|
|
// The cookie value may be URL-encoded; decode for inject()
|
||
|
|
const cookieValue = decodeURIComponent(cookieMatch![1]);
|
||
|
|
|
||
|
|
const redirectUrl = new URL(loginRes.headers.location as string);
|
||
|
|
const state = redirectUrl.searchParams.get("state");
|
||
|
|
|
||
|
|
// Simulate IdP returning an error
|
||
|
|
const callbackRes = await oidcApp.app.inject({
|
||
|
|
method: "GET",
|
||
|
|
url: `/api/auth/oidc/callback?error=access_denied&error_description=User+denied&state=${state}`,
|
||
|
|
cookies: { "oidc-state": cookieValue },
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(callbackRes.statusCode).toBe(302);
|
||
|
|
const location = callbackRes.headers.location as string;
|
||
|
|
expect(location).toContain("/login?error=oidc_auth_failed");
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// =====================================================================
|
||
|
|
// ADMIN OPERATIONS ON OIDC USERS
|
||
|
|
// =====================================================================
|
||
|
|
describe("Admin operations on OIDC users", () => {
|
||
|
|
it("admin can delete an OIDC user", async () => {
|
||
|
|
const { userId } = createOidcUser();
|
||
|
|
|
||
|
|
const res = await testApp.app.inject({
|
||
|
|
method: "DELETE",
|
||
|
|
url: `/api/auth/users/${userId}`,
|
||
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(res.statusCode).toBe(200);
|
||
|
|
|
||
|
|
// Verify user is gone
|
||
|
|
const user = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
|
||
|
|
expect(user).toBeUndefined();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("admin can update role of an OIDC user", async () => {
|
||
|
|
const { userId } = createOidcUser();
|
||
|
|
|
||
|
|
const res = await testApp.app.inject({
|
||
|
|
method: "PUT",
|
||
|
|
url: `/api/auth/users/${userId}`,
|
||
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
||
|
|
payload: { role: "editor" },
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(res.statusCode).toBe(200);
|
||
|
|
|
||
|
|
const user = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
|
||
|
|
expect(user?.role).toBe("editor");
|
||
|
|
});
|
||
|
|
});
|