import crypto from 'node:crypto'; import type { Store } from '../db/store.ts'; import type { Connection } from '../protocol/connection.ts'; import { isLegacyFidelity } from '../protocol/fidelity.ts'; import { MESSAGE_TYPE, ERROR_CODE } from '../protocol/messageTypes.ts'; import type { AuthUser, BroadcastTableChangeFn, GenesisMessage, LoginCtx, LoginFailure, MockServerConfig, ProductDetails, Row, } from '../types.ts'; const LEGACY_HEARTBEAT_INTERVAL_SECS = 30; // auth 8.15.5 AuthPreferencesDefinition.kt:17,19 defaults. const DEFAULT_SESSION_TIMEOUT_MINS = 30; const DEFAULT_REFRESH_TOKEN_EXPIRATION_MINS = 7200; // LoginProcessor.kt:176-177 @auth v8.15.5: a user with no password expiry date. const DAYS_TO_PASSWORD_EXPIRY_NONE = 730; // 2 * 365 // The versions this mock follows (test/conformance/README.md). const DEFAULT_PRODUCTS: ProductDetails[] = [ { NAME: 'auth', VERSION: '8.15.5' }, { NAME: 'genesis', VERSION: '8.15.29' }, ]; // What a stock ZeroMQ install reports (LoginProcessor.kt:247-256 @auth // v8.15.5; the values recorded in http-login.json). The mock has no update // queue, and no client reads this. const UPDATE_QUEUE_DETAILS: Row = { UPDATE_QUEUE: 'ZeroMq', ZERO_MQ_PROXY_MODE_ENABLED: false, ZERO_MQ_PROXY_INBOUND_PORT: 5001, ZERO_MQ_PROXY_OUTBOUND_PORT: 5000, AERON_SERVICE_PORT: null, }; // Every SESSION_AUTH_TOKEN/REFRESH_AUTH_TOKEN issued by this server -> the // username it was issued to. Shared across the WS and HTTP transports so a // token from an HTTP login authenticates later WS traffic (see router.ts) and // foundation-comms' WS token-refresh (EVENT_LOGIN_AUTH with only // DETAILS.REFRESH_AUTH_TOKEN, no credentials) can succeed. export type TokenRegistry = Map; // A login session, as auth's USER_SESSION row holds it. export interface AuthSession { username: string; sessionAuthToken: string; refreshAuthToken: string; sessionId: string; } // The auth state both transports share. export interface AuthState { tokens: TokenRegistry; // Live sessions by SESSION_AUTH_TOKEN. event-login-details answers with the // session's own tokens, and a cookie-authenticated socket needs one. sessions: Map; // username -> epoch millis of their latest login (USER_LOGIN's // LAST_LOGIN_DATETIME, the ack's DETAILS.LAST_LOGIN_DATE_TIME). lastLogin: Map; } export function createAuthState(): AuthState { return { tokens: new Map(), sessions: new Map(), lastLogin: new Map() }; } // Both transports mint tokens on every login/silent-login, so a long-lived // dev/CI server would otherwise grow these maps one entry per page load. // Maps iterate in insertion order, so dropping from the front evicts the // oldest sessions/tokens first. const MAX_REGISTRY_ENTRIES = 1000; export function capRegistry(registry: Map): void { while (registry.size > MAX_REGISTRY_ENTRIES) { const oldest = registry.keys().next().value; if (oldest === undefined) return; registry.delete(oldest); } } export function openSession(auth: AuthState, user: AuthUser): AuthSession { const session: AuthSession = { username: user.username, sessionAuthToken: crypto.randomUUID(), refreshAuthToken: crypto.randomUUID(), sessionId: crypto.randomUUID(), }; auth.sessions.set(session.sessionAuthToken, session); capRegistry(auth.sessions); auth.tokens.set(session.sessionAuthToken, user.username); auth.tokens.set(session.refreshAuthToken, user.username); capRegistry(auth.tokens); auth.lastLogin.set(user.username, Date.now()); return session; } // Revokes the session's tokens, not just the session: every message // re-adopts a stamped SESSION_AUTH_TOKEN (see router.ts), so a token left in // the registry silently logs a connection straight back in. export function closeSession(auth: AuthState, session: AuthSession): void { auth.sessions.delete(session.sessionAuthToken); auth.tokens.delete(session.sessionAuthToken); auth.tokens.delete(session.refreshAuthToken); } export function sessionByRefreshToken( auth: AuthState, refreshAuthToken: unknown, ): AuthSession | undefined { if (typeof refreshAuthToken !== 'string') return undefined; for (const session of auth.sessions.values()) { if (session.refreshAuthToken === refreshAuthToken) return session; } return undefined; } export function sessionTimeoutMins(config: MockServerConfig): number { return config.auth?.sessionTimeoutMins ?? DEFAULT_SESSION_TIMEOUT_MINS; } export function refreshTokenExpirationMins(config: MockServerConfig): number { return config.auth?.refreshTokenExpirationMins ?? DEFAULT_REFRESH_TOKEN_EXPIRATION_MINS; } const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const MONTH_NAMES = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', ]; // java.util.Date#toString in UTC ("Thu Oct 01 13:05:42 UTC 2026"), which is // what auth sends as DETAILS.SYSTEM.DATE (LoginProcessor.kt:228). export function javaDateString(date: Date): string { const pad = (value: number) => String(value).padStart(2, '0'); const time = [date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds()].map(pad).join(':'); return `${DAY_NAMES[date.getUTCDay()]} ${MONTH_NAMES[date.getUTCMonth()]} ${pad(date.getUTCDate())} ${time} UTC ${date.getUTCFullYear()}`; } // The login ack, GSF auth 8.15.5 (LoginProcessor.successfulLoginMessage, // LoginProcessor.kt:163-245; field names from Events.kt:66-114). Recorded in // http-login.json. EVENT_LOGIN_DETAILS_ACK is the same body under its own // MESSAGE_TYPE (Events.kt:79-82), built afresh for the same session // (auth-login-eventhandler.kts:84-116). Nothing here is a login count: a // successful login resets FAILED_ and REJECTED_LOGIN_ATTEMPTS to 0 // (LoginAuditController.kt:63,187). export function buildLoginAckBody( user: AuthUser, session: AuthSession, config: MockServerConfig, auth: AuthState, messageType: string = MESSAGE_TYPE.EVENT_LOGIN_AUTH_ACK, ): Row { if (isLegacyFidelity(config)) return buildLegacyLoginAckBody(user, session, config, messageType); return { SESSION_AUTH_TOKEN: session.sessionAuthToken, REFRESH_AUTH_TOKEN: session.refreshAuthToken, SESSION_ID: session.sessionId, USER_NAME: user.username, DETAILS: { HEARTBEAT_INTERVAL_SECONDS: config.auth?.heartbeatIntervalSecs ?? 0, SESSION_TIMEOUT_MINS: sessionTimeoutMins(config), REFRESH_TOKEN_EXPIRATION_MINS: refreshTokenExpirationMins(config), FAILED_LOGIN_ATTEMPTS: 0, REJECTED_LOGIN_ATTEMPTS: 0, LAST_LOGIN_DATE_TIME: auth.lastLogin.get(user.username) ?? null, DAYS_TO_PASSWORD_EXPIRY: DAYS_TO_PASSWORD_EXPIRY_NONE, NOTIFY_EXPIRY: null, MFA_CODE: null, MFA_CODE_EXPIRY_MINS: null, SYSTEM: { DATE: javaDateString(new Date()) }, PRODUCT: config.auth?.products ?? DEFAULT_PRODUCTS, }, PERMISSION: user.permission ?? [], PROFILE: user.profile ?? [], UPDATE_QUEUE_DETAILS: UPDATE_QUEUE_DETAILS, USER: user.loginAck ?? null, USER_DETAILS: { FIRST_NAME: user.user?.FIRST_NAME ?? null, LAST_NAME: user.user?.LAST_NAME ?? null, }, MESSAGE_TYPE: messageType, }; } // fidelity: 'legacy': the ack this engine sent up to 15.47. function buildLegacyLoginAckBody( user: AuthUser, session: AuthSession, config: MockServerConfig, messageType: string, ): Row { return { MESSAGE_TYPE: messageType, SESSION_AUTH_TOKEN: session.sessionAuthToken, REFRESH_AUTH_TOKEN: session.refreshAuthToken, SESSION_ID: session.sessionId, USER_NAME: user.username, UPDATE_QUEUE: 'MOCK', ZERO_MQ_PROXY_MODE_ENABLED: false, ZERO_MQ_PROXY_INBOUND_PORT: 0, ZERO_MQ_PROXY_OUTBOUND_PORT: 0, USER: user.user ?? {}, DETAILS: { SYSTEM: { DATE: new Date().toISOString() }, HEARTBEAT_INTERVAL_SECS: config.auth?.heartbeatIntervalSecs ?? LEGACY_HEARTBEAT_INTERVAL_SECS, FAILED_LOGIN_ATTEMPTS: 0, REJECTED_LOGIN_ATTEMPTS: 0, LAST_LOGIN_DATETIME: 0, DAYS_TO_PASSWORD_EXPIRY: -1, PRODUCT: [{ NAME: config.productName ?? 'mock-server', VERSION: '0.1.0' }], }, PERMISSION: user.permission ?? [], PROFILE: user.profile ?? [], }; } // The status each login failure answers with (AuthFailure$WhenMappings). const LOGIN_FAILURE_STATUS: Record = { INCORRECT_CREDENTIALS: '401 Unauthorized', UNKNOWN_ACCOUNT: '401 Unauthorized', PASSWORD_EXPIRED: '403 Forbidden', LOCKED_ACCOUNT: '403 Forbidden', MAX_TRIES_REACHED: '429 Too Many Requests', NO_COMPATIBLE_PROVIDERS: '400 Bad Request', INTERNAL_ERROR: '500 Internal Server Error', }; // A failed login, GSF auth 8.15.5: one LoginError (Events.kt:91-98,133-140) // with the "Problem logging in" text whatever the failure, an unknown user // and a wrong password alike (LoginProcessor.kt:160-161,330). Over HTTP it is // a 200 (http-login-failed.json). export function buildLoginNackBody( config: MockServerConfig, transport: 'ws' | 'http', failure: LoginError = 'INCORRECT_CREDENTIALS', ): Row { if (isLegacyFidelity(config)) { return { MESSAGE_TYPE: MESSAGE_TYPE.EVENT_LOGIN_AUTH_NACK, ERROR: [ transport === 'ws' ? { CODE: ERROR_CODE.INCORRECT_CREDENTIALS, STATUS_CODE: '401', TEXT: 'Incorrect username or password', } : { CODE: ERROR_CODE.INCORRECT_CREDENTIALS, STATUS_CODE: '401 Unauthorized', TEXT: 'Problem logging in', }, ], WARNING: [], }; } return { ERROR: [ { '@type': 'LoginError', CODE: failure, TEXT: 'Problem logging in', STATUS_CODE: LOGIN_FAILURE_STATUS[failure], DETAILS: null, }, ], WARNING: [], MESSAGE_TYPE: MESSAGE_TYPE.EVENT_LOGIN_AUTH_NACK, REFRESH_AUTH_TOKEN: null, REFRESH_TOKEN_EXPIRATION_MINS: null, }; } // LOGOUT_ACK, GSF auth 8.15.5 (Events.kt:121-123): OIDC_LOGOUT_URL is null // without an OIDC provider. export function buildLogoutAckBody(config: MockServerConfig): Row { if (isLegacyFidelity(config)) return { MESSAGE_TYPE: MESSAGE_TYPE.LOGOUT_ACK }; return { OIDC_LOGOUT_URL: null, MESSAGE_TYPE: MESSAGE_TYPE.LOGOUT_ACK }; } // Trusted lookup by username alone — for paths where identity is already // established (a token this server issued, an HTTP session cookie, // httpAutoLogin): the directory's user when the config has one, else a // configured one. NEVER use this for a credentials login. export function findUser( config: MockServerConfig, store: Store, username?: string, ): AuthUser | undefined { if (username === undefined) return undefined; const directory = config.auth?.directory; if (directory) return directory.user(username, store); return (config.auth?.users ?? []).find((u) => u.username === username); } // A status auth asks a new password for (UserStatus). const needsNewPassword = (status: string | undefined) => status === 'PASSWORD_EXPIRED' || status === 'PASSWORD_RESET'; const statusLetsIn = (status: string | undefined) => status === undefined || status === 'ENABLED'; // Which of auth's authenticators takes a login. AuthenticationService.login // asks RefreshTokenAuthenticator first, which takes a refresh token without // a PASSWORD, then GenesisDbAuthenticator, which needs USER_NAME and // PASSWORD both; a message neither takes is NO_COMPATIBLE_PROVIDERS. Under // fidelity: 'legacy', as up to 15.47: a USER_NAME makes it a credentials // login, and a message without one a refresh. export type LoginKind = 'credentials' | 'refresh' | 'none'; export function loginKind( config: MockServerConfig, details: Row, refreshToken: unknown, ): LoginKind { if (isLegacyFidelity(config)) return details.USER_NAME === undefined ? 'refresh' : 'credentials'; const given = (value: unknown) => value !== undefined && value !== null; if (!given(details.PASSWORD) && given(refreshToken)) return 'refresh'; return given(details.USER_NAME) && given(details.PASSWORD) ? 'credentials' : 'none'; } // A credentials login: the directory's when the config has one, else checked // against the configured users in auth's order (GenesisDbAuthenticator, auth // v8.15.5): an unknown or DISABLED user fails as a wrong password does, a // status that asks for a new password answers PASSWORD_EXPIRED, both before // the password is looked at, and a right password with any other status but // ENABLED LOCKED_ACCOUNT. fidelity: 'legacy' compares the password as 15.47 // did, so a user configured without one logs in by sending none. export function credentialsLogin( config: MockServerConfig, store: Store, details: Row, ctx: LoginCtx, ): AuthUser | LoginFailure { const username = String(details.USER_NAME); const directory = config.auth?.directory; if (directory) { const password = details.PASSWORD === undefined || details.PASSWORD === null ? undefined : String(details.PASSWORD); return directory.login(username, password, ctx); } const user = findUser(config, store, username); if (!user || user.status === 'DISABLED') return 'INCORRECT_CREDENTIALS'; if (needsNewPassword(user.status)) return 'PASSWORD_EXPIRED'; const password = isLegacyFidelity(config) ? details.PASSWORD : String(details.PASSWORD); if (user.password !== password) return 'INCORRECT_CREDENTIALS'; return statusLetsIn(user.status) ? user : 'LOCKED_ACCOUNT'; } // A login no authenticator takes: the directory records it (it is asked with // no password), and it fails whatever the directory answers. export function unclaimedLogin( config: MockServerConfig, details: Row, ctx: LoginCtx, ): LoginFailure { const directory = config.auth?.directory; const username = typeof details.USER_NAME === 'string' ? details.USER_NAME : ''; const answer = directory?.login(username, undefined, ctx); return typeof answer === 'string' ? answer : 'NO_COMPATIBLE_PROVIDERS'; } // A refresh-token login (RefreshTokenAuthenticator) as the user the token's // session belongs to (undefined: none has it), refused when their status no // longer lets them in. export function refreshLogin( config: MockServerConfig, store: Store, username: string | undefined, ctx: LoginCtx, ): AuthUser | LoginFailure { const directory = config.auth?.directory; if (directory) return directory.refresh(username, ctx); const user = findUser(config, store, username); if (!user) return isLegacyFidelity(config) ? 'INCORRECT_CREDENTIALS' : 'UNKNOWN_ACCOUNT'; if (needsNewPassword(user.status)) return 'PASSWORD_EXPIRED'; return statusLetsIn(user.status) ? user : 'LOCKED_ACCOUNT'; } // Why a login fails as the client sees it: auth's reasons, or an error in // the mock's own login (a directory that throws, or answers with neither a // user nor a reason), which would otherwise leave a socket login unanswered. export type LoginError = LoginFailure | 'INTERNAL_ERROR'; export function attemptLogin(run: () => AuthUser | LoginFailure): AuthUser | LoginError { try { const answer: unknown = run(); if (typeof answer === 'string' && answer in LOGIN_FAILURE_STATUS) return answer as LoginFailure; if (typeof answer === 'object' && answer !== null) return answer as AuthUser; console.error('[mock-server] A login answered with neither a user nor a reason:', answer); } catch (error) { console.error('[mock-server] Error during a login:', error); } return 'INTERNAL_ERROR'; } // Tells the directory a session of `username`'s has logged out. export function recordLogout( config: MockServerConfig, auth: AuthState, username: string, ctx: LoginCtx, ): void { const online = [...auth.sessions.values()].some((session) => session.username === username); config.auth?.directory?.logout(username, online, ctx); } // DETAILS.HOSTNAME as GSF's router sets it on a login or logout // (MsgCreator.kt:59-66 @v8.15.29): the HOSTNAME header, else the socket's // address as Java prints it, up to its first ':' ("/127.0.0.1"; an IPv6 one // keeps "/[" and its first group). export function routerHostname(header: unknown, address: string | undefined): string { if (typeof header === 'string' && header !== '') return header; const ip = (address ?? '').replace(/^::ffff:(?=\d+\.)/, ''); if (!ip.includes(':')) return `/${ip}`; return `/[${ip.startsWith(':') ? '0' : ip.split(':')[0]}`; } // What a login over the socket runs with. function socketLoginCtx( message: GenesisMessage, connection: Connection, store: Store, broadcastTableChange: BroadcastTableChangeFn, ): LoginCtx { return { store, broadcastTableChange, hostname: routerHostname( connection.hostnameHeader ?? message.HOSTNAME, connection.remoteAddress, ), messageUser: typeof message.USER_NAME === 'string' ? message.USER_NAME : null, }; } export function handleLogin( message: GenesisMessage, connection: Connection, config: MockServerConfig, auth: AuthState, store: Store, broadcastTableChange: BroadcastTableChangeFn, ): void { const details = message.DETAILS ?? {}; const ctx = socketLoginCtx(message, connection, store, broadcastTableChange); // The session a refresh logs out: RefreshTokenAuthenticator deletes the // USER_SESSION whose token it used. Under fidelity: 'legacy' the old // session stays, as up to 15.47. let replaced: AuthSession | undefined; const user = attemptLogin(() => { const kind = loginKind(config, details, details.REFRESH_AUTH_TOKEN); if (kind === 'credentials') return credentialsLogin(config, store, details, ctx); if (kind === 'none') return unclaimedLogin(config, details, ctx); if (isLegacyFidelity(config)) { // foundation-comms' createRefreshTokenMessage sends the refresh token // in DETAILS and the stored SESSION_AUTH_TOKEN at the top level. const username = auth.tokens.get(details.REFRESH_AUTH_TOKEN) ?? auth.tokens.get(details.SESSION_AUTH_TOKEN) ?? auth.tokens.get(message.SESSION_AUTH_TOKEN); return refreshLogin(config, store, username, ctx); } replaced = sessionByRefreshToken(auth, details.REFRESH_AUTH_TOKEN); return refreshLogin(config, store, replaced?.username, ctx); }); if (typeof user === 'string') { connection.reply(message, buildLoginNackBody(config, 'ws', user)); return; } if (replaced) closeSession(auth, replaced); const session = openSession(auth, user); connection.authenticated = true; connection.userName = user.username; connection.profile = user.profile ?? []; connection.permission = user.permission ?? []; connection.sessionAuthToken = session.sessionAuthToken; connection.refreshAuthToken = session.refreshAuthToken; connection.sessionId = session.sessionId; connection.reply(message, buildLoginAckBody(user, session, config, auth)); } export function handleLogout( message: GenesisMessage, connection: Connection, config: MockServerConfig, auth: AuthState, store: Store, broadcastTableChange: BroadcastTableChangeFn, ): void { const session = connection.sessionAuthToken === undefined ? undefined : auth.sessions.get(connection.sessionAuthToken); if (session) { closeSession(auth, session); const ctx = socketLoginCtx(message, connection, store, broadcastTableChange); recordLogout(config, auth, session.username, ctx); } for (const token of [connection.sessionAuthToken, connection.refreshAuthToken]) { if (token !== undefined) auth.tokens.delete(token); } connection.authenticated = false; connection.sessionAuthToken = undefined; connection.refreshAuthToken = undefined; connection.subscriptions.clear(); connection.reply(message, buildLogoutAckBody(config)); }