Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
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
32 changes: 28 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 @@ -123,8 +132,16 @@ export default {
if (this.colCount) classes += ` col-count-${this.colCount}`;
return classes;
},
/* True if the server served a stripped bootstrap config (e.g. expired token) */
isBootstrap() {
return this.$store.state.rootConfig?._bootstrap?.authenticated === false;
},
},
methods: {
/* Reload to restart the auth flow (OIDC will see the bootstrap marker and redirect) */
reAuth() {
window.location.reload();
},
Comment thread
lissy93 marked this conversation as resolved.
Outdated
/* Clears input field, once a searched item is opened */
finishedSearching() {
if (this.$refs.filterComp) this.$refs.filterComp.clearFilterInput();
Expand Down Expand Up @@ -277,13 +294,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
21 changes: 20 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 All @@ -102,7 +111,17 @@ export default {
},
sections() { this.syncSelectedFromRoute(); },
},
computed: {
/* True if the server served a stripped bootstrap config (e.g. expired token) */
isBootstrap() {
return this.$store.state.rootConfig?._bootstrap?.authenticated === false;
},
},
methods: {
/* Reload to restart the auth flow (OIDC will see the bootstrap marker and redirect) */
reAuth() {
window.location.reload();
},
Comment thread
lissy93 marked this conversation as resolved.
Outdated
sectionSelected(index) {
this.selectedSection = index;
},
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);
});
});