🔀 Merge pull request #2160 from lissy93/fix/refresh-oidc-token

OIDC server-side token refresh
This commit is contained in:
Alicia Sykes
2026-05-21 22:19:21 +01:00
committed by GitHub
8 changed files with 115 additions and 34 deletions

View File

@@ -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);

View File

@@ -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,

View File

@@ -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",

View File

@@ -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

View File

@@ -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 */

View File

@@ -39,7 +39,14 @@
</div>
<!-- Show message when there's no data to show -->
<div v-if="checkIfResults(filteredSections) && !isEditMode" class="no-data">
{{searchValue ? $t('home.no-results') : $t('home.no-data')}}
<template v-if="isBootstrap">
{{ $t('home.session-expired-line1') }}
<p class="hint">{{ $t('home.session-expired-line2') }}</p>
<Button :click="reAuth">{{ $t('home.sign-in-again') }}</Button>
</template>
<template v-else>
{{ searchValue ? $t('home.no-results') : $t('home.no-data') }}
</template>
</div>
<!-- Show banner at bottom of screen, for Saving config changes -->
<EditModeSaveMenu v-if="isEditMode" />
@@ -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 */

View File

@@ -56,7 +56,14 @@
{{searchValue ? $t('home.no-results') : $t('home.no-data')}}
</div>
</div>
<div v-else class="no-data"> {{ $t('home.no-data') }} </div>
<div v-else class="no-data">
<template v-if="isBootstrap">
{{ $t('home.session-expired-line1') }}
<p class="hint">{{ $t('home.session-expired-line2') }}</p>
<Button :click="reAuth">{{ $t('home.sign-in-again') }}</Button>
</template>
<template v-else>{{ $t('home.no-data') }}</template>
</div>
</div>
<!-- Interactive editor save options bottom banner -->
<EditModeSaveMenu v-if="isEditMode" />
@@ -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: '',

View File

@@ -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('rejects invalid Bearer tokens with 401 from the OIDC middleware', async () => {
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('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);
});
});