mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
test(oidc): add integration tests for OIDC auth flow
Covers session response fields, password guards, users list, config endpoint, login redirect, callback edge cases, and backward compatibility. Also adds migration to make password_hash nullable (required for OIDC-only users) and vitest aliases for @fastify/cookie and openid-client.
This commit is contained in:
@@ -0,0 +1,655 @@
|
||||
/**
|
||||
* 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user