feat(portal-bff): msal confidential client provider in AuthModule #104

Merged
julien merged 1 commits from feat/portal-bff/auth-msal-client into main 2026-05-12 10:34:24 +02:00
5 changed files with 199 additions and 16 deletions
+22 -4
View File
@@ -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<string, string | undefined> = {};
@@ -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<EntraConfig>(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<ConfidentialClientApplication>(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/);
});
});
+61 -12
View File
@@ -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 {}
@@ -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 };
+1
View File
@@ -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",
+99
View File
@@ -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: {}