|
|
@@ -43,6 +43,54 @@ interface AuthorizeService {
|
|
|
completeSignOut(url: string): Promise<AuthenticationResult>;
|
|
|
}
|
|
|
|
|
|
+// These are the values for the .NET logger LogLevel.
|
|
|
+// We only use debug and trace
|
|
|
+export enum LogLevel {
|
|
|
+ Trace = 0,
|
|
|
+ Debug = 1
|
|
|
+}
|
|
|
+
|
|
|
+interface JavaScriptLoggingOptions {
|
|
|
+ debugEnabled: boolean;
|
|
|
+ traceEnabled: boolean;
|
|
|
+}
|
|
|
+
|
|
|
+export class Logger {
|
|
|
+ public debug: boolean;
|
|
|
+ public trace: boolean;
|
|
|
+ public constructor(options: JavaScriptLoggingOptions) {
|
|
|
+ this.debug = options.debugEnabled;
|
|
|
+ this.trace = options.traceEnabled;
|
|
|
+ }
|
|
|
+
|
|
|
+ log(level: LogLevel, message: string): void {
|
|
|
+ if ((level == LogLevel.Trace && this.trace) ||
|
|
|
+ (level == LogLevel.Debug && this.debug)) {
|
|
|
+ const levelString = level == LogLevel.Trace ? 'trce' : 'dbug';
|
|
|
+ console.debug(
|
|
|
+ // Logs in the following format to keep consistency with the way ASP.NET Core logs to the console while avoiding the
|
|
|
+ // additional overhead of passing the logger as a JSObjectReference
|
|
|
+ // dbug: Microsoft.AspNetCore.Components.WebAssembly.Authentication.RemoteAuthenticationService[0]
|
|
|
+ // <<message>>
|
|
|
+ // trce: Microsoft.AspNetCore.Components.WebAssembly.Authentication.RemoteAuthenticationService[0]
|
|
|
+ // <<message>>
|
|
|
+ `${levelString}: Microsoft.AspNetCore.Components.WebAssembly.Authentication.RemoteAuthenticationService[0]
|
|
|
+ ${message}`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export interface AuthenticationContext {
|
|
|
+ state?: unknown;
|
|
|
+ interactiveRequest: InteractiveAuthenticationRequest;
|
|
|
+}
|
|
|
+
|
|
|
+export interface InteractiveAuthenticationRequest {
|
|
|
+ interaction: string;
|
|
|
+ scopes?: string[];
|
|
|
+ additionalRequestParameters?: { [key: string]: any };
|
|
|
+};
|
|
|
+
|
|
|
interface AuthorizeServiceConfiguration extends Msal.Configuration {
|
|
|
defaultAccessTokenScopes: string[];
|
|
|
additionalScopesToConsent: string[];
|
|
|
@@ -51,14 +99,48 @@ interface AuthorizeServiceConfiguration extends Msal.Configuration {
|
|
|
|
|
|
class MsalAuthorizeService implements AuthorizeService {
|
|
|
private readonly _msalApplication: Msal.PublicClientApplication;
|
|
|
- private _account: Msal.AccountInfo | undefined | null;
|
|
|
+ private _account: Msal.AccountInfo | null | undefined;
|
|
|
private _redirectCallback: Promise<AuthenticationResult | null> | undefined;
|
|
|
- private _requestedScopes: string[] | undefined;
|
|
|
|
|
|
- constructor(private readonly _settings: AuthorizeServiceConfiguration) {
|
|
|
+ constructor(private readonly _settings: AuthorizeServiceConfiguration, private readonly _logger: Logger) {
|
|
|
if (this._settings.auth?.knownAuthorities?.length == 0) {
|
|
|
this._settings.auth.knownAuthorities = [new URL(this._settings.auth.authority!).hostname]
|
|
|
}
|
|
|
+
|
|
|
+ this._settings.system = this._settings.system || {};
|
|
|
+
|
|
|
+ this._settings.system.navigationClient = {
|
|
|
+ async navigateInternal(url: string, options: Msal.NavigationOptions): Promise<boolean> {
|
|
|
+ // We always replace the URL
|
|
|
+ _logger.log(LogLevel.Trace, `Navigating to ${url}`);
|
|
|
+ location.replace(url);
|
|
|
+ return false;
|
|
|
+ },
|
|
|
+ async navigateExternal(url: string, options: Msal.NavigationOptions): Promise<boolean> {
|
|
|
+ // We always replace the URL
|
|
|
+ _logger.log(LogLevel.Trace, `Navigating to ${url}`);
|
|
|
+ location.replace(url);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ this._settings.system.loggerOptions = {
|
|
|
+ logLevel: _logger.trace ? Msal.LogLevel.Trace : (_logger.debug ? Msal.LogLevel.Verbose : Msal.LogLevel.Warning),
|
|
|
+ loggerCallback: (level, message, containsPii) => {
|
|
|
+ if (containsPii) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (level === Msal.LogLevel.Trace) {
|
|
|
+ _logger.log(LogLevel.Trace, message);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // We only have Debug/Trace, so anything above, we only log when Debug is enabled.
|
|
|
+ _logger.log(LogLevel.Debug, message);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
this._msalApplication = new Msal.PublicClientApplication(this._settings);
|
|
|
}
|
|
|
|
|
|
@@ -81,35 +163,12 @@ class MsalAuthorizeService implements AuthorizeService {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- const scopes: string[] = [];
|
|
|
- if (this._settings.defaultAccessTokenScopes && this._settings.defaultAccessTokenScopes.length > 0) {
|
|
|
- scopes.push(...this._settings.defaultAccessTokenScopes)
|
|
|
- }
|
|
|
-
|
|
|
- if (this._settings.additionalScopesToConsent && this._settings.additionalScopesToConsent.length > 0) {
|
|
|
- scopes.push(...this._settings.additionalScopesToConsent);
|
|
|
- }
|
|
|
-
|
|
|
- if (this._requestedScopes && this._requestedScopes.length > 0) {
|
|
|
- scopes.push(...this._requestedScopes);
|
|
|
- }
|
|
|
-
|
|
|
- const silentRequest = {
|
|
|
- redirectUri: this._settings.auth?.redirectUri,
|
|
|
- account: account,
|
|
|
- scopes: scopes
|
|
|
- };
|
|
|
-
|
|
|
- try {
|
|
|
- const response = await this._msalApplication.acquireTokenSilent(silentRequest);
|
|
|
- return response.idTokenClaims;
|
|
|
- } catch (e) {
|
|
|
- await this.signInCore(silentRequest);
|
|
|
- }
|
|
|
+ return account.idTokenClaims;
|
|
|
}
|
|
|
|
|
|
async getAccessToken(request?: AccessTokenRequestOptions): Promise<AccessTokenResult> {
|
|
|
try {
|
|
|
+ this.trace('getAccessToken', request);
|
|
|
const newToken = await this.getTokenCore(request?.scopes);
|
|
|
return {
|
|
|
status: AccessTokenResultStatus.Success,
|
|
|
@@ -128,72 +187,76 @@ class MsalAuthorizeService implements AuthorizeService {
|
|
|
throw new Error('Failed to retrieve token, no account found.');
|
|
|
}
|
|
|
|
|
|
- this._requestedScopes = scopes;
|
|
|
const silentRequest = {
|
|
|
redirectUri: this._settings.auth?.redirectUri,
|
|
|
account: account,
|
|
|
scopes: scopes || this._settings.defaultAccessTokenScopes
|
|
|
};
|
|
|
|
|
|
+ this.debug(`Provisioning a token silently for scopes '${silentRequest.scopes}'`)
|
|
|
+ this.trace('_msalApplication.acquireTokenSilent', silentRequest);
|
|
|
const response = await this._msalApplication.acquireTokenSilent(silentRequest);
|
|
|
- return {
|
|
|
+ this.trace('_msalApplication.acquireTokenSilent-response', response);
|
|
|
+
|
|
|
+ if (response.scopes.length === 0 || response.accessToken === '') {
|
|
|
+ throw new Error('Scopes not granted.');
|
|
|
+ }
|
|
|
+
|
|
|
+ const result = {
|
|
|
value: response.accessToken,
|
|
|
grantedScopes: response.scopes,
|
|
|
expires: response.expiresOn
|
|
|
};
|
|
|
+
|
|
|
+ this.trace('getAccessToken-result', result);
|
|
|
+
|
|
|
+ return result;
|
|
|
}
|
|
|
|
|
|
- async signIn(state: any) {
|
|
|
+ async signIn(context: AuthenticationContext) {
|
|
|
+ this.trace('signIn', context);
|
|
|
try {
|
|
|
// Before we start any sign-in flow, clear out any previous state so that it doesn't pile up.
|
|
|
this.purgeState();
|
|
|
|
|
|
- const request: Partial<Msal.AuthorizationUrlRequest> = {
|
|
|
- redirectUri: this._settings.auth.redirectUri!,
|
|
|
- state: await this.saveState(state)
|
|
|
- };
|
|
|
-
|
|
|
- if (this._settings.defaultAccessTokenScopes && this._settings.defaultAccessTokenScopes.length > 0) {
|
|
|
- request.scopes = this._settings.defaultAccessTokenScopes;
|
|
|
- }
|
|
|
-
|
|
|
- if (this._settings.additionalScopesToConsent && this._settings.additionalScopesToConsent.length > 0) {
|
|
|
- request.extraScopesToConsent = this._settings.additionalScopesToConsent;
|
|
|
- }
|
|
|
-
|
|
|
- const result = await this.signInCore(request);
|
|
|
- if (!result) {
|
|
|
- return this.redirect();
|
|
|
- } else if (this.isMsalError(result)) {
|
|
|
- return this.error(result.errorMessage);
|
|
|
- }
|
|
|
-
|
|
|
- try {
|
|
|
- if (this._settings.defaultAccessTokenScopes?.length > 0) {
|
|
|
- // This provisions the token as part of the sign-in flow eagerly so that is already in the cache
|
|
|
- // when the app asks for it.
|
|
|
- const account = this.getAccount();
|
|
|
- if (!account) {
|
|
|
- return this.error("No account to get tokens for.");
|
|
|
- }
|
|
|
- const silentRequest : Msal.SilentRequest = {
|
|
|
- redirectUri: request.redirectUri,
|
|
|
- account: account,
|
|
|
- scopes: request?.scopes?.concat(request.extraScopesToConsent || []) || []
|
|
|
- };
|
|
|
- await this._msalApplication.acquireTokenSilent(silentRequest);
|
|
|
+ const { state, interactiveRequest } = context;
|
|
|
+
|
|
|
+ if (interactiveRequest && interactiveRequest.interaction === 'GetToken') {
|
|
|
+ this.debug('Acquiring additional token.');
|
|
|
+ const request: Msal.RedirectRequest = {
|
|
|
+ scopes: interactiveRequest.scopes || [],
|
|
|
+ state: this.saveState(context.state),
|
|
|
+ ...interactiveRequest.additionalRequestParameters
|
|
|
+ };
|
|
|
+ this.trace('getInteractiveToken-Request', request);
|
|
|
+ await this._msalApplication.acquireTokenRedirect(request);
|
|
|
+ return this.success(state);
|
|
|
+ } else {
|
|
|
+ const request: Partial<Msal.AuthorizationUrlRequest> = {
|
|
|
+ redirectUri: this._settings.auth.redirectUri!,
|
|
|
+ state: this.saveState(context.state),
|
|
|
+ ...interactiveRequest?.additionalRequestParameters
|
|
|
+ };
|
|
|
+
|
|
|
+ request.scopes = request.scopes || this._settings.defaultAccessTokenScopes || [];
|
|
|
+
|
|
|
+ const result = await this.signInCore(request);
|
|
|
+ this.trace('signIn-Response', result);
|
|
|
+ if (!result) {
|
|
|
+ return this.redirect();
|
|
|
+ } else if (this.isMsalError(result)) {
|
|
|
+ return this.error(result.errorMessage);
|
|
|
}
|
|
|
- } catch (e) {
|
|
|
- return this.error((e as Msal.AuthError).errorMessage);
|
|
|
- }
|
|
|
|
|
|
- return this.success(state);
|
|
|
+ return this.success(state);
|
|
|
+ }
|
|
|
} catch (e) {
|
|
|
return this.error((e as Error).message);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async signInCore(request: Partial<Msal.AuthorizationUrlRequest>): Promise<Msal.AuthenticationResult | Msal.AuthError | undefined> {
|
|
|
+ this.trace('signIn-Request', request);
|
|
|
const loginMode = this._settings.loginMode.toLowerCase();
|
|
|
if (loginMode === 'redirect') {
|
|
|
return this.signInWithRedirect(request as Msal.RedirectRequest);
|
|
|
@@ -204,20 +267,25 @@ class MsalAuthorizeService implements AuthorizeService {
|
|
|
|
|
|
private async signInWithRedirect(request: Msal.RedirectRequest) {
|
|
|
try {
|
|
|
+ this.debug('Starting sign-in redirect.');
|
|
|
return await this._msalApplication.loginRedirect(request);
|
|
|
} catch (e) {
|
|
|
+ this.debug(`Sign-in redirect failed: '${(e as Error).message}'.`);
|
|
|
return e as any;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
private async signInWithPopup(request: Msal.PopupRequest) {
|
|
|
try {
|
|
|
+ this.debug('Starting sign-in pop-up');
|
|
|
return await this._msalApplication.loginPopup(request);
|
|
|
} catch (e) {
|
|
|
// If the user explicitly cancelled the pop-up, avoid performing a redirect.
|
|
|
if (this.isMsalError(e) && e.errorCode !== Msal.BrowserAuthErrorMessage.userCancelledError.code) {
|
|
|
+ this.debug('User canceled sign-in pop-up');
|
|
|
this.signInWithRedirect(request);
|
|
|
} else {
|
|
|
+ this.debug(`Sign-in pop-up failed: '${(e as Error).message}'.`);
|
|
|
return e as any;
|
|
|
}
|
|
|
}
|
|
|
@@ -226,64 +294,71 @@ class MsalAuthorizeService implements AuthorizeService {
|
|
|
async completeSignIn() {
|
|
|
// Make sure that the redirect handler has completed execution before
|
|
|
// completing sign in.
|
|
|
- var authenticationResult = await this._redirectCallback;
|
|
|
- if (authenticationResult) {
|
|
|
- return authenticationResult;
|
|
|
+ try {
|
|
|
+ this.debug('Completing sign-in redirect.');
|
|
|
+ var authenticationResult = await this._redirectCallback;
|
|
|
+ this.trace('completeSignIn-result', authenticationResult);
|
|
|
+ if (authenticationResult) {
|
|
|
+ this.trace('completeSignIn-success', authenticationResult);
|
|
|
+ return authenticationResult;
|
|
|
+ }
|
|
|
+ this.debug('No authentication result.');
|
|
|
+ return this.operationCompleted();
|
|
|
+ } catch (e) {
|
|
|
+ this.debug(`completeSignIn-error:'${(e as Error).message}'`);
|
|
|
+ return this.error((e as Error).message);
|
|
|
}
|
|
|
- return this.operationCompleted();
|
|
|
}
|
|
|
|
|
|
- async signOut(state: any) {
|
|
|
- // We are about to sign out, so clear any state before we do so and leave just the sign out state for
|
|
|
- // the current sign out flow.
|
|
|
- this.purgeState();
|
|
|
+ async signOut(context: AuthenticationContext) {
|
|
|
+ this.trace('signOut', context);
|
|
|
+ try {
|
|
|
+ // Before we start any sign-in flow, clear out any previous state so that it doesn't pile up.
|
|
|
+ this.purgeState();
|
|
|
|
|
|
- const logoutStateId = await this.saveState(state);
|
|
|
+ const { state, interactiveRequest } = context;
|
|
|
|
|
|
- // msal.js doesn't support providing logout state, so we shim it by putting the identifier in session storage
|
|
|
- // and using that on the logout callback to workout the problems.
|
|
|
- sessionStorage.setItem(`${AuthenticationService._infrastructureKey}.LogoutState`, logoutStateId);
|
|
|
+ const request: Partial<Msal.EndSessionRequest> = {
|
|
|
+ postLogoutRedirectUri: this._settings.auth.postLogoutRedirectUri,
|
|
|
+ state: this.saveState(state),
|
|
|
+ ...interactiveRequest?.additionalRequestParameters
|
|
|
+ };
|
|
|
+ this.trace('signOut-Request', request);
|
|
|
|
|
|
- this._msalApplication.logout();
|
|
|
+ await this._msalApplication.logoutRedirect(request);
|
|
|
|
|
|
- // We are about to be redirected.
|
|
|
- return this.redirect();
|
|
|
+ // We are about to be redirected.
|
|
|
+ return this.redirect();
|
|
|
+ } catch (e) {
|
|
|
+ return this.error((e as Error).message);
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
async completeSignOut(url: string) {
|
|
|
- const logoutStateId = sessionStorage.getItem(`${AuthenticationService._infrastructureKey}.LogoutState`);
|
|
|
- const updatedUrl = new URL(url);
|
|
|
- updatedUrl.search = `?state=${logoutStateId}`;
|
|
|
- const logoutState = await this.retrieveState(updatedUrl.href, null, /*isLogout*/ true);
|
|
|
-
|
|
|
- sessionStorage.removeItem(`${AuthenticationService._infrastructureKey}.LogoutState`);
|
|
|
-
|
|
|
- if (logoutState) {
|
|
|
- return this.success(logoutState);
|
|
|
- } else {
|
|
|
+ this.trace('completeSignOut-request', url);
|
|
|
+ try {
|
|
|
+ this.debug('Completing sign-out redirect.');
|
|
|
+ var authenticationResult = await this._redirectCallback;
|
|
|
+ this.trace('completeSignOut-result', authenticationResult);
|
|
|
+ if (authenticationResult) {
|
|
|
+ this.trace('completeSignOut-success', authenticationResult);
|
|
|
+ return authenticationResult;
|
|
|
+ }
|
|
|
+ this.debug('No authentication result.');
|
|
|
return this.operationCompleted();
|
|
|
+ } catch (e) {
|
|
|
+ this.debug(`completeSignOut-error:'${(e as Error).message}'`);
|
|
|
+ return this.error((e as Error).message);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// msal.js only allows a string as the account state and it simply attaches it to the sign-in request state.
|
|
|
// Given that we don't want to serialize the entire state and put it in the query string, we need to serialize the
|
|
|
// state ourselves and pass an identifier to retrieve it while in the callback flow.
|
|
|
- async saveState<T>(state: T): Promise<string> {
|
|
|
- const base64UrlIdentifier = await new Promise<string>((resolve, reject) => {
|
|
|
- const reader = new FileReader();
|
|
|
- reader.onloadend = evt => resolve((evt?.target?.result as string)
|
|
|
- // The result comes back as a base64 string inside a dataUrl.
|
|
|
- // We remove the prefix and convert it to base64url by replacing '+' with '-', '/' with '_' and removing '='.
|
|
|
- .split(',')[1].replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''));
|
|
|
- reader.onerror = evt => reject(evt.target?.error?.message);
|
|
|
-
|
|
|
- // We generate a base 64 url encoded string of random data.
|
|
|
- const entropy = window.crypto.getRandomValues(new Uint8Array(32));
|
|
|
- reader.readAsDataURL(new Blob([entropy]));
|
|
|
- });
|
|
|
-
|
|
|
- sessionStorage.setItem(`${AuthenticationService._infrastructureKey}.AuthorizeService.${base64UrlIdentifier}`, JSON.stringify(state));
|
|
|
- return base64UrlIdentifier;
|
|
|
+ saveState<T>(state: T): string {
|
|
|
+ const identifier = window.crypto.randomUUID();
|
|
|
+ sessionStorage.setItem(`${AuthenticationService._infrastructureKey}.AuthorizeService.${identifier}`, JSON.stringify(state));
|
|
|
+ return identifier;
|
|
|
}
|
|
|
|
|
|
retrieveState<T>(url: string | null, providedState: string | null = null, isLogout: boolean = false): T | undefined {
|
|
|
@@ -321,37 +396,36 @@ class MsalAuthorizeService implements AuthorizeService {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- async initializeMsalHandler() {
|
|
|
- this._redirectCallback = this._msalApplication.handleRedirectPromise().then(
|
|
|
- (result: Msal.AuthenticationResult | null) => this.handleResult(result)
|
|
|
- ).catch((error: any) => {
|
|
|
+ initializeMsalHandler() {
|
|
|
+ this._redirectCallback = this.completeAuthentication();
|
|
|
+ }
|
|
|
+
|
|
|
+ private async completeAuthentication() {
|
|
|
+ try {
|
|
|
+ const result = await this._msalApplication.handleRedirectPromise();
|
|
|
+ const res = this.handleResult(result);
|
|
|
+ return res;
|
|
|
+ } catch (error) {
|
|
|
if (this.isMsalError(error)) {
|
|
|
return this.error(error.errorMessage);
|
|
|
} else {
|
|
|
- return this.error(error);
|
|
|
+ return this.error((error as Error).message);
|
|
|
}
|
|
|
- })
|
|
|
+ };
|
|
|
}
|
|
|
|
|
|
private handleResult(result: Msal.AuthenticationResult | null) {
|
|
|
+ const logoutState = this.retrieveState(location.href, undefined);
|
|
|
if (result) {
|
|
|
this._account = result.account;
|
|
|
return this.success(this.retrieveState(null, result.state));
|
|
|
+ } else if (logoutState) {
|
|
|
+ return this.success(logoutState);
|
|
|
} else {
|
|
|
return this.operationCompleted();
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- private getAccountState(state: string) {
|
|
|
- if (state) {
|
|
|
- const splitIndex = state.indexOf("|");
|
|
|
- if (splitIndex > -1 && splitIndex + 1 < state.length) {
|
|
|
- return state.substring(splitIndex + 1);
|
|
|
- }
|
|
|
- }
|
|
|
- return state;
|
|
|
- }
|
|
|
-
|
|
|
private isMsalError(resultOrError: any): resultOrError is Msal.AuthError {
|
|
|
return resultOrError?.errorCode;
|
|
|
}
|
|
|
@@ -371,20 +445,29 @@ class MsalAuthorizeService implements AuthorizeService {
|
|
|
private operationCompleted() {
|
|
|
return { status: AuthenticationResultStatus.OperationCompleted };
|
|
|
}
|
|
|
+
|
|
|
+ private debug(message: string) {
|
|
|
+ this._logger?.log(LogLevel.Debug, message);
|
|
|
+ }
|
|
|
+
|
|
|
+ private trace(message: string, data: any) {
|
|
|
+ this._logger?.log(LogLevel.Trace, `${message}: ${JSON.stringify(data)}`);
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
export class AuthenticationService {
|
|
|
|
|
|
static _infrastructureKey = 'Microsoft.Authentication.WebAssembly.Msal';
|
|
|
- static _initialized: Promise<void>;
|
|
|
+ static _initialized: boolean;
|
|
|
static instance: MsalAuthorizeService;
|
|
|
|
|
|
- public static async init(settings: AuthorizeServiceConfiguration) {
|
|
|
+ public static async init(settings: AuthorizeServiceConfiguration, jsLoggingOptions: JavaScriptLoggingOptions) {
|
|
|
if (!AuthenticationService._initialized) {
|
|
|
- AuthenticationService.instance = new MsalAuthorizeService(settings);
|
|
|
- AuthenticationService._initialized = AuthenticationService.instance.initializeMsalHandler();
|
|
|
+ AuthenticationService.instance = new MsalAuthorizeService(settings, new Logger(jsLoggingOptions));
|
|
|
+ AuthenticationService.instance.initializeMsalHandler();
|
|
|
+ AuthenticationService._initialized = true;
|
|
|
}
|
|
|
- return AuthenticationService._initialized;
|
|
|
+ return Promise.resolve();
|
|
|
}
|
|
|
|
|
|
public static getUser() {
|