mirror of
https://github.com/Lissy93/dashy.git
synced 2026-06-03 23:34:51 -04:00
🔀 Merge pull request #2160 from lissy93/fix/refresh-oidc-token
OIDC server-side token refresh
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user