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
9 changes: 8 additions & 1 deletion services/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down
13 changes: 10 additions & 3 deletions services/auth-oidc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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',
Expand All @@ -139,13 +141,18 @@ 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;
// Pass through, if already authenticated / auth not configured
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,
Expand Down
5 changes: 4 additions & 1 deletion src/assets/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions src/mixins/HomeMixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: '',
Expand All @@ -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
Expand Down
54 changes: 34 additions & 20 deletions src/utils/auth/OidcAuth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Comment thread
lissy93 marked this conversation as resolved.
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 */
Expand Down
24 changes: 20 additions & 4 deletions src/views/Home.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
Expand All @@ -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';
Expand All @@ -73,6 +81,7 @@ export default {
NotificationThing,
Section,
BackIcon,
Button,
},
data: () => ({
layout: '',
Expand Down Expand Up @@ -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 */
Expand Down
11 changes: 10 additions & 1 deletion src/views/Minimal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
Expand All @@ -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';

Expand All @@ -81,6 +89,7 @@ export default {
MinimalSearch,
ConfigLauncher,
EditModeSaveMenu,
Button,
},
data: () => ({
layout: '',
Expand Down
25 changes: 21 additions & 4 deletions tests/server/conf-strip.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
});
});