Skip to content

Commit

Permalink
[msal-browser] Add support for logout_hint (#4450)
Browse files Browse the repository at this point in the history
* Add loginHint attribute to End Session Request types

* Add support for logout_hint for logout APIs in msal-browser

* Replace preferred_username with login_hint ID token claim for logout_hint behavior

* Add promptless logout docs

* Change files

* Update tests tpush origin logout-hint

* Remove localization from MS docs link on login_hint optional claim

Co-authored-by: Jason Nutter <janutter@microsoft.com>

Co-authored-by: Jason Nutter <janutter@microsoft.com>
  • Loading branch information
hectormmg and jasonnutter committed Jan 29, 2022
1 parent dc27ea9 commit 4c7418a
Show file tree
Hide file tree
Showing 15 changed files with 302 additions and 5 deletions.
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "Add support for logout_hint #4450",
"packageName": "@azure/msal-browser",
"email": "hemoral@microsoft.com",
"dependentChangeType": "patch"
}
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "Add support for logout_hint #4450",
"packageName": "@azure/msal-common",
"email": "hemoral@microsoft.com",
"dependentChangeType": "patch"
}
28 changes: 28 additions & 0 deletions lib/msal-browser/docs/logout.md
Expand Up @@ -103,6 +103,34 @@ await msalInstance.logoutPopup({
});
```

## Promptless logout

If your client application has the [login_hint optional claim](https://docs.microsoft.com/azure/active-directory/develop/active-directory-optional-claims#v10-and-v20-optional-claims-set) enabled for ID Tokens, you can leverage the ID Token's `login_hint` claim to perform a "silent" or promptless logout while using either `logoutRedirect` or `logoutPopup`. There are two ways to achieve a promptless logout:

### Option 1: Let MSAL automatically parse the login_hint out of the account's ID token claims

The first and simplest option is to provide the account object you want to end the session for to the logout API. MSAL will check to see if the `login_hint` claim is available in the account's ID token and automatically add it to the end session request as `logout_hint` to skip the account picker prompt.

```javascript
const currentAccount = msalInstance.getAccountByHomeId(homeAccountId);
// The account's ID Token must contain the login_hint optional claim to avoid the account picker
await msalInstance.logoutRedirect({ account: currentAccount});
```

### Option 2: Manually set the logoutHint option in the logout request

Alternatively, if you prefer to manually set the `logoutHint`, you can extract the `login_hint` claim in your app and set it as the `logoutHint` in the logout request:

```javascript
const currentAccount = msalInstance.getAccountByHomeId(homeAccountId);

// Extract login hint to use as logout hint
const logoutHint = currentAccount.idTokenClaims.login_hint;
await msalInstance.logoutPopup({ logoutHint: logoutHint });
```

***Note: Depending on the API you choose (redirect/popup), the app will still redirect or open a popup to terminate the server session. The difference is that the user will not see or have to interact with the server's account picker prompt.***

## Front-channel logout

Azure AD and Azure AD B2C support the [OAuth front-channel logout feature](https://openid.net/specs/openid-connect-frontchannel-1_0.html), which enables single-sign out across all applications when a user initiates logout. To take advantage of this feature with MSAL.js, perform the following steps:
Expand Down
Expand Up @@ -3,7 +3,7 @@
* Licensed under the MIT License.
*/

import { ICrypto, Logger, ServerTelemetryManager, CommonAuthorizationCodeRequest, Constants, AuthorizationCodeClient, ClientConfiguration, AuthorityOptions, Authority, AuthorityFactory, ServerAuthorizationCodeResponse, UrlString, CommonEndSessionRequest, ProtocolUtils, ResponseMode, StringUtils } from "@azure/msal-common";
import { ICrypto, Logger, ServerTelemetryManager, CommonAuthorizationCodeRequest, Constants, AuthorizationCodeClient, ClientConfiguration, AuthorityOptions, Authority, AuthorityFactory, ServerAuthorizationCodeResponse, UrlString, CommonEndSessionRequest, ProtocolUtils, ResponseMode, StringUtils, IdTokenClaims, AccountInfo } from "@azure/msal-common";
import { BaseInteractionClient } from "./BaseInteractionClient";
import { BrowserConfiguration } from "../config/Configuration";
import { AuthorizationUrlRequest } from "../request/AuthorizationUrlRequest";
Expand Down Expand Up @@ -69,6 +69,29 @@ export abstract class StandardInteractionClient extends BaseInteractionClient {
...logoutRequest
};

/**
* Set logout_hint to be login_hint from ID Token Claims if present
* and logoutHint attribute wasn't manually set in logout request
*/
if (logoutRequest) {
// If logoutHint isn't set and an account was passed in, try to extract logoutHint from ID Token Claims
if (!logoutRequest.logoutHint) {
if(logoutRequest.account) {
const logoutHint = this.getLogoutHintFromIdTokenClaims(logoutRequest.account);
if (logoutHint) {
this.logger.verbose("Setting logoutHint to login_hint ID Token Claim value for the account provided");
validLogoutRequest.logoutHint = logoutHint;
}
} else {
this.logger.verbose("logoutHint was not set and account was not passed into logout request, logoutHint will not be set");
}
} else {
this.logger.verbose("logoutHint has already been set in logoutRequest");
}
} else {
this.logger.verbose("logoutHint will not be set since no logout request was configured");
}

/*
* Only set redirect uri if logout request isn't provided or the set uri isn't null.
* Otherwise, use passed uri, config, or current page.
Expand All @@ -93,6 +116,26 @@ export abstract class StandardInteractionClient extends BaseInteractionClient {
return validLogoutRequest;
}

/**
* Parses login_hint ID Token Claim out of AccountInfo object to be used as
* logout_hint in end session request.
* @param account
*/
protected getLogoutHintFromIdTokenClaims(account: AccountInfo): string | null {
const idTokenClaims: IdTokenClaims | undefined = account.idTokenClaims;
if (idTokenClaims) {
if (idTokenClaims.login_hint) {
return idTokenClaims.login_hint;
} else {
this.logger.verbose("The ID Token Claims tied to the provided account do not contain a login_hint claim, logoutHint will not be added to logout request");
}
} else {
this.logger.verbose("The provided account does not contain ID Token Claims, logoutHint will not be added to logout request");
}

return null;
}

/**
* Creates an Authorization Code Client with the given authority, or the default authority.
* @param serverTelemetryManager
Expand Down
1 change: 1 addition & 0 deletions lib/msal-browser/src/request/EndSessionPopupRequest.ts
Expand Up @@ -15,6 +15,7 @@ import { PopupWindowAttributes } from "../utils/PopupUtils";
* - idTokenHint - ID Token used by B2C to validate logout if required by the policy
* - mainWindowRedirectUri - URI to navigate the main window to after logout is complete
* - popupWindowAttributes - Optional popup window attributes. popupSize with height and width, and popupPosition with top and left can be set.
* - logoutHint - A string that specifies the account that is being logged out in order to skip the server account picker on logout
*/
export type EndSessionPopupRequest = Partial<CommonEndSessionRequest> & {
authority?: string;
Expand Down
1 change: 1 addition & 0 deletions lib/msal-browser/src/request/EndSessionRequest.ts
Expand Up @@ -13,6 +13,7 @@ import { CommonEndSessionRequest } from "@azure/msal-common";
* - correlationId - Unique GUID set per request to trace a request end-to-end for telemetry purposes.
* - idTokenHint - ID Token used by B2C to validate logout if required by the policy
* - onRedirectNavigate - Callback that will be passed the url that MSAL will navigate to. Returning false in the callback will stop navigation.
* - logoutHint - A string that specifies the account that is being logged out in order to skip the server account picker on logout
*/
export type EndSessionRequest = Partial<CommonEndSessionRequest> & {
authority?: string;
Expand Down
119 changes: 119 additions & 0 deletions lib/msal-browser/test/interaction_client/PopupClient.spec.ts
Expand Up @@ -393,6 +393,125 @@ describe("PopupClient", () => {
popupClient.logout().catch(() => {});
});

it("includes logoutHint if it is set on request", (done) => {
const pca = new PublicClientApplication({
auth: {
clientId: TEST_CONFIG.MSAL_CLIENT_ID
},
system: {
asyncPopups: true
}
});

//@ts-ignore
popupClient = new PopupClient(pca.config, pca.browserStorage, pca.browserCrypto, pca.logger, pca.eventHandler, pca.navigationClient);
const logoutHint = "test@user.com";

sinon.stub(PopupUtils, "openSizedPopup").callsFake((urlNavigate) => {
expect(urlNavigate).toContain(`logout_hint=${encodeURIComponent(logoutHint)}`);
done();
throw "Stop Test";
});

popupClient.logout({
logoutHint
}).catch(() => {});
});

it("includes logoutHint from ID token claims if account is passed in and logoutHint is not", (done) => {
const pca = new PublicClientApplication({
auth: {
clientId: TEST_CONFIG.MSAL_CLIENT_ID
},
system: {
asyncPopups: true
}
});

//@ts-ignore
popupClient = new PopupClient(pca.config, pca.browserStorage, pca.browserCrypto, pca.logger, pca.eventHandler, pca.navigationClient);

const logoutHint = "test@user.com";
const testIdTokenClaims: TokenClaims = {
"ver": "2.0",
"iss": "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0",
"sub": "AAAAAAAAAAAAAAAAAAAAAIkzqFVrSaSaFHy782bbtaQ",
"name": "Abe Lincoln",
"preferred_username": "AbeLi@microsoft.com",
"oid": "00000000-0000-0000-66f3-3332eca7ea81",
"tid": "3338040d-6c67-4c5b-b112-36a304b66dad",
"nonce": "123523",
"login_hint": logoutHint
};

const testAccountInfo: AccountInfo = {
homeAccountId: TEST_DATA_CLIENT_INFO.TEST_HOME_ACCOUNT_ID,
localAccountId: TEST_DATA_CLIENT_INFO.TEST_UID,
environment: "login.windows.net",
tenantId: testIdTokenClaims.tid || "",
username: testIdTokenClaims.preferred_username || "",
idTokenClaims: testIdTokenClaims
};

sinon.stub(PopupUtils, "openSizedPopup").callsFake((urlNavigate) => {
expect(urlNavigate).toContain(`logout_hint=${encodeURIComponent(logoutHint)}`);
done();
throw "Stop Test";
});

popupClient.logout({
account: testAccountInfo
}).catch(() => {});
});

it("logoutHint attribute takes precedence over ID Token Claims from provided account when setting logout_hint", (done) => {
const pca = new PublicClientApplication({
auth: {
clientId: TEST_CONFIG.MSAL_CLIENT_ID
},
system: {
asyncPopups: true
}
});

//@ts-ignore
popupClient = new PopupClient(pca.config, pca.browserStorage, pca.browserCrypto, pca.logger, pca.eventHandler, pca.navigationClient);
const logoutHint = "test@user.com";
const loginHint = "anothertest@user.com";
const testIdTokenClaims: TokenClaims = {
"ver": "2.0",
"iss": "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0",
"sub": "AAAAAAAAAAAAAAAAAAAAAIkzqFVrSaSaFHy782bbtaQ",
"name": "Abe Lincoln",
"preferred_username": "AbeLi@microsoft.com",
"oid": "00000000-0000-0000-66f3-3332eca7ea81",
"tid": "3338040d-6c67-4c5b-b112-36a304b66dad",
"nonce": "123523",
"login_hint": loginHint
};

const testAccountInfo: AccountInfo = {
homeAccountId: TEST_DATA_CLIENT_INFO.TEST_HOME_ACCOUNT_ID,
localAccountId: TEST_DATA_CLIENT_INFO.TEST_UID,
environment: "login.windows.net",
tenantId: testIdTokenClaims.tid || "",
username: testIdTokenClaims.preferred_username || "",
idTokenClaims: testIdTokenClaims
};

sinon.stub(PopupUtils, "openSizedPopup").callsFake((urlNavigate) => {
expect(urlNavigate).toContain(`logout_hint=${encodeURIComponent(logoutHint)}`);
expect(urlNavigate).not.toContain(`logout_hint=${encodeURIComponent(loginHint)}`);
done();
throw "Stop Test";
});

popupClient.logout({
account: testAccountInfo,
logoutHint
}).catch(() => {});
});

it("redirects main window when logout is complete", (done) => {
const popupWindow = {...window};
sinon.stub(PopupUtils, "openSizedPopup").returns(popupWindow);
Expand Down
74 changes: 74 additions & 0 deletions lib/msal-browser/test/interaction_client/RedirectClient.spec.ts
Expand Up @@ -1736,6 +1736,80 @@ describe("RedirectClient", () => {
redirectClient.logout();
});

it("includes logoutHint if it is set on request", (done) => {
const logoutHint = "test@user.com";
sinon.stub(NavigationClient.prototype, "navigateExternal").callsFake((urlNavigate: string, options: NavigationOptions): Promise<boolean> => {
expect(urlNavigate).toContain(`logout_hint=${encodeURIComponent(logoutHint)}`);
done();
return Promise.resolve(true);
});
redirectClient.logout({ logoutHint: logoutHint });
});

it("includes logoutHint from ID token claims if account is passed in and logoutHint is not", (done) => {
const logoutHint = "test@user.com";
const testIdTokenClaims: TokenClaims = {
"ver": "2.0",
"iss": "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0",
"sub": "AAAAAAAAAAAAAAAAAAAAAIkzqFVrSaSaFHy782bbtaQ",
"name": "Abe Lincoln",
"preferred_username": "AbeLi@microsoft.com",
"oid": "00000000-0000-0000-66f3-3332eca7ea81",
"tid": "3338040d-6c67-4c5b-b112-36a304b66dad",
"nonce": "123523",
"login_hint": logoutHint
};

const testAccountInfo: AccountInfo = {
homeAccountId: TEST_DATA_CLIENT_INFO.TEST_HOME_ACCOUNT_ID,
localAccountId: TEST_DATA_CLIENT_INFO.TEST_UID,
environment: "login.windows.net",
tenantId: testIdTokenClaims.tid || "",
username: testIdTokenClaims.preferred_username || "",
idTokenClaims: testIdTokenClaims
};

sinon.stub(NavigationClient.prototype, "navigateExternal").callsFake((urlNavigate: string, options: NavigationOptions): Promise<boolean> => {
expect(urlNavigate).toContain(`logout_hint=${encodeURIComponent(logoutHint)}`);
done();
return Promise.resolve(true);
});
redirectClient.logout({ account: testAccountInfo });
});

it("logoutHint attribute takes precedence over ID Token Claims from provided account when setting logout_hint", (done) => {
const logoutHint = "test@user.com";
const loginHint = "anothertest@user.com";
const testIdTokenClaims: TokenClaims = {
"ver": "2.0",
"iss": "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0",
"sub": "AAAAAAAAAAAAAAAAAAAAAIkzqFVrSaSaFHy782bbtaQ",
"name": "Abe Lincoln",
"preferred_username": "AbeLi@microsoft.com",
"oid": "00000000-0000-0000-66f3-3332eca7ea81",
"tid": "3338040d-6c67-4c5b-b112-36a304b66dad",
"nonce": "123523",
"login_hint": loginHint
};

const testAccountInfo: AccountInfo = {
homeAccountId: TEST_DATA_CLIENT_INFO.TEST_HOME_ACCOUNT_ID,
localAccountId: TEST_DATA_CLIENT_INFO.TEST_UID,
environment: "login.windows.net",
tenantId: testIdTokenClaims.tid || "",
username: testIdTokenClaims.preferred_username || "",
idTokenClaims: testIdTokenClaims
};

sinon.stub(NavigationClient.prototype, "navigateExternal").callsFake((urlNavigate: string, options: NavigationOptions): Promise<boolean> => {
expect(urlNavigate).toContain(`logout_hint=${encodeURIComponent(logoutHint)}`);
expect(urlNavigate).not.toContain(`logout_hint=${encodeURIComponent(loginHint)}`);
done();
return Promise.resolve(true);
});
redirectClient.logout({ account: testAccountInfo, logoutHint: logoutHint });
});

it("doesnt navigate if onRedirectNavigate returns false", (done) => {
const logoutUriSpy = sinon.stub(AuthorizationCodeClient.prototype, "getLogoutUri").returns(testLogoutUrl);
sinon.stub(NavigationClient.prototype, "navigateExternal").callsFake((urlNavigate: string, options: NavigationOptions): Promise<boolean> => {
Expand Down
1 change: 1 addition & 0 deletions lib/msal-common/src/account/TokenClaims.ts
Expand Up @@ -15,6 +15,7 @@ export type TokenClaims = {
ver?: string,
upn?: string,
preferred_username?: string,
login_hint?: string,
emails?: string[],
name?: string,
nonce?: string,
Expand Down
4 changes: 4 additions & 0 deletions lib/msal-common/src/client/AuthorizationCodeClient.ts
Expand Up @@ -429,6 +429,10 @@ export class AuthorizationCodeClient extends BaseClient {
parameterBuilder.addState(request.state);
}

if (request.logoutHint) {
parameterBuilder.addLogoutHint(request.logoutHint);
}

if (request.extraQueryParameters) {
parameterBuilder.addExtraQueryParameters(request.extraQueryParameters);
}
Expand Down
2 changes: 2 additions & 0 deletions lib/msal-common/src/request/CommonEndSessionRequest.ts
Expand Up @@ -13,12 +13,14 @@ import { StringDict } from "../utils/MsalTypes";
* - correlationId - Unique GUID set per request to trace a request end-to-end for telemetry purposes.
* - idTokenHint - ID Token used by B2C to validate logout if required by the policy
* - state - A value included in the request to the logout endpoint which will be returned in the query string upon post logout redirection
* - logoutHint - A string that specifies the account that is being logged out in order to skip the server account picker on logout
*/
export type CommonEndSessionRequest = {
correlationId: string
account?: AccountInfo | null,
postLogoutRedirectUri?: string | null,
idTokenHint?: string,
state?: string,
logoutHint?: string,
extraQueryParameters?: StringDict
};
7 changes: 7 additions & 0 deletions lib/msal-common/src/request/RequestParameterBuilder.ts
Expand Up @@ -383,6 +383,13 @@ export class RequestParameterBuilder {
this.parameters.set(AADServerParamKeys.X_MS_LIB_CAPABILITY, ThrottlingConstants.X_MS_LIB_CAPABILITY_VALUE);
}

/**
* Adds logout_hint parameter for "silent" logout which prevent server account picker
*/
addLogoutHint(logoutHint: string): void {
this.parameters.set(AADServerParamKeys.LOGOUT_HINT, encodeURIComponent(logoutHint));
}

/**
* Utility to create a URL from the params map
*/
Expand Down
3 changes: 2 additions & 1 deletion lib/msal-common/src/utils/Constants.ts
Expand Up @@ -137,7 +137,8 @@ export enum AADServerParamKeys {
ON_BEHALF_OF = "on_behalf_of",
FOCI = "foci",
CCS_HEADER = "X-AnchorMailbox",
RETURN_SPA_CODE = "return_spa_code"
RETURN_SPA_CODE = "return_spa_code",
LOGOUT_HINT = "logout_hint"
}

/**
Expand Down

0 comments on commit 4c7418a

Please sign in to comment.