mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 23:00:57 +00:00
feat(eims): add invoice mapper and signed EIMS transport
Map EDR invoices onto the MoR EIMS /v1/register document and add the cryptographic transport needed to talk to core.mor.gov.et. Mapper: DTOs mirror the supplied Postman collection section by section. Tax is resolved per line via a caller-supplied resolver and throws when unresolved -- the app models no tax at all (invoice.taxAmount is always 0, invoice_lines and the rate catalogue carry no fiscal columns), so a zero-rated default would assert a tax position the codebase cannot support. Seller identity, document number, counters and previous IRN are passed in explicitly; the mapper stays pure. Transport: config, credential loading, RSA-SHA512 signing and /auth/login with an in-memory token cache. Signing reproduces the process that produced a working live token -- compact JSON of the inner request only, exact UTF-8 bytes, base64 signature, and base64 of the certificate file's exact bytes with no parsing or re-encoding. Concurrent callers share one login via an in-flight promise. Refresh is deliberately unimplemented: the collection shows an unsigned refresh body but also ships unsigned examples of calls that do require signing, so an expired token re-logs in instead. Errors normalise to EimsApiException carrying only the gateway's own error fields; secrets, signature, certificate and tokens never reach logs. Key and certificate file patterns are gitignored. Nothing calls EIMS automatically and no invoice entity, migration or UI is touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
190
apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts
Normal file
190
apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { AxiosError, AxiosHeaders } from "axios";
|
||||
import { of, throwError } from "rxjs";
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
import { EimsSignerService } from "./eims-signer.service";
|
||||
|
||||
const CLIENT_SECRET = "super-secret-value";
|
||||
const API_KEY = "super-secret-apikey";
|
||||
|
||||
const cfg = (over: Partial<EimsConfig> = {}): EimsConfig => ({
|
||||
enabled: true,
|
||||
baseUrl: "https://core.mor.gov.et",
|
||||
clientId: "cid",
|
||||
clientSecret: CLIENT_SECRET,
|
||||
apiKey: API_KEY,
|
||||
tin: "0000034558",
|
||||
systemNumber: "B0360154BA",
|
||||
systemType: "SYS",
|
||||
privateKeyPath: "/dev/null",
|
||||
certificatePath: "/dev/null",
|
||||
httpTimeoutMs: 30_000,
|
||||
tokenSkewMs: 45_000,
|
||||
...over,
|
||||
});
|
||||
|
||||
const loginBody = (accessToken: string, expiresIn = 3600) => ({
|
||||
data: { accessToken, refreshToken: "refresh-1", encryptionKey: null, expiresIn },
|
||||
status: "SUCCESS",
|
||||
});
|
||||
|
||||
/** Stub signer: the real signing path has its own spec and needs no key material here. */
|
||||
const signer = {
|
||||
signRequest: <T>(request: T) => ({ request, signature: "SIGNATURE", certificate: "CERTIFICATE" }),
|
||||
} as unknown as EimsSignerService;
|
||||
|
||||
const build = (post: jest.Mock, config: EimsConfig = cfg()) =>
|
||||
new EimsAuthService(
|
||||
{ post } as unknown as HttpService,
|
||||
{ get: () => config } as unknown as ConfigService,
|
||||
signer,
|
||||
);
|
||||
|
||||
const axiosErr = (status: number, data: unknown) =>
|
||||
new AxiosError("Request failed", undefined, undefined, undefined, {
|
||||
status,
|
||||
statusText: "",
|
||||
data,
|
||||
headers: new AxiosHeaders(),
|
||||
config: { headers: new AxiosHeaders() },
|
||||
});
|
||||
|
||||
describe("EimsAuthService.getValidAccessToken", () => {
|
||||
it("posts the signed login envelope to /auth/login with no Authorization header", async () => {
|
||||
const post = jest.fn().mockReturnValue(of({ data: loginBody("token-1") }));
|
||||
|
||||
await build(post).getValidAccessToken();
|
||||
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
const [url, body, options] = post.mock.calls[0];
|
||||
expect(url).toBe("https://core.mor.gov.et/auth/login");
|
||||
expect(options.headers).toEqual({ "Content-Type": "application/json" });
|
||||
expect(options.headers.Authorization).toBeUndefined();
|
||||
|
||||
expect(typeof body).toBe("string");
|
||||
expect(JSON.parse(body)).toEqual({
|
||||
request: { clientId: "cid", clientSecret: CLIENT_SECRET, apikey: API_KEY, tin: "0000034558" },
|
||||
signature: "SIGNATURE",
|
||||
certificate: "CERTIFICATE",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the access token from data.accessToken", async () => {
|
||||
const post = jest.fn().mockReturnValue(of({ data: loginBody("token-1") }));
|
||||
await expect(build(post).getValidAccessToken()).resolves.toBe("token-1");
|
||||
});
|
||||
|
||||
it("reuses a cached token instead of logging in again", async () => {
|
||||
const post = jest.fn().mockReturnValue(of({ data: loginBody("token-1") }));
|
||||
const auth = build(post);
|
||||
|
||||
await auth.getValidAccessToken();
|
||||
await expect(auth.getValidAccessToken()).resolves.toBe("token-1");
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("re-authenticates a skew-window before the token actually expires", async () => {
|
||||
const post = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(of({ data: loginBody("token-1", 100) })) // 100s ttl, 45s skew ⇒ usable 55s
|
||||
.mockReturnValueOnce(of({ data: loginBody("token-2") }));
|
||||
const auth = build(post);
|
||||
const start = Date.now();
|
||||
const clock = jest.spyOn(Date, "now");
|
||||
|
||||
try {
|
||||
clock.mockReturnValue(start);
|
||||
await expect(auth.getValidAccessToken()).resolves.toBe("token-1");
|
||||
|
||||
clock.mockReturnValue(start + 50_000); // inside the window: still cached
|
||||
await expect(auth.getValidAccessToken()).resolves.toBe("token-1");
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
|
||||
clock.mockReturnValue(start + 56_000); // past ttl-minus-skew, before the real 100s expiry
|
||||
await expect(auth.getValidAccessToken()).resolves.toBe("token-2");
|
||||
expect(post).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("logs in again after invalidate()", async () => {
|
||||
const post = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(of({ data: loginBody("token-1") }))
|
||||
.mockReturnValueOnce(of({ data: loginBody("token-2") }));
|
||||
const auth = build(post);
|
||||
|
||||
await auth.getValidAccessToken();
|
||||
auth.invalidate();
|
||||
await expect(auth.getValidAccessToken()).resolves.toBe("token-2");
|
||||
expect(post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("performs exactly one login for many concurrent callers", async () => {
|
||||
const post = jest.fn().mockReturnValue(of({ data: loginBody("token-1") }));
|
||||
const auth = build(post);
|
||||
|
||||
const tokens = await Promise.all(Array.from({ length: 20 }, () => auth.getValidAccessToken()));
|
||||
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
expect(new Set(tokens)).toEqual(new Set(["token-1"]));
|
||||
});
|
||||
|
||||
it("refuses to call the gateway when EIMS is disabled", async () => {
|
||||
const post = jest.fn();
|
||||
await expect(build(post, cfg({ enabled: false })).getValidAccessToken()).rejects.toThrow(
|
||||
/EIMS integration is disabled/,
|
||||
);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a 200 response that carries no access token", async () => {
|
||||
const post = jest.fn().mockReturnValue(of({ data: { data: {}, status: "SUCCESS" } }));
|
||||
await expect(build(post).getValidAccessToken()).rejects.toThrow(/returned no accessToken/);
|
||||
});
|
||||
|
||||
it("surfaces gateway errors without leaking credentials or the envelope", async () => {
|
||||
const post = jest.fn().mockReturnValue(
|
||||
throwError(() =>
|
||||
axiosErr(401, {
|
||||
message: "GATEWAY ERROR",
|
||||
statusCode: 401,
|
||||
code: "4400",
|
||||
details: [{ errorMessage: "Invalid Credentials" }],
|
||||
// Fields the gateway must never echo back into our logs or exceptions:
|
||||
signature: "SIGNATURE",
|
||||
certificate: "CERTIFICATE",
|
||||
accessToken: "leaked-token",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const error = (await build(post)
|
||||
.getValidAccessToken()
|
||||
.catch((e: Error) => e)) as Error & { response?: unknown };
|
||||
const serialized = JSON.stringify({ message: error.message, response: error.response });
|
||||
|
||||
expect(error.message).toContain("EIMS login failed (401)");
|
||||
expect(error.message).toContain("Invalid Credentials");
|
||||
for (const secret of [CLIENT_SECRET, API_KEY, "SIGNATURE", "CERTIFICATE", "leaked-token"]) {
|
||||
expect(serialized).not.toContain(secret);
|
||||
}
|
||||
});
|
||||
|
||||
it("maps a timeout to a TIMEOUT failure without a status", async () => {
|
||||
const timeout = new AxiosError("timeout of 30000ms exceeded", "ECONNABORTED");
|
||||
const post = jest.fn().mockReturnValue(throwError(() => timeout));
|
||||
|
||||
await expect(build(post).getValidAccessToken()).rejects.toThrow(/EIMS login timed out/);
|
||||
});
|
||||
|
||||
it("maps an unreachable gateway to a NETWORK failure", async () => {
|
||||
const refused = new AxiosError("connect ECONNREFUSED", "ECONNREFUSED");
|
||||
const post = jest.fn().mockReturnValue(throwError(() => refused));
|
||||
|
||||
await expect(build(post).getValidAccessToken()).rejects.toThrow(/could not reach the gateway/);
|
||||
});
|
||||
});
|
||||
116
apps/edr-freight-api/src/modules/eims/eims-auth.service.ts
Normal file
116
apps/edr-freight-api/src/modules/eims/eims-auth.service.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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<string> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly http: HttpService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly signer: EimsSignerService,
|
||||
) {}
|
||||
|
||||
private get cfg(): EimsConfig {
|
||||
return this.config.get<EimsConfig>("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<string> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the cached token — called after a 401 so the next request re-authenticates. */
|
||||
invalidate(): void {
|
||||
this.cache = null;
|
||||
}
|
||||
|
||||
private async login(): Promise<string> {
|
||||
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<EimsLoginResponse>(`${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.
|
||||
this.cache = {
|
||||
accessToken,
|
||||
expiresAt: Date.now() + Math.max(expiresIn * 1000 - cfg.tokenSkewMs, 1000),
|
||||
};
|
||||
this.logger.log(`EIMS login succeeded; token cached for ~${expiresIn}s`);
|
||||
return accessToken;
|
||||
}
|
||||
}
|
||||
67
apps/edr-freight-api/src/modules/eims/eims-client.service.ts
Normal file
67
apps/edr-freight-api/src/modules/eims/eims-client.service.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
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 { EimsAuthService } from "./eims-auth.service";
|
||||
import { EimsSignerService, toSignedBody } from "./eims-signer.service";
|
||||
import { toEimsApiException } from "./eims.errors";
|
||||
|
||||
/**
|
||||
* Foundation for EIMS's bearer-authenticated endpoints (`/v1/register`, `/v1/verify`, …).
|
||||
*
|
||||
* Login is not routed through here: `/auth/login` carries no bearer token and lives in
|
||||
* `EimsAuthService`. Nothing calls `postSigned` yet — invoice registration is a later phase.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EimsClientService {
|
||||
private readonly logger = new Logger(EimsClientService.name);
|
||||
|
||||
constructor(
|
||||
private readonly http: HttpService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly auth: EimsAuthService,
|
||||
private readonly signer: EimsSignerService,
|
||||
) {}
|
||||
|
||||
private get cfg(): EimsConfig {
|
||||
return this.config.get<EimsConfig>("eims")!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign `request`, POST it to `path` with a valid bearer token, and return the parsed response.
|
||||
* A 401 invalidates the cached token and retries exactly once.
|
||||
*/
|
||||
async postSigned<TRequest, TResponse>(path: string, request: TRequest): Promise<TResponse> {
|
||||
return this.send<TRequest, TResponse>(path, request, false);
|
||||
}
|
||||
|
||||
private async send<TRequest, TResponse>(
|
||||
path: string,
|
||||
request: TRequest,
|
||||
isRetry: boolean,
|
||||
): Promise<TResponse> {
|
||||
const cfg = this.cfg;
|
||||
const token = await this.auth.getValidAccessToken();
|
||||
const body = toSignedBody(this.signer.signRequest(request));
|
||||
|
||||
try {
|
||||
const res = await firstValueFrom(
|
||||
this.http.post<TResponse>(`${cfg.baseUrl}${path}`, body, {
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
timeout: cfg.httpTimeoutMs,
|
||||
}),
|
||||
);
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
const mapped = toEimsApiException(err, `POST ${path}`);
|
||||
if (mapped.kind === "AUTH" && !isRetry) {
|
||||
this.logger.warn(`EIMS rejected the token on ${path}; re-authenticating once`);
|
||||
this.auth.invalidate();
|
||||
return this.send<TRequest, TResponse>(path, request, true);
|
||||
}
|
||||
this.logger.error(mapped.message);
|
||||
throw mapped;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { KeyObject, createPrivateKey } from "node:crypto";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { EimsConfigException } from "./eims.errors";
|
||||
|
||||
/**
|
||||
* Loads the INSA-issued EIMS credentials from disk, once, and keeps them in memory.
|
||||
*
|
||||
* The certificate is sent as base64 of the **exact bytes of the issued file** — it is deliberately
|
||||
* never parsed, re-encoded or re-exported, because that is what produced a working live login.
|
||||
* The private key never leaves this process: it is only ever used to produce a signature.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EimsCredentialsProvider {
|
||||
private readonly logger = new Logger(EimsCredentialsProvider.name);
|
||||
private privateKey: KeyObject | null = null;
|
||||
private certificateBase64: string | null = null;
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
private get cfg(): EimsConfig {
|
||||
return this.config.get<EimsConfig>("eims")!;
|
||||
}
|
||||
|
||||
/** RSA private key, parsed once. Throws a config error if the path is missing or unusable. */
|
||||
getPrivateKey(): KeyObject {
|
||||
if (this.privateKey) return this.privateKey;
|
||||
|
||||
const path = this.cfg.privateKeyPath;
|
||||
if (!path) throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set");
|
||||
|
||||
let key: KeyObject;
|
||||
try {
|
||||
key = createPrivateKey(readFileSync(path));
|
||||
} catch (err) {
|
||||
// The path is operational information, not a secret; the key material never appears.
|
||||
throw new EimsConfigException(
|
||||
`EIMS private key at ${path} could not be read or parsed: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
if (key.asymmetricKeyType !== "rsa") {
|
||||
throw new EimsConfigException(
|
||||
`EIMS private key at ${path} is ${key.asymmetricKeyType ?? "of unknown type"}; EIMS requires RSA`,
|
||||
);
|
||||
}
|
||||
|
||||
this.privateKey = key;
|
||||
this.logger.log(`EIMS private key loaded (RSA-${key.asymmetricKeyDetails?.modulusLength ?? "?"})`);
|
||||
return key;
|
||||
}
|
||||
|
||||
/** Base64 of the certificate file's exact bytes. No parsing, no re-encoding. */
|
||||
getCertificateBase64(): string {
|
||||
if (this.certificateBase64) return this.certificateBase64;
|
||||
|
||||
const path = this.cfg.certificatePath;
|
||||
if (!path) throw new EimsConfigException("EIMS_CERTIFICATE_PATH is not set");
|
||||
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
bytes = readFileSync(path);
|
||||
} catch (err) {
|
||||
throw new EimsConfigException(
|
||||
`EIMS certificate at ${path} could not be read: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
if (bytes.length === 0) {
|
||||
throw new EimsConfigException(`EIMS certificate at ${path} is empty`);
|
||||
}
|
||||
|
||||
this.certificateBase64 = bytes.toString("base64");
|
||||
this.logger.log(`EIMS certificate bundle loaded (${bytes.length} bytes)`);
|
||||
return this.certificateBase64;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createVerify, generateKeyPairSync } from "node:crypto";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { EimsCredentialsProvider } from "./eims-credentials.provider";
|
||||
import { EimsSignerService, toSignedBody } from "./eims-signer.service";
|
||||
|
||||
/**
|
||||
* Test-only key material: generated per run, never a production key. The "certificate" fixture is
|
||||
* an arbitrary byte blob — the point is that its exact bytes survive base64 round-tripping, not
|
||||
* that it is a valid X.509 chain.
|
||||
*/
|
||||
const CERTIFICATE_FIXTURE = "Subject: CN=TEST\n-----BEGIN CERTIFICATE-----\nZm9vYmFy\n-----END CERTIFICATE-----\n";
|
||||
|
||||
let dir: string;
|
||||
let keyPath: string;
|
||||
let certPath: string;
|
||||
let publicKeyPem: string;
|
||||
let signer: EimsSignerService;
|
||||
|
||||
beforeAll(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "eims-signer-"));
|
||||
keyPath = join(dir, "private_key.key");
|
||||
certPath = join(dir, "certificate.pem.txt");
|
||||
|
||||
const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||
writeFileSync(keyPath, privateKey.export({ type: "pkcs8", format: "pem" }));
|
||||
writeFileSync(certPath, CERTIFICATE_FIXTURE, "utf8");
|
||||
publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
|
||||
|
||||
const config = {
|
||||
get: () => ({ privateKeyPath: keyPath, certificatePath: certPath }),
|
||||
} as unknown as ConfigService;
|
||||
signer = new EimsSignerService(new EimsCredentialsProvider(config));
|
||||
});
|
||||
|
||||
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
const login = () => ({ clientId: "cid", clientSecret: "secret", apikey: "key", tin: "0000000000" });
|
||||
|
||||
const verify = (payload: string, signature: string): boolean =>
|
||||
createVerify("RSA-SHA512").update(payload, "utf8").verify(publicKeyPem, signature, "base64");
|
||||
|
||||
describe("EimsSignerService", () => {
|
||||
it("produces a signature that verifies against the matching public key", () => {
|
||||
const signed = signer.signRequest(login());
|
||||
expect(verify(JSON.stringify(signed.request), signed.signature)).toBe(true);
|
||||
});
|
||||
|
||||
it("fails verification when a single request field changes", () => {
|
||||
const signed = signer.signRequest(login());
|
||||
const tampered = JSON.stringify({ ...signed.request, tin: "9999999999" });
|
||||
expect(verify(tampered, signed.signature)).toBe(false);
|
||||
});
|
||||
|
||||
it("emits a 256-byte signature for an RSA-2048 key", () => {
|
||||
const signed = signer.signRequest(login());
|
||||
expect(Buffer.from(signed.signature, "base64")).toHaveLength(256);
|
||||
});
|
||||
|
||||
it("sends the certificate as base64 of the file's exact bytes", () => {
|
||||
const signed = signer.signRequest(login());
|
||||
expect(signed.certificate).toBe(readFileSync(certPath).toString("base64"));
|
||||
expect(Buffer.from(signed.certificate, "base64").equals(readFileSync(certPath))).toBe(true);
|
||||
});
|
||||
|
||||
it("signs the inner request only, and the wire body carries those exact bytes", () => {
|
||||
const signed = signer.signRequest(login());
|
||||
const body = toSignedBody(signed);
|
||||
|
||||
// The signed string appears verbatim inside the transmitted envelope.
|
||||
expect(body).toContain(`"request":${JSON.stringify(signed.request)}`);
|
||||
// Compact, never pretty-printed.
|
||||
expect(body).not.toMatch(/\n/);
|
||||
expect(JSON.parse(body)).toEqual({
|
||||
request: login(),
|
||||
signature: signed.signature,
|
||||
certificate: signed.certificate,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mutate the request object", () => {
|
||||
const request = login();
|
||||
const signed = signer.signRequest(request);
|
||||
expect(signed.request).toBe(request);
|
||||
expect(request).toEqual(login());
|
||||
});
|
||||
|
||||
it("reuses the loaded key and certificate across calls", () => {
|
||||
const first = signer.signRequest(login());
|
||||
const second = signer.signRequest(login());
|
||||
// PKCS#1 v1.5 is deterministic: same key + same payload ⇒ identical signature.
|
||||
expect(second.signature).toBe(first.signature);
|
||||
expect(second.certificate).toBe(first.certificate);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EimsCredentialsProvider", () => {
|
||||
const providerFor = (paths: { privateKeyPath?: string; certificatePath?: string }) =>
|
||||
new EimsCredentialsProvider({ get: () => paths } as unknown as ConfigService);
|
||||
|
||||
it("fails clearly when the key path is unset", () => {
|
||||
expect(() => providerFor({}).getPrivateKey()).toThrow(/EIMS_PRIVATE_KEY_PATH is not set/);
|
||||
});
|
||||
|
||||
it("fails clearly when the key file is missing", () => {
|
||||
expect(() => providerFor({ privateKeyPath: join(dir, "nope.key") }).getPrivateKey()).toThrow(
|
||||
/could not be read or parsed/,
|
||||
);
|
||||
});
|
||||
|
||||
it("fails clearly when the certificate file is empty", () => {
|
||||
const emptyPath = join(dir, "empty.txt");
|
||||
writeFileSync(emptyPath, "");
|
||||
expect(() => providerFor({ certificatePath: emptyPath }).getCertificateBase64()).toThrow(/is empty/);
|
||||
});
|
||||
});
|
||||
37
apps/edr-freight-api/src/modules/eims/eims-signer.service.ts
Normal file
37
apps/edr-freight-api/src/modules/eims/eims-signer.service.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { createSign } from "node:crypto";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { EimsCredentialsProvider } from "./eims-credentials.provider";
|
||||
import { EimsSignedRequest } from "./eims.types";
|
||||
|
||||
/**
|
||||
* Signs EIMS request objects, reproducing the process that produced a working live access token:
|
||||
*
|
||||
* 1. compact `JSON.stringify` of the **inner** request object only,
|
||||
* 2. those exact UTF-8 bytes,
|
||||
* 3. RSA + SHA-512 (`SHA512withRSA`, PKCS#1 v1.5 — Node's default RSA padding),
|
||||
* 4. base64 of the raw signature bytes (256 bytes for an RSA-2048 key),
|
||||
* 5. base64 of the certificate file's exact bytes.
|
||||
*
|
||||
* The outer `{request, signature, certificate}` envelope is never itself signed, and the request
|
||||
* object is never mutated after serialization.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EimsSignerService {
|
||||
constructor(private readonly credentials: EimsCredentialsProvider) {}
|
||||
|
||||
signRequest<T>(request: T): EimsSignedRequest<T> {
|
||||
const payload = JSON.stringify(request);
|
||||
const signature = createSign("RSA-SHA512")
|
||||
.update(payload, "utf8")
|
||||
.sign(this.credentials.getPrivateKey(), "base64");
|
||||
|
||||
return { request, signature, certificate: this.credentials.getCertificateBase64() };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact wire body for a signed envelope. Serializing here (rather than handing axios an object)
|
||||
* keeps one serializer in play: the `request` segment of this string is byte-identical to the
|
||||
* string that was signed.
|
||||
*/
|
||||
export const toSignedBody = <T>(signed: EimsSignedRequest<T>): string => JSON.stringify(signed);
|
||||
89
apps/edr-freight-api/src/modules/eims/eims.errors.ts
Normal file
89
apps/edr-freight-api/src/modules/eims/eims.errors.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { BadGatewayException, ServiceUnavailableException } from "@nestjs/common";
|
||||
import { AxiosError } from "axios";
|
||||
import { EimsErrorResponse } from "./eims.types";
|
||||
|
||||
export type EimsFailureKind =
|
||||
| "NETWORK"
|
||||
| "TIMEOUT"
|
||||
| "SCHEMA_VALIDATION"
|
||||
| "AUTH"
|
||||
| "FORBIDDEN"
|
||||
| "RULE_VALIDATION"
|
||||
| "SERVER"
|
||||
| "UNKNOWN";
|
||||
|
||||
/** Raised when EIMS is disabled or its credential files are unusable. */
|
||||
export class EimsConfigException extends ServiceUnavailableException {
|
||||
constructor(message: string) {
|
||||
super({ code: "EIMS_NOT_CONFIGURED", message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed EIMS call. Carries only the gateway's own error reporting — never the request body,
|
||||
* signature, certificate, bearer token or any configured secret.
|
||||
*/
|
||||
export class EimsApiException extends BadGatewayException {
|
||||
constructor(
|
||||
readonly kind: EimsFailureKind,
|
||||
message: string,
|
||||
readonly httpStatus?: number,
|
||||
readonly details?: EimsErrorResponse,
|
||||
) {
|
||||
super({ code: `EIMS_${kind}`, message });
|
||||
}
|
||||
}
|
||||
|
||||
const SAFE_KEYS = ["message", "statusCode", "code", "details", "body"] as const;
|
||||
|
||||
/**
|
||||
* Keep only the gateway's error-reporting fields. Anything else a response might carry — an echoed
|
||||
* request, a token, a signature — is dropped before it can reach a log or an exception payload.
|
||||
*/
|
||||
export function redactEimsBody(data: unknown): EimsErrorResponse | undefined {
|
||||
if (!data || typeof data !== "object") return undefined;
|
||||
const source = data as Record<string, unknown>;
|
||||
const safe: Record<string, unknown> = {};
|
||||
for (const key of SAFE_KEYS) {
|
||||
if (source[key] !== undefined) safe[key] = source[key];
|
||||
}
|
||||
return Object.keys(safe).length > 0 ? (safe as EimsErrorResponse) : undefined;
|
||||
}
|
||||
|
||||
const kindFor = (status: number): EimsFailureKind => {
|
||||
if (status === 400) return "SCHEMA_VALIDATION";
|
||||
if (status === 401) return "AUTH";
|
||||
if (status === 403) return "FORBIDDEN";
|
||||
if (status === 406) return "RULE_VALIDATION";
|
||||
if (status >= 500) return "SERVER";
|
||||
return "UNKNOWN";
|
||||
};
|
||||
|
||||
/** First error line the gateway gives us, whichever shape it used. */
|
||||
const describe = (body: EimsErrorResponse | undefined): string => {
|
||||
if (!body) return "no error body";
|
||||
const detail = body.details?.find((d) => d.errorMessage)?.errorMessage;
|
||||
return [body.message, body.code && `code=${body.code}`, detail].filter(Boolean).join(" ") || "no error body";
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalise anything thrown by an EIMS HTTP call into an `EimsApiException`. `operation` is a
|
||||
* short label such as `"login"` or `"POST /v1/register"` — never a payload.
|
||||
*/
|
||||
export function toEimsApiException(err: unknown, operation: string): EimsApiException {
|
||||
if (err instanceof EimsApiException) return err;
|
||||
|
||||
if (err instanceof AxiosError) {
|
||||
if (err.code === "ECONNABORTED" || err.code === "ETIMEDOUT") {
|
||||
return new EimsApiException("TIMEOUT", `EIMS ${operation} timed out`);
|
||||
}
|
||||
if (!err.response) {
|
||||
return new EimsApiException("NETWORK", `EIMS ${operation} could not reach the gateway (${err.code ?? "no code"})`);
|
||||
}
|
||||
const status = err.response.status;
|
||||
const body = redactEimsBody(err.response.data);
|
||||
return new EimsApiException(kindFor(status), `EIMS ${operation} failed (${status}): ${describe(body)}`, status, body);
|
||||
}
|
||||
|
||||
return new EimsApiException("UNKNOWN", `EIMS ${operation} failed: ${(err as Error)?.message ?? "unknown error"}`);
|
||||
}
|
||||
19
apps/edr-freight-api/src/modules/eims/eims.module.ts
Normal file
19
apps/edr-freight-api/src/modules/eims/eims.module.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
import { EimsClientService } from "./eims-client.service";
|
||||
import { EimsCredentialsProvider } from "./eims-credentials.provider";
|
||||
import { EimsSignerService } from "./eims-signer.service";
|
||||
|
||||
/**
|
||||
* MoR EIMS e-invoicing transport. Exports only what other modules will consume; the credential
|
||||
* loader and signer stay internal so the private key has exactly one user.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule.register({ timeout: Number(process.env.EIMS_HTTP_TIMEOUT_MS) || 30_000 }),
|
||||
],
|
||||
providers: [EimsCredentialsProvider, EimsSignerService, EimsAuthService, EimsClientService],
|
||||
exports: [EimsAuthService, EimsClientService],
|
||||
})
|
||||
export class EimsModule {}
|
||||
46
apps/edr-freight-api/src/modules/eims/eims.types.ts
Normal file
46
apps/edr-freight-api/src/modules/eims/eims.types.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Wire types for the MoR EIMS gateway, taken from the supplied Postman collection.
|
||||
*
|
||||
* Every protected payload is the same envelope: the business object under `request`, a base64
|
||||
* RSA-SHA512 signature over the *inner* object only, and the base64 certificate bundle.
|
||||
*/
|
||||
export interface EimsSignedRequest<T> {
|
||||
request: T;
|
||||
signature: string;
|
||||
certificate: string;
|
||||
}
|
||||
|
||||
/** Inner request of `POST /auth/login`. Note the lowercase `apikey` — that is the wire name. */
|
||||
export interface EimsLoginRequest {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
apikey: string;
|
||||
tin: string;
|
||||
}
|
||||
|
||||
export interface EimsLoginData {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
/** Observed as a UUID on login and `null` on refresh; unused today. */
|
||||
encryptionKey: string | null;
|
||||
/** Seconds. Observed value: 3600. */
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
export interface EimsLoginResponse {
|
||||
data: EimsLoginData;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error bodies differ per failure mode: gateway errors carry `message`/`code`/`details`,
|
||||
* schema errors carry a JSON-Schema violation array under `body`, rule errors carry
|
||||
* `[{portion, errorMessage[]}]` under `body`. Only these fields are ever surfaced or logged.
|
||||
*/
|
||||
export interface EimsErrorResponse {
|
||||
message?: string;
|
||||
statusCode?: number;
|
||||
code?: string;
|
||||
details?: { errorMessage?: string; field?: string }[];
|
||||
body?: unknown;
|
||||
}
|
||||
Reference in New Issue
Block a user