mirror of
https://github.com/Lissy93/dashy.git
synced 2026-06-02 06:44:51 -04:00
170 lines
6.3 KiB
JavaScript
170 lines
6.3 KiB
JavaScript
/**
|
|
* Server-side OIDC / Keycloak token verification.
|
|
*
|
|
* Why this exists: Dashy's OIDC + Keycloak flows are otherwise entirely
|
|
* client-side, so the server never sees an identity. This module verifies the
|
|
* id_token a logged-in browser attaches to API requests, so admin-gated
|
|
* endpoints (and authenticated endpoints generally) can enforce auth server-side.
|
|
*
|
|
* The middleware is permissive on a missing Authorization header — bootstrap
|
|
* fetches (e.g. /conf.yml) must succeed before the user is logged in. Endpoints
|
|
* that require auth should be paired with `requireAuth` (in app.js).
|
|
* A present-but-invalid token is always rejected.
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const yaml = require('js-yaml');
|
|
const { createRemoteJWKSet, jwtVerify } = require('jose');
|
|
|
|
/* Normalise the OIDC / Keycloak block from conf.yml into a unified shape.
|
|
Returns null when neither provider is enabled or required fields are missing. */
|
|
function loadOidcSettings(authConfig) {
|
|
if (!authConfig || typeof authConfig !== 'object') return null;
|
|
|
|
if (authConfig.enableOidc && authConfig.oidc) {
|
|
const { endpoint, clientId, adminGroup, adminRole } = authConfig.oidc;
|
|
if (!endpoint || !clientId) return null;
|
|
return {
|
|
kind: 'oidc',
|
|
issuer: String(endpoint),
|
|
clientId: String(clientId),
|
|
adminGroup: adminGroup || null,
|
|
adminRole: adminRole || null,
|
|
};
|
|
}
|
|
|
|
if (authConfig.enableKeycloak && authConfig.keycloak) {
|
|
const {
|
|
serverUrl, realm, clientId, adminGroup, adminRole, legacySupport,
|
|
} = authConfig.keycloak;
|
|
if (!serverUrl || !realm || !clientId) return null;
|
|
// Mirror the URL keycloak-js builds in the browser.
|
|
const base = (legacySupport ? `${serverUrl}/auth` : serverUrl).replace(/\/$/, '');
|
|
return {
|
|
kind: 'keycloak',
|
|
issuer: `${base}/realms/${realm}`,
|
|
clientId: String(clientId),
|
|
adminGroup: adminGroup || null,
|
|
adminRole: adminRole || null,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/* Per-issuer cache: discovery metadata + JWKS resolver. createRemoteJWKSet
|
|
handles key caching + rotation internally. */
|
|
const issuerCache = new Map();
|
|
|
|
async function fetchDiscovery(issuer) {
|
|
const base = issuer.endsWith('/') ? issuer : `${issuer}/`;
|
|
const url = new URL('.well-known/openid-configuration', base);
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error(`OIDC discovery returned ${res.status} for ${url}`);
|
|
return res.json();
|
|
}
|
|
|
|
async function getIssuerContext(issuer) {
|
|
const cached = issuerCache.get(issuer);
|
|
if (cached) return cached;
|
|
const config = await fetchDiscovery(issuer);
|
|
if (!config.issuer || !config.jwks_uri) {
|
|
throw new Error('Discovery document is missing `issuer` or `jwks_uri`');
|
|
}
|
|
const ctx = {
|
|
canonicalIssuer: config.issuer,
|
|
jwks: createRemoteJWKSet(new URL(config.jwks_uri)),
|
|
};
|
|
issuerCache.set(issuer, ctx);
|
|
return ctx;
|
|
}
|
|
|
|
/* Pure helper: true when the token's claims map to the configured admin group/role.
|
|
Handles standard OIDC top-level `groups`/`roles` claims plus Keycloak's nested
|
|
realm_access / resource_access role shapes (mirrors what KeycloakAuth.js does
|
|
client-side). */
|
|
function deriveIsAdmin(claims, settings) {
|
|
if (!claims) return false;
|
|
const groups = Array.isArray(claims.groups) ? [...claims.groups] : [];
|
|
const roles = Array.isArray(claims.roles) ? [...claims.roles] : [];
|
|
|
|
if (settings.kind === 'keycloak') {
|
|
const realmRoles = claims.realm_access && claims.realm_access.roles;
|
|
if (Array.isArray(realmRoles)) roles.push(...realmRoles);
|
|
const clientRoles = claims.resource_access
|
|
&& claims.resource_access[settings.clientId]
|
|
&& claims.resource_access[settings.clientId].roles;
|
|
if (Array.isArray(clientRoles)) roles.push(...clientRoles);
|
|
}
|
|
|
|
const { adminGroup, adminRole } = settings;
|
|
if (adminGroup && groups.includes(adminGroup)) return true;
|
|
if (adminRole && roles.includes(adminRole)) return true;
|
|
return false;
|
|
}
|
|
|
|
/* 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(); // No token attached, let downstream gates decide
|
|
const token = match[1].trim();
|
|
if (!token) return next();
|
|
|
|
try {
|
|
const { canonicalIssuer, jwks } = await getIssuerContext(settings.issuer);
|
|
const { payload } = await jwtVerify(token, jwks, {
|
|
issuer: canonicalIssuer,
|
|
audience: settings.clientId,
|
|
clockTolerance: '30s',
|
|
});
|
|
req.auth = {
|
|
user: payload.preferred_username || payload.email || payload.sub || 'unknown',
|
|
isAdmin: deriveIsAdmin(payload, settings),
|
|
claims: payload,
|
|
};
|
|
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',
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
/* Prevent unauthenticated access to config, by making stripped version of conf.yml
|
|
* 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,
|
|
},
|
|
pageInfo: { title: `Login | ${full.pageInfo?.title || 'Dashy'}` },
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
loadOidcSettings,
|
|
createOidcMiddleware,
|
|
deriveIsAdmin,
|
|
maybeBootstrapConfig,
|
|
};
|