@equinor/fusion-framework-module-msal-node
Version:
Fusion Framework module for secure Azure AD authentication in Node.js using MSAL. Supports interactive, silent, and token-only authentication modes with encrypted token storage.
112 lines (104 loc) • 4.06 kB
text/typescript
import type { DeviceCodeRequest } from '@azure/msal-node';
import type { AuthenticationResult, PublicClientApplication } from '@azure/msal-node';
import { AuthProvider } from './AuthProvider.js';
import { SilentTokenAcquisitionError } from './errors/SilentTokenAcquisitionError.js';
/**
* Authentication provider that uses the OAuth 2.0 device code flow.
*
* When an access token cannot be acquired silently, the provider calls
* `acquireTokenByDeviceCode` and invokes `deviceCodeCallback` with the
* response containing `userCode`, `verificationUri`, and `message`.
* The user opens the URL on any device, enters the code, and authenticates.
* No local HTTP server is required, making this the recommended mode for CLI tools.
*
* @example
* ```ts
* const provider = new AuthProviderDeviceCode(msalClient, {
* deviceCodeCallback: (response) => console.log(response.message),
* });
* ```
*
* @see AuthProviderInteractive - Browser-based login with a local callback server.
* @see AuthProvider - Silent-only provider (base class).
*/
export class AuthProviderDeviceCode extends AuthProvider {
readonly #deviceCodeCallback: DeviceCodeRequest['deviceCodeCallback'];
/**
* Creates an instance of `AuthProviderDeviceCode`.
*
* @param client - The MSAL `PublicClientApplication` to use for token acquisition.
* @param options - Configuration options.
* @param options.deviceCodeCallback - Callback invoked with the device code response.
* Defaults to printing `response.message` to `console.log`.
*/
constructor(
client: PublicClientApplication,
options?: {
deviceCodeCallback?: DeviceCodeRequest['deviceCodeCallback'];
},
) {
super(client);
this.#deviceCodeCallback =
options?.deviceCodeCallback ?? ((response) => console.log(response.message));
}
/**
* Acquires an access token for the specified scopes.
*
* First attempts silent acquisition using the cached account.
* If that fails (e.g. no account or new resource requiring consent),
* falls back to the device code flow — invoking `deviceCodeCallback` so
* the user can authenticate on any device.
*
* @param options - Token request options.
* @param options.request.scopes - OAuth 2.0 scopes to request.
* @returns A promise resolving to an `AuthenticationResult`.
* @throws {@link SilentTokenAcquisitionError} If device code acquisition also fails.
*/
public override async acquireToken(options: {
request: { scopes: string[] };
}): Promise<AuthenticationResult> {
// Attempt silent acquisition first (uses cached account / refresh token)
const account = await this.getAccount();
// Only attempt silent acquisition when a cached account is available
if (account) {
try {
return await this._client.acquireTokenSilent({
scopes: options.request.scopes,
account,
});
} catch {
// Silent failed — fall through to device code flow below
}
}
// Fall back to device code flow
try {
const result = await this._client.acquireTokenByDeviceCode({
scopes: options.request.scopes,
deviceCodeCallback: this.#deviceCodeCallback,
});
// MSAL can resolve with an empty result even without throwing
if (!result) {
throw new SilentTokenAcquisitionError('Device code flow returned no result');
}
return result;
} catch (error) {
throw new SilentTokenAcquisitionError('Device code token acquisition failed', {
cause: error,
});
}
}
/**
* Initiates the device code login flow explicitly.
*
* This is equivalent to calling `acquireToken` and is provided to satisfy
* the `IAuthProvider` contract.
*
* @param options - Login options containing the requested scopes.
* @returns A promise resolving to an `AuthenticationResult`.
*/
public override async login(options: {
request: { scopes: string[] };
}): Promise<AuthenticationResult> {
return this.acquireToken(options);
}
}