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

Add RT expiration check #6703

Merged
merged 17 commits into from
Dec 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "Check RT expiration before attempting to redeem it #6703",
"packageName": "@azure/msal-browser",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "Check RT expiration before attempting to redeem it #6703",
"packageName": "@azure/msal-common",
"email": "[email protected]",
"dependentChangeType": "patch"
}
46 changes: 40 additions & 6 deletions lib/msal-browser/docs/token-lifetimes.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,50 @@ The `PublicClientApplication` object exposes an API called `acquireTokenSilent`

See [here](./request-response-object.md#silentflowrequest) for more information on what configuration parameters you can set for the `acquireTokenSilent` method.

### Avoiding interactive interruptions in the middle of a user's session

In some cases you may want to pre-emptively invoke interaction, if needed, at the beginning of a user's session to ensure they can continue to acquire tokens silently and use your application without further interruptions. You can of course achieve this by invoking interaction each time your application is loaded for the first time, this is, however, a poor user experience and less performant when a user already has tokens from a previous session or another window/tab. Instead, with a few request parameters you can use `acquireTokenSilent` to ensure the cache has the necessary tokens available to return silently for some arbitrary length of time.

To ensure `acquireTokenSilent` can return valid tokens for a minimum of up to 1 hour:

- Call `acquireTokenSilent` on page load with the `forceRefresh` request parameter set to `true`. This will skip the cache and acquire a fresh token which can then be served from the cache on subsequent calls.
- On subsequent calls leave `forceRefresh` unset or explicitly `false` to ensure tokens can be served from the cache

To ensure `acquireTokenSilent` can return valid tokens for a minimum of any length of time up to 24 hours:

- Call `acquireTokenSilent` on page load with the `forceRefresh` request paramter set to `true` & the `refreshTokenExpirationOffsetSeconds` parameter set to the desired length of time (in seconds) to be interaction-free
- On subsequent calls leave `forceRefresh` and `refreshTokenExpirationOffsetSeconds` unset to ensure tokens can be served from the cache

For example if you'd like to ensure the user can acquire tokens silently for the next 2 hours:

```javascript
var request = {
scopes: ["Mail.Read"],
account: currentAccount,
forceRefresh: true
refreshTokenExpirationOffsetSeconds: 7200 // 2 hours * 60 minutes * 60 seconds = 7200 seconds
};

const tokenResponse = await msalInstance.acquireTokenSilent(request).catch(async (error) => {
if (error instanceof InteractionRequiredAuthError) {
// fallback to interaction when silent call fails
await msalInstance.acquireTokenRedirect(request);
}
});
```

Note: There is never a guarantee that a token can be acquired silently even if the refresh token has not expired yet. The patterns described above are best effort attempts to minimize interaction at inconvenient times but will not eliminate the possibility of required interactions within the desired timeframes. Additionally, not all identity providers return the refresh token expiration - in those cases the `refreshTokenExpirationOffsetSeconds` request parameter will not be evaluated.

### Cache Lookup Policy

A Cache Lookup Policy can be optionally provided to the request. The Cache Lookup Policies are:

- `CacheLookupPolicy.Default` - `acquireTokenSilent` will attempt to retrieve an access token from the cache. If the access token is expired or cannot be found the refresh token will be used to acquire a new one. Finally, if the refresh token is expired, `acquireTokenSilent` will attempt to silently acquire a new access token, id token, and refresh token.
- `CacheLookupPolicy.AccessToken` - `acquireTokenSilent` will only look for access tokens in the cache. It will not attempt to renew access or refresh tokens.
- `CacheLookupPolicy.AccessTokenAndRefreshToken` - `acquireTokenSilent` will attempt to retrieve an access token from the cache. If the access token is expired or cannot be found, the refresh token will be used to acquire a new one. If the refresh token is expired, it will not be renewed and `acquireTokenSilent` will fail.
- `CacheLookupPolicy.RefreshToken` - `acquireTokenSilent` will not attempt to retrieve access tokens from the cache and will instead attempt to exchange the cached refresh token for a new access token. If the refresh token is expired, it will not be renewed and `acquireTokenSilent` will fail.
- `CacheLookupPolicy.RefreshTokenAndNetwork` - `acquireTokenSilent` will not look in the cache for the access token. It will go directly to network with the cached refresh token. If the refresh token is expired an attempt will be made to renew it. This is equivalent to setting `forceRefresh: true`.
- `CacheLookupPolicy.Skip` - `acquireTokenSilent` will attempt to renew both access and refresh tokens. It will not look in the cache. This will always fail if 3rd party cookies are blocked by the browser.
- `CacheLookupPolicy.Default` - `acquireTokenSilent` will attempt to retrieve an access token from the cache. If the access token is expired or cannot be found the refresh token will be used to acquire a new one. Finally, if the refresh token is expired, `acquireTokenSilent` will attempt to silently acquire a new access token, id token, and refresh token.
- `CacheLookupPolicy.AccessToken` - `acquireTokenSilent` will only look for access tokens in the cache. It will not attempt to renew access or refresh tokens.
- `CacheLookupPolicy.AccessTokenAndRefreshToken` - `acquireTokenSilent` will attempt to retrieve an access token from the cache. If the access token is expired or cannot be found, the refresh token will be used to acquire a new one. If the refresh token is expired, it will not be renewed and `acquireTokenSilent` will fail.
- `CacheLookupPolicy.RefreshToken` - `acquireTokenSilent` will not attempt to retrieve access tokens from the cache and will instead attempt to exchange the cached refresh token for a new access token. If the refresh token is expired, it will not be renewed and `acquireTokenSilent` will fail.
- `CacheLookupPolicy.RefreshTokenAndNetwork` - `acquireTokenSilent` will not look in the cache for the access token. It will go directly to network with the cached refresh token. If the refresh token is expired an attempt will be made to renew it. This is equivalent to setting `forceRefresh: true`.
- `CacheLookupPolicy.Skip` - `acquireTokenSilent` will attempt to renew both access and refresh tokens. It will not look in the cache. This will always fail if 3rd party cookies are blocked by the browser.

### Code Snippets

Expand Down
4 changes: 3 additions & 1 deletion lib/msal-browser/src/controllers/StandardController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2088,7 +2088,9 @@ export class StandardController implements IController {
refreshTokenError.errorCode ===
ClientAuthErrorCodes.tokenRefreshRequired)) ||
refreshTokenError.errorCode ===
InteractionRequiredAuthErrorCodes.noTokensFound;
InteractionRequiredAuthErrorCodes.noTokensFound ||
refreshTokenError.errorCode ===
InteractionRequiredAuthErrorCodes.refreshTokenExpired;

const tryIframeRenewal =
cacheLookupPolicy ===
Expand Down
26 changes: 26 additions & 0 deletions lib/msal-browser/test/app/PublicClientApplication.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4771,6 +4771,32 @@ describe("PublicClientApplication.ts Class Unit Tests", () => {
expect(silentIframeSpy.calledOnce).toBeTruthy();
});

it("Calls SilentCacheClient.acquireToken, SilentRefreshClient.acquireToken and SilentIframeClient.acquireToken if cache lookup throws and cached refresh token is expired when CacheLookupPolicy is set to Default", async () => {
const silentCacheSpy = jest
.spyOn(SilentCacheClient.prototype, "acquireToken")
.mockRejectedValue(refreshRequiredCacheError);
const silentRefreshSpy = jest
.spyOn(SilentRefreshClient.prototype, "acquireToken")
.mockRejectedValue(
createInteractionRequiredAuthError(
InteractionRequiredAuthErrorCodes.refreshTokenExpired
)
);
const silentIframeSpy = jest
.spyOn(SilentIframeClient.prototype, "acquireToken")
.mockResolvedValue(testTokenResponse);

const response = pca.acquireTokenSilent({
scopes: ["openid"],
account: testAccount,
cacheLookupPolicy: CacheLookupPolicy.Default,
});
await expect(response).resolves.toEqual(testTokenResponse);
expect(silentCacheSpy).toHaveBeenCalledTimes(1);
expect(silentRefreshSpy).toHaveBeenCalledTimes(1);
expect(silentIframeSpy).toHaveBeenCalledTimes(1);
});

it("Calls SilentCacheClient.acquireToken, and doesn't call SilentRefreshClient.acquireToken or SilentIframeClient.acquireToken if cache lookup throws when CacheLookupPolicy is set to AccessToken", async () => {
const silentCacheSpy = sinon
.stub(SilentCacheClient.prototype, "acquireToken")
Expand Down
4 changes: 3 additions & 1 deletion lib/msal-common/src/cache/entities/RefreshTokenEntity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@ import { CredentialEntity } from "./CredentialEntity";
/**
* Refresh Token Cache Type
*/
export type RefreshTokenEntity = CredentialEntity;
export type RefreshTokenEntity = CredentialEntity & {
expiresOn?: string;
};
7 changes: 6 additions & 1 deletion lib/msal-common/src/cache/utils/CacheHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,8 @@ export function createRefreshTokenEntity(
refreshToken: string,
clientId: string,
familyId?: string,
userAssertionHash?: string
userAssertionHash?: string,
expiresOn?: number
): RefreshTokenEntity {
const rtEntity: RefreshTokenEntity = {
credentialType: CredentialType.REFRESH_TOKEN,
Expand All @@ -186,6 +187,10 @@ export function createRefreshTokenEntity(
rtEntity.familyId = familyId;
}

if (expiresOn) {
rtEntity.expiresOn = expiresOn.toString();
}

return rtEntity;
}

Expand Down
16 changes: 16 additions & 0 deletions lib/msal-common/src/client/RefreshTokenClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ import {
import { PerformanceEvents } from "../telemetry/performance/PerformanceEvent";
import { IPerformanceClient } from "../telemetry/performance/IPerformanceClient";
import { invoke, invokeAsync } from "../utils/FunctionWrappers";

const DEFAULT_REFRESH_TOKEN_EXPIRATION_OFFSET_SECONDS = 300; // 5 Minutes

/**
* OAuth2.0 refresh token client
* @internal
Expand Down Expand Up @@ -215,6 +218,19 @@ export class RefreshTokenClient extends BaseClient {
InteractionRequiredAuthErrorCodes.noTokensFound
);
}

if (
refreshToken.expiresOn &&
TimeUtils.isTokenExpired(
refreshToken.expiresOn,
request.refreshTokenExpirationOffsetSeconds ||
DEFAULT_REFRESH_TOKEN_EXPIRATION_OFFSET_SECONDS
)
) {
throw createInteractionRequiredAuthError(
InteractionRequiredAuthErrorCodes.refreshTokenExpired
);
}
// attach cached RT size to the current measurement

const refreshTokenRequest: CommonRefreshTokenRequest = {
Expand Down
1 change: 1 addition & 0 deletions lib/msal-common/src/constants/AADServerParamKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const ACCESS_TOKEN = "access_token";
export const ID_TOKEN = "id_token";
export const REFRESH_TOKEN = "refresh_token";
export const EXPIRES_IN = "expires_in";
export const REFRESH_TOKEN_EXPIRES_IN = "refresh_token_expires_in";
export const STATE = "state";
export const NONCE = "nonce";
export const PROMPT = "prompt";
Expand Down
2 changes: 2 additions & 0 deletions lib/msal-common/src/error/InteractionRequiredAuthError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ const InteractionRequiredAuthErrorMessages = {
"No refresh token found in the cache. Please sign-in.",
[InteractionRequiredAuthErrorCodes.nativeAccountUnavailable]:
"The requested account is not available in the native broker. It may have been deleted or logged out. Please sign-in again using an interactive API.",
[InteractionRequiredAuthErrorCodes.refreshTokenExpired]:
"Refresh token has expired.",
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// Codes defined by MSAL
export const noTokensFound = "no_tokens_found";
export const nativeAccountUnavailable = "native_account_unavailable";
export const refreshTokenExpired = "refresh_token_expired";

// Codes potentially returned by server
export const interactionRequired = "interaction_required";
Expand Down
5 changes: 5 additions & 0 deletions lib/msal-common/src/request/CommonSilentFlowRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ import { BaseAuthRequest } from "./BaseAuthRequest";
* - tokenQueryParameters - String to string map of custom query parameters added to the /token call
*/
export type CommonSilentFlowRequest = BaseAuthRequest & {
/** Account object to lookup the credentials */
account: AccountInfo;
/** Skip cache lookup and forces network call(s) to get fresh tokens */
forceRefresh: boolean;
/** Key value pairs to include on the POST body to the /token endpoint */
tokenBodyParameters?: StringDict;
/** If refresh token will expire within the configured value, consider it already expired. Used to pre-emptively invoke interaction when cached refresh token is close to expiry. */
refreshTokenExpirationOffsetSeconds?: number;
};
15 changes: 14 additions & 1 deletion lib/msal-common/src/response/ResponseHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,13 +498,26 @@ export class ResponseHandler {
// refreshToken
let cachedRefreshToken: RefreshTokenEntity | null = null;
if (serverTokenResponse.refresh_token) {
let rtExpiresOn: number | undefined;
if (serverTokenResponse.refresh_token_expires_in) {
const rtExpiresIn: number =
typeof serverTokenResponse.refresh_token_expires_in ===
"string"
? parseInt(
serverTokenResponse.refresh_token_expires_in,
10
)
: serverTokenResponse.refresh_token_expires_in;
rtExpiresOn = reqTimestamp + rtExpiresIn;
}
cachedRefreshToken = CacheHelpers.createRefreshTokenEntity(
this.homeAccountIdentifier,
env,
serverTokenResponse.refresh_token,
this.clientId,
serverTokenResponse.foci,
userAssertionHash
userAssertionHash,
rtExpiresOn
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export type ServerAuthorizationTokenResponse = {
ext_expires_in?: number;
access_token?: string;
refresh_token?: string;
refresh_token_expires_in?: number;
id_token?: string;
client_info?: string;
foci?: string;
Expand Down
5 changes: 4 additions & 1 deletion lib/msal-common/test/client/ClientTestUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,10 @@ export class ClientTestUtils {
const mockStorage = new MockStorageClass(
TEST_CONFIG.MSAL_CLIENT_ID,
mockCrypto,
new Logger({})
new Logger({}),
{
canonicalAuthority: TEST_CONFIG.validAuthority,
}
);

const testLoggerCallback = (): void => {
Expand Down
58 changes: 58 additions & 0 deletions lib/msal-common/test/client/RefreshTokenClient.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
} from "../../src/error/InteractionRequiredAuthError";
import { StubPerformanceClient } from "../../src/telemetry/performance/StubPerformanceClient";
import { ProtocolMode } from "../../src/authority/ProtocolMode";
import { TimeUtils } from "../../src/utils/TimeUtils";
import { buildAccountFromIdTokenClaims } from "msal-test-utils";

const testAccountEntity: AccountEntity = new AccountEntity();
Expand Down Expand Up @@ -1311,6 +1312,63 @@ describe("RefreshTokenClient unit tests", () => {
)
);
});

it("Throws error if cached RT is expired", async () => {
const testScope2 = "scope2";
const tokenRequest: CommonSilentFlowRequest = {
scopes: [testScope2],
account: testAccountEntity.getAccountInfo(),
authority: TEST_CONFIG.validAuthority,
correlationId: TEST_CONFIG.CORRELATION_ID,
forceRefresh: false,
};
const config =
await ClientTestUtils.createTestClientConfiguration();
config.storageInterface!.setRefreshTokenCredential({
...testRefreshTokenEntity,
expiresOn: (TimeUtils.nowSeconds() - 48 * 60 * 60).toString(), // Set expiration to yesterday
});
const client = new RefreshTokenClient(
config,
stubPerformanceClient
);
await expect(
client.acquireTokenByRefreshToken(tokenRequest)
).rejects.toMatchObject(
createInteractionRequiredAuthError(
InteractionRequiredAuthErrorCodes.refreshTokenExpired
)
);
});

it("Throws error if cached RT expiration is within provided offset", async () => {
const testScope2 = "scope2";
const tokenRequest: CommonSilentFlowRequest = {
scopes: [testScope2],
account: testAccountEntity.getAccountInfo(),
authority: TEST_CONFIG.validAuthority,
correlationId: TEST_CONFIG.CORRELATION_ID,
forceRefresh: false,
refreshTokenExpirationOffsetSeconds: 60 * 60, // 1 hour
};
const config =
await ClientTestUtils.createTestClientConfiguration();
config.storageInterface!.setRefreshTokenCredential({
...testRefreshTokenEntity,
expiresOn: (TimeUtils.nowSeconds() + 30 * 60).toString(), // Set expiration to 30 minutes from now
});
const client = new RefreshTokenClient(
config,
stubPerformanceClient
);
await expect(
client.acquireTokenByRefreshToken(tokenRequest)
).rejects.toMatchObject(
createInteractionRequiredAuthError(
InteractionRequiredAuthErrorCodes.refreshTokenExpired
)
);
});
});
describe("Telemetry protocol mode tests", () => {
const refreshTokenRequest: CommonRefreshTokenRequest = {
Expand Down
Loading