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 @@
{{ $t('home.session-expired-line2') }}
+ + + + {{ searchValue ? $t('home.no-results') : $t('home.no-data') }} +{{ $t('home.session-expired-line2') }}
+ + + {{ $t('home.no-data') }} +