All checks were successful
deploy / deploy (push) Successful in 22s
Proof: Include operator dashboard auth module and runtime package manifest updates required for the deployed operator-dashboard and ops-sentinel processes to start successfully. Assumptions: React and ws belong in production dependencies because operator-dashboard now ships in the runtime image; operator-dashboard-auth is part of the deployed backend surface. Still fake: External alert receiver remains unconfigured; this commit only fixes rollout blockers for the runtime-health/dashboard deploy.
149 lines
3.8 KiB
JavaScript
149 lines
3.8 KiB
JavaScript
import crypto from 'node:crypto';
|
|
|
|
const DEFAULT_SESSION_COOKIE_NAME = 'operator_dashboard_session';
|
|
|
|
export function resolveDashboardRequestAuth({
|
|
mode = 'stub',
|
|
authorizationHeader = '',
|
|
cookieHeader = '',
|
|
username = 'admin',
|
|
password = '',
|
|
sessionCookieName = DEFAULT_SESSION_COOKIE_NAME,
|
|
} = {}) {
|
|
if (mode === 'stub') {
|
|
return {
|
|
authenticated: true,
|
|
subject: 'local-operator',
|
|
mode,
|
|
roles: ['operator'],
|
|
via: 'stub',
|
|
setSessionCookie: false,
|
|
sessionCookieName,
|
|
sessionToken: buildDashboardSessionToken({ username, password }),
|
|
};
|
|
}
|
|
|
|
if (mode !== 'basic') {
|
|
return {
|
|
authenticated: false,
|
|
subject: null,
|
|
mode,
|
|
roles: [],
|
|
via: 'unsupported',
|
|
failure_reason: 'unsupported_auth_mode',
|
|
setSessionCookie: false,
|
|
sessionCookieName,
|
|
sessionToken: buildDashboardSessionToken({ username, password }),
|
|
};
|
|
}
|
|
|
|
const sessionToken = buildDashboardSessionToken({ username, password });
|
|
const cookies = parseCookieHeader(cookieHeader);
|
|
if (cookies[sessionCookieName] && cookies[sessionCookieName] === sessionToken) {
|
|
return {
|
|
authenticated: true,
|
|
subject: username,
|
|
mode,
|
|
roles: ['operator'],
|
|
via: 'session_cookie',
|
|
setSessionCookie: false,
|
|
sessionCookieName,
|
|
sessionToken,
|
|
};
|
|
}
|
|
|
|
const basic = parseBasicAuthorizationHeader(authorizationHeader);
|
|
if (!basic) {
|
|
return {
|
|
authenticated: false,
|
|
subject: null,
|
|
mode,
|
|
roles: [],
|
|
via: 'missing',
|
|
failure_reason: 'missing_basic_auth',
|
|
setSessionCookie: false,
|
|
sessionCookieName,
|
|
sessionToken,
|
|
};
|
|
}
|
|
|
|
if (basic.username !== username || basic.password !== password) {
|
|
return {
|
|
authenticated: false,
|
|
subject: null,
|
|
mode,
|
|
roles: [],
|
|
via: 'basic_auth',
|
|
failure_reason: 'invalid_credentials',
|
|
setSessionCookie: false,
|
|
sessionCookieName,
|
|
sessionToken,
|
|
};
|
|
}
|
|
|
|
return {
|
|
authenticated: true,
|
|
subject: username,
|
|
mode,
|
|
roles: ['operator'],
|
|
via: 'basic_auth',
|
|
setSessionCookie: cookies[sessionCookieName] !== sessionToken,
|
|
sessionCookieName,
|
|
sessionToken,
|
|
};
|
|
}
|
|
|
|
export function buildDashboardSessionToken({ username = '', password = '' } = {}) {
|
|
return crypto
|
|
.createHash('sha256')
|
|
.update(`${username}:${password}`)
|
|
.digest('hex');
|
|
}
|
|
|
|
export function buildDashboardSessionCookie({
|
|
sessionCookieName = DEFAULT_SESSION_COOKIE_NAME,
|
|
sessionToken,
|
|
} = {}) {
|
|
return `${sessionCookieName}=${sessionToken}; Path=/; HttpOnly; SameSite=Lax`;
|
|
}
|
|
|
|
export function buildDashboardAuthChallengeHeader({ realm = 'unrip operator dashboard' } = {}) {
|
|
return `Basic realm="${realm}"`;
|
|
}
|
|
|
|
export function parseBasicAuthorizationHeader(value = '') {
|
|
if (!value || typeof value !== 'string') return null;
|
|
const [scheme, token] = value.split(/\s+/, 2);
|
|
if (!scheme || !token || scheme.toLowerCase() !== 'basic') return null;
|
|
|
|
try {
|
|
const decoded = Buffer.from(token, 'base64').toString('utf8');
|
|
const separatorIndex = decoded.indexOf(':');
|
|
if (separatorIndex === -1) return null;
|
|
return {
|
|
username: decoded.slice(0, separatorIndex),
|
|
password: decoded.slice(separatorIndex + 1),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseCookieHeader(value = '') {
|
|
if (!value || typeof value !== 'string') return {};
|
|
|
|
return Object.fromEntries(
|
|
value
|
|
.split(';')
|
|
.map((part) => part.trim())
|
|
.filter(Boolean)
|
|
.map((part) => {
|
|
const separatorIndex = part.indexOf('=');
|
|
if (separatorIndex === -1) return [part, ''];
|
|
return [
|
|
part.slice(0, separatorIndex).trim(),
|
|
decodeURIComponent(part.slice(separatorIndex + 1).trim()),
|
|
];
|
|
}),
|
|
);
|
|
}
|