@equinor/fusion-framework-module-msal 10.0.2 → 11.0.0-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +163 -0
- package/README.md +17 -368
- package/dist/esm/MsalConfigurator.js +176 -88
- package/dist/esm/MsalConfigurator.js.map +1 -1
- package/dist/esm/__tests__/MsalConfigurator.test.js +75 -0
- package/dist/esm/__tests__/MsalConfigurator.test.js.map +1 -1
- package/dist/esm/__tests__/create-proxy-provider.test.js +53 -0
- package/dist/esm/__tests__/create-proxy-provider.test.js.map +1 -0
- package/dist/esm/__tests__/mock/msal-mock.test.js +399 -0
- package/dist/esm/__tests__/mock/msal-mock.test.js.map +1 -0
- package/dist/esm/index.js +5 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/mock/MsalMockClient.js +467 -0
- package/dist/esm/mock/MsalMockClient.js.map +1 -0
- package/dist/esm/mock/MsalMockConfigurator.js +187 -0
- package/dist/esm/mock/MsalMockConfigurator.js.map +1 -0
- package/dist/esm/mock/create-mock-token.js +60 -0
- package/dist/esm/mock/create-mock-token.js.map +1 -0
- package/dist/esm/mock/create-msal-mock-client.js +20 -0
- package/dist/esm/mock/create-msal-mock-client.js.map +1 -0
- package/dist/esm/mock/decode-jwt-segment.js +22 -0
- package/dist/esm/mock/decode-jwt-segment.js.map +1 -0
- package/dist/esm/mock/index.js +30 -0
- package/dist/esm/mock/index.js.map +1 -0
- package/dist/esm/mock/module.js +38 -0
- package/dist/esm/mock/module.js.map +1 -0
- package/dist/esm/msal-config-schema.js +37 -0
- package/dist/esm/msal-config-schema.js.map +1 -0
- package/dist/esm/telemetry-config-schema.js +16 -0
- package/dist/esm/telemetry-config-schema.js.map +1 -0
- package/dist/esm/version.js +1 -1
- package/dist/esm/version.js.map +1 -1
- package/dist/esm/versioning/resolve-version.js +0 -1
- package/dist/esm/versioning/resolve-version.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/MsalConfigurator.d.ts +96 -20
- package/dist/types/__tests__/create-proxy-provider.test.d.ts +1 -0
- package/dist/types/__tests__/mock/msal-mock.test.d.ts +1 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/mock/MsalMockClient.d.ts +270 -0
- package/dist/types/mock/MsalMockConfigurator.d.ts +156 -0
- package/dist/types/mock/create-mock-token.d.ts +54 -0
- package/dist/types/mock/create-msal-mock-client.d.ts +14 -0
- package/dist/types/mock/decode-jwt-segment.d.ts +13 -0
- package/dist/types/mock/index.d.ts +29 -0
- package/dist/types/mock/module.d.ts +35 -0
- package/dist/types/msal-config-schema.d.ts +64 -0
- package/dist/types/telemetry-config-schema.d.ts +8 -0
- package/dist/types/version.d.ts +1 -1
- package/docs/api-reference.md +85 -0
- package/docs/auth-code-flow.md +86 -0
- package/docs/migration-v2-to-v4.md +115 -0
- package/docs/testing.md +167 -0
- package/docs/troubleshooting.md +17 -0
- package/docs/version-management.md +67 -0
- package/package.json +12 -5
- package/src/MsalConfigurator.ts +202 -115
- package/src/__tests__/MsalConfigurator.test.ts +106 -0
- package/src/__tests__/create-proxy-provider.test.ts +77 -0
- package/src/__tests__/mock/msal-mock.test.ts +544 -0
- package/src/index.ts +6 -0
- package/src/mock/MsalMockClient.ts +599 -0
- package/src/mock/MsalMockConfigurator.ts +241 -0
- package/src/mock/create-mock-token.ts +92 -0
- package/src/mock/create-msal-mock-client.ts +25 -0
- package/src/mock/decode-jwt-segment.ts +22 -0
- package/src/mock/index.ts +29 -0
- package/src/mock/module.ts +54 -0
- package/src/msal-config-schema.ts +81 -0
- package/src/telemetry-config-schema.ts +25 -0
- package/src/version.ts +1 -1
- package/src/versioning/resolve-version.ts +0 -1
- package/vitest.config.ts +1 -1
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConfigBuilderCallback,
|
|
3
|
+
ConfigBuilderCallbackArgs,
|
|
4
|
+
} from '@equinor/fusion-framework-module';
|
|
5
|
+
|
|
6
|
+
import type { IMsalClient } from '../MsalClient.interface';
|
|
7
|
+
import type { IMsalProvider } from '../MsalProvider.interface';
|
|
8
|
+
import type { MsalClientConfig } from '../MsalClient';
|
|
9
|
+
import { MsalConfigurator, type MsalConfig } from '../MsalConfigurator';
|
|
10
|
+
|
|
11
|
+
import { MsalMockClient, type MsalMockUser } from './MsalMockClient';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Declares the mock's own branch of the MSAL configuration.
|
|
15
|
+
*
|
|
16
|
+
* @remarks
|
|
17
|
+
* Merging into `MsalConfigExtension` is what lets `setAccount` record the
|
|
18
|
+
* user through the ordinary builder — `_set` derives its target from
|
|
19
|
+
* {@link MsalConfig}, so a key the type does not know about could only be set by
|
|
20
|
+
* casting past it.
|
|
21
|
+
*
|
|
22
|
+
* The schema strips `mock` when it validates, so a declaration made here travels
|
|
23
|
+
* the builder and stops there: it is readable from the raw configuration and
|
|
24
|
+
* absent from the one `MsalProvider` receives.
|
|
25
|
+
*/
|
|
26
|
+
declare module '../msal-config-schema' {
|
|
27
|
+
interface MsalConfigExtension {
|
|
28
|
+
mock?: {
|
|
29
|
+
/**
|
|
30
|
+
* The user to sign in, resolved if it was declared as a callback, or
|
|
31
|
+
* `null` when nobody is signed in.
|
|
32
|
+
*/
|
|
33
|
+
account?: MsalMockUser | null;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The client configuration used when a test declares none.
|
|
40
|
+
*
|
|
41
|
+
* @remarks
|
|
42
|
+
* `MsalClientConfig.auth.clientId` is required, so a mock still needs a client
|
|
43
|
+
* configuration to exist. Supplying a default is what lets an application boot
|
|
44
|
+
* under test without declaring credentials it does not have.
|
|
45
|
+
*/
|
|
46
|
+
const defaultMockClientConfig: MsalClientConfig = {
|
|
47
|
+
auth: {
|
|
48
|
+
clientId: 'fusion-mock-client',
|
|
49
|
+
tenantId: 'fusion-mock-tenant',
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The real MSAL configurator, backed by an in-process client.
|
|
55
|
+
*
|
|
56
|
+
* @remarks
|
|
57
|
+
* Nothing else changes: the same builder API, the same validation and the same
|
|
58
|
+
* `MsalProvider` are used. Only the boundary that would contact Entra ID is
|
|
59
|
+
* substituted, through the same
|
|
60
|
+
* {@link MsalConfigurator._createClient | _createClient} seam the real
|
|
61
|
+
* configurator builds its own client from — and from the same
|
|
62
|
+
* {@link MsalConfigurator._createClientConfig | _createClientConfig}, so
|
|
63
|
+
* `setClientConfig` means exactly what it means in production.
|
|
64
|
+
*
|
|
65
|
+
* A user named `Test User` is signed in by default, so an application boots
|
|
66
|
+
* without declaring anything.
|
|
67
|
+
*
|
|
68
|
+
* @example Name the signed-in user
|
|
69
|
+
* ```typescript
|
|
70
|
+
* enableMsalMock(configurator, (builder) => {
|
|
71
|
+
* builder.setAccount({ name: 'Ada Lovelace', username: 'ada@equinor.com' });
|
|
72
|
+
* });
|
|
73
|
+
* ```
|
|
74
|
+
*
|
|
75
|
+
* @example Configure the client exactly as in production
|
|
76
|
+
* ```typescript
|
|
77
|
+
* enableMsalMock(configurator, (builder) => {
|
|
78
|
+
* builder.setClientConfig({ auth: { clientId: 'my-app', tenantId: 'my-tenant' } });
|
|
79
|
+
* });
|
|
80
|
+
* ```
|
|
81
|
+
*
|
|
82
|
+
* @example Take full control of authentication
|
|
83
|
+
* ```typescript
|
|
84
|
+
* enableMsalMock(configurator, (builder) => {
|
|
85
|
+
* builder.setClient(new MyOwnMsalClient());
|
|
86
|
+
* });
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
export class MsalMockConfigurator extends MsalConfigurator {
|
|
90
|
+
/**
|
|
91
|
+
* Declares the user to sign in.
|
|
92
|
+
*
|
|
93
|
+
* @remarks
|
|
94
|
+
* Who is signed in is session state, not client configuration — which is what
|
|
95
|
+
* lets {@link MsalMockClient} take the same argument the real client takes: a
|
|
96
|
+
* client is configured with *what it talks to*, never with *who is signed in*.
|
|
97
|
+
*
|
|
98
|
+
* The user is therefore recorded on the configuration as `mock.account`, not
|
|
99
|
+
* on this builder, and is signed in on whichever client the module ends up
|
|
100
|
+
* authenticating through — wherever that client was built:
|
|
101
|
+
*
|
|
102
|
+
* - The client this builder builds, normally. The user is in place before
|
|
103
|
+
* `MsalProvider.initialize` runs, which is what makes the provider's own
|
|
104
|
+
* start-up path observable: with `signedOut` and `setRequiresAuth(true)`, a
|
|
105
|
+
* test sees the real automatic login run.
|
|
106
|
+
* - The **host's** client when the module is hoisted onto a host
|
|
107
|
+
* application's provider, because none is built here. An application inside
|
|
108
|
+
* a portal shares the portal's session, so this changes who the host sees
|
|
109
|
+
* signed in too, as it would in production.
|
|
110
|
+
* - A client supplied through {@link MsalConfigurator.setClient | setClient},
|
|
111
|
+
* when that client is a {@link MsalMockClient}.
|
|
112
|
+
*
|
|
113
|
+
* Throws when that client cannot represent a declared user, rather than
|
|
114
|
+
* failing quietly — a silent no-op is the whole failure mode this exists to
|
|
115
|
+
* prevent.
|
|
116
|
+
*
|
|
117
|
+
* Pass `null` when nobody is signed in, or `{ signedOut: true }` to keep an
|
|
118
|
+
* identity without a session — a later login then resolves as that user.
|
|
119
|
+
*
|
|
120
|
+
* @param account - The user, or an ordinary config-builder callback resolving it.
|
|
121
|
+
* @returns The builder, for chaining.
|
|
122
|
+
*
|
|
123
|
+
* @example Derive the user from the modules in scope
|
|
124
|
+
* ```typescript
|
|
125
|
+
* builder.setAccount(async ({ hasModule }) => ({
|
|
126
|
+
* name: hasModule('app') ? 'App User' : 'Portal User',
|
|
127
|
+
* }));
|
|
128
|
+
* ```
|
|
129
|
+
*/
|
|
130
|
+
public setAccount(
|
|
131
|
+
account: MsalMockUser | null | ConfigBuilderCallback<MsalMockUser | null>,
|
|
132
|
+
): this {
|
|
133
|
+
this._set('mock.account', account);
|
|
134
|
+
return this;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Signs the declared user in on the client the module authenticates through.
|
|
139
|
+
*
|
|
140
|
+
* @remarks
|
|
141
|
+
* Deliberately not done while the client is built: that would assume the scope
|
|
142
|
+
* declaring the user is the scope building the client, which is exactly what
|
|
143
|
+
* is not true when an application is tested inside a portal. The host built
|
|
144
|
+
* that client, in a scope this builder never sees, so the client has to be
|
|
145
|
+
* located rather than assumed.
|
|
146
|
+
*
|
|
147
|
+
* @param account - The user to sign in, or `null` when nobody is.
|
|
148
|
+
* @param config - The validated configuration, carrying the client when one was built.
|
|
149
|
+
* @param init - The builder arguments, carrying the host reference when hoisted.
|
|
150
|
+
* @throws When the resolved client is not a {@link MsalMockClient}.
|
|
151
|
+
*/
|
|
152
|
+
#signIn(
|
|
153
|
+
account: MsalMockUser | null,
|
|
154
|
+
config: MsalConfig,
|
|
155
|
+
init?: ConfigBuilderCallbackArgs,
|
|
156
|
+
): void {
|
|
157
|
+
const host = (init?.ref as { auth?: IMsalProvider } | undefined)?.auth;
|
|
158
|
+
const client = config.client ?? host?.client;
|
|
159
|
+
|
|
160
|
+
// Reject a real client because mock account state cannot be applied to it.
|
|
161
|
+
if (!(client instanceof MsalMockClient)) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
'MsalMockConfigurator: cannot sign a user in, because this module does not authenticate through a mock client. Declare the user where that client is configured instead.',
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
client.setUser(account);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Assembles the configuration, then signs the declared user in.
|
|
172
|
+
*
|
|
173
|
+
* @remarks
|
|
174
|
+
* Stands a client configuration in first when this builder is the one that
|
|
175
|
+
* will build a client: `MsalClientConfig.auth.clientId` is required to build
|
|
176
|
+
* any client at all and a test has no real credentials to declare. It then
|
|
177
|
+
* flows through the very same
|
|
178
|
+
* {@link MsalConfigurator._createClientConfig | _createClientConfig}
|
|
179
|
+
* enrichment the real client is built from, and anything declared through
|
|
180
|
+
* {@link MsalConfigurator.setClientConfig | setClientConfig} wins — exactly as
|
|
181
|
+
* in production.
|
|
182
|
+
*
|
|
183
|
+
* Doing that here rather than in the constructor is deliberate: a hoisted
|
|
184
|
+
* module authenticates through the host and builds no client, so it must not
|
|
185
|
+
* look configured either.
|
|
186
|
+
*
|
|
187
|
+
* The user is read from `rawConfig`, because the schema strips `mock` when it
|
|
188
|
+
* validates — the key exists to carry a test's declaration through the
|
|
189
|
+
* builder, never to reach the provider.
|
|
190
|
+
*
|
|
191
|
+
* @param rawConfig - The raw configuration to process.
|
|
192
|
+
* @param init - The builder arguments, carrying the host reference when hoisted.
|
|
193
|
+
* @returns The processed and validated configuration.
|
|
194
|
+
*/
|
|
195
|
+
override async _processConfig(
|
|
196
|
+
rawConfig: MsalConfig,
|
|
197
|
+
init?: ConfigBuilderCallbackArgs,
|
|
198
|
+
): Promise<MsalConfig> {
|
|
199
|
+
// Supply mock credentials only when this builder owns client construction.
|
|
200
|
+
if (!this._isHoisted(init) && !this.getClientConfig()) {
|
|
201
|
+
this.setClientConfig(defaultMockClientConfig);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const config = await super._processConfig(rawConfig, init);
|
|
205
|
+
|
|
206
|
+
// `null` is a declaration in its own right — nobody is signed in — so only
|
|
207
|
+
// an absent one means the test said nothing about the user
|
|
208
|
+
const account = rawConfig.mock?.account;
|
|
209
|
+
// Apply even null because null explicitly requests a signed-out mock state.
|
|
210
|
+
if (account !== undefined) {
|
|
211
|
+
this.#signIn(account, config, init);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return config;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Builds an in-process client.
|
|
219
|
+
*
|
|
220
|
+
* @remarks
|
|
221
|
+
* Called only when no client was set, so
|
|
222
|
+
* {@link MsalConfigurator.setClient | setClient} still replaces authentication
|
|
223
|
+
* outright.
|
|
224
|
+
*
|
|
225
|
+
* Deliberately does not delegate to `super`, which would build a real
|
|
226
|
+
* `MsalClient` and contact Entra ID. It is never reached when the module is
|
|
227
|
+
* hoisted onto a host application's provider, because the base configurator
|
|
228
|
+
* gates client creation on {@link MsalConfigurator._isHoisted | _isHoisted} —
|
|
229
|
+
* a mock client built there would shadow the host's client, the exact scenario
|
|
230
|
+
* an application-inside-a-portal test exists to cover.
|
|
231
|
+
*
|
|
232
|
+
* Knows nothing about who is signed in: a client is built from what it talks
|
|
233
|
+
* to, and the declared user is applied to it afterwards.
|
|
234
|
+
*
|
|
235
|
+
* @param config - The validated configuration the client is built from.
|
|
236
|
+
* @returns A client resolving tokens in-process.
|
|
237
|
+
*/
|
|
238
|
+
protected override async _createClient(config: MsalConfig): Promise<IMsalClient> {
|
|
239
|
+
return new MsalMockClient(this._createClientConfig(config) ?? defaultMockClientConfig);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claims that can be set on a generated mock token.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* Mirrors the subset of Entra ID claims that Fusion applications read. Any
|
|
6
|
+
* additional claims are passed through unchanged.
|
|
7
|
+
*/
|
|
8
|
+
export interface MockTokenClaims {
|
|
9
|
+
/** Object ID of the signed-in user. */
|
|
10
|
+
oid?: string;
|
|
11
|
+
/** Display name of the signed-in user. */
|
|
12
|
+
name?: string;
|
|
13
|
+
/** UPN / email of the signed-in user. */
|
|
14
|
+
preferred_username?: string;
|
|
15
|
+
/** Tenant the token was issued for. */
|
|
16
|
+
tid?: string;
|
|
17
|
+
/** Audience — normally the client or resource the token is intended for. */
|
|
18
|
+
aud?: string;
|
|
19
|
+
/** Issuer. */
|
|
20
|
+
iss?: string;
|
|
21
|
+
/** Scopes granted, space-separated as in a real Entra ID token. */
|
|
22
|
+
scp?: string;
|
|
23
|
+
/** Issued-at, seconds since epoch. */
|
|
24
|
+
iat?: number;
|
|
25
|
+
/** Not-before, seconds since epoch. */
|
|
26
|
+
nbf?: number;
|
|
27
|
+
/** Expiry, seconds since epoch. */
|
|
28
|
+
exp?: number;
|
|
29
|
+
[claim: string]: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Encodes a value as base64url without padding, as used in JWT segments.
|
|
34
|
+
*
|
|
35
|
+
* @param value - Raw string to encode.
|
|
36
|
+
* @returns The base64url representation.
|
|
37
|
+
*/
|
|
38
|
+
const base64Url = (value: string): string => {
|
|
39
|
+
// btoa operates on latin1; encodeURIComponent round-trip keeps non-ASCII names intact
|
|
40
|
+
const bytes = new TextEncoder().encode(value);
|
|
41
|
+
let binary = '';
|
|
42
|
+
// Iterate over encoded bytes so Unicode claims are preserved before base64url encoding.
|
|
43
|
+
for (const byte of bytes) {
|
|
44
|
+
binary += String.fromCharCode(byte);
|
|
45
|
+
}
|
|
46
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Creates a structurally valid, unsigned JWT for use in tests.
|
|
51
|
+
*
|
|
52
|
+
* The token has three base64url segments and decodes to the supplied claims, so
|
|
53
|
+
* code that splits, decodes, or inspects token claims behaves exactly as it does
|
|
54
|
+
* in production. The signature segment is a fixed placeholder — the token is
|
|
55
|
+
* **not** cryptographically valid and will be rejected by any real service.
|
|
56
|
+
*
|
|
57
|
+
* @remarks
|
|
58
|
+
* Timestamps default to a fixed issue time and a one-hour lifetime so repeated
|
|
59
|
+
* runs produce byte-identical tokens. A test that needs an expired token can set
|
|
60
|
+
* `exp` in the past.
|
|
61
|
+
*
|
|
62
|
+
* @param claims - Claims to embed in the token payload.
|
|
63
|
+
* @returns An unsigned JWT string in `header.payload.signature` form.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```typescript
|
|
67
|
+
* const token = createMockToken({ name: 'Test User', scp: 'Files.Read' });
|
|
68
|
+
* const [, payload] = token.split('.');
|
|
69
|
+
* JSON.parse(decodeJwtSegment(payload)).name; // 'Test User'
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
export const createMockToken = (claims: MockTokenClaims = {}): string => {
|
|
73
|
+
const header = { alg: 'none', typ: 'JWT' };
|
|
74
|
+
// Fixed default clock keeps generated tokens byte-identical between runs
|
|
75
|
+
const issuedAt = claims.iat ?? 1_700_000_000;
|
|
76
|
+
const payload: MockTokenClaims = {
|
|
77
|
+
iss: 'https://login.microsoftonline.com/fusion-test-tenant/v2.0',
|
|
78
|
+
aud: 'fusion-test-client',
|
|
79
|
+
tid: 'fusion-test-tenant',
|
|
80
|
+
oid: 'fusion-test-user',
|
|
81
|
+
iat: issuedAt,
|
|
82
|
+
nbf: issuedAt,
|
|
83
|
+
exp: issuedAt + 3600,
|
|
84
|
+
...claims,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return [
|
|
88
|
+
base64Url(JSON.stringify(header)),
|
|
89
|
+
base64Url(JSON.stringify(payload)),
|
|
90
|
+
'fusion-test-signature',
|
|
91
|
+
].join('.');
|
|
92
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { IMsalClient } from '../MsalClient.interface';
|
|
2
|
+
import type { MsalClientConfig } from '../MsalClient';
|
|
3
|
+
import { MsalMockClient, type MsalMockUser } from './MsalMockClient';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Convenience helper that creates a mock client instance.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* The class form is preferred for a more familiar configuration pattern.
|
|
10
|
+
*
|
|
11
|
+
* @param config - The same client configuration the real client is built from.
|
|
12
|
+
* @param user - Optional user to sign in, applied after construction.
|
|
13
|
+
* @returns A client that resolves tokens in-process.
|
|
14
|
+
*/
|
|
15
|
+
export const createMsalMockClient = (
|
|
16
|
+
config: MsalClientConfig,
|
|
17
|
+
user?: MsalMockUser,
|
|
18
|
+
): IMsalClient => {
|
|
19
|
+
const client = new MsalMockClient(config);
|
|
20
|
+
// Apply the optional identity after construction so the helper matches setUser semantics.
|
|
21
|
+
if (user) {
|
|
22
|
+
client.setUser(user);
|
|
23
|
+
}
|
|
24
|
+
return client;
|
|
25
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decodes a base64url segment of a {@link createMockToken} JWT back to its JSON string.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* Plain `atob` alone mangles non-ASCII claims: it treats its output as latin1,
|
|
6
|
+
* while segments are UTF-8 encoded. This reverses that encoding and also
|
|
7
|
+
* restores the standard base64 alphabet/padding `atob` expects.
|
|
8
|
+
*
|
|
9
|
+
* @param segment - A base64url segment, e.g. from splitting a JWT on `.`.
|
|
10
|
+
* @returns The decoded UTF-8 string.
|
|
11
|
+
*/
|
|
12
|
+
export function decodeJwtSegment(segment: string): string {
|
|
13
|
+
const base64 = segment
|
|
14
|
+
.replace(/-/g, '+')
|
|
15
|
+
.replace(/_/g, '/')
|
|
16
|
+
.padEnd(segment.length + ((4 - (segment.length % 4)) % 4), '=');
|
|
17
|
+
const binary = atob(base64);
|
|
18
|
+
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
19
|
+
return new TextDecoder().decode(bytes);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default decodeJwtSegment;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock MSAL for tests: real provider, real configurator, fake client.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* Substituting the client is the smallest change that removes Entra ID from a test.
|
|
6
|
+
* Everything above it — scope resolution, silent-first token acquisition, account
|
|
7
|
+
* handling, proxy providers, telemetry — is the production code path.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { enableMsalMock } from '@equinor/fusion-framework-module-msal/mock';
|
|
12
|
+
*
|
|
13
|
+
* // default mock user
|
|
14
|
+
* enableMsalMock(configurator);
|
|
15
|
+
*
|
|
16
|
+
* // or a specific one
|
|
17
|
+
* enableMsalMock(configurator, (builder) => {
|
|
18
|
+
* builder.setAccount({ name: 'Ada Lovelace', signedOut: true });
|
|
19
|
+
* });
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* @packageDocumentation
|
|
23
|
+
*/
|
|
24
|
+
export { MsalMockClient, type MsalMockUser } from './MsalMockClient';
|
|
25
|
+
export { createMsalMockClient } from './create-msal-mock-client';
|
|
26
|
+
export { MsalMockConfigurator } from './MsalMockConfigurator';
|
|
27
|
+
export { enableMsalMock, msalMockModule, type AuthConfigMockFn } from './module';
|
|
28
|
+
export { createMockToken, type MockTokenClaims } from './create-mock-token';
|
|
29
|
+
export { decodeJwtSegment } from './decode-jwt-segment';
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { IModulesConfigurator } from '@equinor/fusion-framework-module';
|
|
2
|
+
|
|
3
|
+
import { module, type MsalModule } from '../module';
|
|
4
|
+
|
|
5
|
+
import { MsalMockConfigurator } from './MsalMockConfigurator';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The MSAL module with a mock client instead of a live connection to Entra ID.
|
|
9
|
+
*
|
|
10
|
+
* @remarks
|
|
11
|
+
* Only `configure` differs from the real module. `initialize` is the production
|
|
12
|
+
* one, untouched, so proxy providers, host-provider hoisting and provider
|
|
13
|
+
* initialization all behave exactly as they do in production — and a test
|
|
14
|
+
* observes the real start-up path rather than a rehearsal of it.
|
|
15
|
+
*/
|
|
16
|
+
export const msalMockModule: MsalModule = {
|
|
17
|
+
...module,
|
|
18
|
+
configure: () => new MsalMockConfigurator(),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Configuration callback for {@link enableMsalMock}.
|
|
23
|
+
*/
|
|
24
|
+
export type AuthConfigMockFn<TRef = unknown> = (
|
|
25
|
+
configurator: MsalMockConfigurator,
|
|
26
|
+
ref?: TRef,
|
|
27
|
+
) => void;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Enables MSAL against a mock client, so a test needs no credentials and no network.
|
|
31
|
+
*
|
|
32
|
+
* @remarks
|
|
33
|
+
* Registered last, this replaces whichever auth module the configurator already
|
|
34
|
+
* carries, so it works on a `FrameworkConfigurator` that pre-registers the real one.
|
|
35
|
+
*
|
|
36
|
+
* @param configurator - The modules configurator to register on.
|
|
37
|
+
* @param configure - Optional callback to override the default mock client.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```typescript
|
|
41
|
+
* enableMsalMock(configurator, (builder) => {
|
|
42
|
+
* builder.setAccount({ name: 'Ada Lovelace' });
|
|
43
|
+
* });
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export const enableMsalMock = (
|
|
47
|
+
// biome-ignore lint/suspicious/noExplicitAny: must be any to support all module types
|
|
48
|
+
configurator: IModulesConfigurator<any, any>,
|
|
49
|
+
configure?: AuthConfigMockFn,
|
|
50
|
+
): void => {
|
|
51
|
+
configurator.addConfig({ module: msalMockModule, configure } as {
|
|
52
|
+
module: MsalModule;
|
|
53
|
+
});
|
|
54
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import z from 'zod';
|
|
2
|
+
import semver from 'semver';
|
|
3
|
+
import { CacheLookupPolicy } from '@azure/msal-browser';
|
|
4
|
+
|
|
5
|
+
import type { IMsalClient } from './MsalClient.interface';
|
|
6
|
+
import type { IMsalProvider } from './MsalProvider.interface';
|
|
7
|
+
import { TelemetryConfigSchema } from './telemetry-config-schema';
|
|
8
|
+
export type { TelemetryConfig } from './telemetry-config-schema';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Zod schema for MSAL module configuration validation.
|
|
12
|
+
*
|
|
13
|
+
* @remarks
|
|
14
|
+
* Kept in its own module so the configuration can be extended at its source.
|
|
15
|
+
* The schema itself describes what reaches `MsalProvider` and strips anything
|
|
16
|
+
* else; keys a variant of this module needs only while the configuration is
|
|
17
|
+
* being built are declared on {@link MsalConfigExtension} instead.
|
|
18
|
+
*/
|
|
19
|
+
export const MsalConfigSchema = z.object({
|
|
20
|
+
client: z.custom<IMsalClient>().optional(),
|
|
21
|
+
provider: z.custom<IMsalProvider>().optional(),
|
|
22
|
+
requiresAuth: z.boolean().optional(),
|
|
23
|
+
redirectUri: z.string().optional(),
|
|
24
|
+
loginHint: z.string().optional(),
|
|
25
|
+
authCode: z.string().optional(),
|
|
26
|
+
cacheLookupPolicy: z
|
|
27
|
+
.custom<CacheLookupPolicy>(
|
|
28
|
+
(val) =>
|
|
29
|
+
typeof val === 'number' &&
|
|
30
|
+
Object.values(CacheLookupPolicy).includes(val as CacheLookupPolicy),
|
|
31
|
+
)
|
|
32
|
+
.optional(),
|
|
33
|
+
version: z.string().transform((value, ctx) => {
|
|
34
|
+
const coerced = semver.coerce(value);
|
|
35
|
+
// `semver.coerce` returns `null` for an unparseable version; without this guard it
|
|
36
|
+
// would silently become the literal string "null" instead of failing validation.
|
|
37
|
+
if (!coerced) {
|
|
38
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid MSAL module version' });
|
|
39
|
+
return z.NEVER;
|
|
40
|
+
}
|
|
41
|
+
return coerced.version;
|
|
42
|
+
}),
|
|
43
|
+
telemetry: TelemetryConfigSchema,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Configuration a variant of this module adds to {@link MsalConfig}.
|
|
48
|
+
*
|
|
49
|
+
* @remarks
|
|
50
|
+
* Empty by design: production MSAL configuration is exactly the schema. This
|
|
51
|
+
* exists so a variant — the test double in `./mock`, for instance — can declare
|
|
52
|
+
* its own branch of the configuration through declaration merging:
|
|
53
|
+
*
|
|
54
|
+
* ```typescript
|
|
55
|
+
* declare module '@equinor/fusion-framework-module-msal' {
|
|
56
|
+
* interface MsalConfigExtension {
|
|
57
|
+
* mock?: { account?: MsalMockUser };
|
|
58
|
+
* }
|
|
59
|
+
* }
|
|
60
|
+
* ```
|
|
61
|
+
*
|
|
62
|
+
* That is what keeps `BaseConfigBuilder._set` honest about the added key. Its
|
|
63
|
+
* target is a dot-path union derived from {@link MsalConfig}, so a key the type
|
|
64
|
+
* does not know about can only be set by casting past the builder — and a
|
|
65
|
+
* generic configurator cannot help, because a dot-path union over an unresolved
|
|
66
|
+
* type parameter defers, taking every existing literal path down with it.
|
|
67
|
+
*
|
|
68
|
+
* The key exists to carry a declaration across the builder, not to reach the
|
|
69
|
+
* provider: the schema strips it during validation, so it is readable from the
|
|
70
|
+
* raw configuration and absent from the validated one.
|
|
71
|
+
*/
|
|
72
|
+
// biome-ignore lint/suspicious/noEmptyInterface: the extension point is the point
|
|
73
|
+
export interface MsalConfigExtension {}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Complete configuration object for MSAL authentication module.
|
|
77
|
+
*
|
|
78
|
+
* This type represents the full configuration including client setup, authentication
|
|
79
|
+
* requirements, telemetry, and version information.
|
|
80
|
+
*/
|
|
81
|
+
export type MsalConfig = z.infer<typeof MsalConfigSchema> & MsalConfigExtension;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import z from 'zod';
|
|
2
|
+
import type { ITelemetryProvider } from '@equinor/fusion-framework-module-telemetry';
|
|
3
|
+
import { version } from './version';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Zod schema for telemetry configuration validation.
|
|
7
|
+
*
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
export const TelemetryConfigSchema = z.object({
|
|
11
|
+
provider: z.custom<ITelemetryProvider>().optional(),
|
|
12
|
+
metadata: z.record(z.string(), z.unknown()).optional().default({
|
|
13
|
+
module: 'msal',
|
|
14
|
+
version,
|
|
15
|
+
}),
|
|
16
|
+
scope: z.array(z.string()).optional().default(['framework', 'authentication']),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Telemetry configuration for MSAL module.
|
|
21
|
+
*
|
|
22
|
+
* This configuration controls how authentication events are tracked and logged
|
|
23
|
+
* through the framework's telemetry system.
|
|
24
|
+
*/
|
|
25
|
+
export type TelemetryConfig = z.infer<typeof TelemetryConfigSchema>;
|
package/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by genversion.
|
|
2
|
-
export const version = '
|
|
2
|
+
export const version = '11.0.0-next.0';
|
|
@@ -29,7 +29,6 @@ import { version as latestVersionString } from '../version';
|
|
|
29
29
|
* ```
|
|
30
30
|
*/
|
|
31
31
|
function mapVersionToEnumVersion(version: string | SemVer): MsalModuleVersion {
|
|
32
|
-
console.log('Resolving version:', version);
|
|
33
32
|
const coercedVersion = semver.coerce(version);
|
|
34
33
|
// An uncoercible version string cannot be mapped to a module version
|
|
35
34
|
if (!coercedVersion) {
|