mirror of
https://github.com/gethomepage/homepage.git
synced 2026-09-15 00:25:51 -07:00
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
331 lines
13 KiB
JavaScript
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,
|
|
);
|
|
});
|
|
});
|