diff --git a/docs/authentication/oidc.md b/docs/authentication/oidc.md index 77609d19bb..7cac9d08f3 100644 --- a/docs/authentication/oidc.md +++ b/docs/authentication/oidc.md @@ -16,11 +16,11 @@ Dashy also supports using a general [OIDC compatible](https://openid.net/connect ```yaml appConfig: disableConfigurationForNonAdmin: true # Hide the config editor from non-admins (recommended) - enableGuestAccess: false # Optional: view the dashboard read-only without signing in enableServiceWorker: true # Optional: enables the PWA and offline support enableAuthProxyCompat: true # Recover the PWA after a session expires (needs the service worker) auth: enableOidc: true # Turn OIDC on + enableGuestAccess: false # Optional: view the dashboard read-only without signing in oidc: clientId: dashy # Client ID from your provider endpoint: https://auth.example.com/application/o/dashy/ # The issuer URL, not the .well-known one @@ -28,6 +28,7 @@ appConfig: adminGroup: dashy-admins # Members of this group are admins adminRole: dashy-admin # Or grant admin by role instead enableSilentRenew: true # Refresh the session in the background before it expires + # allowedIssuers: [] # Only for multi-tenant providers to override discovery document ``` Because Dashy is a SPA, a [public client](https://datatracker.ietf.org/doc/html/rfc6749#section-2.1) registration with PKCE is needed. @@ -46,6 +47,20 @@ The claim has to be in the id_token, not just the access token. Most providers i If your admins aren't being picked up, decode the id_token (paste it into [jwt.io](https://jwt.io)) and check the claim is there. +## Multi-tenant providers + +With a multi-tenant provider (e.g. Microsoft Entra's `organizations` / `common` endpoints), the issuer in the token doesn't match the one in the discovery document, so verification fails. Set `allowedIssuers` to the issuer URL(s) you want to accept: + +```yaml + oidc: + clientId: dashy + endpoint: 'https://login.microsoftonline.com/organizations/v2.0/' + allowedIssuers: + - 'https://login.microsoftonline.com//v2.0' +``` + +When set, tokens are accepted only if their `iss` matches one of these (signature, audience and expiry are still checked). Leave it unset for normal single-tenant providers. + ## Guest access Set `enableGuestAccess: true` to let people view the dashboard read-only without signing in. They get the full config but can't save anything, and sections or items marked `hideForGuests` stay hidden. With it off (the default), anyone who isn't signed in is sent to the login flow. diff --git a/docs/configuring.md b/docs/configuring.md index b99f1e26d7..6845bdb738 100644 --- a/docs/configuring.md +++ b/docs/configuring.md @@ -220,6 +220,7 @@ For more info, see the **[Authentication Docs](/docs/authentication.md)** **`adminGroup`** | `string` | _Optional_ | The group that will be considered as admin. **`scope`** | `string` | Required | The scope(s) to request from the OIDC provider **`enableSilentRenew`** | `boolean` | _Optional_ | If set to `true`, your session is silently renewed in the background before it expires (only works for providers which support the `offline_access` scope) +**`allowedIssuers`** | `array` | _Optional_ | List of issuer URLs to accept tokens from. Needed for multi-tenant providers (e.g. Microsoft Entra) where the token issuer differs from the configured `endpoint`. If unset, the issuer from the discovery document is used **[⬆️ Back to Top](#configuring)** diff --git a/services/auth-oidc.js b/services/auth-oidc.js index 9dbc29b05e..b4dfc76e6f 100644 --- a/services/auth-oidc.js +++ b/services/auth-oidc.js @@ -22,7 +22,9 @@ function loadOidcSettings(authConfig) { if (!authConfig || typeof authConfig !== 'object') return null; if (authConfig.enableOidc && authConfig.oidc) { - const { endpoint, clientId, adminGroup, adminRole } = authConfig.oidc; + const { + endpoint, clientId, adminGroup, adminRole, allowedIssuers, + } = authConfig.oidc; if (!endpoint || !clientId) return null; return { kind: 'oidc', @@ -30,6 +32,8 @@ function loadOidcSettings(authConfig) { clientId: String(clientId), adminGroup: adminGroup || null, adminRole: adminRole || null, + allowedIssuers: Array.isArray(allowedIssuers) && allowedIssuers.length + ? allowedIssuers.map(String) : null, }; } @@ -116,7 +120,7 @@ function createOidcMiddleware(settings, { permissive = false } = {}) { try { const { canonicalIssuer, jwks } = await getIssuerContext(settings.issuer); const { payload } = await jwtVerify(token, jwks, { - issuer: canonicalIssuer, + issuer: settings.allowedIssuers || canonicalIssuer, audience: settings.clientId, clockTolerance: '30s', }); diff --git a/src/utils/config/ConfigSchema.json b/src/utils/config/ConfigSchema.json index b7faf4cb5b..d9636a92d5 100644 --- a/src/utils/config/ConfigSchema.json +++ b/src/utils/config/ConfigSchema.json @@ -661,6 +661,12 @@ "type": "boolean", "default": false, "description": "If set to true, Dashy automatically renews your session in the background before it expires. Requires your OIDC provider to issue refresh tokens" + }, + "allowedIssuers": { + "title": "Allowed Token Issuers", + "type": "array", + "items": { "type": "string" }, + "description": "List of issuer URLs to accept tokens from, overriding the issuer in the discovery document. Only needed for multi-tenant providers where the token issuer differs from the configured endpoint" } } }, diff --git a/tests/server/api-token-coexist.test.js b/tests/server/api-token-coexist.test.js new file mode 100644 index 0000000000..82c1c07ffb --- /dev/null +++ b/tests/server/api-token-coexist.test.js @@ -0,0 +1,54 @@ +// @vitest-environment node +import { describe, it, expect, afterAll } from 'vitest'; +import request from 'supertest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +// Both Dashy basic-auth AND an API_TOKEN configured — either should work +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashy-api-coexist-')); +process.env.USER_DATA_DIR = tmpDir; +process.env.ENABLE_API = 'true'; +process.env.API_TOKEN = 'token-value'; +process.env.BASIC_AUTH_USERNAME = 'admin'; +process.env.BASIC_AUTH_PASSWORD = 'pass'; +fs.writeFileSync(path.join(tmpDir, 'conf.yml'), 'pageInfo:\n title: Test\nsections: []\n'); + +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + delete process.env.ENABLE_API; + delete process.env.API_TOKEN; + delete process.env.BASIC_AUTH_USERNAME; + delete process.env.BASIC_AUTH_PASSWORD; +}); + +const app = require('../../services/app'); + +describe('API token coexisting with Dashy auth', () => { + it('accepts a valid API token', async () => { + const res = await request(app).get('/api/config').set({ Authorization: 'Bearer token-value' }); + expect(res.status).toBe(200); + }); + + it('accepts valid basic-auth credentials', async () => { + const res = await request(app).get('/api/config').auth('admin', 'pass'); + expect(res.status).toBe(200); + }); + + it('allows token-authenticated writes', async () => { + const res = await request(app).put('/api/config/conf.yml') + .set({ Authorization: 'Bearer token-value' }) + .send({ pageInfo: { title: 'Via token' }, sections: [] }); + expect(res.status).toBe(200); + }); + + it('rejects when neither credential is supplied', async () => { + const res = await request(app).get('/api/config'); + expect(res.status).toBe(401); + }); + + it('rejects an invalid token without falling through to open access', async () => { + const res = await request(app).get('/api/config').set({ Authorization: 'Bearer nope' }); + expect(res.status).toBe(401); + }); +}); diff --git a/tests/server/api-token.test.js b/tests/server/api-token.test.js new file mode 100644 index 0000000000..3398e5b907 --- /dev/null +++ b/tests/server/api-token.test.js @@ -0,0 +1,62 @@ +// @vitest-environment node +import { describe, it, expect, afterAll } from 'vitest'; +import request from 'supertest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +// No Dashy auth configured here — only an API_TOKEN. Set before requiring app. +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashy-api-token-')); +process.env.USER_DATA_DIR = tmpDir; +process.env.ENABLE_API = 'true'; +process.env.API_TOKEN = 'super-secret-token'; +fs.writeFileSync(path.join(tmpDir, 'conf.yml'), 'pageInfo:\n title: Test\nsections: []\n'); + +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + delete process.env.ENABLE_API; + delete process.env.API_TOKEN; +}); + +const app = require('../../services/app'); +const bearer = (token) => ({ Authorization: `Bearer ${token}` }); + +describe('API token auth (no other auth configured)', () => { + it('closes the open gate — anonymous reads are rejected', async () => { + const res = await request(app).get('/api/config'); + expect(res.status).toBe(401); + }); + + it('rejects an incorrect token', async () => { + const res = await request(app).get('/api/config').set(bearer('wrong-token')); + expect(res.status).toBe(401); + }); + + it('rejects a token of a different length', async () => { + const res = await request(app).get('/api/config').set(bearer('short')); + expect(res.status).toBe(401); + }); + + it('rejects a non-bearer authorization header', async () => { + const res = await request(app).get('/api/config').set({ Authorization: 'super-secret-token' }); + expect(res.status).toBe(401); + }); + + it('allows reads with a valid token', async () => { + const res = await request(app).get('/api/config').set(bearer('super-secret-token')); + expect(res.status).toBe(200); + expect(res.body.files).toContain('conf.yml'); + }); + + it('grants admin (write) access with a valid token', async () => { + const res = await request(app).post('/api/config/conf.yml/sections') + .set(bearer('super-secret-token')).send({ name: 'Added via token' }); + expect(res.status).toBe(201); + }); + + it('rejects writes without a token', async () => { + const res = await request(app).post('/api/config/conf.yml/sections') + .send({ name: 'Should fail' }); + expect(res.status).toBe(401); + }); +}); diff --git a/tests/server/auth-oidc.test.js b/tests/server/auth-oidc.test.js index c9127fc110..83ff5cc63f 100644 --- a/tests/server/auth-oidc.test.js +++ b/tests/server/auth-oidc.test.js @@ -1,6 +1,6 @@ // @vitest-environment node import { describe, it, expect } from 'vitest'; -import { deriveIsAdmin } from '../../services/auth-oidc'; +import { deriveIsAdmin, loadOidcSettings } from '../../services/auth-oidc'; const oidc = { kind: 'oidc', clientId: 'dashy', adminGroup: 'admins', adminRole: null }; const keycloak = { kind: 'keycloak', clientId: 'dashy', adminGroup: null, adminRole: 'admin' }; @@ -33,3 +33,21 @@ describe('deriveIsAdmin', () => { expect(deriveIsAdmin({ groups: ['admins'] }, { ...oidc, adminGroup: null })).toBe(false); }); }); + +describe('loadOidcSettings allowedIssuers', () => { + const base = { enableOidc: true, oidc: { endpoint: 'https://idp.example.com', clientId: 'dashy' } }; + + it('defaults to null when not set', () => { + expect(loadOidcSettings(base).allowedIssuers).toBe(null); + }); + + it('keeps a non-empty list of issuers', () => { + const cfg = { ...base, oidc: { ...base.oidc, allowedIssuers: ['https://a.example.com', 'https://b.example.com'] } }; + expect(loadOidcSettings(cfg).allowedIssuers).toEqual(['https://a.example.com', 'https://b.example.com']); + }); + + it('is null for an empty or non-array value', () => { + expect(loadOidcSettings({ ...base, oidc: { ...base.oidc, allowedIssuers: [] } }).allowedIssuers).toBe(null); + expect(loadOidcSettings({ ...base, oidc: { ...base.oidc, allowedIssuers: 'nope' } }).allowedIssuers).toBe(null); + }); +});