+ Authentication not configured +
+OIDC is disabled or misconfigured.
+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(
+
OIDC is disabled or misconfigured.
+Login to view your dashboard.
++ Invalid password. Please try again. +
+ )} +