Files
HealthLog/src/lib/auth/__tests__/oidc.test.ts
T
Marc-André Bombeck 7592c73947 fix(auth): harden the OIDC token path and state-cookie lifecycle
Token verification: jwtVerify now carries a closed asymmetric algorithm
allowlist (RS256/PS256/ES256/EdDSA — an HMAC alg would let anyone with
the client secret forge identities) and a 60 s clock tolerance for the
usual self-host drift between IdP and app box. The JWKS fetch — jose's
own, outside safeFetch by design — is disciplined instead by pinning
the jwks_uri host to the configured issuer's host byte-exact and
passing the same 15 s timeout safeFetch defaults to. requirePublicHost
stays deliberately unused on this surface: LAN/Tailscale-only IdPs are
a legitimate topology and the issuer is operator config, not user
input.

Discovery: the issuer comparison now tolerates exactly one trailing
slash on either side (a recurring IdP/operator mismatch); token
verification still matches the provider-sent issuer byte-exact.

State cookie: the cookie is set under Path=/api/auth/oidc but was
deleted without a path — RFC 6265 keys cookies by (name, domain,
path), so the deletion never matched and the single-use blob survived
its whole TTL. Every delete now repeats the exact path, via a shared
constant.

Also: the IdP error code recorded in the wide event is whitelisted
against the closed OAuth error-code set (unknown values collapse to
"other"); the status route drops an unreachable try/catch and a
duplicated annotate; the passkey conditional-UI autofill stays unarmed
under OIDC_ONLY (passkey login is a server-enforced dead end there);
env-manifest gains the three optional OIDC_* tuning vars; the env
example notes why the issuer should be HTTPS.
2026-07-14 11:53:53 +02:00

490 lines
16 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/safe-fetch", () => ({ safeFetch: vi.fn() }));
vi.mock("jose", () => ({
jwtVerify: vi.fn(),
createRemoteJWKSet: vi.fn(() => ({ jwks: "fake" })),
}));
import { safeFetch } from "@/lib/safe-fetch";
import {
createRemoteJWKSet as mockCreateRemoteJWKSet,
jwtVerify as mockJwtVerify,
} from "jose";
import {
_resetOidcCacheForTests,
buildAuthorizationUrl,
deriveUniqueUsername,
discoverOidcMetadata,
exchangeCodeForTokens,
fetchUserinfoEmail,
getOidcConfig,
getOidcRedirectUri,
isOidcConfigured,
isOidcOnly,
sanitizeOidcNextPath,
verifyIdToken,
} from "../oidc";
const ENV_KEYS = [
"OIDC_ISSUER_URL",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_SCOPES",
"OIDC_BUTTON_LABEL",
"OIDC_ONLY",
"NEXT_PUBLIC_APP_URL",
] as const;
const ORIGINAL_ENV: Record<string, string | undefined> = {};
function jsonResponse(body: unknown, ok = true, status = 200): Response {
return {
ok,
status,
json: async () => body,
} as unknown as Response;
}
beforeEach(() => {
for (const key of ENV_KEYS) ORIGINAL_ENV[key] = process.env[key];
vi.resetAllMocks();
_resetOidcCacheForTests();
});
afterEach(() => {
for (const key of ENV_KEYS) {
if (ORIGINAL_ENV[key] === undefined) delete process.env[key];
else process.env[key] = ORIGINAL_ENV[key];
}
});
function configureEnv() {
process.env.OIDC_ISSUER_URL = "https://idp.example.com";
process.env.OIDC_CLIENT_ID = "client-123";
process.env.OIDC_CLIENT_SECRET = "secret-abc";
process.env.NEXT_PUBLIC_APP_URL = "https://healthlog.example.com";
}
describe("isOidcConfigured / getOidcConfig", () => {
it("is unconfigured when any of the three vars is missing", () => {
delete process.env.OIDC_ISSUER_URL;
delete process.env.OIDC_CLIENT_ID;
delete process.env.OIDC_CLIENT_SECRET;
expect(isOidcConfigured()).toBe(false);
expect(getOidcConfig()).toBeNull();
process.env.OIDC_ISSUER_URL = "https://idp.example.com";
process.env.OIDC_CLIENT_ID = "client-123";
// secret still missing
expect(isOidcConfigured()).toBe(false);
});
it("applies defaults for scopes and button label", () => {
configureEnv();
delete process.env.OIDC_SCOPES;
delete process.env.OIDC_BUTTON_LABEL;
const config = getOidcConfig();
expect(config?.scopes).toBe("openid email profile");
expect(config?.buttonLabel).toBe("Single Sign-On");
// Trailing slash on the issuer is normalised away.
process.env.OIDC_ISSUER_URL = "https://idp.example.com/";
expect(getOidcConfig()?.issuerUrl).toBe("https://idp.example.com");
});
});
describe("isOidcOnly", () => {
it("only takes effect when the provider is fully configured", () => {
delete process.env.OIDC_ISSUER_URL;
process.env.OIDC_ONLY = "true";
expect(isOidcOnly()).toBe(false);
configureEnv();
process.env.OIDC_ONLY = "true";
expect(isOidcOnly()).toBe(true);
process.env.OIDC_ONLY = "false";
expect(isOidcOnly()).toBe(false);
});
});
describe("getOidcRedirectUri", () => {
it("derives the callback URL from NEXT_PUBLIC_APP_URL", () => {
configureEnv();
expect(getOidcRedirectUri()).toBe(
"https://healthlog.example.com/api/auth/oidc/callback",
);
});
});
describe("sanitizeOidcNextPath", () => {
const REQUEST_URL = "https://healthlog.example.com/api/auth/oidc/login";
it("passes through a plain same-origin path", () => {
expect(sanitizeOidcNextPath("/dashboard", REQUEST_URL)).toBe("/dashboard");
});
it("defaults to / when next is null or empty", () => {
expect(sanitizeOidcNextPath(null, REQUEST_URL)).toBe("/");
expect(sanitizeOidcNextPath("", REQUEST_URL)).toBe("/");
});
it("rejects a protocol-relative next (//host)", () => {
expect(sanitizeOidcNextPath("//evil.com", REQUEST_URL)).toBe("/");
});
it("rejects an absolute next pointing at another origin", () => {
expect(sanitizeOidcNextPath("https://evil.com/phish", REQUEST_URL)).toBe(
"/",
);
});
it("rejects the backslash-normalization bypass (/\\evil.com)", () => {
// WHATWG URL parsing treats a leading backslash as a path separator
// for special schemes, so a naive startsWith("/") && !startsWith("//")
// check would accept this — resolving through URL and comparing the
// real origin closes that bypass.
expect(sanitizeOidcNextPath("/\\evil.com", REQUEST_URL)).toBe("/");
expect(sanitizeOidcNextPath("\\\\evil.com", REQUEST_URL)).toBe("/");
});
it("preserves a same-origin path's search and hash", () => {
expect(
sanitizeOidcNextPath("/settings?tab=security#top", REQUEST_URL),
).toBe("/settings?tab=security#top");
});
it("falls back to / on an unparseable value", () => {
expect(sanitizeOidcNextPath("https://", REQUEST_URL)).toBe("/");
});
});
const METADATA_DOC = {
issuer: "https://idp.example.com",
authorization_endpoint: "https://idp.example.com/authorize",
token_endpoint: "https://idp.example.com/token",
jwks_uri: "https://idp.example.com/jwks",
userinfo_endpoint: "https://idp.example.com/userinfo",
};
describe("discoverOidcMetadata", () => {
beforeEach(() => configureEnv());
it("fetches and validates the discovery document", async () => {
vi.mocked(safeFetch).mockResolvedValue(jsonResponse(METADATA_DOC));
const config = getOidcConfig()!;
const metadata = await discoverOidcMetadata(config);
expect(metadata.token_endpoint).toBe(METADATA_DOC.token_endpoint);
expect(safeFetch).toHaveBeenCalledWith(
"https://idp.example.com/.well-known/openid-configuration",
expect.objectContaining({ method: "GET" }),
);
});
it("caches the result — a second call within the TTL does not refetch", async () => {
vi.mocked(safeFetch).mockResolvedValue(jsonResponse(METADATA_DOC));
const config = getOidcConfig()!;
await discoverOidcMetadata(config);
await discoverOidcMetadata(config);
expect(safeFetch).toHaveBeenCalledTimes(1);
});
it("rejects a discovery doc whose issuer does not match", async () => {
vi.mocked(safeFetch).mockResolvedValue(
jsonResponse({ ...METADATA_DOC, issuer: "https://evil.example.com" }),
);
const config = getOidcConfig()!;
await expect(discoverOidcMetadata(config)).rejects.toThrow(
/issuer mismatch/,
);
});
it("tolerates exactly one trailing slash between config and discovery issuer", async () => {
// Several IdPs mint their issuer with a canonical trailing slash while
// the operator configures without one. The provider-sent form is kept
// in the metadata — the ID token's `iss` must match IT byte-exact.
vi.mocked(safeFetch).mockResolvedValue(
jsonResponse({ ...METADATA_DOC, issuer: "https://idp.example.com/" }),
);
const config = getOidcConfig()!;
const metadata = await discoverOidcMetadata(config);
expect(metadata.issuer).toBe("https://idp.example.com/");
});
it("does not stretch the slash tolerance beyond a single trailing slash", async () => {
vi.mocked(safeFetch).mockResolvedValue(
jsonResponse({ ...METADATA_DOC, issuer: "https://idp.example.com//" }),
);
const config = getOidcConfig()!;
await expect(discoverOidcMetadata(config)).rejects.toThrow(
/issuer mismatch/,
);
});
it("rejects a discovery doc missing required fields", async () => {
vi.mocked(safeFetch).mockResolvedValue(
jsonResponse({ issuer: METADATA_DOC.issuer }),
);
const config = getOidcConfig()!;
await expect(discoverOidcMetadata(config)).rejects.toThrow(
/missing required fields/,
);
});
});
describe("buildAuthorizationUrl", () => {
it("includes PKCE, state, nonce, and requested scopes", () => {
configureEnv();
const config = getOidcConfig()!;
const url = new URL(
buildAuthorizationUrl({
metadata: METADATA_DOC,
config,
state: "state-1",
nonce: "nonce-1",
codeChallenge: "challenge-1",
redirectUri: "https://healthlog.example.com/api/auth/oidc/callback",
}),
);
expect(url.origin + url.pathname).toBe(METADATA_DOC.authorization_endpoint);
expect(url.searchParams.get("response_type")).toBe("code");
expect(url.searchParams.get("client_id")).toBe(config.clientId);
expect(url.searchParams.get("state")).toBe("state-1");
expect(url.searchParams.get("nonce")).toBe("nonce-1");
expect(url.searchParams.get("code_challenge")).toBe("challenge-1");
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
expect(url.searchParams.get("scope")).toBe(config.scopes);
});
});
describe("exchangeCodeForTokens", () => {
beforeEach(() => configureEnv());
it("posts the authorization code + PKCE verifier and returns the token response", async () => {
vi.mocked(safeFetch).mockResolvedValue(
jsonResponse({ id_token: "id-token-value", access_token: "at-1" }),
);
const config = getOidcConfig()!;
const tokens = await exchangeCodeForTokens({
metadata: METADATA_DOC,
config,
code: "auth-code",
codeVerifier: "verifier-1",
redirectUri: "https://healthlog.example.com/api/auth/oidc/callback",
});
expect(tokens.id_token).toBe("id-token-value");
const [url, init] = vi.mocked(safeFetch).mock.calls[0];
expect(url).toBe(METADATA_DOC.token_endpoint);
const body = (init as RequestInit).body as string;
expect(body).toContain("grant_type=authorization_code");
expect(body).toContain("code=auth-code");
expect(body).toContain("code_verifier=verifier-1");
});
it("throws on a non-OK response", async () => {
vi.mocked(safeFetch).mockResolvedValue(jsonResponse({}, false, 400));
const config = getOidcConfig()!;
await expect(
exchangeCodeForTokens({
metadata: METADATA_DOC,
config,
code: "bad-code",
codeVerifier: "verifier-1",
redirectUri: "https://healthlog.example.com/api/auth/oidc/callback",
}),
).rejects.toThrow(/token exchange failed/);
});
it("throws when the response is missing id_token", async () => {
vi.mocked(safeFetch).mockResolvedValue(
jsonResponse({ access_token: "at" }),
);
const config = getOidcConfig()!;
await expect(
exchangeCodeForTokens({
metadata: METADATA_DOC,
config,
code: "code",
codeVerifier: "verifier",
redirectUri: "https://healthlog.example.com/api/auth/oidc/callback",
}),
).rejects.toThrow(/missing id_token/);
});
});
describe("verifyIdToken", () => {
beforeEach(() => configureEnv());
it("returns the identity claims on success", async () => {
// Test payloads deliberately omit the full JWTVerifyResult shape.
vi.mocked(mockJwtVerify).mockResolvedValue({
payload: {
sub: "user-sub-1",
email: "person@example.com",
email_verified: true,
name: "Person",
nonce: "nonce-1",
},
} as never);
const config = getOidcConfig()!;
const identity = await verifyIdToken({
metadata: METADATA_DOC,
config,
idToken: "id-token",
nonce: "nonce-1",
});
expect(identity).toEqual({
sub: "user-sub-1",
email: "person@example.com",
emailVerified: true,
name: "Person",
});
});
it("rejects a nonce mismatch", async () => {
vi.mocked(mockJwtVerify).mockResolvedValue({
payload: { sub: "user-sub-1", nonce: "wrong-nonce" },
} as never);
const config = getOidcConfig()!;
await expect(
verifyIdToken({
metadata: METADATA_DOC,
config,
idToken: "id-token",
nonce: "nonce-1",
}),
).rejects.toThrow(/nonce mismatch/);
});
it("rejects a token missing sub", async () => {
vi.mocked(mockJwtVerify).mockResolvedValue({
payload: { nonce: "nonce-1" },
} as never);
const config = getOidcConfig()!;
await expect(
verifyIdToken({
metadata: METADATA_DOC,
config,
idToken: "id-token",
nonce: "nonce-1",
}),
).rejects.toThrow(/missing sub claim/);
});
it("verifies with a closed asymmetric algorithm allowlist and bounded clock tolerance", async () => {
vi.mocked(mockJwtVerify).mockResolvedValue({
payload: { sub: "user-sub-1", nonce: "nonce-1" },
} as never);
const config = getOidcConfig()!;
await verifyIdToken({
metadata: METADATA_DOC,
config,
idToken: "id-token",
nonce: "nonce-1",
});
expect(mockJwtVerify).toHaveBeenCalledWith(
"id-token",
expect.anything(),
expect.objectContaining({
issuer: METADATA_DOC.issuer,
audience: config.clientId,
algorithms: ["RS256", "PS256", "ES256", "EdDSA"],
clockTolerance: "60s",
}),
);
});
it("pins the JWKS fetch to the issuer's host with a bounded timeout", async () => {
vi.mocked(mockJwtVerify).mockResolvedValue({
payload: { sub: "user-sub-1", nonce: "nonce-1" },
} as never);
const config = getOidcConfig()!;
await verifyIdToken({
metadata: METADATA_DOC,
config,
idToken: "id-token",
nonce: "nonce-1",
});
expect(mockCreateRemoteJWKSet).toHaveBeenCalledWith(
new URL(METADATA_DOC.jwks_uri),
{ timeoutDuration: 15_000 },
);
});
it("rejects a discovery doc whose jwks_uri points at a foreign host", async () => {
const config = getOidcConfig()!;
await expect(
verifyIdToken({
metadata: {
...METADATA_DOC,
jwks_uri: "https://evil.example.com/jwks",
},
config,
idToken: "id-token",
nonce: "nonce-1",
}),
).rejects.toThrow(/jwks_uri host mismatch/);
expect(mockCreateRemoteJWKSet).not.toHaveBeenCalled();
});
});
describe("fetchUserinfoEmail", () => {
it("returns the userinfo email on success", async () => {
vi.mocked(safeFetch).mockResolvedValue(
jsonResponse({ email: "fallback@example.com", email_verified: true }),
);
const result = await fetchUserinfoEmail({
metadata: METADATA_DOC,
accessToken: "at-1",
});
expect(result).toEqual({
email: "fallback@example.com",
emailVerified: true,
});
});
it("fails soft (null email) when the endpoint is missing or errors", async () => {
const noEndpoint = await fetchUserinfoEmail({
metadata: { ...METADATA_DOC, userinfo_endpoint: undefined },
accessToken: "at-1",
});
expect(noEndpoint).toEqual({ email: null, emailVerified: undefined });
vi.mocked(safeFetch).mockRejectedValue(new Error("network down"));
const onError = await fetchUserinfoEmail({
metadata: METADATA_DOC,
accessToken: "at-1",
});
expect(onError).toEqual({ email: null, emailVerified: undefined });
});
});
describe("deriveUniqueUsername", () => {
it("sanitises the email local-part into a valid username", async () => {
const exists = vi.fn().mockResolvedValue(false);
const username = await deriveUniqueUsername(
"j.doe+sso@example.com",
exists,
);
expect(username).toMatch(/^[a-zA-Z0-9_-]+$/);
expect(username.length).toBeGreaterThanOrEqual(3);
expect(username.length).toBeLessThanOrEqual(30);
});
it("appends a numeric suffix on collision", async () => {
const exists = vi
.fn()
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false);
const username = await deriveUniqueUsername("taken@example.com", exists);
expect(username).toBe("taken2");
expect(exists).toHaveBeenCalledTimes(3);
});
it("pads a too-short local-part up to the 3-char minimum", async () => {
const exists = vi.fn().mockResolvedValue(false);
const username = await deriveUniqueUsername("a@example.com", exists);
expect(username.length).toBeGreaterThanOrEqual(3);
});
});