Change: require external URL with auth

This commit is contained in:
shamoon
2026-07-13 10:01:27 -07:00
parent 8a2d6647d2
commit 53dcbb548d
4 changed files with 83 additions and 2 deletions
+3 -1
View File
@@ -47,6 +47,9 @@ Required environment variables for authentication:
- `HOMEPAGE_AUTH_ENABLED=true`
- `HOMEPAGE_AUTH_SECRET` (random string for signing/encrypting cookies)
- `HOMEPAGE_EXTERNAL_URL` (the absolute URL used to access Homepage, including scheme and port when needed)
Use an `https://` URL for public or TLS-terminated deployments so authentication cookies are marked `Secure`. Trusted HTTP-only LAN deployments may use an `http://` URL.
For password-only login:
@@ -57,7 +60,6 @@ For OIDC login (overrides password login):
- `HOMEPAGE_OIDC_ISSUER` (OIDC issuer URL, e.g., `https://auth.example.com/realms/homepage`)
- `HOMEPAGE_OIDC_CLIENT_ID`
- `HOMEPAGE_OIDC_CLIENT_SECRET`
- `HOMEPAGE_EXTERNAL_URL` (external URL to your Homepage instance; used for callbacks)
- Optional: `HOMEPAGE_OIDC_NAME` (display name), `HOMEPAGE_OIDC_SCOPE` (defaults to `openid email profile`)
All app pages and `/api` routes except `/api/healthcheck` will require a signed-in session. Static assets remain public.
@@ -25,6 +25,7 @@ describe("pages/api/auth/[...nextauth]", () => {
nextAuthMock.mockClear();
warnMock.mockClear();
process.env = { ...originalEnv };
delete process.env.HOMEPAGE_EXTERNAL_URL;
delete process.env.NEXTAUTH_SECRET;
delete process.env.NEXTAUTH_URL;
});
@@ -92,8 +93,33 @@ describe("pages/api/auth/[...nextauth]", () => {
expect(mod.default.options.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 = "auth-secret";
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 = "auth-secret";
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 = "auth-secret";
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,
@@ -104,6 +130,7 @@ describe("pages/api/auth/[...nextauth]", () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_AUTH_PASSWORD = "secret";
process.env.HOMEPAGE_AUTH_SECRET = "auth-secret";
process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example";
const mod = await import("pages/api/auth/[...nextauth]");
const [provider] = mod.default.options.providers;
@@ -112,6 +139,30 @@ describe("pages/api/auth/[...nextauth]", () => {
expect(provider.name).toBe("Credentials");
expect(provider.type).toBe("credentials");
expect(typeof provider.authorize).toBe("function");
expect(mod.default.options.useSecureCookies).toBe(true);
});
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 = "auth-secret";
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.default.options.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 = "auth-secret";
process.env.NEXTAUTH_URL = "https://homepage.example";
const mod = await import("pages/api/auth/[...nextauth]");
expect(mod.default.options.useSecureCookies).toBe(true);
});
it("builds an OIDC provider when enabled and maps profile fields", async () => {
@@ -170,6 +221,7 @@ describe("pages/api/auth/[...nextauth]", () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_OIDC_ISSUER = "https://issuer.example";
process.env.HOMEPAGE_AUTH_SECRET = "auth-secret";
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,
+25 -1
View File
@@ -25,10 +25,33 @@ const defaultScope = process.env.HOMEPAGE_OIDC_SCOPE || "openid email profile";
const cleanedIssuer = issuer ? issuer.replace(/\/+$/, "") : issuer;
const hasOidcConfig = Boolean(issuer && clientId && clientSecret);
const hasAnyOidcConfig = Boolean(issuer || clientId || clientSecret);
let parsedAuthUrl;
if (authEnabled) {
if (!process.env.NEXTAUTH_URL) {
throw new Error("Homepage auth is enabled but HOMEPAGE_EXTERNAL_URL (or NEXTAUTH_URL) is missing.");
}
try {
parsedAuthUrl = new URL(process.env.NEXTAUTH_URL);
} catch {
throw new Error("HOMEPAGE_EXTERNAL_URL (or NEXTAUTH_URL) must be an absolute HTTP(S) URL.");
}
if (
!["http:", "https:"].includes(parsedAuthUrl.protocol) ||
parsedAuthUrl.username ||
parsedAuthUrl.password ||
parsedAuthUrl.search ||
parsedAuthUrl.hash
) {
throw new Error(
"HOMEPAGE_EXTERNAL_URL (or NEXTAUTH_URL) must be an absolute HTTP(S) URL without credentials, query, or fragment.",
);
}
if (hasOidcConfig) {
if (!process.env.NEXTAUTH_SECRET || !process.env.NEXTAUTH_URL) {
if (!process.env.NEXTAUTH_SECRET) {
throw new Error("OIDC auth is enabled but required settings are missing.");
}
} else if (hasAnyOidcConfig) {
@@ -99,6 +122,7 @@ export const authOptions = {
strategy: "jwt",
},
secret: process.env.NEXTAUTH_SECRET,
useSecureCookies: parsedAuthUrl?.protocol === "https:",
pages: {
signIn: "/auth/signin",
},
+3
View File
@@ -106,6 +106,7 @@ describe("pages/api/mcp", () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_AUTH_PASSWORD = "password";
process.env.HOMEPAGE_AUTH_SECRET = "auth-secret";
process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example";
getServerSession.mockResolvedValueOnce({ user: { name: "Homepage" } });
const handler = await loadHandler();
const res = mockResponse();
@@ -122,6 +123,7 @@ describe("pages/api/mcp", () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_AUTH_PASSWORD = "password";
process.env.HOMEPAGE_AUTH_SECRET = "auth-secret";
process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example";
getServerSession.mockResolvedValueOnce(null);
const handler = await loadHandler();
const res = mockResponse();
@@ -137,6 +139,7 @@ describe("pages/api/mcp", () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_AUTH_PASSWORD = "password";
process.env.HOMEPAGE_AUTH_SECRET = "auth-secret";
process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example";
process.env.HOMEPAGE_MCP_TOKEN = "secret";
const handler = await loadHandler();
const res = mockResponse();