From aeda239b1c92c2e0f91c631cc5123189da5cc769 Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Tue, 12 May 2026 09:58:59 +0200 Subject: [PATCH] feat(portal-bff): msal confidential client provider in AuthModule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second step of ADR-0009 wiring. AuthModule now exposes the `@azure/msal-node` confidential client alongside the parsed Entra config — the building block the upcoming OIDC routes inject to issue the auth-code URL, exchange the callback code for tokens, and acquire downstream tokens on behalf of the user. What lands: - `@azure/msal-node` added as a direct dependency (^5.2.1). - `apps/portal-bff/src/auth/msal-client.token.ts` — `MSAL_CLIENT` string token + `ConfidentialClientApplication` type re-export. Mirrors the `ENTRA_CONFIG` token shape from PR #102. - AuthModule grows a factory provider for `MSAL_CLIENT`: - Injects `ENTRA_CONFIG` + nestjs-pino `Logger`. - Builds a `ConfidentialClientApplication` with `clientId`, `authority`, `clientSecret` from the parsed config. - Wires `system.loggerOptions.loggerCallback` to forward MSAL's internal log lines into the Pino stream (per ADR-0012) — Error → logger.error, Warning → logger.warn, Verbose / Trace → logger.debug, Info → logger.log. PII logging is disabled by default so tokens / user identifiers never leak into our structured log records. - Sets MSAL's `logLevel` to Info — Pino's own threshold re-filters from there. - All MSAL log lines carry the `msal` Pino context for easy isolation in log queries. - `AuthModule.exports` extended to include `MSAL_CLIENT`. Verification: - `nx run-many -t lint test build --projects=portal-bff` — green. - 30/30 specs (was 29; +1 covering MSAL client construction). - New spec imports `nestjs-pino`'s `LoggerModule.forRoot({ pinoHttp: { level: 'silent' } })` to provide the same Logger the production app supplies via `ObservabilityModule`, without flooding test stdout. Two tests assert the provider tree resolves correctly (ENTRA_CONFIG + MSAL_CLIENT) and one re-checks the missing-env failure mode still propagates through the new factory. Construction is cheap — MSAL Node defers authority discovery to the first auth call — so the client is built eagerly at module init. The factory is injection-only; no MSAL methods get invoked yet. Routes land in the next PR. --- apps/portal-bff/src/auth/auth.module.spec.ts | 26 ++++- apps/portal-bff/src/auth/auth.module.ts | 73 +++++++++++--- apps/portal-bff/src/auth/msal-client.token.ts | 16 +++ package.json | 1 + pnpm-lock.yaml | 99 +++++++++++++++++++ 5 files changed, 199 insertions(+), 16 deletions(-) create mode 100644 apps/portal-bff/src/auth/msal-client.token.ts diff --git a/apps/portal-bff/src/auth/auth.module.spec.ts b/apps/portal-bff/src/auth/auth.module.spec.ts index ae96ca3..bcd76d0 100644 --- a/apps/portal-bff/src/auth/auth.module.spec.ts +++ b/apps/portal-bff/src/auth/auth.module.spec.ts @@ -1,6 +1,9 @@ +import { ConfidentialClientApplication } from '@azure/msal-node'; import { Test } from '@nestjs/testing'; +import { LoggerModule } from 'nestjs-pino'; import { AuthModule } from './auth.module'; import { ENTRA_CONFIG, type EntraConfig } from './entra-config.token'; +import { MSAL_CLIENT } from './msal-client.token'; const VALID = { ENTRA_INSTANCE_URL: 'https://login.microsoftonline.com/', @@ -11,6 +14,17 @@ const VALID = { ENTRA_POST_LOGOUT_REDIRECT_URI: 'http://localhost:4200/', }; +// AuthModule's MSAL_CLIENT factory injects the Pino-backed `Logger` +// (which the production app supplies through `ObservabilityModule`). +// Importing `LoggerModule.forRoot({ pinoHttp: { level: 'silent' } })` +// here registers the same provider for the testing module without +// flooding test stdout. +async function compile() { + return Test.createTestingModule({ + imports: [LoggerModule.forRoot({ pinoHttp: { level: 'silent' } }), AuthModule], + }).compile(); +} + describe('AuthModule', () => { const originalEnv: Record = {}; @@ -33,16 +47,20 @@ describe('AuthModule', () => { }); it('provides EntraConfig via the ENTRA_CONFIG token', async () => { - const ref = await Test.createTestingModule({ imports: [AuthModule] }).compile(); + const ref = await compile(); const config = ref.get(ENTRA_CONFIG); expect(config.clientId).toBe(VALID.ENTRA_CLIENT_ID); expect(config.authority).toBe(`${VALID.ENTRA_INSTANCE_URL}${VALID.ENTRA_TENANT_ID}`); }); + it('provides a ConfidentialClientApplication via the MSAL_CLIENT token', async () => { + const ref = await compile(); + const client = ref.get(MSAL_CLIENT); + expect(client).toBeInstanceOf(ConfidentialClientApplication); + }); + it('fails to compile when an env var is missing', async () => { delete process.env['ENTRA_CLIENT_SECRET']; - await expect(Test.createTestingModule({ imports: [AuthModule] }).compile()).rejects.toThrow( - /ENTRA_CLIENT_SECRET/, - ); + await expect(compile()).rejects.toThrow(/ENTRA_CLIENT_SECRET/); }); }); diff --git a/apps/portal-bff/src/auth/auth.module.ts b/apps/portal-bff/src/auth/auth.module.ts index c88e7d9..93d24ee 100644 --- a/apps/portal-bff/src/auth/auth.module.ts +++ b/apps/portal-bff/src/auth/auth.module.ts @@ -1,20 +1,29 @@ +import { ConfidentialClientApplication, LogLevel } from '@azure/msal-node'; import { Module } from '@nestjs/common'; +import { Logger } from 'nestjs-pino'; import { assertEntraConfig } from '../config/check-entra-config'; -import { ENTRA_CONFIG } from './entra-config.token'; +import { ENTRA_CONFIG, type EntraConfig } from './entra-config.token'; +import { MSAL_CLIENT } from './msal-client.token'; /** - * Auth module — owns the Entra ID configuration and (in subsequent - * PRs) the MSAL Node confidential client, the OIDC routes, the + * Auth module — owns the Entra ID configuration, the MSAL Node + * confidential client, and (in subsequent PRs) the OIDC routes, the * session integration, and the route guards. Per ADR-0009. * - * v1 of this module exposes a single provider: the parsed Entra - * config, available via the `ENTRA_CONFIG` injection token. The - * factory delegates to `assertEntraConfig()` — the same validator - * `main.ts` calls at boot — so the typed config object that - * consumers receive is guaranteed to be complete and well-formed. - * The duplicate call is intentional: boot-time fails fast, the - * factory parses again for the DI result. Both reads are - * idempotent and trivially cheap. + * v1 providers: + * + * - `ENTRA_CONFIG` — the parsed, validated Entra app-registration + * config (PR #102). + * - `MSAL_CLIENT` — a `ConfidentialClientApplication` instance + * wired with that config plus a logger callback that forwards + * MSAL's internal log lines into the Pino stream so auth flow + * diagnostics land alongside the rest of the BFF logs (per + * ADR-0012). PII logging is disabled by default — MSAL won't + * include tokens or user identifiers in the messages it emits. + * + * The module stays non-global: modules state "I depend on auth" by + * importing it. Re-exports both tokens so a single + * `imports: [AuthModule]` is enough to consume either. */ @Module({ providers: [ @@ -22,7 +31,47 @@ import { ENTRA_CONFIG } from './entra-config.token'; provide: ENTRA_CONFIG, useFactory: () => assertEntraConfig(), }, + { + provide: MSAL_CLIENT, + inject: [ENTRA_CONFIG, Logger], + useFactory: (config: EntraConfig, logger: Logger) => + new ConfidentialClientApplication({ + auth: { + clientId: config.clientId, + authority: config.authority, + clientSecret: config.clientSecret, + }, + system: { + loggerOptions: { + piiLoggingEnabled: false, + logLevel: LogLevel.Info, + loggerCallback: (level, message) => { + // MSAL levels: 0=Error, 1=Warning, 2=Info, 3=Verbose, 4=Trace. + // Forward to Pino with the matching level. The whole + // payload lives under the `msal` context key so a log + // search filtering on `msal` returns every MSAL line + // without false positives from app code. + const ctx = 'msal'; + switch (level) { + case LogLevel.Error: + logger.error(message, ctx); + break; + case LogLevel.Warning: + logger.warn(message, ctx); + break; + case LogLevel.Verbose: + case LogLevel.Trace: + logger.debug(message, ctx); + break; + default: + logger.log(message, ctx); + } + }, + }, + }, + }), + }, ], - exports: [ENTRA_CONFIG], + exports: [ENTRA_CONFIG, MSAL_CLIENT], }) export class AuthModule {} diff --git a/apps/portal-bff/src/auth/msal-client.token.ts b/apps/portal-bff/src/auth/msal-client.token.ts new file mode 100644 index 0000000..366662e --- /dev/null +++ b/apps/portal-bff/src/auth/msal-client.token.ts @@ -0,0 +1,16 @@ +import type { ConfidentialClientApplication } from '@azure/msal-node'; + +/** + * DI token used to inject the MSAL Node confidential client. + * + * Usage: + * @Inject(MSAL_CLIENT) private readonly msal: ConfidentialClientApplication + * + * The provider lives in `AuthModule`. Construction is cheap (no + * network call) so the client is built eagerly at module init; the + * lazy authority-discovery round-trip MSAL needs runs on the first + * actual auth method call (e.g. `getAuthCodeUrl`). + */ +export const MSAL_CLIENT = 'MSAL_CLIENT'; + +export type { ConfidentialClientApplication }; diff --git a/package.json b/package.json index 7c0258f..afaed6e 100644 --- a/package.json +++ b/package.json @@ -119,6 +119,7 @@ "@angular/localize": "~21.2.12", "@angular/platform-browser": "~21.2.0", "@angular/router": "~21.2.0", + "@azure/msal-node": "^5.2.1", "@nestjs/common": "^11.0.0", "@nestjs/core": "^11.0.0", "@nestjs/platform-express": "^11.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ccf2031..6e24797 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@angular/router': specifier: ~21.2.0 version: 21.2.12(@angular/common@21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.12(@angular/common@21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) + '@azure/msal-node': + specifier: ^5.2.1 + version: 5.2.1 '@nestjs/common': specifier: ^11.0.0 version: 11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -685,6 +688,14 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@azure/msal-common@16.6.1': + resolution: {integrity: sha512-VxKdEtUwDuLD0F1hOQP7kye0YadZxFJfv37Em440geEf/w9uggKnHpRrqwZJOdxmPUOdhZ9kyRtKuAJW8wUcRg==} + engines: {node: '>=0.8.0'} + + '@azure/msal-node@5.2.1': + resolution: {integrity: sha512-tmQiQ2HvtzaeLqYGy3BemiPOSGPY4wCy1IW5zDWITKSs/s35WEd7Zij/hCxvUdAOzj6U3qnyaGbYXY91ortFEQ==} + engines: {node: '>=20'} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -5097,6 +5108,9 @@ packages: buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -5756,6 +5770,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -7129,6 +7146,16 @@ packages: resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} engines: {'0': node >= 0.2.0} + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -7338,12 +7365,33 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.uniq@4.5.0: resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} @@ -10531,6 +10579,13 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} + '@azure/msal-common@16.6.1': {} + + '@azure/msal-node@5.2.1': + dependencies: + '@azure/msal-common': 16.6.1 + jsonwebtoken: 9.0.3 + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -15699,6 +15754,8 @@ snapshots: buffer-crc32@0.2.13: {} + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -16350,6 +16407,10 @@ snapshots: eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} effect@3.21.0: @@ -18175,6 +18236,30 @@ snapshots: jsonparse@1.3.1: {} + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.0 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -18418,10 +18503,24 @@ snapshots: lodash.debounce@4.0.8: {} + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + lodash.memoize@4.1.2: {} lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash.uniq@4.5.0: {} lodash@4.18.1: {}