diff --git a/README.md b/README.md index 14931ca7c..88d20b0ec 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,10 @@ For configuration options, examples and more, [please check out the homepage doc ## Security Notice 🔒 -Please note that when using features such as widgets, Homepage can access personal information (for example from your home automation system) and Homepage currently does not (and is not planned to) include any authentication layer itself. If Homepage is reachable from any untrusted network, it **must** sit behind a reverse proxy (and/or VPN) that enforces authentication, TLS, and strictly validates Host headers. The built-in host check in Homepage is a best-effort guard and should not be treated as security when exposed publicly. +Please note that when using features such as widgets, Homepage can access personal information (for example from your home automation system). To keep your information private, if Homepage is reachable from any untrusted network, it: + +1. **must** sit behind a reverse proxy (and/or VPN) that enforces authentication, TLS, and strictly validates Host headers. +2. An optional built-in OIDC login flow is available (opt-in) offering a simple “authenticated or not” guard. ## With Docker @@ -120,7 +123,7 @@ If this is your first time starting, copy the `src/skeleton` directory to `confi Finally, run the server in production mode: ```bash -pnpm start +HOMEPAGE_ALLOWED_HOSTS=gethomepage.dev:1234 pnpm start ``` # Configuration diff --git a/docs/installation/index.md b/docs/installation/index.md index b6bfb23b5..d31f540c4 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -38,3 +38,26 @@ The value is a comma-separated (no spaces) list of allowed hosts (sometimes with If you are seeing errors about host validation, check the homepage logs and ensure that the host exactly as output in the logs is in the `HOMEPAGE_ALLOWED_HOSTS` list. This can be disabled by setting `HOMEPAGE_ALLOWED_HOSTS` to `*` but this is not recommended. Public deployments must rely on a reverse proxy (and/or VPN) that enforces authentication, TLS, and unexpected Host headers; the built-in host check is a best-effort guard for local setups and is not a substitute for edge protections. + +### Security & Authentication + +Public deployments of Homepage should be secured via a reverse proxy, VPN, or similar. As of version 2.0, Homepage supports a simple authorization gate with a password or OIDC. When enabled, Homepage will use password login by default unless OIDC variables are provided. + +Required environment variables for authentication: + +- `HOMEPAGE_AUTH_ENABLED=true` +- `HOMEPAGE_AUTH_SECRET` (random string for signing/encrypting cookies) + +For password-only login: + +- `HOMEPAGE_AUTH_PASSWORD` (password-only login; required unless OIDC settings are provided) + +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 will require a signed-in session. Static assets remain public. Homepage still does not implement per-user dashboards or roles; authentication is a simple gate only. diff --git a/package.json b/package.json index 1ceeeea3b..84138fdde 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "memory-cache": "^0.2.0", "minecraftstatuspinger": "^1.2.2", "next": "^16.2.6", + "next-auth": "^4.24.10", "next-i18next": "^16.0.7", "ping": "^0.4.4", "pretty-bytes": "^7.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbbfccf72..0e6b62d9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: next: specifier: ^16.2.6 version: 16.2.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + next-auth: + specifier: ^4.24.10 + version: 4.24.14(next@16.2.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) next-i18next: specifier: ^16.0.7 version: 16.0.7(@types/react@19.0.10)(i18next@26.3.0(typescript@5.7.3))(next@16.2.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-i18next@15.5.3(i18next@26.3.0(typescript@5.7.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.7.3))(react@19.2.5) @@ -842,6 +845,9 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@panva/hkdf@1.2.1': + resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + '@pkgr/core@0.2.9': resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -1708,6 +1714,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -2556,6 +2566,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@4.15.9: + resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + jose@5.10.0: resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} @@ -2723,6 +2736,10 @@ packages: resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} engines: {node: 20 || >=22} + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + luxon@3.7.2: resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} engines: {node: '>=12'} @@ -2834,6 +2851,20 @@ packages: net@1.0.2: resolution: {integrity: sha512-kbhcj2SVVR4caaVnGLJKmlk2+f+oLkjqdKeQlmUtz6nGzOpbcobwVIeSURNgraV/v3tlmGIX82OcPCl0K6RbHQ==} + next-auth@4.24.14: + resolution: {integrity: sha512-YRz6xFDXKUwiXSMMChbrBEWyFktZ1qZXEgeSHQQ3nsy08B4c/xLk6REeutRsIFwkjY/1+ShHnu07DN3JeJguig==} + peerDependencies: + '@auth/core': 0.34.3 + next: ^12.2.5 || ^13 || ^14 || ^15 || ^16 + nodemailer: ^7.0.7 + react: ^17.0.2 || ^18 || ^19 + react-dom: ^17.0.2 || ^18 || ^19 + peerDependenciesMeta: + '@auth/core': + optional: true + nodemailer: + optional: true + next-i18next@16.0.7: resolution: {integrity: sha512-IyH9aDsDFqifndvHYQyJMJq0syLBm/E7MNrdCkAFl/5wlQ4bk4JUeLIIJC1wybWjEhYQLwWB6/dPeEf7LmqLTg==} engines: {node: '>=20'} @@ -2880,10 +2911,17 @@ packages: oauth4webapi@3.3.0: resolution: {integrity: sha512-ZlozhPlFfobzh3hB72gnBFLjXpugl/dljz1fJSRdqaV2r3D5dmi5lg2QWI0LmUYuazmE+b5exsloEv6toUtw9g==} + oauth@0.9.15: + resolution: {integrity: sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-hash@2.2.0: + resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} + engines: {node: '>= 6'} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -2915,12 +2953,19 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + oidc-token-hash@5.2.0: + resolution: {integrity: sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==} + engines: {node: ^10.13.0 || >=12.0.0} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} one-time@1.0.0: resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} + openid-client@5.7.1: + resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==} + openid-client@6.3.0: resolution: {integrity: sha512-68LMqb/Whtq214B9c9kCtuniCKQrEqWJRTEoOEZlv2QV5VgqhjySCpBe4RXeU+pj/VNOi7erP/ixxfHtqR7FOw==} @@ -3005,6 +3050,14 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + preact-render-to-string@5.2.6: + resolution: {integrity: sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==} + peerDependencies: + preact: '>=10' + + preact@10.29.2: + resolution: {integrity: sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -3036,6 +3089,9 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-format@3.8.0: + resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==} + prism-react-renderer@2.4.1: resolution: {integrity: sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==} peerDependencies: @@ -3621,6 +3677,11 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + varint@6.0.0: resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==} @@ -3816,6 +3877,9 @@ packages: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yallist@5.0.0: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} @@ -4367,6 +4431,8 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@panva/hkdf@1.2.1': {} + '@pkgr/core@0.2.9': {} '@protobufjs/aspromise@1.1.2': {} @@ -5203,6 +5269,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie@0.7.2: {} + core-util-is@1.0.3: {} cpu-features@0.0.10: @@ -6276,6 +6344,8 @@ snapshots: jiti@2.6.1: {} + jose@4.15.9: {} + jose@5.10.0: {} js-tokens@10.0.0: {} @@ -6433,6 +6503,10 @@ snapshots: lru-cache@11.2.6: {} + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + luxon@3.7.2: {} lz-string@1.5.0: {} @@ -6513,6 +6587,21 @@ snapshots: net@1.0.2: {} + next-auth@4.24.14(next@16.2.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + '@babel/runtime': 7.29.7 + '@panva/hkdf': 1.2.1 + cookie: 0.7.2 + jose: 4.15.9 + next: 16.2.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + oauth: 0.9.15 + openid-client: 5.7.1 + preact: 10.29.2 + preact-render-to-string: 5.2.6(preact@10.29.2) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + uuid: 8.3.2 + next-i18next@16.0.7(@types/react@19.0.10)(i18next@26.3.0(typescript@5.7.3))(next@16.2.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-i18next@15.5.3(i18next@26.3.0(typescript@5.7.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.7.3))(react@19.2.5): dependencies: '@types/hoist-non-react-statics': 3.3.7(@types/react@19.0.10) @@ -6557,8 +6646,12 @@ snapshots: oauth4webapi@3.3.0: {} + oauth@0.9.15: {} + object-assign@4.1.1: {} + object-hash@2.2.0: {} + object-inspect@1.13.4: {} object-keys@1.1.1: {} @@ -6600,6 +6693,8 @@ snapshots: obug@2.1.1: {} + oidc-token-hash@5.2.0: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -6608,6 +6703,13 @@ snapshots: dependencies: fn.name: 1.1.0 + openid-client@5.7.1: + dependencies: + jose: 4.15.9 + lru-cache: 6.0.0 + object-hash: 2.2.0 + oidc-token-hash: 5.2.0 + openid-client@6.3.0: dependencies: jose: 5.10.0 @@ -6687,6 +6789,13 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + preact-render-to-string@5.2.6(preact@10.29.2): + dependencies: + preact: 10.29.2 + pretty-format: 3.8.0 + + preact@10.29.2: {} + prelude-ls@1.2.1: {} prettier-linter-helpers@1.0.1: @@ -6708,6 +6817,8 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + pretty-format@3.8.0: {} + prism-react-renderer@2.4.1(react@19.2.5): dependencies: '@types/prismjs': 1.26.5 @@ -7429,6 +7540,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@8.3.2: {} + varint@6.0.0: {} victory-vendor@37.3.6: @@ -7625,6 +7738,8 @@ snapshots: y18n@5.0.8: {} + yallist@4.0.0: {} + yallist@5.0.0: {} yargs-parser@21.1.1: {} diff --git a/src/__tests__/pages/api/auth/[...nextauth].test.js b/src/__tests__/pages/api/auth/[...nextauth].test.js new file mode 100644 index 000000000..2a510d514 --- /dev/null +++ b/src/__tests__/pages/api/auth/[...nextauth].test.js @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { nextAuthMock } = vi.hoisted(() => ({ + nextAuthMock: vi.fn((options) => ({ options })), +})); + +vi.mock("next-auth", () => ({ + default: nextAuthMock, +})); + +describe("pages/api/auth/[...nextauth]", () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.resetModules(); + nextAuthMock.mockClear(); + process.env = { ...originalEnv }; + 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.default.options.providers).toEqual([]); + expect(mod.default.options.pages?.signIn).toBe("/auth/signin"); + }); + + 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.default.options.secret).toBe("secret"); + }); + + it("throws when auth is enabled but no provider settings are present", async () => { + process.env.HOMEPAGE_AUTH_ENABLED = "true"; + + await expect(import("pages/api/auth/[...nextauth]")).rejects.toThrow( + /Password auth is enabled but required settings are missing/i, + ); + }); + + 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 = "auth-secret"; + + const mod = await import("pages/api/auth/[...nextauth]"); + const [provider] = mod.default.options.providers; + + expect(provider.id).toBe("credentials"); + expect(provider.name).toBe("Credentials"); + expect(provider.type).toBe("credentials"); + expect(typeof provider.authorize).toBe("function"); + }); + + 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 = "auth-secret"; + 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.default.options.providers; + + expect(provider).toMatchObject({ + id: "homepage-oidc", + name: "My OIDC", + type: "oauth", + idToken: true, + 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 = "auth-secret"; + + await expect(import("pages/api/auth/[...nextauth]")).rejects.toThrow( + /OIDC auth is enabled but required settings are missing/i, + ); + }); +}); diff --git a/src/__tests__/pages/auth/signin.test.jsx b/src/__tests__/pages/auth/signin.test.jsx new file mode 100644 index 000000000..9b40b147f --- /dev/null +++ b/src/__tests__/pages/auth/signin.test.jsx @@ -0,0 +1,78 @@ +// @vitest-environment jsdom + +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +const { getSettingsMock } = vi.hoisted(() => ({ + getSettingsMock: vi.fn(), +})); + +vi.mock("utils/config/config", () => ({ + getSettings: getSettingsMock, +})); + +vi.mock("next/router", () => ({ + useRouter: () => ({ + query: {}, + }), +})); + +import { getProviders } from "next-auth/react"; +import SignInPage, { getServerSideProps } from "pages/auth/signin"; + +describe("pages/auth/signin", () => { + it("renders an error state when no providers are configured", async () => { + render( + , + ); + + expect(screen.getByText("Authentication not configured")).toBeInTheDocument(); + + await waitFor(() => { + expect(document.documentElement.classList.contains("dark")).toBe(true); + expect(document.documentElement.classList.contains("scheme-dark")).toBe(true); + expect(document.documentElement.classList.contains("theme-slate")).toBe(true); + }); + }); + + it("renders provider buttons when providers are available", () => { + render( + , + ); + + expect(screen.getByText("Sign in")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /login via oidc/i })).toBeInTheDocument(); + }); + + it("getServerSideProps returns providers and settings", async () => { + getProviders.mockResolvedValueOnce({ foo: { id: "foo", name: "Foo" } }); + getSettingsMock.mockReturnValueOnce({ theme: "dark" }); + + const res = await getServerSideProps({}); + + expect(getProviders).toHaveBeenCalled(); + expect(getSettingsMock).toHaveBeenCalled(); + expect(res).toEqual({ + props: { + providers: { foo: { id: "foo", name: "Foo" } }, + settings: { theme: "dark" }, + }, + }); + }); +}); diff --git a/src/middleware.js b/src/middleware.js index 39452f6cc..7d4052692 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -1,6 +1,10 @@ +import { getToken } from "next-auth/jwt"; import { NextResponse } from "next/server"; -export function middleware(req) { +const authEnabled = Boolean(process.env.HOMEPAGE_AUTH_ENABLED); +const authSecret = process.env.NEXTAUTH_SECRET || process.env.HOMEPAGE_AUTH_SECRET; + +export async function middleware(req) { // Check the Host header, if HOMEPAGE_ALLOWED_HOSTS is set const host = req.headers.get("host"); const port = process.env.PORT || 3000; @@ -15,9 +19,23 @@ export function middleware(req) { ); return NextResponse.json({ error: "Host validation failed. See logs for more details." }, { status: 400 }); } + + if (authEnabled) { + const token = await getToken({ req, secret: authSecret }); + if (!token) { + const signInUrl = new URL("/auth/signin", req.url); + signInUrl.searchParams.set("callbackUrl", "/"); + return NextResponse.redirect(signInUrl); + } + } + return NextResponse.next(); } export const config = { - matcher: "/api/:path*", + // Protect all app and API routes; allow Next.js internals, public assets, auth pages, and NextAuth endpoints. + matcher: [ + "/", + "/((?!_next/static|_next/image|favicon.ico|robots.txt|manifest.json|sitemap.xml|icons/|api/auth|auth/).*)", + ], }; diff --git a/src/middleware.test.js b/src/middleware.test.js index cb37749ec..5f24c0c23 100644 --- a/src/middleware.test.js +++ b/src/middleware.test.js @@ -1,18 +1,26 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { NextResponse } = vi.hoisted(() => ({ +const { NextResponse, getToken } = vi.hoisted(() => ({ NextResponse: { json: vi.fn((body, init) => ({ type: "json", body, init })), next: vi.fn(() => ({ type: "next" })), + redirect: vi.fn((url) => ({ type: "redirect", url })), }, + getToken: vi.fn(), })); vi.mock("next/server", () => ({ NextResponse })); +vi.mock("next-auth/jwt", () => ({ getToken })); -import { middleware } from "./middleware"; +async function loadMiddleware() { + vi.resetModules(); + const mod = await import("./middleware"); + return mod.middleware; +} -function createReq(host) { +function createReq(host = "localhost:3000", url = "http://localhost:3000/") { return { + url, headers: { get: (key) => (key === "host" ? host : null), }, @@ -29,42 +37,80 @@ describe("middleware", () => { console.error = originalConsoleError; }); - it("allows requests for default localhost hosts", () => { + it("allows requests for default localhost hosts when auth is disabled", async () => { process.env.PORT = "3000"; - const res = middleware(createReq("localhost:3000")); + + const middleware = await loadMiddleware(); + const res = await middleware(createReq("localhost:3000")); expect(NextResponse.next).toHaveBeenCalled(); expect(res).toEqual({ type: "next" }); }); - it("blocks requests when host is not allowed", () => { + it("blocks requests when host is not allowed", async () => { process.env.PORT = "3000"; const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const res = middleware(createReq("evil.com")); + const middleware = await loadMiddleware(); + const res = await middleware(createReq("evil.com")); expect(errSpy).toHaveBeenCalled(); expect(NextResponse.json).toHaveBeenCalledWith( { error: "Host validation failed. See logs for more details." }, { status: 400 }, ); + expect(getToken).not.toHaveBeenCalled(); expect(res.type).toBe("json"); expect(res.init.status).toBe(400); }); - it("allows requests when HOMEPAGE_ALLOWED_HOSTS is '*'", () => { + it("allows requests when HOMEPAGE_ALLOWED_HOSTS is '*'", async () => { process.env.HOMEPAGE_ALLOWED_HOSTS = "*"; - const res = middleware(createReq("anything.example")); + + const middleware = await loadMiddleware(); + const res = await middleware(createReq("anything.example")); expect(NextResponse.next).toHaveBeenCalled(); expect(res).toEqual({ type: "next" }); }); - it("allows requests when host is included in HOMEPAGE_ALLOWED_HOSTS", () => { + it("allows requests when host is included in HOMEPAGE_ALLOWED_HOSTS", async () => { process.env.PORT = "3000"; process.env.HOMEPAGE_ALLOWED_HOSTS = "example.com:3000,other:3000"; - const res = middleware(createReq("example.com:3000")); + const middleware = await loadMiddleware(); + const res = await middleware(createReq("example.com:3000", "http://example.com:3000/")); + + expect(NextResponse.next).toHaveBeenCalled(); + expect(res).toEqual({ type: "next" }); + }); + + it("redirects to signin when auth is enabled and no token is present", async () => { + process.env.HOMEPAGE_AUTH_ENABLED = "true"; + process.env.HOMEPAGE_AUTH_SECRET = "secret"; + + getToken.mockResolvedValueOnce(null); + + const middleware = await loadMiddleware(); + const res = await middleware(createReq("localhost:3000", "http://localhost:3000/some")); + + expect(getToken).toHaveBeenCalledWith({ + req: expect.objectContaining({ url: "http://localhost:3000/some" }), + secret: "secret", + }); + expect(NextResponse.redirect).toHaveBeenCalled(); + expect(res.type).toBe("redirect"); + expect(String(res.url)).toContain("/auth/signin"); + }); + + it("allows requests when auth is enabled and a token is present", async () => { + process.env.HOMEPAGE_AUTH_ENABLED = "true"; + process.env.HOMEPAGE_AUTH_SECRET = "secret"; + + getToken.mockResolvedValueOnce({ sub: "user" }); + + const middleware = await loadMiddleware(); + const res = await middleware(createReq("localhost:3000", "http://localhost:3000/")); expect(NextResponse.next).toHaveBeenCalled(); expect(res).toEqual({ type: "next" }); diff --git a/src/pages/_app.jsx b/src/pages/_app.jsx index ef90b4bad..3767d366d 100644 --- a/src/pages/_app.jsx +++ b/src/pages/_app.jsx @@ -1,4 +1,5 @@ /* eslint-disable react/jsx-props-no-spreading */ +import { SessionProvider } from "next-auth/react"; import { appWithTranslation } from "next-i18next/pages"; import Head from "next/head"; import "styles/globals.css"; @@ -72,28 +73,30 @@ const tailwindSafelist = [ function MyApp({ Component, pageProps }) { return ( - fetch(resource, init).then((res) => res.json()), - }} - > - - {/* https://nextjs.org/docs/messages/no-document-viewport-meta */} - - - - - - - - - - - - + + fetch(resource, init).then((res) => res.json()), + }} + > + + {/* https://nextjs.org/docs/messages/no-document-viewport-meta */} + + + + + + + + + + + + + ); } diff --git a/src/pages/api/auth/[...nextauth].js b/src/pages/api/auth/[...nextauth].js new file mode 100644 index 000000000..a6c3f652c --- /dev/null +++ b/src/pages/api/auth/[...nextauth].js @@ -0,0 +1,114 @@ +import { timingSafeEqual } from "node:crypto"; + +import NextAuth from "next-auth"; +import CredentialsProvider from "next-auth/providers/credentials"; + +const authEnabled = Boolean(process.env.HOMEPAGE_AUTH_ENABLED); +const issuer = process.env.HOMEPAGE_OIDC_ISSUER; +const clientId = process.env.HOMEPAGE_OIDC_CLIENT_ID; +const clientSecret = process.env.HOMEPAGE_OIDC_CLIENT_SECRET; +const homepageAuthSecret = process.env.HOMEPAGE_AUTH_SECRET; +const homepageExternalUrl = process.env.HOMEPAGE_EXTERNAL_URL; +const homepageAuthPassword = process.env.HOMEPAGE_AUTH_PASSWORD; + +// Map HOMEPAGE_* envs to what NextAuth expects +if (!process.env.NEXTAUTH_SECRET && homepageAuthSecret) { + process.env.NEXTAUTH_SECRET = homepageAuthSecret; +} +if (!process.env.NEXTAUTH_URL && homepageExternalUrl) { + process.env.NEXTAUTH_URL = homepageExternalUrl; +} + +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); + +if (authEnabled) { + if (hasOidcConfig) { + if (!process.env.NEXTAUTH_SECRET || !process.env.NEXTAUTH_URL) { + throw new Error("OIDC auth is enabled but required settings are missing."); + } + } else if (hasAnyOidcConfig) { + throw new Error("OIDC auth is enabled but required settings are missing."); + } else if (!homepageAuthPassword || !process.env.NEXTAUTH_SECRET) { + throw new Error("Password auth is enabled but required settings are missing."); + } +} + +let providers = []; +if (authEnabled) { + if (hasOidcConfig) { + providers = [ + { + id: "homepage-oidc", + name: process.env.HOMEPAGE_OIDC_NAME || "Homepage OIDC", + type: "oauth", + idToken: true, + issuer: cleanedIssuer, + wellKnown: `${cleanedIssuer}/.well-known/openid-configuration`, + clientId, + clientSecret, + authorization: { + params: { + scope: defaultScope, + }, + }, + profile(profile) { + return { + id: profile.sub ?? profile.id ?? profile.user_id ?? profile.uid ?? profile.email, + name: profile.name ?? profile.preferred_username ?? profile.nickname ?? profile.email, + email: profile.email ?? null, + image: profile.picture ?? null, + }; + }, + }, + ]; + } else { + providers = [ + CredentialsProvider({ + name: "Password", + credentials: { + password: { label: "Password", type: "password" }, + }, + async authorize(credentials) { + const provided = credentials?.password ?? ""; + const expected = homepageAuthPassword ?? ""; + if (!expected || provided.length !== expected.length) { + return null; + } + const isMatch = timingSafeEqual(Buffer.from(provided), Buffer.from(expected)); + if (!isMatch) { + return null; + } + return { + id: "homepage", + name: "Homepage", + }; + }, + }), + ]; + } +} + +export default NextAuth({ + providers, + session: { + strategy: "jwt", + }, + secret: process.env.NEXTAUTH_SECRET, + pages: { + signIn: "/auth/signin", + }, + debug: true, + logger: { + error: (...args) => console.error("[nextauth][error]", ...args), + warn: (...args) => console.warn("[nextauth][warn]", ...args), + debug: (...args) => console.debug("[nextauth][debug]", ...args), + }, + events: { + signIn: async (message) => console.debug("[nextauth][event][signIn]", message), + signOut: async (message) => console.debug("[nextauth][event][signOut]", message), + error: async (message) => console.error("[nextauth][event][error]", message), + }, +}); diff --git a/src/pages/auth/signin.jsx b/src/pages/auth/signin.jsx new file mode 100644 index 000000000..2ff4b8220 --- /dev/null +++ b/src/pages/auth/signin.jsx @@ -0,0 +1,210 @@ +import classNames from "classnames"; +import { getProviders, signIn } from "next-auth/react"; +import { useRouter } from "next/router"; +import { useEffect, useMemo, useState } from "react"; +import { BiShieldQuarter } from "react-icons/bi"; + +import { getSettings } from "utils/config/config"; + +export default function SignIn({ providers, settings }) { + const router = useRouter(); + const [password, setPassword] = useState(""); + const theme = settings?.theme || "dark"; + const color = settings?.color || "slate"; + const title = settings?.title || "Homepage"; + const callbackUrl = useMemo(() => { + const value = router.query?.callbackUrl; + return typeof value === "string" ? value : "/"; + }, [router.query?.callbackUrl]); + const error = router.query?.error; + + let backgroundImage = ""; + let opacity = settings?.backgroundOpacity ?? 0; + let backgroundBlur = false; + let backgroundSaturate = false; + let backgroundBrightness = false; + + if (settings?.background) { + const bg = settings.background; + if (typeof bg === "object") { + backgroundImage = bg.image || ""; + if (bg.opacity !== undefined) { + opacity = 1 - bg.opacity / 100; + } + backgroundBlur = bg.blur !== undefined; + backgroundSaturate = bg.saturate !== undefined; + backgroundBrightness = bg.brightness !== undefined; + } else { + backgroundImage = bg; + } + } + + useEffect(() => { + const html = document.documentElement; + const body = document.body; + + html.classList.remove("dark", "scheme-dark", "scheme-light"); + html.classList.toggle("dark", theme === "dark"); + html.classList.add(theme === "dark" ? "scheme-dark" : "scheme-light"); + + const desiredThemeClass = `theme-${color}`; + const themeClassesToRemove = Array.from(html.classList).filter( + (cls) => cls.startsWith("theme-") && cls !== desiredThemeClass, + ); + if (themeClassesToRemove.length) { + html.classList.remove(...themeClassesToRemove); + } + if (!html.classList.contains(desiredThemeClass)) { + html.classList.add(desiredThemeClass); + } + + body.style.backgroundImage = ""; + body.style.backgroundColor = ""; + body.style.backgroundAttachment = ""; + }, [color, theme]); + + if (!providers || Object.keys(providers).length === 0) { + return ( + <> + {backgroundImage && ( +