kyush-llm-router/server/src/routes/admin-auth.ts

304 lines
9.6 KiB
TypeScript

import { Router, Request, Response } from 'express';
import { AdminPrincipal, AdminSessionResponse } from '../../../shared/types';
import { AdminApiTokenModel } from '../models/AdminApiToken';
import { AdminSessionModel } from '../models/AdminSession';
import {
getAdminApiTokenTtlDays,
getAdminAuthMode,
getAdminSessionTtlHours,
getAdminUsername,
getAllowedOidcEmails,
getOidcConfig,
isEnvAdminEnabled,
isOidcEnabled,
} from '../config/admin-auth';
import { AdminRequest, requireAdminAccess, requireSessionCsrf, resolveAdminAuth } from '../utils/adminAuth';
import {
clearAdminSessionCookie,
createCsrfToken,
generateOpaqueToken,
hashAdminToken,
issueAdminSessionCookie,
tokenPrefix,
verifyAdminPassword,
} from '../utils/adminSecurity';
const router: Router = Router();
const oidcStateStore = new Map<string, { next: string; expiresAt: number }>();
function isSafeNextPath(value?: string): string {
if (!value || value === '/') {
return '/dashboard';
}
if (!value.startsWith('/') || value.startsWith('//')) {
return '/dashboard';
}
if (value.startsWith('/admin/') || value === '/admin') {
return '/dashboard';
}
if (value === '/dashboard' || value.startsWith('/dashboard/')) {
return value;
}
return '/dashboard';
}
function buildSessionResponse(req: AdminRequest): AdminSessionResponse {
return {
authenticated: !!req.adminAuth,
authMode: getAdminAuthMode(),
csrfToken: req.adminAuth?.method === 'session' ? req.adminAuth.csrfToken ?? null : null,
principal: req.adminAuth?.principal ?? null,
};
}
function createAdminSession(res: Response, principal: AdminPrincipal): AdminSessionResponse {
const sessionToken = generateOpaqueToken('adm_sess');
const csrfToken = createCsrfToken();
const ttlHours = getAdminSessionTtlHours();
const expiresAt = new Date(Date.now() + ttlHours * 60 * 60 * 1000).toISOString();
AdminSessionModel.create({
sessionTokenHash: hashAdminToken(sessionToken),
principal,
csrfToken,
expiresAt,
});
issueAdminSessionCookie(res, sessionToken, ttlHours * 60 * 60 * 1000);
return {
authenticated: true,
authMode: getAdminAuthMode(),
csrfToken,
principal,
};
}
router.get('/session', (req: AdminRequest, res: Response) => {
resolveAdminAuth(req);
res.json(buildSessionResponse(req));
});
router.post('/login', (req: Request, res: Response) => {
if (!isEnvAdminEnabled()) {
res.status(404).json({ error: 'ENV admin login is disabled' });
return;
}
const { username, password } = req.body as { username?: string; password?: string };
const configuredUsername = getAdminUsername();
if (!configuredUsername || !username || !password) {
res.status(401).json({ error: 'Invalid admin credentials' });
return;
}
if (username !== configuredUsername || !verifyAdminPassword(password)) {
res.status(401).json({ error: 'Invalid admin credentials' });
return;
}
const principal: AdminPrincipal = {
provider: 'env',
subject: `env:${configuredUsername}`,
username: configuredUsername,
displayName: configuredUsername,
};
res.json(createAdminSession(res, principal));
});
router.post('/logout', requireAdminAccess, requireSessionCsrf, (req: AdminRequest, res: Response) => {
if (req.adminAuth?.sessionId) {
AdminSessionModel.revoke(req.adminAuth.sessionId);
}
clearAdminSessionCookie(res);
res.status(204).send();
});
router.get('/oidc/start', async (req: Request, res: Response) => {
if (!isOidcEnabled()) {
res.status(404).json({ error: 'OIDC is disabled' });
return;
}
const oidc = getOidcConfig();
if (!oidc.issuerUrl || !oidc.clientId || !oidc.redirectUri) {
res.status(500).json({ error: 'OIDC is not configured' });
return;
}
const state = generateOpaqueToken('oidc_state');
const next = isSafeNextPath(typeof req.query.next === 'string' ? req.query.next : '/dashboard');
oidcStateStore.set(state, { next, expiresAt: Date.now() + 10 * 60 * 1000 });
try {
const discoveryResponse = await fetch(`${oidc.issuerUrl.replace(/\/$/, '')}/.well-known/openid-configuration`);
if (!discoveryResponse.ok) {
throw new Error('Failed to load OIDC discovery document');
}
const discovery = await discoveryResponse.json() as { authorization_endpoint: string };
const redirect = new URL(discovery.authorization_endpoint);
redirect.searchParams.set('client_id', oidc.clientId);
redirect.searchParams.set('response_type', 'code');
redirect.searchParams.set('scope', oidc.scopes);
redirect.searchParams.set('redirect_uri', oidc.redirectUri);
redirect.searchParams.set('state', state);
res.redirect(redirect.toString());
} catch (error) {
oidcStateStore.delete(state);
res.status(502).json({ error: error instanceof Error ? error.message : 'OIDC discovery failed' });
}
});
router.get('/oidc/callback', async (req: Request, res: Response) => {
if (!isOidcEnabled()) {
res.status(404).json({ error: 'OIDC is disabled' });
return;
}
const state = typeof req.query.state === 'string' ? req.query.state : '';
const code = typeof req.query.code === 'string' ? req.query.code : '';
const stateRecord = oidcStateStore.get(state);
oidcStateStore.delete(state);
if (!stateRecord || stateRecord.expiresAt < Date.now() || !code) {
res.status(400).json({ error: 'Invalid OIDC callback state' });
return;
}
const oidc = getOidcConfig();
try {
const discoveryResponse = await fetch(`${oidc.issuerUrl.replace(/\/$/, '')}/.well-known/openid-configuration`);
if (!discoveryResponse.ok) {
throw new Error('Failed to load OIDC discovery document');
}
const discovery = await discoveryResponse.json() as {
token_endpoint: string;
userinfo_endpoint?: string;
};
const tokenResponse = await fetch(discovery.token_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: oidc.clientId,
client_secret: oidc.clientSecret,
redirect_uri: oidc.redirectUri,
}),
});
if (!tokenResponse.ok) {
throw new Error('Failed to exchange OIDC authorization code');
}
const tokenPayload = await tokenResponse.json() as { access_token?: string; id_token?: string };
let email = '';
let subject = '';
let displayName = '';
if (discovery.userinfo_endpoint && tokenPayload.access_token) {
const userInfoResponse = await fetch(discovery.userinfo_endpoint, {
headers: { Authorization: `Bearer ${tokenPayload.access_token}` },
});
if (userInfoResponse.ok) {
const userInfo = await userInfoResponse.json() as {
email?: string;
sub?: string;
name?: string;
preferred_username?: string;
};
email = userInfo.email ?? '';
subject = userInfo.sub ?? '';
displayName = userInfo.name ?? userInfo.preferred_username ?? email ?? subject;
}
}
if ((!email || !subject) && tokenPayload.id_token) {
const parts = tokenPayload.id_token.split('.');
if (parts.length >= 2) {
const claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) as {
email?: string;
sub?: string;
name?: string;
preferred_username?: string;
};
email = email || claims.email || '';
subject = subject || claims.sub || '';
displayName = displayName || claims.name || claims.preferred_username || email || subject;
}
}
const normalizedEmail = email.toLowerCase();
if (!normalizedEmail || !subject || !getAllowedOidcEmails().includes(normalizedEmail)) {
res.status(403).json({ error: 'OIDC account is not allowed for admin access' });
return;
}
const principal: AdminPrincipal = {
provider: 'oidc',
subject: `oidc:${oidc.issuerUrl}:${subject}`,
email: normalizedEmail,
displayName: displayName || normalizedEmail,
};
createAdminSession(res, principal);
res.redirect(stateRecord.next);
} catch (error) {
res.status(502).json({ error: error instanceof Error ? error.message : 'OIDC authentication failed' });
}
});
router.get('/tokens', requireAdminAccess, (req: AdminRequest, res: Response) => {
res.json(AdminApiTokenModel.listBySubject(req.adminAuth!.principal.subject));
});
router.post('/tokens', requireAdminAccess, requireSessionCsrf, (req: AdminRequest, res: Response) => {
const { name, expiresInDays } = req.body as { name?: string; expiresInDays?: number };
const trimmedName = name?.trim();
if (!trimmedName) {
res.status(400).json({ error: 'Token name is required' });
return;
}
const ttlDays = Number.isFinite(expiresInDays) && Number(expiresInDays) > 0
? Number(expiresInDays)
: getAdminApiTokenTtlDays();
const token = generateOpaqueToken('adm_tok');
const record = AdminApiTokenModel.create({
tokenHash: hashAdminToken(token),
tokenPrefix: tokenPrefix(token),
name: trimmedName,
principal: req.adminAuth!.principal,
expiresAt: new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000).toISOString(),
});
res.status(201).json({ token, record });
});
router.delete('/tokens/:id', requireAdminAccess, requireSessionCsrf, (req: AdminRequest, res: Response) => {
const tokenId = Number(req.params.id);
if (!Number.isFinite(tokenId)) {
res.status(400).json({ error: 'Invalid token id' });
return;
}
const success = AdminApiTokenModel.revokeForSubject(tokenId, req.adminAuth!.principal.subject);
if (!success) {
res.status(404).json({ error: 'Admin API token not found' });
return;
}
res.status(204).send();
});
export default router;