Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion docs/authentication/oidc.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,19 @@ 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
scope: openid profile email groups # Scopes to request (groups for adminGroup, roles for adminRole)
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.
Expand All @@ -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/<your-tenant-id>/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.
Expand Down
1 change: 1 addition & 0 deletions docs/configuring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)**

Expand Down
8 changes: 6 additions & 2 deletions services/auth-oidc.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,18 @@ 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',
issuer: String(endpoint),
clientId: String(clientId),
adminGroup: adminGroup || null,
adminRole: adminRole || null,
allowedIssuers: Array.isArray(allowedIssuers) && allowedIssuers.length
? allowedIssuers.map(String) : null,
};
}

Expand Down Expand Up @@ -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',
});
Expand Down
6 changes: 6 additions & 0 deletions src/utils/config/ConfigSchema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
},
Expand Down
54 changes: 54 additions & 0 deletions tests/server/api-token-coexist.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
62 changes: 62 additions & 0 deletions tests/server/api-token.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
20 changes: 19 additions & 1 deletion tests/server/auth-oidc.test.js
Original file line number Diff line number Diff line change
@@ -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' };
Expand Down Expand Up @@ -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);
});
});