Skip to content

Commit

Permalink
Merge pull request #2985 from AzureAD/client-side-navigation
Browse files Browse the repository at this point in the history
Provide a way for developers to override the navigation methods used
  • Loading branch information
tnorling committed Feb 26, 2021
2 parents 30b5ff9 + d09cd86 commit dc03a45
Show file tree
Hide file tree
Showing 40 changed files with 900 additions and 271 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "Add setNavigationClient API and expose INavigationClient interface (#2985)",
"packageName": "@azure/msal-browser",
"email": "thomas.norling@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "none",
"comment": "Docs Updates (#2985)",
"packageName": "@azure/msal-react",
"email": "thomas.norling@microsoft.com",
"dependentChangeType": "none"
}
94 changes: 94 additions & 0 deletions lib/msal-browser/docs/navigation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Intercepting or Overriding Window Navigation

By default `msal-browser` uses `window.location.assign` and `window.location.replace` to redirect your application to external urls, such as the sign-in or sign-out pages, and internal urls, other pages in your app after returning from an external redirect. There may be situations, however, where you would like to override the default behavior with your own. For example, frameworks like React and Angular provide different APIs to handle client-side navigation without reloading the entire SPA. `msal-browser` exposes both the `INavigationClient` interface as well as the `NavigationClient` default implementation for you to provide your own custom implementation.

## Writing your own implemenation

The interface contains 2 methods:

- `navigateInternal` - Called when redirecting between pages in your app e.g. redirecting from the `redirectUri` back to the page that initiated the login
- `navigateExternal` - Called when redirecting to urls external to your app e.g. AAD Sign-in prompt

You can choose to provide custom implementations of both by implementing `INavigationClient`:

```javascript
class CustomNavigationClient implements INavigationClient {
async navigateInternal(url, options) {
// Your custom logic
}

async navigateExternal(url, options) {
// Your custom logic
}
}
```

Alternatively, if you just need to override one method you can extend the default `NavigationClient`:

```javascript
class CustomNavigationClient extends NavigationClient {
async navigateInternal(url, options) {
// Your custom logic
}

// navigateExternal will use the default
}
```

**Note:** When providing your own implementation of `navigateInternal` you should not navigate to a different domain as this can break your authentication flow. It is intended only to be used for navigation to pages on the same domain.

### Function Parameters

- `url`: The URL MSAL.js would like to navigate to. This will be an absolute url. If you need a relative url it is your responsibility to parse this out.
- `options`: Will contain additional information about the navigation you may find useful such as; the ApiId of the function attempting to invoke navigation, a suggested timeout value and whether or not this navigation should be added to your browser history. You do not need to use any of these values but they are provided for your convenience.

### Return Values

Both functions are async and should return a promise that resolves to boolean `true`/`false`. In most cases you should return `true`.

Return `true` if:

- The function will cause the page to fully redirect to another page, such as going to the AAD sign-in page or reassigning the window object
- The function will directly or indirectly cause `PublicClientApplication` to reinitialize or `handleRedirectPromise` to run again

Return `false` if:

- The function will not cause the page to redirect or reload, for example when extracting the url and navigating to it in a different window
- The function will invoke client-side navigation to re-render a part of the page that does not reinitialize `PublicClientApplication` or call `handleRedirectPromise` again

## Providing your custom implementation to `PublicClientApplication`

Once you have written your custom class you provide an instance of it on the config you pass to `PublicClientApplication`:

```javascript
const navigationClient = new CustomNavigationClient();

const config: Configuration = {
auth: {
clientId: "your-client-id"
},
system: {
navigationClient: navigationClient
}
};

const msalInstance = new PublicClientApplication(config);
```

In some cases, like in React or Angular, you may need to provide your custom class after initialization. You can do this by calling the `setNavigationClient` API:

```javascript
const config: Configuration = {
auth: {
clientId: "your-client-id"
}
};

const msalInstance = new PublicClientApplication(config);
const navigationClient = new CustomNavigationClient();
msalInstance.setNavigationClient(navigationClient);
```

## Examples

If you'd like to see end to end examples of providing a custom `NavigationClient` check out our [react samples](https://github.com/AzureAD/microsoft-authentication-library-for-js/tree/dev/samples/msal-react-samples).
49 changes: 45 additions & 4 deletions lib/msal-browser/src/app/ClientApplication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import { EventError, EventMessage, EventPayload, EventCallbackFunction } from ".
import { EventType } from "../event/EventType";
import { EndSessionRequest } from "../request/EndSessionRequest";
import { BrowserConfigurationAuthError } from "../error/BrowserConfigurationAuthError";
import { INavigationClient } from "../navigation/INavigationClient";
import { NavigationOptions } from "../navigation/NavigationOptions";

export abstract class ClientApplication {

Expand All @@ -35,6 +37,9 @@ export abstract class ClientApplication {
// Network interface implementation
protected readonly networkClient: INetworkModule;

// Navigation interface implementation
protected navigationClient: INavigationClient;

// Input configuration by developer/user
protected config: BrowserConfiguration;

Expand Down Expand Up @@ -99,6 +104,9 @@ export abstract class ClientApplication {
// Initialize the network module class.
this.networkClient = this.config.system.networkClient;

// Initialize the navigation client class.
this.navigationClient = this.config.system.navigationClient;

// Initialize redirectResponse Map
this.redirectResponse = new Map();

Expand Down Expand Up @@ -233,17 +241,33 @@ export abstract class ClientApplication {
* Cache the hash to be retrieved after the next redirect
*/
this.browserStorage.setTemporaryCache(TemporaryCacheKeys.URL_HASH, responseHash, true);
const navigationOptions: NavigationOptions = {
apiId: ApiId.handleRedirectPromise,
timeout: this.config.system.redirectNavigationTimeout,
noHistory: true
};

/**
* Default behavior is to redirect to the start page and not process the hash now.
* The start page is expected to also call handleRedirectPromise which will process the hash in one of the checks above.
*/
let processHashOnRedirect: boolean = true;
if (!loginRequestUrl || loginRequestUrl === "null") {
// Redirect to home page if login request url is null (real null or the string null)
const homepage = BrowserUtils.getHomepage();
// Cache the homepage under ORIGIN_URI to ensure cached hash is processed on homepage
this.browserStorage.setTemporaryCache(TemporaryCacheKeys.ORIGIN_URI, homepage, true);
this.logger.warning("Unable to get valid login request url from cache, redirecting to home page");
await BrowserUtils.navigateWindow(homepage, this.config.system.redirectNavigationTimeout, this.logger, true);
processHashOnRedirect = await this.navigationClient.navigateInternal(homepage, navigationOptions);
} else {
// Navigate to page that initiated the redirect request
this.logger.verbose(`Navigating to loginRequestUrl: ${loginRequestUrl}`);
await BrowserUtils.navigateWindow(loginRequestUrl, this.config.system.redirectNavigationTimeout, this.logger, true);
processHashOnRedirect = await this.navigationClient.navigateInternal(loginRequestUrl, navigationOptions);
}

// If navigateInternal implementation returns false, handle the hash now
if (!processHashOnRedirect) {
return this.handleHash(responseHash, state);
}
}

Expand Down Expand Up @@ -365,6 +389,7 @@ export abstract class ClientApplication {

// Show the UI once the url has been created. Response will come back in the hash, which will be handled in the handleRedirectCallback function.
return interactionHandler.initiateAuthRequest(navigateUrl, {
navigationClient: this.navigationClient,
redirectTimeout: this.config.system.redirectNavigationTimeout,
redirectStartPage: redirectStartPage,
onRedirectNavigate: request.onRedirectNavigate
Expand Down Expand Up @@ -651,18 +676,26 @@ export abstract class ClientApplication {
this.setActiveAccount(null);
}

const navigationOptions: NavigationOptions = {
apiId: ApiId.logout,
timeout: this.config.system.redirectNavigationTimeout,
noHistory: false
};

// Check if onRedirectNavigate is implemented, and invoke it if so
if (logoutRequest && typeof logoutRequest.onRedirectNavigate === "function") {
const navigate = logoutRequest.onRedirectNavigate(logoutUri);

if (navigate !== false) {
this.logger.verbose("Logout onRedirectNavigate did not return false, navigating");
return BrowserUtils.navigateWindow(logoutUri, this.config.system.redirectNavigationTimeout, this.logger);
await this.navigationClient.navigateExternal(logoutUri, navigationOptions);
return;
} else {
this.logger.verbose("Logout onRedirectNavigate returned false, stopping navigation");
}
} else {
return BrowserUtils.navigateWindow(logoutUri, this.config.system.redirectNavigationTimeout, this.logger);
await this.navigationClient.navigateExternal(logoutUri, navigationOptions);
return;
}
} catch(e) {
serverTelemetryManager.cacheFailedRequest(e);
Expand Down Expand Up @@ -1166,5 +1199,13 @@ export abstract class ClientApplication {
this.wrapperSKU = sku;
this.wrapperVer = version;
}

/**
* Sets navigation client
* @param navigationClient
*/
setNavigationClient(navigationClient: INavigationClient): void {
this.navigationClient = navigationClient;
}
// #endregion
}
5 changes: 5 additions & 0 deletions lib/msal-browser/src/app/IPublicClientApplication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { SsoSilentRequest } from "../request/SsoSilentRequest";
import { EndSessionRequest } from "../request/EndSessionRequest";
import { BrowserConfigurationAuthError } from "../error/BrowserConfigurationAuthError";
import { WrapperSKU } from "../utils/BrowserConstants";
import { INavigationClient } from "../navigation/INavigationClient";

export interface IPublicClientApplication {
acquireTokenPopup(request: PopupRequest): Promise<AuthenticationResult>;
Expand All @@ -32,6 +33,7 @@ export interface IPublicClientApplication {
setActiveAccount(account: AccountInfo | null): void;
getActiveAccount(): AccountInfo | null;
initializeWrapperLibrary(sku: WrapperSKU, version: string): void;
setNavigationClient(navigationClient: INavigationClient): void;
}

export const stubbedPublicClientApplication: IPublicClientApplication = {
Expand Down Expand Up @@ -91,5 +93,8 @@ export const stubbedPublicClientApplication: IPublicClientApplication = {
},
initializeWrapperLibrary: () => {
return;
},
setNavigationClient: () => {
return;
}
};
4 changes: 4 additions & 0 deletions lib/msal-browser/src/config/Configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import { SystemOptions, LoggerOptions, INetworkModule, DEFAULT_SYSTEM_OPTIONS, Constants, ProtocolMode, LogLevel, StubbedNetworkModule } from "@azure/msal-common";
import { BrowserUtils } from "../utils/BrowserUtils";
import { BrowserCacheLocation } from "../utils/BrowserConstants";
import { INavigationClient } from "../navigation/INavigationClient";
import { NavigationClient } from "../navigation/NavigationClient";

// Default timeout for popup windows and iframes in milliseconds
export const DEFAULT_POPUP_TIMEOUT_MS = 60000;
Expand Down Expand Up @@ -68,6 +70,7 @@ export type CacheOptions = {
export type BrowserSystemOptions = SystemOptions & {
loggerOptions?: LoggerOptions;
networkClient?: INetworkModule;
navigationClient?: INavigationClient;
windowHashTimeout?: number;
iframeHashTimeout?: number;
loadFrameTimeout?: number;
Expand Down Expand Up @@ -141,6 +144,7 @@ export function buildConfiguration({ auth: userInputAuth, cache: userInputCache,
...DEFAULT_SYSTEM_OPTIONS,
loggerOptions: DEFAULT_LOGGER_OPTIONS,
networkClient: isBrowserEnvironment ? BrowserUtils.getBrowserNetworkClient() : StubbedNetworkModule,
navigationClient: new NavigationClient(),
loadFrameTimeout: 0,
// If loadFrameTimeout is provided, use that as default.
windowHashTimeout: (userInputSystem && userInputSystem.loadFrameTimeout) || DEFAULT_POPUP_TIMEOUT_MS,
Expand Down
5 changes: 4 additions & 1 deletion lib/msal-browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

export { PublicClientApplication } from "./app/PublicClientApplication";
export { Configuration, BrowserAuthOptions, CacheOptions, BrowserSystemOptions } from "./config/Configuration";
export { InteractionType, InteractionStatus, BrowserCacheLocation, WrapperSKU } from "./utils/BrowserConstants";
export { InteractionType, InteractionStatus, BrowserCacheLocation, WrapperSKU, ApiId } from "./utils/BrowserConstants";
export { BrowserUtils } from "./utils/BrowserUtils";

// Browser Errors
Expand All @@ -19,6 +19,9 @@ export { BrowserConfigurationAuthError, BrowserConfigurationAuthErrorMessage } f

// Interfaces
export { IPublicClientApplication, stubbedPublicClientApplication } from "./app/IPublicClientApplication";
export { INavigationClient } from "./navigation/INavigationClient";
export { NavigationClient } from "./navigation/NavigationClient";
export { NavigationOptions } from "./navigation/NavigationOptions";
export { PopupRequest } from "./request/PopupRequest";
export { RedirectRequest } from "./request/RedirectRequest";
export { SilentRequest } from "./request/SilentRequest";
Expand Down
22 changes: 16 additions & 6 deletions lib/msal-browser/src/interaction_handler/RedirectHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@

import { AuthorizationCodeClient, StringUtils, CommonAuthorizationCodeRequest, ICrypto, AuthenticationResult, ThrottlingUtils, Authority, INetworkModule, ClientAuthError } from "@azure/msal-common";
import { BrowserAuthError } from "../error/BrowserAuthError";
import { BrowserConstants, TemporaryCacheKeys } from "../utils/BrowserConstants";
import { BrowserUtils } from "../utils/BrowserUtils";
import { ApiId, BrowserConstants, TemporaryCacheKeys } from "../utils/BrowserConstants";
import { BrowserCacheManager } from "../cache/BrowserCacheManager";
import { InteractionHandler, InteractionParams } from "./InteractionHandler";
import { INavigationClient } from "../navigation/INavigationClient";
import { NavigationOptions } from "../navigation/NavigationOptions";

export type RedirectParams = InteractionParams & {
navigationClient: INavigationClient;
redirectTimeout: number;
redirectStartPage: string;
onRedirectNavigate?: (url: string) => void | boolean;
Expand All @@ -29,7 +31,7 @@ export class RedirectHandler extends InteractionHandler {
* Redirects window to given URL.
* @param urlNavigate
*/
initiateAuthRequest(requestUrl: string, params: RedirectParams): Promise<void> {
async initiateAuthRequest(requestUrl: string, params: RedirectParams): Promise<void> {
// Navigate if valid URL
if (!StringUtils.isEmpty(requestUrl)) {
// Cache start page, returns to this page after redirectUri if navigateToLoginRequestUrl is true
Expand All @@ -41,6 +43,12 @@ export class RedirectHandler extends InteractionHandler {
this.browserStorage.setTemporaryCache(TemporaryCacheKeys.INTERACTION_STATUS_KEY, BrowserConstants.INTERACTION_IN_PROGRESS_VALUE, true);
this.browserStorage.cacheCodeRequest(this.authCodeRequest, this.browserCrypto);
this.authModule.logger.infoPii("Navigate to:" + requestUrl);
const navigationOptions: NavigationOptions = {
apiId: ApiId.acquireTokenRedirect,
timeout: params.redirectTimeout,
noHistory: false
};

// If onRedirectNavigate is implemented, invoke it and provide requestUrl
if (typeof params.onRedirectNavigate === "function") {
this.authModule.logger.verbose("Invoking onRedirectNavigate callback");
Expand All @@ -49,15 +57,17 @@ export class RedirectHandler extends InteractionHandler {
// Returning false from onRedirectNavigate will stop navigation
if (navigate !== false) {
this.authModule.logger.verbose("onRedirectNavigate did not return false, navigating");
return BrowserUtils.navigateWindow(requestUrl, params.redirectTimeout, this.authModule.logger);
await params.navigationClient.navigateExternal(requestUrl, navigationOptions);
return;
} else {
this.authModule.logger.verbose("onRedirectNavigate returned false, stopping navigation");
return Promise.resolve();
return;
}
} else {
// Navigate window to request URL
this.authModule.logger.verbose("Navigating window to navigate url");
return BrowserUtils.navigateWindow(requestUrl, params.redirectTimeout, this.authModule.logger);
await params.navigationClient.navigateExternal(requestUrl, navigationOptions);
return;
}
} else {
// Throw error if request URL is empty.
Expand Down
23 changes: 23 additions & 0 deletions lib/msal-browser/src/navigation/INavigationClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/

import { NavigationOptions } from "./NavigationOptions";

export interface INavigationClient {
/**
* Navigates to other pages within the same web application
* Return false if this doesn't cause the page to reload i.e. Client-side navigation
* @param url
* @param options
*/
navigateInternal(url: string, options: NavigationOptions): Promise<boolean>;

/**
* Navigates to other pages outside the web application i.e. the Identity Provider
* @param url
* @param options
*/
navigateExternal(url: string, options: NavigationOptions): Promise<boolean>;
}

0 comments on commit dc03a45

Please sign in to comment.