import crypto from 'node:crypto'; import type { IncomingMessage, ServerResponse } from 'node:http'; import type { Store } from '../db/store.ts'; import { isLegacyFidelity } from '../protocol/fidelity.ts'; import { MESSAGE_TYPE } from '../protocol/messageTypes.ts'; import { notLoggedInNack } from '../protocol/msgNack.ts'; import type { AuthUser, CookieAuthenticationOptions, HttpRouteCtx, LoginCtx, MockServerConfig, Row, } from '../types.ts'; import { attemptLogin, buildLoginAckBody, buildLoginNackBody, buildLogoutAckBody, capRegistry, closeSession, credentialsLogin, findUser, loginKind, openSession, recordLogout, refreshLogin, refreshTokenExpirationMins, routerHostname, sessionByRefreshToken, sessionTimeoutMins, unclaimedLogin, type AuthSession, type AuthState, } from './auth.ts'; // fidelity: 'legacy' without cookieAuthentication: the mock's own session // cookie, sessionId -> username. const LEGACY_SESSION_COOKIE = 'MOCK_SESSION_ID'; // genesis-router CookieHandler.kt:195-202 @v8.15.29. export const SESSION_AUTH_TOKEN_COOKIE = 'sessionAuthToken'; const REFRESH_AUTH_TOKEN_COOKIE = 'refreshAuthToken'; const SESSION_ID_COOKIE = 'sessionId'; const MFA_CODE_COOKIE = 'mfaCode'; const SECONDS_PER_MINUTE = 60; const MILLIS_PER_SECOND = 1000; const HTTP_STATUS = { OK: 200, UNAUTHORIZED: 401, NOT_FOUND: 404, INTERNAL_SERVER_ERROR: 500, }; // The `cookieAuthentication { }` block GSF ships in genesis-router.kts. const SHIPPED_COOKIE_OPTIONS: Required> = { httpOnly: true, secure: false, sameSite: 'Lax', path: '/', sessionCookies: false, }; export function cookieAuthentication( config: MockServerConfig, ): CookieAuthenticationOptions | undefined { // On unless the config opts out, as GSF ships it; off under fidelity: 'legacy'. const option = config.auth?.cookieAuthentication ?? !isLegacyFidelity(config); if (!option) return undefined; return option === true ? SHIPPED_COOKIE_OPTIONS : { ...SHIPPED_COOKIE_OPTIONS, ...option }; } // foundation-auth's login UI (client-app-login) doesn't use the WebSocket // EVENT_LOGIN_AUTH flow at all — it POSTs credentials over plain HTTP to // gwf/event-login-auth, and separately POSTs gwf/event-login-details on every // app load (silent login; foundation-comms/src/connect/sessionRefresh.ts's // isAuthenticated() GETs it later). Both expect the same ACK body as the WS // login (EVENT_LOGIN_DETAILS_ACK literally extends EVENT_LOGIN_AUTH_ACK), with // the session named by a cookie (cookieAuthentication) or a SESSION_AUTH_TOKEN // header instead of SOURCE_REF correlation. This is a *separate* transport // from the WS protocol in server.ts — a client-app can use either, or (as // here) both. export function parseCookies(header: string | undefined): Record { const cookies: Record = {}; for (const part of (header ?? '').split(';')) { const [key, ...rest] = part.trim().split('='); if (key) cookies[key] = decodeURIComponent(rest.join('=')); } return cookies; } // As Netty's ServerCookieEncoder.STRICT writes it (CookieHandler.kt:178-192 // @v8.15.29): Max-Age and Expires unless it is a session cookie, then Path, // Domain, Secure, HTTPOnly, SameSite. function encodeCookie( options: CookieAuthenticationOptions, name: string, value: string, maxAgeSecs: number | undefined, ): string { const parts = [`${name}=${value}`]; if (maxAgeSecs !== undefined) { const expires = new Date(Date.now() + maxAgeSecs * MILLIS_PER_SECOND); parts.push(`Max-Age=${maxAgeSecs}`, `Expires=${expires.toUTCString()}`); } if (options.path) parts.push(`Path=${options.path}`); if (options.domain) parts.push(`Domain=${options.domain}`); if (options.secure) parts.push('Secure'); if (options.httpOnly) parts.push('HTTPOnly'); if (options.sameSite) parts.push(`SameSite=${options.sameSite}`); return parts.join('; '); } // The cookies GSF's router sets on an EVENT_LOGIN_AUTH_ACK // (CookieHandler.kt:91-130): the session token lives as long as the session, // the refresh token and session id as long as the refresh token, and the // unused MFA code expires at once. function loginCookies( options: CookieAuthenticationOptions, config: MockServerConfig, session: AuthSession, ): string[] { const sessionMaxAge = options.sessionCookies ? undefined : sessionTimeoutMins(config) * SECONDS_PER_MINUTE; const refreshMaxAge = options.sessionCookies ? undefined : refreshTokenExpirationMins(config) * SECONDS_PER_MINUTE; return [ encodeCookie(options, SESSION_AUTH_TOKEN_COOKIE, session.sessionAuthToken, sessionMaxAge), encodeCookie(options, REFRESH_AUTH_TOKEN_COOKIE, session.refreshAuthToken, refreshMaxAge), encodeCookie(options, SESSION_ID_COOKIE, session.sessionId, refreshMaxAge), encodeCookie(options, MFA_CODE_COOKIE, '', 0), ]; } // On EVENT_LOGIN_AUTH_NACK the router sets the NACK's REFRESH_AUTH_TOKEN // (null) for its REFRESH_TOKEN_EXPIRATION_MINS (null): an empty cookie that // expires at once (CookieHandler.kt:132-139); recorded in // http-login-failed.json. function loginNackCookies(options: CookieAuthenticationOptions): string[] { return [encodeCookie(options, REFRESH_AUTH_TOKEN_COOKIE, '', 0)]; } // LOGOUT_ACK expires all four (CookieHandler.kt:141-147). function logoutCookies(options: CookieAuthenticationOptions): string[] { return [ SESSION_AUTH_TOKEN_COOKIE, REFRESH_AUTH_TOKEN_COOKIE, SESSION_ID_COOKIE, MFA_CODE_COOKIE, ].map((name) => encodeCookie(options, name, '', 0)); } function sendJson( res: ServerResponse, status: number, body: unknown, cookies: string[] = [], ): void { if (cookies.length > 0) res.setHeader('Set-Cookie', cookies); res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); } const BYTES_PER_KB = 1024; const MAX_BODY_KB = 1024; const MAX_BODY_BYTES = MAX_BODY_KB * BYTES_PER_KB; // 1MB — logins/commits carry small payloads, this only guards against abuse async function readJsonBody(req: IncomingMessage): Promise> { const chunks: Buffer[] = []; let bytesRead = 0; for await (const chunk of req) { bytesRead += chunk.length; if (bytesRead > MAX_BODY_BYTES) { throw new Error('Request body size limit exceeded'); } chunks.push(chunk); } const raw = Buffer.concat(chunks).toString('utf8'); return raw ? JSON.parse(raw) : {}; } // A header the client may send as SESSION_AUTH_TOKEN or, as foundation-comms' // createHTTPHeadersFromMessage does, SESSION-AUTH-TOKEN. function headerValue(req: IncomingMessage, name: string): string | undefined { const value = req.headers[name.toLowerCase()] ?? req.headers[name.toLowerCase().replaceAll('_', '-')]; return typeof value === 'string' ? value : undefined; } function autoLoginUser(config: MockServerConfig, store: Store): AuthUser | undefined { const autoLogin = config.auth?.httpAutoLogin; if (!autoLogin) return undefined; return findUser( config, store, autoLogin === true ? config.auth?.users?.[0]?.username : autoLogin, ); } // Drops every token this server minted for `username` (fidelity: 'legacy', // whose logout ends all of the user's sessions). function revokeSessionsOf(auth: AuthState, username: string): void { for (const session of auth.sessions.values()) { if (session.username === username) closeSession(auth, session); } for (const [token, owner] of auth.tokens) { if (owner === username) auth.tokens.delete(token); } } // fidelity: 'legacy': reuse the request's MOCK_SESSION_ID when it already maps // to this user — otherwise every page load would mint (and leak) a fresh // session entry on a long-lived dev/CI server. function ensureLegacySession( legacySessions: Map, cookieSessionId: string | undefined, username: string, ): string { if (cookieSessionId && legacySessions.get(cookieSessionId) === username) return cookieSessionId; const sessionId = crypto.randomUUID(); legacySessions.set(sessionId, username); capRegistry(legacySessions); return sessionId; } function legacySessionCookie(sessionId: string): string { return `${LEGACY_SESSION_COOKIE}=${sessionId}; Path=/; SameSite=Lax`; } export function createHttpRequestListener( config: MockServerConfig, legacySessions: Map, auth: AuthState, routeCtx: Omit, ) { const { store } = routeCtx; // What a login or logout over HTTP runs with. const loginCtx = (req: IncomingMessage, body: Row): LoginCtx => ({ store, broadcastTableChange: routeCtx.broadcastTableChange, hostname: routerHostname(req.headers.hostname, req.socket.remoteAddress), messageUser: typeof body.USER_NAME === 'string' ? body.USER_NAME : null, }); // Every HTTP reply carries a SOURCE_REF: the request's, or AUTO_ as // the router mints one (http-login.json). Not under fidelity: 'legacy'. const withSourceRef = (body: Row, request: Row = {}): Row => isLegacyFidelity(config) ? body : { ...body, SOURCE_REF: request.SOURCE_REF ?? `AUTO_${crypto.randomUUID()}` }; // The session a request names: the SESSION_AUTH_TOKEN header, which under // cookieAuthentication the sessionAuthToken cookie overwrites when there is // one (CookieHandler.kt:52-59 @v8.15.29). '' when the cookie is there but // empty, as after a logout cleared it. function requestToken(req: IncomingMessage, cookies: Record): string | undefined { const cookie = cookieAuthentication(config) ? cookies[SESSION_AUTH_TOKEN_COOKIE] : undefined; return cookie ?? headerValue(req, 'SESSION_AUTH_TOKEN'); } // The legacy-mode session cookie, or none. function legacyCookies(cookies: Record, username: string): string[] { if (!isLegacyFidelity(config) || cookieAuthentication(config)) return []; return [ legacySessionCookie( ensureLegacySession(legacySessions, cookies[LEGACY_SESSION_COOKIE], username), ), ]; } // A session opened by a login (or an httpAutoLogin): the login cookies. function sessionCookies(cookies: Record, session: AuthSession): string[] { const options = cookieAuthentication(config); return options ? loginCookies(options, config, session) : legacyCookies(cookies, session.username); } function handleLoginDetails(req: IncomingMessage, res: ServerResponse, request: Row): void { const cookies = parseCookies(req.headers.cookie); const legacyWithoutCookies = isLegacyFidelity(config) && !cookieAuthentication(config); const autoLogin = autoLoginUser(config, store); // fidelity: 'legacy' without cookieAuthentication: as up to 15.47, a new // session per call, found by the MOCK_SESSION_ID cookie. if (legacyWithoutCookies) { const user = autoLogin ?? findUser(config, store, legacySessions.get(cookies[LEGACY_SESSION_COOKIE])); if (!user) { sendJson(res, HTTP_STATUS.UNAUTHORIZED, { MESSAGE_TYPE: MESSAGE_TYPE.EVENT_LOGIN_AUTH_NACK, ERROR: [ { CODE: 'NOT_LOGGED_ON', STATUS_CODE: '401 Unauthorized', TEXT: 'No active session' }, ], }); return; } const session = openSession(auth, user); const body = buildLoginAckBody(user, session, config, auth, 'EVENT_LOGIN_DETAILS_ACK'); sendJson(res, HTTP_STATUS.OK, body, legacyCookies(cookies, user.username)); return; } const token = requestToken(req, cookies); const session = token === undefined ? undefined : auth.sessions.get(token); const sessionUser = session && findUser(config, store, session.username); if (session && sessionUser) { const body = buildLoginAckBody(sessionUser, session, config, auth, 'EVENT_LOGIN_DETAILS_ACK'); sendJson(res, HTTP_STATUS.OK, withSourceRef(body, request)); return; } // httpAutoLogin: a request without a live session logs the user in. if (autoLogin) { const opened = openSession(auth, autoLogin); const body = buildLoginAckBody(autoLogin, opened, config, auth, 'EVENT_LOGIN_DETAILS_ACK'); sendJson(res, HTTP_STATUS.OK, withSourceRef(body, request), sessionCookies(cookies, opened)); return; } sendJson( res, HTTP_STATUS.UNAUTHORIZED, withSourceRef(notLoggedInNack(token !== undefined), request), ); } async function handleLoginAuth(req: IncomingMessage, res: ServerResponse): Promise { const body = await readJsonBody(req); const details = body.DETAILS ?? {}; const cookies = parseCookies(req.headers.cookie); const cookieOptions = cookieAuthentication(config); // A refresh (foundation-comms' sessionRefresh performRefresh) names its // token in the refreshAuthToken cookie under cookieAuthentication // (CookieHandler.kt:62-67), else in DETAILS; under fidelity: 'legacy' // without cookies, the MOCK_SESSION_ID session is the one refreshed. const refreshToken = cookieOptions ? cookies[REFRESH_AUTH_TOKEN_COOKIE] : (details.REFRESH_AUTH_TOKEN ?? headerValue(req, 'REFRESH_AUTH_TOKEN')); const ctx = loginCtx(req, body); // The session a refresh logs out (RefreshTokenAuthenticator deletes the // USER_SESSION whose token it used); fidelity: 'legacy' keeps it. let replaced: AuthSession | undefined; const user = attemptLogin(() => { const kind = loginKind(config, details, refreshToken); if (kind === 'credentials') return credentialsLogin(config, store, details, ctx); if (kind === 'none') return unclaimedLogin(config, details, ctx); if (isLegacyFidelity(config) && !cookieOptions) { const username = legacySessions.get(cookies[LEGACY_SESSION_COOKIE]); return findUser(config, store, username) ?? 'INCORRECT_CREDENTIALS'; } const session = sessionByRefreshToken(auth, refreshToken); if (!isLegacyFidelity(config)) replaced = session; return refreshLogin(config, store, session?.username, ctx); }); if (typeof user === 'string') { // GSF answers a failed login with HTTP 200; fidelity: 'legacy' with 401. const status = isLegacyFidelity(config) ? HTTP_STATUS.UNAUTHORIZED : HTTP_STATUS.OK; const nackCookies = cookieOptions ? loginNackCookies(cookieOptions) : []; const nack = buildLoginNackBody(config, 'http', user); sendJson(res, status, withSourceRef(nack, body), nackCookies); return; } if (replaced) closeSession(auth, replaced); const session = openSession(auth, user); const ack = buildLoginAckBody(user, session, config, auth); sendJson(res, HTTP_STATUS.OK, withSourceRef(ack, body), sessionCookies(cookies, session)); } async function handleLogout(req: IncomingMessage, res: ServerResponse): Promise { const body = await readJsonBody(req).catch(() => ({}) as Record); const cookies = parseCookies(req.headers.cookie); const cookieOptions = cookieAuthentication(config); if (isLegacyFidelity(config) && !cookieOptions) { const sessionId = cookies[LEGACY_SESSION_COOKIE]; const username = legacySessions.get(sessionId); legacySessions.delete(sessionId); if (username !== undefined) revokeSessionsOf(auth, username); sendJson(res, HTTP_STATUS.OK, buildLogoutAckBody(config)); return; } // auth's LOGOUT ends the session SESSION_ID names (the sessionId cookie // under cookieAuthentication, CookieHandler.kt:76-81, else the request's // own), else the token's. const sessionId = (cookieOptions ? cookies[SESSION_ID_COOKIE] : undefined) ?? body.SESSION_ID ?? headerValue(req, 'SESSION_ID'); const token = requestToken(req, cookies); const ended = [...auth.sessions.values()].filter( (session) => session.sessionId === sessionId || session.sessionAuthToken === token, ); for (const session of ended) closeSession(auth, session); for (const session of ended) recordLogout(config, auth, session.username, loginCtx(req, body)); if (isLegacyFidelity(config)) { sendJson(res, HTTP_STATUS.OK, buildLogoutAckBody(config), logoutCookies(cookieOptions!)); return; } sendJson( res, HTTP_STATUS.OK, withSourceRef(buildLogoutAckBody(config), body), cookieOptions ? logoutCookies(cookieOptions) : [], ); } return async function requestListener(req: IncomingMessage, res: ServerResponse): Promise { const pathname = (req.url ?? '').split('?')[0]; try { // Project-defined routes run first (see HttpRouteDef) — endpoints the // protocol doesn't cover (e.g. GET pbi/configs), or overrides of the // built-ins below. const route = (config.httpRoutes ?? []).find( (candidate) => (!candidate.method || candidate.method.toUpperCase() === req.method) && (typeof candidate.path === 'string' ? pathname.endsWith(candidate.path) : candidate.path.test(pathname)), ); if (route) { const token = requestToken(req, parseCookies(req.headers.cookie)); const userName = token === undefined ? undefined : auth.sessions.get(token)?.username; await route.handler(req, res, { ...routeCtx, ...(userName ? { userName } : {}) }); return; } // foundation-auth's login screen fetches this whenever SSO is toggled // on, and treats both a failed fetch and an empty IDPS list as fatal // (red error, credentials form hidden). Serve the configured providers, // or one non-functional placeholder so the screen still renders. if (req.method === 'GET' && pathname.endsWith('sso/list')) { const idps = config.auth?.ssoIdentityProviders ?? [ { ID: 'mock-sso', DESCRIPTION: 'Mock SSO (placeholder, not functional)', TYPE: 'saml' }, ]; sendJson(res, HTTP_STATUS.OK, { IDPS: idps }); return; } // Silent login on app load. Two call sites hit this path with // different methods: foundation-auth's fetch machine always POSTs // (createFetchMachineEvent hardcodes method: 'POST') on initial // silent-login; foundation-comms' sessionRefresh.ts's isAuthenticated() // does a plain GET later, when checking whether an expired session can // be silently renewed. if ( (req.method === 'GET' || req.method === 'POST') && pathname.endsWith('event-login-details') ) { const request = req.method === 'POST' ? await readJsonBody(req) : {}; handleLoginDetails(req, res, request); return; } if (req.method === 'POST' && pathname.endsWith('event-login-auth')) { await handleLoginAuth(req, res); return; } if (req.method === 'POST' && pathname.endsWith('event-logout')) { await handleLogout(req, res); return; } res.writeHead(HTTP_STATUS.NOT_FOUND).end(); } catch (error) { console.error('[mock-server] HTTP handler error:', error); res.writeHead(HTTP_STATUS.INTERNAL_SERVER_ERROR).end(); } }; }