@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,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,35 @@
|
|
|
1
|
+
import type { IModulesConfigurator } from '@equinor/fusion-framework-module';
|
|
2
|
+
import { type MsalModule } from '../module';
|
|
3
|
+
import { MsalMockConfigurator } from './MsalMockConfigurator';
|
|
4
|
+
/**
|
|
5
|
+
* The MSAL module with a mock client instead of a live connection to Entra ID.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* Only `configure` differs from the real module. `initialize` is the production
|
|
9
|
+
* one, untouched, so proxy providers, host-provider hoisting and provider
|
|
10
|
+
* initialization all behave exactly as they do in production — and a test
|
|
11
|
+
* observes the real start-up path rather than a rehearsal of it.
|
|
12
|
+
*/
|
|
13
|
+
export declare const msalMockModule: MsalModule;
|
|
14
|
+
/**
|
|
15
|
+
* Configuration callback for {@link enableMsalMock}.
|
|
16
|
+
*/
|
|
17
|
+
export type AuthConfigMockFn<TRef = unknown> = (configurator: MsalMockConfigurator, ref?: TRef) => void;
|
|
18
|
+
/**
|
|
19
|
+
* Enables MSAL against a mock client, so a test needs no credentials and no network.
|
|
20
|
+
*
|
|
21
|
+
* @remarks
|
|
22
|
+
* Registered last, this replaces whichever auth module the configurator already
|
|
23
|
+
* carries, so it works on a `FrameworkConfigurator` that pre-registers the real one.
|
|
24
|
+
*
|
|
25
|
+
* @param configurator - The modules configurator to register on.
|
|
26
|
+
* @param configure - Optional callback to override the default mock client.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```typescript
|
|
30
|
+
* enableMsalMock(configurator, (builder) => {
|
|
31
|
+
* builder.setAccount({ name: 'Ada Lovelace' });
|
|
32
|
+
* });
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export declare const enableMsalMock: (configurator: IModulesConfigurator<any, any>, configure?: AuthConfigMockFn) => void;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import z from 'zod';
|
|
2
|
+
import { CacheLookupPolicy } from '@azure/msal-browser';
|
|
3
|
+
import type { IMsalClient } from './MsalClient.interface';
|
|
4
|
+
import type { IMsalProvider } from './MsalProvider.interface';
|
|
5
|
+
export type { TelemetryConfig } from './telemetry-config-schema';
|
|
6
|
+
/**
|
|
7
|
+
* Zod schema for MSAL module configuration validation.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* Kept in its own module so the configuration can be extended at its source.
|
|
11
|
+
* The schema itself describes what reaches `MsalProvider` and strips anything
|
|
12
|
+
* else; keys a variant of this module needs only while the configuration is
|
|
13
|
+
* being built are declared on {@link MsalConfigExtension} instead.
|
|
14
|
+
*/
|
|
15
|
+
export declare const MsalConfigSchema: z.ZodObject<{
|
|
16
|
+
client: z.ZodOptional<z.ZodCustom<IMsalClient, IMsalClient>>;
|
|
17
|
+
provider: z.ZodOptional<z.ZodCustom<IMsalProvider, IMsalProvider>>;
|
|
18
|
+
requiresAuth: z.ZodOptional<z.ZodBoolean>;
|
|
19
|
+
redirectUri: z.ZodOptional<z.ZodString>;
|
|
20
|
+
loginHint: z.ZodOptional<z.ZodString>;
|
|
21
|
+
authCode: z.ZodOptional<z.ZodString>;
|
|
22
|
+
cacheLookupPolicy: z.ZodOptional<z.ZodCustom<CacheLookupPolicy, CacheLookupPolicy>>;
|
|
23
|
+
version: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
|
|
24
|
+
telemetry: z.ZodObject<{
|
|
25
|
+
provider: z.ZodOptional<z.ZodCustom<import("@equinor/fusion-framework-module-telemetry").ITelemetryProvider, import("@equinor/fusion-framework-module-telemetry").ITelemetryProvider>>;
|
|
26
|
+
metadata: z.ZodDefault<z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
|
|
27
|
+
scope: z.ZodDefault<z.ZodOptional<z.ZodArray<z.ZodString>>>;
|
|
28
|
+
}, z.core.$strip>;
|
|
29
|
+
}, z.core.$strip>;
|
|
30
|
+
/**
|
|
31
|
+
* Configuration a variant of this module adds to {@link MsalConfig}.
|
|
32
|
+
*
|
|
33
|
+
* @remarks
|
|
34
|
+
* Empty by design: production MSAL configuration is exactly the schema. This
|
|
35
|
+
* exists so a variant — the test double in `./mock`, for instance — can declare
|
|
36
|
+
* its own branch of the configuration through declaration merging:
|
|
37
|
+
*
|
|
38
|
+
* ```typescript
|
|
39
|
+
* declare module '@equinor/fusion-framework-module-msal' {
|
|
40
|
+
* interface MsalConfigExtension {
|
|
41
|
+
* mock?: { account?: MsalMockUser };
|
|
42
|
+
* }
|
|
43
|
+
* }
|
|
44
|
+
* ```
|
|
45
|
+
*
|
|
46
|
+
* That is what keeps `BaseConfigBuilder._set` honest about the added key. Its
|
|
47
|
+
* target is a dot-path union derived from {@link MsalConfig}, so a key the type
|
|
48
|
+
* does not know about can only be set by casting past the builder — and a
|
|
49
|
+
* generic configurator cannot help, because a dot-path union over an unresolved
|
|
50
|
+
* type parameter defers, taking every existing literal path down with it.
|
|
51
|
+
*
|
|
52
|
+
* The key exists to carry a declaration across the builder, not to reach the
|
|
53
|
+
* provider: the schema strips it during validation, so it is readable from the
|
|
54
|
+
* raw configuration and absent from the validated one.
|
|
55
|
+
*/
|
|
56
|
+
export interface MsalConfigExtension {
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Complete configuration object for MSAL authentication module.
|
|
60
|
+
*
|
|
61
|
+
* This type represents the full configuration including client setup, authentication
|
|
62
|
+
* requirements, telemetry, and version information.
|
|
63
|
+
*/
|
|
64
|
+
export type MsalConfig = z.infer<typeof MsalConfigSchema> & MsalConfigExtension;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import z from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Telemetry configuration for MSAL module.
|
|
4
|
+
*
|
|
5
|
+
* This configuration controls how authentication events are tracked and logged
|
|
6
|
+
* through the framework's telemetry system.
|
|
7
|
+
*/
|
|
8
|
+
export type TelemetryConfig = z.infer<typeof TelemetryConfigSchema>;
|
package/dist/types/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const version = "
|
|
1
|
+
export declare const version = "11.0.0-next.0";
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# MSAL API Reference
|
|
2
|
+
|
|
3
|
+
## `enableMSAL(configurator, configure?)`
|
|
4
|
+
|
|
5
|
+
Enables the MSAL module in your Fusion Framework application.
|
|
6
|
+
|
|
7
|
+
**Parameters:**
|
|
8
|
+
- `configurator`: `IModulesConfigurator` - The modules configurator instance
|
|
9
|
+
- `configure?`: `(builder: { setClientConfig, setRequiresAuth }) => void` - Optional configuration function
|
|
10
|
+
|
|
11
|
+
**Returns:** `void`
|
|
12
|
+
|
|
13
|
+
**Example:**
|
|
14
|
+
```typescript
|
|
15
|
+
enableMSAL(configurator, (builder) => {
|
|
16
|
+
builder.setClientConfig({ auth: { clientId: '...', tenantId: '...' } });
|
|
17
|
+
builder.setRequiresAuth(true);
|
|
18
|
+
});
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Type Definitions
|
|
22
|
+
|
|
23
|
+
### `LoginOptions`
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
type LoginOptions = {
|
|
27
|
+
request: PopupRequest | RedirectRequest; // MSAL request object
|
|
28
|
+
behavior?: 'popup' | 'redirect'; // Auth method (default: 'redirect')
|
|
29
|
+
silent?: boolean; // Attempt silent auth first (default: true)
|
|
30
|
+
};
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### `LogoutOptions`
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
type LogoutOptions = {
|
|
37
|
+
redirectUri?: string; // Redirect after logout
|
|
38
|
+
account?: AccountInfo; // Account to logout (defaults to active)
|
|
39
|
+
};
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### `AcquireTokenOptions`
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
type AcquireTokenOptions = {
|
|
46
|
+
request: PopupRequest | RedirectRequest; // MSAL request with scopes
|
|
47
|
+
behavior?: 'popup' | 'redirect'; // Auth method (default: 'redirect')
|
|
48
|
+
silent?: boolean; // Attempt silent first (default: true if account available)
|
|
49
|
+
};
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## `IMsalProvider`
|
|
53
|
+
|
|
54
|
+
The authentication provider interface available at `framework.auth`:
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
interface IMsalProvider {
|
|
58
|
+
// The MSAL PublicClientApplication instance
|
|
59
|
+
readonly client: IMsalClient;
|
|
60
|
+
|
|
61
|
+
// Current user account information
|
|
62
|
+
readonly account: AccountInfo | null;
|
|
63
|
+
|
|
64
|
+
// Initialize the MSAL provider
|
|
65
|
+
initialize(): Promise<void>;
|
|
66
|
+
|
|
67
|
+
// Acquire an access token for the specified scopes
|
|
68
|
+
acquireAccessToken(options: AcquireTokenOptionsLegacy): Promise<string | undefined>;
|
|
69
|
+
|
|
70
|
+
// Acquire full authentication result
|
|
71
|
+
acquireToken(options: AcquireTokenOptionsLegacy): Promise<AcquireTokenResult>;
|
|
72
|
+
|
|
73
|
+
// Login user interactively
|
|
74
|
+
login(options: LoginOptions): Promise<LoginResult>;
|
|
75
|
+
|
|
76
|
+
// Logout user (returns boolean)
|
|
77
|
+
logout(options?: LogoutOptions): Promise<boolean>;
|
|
78
|
+
|
|
79
|
+
// Handle authentication redirect (returns AuthenticationResult | null)
|
|
80
|
+
handleRedirect(): Promise<AuthenticationResult | null>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Note: defaultAccount and other deprecated v2 properties are available only
|
|
84
|
+
// when using a v2-compatible proxy via createProxyProvider()
|
|
85
|
+
```
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Backend-Issued Auth Code Flow
|
|
2
|
+
|
|
3
|
+
Enable automatic sign-in using a backend-issued authorization code without interactive login prompts.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
When your backend authenticates a user and generates a short-lived SPA auth code, the MSAL module can exchange it for tokens during initialization, eliminating double-login issues and providing seamless authentication.
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
import { enableMSAL } from '@equinor/fusion-framework-module-msal';
|
|
13
|
+
|
|
14
|
+
enableMSAL(configurator, (builder) => {
|
|
15
|
+
builder.setClientConfig({
|
|
16
|
+
auth: {
|
|
17
|
+
clientId: 'your-client-id',
|
|
18
|
+
tenantId: 'your-tenant-id'
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Backend injects auth code as window.MSAL_AUTH_CODE during initial first page load
|
|
23
|
+
// This is the most secure approach - only available on first render, cleared after use
|
|
24
|
+
if (typeof window !== 'undefined' && window.MSAL_AUTH_CODE) {
|
|
25
|
+
builder.setAuthCode(window.MSAL_AUTH_CODE);
|
|
26
|
+
delete (window as any).MSAL_AUTH_CODE; // Clear after consuming
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
builder.setRequiresAuth(true);
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## How It Works
|
|
34
|
+
|
|
35
|
+
1. Backend authenticates user and generates short-lived auth code
|
|
36
|
+
2. Frontend passes auth code to MSAL: `builder.setAuthCode(authCode)`
|
|
37
|
+
3. During `initialize()`: auth code exchanged for tokens (before `requiresAuth` check)
|
|
38
|
+
4. Tokens cached by MSAL → user automatically signed in
|
|
39
|
+
5. Falls back to standard MSAL flows on exchange failure
|
|
40
|
+
|
|
41
|
+
## API: `setAuthCode(authCode?: string)`
|
|
42
|
+
|
|
43
|
+
Sets backend-issued auth code for token exchange during initialization.
|
|
44
|
+
|
|
45
|
+
Pass `undefined` to clear/reset a previously configured auth code.
|
|
46
|
+
|
|
47
|
+
**Returns:** configurator instance (chainable)
|
|
48
|
+
|
|
49
|
+
**Behavior:**
|
|
50
|
+
- Exchange happens before `requiresAuth` check
|
|
51
|
+
- On success: user auto-authenticated, no login prompt
|
|
52
|
+
- On failure: falls back to standard MSAL login
|
|
53
|
+
- Auth code cleared after exchange (no reuse)
|
|
54
|
+
- `setAuthCode(undefined)` clears configured auth code
|
|
55
|
+
- `setAuthCode('')` is treated as absent auth code
|
|
56
|
+
- `setAuthCode(' ')` is trimmed and treated as absent auth code
|
|
57
|
+
- No auth-code exchange is attempted when auth code is absent/cleared
|
|
58
|
+
|
|
59
|
+
**Example:**
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
// Best practice: Backend injects auth code on initial page load as window.MSAL_AUTH_CODE
|
|
63
|
+
if (typeof window !== 'undefined' && window.MSAL_AUTH_CODE) {
|
|
64
|
+
builder.setAuthCode(window.MSAL_AUTH_CODE);
|
|
65
|
+
delete (window as any).MSAL_AUTH_CODE; // Clear after consuming to prevent reuse
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Clear/reset auth code when input is missing
|
|
69
|
+
builder.setAuthCode(undefined);
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Security
|
|
73
|
+
|
|
74
|
+
- ✅ Auth codes: single-use, short-lived (5-10 min)
|
|
75
|
+
- ✅ MSAL validates tokens from Microsoft authority
|
|
76
|
+
- ✅ Tokens stored securely, refresh tokens auto-managed
|
|
77
|
+
- ⚠️ Pass codes securely: HTTPS, HTTP-only cookies, or encrypted channels
|
|
78
|
+
|
|
79
|
+
## Troubleshooting
|
|
80
|
+
|
|
81
|
+
| Issue | Solution |
|
|
82
|
+
|-------|----------|
|
|
83
|
+
| Auth code not exchanged | Verify `setAuthCode()` called before init |
|
|
84
|
+
| Invalid auth code error | Confirm backend `WithSpaAuthCode` enabled, code is fresh |
|
|
85
|
+
| Still shows login prompt | Check auth code exchange completes before `requiresAuth` check |
|
|
86
|
+
| Exchange fails | Auth code may have expired; backend should generate fresh code per load |
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Migration Guide
|
|
2
|
+
|
|
3
|
+
## MSAL v2 to v4 Migration
|
|
4
|
+
|
|
5
|
+
This package has been upgraded from MSAL Browser v2 to v4, providing the latest security improvements and features from Microsoft.
|
|
6
|
+
|
|
7
|
+
### What Changed in v4
|
|
8
|
+
|
|
9
|
+
**New MSAL Browser v4 Features:**
|
|
10
|
+
- Enhanced security with improved token management
|
|
11
|
+
- Better performance and memory usage
|
|
12
|
+
- New authentication API structure with nested request objects
|
|
13
|
+
- Improved error handling and retry mechanisms
|
|
14
|
+
|
|
15
|
+
**Architecture Changes:**
|
|
16
|
+
- **Module Hoisting**: The module uses module hoisting, meaning sub-module instances proxy the parent module instance
|
|
17
|
+
- **Shared Authentication State**: Authentication state is shared across all module instances
|
|
18
|
+
- **Async Initialization**: New `initialize()` method must be called before using the provider
|
|
19
|
+
|
|
20
|
+
### Breaking Changes
|
|
21
|
+
|
|
22
|
+
1. **Auto-initialization via Framework**
|
|
23
|
+
```typescript
|
|
24
|
+
// The provider initializes automatically when framework loads
|
|
25
|
+
const framework = await initialize(configurator);
|
|
26
|
+
const auth = framework.auth; // Already initialized
|
|
27
|
+
|
|
28
|
+
// Manual initialization is only needed for standalone usage
|
|
29
|
+
const provider = new MsalProvider(config);
|
|
30
|
+
await provider.initialize();
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
2. **API Method Signature Updates**
|
|
34
|
+
- `logout()` now returns `Promise<boolean>` instead of `Promise<void>`
|
|
35
|
+
- `handleRedirect()` now returns `Promise<AuthenticationResult | null>` instead of `Promise<void>`
|
|
36
|
+
- Methods now expect nested request objects (v4 format)
|
|
37
|
+
|
|
38
|
+
3. **Account Property Changes**
|
|
39
|
+
- Use `account` property (returns `AccountInfo | null`) - v4 native
|
|
40
|
+
- `defaultAccount` is deprecated and only available via v2 proxy layer
|
|
41
|
+
- Migration: Replace `defaultAccount` with `account` throughout your code
|
|
42
|
+
|
|
43
|
+
### Migration Steps
|
|
44
|
+
|
|
45
|
+
1. **Update Token Acquisition** (Recommended)
|
|
46
|
+
```typescript
|
|
47
|
+
// Before (v2 format - still works via proxy)
|
|
48
|
+
const token = await framework.auth.acquireAccessToken({
|
|
49
|
+
scopes: ['api.read']
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// After (v4 format - recommended)
|
|
53
|
+
const token = await framework.auth.acquireAccessToken({
|
|
54
|
+
request: { scopes: ['api.read'] }
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
2. **Update Logout Handling**
|
|
59
|
+
```typescript
|
|
60
|
+
// Before
|
|
61
|
+
await framework.auth.logout();
|
|
62
|
+
|
|
63
|
+
// After (check return value)
|
|
64
|
+
const success = await framework.auth.logout();
|
|
65
|
+
if (success) {
|
|
66
|
+
// Handle successful logout
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
3. **Update Redirect Handling**
|
|
71
|
+
```typescript
|
|
72
|
+
// Before
|
|
73
|
+
await framework.auth.handleRedirect();
|
|
74
|
+
|
|
75
|
+
// After (handle result)
|
|
76
|
+
const result = await framework.auth.handleRedirect();
|
|
77
|
+
if (result?.account) {
|
|
78
|
+
// User authenticated successfully
|
|
79
|
+
console.log('Logged in as:', result.account.username);
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
4. **Update Configuration** (if needed)
|
|
84
|
+
```typescript
|
|
85
|
+
// Ensure only the root module configures MSAL
|
|
86
|
+
enableMSAL(configurator, (builder) => {
|
|
87
|
+
builder.setClientConfig({
|
|
88
|
+
auth: {
|
|
89
|
+
clientId: 'your-client-id',
|
|
90
|
+
tenantId: 'your-tenant-id',
|
|
91
|
+
redirectUri: 'https://your-app.com/callback'
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
builder.setRequiresAuth(true);
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
5. **Remove Duplicate Configurations**: Remove MSAL configuration from child modules
|
|
99
|
+
|
|
100
|
+
### Backward Compatibility
|
|
101
|
+
|
|
102
|
+
The module includes a **v2 proxy layer** that automatically converts v2 API calls to v4 format. This means:
|
|
103
|
+
- ✅ Existing code continues to work without changes
|
|
104
|
+
- ✅ Legacy format `{ scopes: [] }` is still supported
|
|
105
|
+
- ✅ Deprecated v2 properties like `defaultAccount` are available via v2 proxy (with deprecation warnings)
|
|
106
|
+
- ⚠️ New v4 features require using v4 format
|
|
107
|
+
|
|
108
|
+
### Benefits of Migration
|
|
109
|
+
|
|
110
|
+
- **Better Security**: Latest MSAL v4 security improvements and token handling
|
|
111
|
+
- **Improved Performance**: Faster token acquisition, better caching, reduced memory usage
|
|
112
|
+
- **Enhanced Error Handling**: More robust error recovery and retry mechanisms
|
|
113
|
+
- **Future-Proof**: Access to latest Microsoft authentication features and updates
|
|
114
|
+
- **Shared State**: Improved authentication state management across app scopes via module hoisting
|
|
115
|
+
- **Better Developer Experience**: Cleaner API, better TypeScript support, comprehensive documentation
|
package/docs/testing.md
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# MSAL — test double
|
|
2
|
+
|
|
3
|
+
Authenticate in-process instead of against Entra ID.
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
import { enableMsalMock } from '@equinor/fusion-framework-module-msal/mock';
|
|
7
|
+
|
|
8
|
+
enableMsalMock(configurator);
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Import path: `@equinor/fusion-framework-module-msal/mock`. The entry point has **no test-runner dependency**.
|
|
12
|
+
|
|
13
|
+
## What is substituted
|
|
14
|
+
|
|
15
|
+
> [!IMPORTANT]
|
|
16
|
+
> Only `IMsalClient` — the object that would contact Entra ID. The real `MsalConfigurator`, the real `MsalProvider` and the real schema validation all still run.
|
|
17
|
+
|
|
18
|
+
That distinction is the point. Scope resolution, silent-first token acquisition, account handling, proxy providers and telemetry stay on the production code path, so a test observes real provider behaviour:
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
const fusion = await mockFramework((configurator) => {
|
|
22
|
+
// the client is configured with *what it talks to*, exactly as in production
|
|
23
|
+
configurator.msal.setClientConfig({ auth: { clientId: 'my-app', tenantId: 'my-tenant' } });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const token = await fusion.modules.auth.acquireAccessToken();
|
|
27
|
+
// scope is 'my-app/.default' — resolved by the real provider, not by the test double
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
A double that replaced the provider would have skipped that logic and reported whatever it was told to.
|
|
31
|
+
|
|
32
|
+
## Defaults
|
|
33
|
+
|
|
34
|
+
A user named `Test User` is signed in. Tokens are real JWTs, minted in-process with a fixed issue time, so they are identical across runs and machines and can be compared or snapshotted directly.
|
|
35
|
+
|
|
36
|
+
When no client configuration is declared, a stand-in one is used, so an application boots under test without credentials it does not have.
|
|
37
|
+
|
|
38
|
+
## Choosing the signed-in user
|
|
39
|
+
|
|
40
|
+
`MsalMockClient` takes the same `MsalClientConfig` the real `MsalClient` takes — a client configuration has no notion of who is signed in, so the user is declared separately and signed in on the client as it is built:
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
import { enableMsalMock } from '@equinor/fusion-framework-module-msal/mock';
|
|
44
|
+
|
|
45
|
+
enableMsalMock(configurator, (builder) => {
|
|
46
|
+
builder.setAccount({ name: 'Ada Lovelace', username: 'ada@equinor.com' });
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Pass `null` when nobody is signed in:
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
enableMsalMock(configurator, (builder) => {
|
|
54
|
+
builder.setAccount(null);
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`setAccount` also takes an ordinary config-builder callback, resolved while the configuration is assembled and handed the same arguments every other builder callback receives:
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
enableMsalMock(configurator, (builder) => {
|
|
62
|
+
builder.setAccount(async ({ hasModule }) => ({
|
|
63
|
+
name: hasModule('app') ? 'App User' : 'Portal User',
|
|
64
|
+
}));
|
|
65
|
+
});
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The user is in place **before** `MsalProvider.initialize()` runs, so the provider's own start-up path acts on it. Combined with `setRequiresAuth(true)`, a test observes the real automatic login rather than a state assigned after the fact.
|
|
69
|
+
|
|
70
|
+
`setClient` replaces the client, but not the rule: the declared user is signed in on whichever client the module authenticates through, so a mock client supplied that way receives it too.
|
|
71
|
+
|
|
72
|
+
| Option | Default | Purpose |
|
|
73
|
+
| --- | --- | --- |
|
|
74
|
+
| `name` | `Test User` | Display name |
|
|
75
|
+
| `username` | `test.user@equinor.com` | UPN / email |
|
|
76
|
+
| `userId` | `fusion-mock-user` | Object ID |
|
|
77
|
+
| `tenantId` | the client's configured tenant | Tenant |
|
|
78
|
+
| `scopes` | `fusion-mock-scope` | Granted when a request specifies none |
|
|
79
|
+
| `account` | derived | A preconfigured `AccountInfo` to use outright |
|
|
80
|
+
| `signedOut` | `false` | Start without a signed-in user |
|
|
81
|
+
|
|
82
|
+
The client tokens are issued for comes from the client configuration (`setClientConfig`), not from the user.
|
|
83
|
+
|
|
84
|
+
### Why the user is signed in at construction
|
|
85
|
+
|
|
86
|
+
The account is put in the client's cache as the client is built — before the provider exists. That reproduces the production shape of a returning user with a live session: the provider finds an account already there and takes the branch it takes in the browser.
|
|
87
|
+
|
|
88
|
+
Assigning the account **after** `MsalProvider.initialize()` — from an `onInitialized` hook, say — looks equivalent but is not. `initialize()` exchanges an auth code, calls `handleRedirect()` and, when `requiresAuth` is set, performs an automatic login. A late assignment silently overwrites all of that, so a test asserting on the sign-in journey would be observing its own assignment rather than the framework.
|
|
89
|
+
|
|
90
|
+
## Changing the user between tests
|
|
91
|
+
|
|
92
|
+
When a suite shares one framework instance but needs a different user per test, set the active account directly:
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
beforeEach(() => {
|
|
96
|
+
fusion.modules.auth.client.setActiveAccount(account);
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The mock keeps a real account cache, so this behaves the way MSAL does: `getActiveAccount`, `getAllAccounts` and `getAccount(filter)` all agree afterwards. Unlike real MSAL, an account that was never issued by a sign-in is accepted and added to the cache, which is what makes the one-liner above possible.
|
|
101
|
+
|
|
102
|
+
Signing out (`logout`, `logoutPopup`, `logoutRedirect`) removes the account from the cache rather than merely deactivating it, as MSAL does.
|
|
103
|
+
|
|
104
|
+
## Running inside a host application
|
|
105
|
+
|
|
106
|
+
When the module is hoisted onto a host application's provider — an app inside a portal — no client is built. The app authenticates through the host's client, exactly as in production.
|
|
107
|
+
|
|
108
|
+
A user declared with `setAccount` is still honoured: it is signed in on the **host's** client, because that is the client the app authenticates through. The alternative would be for `setAccount` to silently do nothing precisely when an app is being tested inside a portal.
|
|
109
|
+
|
|
110
|
+
The session is shared, so this changes who the host sees signed in too — as it does in production. If the host does not authenticate through a mock client, `setAccount` throws rather than failing quietly.
|
|
111
|
+
|
|
112
|
+
## Testing signed-out behaviour
|
|
113
|
+
|
|
114
|
+
Both `null` and `signedOut: true` start without a session. Silent flows then resolve empty so the provider follows its unauthenticated path, while an explicit login still succeeds — which lets a test drive the sign-in journey rather than only its end state.
|
|
115
|
+
|
|
116
|
+
They differ in what the login resolves to. `null` forgets the identity, so a login produces the default user:
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
builder.setAccount(null);
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`signedOut: true` keeps it, so a login produces the user the test named — which is what to reach for when the assertion is about *who* signed in:
|
|
123
|
+
|
|
124
|
+
```typescript
|
|
125
|
+
builder.setAccount({ name: 'Ada Lovelace', signedOut: true });
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Mocking an individual call
|
|
129
|
+
|
|
130
|
+
> [!IMPORTANT]
|
|
131
|
+
> That is your test runner's job. This module ships **no mocking API**.
|
|
132
|
+
|
|
133
|
+
The mock client is a plain class with ordinary methods, so `vi.spyOn`, `bun:test`'s `spyOn` and Node's `t.mock.method` all work on it directly — with their own call assertions, argument matchers and reset semantics, which a framework-specific API would not give you.
|
|
134
|
+
|
|
135
|
+
The provider exposes the client it authenticates through, so a spy has a stable target:
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
vi.spyOn(fusion.modules.auth.client, 'acquireToken').mockResolvedValue(result);
|
|
139
|
+
|
|
140
|
+
afterEach(() => vi.restoreAllMocks());
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Minting a token directly
|
|
144
|
+
|
|
145
|
+
For code that only needs a token — an HTTP interceptor test, say — skip the client:
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
import { createMockToken } from '@equinor/fusion-framework-module-msal/mock';
|
|
149
|
+
|
|
150
|
+
const token = createMockToken({ oid: 'fusion-mock-user' });
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Exports
|
|
154
|
+
|
|
155
|
+
| Export | Purpose |
|
|
156
|
+
| --- | --- |
|
|
157
|
+
| `enableMsalMock(configurator, configure?)` | Register the module with an in-process client |
|
|
158
|
+
| `msalMockModule` | The module itself, for manual registration |
|
|
159
|
+
| `MsalMockConfigurator` | The real configurator, backed by an in-process client |
|
|
160
|
+
| `MsalMockClient(config)` | The in-process client, taking the same `MsalClientConfig` as `MsalClient` |
|
|
161
|
+
| `createMsalMockClient(config, user?)` | Convenience alias for `new MsalMockClient(config)` |
|
|
162
|
+
| `createMockToken(claims?)` | Mint a deterministic JWT |
|
|
163
|
+
|
|
164
|
+
## Related
|
|
165
|
+
|
|
166
|
+
- [Module README](../README.md) — production configuration
|
|
167
|
+
- [`@equinor/fusion-framework/mock`](../../../framework/docs/testing.md) — mock every framework boundary at once
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Troubleshooting
|
|
2
|
+
|
|
3
|
+
## Common Issues
|
|
4
|
+
|
|
5
|
+
| Issue | Solution |
|
|
6
|
+
|-------|----------|
|
|
7
|
+
| **Authentication Loop** | Ensure redirect URIs match your application's routing |
|
|
8
|
+
| **Token Acquisition Fails** | Check that required scopes are properly configured |
|
|
9
|
+
| **Module Not Found** | Ensure the module is properly configured and framework is initialized |
|
|
10
|
+
| **Multiple MSAL Instances** | Remove duplicate configurations from child modules |
|
|
11
|
+
| **Redirect Returns Void** | For redirect flows, use `handleRedirect()` after navigation completes |
|
|
12
|
+
| **Token Empty/Undefined** | Verify user is authenticated and scopes are correct |
|
|
13
|
+
|
|
14
|
+
## Getting Help
|
|
15
|
+
|
|
16
|
+
- 📖 [MSAL Cookbook](https://github.com/equinor/fusion-framework/tree/main/cookbooks/app-react-msal) - Complete working examples
|
|
17
|
+
- 🐛 [Report Issues](https://github.com/equinor/fusion/issues) - Bug reports and feature requests
|