Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Provide a way for developers to override the navigation methods used #2985

Merged
merged 26 commits into from
Feb 26, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c624608
Client side navigation
tnorling Feb 6, 2021
6ec020b
Merge branch 'dev' of https://github.com/AzureAD/microsoft-authentica…
tnorling Feb 8, 2021
d4ec7ab
Update samples
tnorling Feb 9, 2021
dd12a57
Update docs
tnorling Feb 9, 2021
0ef9a0a
Change files
tnorling Feb 9, 2021
2bd8e88
Update tests
tnorling Feb 9, 2021
85ad73e
Merge branch 'dev' into client-side-navigation
tnorling Feb 9, 2021
73eadfd
Merge branch 'dev' of https://github.com/AzureAD/microsoft-authentica…
tnorling Feb 10, 2021
792806b
Add setClientSideNavigateCallback API
tnorling Feb 10, 2021
29b78aa
handleHash before navigate
tnorling Feb 11, 2021
488719e
Replace navigateWindow with NavigationClient
tnorling Feb 16, 2021
98ba919
add setNavigationClient API
tnorling Feb 17, 2021
e34e80e
Test and sample updates
tnorling Feb 18, 2021
df9e38f
Change files
tnorling Feb 18, 2021
27f19cb
Fix build/test errors
tnorling Feb 18, 2021
edd3bfd
Update docs
tnorling Feb 18, 2021
ccaaa44
Update change/@azure-msal-browser-58ddbcda-5d34-4249-acbd-c5e52b1d814…
tnorling Feb 18, 2021
ea88778
Merge branch 'dev' into client-side-navigation
tnorling Feb 18, 2021
09f23c9
Docs
tnorling Feb 18, 2021
04ad2a8
Merge branch 'client-side-navigation' of https://github.com/AzureAD/m…
tnorling Feb 18, 2021
127a9b1
Apply suggestions from code review
tnorling Feb 19, 2021
023e317
Merge branch 'dev' of https://github.com/AzureAD/microsoft-authentica…
tnorling Feb 19, 2021
8277053
Address code review comments
tnorling Feb 19, 2021
6507b73
Merge branch 'dev' of https://github.com/AzureAD/microsoft-authentica…
tnorling Feb 25, 2021
4ac08ed
Default case
tnorling Feb 25, 2021
d09cd86
Address feedback
tnorling Feb 25, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
@@ -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"
}
@@ -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
@@ -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.
tnorling marked this conversation as resolved.
Show resolved Hide resolved

## 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
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
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
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
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
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
@@ -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>;
}