diff --git a/services/app.js b/services/app.js index d2991267f5..a87ed19c2d 100644 --- a/services/app.js +++ b/services/app.js @@ -170,6 +170,9 @@ function getAuthMiddleware(authConfig, oidcSettings) { const initialAuthConfig = loadAuthConfig(); const oidcSettings = loadOidcSettings(initialAuthConfig); const protectConfig = getAuthMiddleware(initialAuthConfig, oidcSettings); +const bootstrapAuth = oidcSettings + ? createOidcMiddleware(oidcSettings, { permissive: true }) + : protectConfig; /* True when any auth method is configured. Used to keep zero-auth deployments open (their original behaviour) while closing the gate for everyone else. */ @@ -279,7 +282,7 @@ const app = express() })) // Middleware to serve any .yml files in USER_DATA_DIR with optional protection // Note: returns stripped version if auth configured but not yet authenticated - .get('/*.yml', protectConfig, (req, res) => { + .get('/*.yml', bootstrapAuth, (req, res) => { const ymlFile = req.path.split('/').pop(); const filePath = path.resolve(rootDir, process.env.USER_DATA_DIR || 'user-data', ymlFile); if (authIsConfigured) { @@ -295,6 +298,10 @@ const app = express() printWarning(`Failed to read or parse ${ymlFile}`, e); return safeEnd(res, errBody('Could not read config'), 500); } + // Not authenticated, not main conf.yml + if (!req.auth && !guestAccessOn) { + return res.status(401).json({ success: false, message: 'Unauthorized' }); + } } res.sendFile(filePath, (err) => { if (err) safeEnd(res, errBody(`Could not read ${ymlFile}`), 404); diff --git a/services/auth-oidc.js b/services/auth-oidc.js index 6d8c4fe307..46b314c27b 100644 --- a/services/auth-oidc.js +++ b/services/auth-oidc.js @@ -103,12 +103,13 @@ function deriveIsAdmin(claims, settings) { return false; } -/* Connect middleware factory. Verifies Bearer id_token; sets req.auth on success. */ -function createOidcMiddleware(settings) { +/* Connect middleware factory. Verifies Bearer id_token; sets req.auth on success + * If `permissive: true`, falls through on verification failure instead of 401 */ +function createOidcMiddleware(settings, { permissive = false } = {}) { return async (req, res, next) => { const header = req.headers.authorization || ''; const match = header.match(/^Bearer\s+(.+)$/i); - if (!match) return next(); // Permissive: no token attached, let downstream gates decide + if (!match) return next(); // No token attached, let downstream gates decide const token = match[1].trim(); if (!token) return next(); @@ -127,6 +128,7 @@ function createOidcMiddleware(settings) { return next(); } catch (e) { console.warn('[auth-oidc] token verification failed:', e.message || e); // eslint-disable-line no-console + if (permissive) return next(); return res.status(401).json({ success: false, message: 'Unauthorized - Invalid or expired token', @@ -139,6 +141,7 @@ function createOidcMiddleware(settings) { * When auth is configured AND guest access disabled AND user not yet authenticated * Otherwise, returns null, and the parent proceeds to use full config * Has just enough info (the auth config) to initiate the auth process + * Plus a special `_bootstrap` marker so frontend can distinguish a stripped config */ function maybeBootstrapConfig(filePath, opts) { const { isRootConfig, isAuthenticated, guestAccessOn } = opts; @@ -146,6 +149,10 @@ function maybeBootstrapConfig(filePath, opts) { if (!isRootConfig || isAuthenticated || guestAccessOn) return null; const full = yaml.load(fs.readFileSync(filePath, 'utf8')) || {}; return yaml.dump({ + _bootstrap: { + authenticated: false, + timestamp: new Date().toISOString(), + }, appConfig: { auth: full.appConfig?.auth || {}, enableServiceWorker: full.appConfig?.enableServiceWorker, diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 4f607886a9..1ce2fad02f 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -7,7 +7,10 @@ "home": { "no-results": "No Search Results", "no-data": "No Data Configured", - "no-items-section": "No Items to Show Yet" + "no-items-section": "No Items to Show Yet", + "session-expired-line1": "Your session has expired", + "session-expired-line2": "Re-authenticate to access your dashboard", + "sign-in-again": "Sign In Again" }, "search": { "search-label": "Search", diff --git a/src/mixins/HomeMixin.js b/src/mixins/HomeMixin.js index 6292f2861d..37c6bfc972 100644 --- a/src/mixins/HomeMixin.js +++ b/src/mixins/HomeMixin.js @@ -28,6 +28,10 @@ const HomeMixin = { pageId() { return this.$store.state.currentConfigInfo?.confId || 'home'; }, + /* True when the server returned a stripped bootstrap config (e.g. expired token) */ + isBootstrap() { + return this.$store.state.rootConfig?._bootstrap?.authenticated === false; + }, }, data: () => ({ searchValue: '', @@ -41,6 +45,10 @@ const HomeMixin = { this.loadUpConfig(); }, methods: { + /* Reload to restart the auth flow, when OIDC/Keycloak get bootstrap marker */ + reAuth() { + window.location.reload(); + }, /* When page loaded / sub-page changed, initiate config fetch. * For ROOT / LEGACY_SECTION intent the store loads the root config * for KNOWN the store loads the matching sub-config diff --git a/src/utils/auth/OidcAuth.js b/src/utils/auth/OidcAuth.js index c0e6e0f0e3..222a989e23 100644 --- a/src/utils/auth/OidcAuth.js +++ b/src/utils/auth/OidcAuth.js @@ -5,6 +5,7 @@ import { statusMsg, statusErrorMsg } from '@/utils/logging/CoolConsole'; import getApiAuthHeader from '@/utils/auth/getApiAuthHeader'; import i18n from '@/utils/i18n'; import { toast } from '@/utils/Toast'; +import $store from '@/store'; // Session storage config for storing last sign-in attempt const SIGNIN_GUARD_KEY = 'dashy.oidc.signin-attempt'; @@ -93,27 +94,40 @@ class OidcAuth { const user = await this.userManager.getUser(); if (user === null) { - if (!isOidcGuestAccessEnabled()) { - // Bail with error, if we've literally just redirected. Prevents loop - const lastAttempt = Number(sessionStorage.getItem(SIGNIN_GUARD_KEY)) || 0; - if (Date.now() - lastAttempt < SIGNIN_GUARD_THRESHOLD_MS) { - sessionStorage.removeItem(SIGNIN_GUARD_KEY); - throw new Error( - 'OIDC sign-in redirect loop detected. Check provider redirect URIs ' - + 'and that id_token claims include a username.', - ); - } - sessionStorage.setItem(SIGNIN_GUARD_KEY, String(Date.now())); - await this.userManager.signinRedirect(); - } - } else { - this.persistUserInfo(user); - // Fresh token established this run: reload to refetch config with Bearer - if (!hadValidToken && getApiAuthHeader()) { - toast(i18n.global.t('login.authenticated-redirecting'), { type: 'success' }); - setTimeout(() => window.location.replace('/'), 500); - } + if (!isOidcGuestAccessEnabled()) await this.redirectToIdp(); + return; + } + + // Server returned an unauthenticated bootstrap config + // Cached id_token is expired / invalid, wipe it and re-authenticate + if ($store.state.rootConfig?._bootstrap?.authenticated === false) { + await this.userManager.removeUser(); + localStorage.removeItem(localStorageKeys.ID_TOKEN); + await this.redirectToIdp(); + return; + } + + this.persistUserInfo(user); + // Fresh token established this run: reload to refetch config with Bearer + if (!hadValidToken && getApiAuthHeader()) { + toast(i18n.global.t('login.authenticated-redirecting'), { type: 'success' }); + setTimeout(() => window.location.replace('/'), 500); + } + } + + /* Redirect to the IdP for interactive sign-in + * If we just tried this, bail with error to prevent loops */ + async redirectToIdp() { + const lastAttempt = Number(sessionStorage.getItem(SIGNIN_GUARD_KEY)) || 0; + if (Date.now() - lastAttempt < SIGNIN_GUARD_THRESHOLD_MS) { + sessionStorage.removeItem(SIGNIN_GUARD_KEY); + throw new Error( + 'OIDC sign-in redirect loop detected. Check provider redirect URIs ' + + 'and that id_token claims include a username.', + ); } + sessionStorage.setItem(SIGNIN_GUARD_KEY, String(Date.now())); + await this.userManager.signinRedirect(); } /* Mirror the OIDC user into the localStorage keys other parts of Dashy read */ diff --git a/src/views/Home.vue b/src/views/Home.vue index 7e8099e7c0..704297f356 100644 --- a/src/views/Home.vue +++ b/src/views/Home.vue @@ -39,7 +39,14 @@
- {{searchValue ? $t('home.no-results') : $t('home.no-data')}} + +
@@ -54,6 +61,7 @@ import HomeMixin from '@/mixins/HomeMixin'; import SettingsContainer from '@/components/Settings/SettingsContainer.vue'; import Section from '@/components/LinkItems/Section.vue'; import NotificationThing from '@/components/Settings/LocalConfigWarning.vue'; +import Button from '@/components/FormElements/Button'; import { makePageName, makeRoutePath, resolveRouteIntent, viewFromPath, } from '@/utils/config/ConfigHelpers'; @@ -73,6 +81,7 @@ export default { NotificationThing, Section, BackIcon, + Button, }, data: () => ({ layout: '', @@ -277,13 +286,20 @@ export default { /* Custom styles only applied when there is no sections in config */ .no-data { - font-size: 2rem; - color: var(--background); - background: #ffffffeb; + background: var(--background-darker); + color: var(--primary); width: fit-content; margin: 2rem auto; padding: 0.5rem 1rem; border-radius: var(--curve-factor); + border: 1px solid var(--primary); + font-size: 1.8rem; + text-align: center; + .hint { + margin: 0.25rem auto; + font-size: 1rem; + opacity: 0.8; + } } /* Settings section, includes search, config and user settings */ diff --git a/src/views/Minimal.vue b/src/views/Minimal.vue index 5ce0d07d7e..c77379abdc 100644 --- a/src/views/Minimal.vue +++ b/src/views/Minimal.vue @@ -56,7 +56,14 @@ {{searchValue ? $t('home.no-results') : $t('home.no-data')}} -
{{ $t('home.no-data') }}
+
+ + +
@@ -69,6 +76,7 @@ import MinimalHeading from '@/components/MinimalView/MinimalHeading.vue'; import MinimalSearch from '@/components/MinimalView/MinimalSearch.vue'; import ConfigLauncher from '@/components/Settings/ConfigLauncher'; import EditModeSaveMenu from '@/components/InteractiveEditor/EditModeSaveMenu.vue'; +import Button from '@/components/FormElements/Button'; import { makePageName, resolveRouteIntent } from '@/utils/config/ConfigHelpers'; import ErrorHandler from '@/utils/logging/ErrorHandler'; @@ -81,6 +89,7 @@ export default { MinimalSearch, ConfigLauncher, EditModeSaveMenu, + Button, }, data: () => ({ layout: '', diff --git a/tests/server/conf-strip.test.js b/tests/server/conf-strip.test.js index 6b4aa80507..c2d55a3393 100644 --- a/tests/server/conf-strip.test.js +++ b/tests/server/conf-strip.test.js @@ -43,6 +43,8 @@ describe('OIDC strip behaviour for /conf.yml', () => { const res = await request(app).get('/conf.yml'); expect(res.status).toBe(200); const body = yamlLoad(res.text); + expect(body._bootstrap.authenticated).toBe(false); + expect(body._bootstrap.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); expect(body.appConfig.auth.enableOidc).toBe(true); expect(body.appConfig.auth.oidc.clientId).toBe('dashy-test'); expect(body.appConfig.enableServiceWorker).toBe(true); @@ -58,16 +60,31 @@ describe('OIDC strip behaviour for /conf.yml', () => { expect(res.headers['vary']).toContain('Authorization'); }); - it('does not strip non-root yml files', async () => { + it('requires valid auth for non-root yml files', async () => { const res = await request(app).get('/sub.yml'); - expect(res.status).toBe(200); - expect(res.text).toContain('Sub'); + expect(res.status).toBe(401); + }); + + it('also rejects sub-yml requests with invalid Bearer', async () => { + const res = await request(app) + .get('/sub.yml') + .set('Authorization', 'Bearer not-a-real-token'); + expect(res.status).toBe(401); }); - it('rejects invalid Bearer tokens with 401 from the OIDC middleware', async () => { + it('falls through to bootstrap on conf.yml when Bearer fails to verify', async () => { const res = await request(app) .get('/conf.yml') .set('Authorization', 'Bearer not-a-real-token'); + expect(res.status).toBe(200); + const body = yamlLoad(res.text); + expect(body._bootstrap.authenticated).toBe(false); + }); + + it('strict middleware still rejects invalid Bearer on protected API routes', async () => { + const res = await request(app) + .get('/status-check') + .set('Authorization', 'Bearer not-a-real-token'); expect(res.status).toBe(401); }); });