Files
homepage/src/__tests__/pages/api/auth/[...nextauth].test.js
T
shamoon e6ad43a76c
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
Tweak: improve nextauth error logging for easier debugging (#7070)
2026-08-28 12:23:55 -07:00

331 lines
13 KiB
JavaScript

import { beforeEach, describe, expect, it, vi } from "vitest";
const { debugMock, errorMock, nextAuthMock, warnMock } = vi.hoisted(() => ({
debugMock: vi.fn(),
errorMock: vi.fn(),
nextAuthMock: vi.fn((options) => ({ options })),
warnMock: vi.fn(),
}));
vi.mock("next-auth", () => ({
default: nextAuthMock,
}));
vi.mock("utils/logger", () => ({
default: vi.fn(() => ({ debug: debugMock, error: errorMock, warn: warnMock })),
}));
describe("pages/api/auth/[...nextauth]", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.resetModules();
debugMock.mockClear();
errorMock.mockClear();
nextAuthMock.mockClear();
warnMock.mockClear();
process.env = { ...originalEnv };
delete process.env.HOMEPAGE_EXTERNAL_URL;
delete process.env.NEXTAUTH_SECRET;
delete process.env.NEXTAUTH_URL;
});
it("configures no providers when auth is disabled", async () => {
const mod = await import("pages/api/auth/[...nextauth]");
expect(nextAuthMock).toHaveBeenCalledTimes(1);
expect(mod.authOptions.providers).toEqual([]);
expect(mod.authOptions.pages?.signIn).toBe("/auth/signin");
});
it("answers the session endpoint with an empty session when auth is disabled", async () => {
const mod = await import("pages/api/auth/[...nextauth]");
const json = vi.fn();
const res = { status: vi.fn(() => ({ json, end: vi.fn() })) };
await mod.default({ query: { nextauth: ["session"] } }, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(json).toHaveBeenCalledWith({});
expect(nextAuthMock).toHaveBeenCalledTimes(1); // built at import, never invoked per-request
});
it.each([["providers"], ["csrf"], ["signin"]])(
"answers the %s endpoint with parseable JSON when auth is disabled",
async (endpoint) => {
const mod = await import("pages/api/auth/[...nextauth]");
const json = vi.fn();
const res = { status: vi.fn(() => ({ json, end: vi.fn() })) };
await mod.default({ query: { nextauth: [endpoint] } }, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(json).toHaveBeenCalledWith({});
},
);
it("does not enable NextAuth's raw debug logger", async () => {
const mod = await import("pages/api/auth/[...nextauth]");
expect(mod.authOptions).not.toHaveProperty("debug");
});
it("routes sanitized NextAuth logs through the Homepage logger", async () => {
const mod = await import("pages/api/auth/[...nextauth]");
const sensitiveMetadata = {
error: Object.assign(new Error("State cookie was missing."), { access_token: "sensitive-access-token" }),
clientSecret: "sensitive-client-secret",
access_token: "sensitive-access-token",
id_token: "sensitive-id-token",
};
mod.authOptions.logger.error("OAUTH_CALLBACK_ERROR", sensitiveMetadata);
mod.authOptions.logger.warn("NEXTAUTH_URL", sensitiveMetadata);
mod.authOptions.logger.debug("OAUTH_CALLBACK_RESPONSE", sensitiveMetadata);
expect(errorMock).toHaveBeenCalledWith("%s: %s", "OAUTH_CALLBACK_ERROR", "State cookie was missing.");
expect(warnMock).toHaveBeenCalledWith("%s", "NEXTAUTH_URL");
expect(debugMock).toHaveBeenCalledWith("%s", "OAUTH_CALLBACK_RESPONSE");
expect(JSON.stringify([...errorMock.mock.calls, ...warnMock.mock.calls, ...debugMock.mock.calls])).not.toContain(
"sensitive",
);
});
it("logs only sanitized authentication lifecycle events", async () => {
const mod = await import("pages/api/auth/[...nextauth]");
await mod.authOptions.events.signIn({
account: {
provider: "homepage-oidc",
access_token: "sensitive-access-token",
id_token: "sensitive-id-token",
},
user: { email: "sensitive@example.com" },
});
await mod.authOptions.events.signOut({ token: { sub: "sensitive-user-id" } });
expect(debugMock).toHaveBeenNthCalledWith(1, "Sign in via provider '%s'", "homepage-oidc");
expect(debugMock).toHaveBeenNthCalledWith(2, "Sign out");
expect(JSON.stringify(debugMock.mock.calls)).not.toContain("sensitive");
});
it("maps HOMEPAGE_AUTH_SECRET and HOMEPAGE_EXTERNAL_URL to NextAuth envs", async () => {
process.env.HOMEPAGE_AUTH_SECRET = "secret";
process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example";
const mod = await import("pages/api/auth/[...nextauth]");
expect(process.env.NEXTAUTH_SECRET).toBe("secret");
expect(process.env.NEXTAUTH_URL).toBe("https://homepage.example");
expect(mod.authOptions.secret).toBe("secret");
});
it("throws when auth is enabled without an external URL", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_AUTH_PASSWORD = "secret";
process.env.HOMEPAGE_AUTH_SECRET = "rk3Xk9wQ0mVJt7cZbN2yLpA8sHdF4gRuEwTiOaSvBnM=";
await expect(import("pages/api/auth/[...nextauth]")).rejects.toThrow(/HOMEPAGE_EXTERNAL_URL.*is missing/i);
});
it.each([
"homepage.example",
"ftp://homepage.example",
"https://user:password@homepage.example",
"https://homepage.example/?unexpected=true",
"https://homepage.example/#unexpected",
])("rejects invalid external URL %s", async (externalUrl) => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_AUTH_PASSWORD = "secret";
process.env.HOMEPAGE_AUTH_SECRET = "rk3Xk9wQ0mVJt7cZbN2yLpA8sHdF4gRuEwTiOaSvBnM=";
process.env.HOMEPAGE_EXTERNAL_URL = externalUrl;
await expect(import("pages/api/auth/[...nextauth]")).rejects.toThrow(/absolute HTTP\(S\) URL/i);
});
it("throws when auth is enabled but no provider settings are present", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_AUTH_SECRET = "rk3Xk9wQ0mVJt7cZbN2yLpA8sHdF4gRuEwTiOaSvBnM=";
process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example";
await expect(import("pages/api/auth/[...nextauth]")).rejects.toThrow(
/Password auth is enabled but required settings are missing/i,
);
});
it.each(["short", "a".repeat(31)])("throws when the auth secret is too weak (%j)", async (secret) => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_AUTH_PASSWORD = "secret";
process.env.HOMEPAGE_AUTH_SECRET = secret;
process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example";
await expect(import("pages/api/auth/[...nextauth]")).rejects.toThrow(/at least 32 characters/i);
});
it("accepts an auth secret at exactly the minimum length", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_AUTH_PASSWORD = "secret";
process.env.HOMEPAGE_AUTH_SECRET = "a".repeat(32);
process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example";
const mod = await import("pages/api/auth/[...nextauth]");
expect(mod.authOptions.providers).toHaveLength(1);
});
it("does not enforce the secret length when auth is disabled", async () => {
process.env.HOMEPAGE_AUTH_SECRET = "short";
const mod = await import("pages/api/auth/[...nextauth]");
expect(mod.authOptions.providers).toEqual([]);
});
it("builds a password provider when auth is enabled without OIDC config", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_AUTH_PASSWORD = "secret";
process.env.HOMEPAGE_AUTH_SECRET = "rk3Xk9wQ0mVJt7cZbN2yLpA8sHdF4gRuEwTiOaSvBnM=";
process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example";
const mod = await import("pages/api/auth/[...nextauth]");
const [provider] = mod.authOptions.providers;
expect(provider.id).toBe("credentials");
expect(provider.name).toBe("Credentials");
expect(provider.type).toBe("credentials");
expect(typeof provider.authorize).toBe("function");
expect(mod.authOptions.useSecureCookies).toBe(true);
await expect(provider.options.authorize({ password: "secret" })).resolves.toEqual({
id: "homepage",
name: "Homepage",
});
await expect(provider.options.authorize({ password: "wrong" })).resolves.toBeNull();
await expect(provider.options.authorize({ password: 123 })).resolves.toBeNull();
});
it("logs failed password sign-in attempts without recording client-supplied data", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_AUTH_PASSWORD = "secret";
process.env.HOMEPAGE_AUTH_SECRET = "rk3Xk9wQ0mVJt7cZbN2yLpA8sHdF4gRuEwTiOaSvBnM=";
process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example";
const mod = await import("pages/api/auth/[...nextauth]");
const [provider] = mod.authOptions.providers;
await provider.options.authorize({ password: "wrong" });
await provider.options.authorize({ password: 123 });
expect(warnMock).toHaveBeenCalledTimes(2);
expect(warnMock).toHaveBeenCalledWith("Failed password sign-in attempt");
// the attempted password must never reach the logs
expect(JSON.stringify(warnMock.mock.calls)).not.toContain("wrong");
warnMock.mockClear();
await provider.options.authorize({ password: "secret" });
expect(warnMock).not.toHaveBeenCalled();
});
it("compares multibyte passwords without throwing on unequal byte lengths", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_AUTH_PASSWORD = "é";
process.env.HOMEPAGE_AUTH_SECRET = "rk3Xk9wQ0mVJt7cZbN2yLpA8sHdF4gRuEwTiOaSvBnM=";
process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example";
const mod = await import("pages/api/auth/[...nextauth]");
const [provider] = mod.authOptions.providers;
await expect(provider.options.authorize({ password: "a" })).resolves.toBeNull();
await expect(provider.options.authorize({ password: "é" })).resolves.toEqual({
id: "homepage",
name: "Homepage",
});
});
it("supports trusted HTTP deployments without Secure cookies", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_AUTH_PASSWORD = "secret";
process.env.HOMEPAGE_AUTH_SECRET = "rk3Xk9wQ0mVJt7cZbN2yLpA8sHdF4gRuEwTiOaSvBnM=";
process.env.HOMEPAGE_EXTERNAL_URL = "http://192.168.1.20:3000";
const mod = await import("pages/api/auth/[...nextauth]");
expect(process.env.NEXTAUTH_URL).toBe("http://192.168.1.20:3000");
expect(mod.authOptions.useSecureCookies).toBe(false);
});
it("accepts an explicitly configured NEXTAUTH_URL", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_AUTH_PASSWORD = "secret";
process.env.HOMEPAGE_AUTH_SECRET = "rk3Xk9wQ0mVJt7cZbN2yLpA8sHdF4gRuEwTiOaSvBnM=";
process.env.NEXTAUTH_URL = "https://homepage.example";
const mod = await import("pages/api/auth/[...nextauth]");
expect(mod.authOptions.useSecureCookies).toBe(true);
});
it("builds an OIDC provider when enabled and maps profile fields", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_OIDC_ISSUER = "https://issuer.example/";
process.env.HOMEPAGE_OIDC_CLIENT_ID = "client-id";
process.env.HOMEPAGE_OIDC_CLIENT_SECRET = "client-secret";
process.env.HOMEPAGE_AUTH_SECRET = "rk3Xk9wQ0mVJt7cZbN2yLpA8sHdF4gRuEwTiOaSvBnM=";
process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example";
process.env.HOMEPAGE_OIDC_NAME = "My OIDC";
process.env.HOMEPAGE_OIDC_SCOPE = "openid email";
const mod = await import("pages/api/auth/[...nextauth]");
const [provider] = mod.authOptions.providers;
expect(provider).toMatchObject({
id: "homepage-oidc",
name: "My OIDC",
type: "oauth",
idToken: true,
checks: ["pkce", "state", "nonce"],
issuer: "https://issuer.example",
wellKnown: "https://issuer.example/.well-known/openid-configuration",
clientId: "client-id",
clientSecret: "client-secret",
});
expect(provider.authorization.params.scope).toBe("openid email");
expect(
provider.profile({
sub: "sub",
preferred_username: "user",
email: "user@example.com",
picture: "https://example.com/p.png",
}),
).toEqual({
id: "sub",
name: "user",
email: "user@example.com",
image: "https://example.com/p.png",
});
expect(
provider.profile({
id: "id",
name: "name",
}),
).toEqual({
id: "id",
name: "name",
email: null,
image: null,
});
});
it("throws when only partial OIDC settings are provided", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_OIDC_ISSUER = "https://issuer.example";
process.env.HOMEPAGE_AUTH_SECRET = "rk3Xk9wQ0mVJt7cZbN2yLpA8sHdF4gRuEwTiOaSvBnM=";
process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example";
await expect(import("pages/api/auth/[...nextauth]")).rejects.toThrow(
/OIDC auth is enabled but required settings are missing/i,
);
});
});