import { HttpService } from "@nestjs/axios"; import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { firstValueFrom } from "rxjs"; import { EimsConfig } from "../../config/eims.config"; import { EimsSignerService, toSignedBody } from "./eims-signer.service"; import { EimsApiException, EimsConfigException, toEimsApiException } from "./eims.errors"; import { EimsLoginRequest, EimsLoginResponse } from "./eims.types"; interface TokenCache { accessToken: string; /** Epoch ms, already reduced by the configured skew. */ expiresAt: number; session: EimsSessionContext; } /** * Source-system identity, taken from the access token MoR issues us. * * The gateway stamps `systemNumber` and `systemType` into the token for the credentials that * authenticated, which makes the token the authority on them — not our environment file. Anything * we configured locally can only ever disagree with what MoR believes. */ export interface EimsSessionContext { systemNumber: string; systemType: string; } /** Decode a JWT payload without verifying it: this is MoR's token, signed with MoR's key. */ function decodeTokenClaims(accessToken: string): Record { const payload = accessToken.split(".")[1]; if (!payload) { throw new EimsApiException("UNKNOWN", "EIMS access token is not a JWT (no payload segment)"); } try { return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Record; } catch (err) { // The token itself is never included — only that its payload would not parse. throw new EimsApiException( "UNKNOWN", `EIMS access token payload could not be decoded: ${(err as Error).message}`, ); } } const claimString = (claims: Record, name: string): string => { const value = claims[name]; return typeof value === "string" ? value.trim() : ""; }; /** Used when the gateway omits `expiresIn`; the observed value is 3600. */ const FALLBACK_EXPIRES_IN_SECONDS = 3600; /** * EIMS authentication: signed `POST /auth/login`, plus an in-memory access-token cache. * * Login is the one EIMS call that carries no bearer token, which is why it lives here rather than * in the generic client. Tokens are held in memory only — never persisted, never logged, never * returned to a frontend. */ @Injectable() export class EimsAuthService { private readonly logger = new Logger(EimsAuthService.name); private cache: TokenCache | null = null; private loginInFlight: Promise | null = null; constructor( private readonly http: HttpService, private readonly config: ConfigService, private readonly signer: EimsSignerService, ) {} private get cfg(): EimsConfig { return this.config.get("eims")!; } /** * A non-expired access token, logging in if needed. Concurrent callers share one login: the * first caller stores the in-flight promise and everyone else awaits it. */ async getValidAccessToken(): Promise { if (this.cache && Date.now() < this.cache.expiresAt) { return this.cache.accessToken; } if (this.loginInFlight) return this.loginInFlight; this.loginInFlight = this.login(); try { return await this.loginInFlight; } finally { this.loginInFlight = null; } } /** * The source-system identity MoR issued this session, refreshing the login if needed. * * This is the authority for `SourceSystem.SystemNumber` / `SystemType`: the gateway stamps both * into the access token for the authenticating credentials, so a local env value could only ever * disagree with it. */ async getSessionContext(): Promise { await this.getValidAccessToken(); return this.cache!.session; } /** Drop the cached token — called after a 401 so the next request re-authenticates. */ invalidate(): void { this.cache = null; } /** * Read the source-system claims out of the token, and cross-check anything configured locally. * * `EIMS_SYSTEM_NUMBER` / `EIMS_SYSTEM_TYPE` are optional expectations, not inputs: when set they * are compared and a mismatch fails immediately rather than one silently winning. Registering * under the wrong source system is not something to discover from a rejected invoice. */ private readSessionContext(accessToken: string, cfg: EimsConfig): EimsSessionContext { const claims = decodeTokenClaims(accessToken); const systemNumber = claimString(claims, "systemNumber"); const systemType = claimString(claims, "systemType"); const missing = [ !systemNumber && "systemNumber", !systemType && "systemType", ].filter(Boolean); if (missing.length > 0) { throw new EimsApiException( "UNKNOWN", `EIMS access token carries no ${missing.join(" or ")} claim; cannot identify the source system`, ); } const mismatches = [ cfg.systemNumber && cfg.systemNumber !== systemNumber ? `EIMS_SYSTEM_NUMBER=${cfg.systemNumber} but the token says ${systemNumber}` : null, cfg.systemType && cfg.systemType !== systemType ? `EIMS_SYSTEM_TYPE=${cfg.systemType} but the token says ${systemType}` : null, ].filter(Boolean); if (mismatches.length > 0) { throw new EimsConfigException( `EIMS source-system configuration disagrees with the issued token: ${mismatches.join("; ")}. ` + "Correct the environment or the credentials — neither value is assumed to win.", ); } return { systemNumber, systemType }; } private async login(): Promise { const cfg = this.cfg; if (!cfg.enabled) { throw new EimsConfigException("EIMS integration is disabled; set EIMS_ENABLED=true to use it"); } const request: EimsLoginRequest = { clientId: cfg.clientId, clientSecret: cfg.clientSecret, apikey: cfg.apiKey, tin: cfg.tin, }; const body = toSignedBody(this.signer.signRequest(request)); let response: EimsLoginResponse; try { const res = await firstValueFrom( this.http.post(`${cfg.baseUrl}/auth/login`, body, { headers: { "Content-Type": "application/json" }, timeout: cfg.httpTimeoutMs, }), ); response = res.data; } catch (err) { const mapped = toEimsApiException(err, "login"); this.logger.error(mapped.message); throw mapped; } const accessToken = response?.data?.accessToken; if (!accessToken) { throw new EimsApiException("UNKNOWN", "EIMS login returned no accessToken"); } const expiresIn = Number.isFinite(response.data.expiresIn) && response.data.expiresIn > 0 ? response.data.expiresIn : FALLBACK_EXPIRES_IN_SECONDS; // TODO: implement `POST /auth/refresh-token` and hold `response.data.refreshToken`. The // collection shows a bare `{refreshToken}` body with no envelope, but it also carries unsigned // examples of calls that do require signing, so whether refresh must be signed is unconfirmed. // Until MoR confirms it, an expired token just triggers a fresh login — `expiresIn` is 3600s, // so that is one extra call an hour. // Reject the session before caching it: a token we cannot identify a source system from is // useless for registration, and a configured expectation that disagrees is a deployment fault. const session = this.readSessionContext(accessToken, cfg); this.cache = { accessToken, expiresAt: Date.now() + Math.max(expiresIn * 1000 - cfg.tokenSkewMs, 1000), session, }; this.logger.log( `EIMS login succeeded; token cached for ~${expiresIn}s ` + `(system ${session.systemNumber}, type ${session.systemType})`, ); return accessToken; } }