Something went wrong. Try again.
AtAuth
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129/** * Admin Routes * * Application registration and management endpoints. * Now includes OIDC client, session, and key management. * Express 5 automatically forwards async errors to the error handler. */
import crypto from 'crypto';import { Router, Request, Response, NextFunction } from 'express';import { DatabaseService } from '../services/database.js';import { generateHmacSecret } from '../utils/hmac.js';import { httpError } from '../utils/errors.js';import { checkAccess } from '../utils/access-check.js';import { parseCookies, ADMIN_COOKIE_NAME, createAdminCookie, verifyAdminCookie } from '../utils/proxy-auth.js';import { createAdminDashboardRoutes } from './admin-dashboard.js';import type { OIDCService } from '../services/oidc/index.js';import type { PasskeyService } from '../services/passkey.js';import type { MFAService } from '../services/mfa.js';
function clientIp(req: Request): string { return (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || req.ip || 'unknown';}
/** * Constant-time string comparison to prevent timing attacks. */function secureCompare(a: string, b: string): boolean { const bufA = Buffer.from(a); const bufB = Buffer.from(b);
// Prevent length-based timing attacks by always comparing fixed-length hashes const hashA = crypto.createHash('sha256').update(bufA).digest(); const hashB = crypto.createHash('sha256').update(bufB).digest();
return crypto.timingSafeEqual(hashA, hashB);}
export function createAdminRoutes( db: DatabaseService, adminToken?: string, oidcService?: OIDCService | null, passkeyService?: PasskeyService | null, mfaService?: MFAService | null, sessionSecret?: string,): Router { const router = Router();
const ADMIN_COOKIE_TTL = 86400; // 24 hours
/** * Admin authentication middleware. * Accepts EITHER a Bearer token in the Authorization header * OR a valid _atauth_admin cookie (for dashboard sessions). */ const requireAdmin = (req: Request, res: Response, next: NextFunction) => { if (!adminToken) { throw httpError.forbidden('admin_disabled', 'Admin endpoints are disabled (set ADMIN_TOKEN)'); }
// Check Bearer token first const authHeader = req.headers.authorization; if (authHeader && authHeader.startsWith('Bearer ')) { const token = authHeader.substring(7); if (!secureCompare(token, adminToken)) { throw httpError.forbidden('invalid_token', 'Invalid admin token'); } return next(); }
// Check admin cookie if (sessionSecret) { const cookies = parseCookies(req.headers.cookie); const adminCookieValue = cookies[ADMIN_COOKIE_NAME]; if (adminCookieValue && verifyAdminCookie(adminCookieValue, sessionSecret)) { return next(); } }
// If this looks like a browser request (Accept: text/html), redirect to login if (req.accepts('html') && !req.accepts('json')) { return res.redirect('/admin/login'); }
throw httpError.unauthorized('missing_auth', 'Authorization required'); };
// ===== Admin Login/Logout (no auth required) =====
/** * GET /admin/login * Render the admin login page. */ router.get('/login', (_req: Request, res: Response) => { if (!adminToken) { return res.status(403).send('Admin endpoints are disabled'); }
res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.send(renderLoginPage()); });
/** * POST /admin/login * Validate admin token and set session cookie. */ router.post('/login', (req: Request, res: Response) => { if (!adminToken) { throw httpError.forbidden('admin_disabled', 'Admin endpoints are disabled'); }
const { token } = req.body; if (!token || !secureCompare(token, adminToken)) { res.setHeader('Content-Type', 'text/html; charset=utf-8'); return res.status(401).send(renderLoginPage('Invalid admin token')); }
if (!sessionSecret) { throw httpError.internalServerError('config_error', 'Session secret not configured'); }
const cookieValue = createAdminCookie(sessionSecret, ADMIN_COOKIE_TTL); res.setHeader('Set-Cookie', `${ADMIN_COOKIE_NAME}=${cookieValue}; Path=/admin; HttpOnly; Secure; SameSite=Strict; Max-Age=${ADMIN_COOKIE_TTL}`); res.redirect('/admin/dashboard'); });
/** * GET /admin/logout * Clear admin session cookie and redirect to login. */ router.get('/logout', (_req: Request, res: Response) => { res.setHeader('Set-Cookie', `${ADMIN_COOKIE_NAME}=; Path=/admin; HttpOnly; Secure; SameSite=Strict; Max-Age=0`); res.redirect('/admin/login'); });
// ===== Dashboard (server-rendered HTML) ===== if (sessionSecret) { const dashboardRouter = createAdminDashboardRoutes(db, sessionSecret); router.use('/dashboard', requireAdmin, dashboardRouter); }
/** * POST /admin/apps * Register a new application * * Body: * - id: Unique app identifier * - name: Display name * - token_ttl_seconds: Token lifetime (default 3600) * - callback_url: OAuth callback URL */ router.post('/apps', requireAdmin, async (req: Request, res: Response) => { const { id, name, token_ttl_seconds, callback_url } = req.body;
if (!id || !name) { throw httpError.badRequest('missing_params', 'id and name are required'); }
const existing = db.getApp(id); if (existing) { throw httpError.conflict('app_exists', `Application '${id}' already exists`); }
const hmac_secret = generateHmacSecret();
db.upsertApp({ id, name, hmac_secret, token_ttl_seconds: token_ttl_seconds || 3600, callback_url, });
db.logAuditEvent('app.create', 'admin', id, `Created app "${name}"`, clientIp(req));
res.status(201).json({ id, name, hmac_secret, token_ttl_seconds: token_ttl_seconds || 3600, callback_url, message: 'Application registered. Store the hmac_secret securely!', }); });
/** * GET /admin/apps/:id * Get application configuration (without secret) */ router.get('/apps/:id', requireAdmin, async (req: Request, res: Response) => { const id = String(req.params.id); const app = db.getApp(id); if (!app) { throw httpError.notFound('app_not_found', `Application '${id}' not found`); }
res.json({ id: app.id, name: app.name, token_ttl_seconds: app.token_ttl_seconds, callback_url: app.callback_url, }); });
/** * PUT /admin/apps/:id * Update application configuration */ router.put('/apps/:id', requireAdmin, async (req: Request, res: Response) => { const id = String(req.params.id); const existing = db.getApp(id); if (!existing) { throw httpError.notFound('app_not_found', `Application '${id}' not found`); }
const { name, token_ttl_seconds, callback_url, rotate_secret } = req.body;
const updated = { id, name: name || existing.name, hmac_secret: rotate_secret ? generateHmacSecret() : existing.hmac_secret, token_ttl_seconds: token_ttl_seconds || existing.token_ttl_seconds, callback_url: callback_url !== undefined ? callback_url : existing.callback_url, };
db.upsertApp(updated);
if (rotate_secret) { db.logAuditEvent('app.secret_rotate', 'admin', id, 'HMAC secret rotated', clientIp(req)); } else { db.logAuditEvent('app.update', 'admin', id, `Updated app config`, clientIp(req)); }
const response: Record<string, unknown> = { id: updated.id, name: updated.name, token_ttl_seconds: updated.token_ttl_seconds, callback_url: updated.callback_url, };
if (rotate_secret) { response.hmac_secret = updated.hmac_secret; response.message = 'Secret rotated. Update your backend configuration!'; }
res.json(response); });
/** * POST /admin/cleanup * Clean up expired sessions and OAuth states */ router.post('/cleanup', requireAdmin, async (_req: Request, res: Response) => { const statesDeleted = db.cleanupOldOAuthStates(); const sessionsDeleted = db.cleanupExpiredSessions(); const authCodesDeleted = db.cleanupExpiredAuthorizationCodes(); const refreshTokensDeleted = db.cleanupExpiredRefreshTokens(); const emailCodesDeleted = db.cleanupExpiredEmailVerificationCodes();
res.json({ oauth_states_deleted: statesDeleted, sessions_deleted: sessionsDeleted, authorization_codes_deleted: authCodesDeleted, refresh_tokens_deleted: refreshTokensDeleted, email_codes_deleted: emailCodesDeleted, }); });
// ===== OIDC Client Management =====
/** * GET /admin/oidc/clients * List all OIDC clients */ router.get('/oidc/clients', requireAdmin, async (_req: Request, res: Response) => { const clients = db.getAllOIDCClients(); res.json({ clients: clients.map(c => ({ id: c.id, name: c.name, client_type: c.client_type, redirect_uris: c.redirect_uris, grant_types: c.grant_types, allowed_scopes: c.allowed_scopes, require_pkce: c.require_pkce, token_endpoint_auth_method: c.token_endpoint_auth_method, created_at: c.created_at.toISOString(), })), }); });
/** * POST /admin/oidc/clients * Create a new OIDC client */ router.post('/oidc/clients', requireAdmin, async (req: Request, res: Response) => { const { id, name, redirect_uris, grant_types, allowed_scopes, require_pkce, token_endpoint_auth_method, id_token_ttl_seconds, access_token_ttl_seconds, refresh_token_ttl_seconds, } = req.body;
if (!id || !name) { throw httpError.badRequest('missing_params', 'id and name are required'); }
const existing = db.getOIDCClient(id); if (existing) { throw httpError.conflict('client_exists', `OIDC client '${id}' already exists`); }
// Generate client secret const clientSecret = crypto.randomBytes(32).toString('hex'); const clientSecretHash = crypto.createHash('sha256').update(clientSecret).digest('hex');
// Create app entry with OIDC configuration db.upsertApp({ id, name, hmac_secret: generateHmacSecret(), token_ttl_seconds: access_token_ttl_seconds || 3600, callback_url: redirect_uris?.[0], });
// Update with OIDC-specific fields db.updateOIDCClient(id, { client_type: 'oidc', client_secret: clientSecretHash, redirect_uris: redirect_uris || [], grant_types: grant_types || ['authorization_code'], allowed_scopes: allowed_scopes || ['openid'], require_pkce: require_pkce ?? true, token_endpoint_auth_method: token_endpoint_auth_method || 'client_secret_basic', id_token_ttl_seconds: id_token_ttl_seconds || 3600, access_token_ttl_seconds: access_token_ttl_seconds || 3600, refresh_token_ttl_seconds: refresh_token_ttl_seconds || 604800, });
db.logAuditEvent('oidc_client.create', 'admin', id, `Created OIDC client "${name}"`, clientIp(req));
res.status(201).json({ id, name, client_secret: clientSecret, // Only shown once! redirect_uris: redirect_uris || [], grant_types: grant_types || ['authorization_code'], allowed_scopes: allowed_scopes || ['openid'], require_pkce: require_pkce ?? true, message: 'OIDC client created. Store the client_secret securely!', }); });
/** * GET /admin/oidc/clients/:id * Get OIDC client details */ router.get('/oidc/clients/:id', requireAdmin, async (req: Request, res: Response) => { const id = String(req.params.id); const client = db.getOIDCClient(id); if (!client) { throw httpError.notFound('client_not_found', `OIDC client '${id}' not found`); }
res.json({ id: client.id, name: client.name, client_type: client.client_type, redirect_uris: client.redirect_uris, grant_types: client.grant_types, allowed_scopes: client.allowed_scopes, require_pkce: client.require_pkce, token_endpoint_auth_method: client.token_endpoint_auth_method, id_token_ttl_seconds: client.id_token_ttl_seconds, access_token_ttl_seconds: client.access_token_ttl_seconds, refresh_token_ttl_seconds: client.refresh_token_ttl_seconds, created_at: client.created_at.toISOString(), }); });
/** * PUT /admin/oidc/clients/:id * Update OIDC client */ router.put('/oidc/clients/:id', requireAdmin, async (req: Request, res: Response) => { const id = String(req.params.id); const existing = db.getOIDCClient(id); if (!existing) { throw httpError.notFound('client_not_found', `OIDC client '${id}' not found`); }
const { name, redirect_uris, grant_types, allowed_scopes, require_pkce, token_endpoint_auth_method, id_token_ttl_seconds, access_token_ttl_seconds, refresh_token_ttl_seconds, } = req.body;
db.updateOIDCClient(id, { redirect_uris: redirect_uris ?? existing.redirect_uris, grant_types: grant_types ?? existing.grant_types, allowed_scopes: allowed_scopes ?? existing.allowed_scopes, require_pkce: require_pkce ?? existing.require_pkce, token_endpoint_auth_method: token_endpoint_auth_method ?? existing.token_endpoint_auth_method, id_token_ttl_seconds: id_token_ttl_seconds ?? existing.id_token_ttl_seconds, access_token_ttl_seconds: access_token_ttl_seconds ?? existing.access_token_ttl_seconds, refresh_token_ttl_seconds: refresh_token_ttl_seconds ?? existing.refresh_token_ttl_seconds, });
// Update app name if provided if (name) { const app = db.getApp(id); if (app) { db.upsertApp({ ...app, name }); } }
db.logAuditEvent('oidc_client.update', 'admin', id, 'Updated OIDC client config', clientIp(req));
res.json({ id, message: 'OIDC client updated', }); });
/** * DELETE /admin/oidc/clients/:id * Delete OIDC client */ router.delete('/oidc/clients/:id', requireAdmin, async (req: Request, res: Response) => { const id = String(req.params.id); const existing = db.getOIDCClient(id); if (!existing) { throw httpError.notFound('client_not_found', `OIDC client '${id}' not found`); }
db.deleteApp(id); db.logAuditEvent('oidc_client.delete', 'admin', id, `Deleted OIDC client "${existing.name}"`, clientIp(req));
res.json({ message: 'OIDC client deleted', }); });
/** * POST /admin/oidc/clients/:id/rotate-secret * Rotate OIDC client secret */ router.post('/oidc/clients/:id/rotate-secret', requireAdmin, async (req: Request, res: Response) => { const id = String(req.params.id); const existing = db.getOIDCClient(id); if (!existing) { throw httpError.notFound('client_not_found', `OIDC client '${id}' not found`); }
const clientSecret = crypto.randomBytes(32).toString('hex'); const clientSecretHash = crypto.createHash('sha256').update(clientSecret).digest('hex');
db.updateOIDCClientSecret(id, clientSecretHash); db.logAuditEvent('oidc_client.secret_rotate', 'admin', id, 'Client secret rotated', clientIp(req));
res.json({ id, client_secret: clientSecret, message: 'Client secret rotated. Update your application configuration!', }); });
// ===== Session Management =====
/** * GET /admin/sessions * List active sessions with optional filters */ router.get('/sessions', requireAdmin, async (req: Request, res: Response) => { const { app_id, did, limit } = req.query; const sessions = db.getAllActiveSessions( app_id as string | undefined, did as string | undefined, limit ? parseInt(limit as string, 10) : 100 );
res.json({ sessions: sessions.map(s => ({ id: s.id, did: s.did, handle: s.handle, app_id: s.app_id, created_at: s.created_at.toISOString(), expires_at: s.expires_at.toISOString(), connection_state: s.connection_state, last_activity: s.last_activity?.toISOString(), })), }); });
/** * DELETE /admin/sessions/:id * Revoke a specific session */ router.delete('/sessions/:id', requireAdmin, async (req: Request, res: Response) => { const id = String(req.params.id); db.deleteSession(id); db.logAuditEvent('session.revoke', 'admin', id, 'Session revoked', clientIp(req)); res.json({ message: 'Session revoked' }); });
/** * POST /admin/sessions/revoke-all * Revoke all sessions for a user/app combination */ router.post('/sessions/revoke-all', requireAdmin, async (req: Request, res: Response) => { const { did, app_id } = req.body; if (!did) { throw httpError.badRequest('missing_params', 'did is required'); }
const count = db.revokeAllSessionsForUser(did, app_id); db.logAuditEvent('session.revoke_all', 'admin', did, `Revoked ${count} sessions${app_id ? ` for app ${app_id}` : ''}`, clientIp(req)); res.json({ sessions_revoked: count, }); });
// ===== Key Management =====
/** * GET /admin/keys * List signing keys (if OIDC service is available) */ router.get('/keys', requireAdmin, async (_req: Request, res: Response) => { if (!oidcService) { throw httpError.badRequest('oidc_disabled', 'OIDC is not enabled'); }
const keys = db.getActiveOIDCKeys(); res.json({ keys: keys.map(k => ({ kid: k.kid, algorithm: k.algorithm, is_active: k.is_active, use_for_signing: k.use_for_signing, created_at: k.created_at.toISOString(), expires_at: k.expires_at?.toISOString(), })), }); });
/** * POST /admin/keys/rotate * Rotate signing keys */ router.post('/keys/rotate', requireAdmin, async (req: Request, res: Response) => { if (!oidcService) { throw httpError.badRequest('oidc_disabled', 'OIDC is not enabled'); }
const { algorithm } = req.body; await oidcService.keyManager.rotateKeys(algorithm || 'ES256'); db.logAuditEvent('key.rotate', 'admin', undefined, `Rotated signing key (${algorithm || 'ES256'})`, clientIp(req));
res.json({ message: 'Signing key rotated', }); });
// ===== User Management =====
/** * GET /admin/users/:did * Get user details including MFA status and passkeys */ router.get('/users/:did', requireAdmin, async (req: Request, res: Response) => { const did = String(req.params.did);
const passkeys = passkeyService?.listPasskeys(did) || []; const mfaStatus = mfaService?.getMFAStatus(did, passkeys.length) || { totp_enabled: false, passkey_count: passkeys.length, backup_codes_remaining: 0, }; const emails = db.getUserEmails(did);
res.json({ did, mfa: mfaStatus, passkeys: passkeys.map(p => ({ id: p.id, name: p.name, device_type: p.device_type, backed_up: p.backed_up, created_at: p.created_at, last_used_at: p.last_used_at, })), emails: emails.map(e => ({ email: e.email, verified: e.verified, is_primary: e.is_primary, created_at: e.created_at.toISOString(), })), }); });
/** * DELETE /admin/users/:did/mfa * Reset MFA for a user (admin override) */ router.delete('/users/:did/mfa', requireAdmin, async (req: Request, res: Response) => { const did = String(req.params.did);
if (mfaService) { mfaService.disableTOTP(did); }
db.logAuditEvent('user.mfa_reset', 'admin', did, 'MFA disabled by admin', clientIp(req));
res.json({ message: 'MFA disabled for user', }); });
/** * DELETE /admin/users/:did/passkeys/:passkeyId * Delete a passkey for a user (admin override) */ router.delete('/users/:did/passkeys/:passkeyId', requireAdmin, async (req: Request, res: Response) => { const did = String(req.params.did); const passkeyId = String(req.params.passkeyId);
if (passkeyService) { const success = passkeyService.deletePasskey(did, passkeyId); if (!success) { throw httpError.notFound('passkey_not_found', 'Passkey not found'); } }
db.logAuditEvent('user.passkey_delete', 'admin', did, `Deleted passkey ${passkeyId}`, clientIp(req));
res.json({ message: 'Passkey deleted', }); });
// ===== Forward-Auth Proxy Management =====
/** * GET /admin/proxy/origins * List allowed origins for forward-auth */ router.get('/proxy/origins', requireAdmin, async (_req: Request, res: Response) => { const origins = db.listProxyAllowedOrigins(); res.json({ origins }); });
/** * POST /admin/proxy/origins * Add an allowed origin for forward-auth * * Body: * - origin: Full origin URL (e.g. "https://search.example.com") * - name: Display name (e.g. "SearXNG") */ router.post('/proxy/origins', requireAdmin, async (req: Request, res: Response) => { const { origin, name } = req.body;
if (!origin || !name) { throw httpError.badRequest('missing_params', 'origin and name are required'); }
// Validate origin format try { const parsed = new URL(origin); if (parsed.origin !== origin) { throw httpError.badRequest('invalid_origin', 'origin must be a valid URL origin (scheme://host[:port])'); } } catch (e) { if (e instanceof Error && 'statusCode' in e) throw e; throw httpError.badRequest('invalid_origin', 'origin must be a valid URL'); }
try { const created = db.addProxyAllowedOrigin(origin, name); db.logAuditEvent('proxy.origin_add', 'admin', origin, `Added origin "${name}"`, clientIp(req)); res.status(201).json(created); } catch (e) { const msg = e instanceof Error ? e.message : ''; if (msg.includes('UNIQUE constraint')) { throw httpError.conflict('origin_exists', `Origin '${origin}' already exists`); } throw e; } });
/** * DELETE /admin/proxy/origins/:id * Remove an allowed origin */ router.delete('/proxy/origins/:id', requireAdmin, async (req: Request, res: Response) => { const id = String(req.params.id); db.removeProxyAllowedOrigin(parseInt(id, 10)); db.logAuditEvent('proxy.origin_remove', 'admin', id, 'Removed proxy origin', clientIp(req)); res.json({ message: 'Origin removed' }); });
/** * GET /admin/proxy/sessions * List active proxy sessions */ router.get('/proxy/sessions', requireAdmin, async (req: Request, res: Response) => { const { did, limit } = req.query; const sessions = db.getAllProxySessions( did as string | undefined, limit ? parseInt(limit as string, 10) : 100, ); res.json({ sessions }); });
/** * DELETE /admin/proxy/sessions/:id * Revoke a proxy session */ router.delete('/proxy/sessions/:id', requireAdmin, async (req: Request, res: Response) => { const id = String(req.params.id); db.deleteProxySession(id); db.logAuditEvent('proxy.session_revoke', 'admin', id, 'Proxy session revoked', clientIp(req)); res.json({ message: 'Proxy session revoked' }); });
// ===== Forward-Auth Access Rules =====
/** * GET /admin/proxy/access * List access rules. Optional ?origin_id=N filter. */ router.get('/proxy/access', requireAdmin, async (req: Request, res: Response) => { const originIdParam = req.query.origin_id; let rules; if (originIdParam !== undefined) { const originId = parseInt(originIdParam as string, 10); rules = db.listProxyAccessRules(originId); } else { rules = db.listProxyAccessRules(); } res.json({ rules }); });
/** * POST /admin/proxy/access * Create an access rule. * * Body: * - origin_id: number | null (null = global rule) * - rule_type: "allow" | "deny" * - subject_type: "did" | "handle_pattern" * - subject_value: string (DID or pattern like "*.example.com") * - description: string (optional label) */ router.post('/proxy/access', requireAdmin, async (req: Request, res: Response) => { const { origin_id, rule_type, subject_type, subject_value, description } = req.body;
if (!rule_type || !subject_type || !subject_value) { throw httpError.badRequest('missing_params', 'rule_type, subject_type, and subject_value are required'); }
if (!['allow', 'deny'].includes(rule_type)) { throw httpError.badRequest('invalid_rule_type', 'rule_type must be "allow" or "deny"'); }
if (!['did', 'handle_pattern'].includes(subject_type)) { throw httpError.badRequest('invalid_subject_type', 'subject_type must be "did" or "handle_pattern"'); }
if (subject_type === 'did' && !subject_value.startsWith('did:')) { throw httpError.badRequest('invalid_did', 'DID must start with "did:"'); }
if (subject_type === 'handle_pattern') { if (subject_value !== '*' && !subject_value.match(/^(\*\.)?[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$/)) { throw httpError.badRequest('invalid_pattern', 'Handle pattern must be "*", "*.domain", or an exact handle'); } }
if (origin_id !== null && origin_id !== undefined) { const origins = db.listProxyAllowedOrigins(); if (!origins.some(o => o.id === origin_id)) { throw httpError.notFound('origin_not_found', `Origin with id ${origin_id} not found`); } }
const rule = db.createProxyAccessRule({ origin_id: origin_id ?? null, rule_type, subject_type, subject_value, description: description || null, });
db.logAuditEvent('proxy.access_rule_create', 'admin', String(rule.id), `Created ${rule_type} rule for ${subject_type}:${subject_value}`, clientIp(req));
res.status(201).json(rule); });
/** * DELETE /admin/proxy/access/:id * Delete an access rule. */ router.delete('/proxy/access/:id', requireAdmin, async (req: Request, res: Response) => { const id = String(req.params.id); db.deleteProxyAccessRule(parseInt(id, 10)); db.logAuditEvent('proxy.access_rule_delete', 'admin', id, 'Deleted access rule', clientIp(req)); res.json({ message: 'Access rule deleted' }); });
/** * POST /admin/proxy/access/check * Test if a DID/handle would be allowed for an origin. * Admin debugging tool -- does not modify state. */ router.post('/proxy/access/check', requireAdmin, async (req: Request, res: Response) => { const { did, handle, origin_id } = req.body;
if (!did || !handle || origin_id === undefined) { throw httpError.badRequest('missing_params', 'did, handle, and origin_id are required'); }
const rules = db.getProxyAccessRulesForCheck(origin_id); const totalRules = rules.denyRules.length + rules.originAllowRules.length + rules.globalAllowRules.length;
if (totalRules === 0) { return res.json({ allowed: true, matched_rule_id: null, reason: 'No access rules configured (open access)', }); }
const result = checkAccess(did, handle, rules); res.json(result); });
// ===== Client Access Rules =====
/** * GET /admin/clients/:clientId/access * List access rules for a client. */ router.get('/clients/:clientId/access', requireAdmin, async (req: Request, res: Response) => { const clientId = String(req.params.clientId); const client = db.getOIDCClient(clientId); if (!client) { throw httpError.notFound('client_not_found', `Client '${clientId}' not found`); } const rules = db.listClientAccessRules(clientId); res.json({ rules, require_access_check: client.require_access_check }); });
/** * POST /admin/clients/:clientId/access * Create an access rule for a client. * * Body: * - rule_type: "allow" | "deny" * - subject_type: "did" | "handle_pattern" * - subject_value: string (DID or pattern like "*.example.com") * - description: string (optional label) */ router.post('/clients/:clientId/access', requireAdmin, async (req: Request, res: Response) => { const clientId = String(req.params.clientId); const client = db.getOIDCClient(clientId); if (!client) { throw httpError.notFound('client_not_found', `Client '${clientId}' not found`); }
const { rule_type, subject_type, subject_value, description } = req.body;
if (!rule_type || !subject_type || !subject_value) { throw httpError.badRequest('missing_params', 'rule_type, subject_type, and subject_value are required'); }
if (!['allow', 'deny'].includes(rule_type)) { throw httpError.badRequest('invalid_rule_type', 'rule_type must be "allow" or "deny"'); }
if (!['did', 'handle_pattern'].includes(subject_type)) { throw httpError.badRequest('invalid_subject_type', 'subject_type must be "did" or "handle_pattern"'); }
if (subject_type === 'did' && !subject_value.startsWith('did:')) { throw httpError.badRequest('invalid_did', 'DID must start with "did:"'); }
if (subject_type === 'handle_pattern') { if (subject_value !== '*' && !subject_value.match(/^(\*\.)?[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$/)) { throw httpError.badRequest('invalid_pattern', 'Handle pattern must be "*", "*.domain", or an exact handle'); } }
const rule = db.createClientAccessRule({ client_id: clientId, rule_type, subject_type, subject_value, description: description || null, });
db.logAuditEvent('client.access_rule_create', 'admin', `${clientId}:${rule.id}`, `Created ${rule_type} rule for ${subject_type}:${subject_value}`, clientIp(req));
res.status(201).json(rule); });
/** * DELETE /admin/clients/:clientId/access/:ruleId * Delete an access rule for a client. */ router.delete('/clients/:clientId/access/:ruleId', requireAdmin, async (req: Request, res: Response) => { const clientId = String(req.params.clientId); const ruleId = String(req.params.ruleId); db.deleteClientAccessRule(parseInt(ruleId, 10)); db.logAuditEvent('client.access_rule_delete', 'admin', `${clientId}:${ruleId}`, 'Deleted client access rule', clientIp(req)); res.json({ message: 'Client access rule deleted' }); });
/** * POST /admin/clients/:clientId/access/check * Test if a DID/handle would be allowed for a client. */ router.post('/clients/:clientId/access/check', requireAdmin, async (req: Request, res: Response) => { const clientId = String(req.params.clientId); const client = db.getOIDCClient(clientId); if (!client) { throw httpError.notFound('client_not_found', `Client '${clientId}' not found`); }
const { did, handle } = req.body; if (!did || !handle) { throw httpError.badRequest('missing_params', 'did and handle are required'); }
if (!client.require_access_check) { return res.json({ allowed: true, matched_rule_id: null, reason: 'Access check not enabled for this client (open access)', }); }
const rules = db.getClientAccessRulesForCheck(clientId); const totalRules = rules.denyRules.length + rules.originAllowRules.length + rules.globalAllowRules.length;
if (totalRules === 0) { return res.json({ allowed: false, matched_rule_id: null, reason: 'Access check enabled but no rules configured (default deny)', }); }
const result = checkAccess(did, handle, rules); res.json(result); });
/** * PATCH /admin/clients/:clientId/access-check * Toggle require_access_check for a client. * * Body: * - enabled: boolean */ router.patch('/clients/:clientId/access-check', requireAdmin, async (req: Request, res: Response) => { const clientId = String(req.params.clientId); const client = db.getOIDCClient(clientId); if (!client) { throw httpError.notFound('client_not_found', `Client '${clientId}' not found`); }
const { enabled } = req.body; if (typeof enabled !== 'boolean') { throw httpError.badRequest('invalid_enabled', 'enabled must be a boolean'); }
db.setClientAccessCheck(clientId, enabled); db.logAuditEvent('client.access_check_toggle', 'admin', clientId, `Set require_access_check=${enabled}`, clientIp(req));
res.json({ message: `Access check ${enabled ? 'enabled' : 'disabled'} for ${clientId}`, require_access_check: enabled }); });
// ===== Audit Log =====
/** * GET /admin/audit-log * List audit log entries (most recent first) */ router.get('/audit-log', requireAdmin, async (req: Request, res: Response) => { const limit = Math.min(parseInt(req.query.limit as string, 10) || 100, 500); const offset = parseInt(req.query.offset as string, 10) || 0; const entries = db.getAuditLog(limit, offset); res.json({ entries, limit, offset }); });
// ===== Stats =====
/** * GET /admin/stats * Get system statistics */ router.get('/stats', requireAdmin, async (_req: Request, res: Response) => { const stats = db.getStats(); res.json(stats); });
return router;}
// ===== Admin Login Page Template =====
function escapeHtml(str: string): string { return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');}
function renderLoginPage(error?: string): string { return `<!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>ATAuth Admin Login</title> <style> *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #334155 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; color: #e2e8f0; } .card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; padding: 2rem; width: 100%; max-width: 400px; box-shadow: 0 25px 50px rgba(0,0,0,0.4); } h1 { font-size: 1.5rem; font-weight: 600; margin-bottom: 0.5rem; color: #f1f5f9; } .subtitle { color: #94a3b8; font-size: 0.875rem; margin-bottom: 1.5rem; } .error { background: #451a22; border: 1px solid #7f1d2f; color: #fca5a5; padding: 0.75rem 1rem; border-radius: 8px; font-size: 0.875rem; margin-bottom: 1rem; } label { display: block; font-size: 0.875rem; font-weight: 500; color: #94a3b8; margin-bottom: 0.5rem; } input[type="password"] { width: 100%; padding: 0.625rem 0.75rem; background: #0f172a; border: 1px solid #475569; border-radius: 8px; color: #e2e8f0; font-size: 0.875rem; outline: none; transition: border-color 0.2s; } input[type="password"]:focus { border-color: #3b82f6; } button { width: 100%; margin-top: 1rem; padding: 0.625rem; background: #3b82f6; color: #fff; border: none; border-radius: 8px; font-size: 0.875rem; font-weight: 500; cursor: pointer; transition: background 0.2s; } button:hover { background: #2563eb; } </style></head><body> <div class="card"> <h1>ATAuth Admin</h1> <p class="subtitle">Enter your admin token to continue</p> ${error ? `<div class="error">${escapeHtml(error)}</div>` : ''} <form method="POST" action="/admin/login"> <label for="token">Admin Token</label> <input type="password" id="token" name="token" required autocomplete="current-password" autofocus> <button type="submit">Sign In</button> </form> </div></body></html>`;}