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

Managed Identity: ResponseHandler can now accept an ISO 8601 date in the ServerAuthorizationTokenResponse's "expires_in" value #7529

Open
wants to merge 8 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 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": "patch",
"comment": "Managed Identity: ResponseHandler can now accept an ISO 8601 date in the ServerAuthorizationTokenResponse expires_in value #7529",
"packageName": "@azure/msal-common",
"email": "[email protected]",
"dependentChangeType": "patch"
}
16 changes: 10 additions & 6 deletions lib/msal-common/apiReview/msal-common.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -2446,6 +2446,9 @@ export interface ISerializableTokenCache {
// @public
function isIdTokenEntity(entity: object): boolean;

// @internal
function isIso8601(dateString: string): boolean;

// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// Warning: (ae-missing-release-tag) "isRefreshTokenEntity" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
Expand Down Expand Up @@ -3607,9 +3610,9 @@ export type ServerAuthorizationTokenResponse = {
status?: number;
token_type?: AuthenticationScheme;
scope?: string;
expires_in?: number;
refresh_in?: number;
expires_in?: number | string;
ext_expires_in?: number;
refresh_in?: number;
access_token?: string;
refresh_token?: string;
refresh_token_expires_in?: number;
Expand Down Expand Up @@ -3956,7 +3959,8 @@ declare namespace TimeUtils {
nowSeconds,
isTokenExpired,
wasClockTurnedBack,
delay
delay,
isIso8601
}
}
export { TimeUtils }
Expand Down Expand Up @@ -4288,9 +4292,9 @@ const X_MS_LIB_CAPABILITY = "x-ms-lib-capability";
// src/index.ts:8:12 - (tsdoc-characters-after-block-tag) The token "@azure" looks like a TSDoc tag but contains an invalid character "/"; if it is not a tag, use a backslash to escape the "@"
// src/index.ts:8:4 - (tsdoc-undefined-tag) The TSDoc tag "@module" is not defined in this configuration
// src/request/AuthenticationHeaderParser.ts:74:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// src/response/ResponseHandler.ts:430:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// src/response/ResponseHandler.ts:431:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// src/response/ResponseHandler.ts:432:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// src/response/ResponseHandler.ts:455:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// src/response/ResponseHandler.ts:456:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// src/response/ResponseHandler.ts:457:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// src/telemetry/performance/PerformanceClient.ts:916:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// src/telemetry/performance/PerformanceClient.ts:916:15 - (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}'
// src/telemetry/performance/PerformanceClient.ts:928:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
Expand Down
44 changes: 36 additions & 8 deletions lib/msal-common/src/response/ResponseHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
updateAccountTenantProfileData,
} from "../account/AccountInfo.js";
import * as CacheHelpers from "../cache/utils/CacheHelpers.js";
import { isIso8601 } from "../utils/TimeUtils.js";

function parseServerErrorNo(
serverResponse: ServerAuthorizationCodeResponse
Expand Down Expand Up @@ -425,6 +426,30 @@ export class ResponseHandler {
);
}

/**
* Calculates the number of seconds until the token expires.
*
* @param reqTimestamp - The timestamp when the request was made, in seconds.
* @param tokenExpirationTimestamp - The expiration timestamp of the token, which can be a number (or a number as a string), a string in ISO 8601 format, or undefined.
* @param returnValueIfUndefined - The value to return if the tokenExpirationTimestamp is undefined.
* @returns The number of seconds until the token expires, or the returnValueIfUndefined if the tokenExpirationTimestamp is undefined.
*/
private getSecondsUntilTokenExpires(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you move this to TimeUtils as a pure function?

reqTimestamp: number,
tokenExpirationTimestamp: number | string | undefined,
returnValueIfUndefined: number | undefined
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this as a param if we only ever pass 0?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added that in there because I originally coded this PR to also handle (future-proof?) ext_expires_in, refresh_in, and refresh_token_expires_in. However, I'm not sure if those are necessary, and if they are, maybe it should be another PR. I've posed this question to Bogdan and Gladwin and am awaiting feedback.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think even for those the fallback would be 0, no?

Copy link
Collaborator Author

@Robbie-Microsoft Robbie-Microsoft Jan 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

refreshOnSeconds (derived from refresh_in) can be passed as undefined to CacheHelpers.createAccessTokenEntity, and rtExpiresOn (derived from refresh_token_expires_in) can be passed as undefined to CacheHelpers.createRefreshTokenEntity.

): number | undefined {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
): number | undefined {
): number {

if (typeof tokenExpirationTimestamp === "string") {
return isIso8601(tokenExpirationTimestamp)
? Math.floor(
new Date(tokenExpirationTimestamp).getTime() / 1000
) - reqTimestamp
: parseInt(tokenExpirationTimestamp, 10);
Copy link
Collaborator

@tnorling tnorling Jan 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minus reqTimestamp?

Copy link
Collaborator Author

@Robbie-Microsoft Robbie-Microsoft Jan 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I don't understand what you're asking.

But to explain this: If the expires_in value is an ISO 8601 string, it's in the future - say 3599 seconds (an hour minus 1 second). This equation will subtract the current time, as of the moment of the request, from the ISO 8601 string and end up with what's left (3599 seconds).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused by the fact that we seem to be mixing and matching expiresAT with expiresIN values. It's not clear what these variables represent. We have a variable called tokenExpirationTimestamp which suggests it represents expiresAT and would need a subtraction of reqTimestamp in order to obtain expiresIN but we're only doing that subtraction if the value is ISO8601 format otherwise treating it as if it's already expiresIN.

This might be poor API design on the server side if they're sending back values that represent different things on the same parameter but if that's the case we should write our logic in way that clears up this confusion. e.g.

const expiresIn = isIso8601(serverResponse.expires_in) ? getExpiresInFromExpiresAt(serverResponse.expires_in, reqTimestamp) : serverResponse.expires_in || 0;
const expiresAt = isIso8601(serverResponse.expires_in) ? serverResponse.expiresIn : getExpiresAtFromExpiresIn(serverResponse.expires_in, reqTimestamp);

} else {
return tokenExpirationTimestamp || returnValueIfUndefined;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minus reqTimestamp?

}
}

/**
* Generates CacheRecord
* @param serverTokenResponse
Expand Down Expand Up @@ -486,23 +511,26 @@ export class ResponseHandler {

/*
* Use timestamp calculated before request
* Server may return timestamps as strings, parse to numbers if so.
* Server may return timestamps as an ISO 8601 date string or a numeric string, parse to numbers if so.
*/
const expiresIn: number =
(typeof serverTokenResponse.expires_in === "string"
? parseInt(serverTokenResponse.expires_in, 10)
: serverTokenResponse.expires_in) || 0;
const expiresIn: number = this.getSecondsUntilTokenExpires(
reqTimestamp,
serverTokenResponse.expires_in,
0
) as number;
const tokenExpirationSeconds = reqTimestamp + expiresIn;

const extExpiresIn: number =
(typeof serverTokenResponse.ext_expires_in === "string"
? parseInt(serverTokenResponse.ext_expires_in, 10)
: serverTokenResponse.ext_expires_in) || 0;
const extendedTokenExpirationSeconds =
tokenExpirationSeconds + extExpiresIn;

const refreshIn: number | undefined =
(typeof serverTokenResponse.refresh_in === "string"
? parseInt(serverTokenResponse.refresh_in, 10)
: serverTokenResponse.refresh_in) || undefined;
const tokenExpirationSeconds = reqTimestamp + expiresIn;
const extendedTokenExpirationSeconds =
tokenExpirationSeconds + extExpiresIn;
const refreshOnSeconds =
refreshIn && refreshIn > 0
? reqTimestamp + refreshIn
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import { AuthenticationScheme } from "../utils/Constants.js";
* Deserialized response object from server authorization code request.
* - token_type: Indicates the token type value. Can be either Bearer or pop.
* - scope: The scopes that the access_token is valid for.
* - expires_in: How long the access token is valid (in seconds).
* - refresh_in: Duration afer which a token should be renewed, regardless of expiration.
* - expires_in: How long the access token is valid (in seconds, or an ISO 8601 string).
* - ext_expires_in: How long the access token is valid (in seconds) if the server isn't responding.
* - refresh_in: Duration afer which a token should be renewed, regardless of expiration.
* - access_token: The requested access token. The app can use this token to authenticate to the secured resource, such as a web API.
* - refresh_token: An OAuth 2.0 refresh token. The app can use this token acquire additional access tokens after the current access token expires.
* - id_token: A JSON Web Token (JWT). The app can decode the segments of this token to request information about the user who signed in.
Expand All @@ -31,9 +31,9 @@ export type ServerAuthorizationTokenResponse = {
// Success
token_type?: AuthenticationScheme;
scope?: string;
expires_in?: number;
refresh_in?: number;
expires_in?: number | string; // Managed Identity can send an ISO 8601 string instead of a number (or a number as a string)
ext_expires_in?: number;
refresh_in?: number;
access_token?: string;
refresh_token?: string;
refresh_token_expires_in?: number;
Expand Down
12 changes: 12 additions & 0 deletions lib/msal-common/src/utils/TimeUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,15 @@ export function wasClockTurnedBack(cachedAt: string): boolean {
export function delay<T>(t: number, value?: T): Promise<T | void> {
return new Promise((resolve) => setTimeout(() => resolve(value), t));
}

/**
* @internal
* Checks if a given date string is in ISO 8601 format.
*
* @param dateString - The date string to be checked.
* @returns boolean - Returns true if the date string is in ISO 8601 format, otherwise false.
*/
export function isIso8601(dateString: string): boolean {
const date = new Date(dateString);
return !isNaN(date.getTime()) && date.toISOString() === dateString;
}
49 changes: 49 additions & 0 deletions lib/msal-common/test/response/ResponseHandler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,55 @@ describe("ResponseHandler.ts", () => {
);
});

it("Ensure handleServerTokenResponse is able to parse token expiry information that is provided as ISO 8601 string", async () => {
const testRequest: BaseAuthRequest = {
authority: testAuthority.canonicalAuthority,
correlationId: "CORRELATION_ID",
scopes: ["openid", "profile", "User.Read", "email"],
};

const testResponse: ServerAuthorizationTokenResponse = {
...AUTHENTICATION_RESULT.body,
};

const responseHandler = new ResponseHandler(
"this-is-a-client-id",
testCacheManager,
cryptoInterface,
logger,
null,
null
);

const timestamp = TimeUtils.nowSeconds();

// convert the test response's expires_in to an ISO string
const newExpiresIn = new Date(timestamp * 1000);
newExpiresIn.setSeconds(
newExpiresIn.getSeconds() + (testResponse.expires_in as number)
);
const newExpiresInIsoDate = newExpiresIn.toISOString();
testResponse.expires_in = newExpiresInIsoDate;

const response = await responseHandler.handleServerTokenResponse(
testResponse,
testAuthority,
timestamp,
testRequest
);

expect(
response.expiresOn && response.expiresOn.toISOString()
).toEqual(newExpiresInIsoDate);
expect(
((response.expiresOn as Date) &&
Math.round((response.expiresOn as Date).getTime() / 1000)) -
timestamp
).toEqual(AUTHENTICATION_RESULT.body.expires_in);

jest.restoreAllMocks();
});

it("includes spa_code in response as code", async () => {
const testSpaCode = "sample-spa-code";

Expand Down
32 changes: 16 additions & 16 deletions lib/msal-common/test/test_kit/StringConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,8 +508,8 @@ export const AUTHENTICATION_RESULT = {
body: {
token_type: AuthenticationScheme.BEARER,
scope: "openid profile User.Read email",
expires_in: 3599,
ext_expires_in: 3599,
expires_in: TEST_TOKEN_LIFETIMES.DEFAULT_EXPIRES_IN,
ext_expires_in: TEST_TOKEN_LIFETIMES.DEFAULT_EXPIRES_IN,
access_token: "thisIs.an.accessT0ken",
refresh_token: "thisIsARefreshT0ken",
id_token: TEST_TOKENS.IDTOKEN_V2,
Expand All @@ -522,8 +522,8 @@ export const AUTHENTICATION_RESULT_NO_REFRESH_TOKEN = {
body: {
token_type: AuthenticationScheme.BEARER,
scope: "openid profile User.Read email",
expires_in: 3599,
ext_expires_in: 3599,
expires_in: TEST_TOKEN_LIFETIMES.DEFAULT_EXPIRES_IN,
ext_expires_in: TEST_TOKEN_LIFETIMES.DEFAULT_EXPIRES_IN,
access_token: "thisIs.an.accessT0ken",
refresh_token: "",
id_token:
Expand All @@ -537,8 +537,8 @@ export const POP_AUTHENTICATION_RESULT = {
body: {
token_type: AuthenticationScheme.POP,
scope: "openid profile User.Read email",
expires_in: 3599,
ext_expires_in: 3599,
expires_in: TEST_TOKEN_LIFETIMES.DEFAULT_EXPIRES_IN,
ext_expires_in: TEST_TOKEN_LIFETIMES.DEFAULT_EXPIRES_IN,
access_token: `${TEST_POP_VALUES.SAMPLE_POP_AT}`,
refresh_token: "thisIsARefreshT0ken",
id_token:
Expand All @@ -552,8 +552,8 @@ export const SSH_AUTHENTICATION_RESULT = {
body: {
token_type: AuthenticationScheme.SSH,
scope: "https://pas.windows.net/CheckMyAccess/Linux/user_impersonation https://pas.windows.net/CheckMyAccess/Linux/.default",
"expires}_in": 3599,
ext_expires_in: 3599,
"expires}_in": TEST_TOKEN_LIFETIMES.DEFAULT_EXPIRES_IN,
ext_expires_in: TEST_TOKEN_LIFETIMES.DEFAULT_EXPIRES_IN,
access_token:
"AAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20AAAAgrk+wGrNoM6ZcU8/aVc+O9nMQArnTpgQcN2nDOojq3LwAAAADAQABAAABAQCiPcGP8PriIUKC1EAiepduIitPFswHDoPpAUJqbzgKNLdTdy86OoGFpY9yKo9kVgCTdPj/v8cO76/+I1vlHk1p7Q9DeFe333LefRnBUT8tDiFC4wtYJDxhpCcuOsEIlHVhYPp33ZQZePomb9rzTCatzFnrP9b62FRpx0Y3pjk/lstOr50Bh/3ZlDFPH36chXwEDSOcW3QX+0y4FT6x5zxna9KrwpCOWVaBdqsHpoqruDhGwkCAaoL6RXCyQTZatcqJNWCcD6a8GFHAkTZMxh2LR0xPZ4JkIDofKbauP/s9FPlAJN+VhY+HthrduVzgRP3ELxqSCE8xmNV8R/AVv1OxAAAAAAAAAAAAAAABAAAASTE4ZmRhY2MyLThkMWEtNDMzOC04NWM4LTAwMjY1ZmI2NWVmNkA3MmY5ODhiZi04NmYxLTQxYWYtOTFhYi0yZDdjZDAxMWRiNDcAAAAZAAAAFWhlbW9yYWxAbWljcm9zb2Z0LmNvbQAAAABhcFyFAAAAAGFwa8EAAAAAAAABTAAAACBkaXNwbGF5bmFtZUBzc2hzZXJ2aWNlLmF6dXJlLm5ldAAAABIAAAAOSGVjdG9yIE1vcmFsZXMAAAAYb2lkQHNzaHNlcnZpY2UuYXp1cmUubmV0AAAAKAAAACQxOGZkYWNjMi04ZDFhLTQzMzgtODVjOC0wMDI2NWZiNjVlZjYAAAAVcGVybWl0LVgxMS1mb3J3YXJkaW5nAAAAAAAAABdwZXJtaXQtYWdlbnQtZm9yd2FyZGluZwAAAAAAAAAWcGVybWl0LXBvcnQtZm9yd2FyZGluZwAAAAAAAAAKcGVybWl0LXB0eQAAAAAAAAAOcGVybWl0LXVzZXItcmMAAAAAAAAAGHRpZEBzc2hzZXJ2aWNlLmF6dXJlLm5ldAAAACgAAAAkNzJmOTg4YmYtODZmMS00MWFmLTkxYWItMmQ3Y2QwMTFkYjQ3AAAAAAAAARcAAAAHc3NoLXJzYQAAAAMBAAEAAAEBALH7FzF1rjvnZ4i2iBC2tz8qs/WP61n3/wFawgJxUnTx2vP/z5pG7f8qvumd7taOII0aSlp648SIfMw59WdUUtup5CnDYOcX1sUdivAj20m2PIDK6f+KWZ+7YKxJqCzJMH4GGlQvuDIhRKNT9oHfZgnYCCAmjXmJBtWyD052qqrkzOSn0/e9TKbjlTnTNcrIno3XDQ7xG+79vOD2GZMNopsKogWNxUdLFRu44ClKLRb4Xe00eVrANtBkv+mSJFFJS1Gxv611hpdGI2S0v1H+KvB26O7vuzGhZ/AevRemGhXQ5V5vwNEqXnVRVkBRszLKeN/+rxM436xQyVQGJMG+sVEAAAEUAAAADHJzYS1zaGEyLTI1NgAAAQBlbiFgkvtKprsj96PD2uIJ7ZypzE/t/iba7/eDvXXc3ixI8fBns2bSuNx7LF3i2vlAUgz6UHe4xW0voc+jmZKEI8jXj91C84npo7J4kCxAkfO4GmdwGhQMjNRoN+pZliPNtj5jQLsuVxgXoJARAEP8nSp372i2bn7iFzolXWPiWkF1MVFV9BLwL3uPDeqTZqurYcpXJnSX30owMyC9qf913MGvWujN2AKNyoX1OIm19EKUSVLMM7S65A5nuuOMrkaajumdEgCgiVQSgHjqD5gDix+EZy7w6L6b8nKqT2mu481dM2yMqejAWxgife4oPI07sGXf1kIOn8kTuZAHkiSH",
refresh_token:
Expand All @@ -571,8 +571,8 @@ export const AUTHENTICATION_RESULT_WITH_FOCI = {
body: {
token_type: AuthenticationScheme.BEARER,
scope: "openid profile User.Read email",
expires_in: 3599,
ext_expires_in: 3599,
expires_in: TEST_TOKEN_LIFETIMES.DEFAULT_EXPIRES_IN,
ext_expires_in: TEST_TOKEN_LIFETIMES.DEFAULT_EXPIRES_IN,
access_token: "thisIs.an.accessT0ken",
refresh_token: "thisIsARefreshT0ken",
id_token: TEST_TOKENS.IDTOKEN_V2,
Expand All @@ -586,8 +586,8 @@ export const AUTHENTICATION_RESULT_DEFAULT_SCOPES = {
body: {
token_type: AuthenticationScheme.BEARER,
scope: "openid profile offline_access User.Read",
expires_in: 3599,
ext_expires_in: 3599,
expires_in: TEST_TOKEN_LIFETIMES.DEFAULT_EXPIRES_IN,
ext_expires_in: TEST_TOKEN_LIFETIMES.DEFAULT_EXPIRES_IN,
access_token: "thisIs.an.accessT0ken",
refresh_token: "thisIsARefreshT0ken",
id_token:
Expand All @@ -610,8 +610,8 @@ export const AUTHENTICATION_RESULT_WITH_HEADERS = {
body: {
token_type: AuthenticationScheme.BEARER,
scope: "openid profile offline_access User.Read",
expires_in: 3599,
ext_expires_in: 3599,
expires_in: TEST_TOKEN_LIFETIMES.DEFAULT_EXPIRES_IN,
ext_expires_in: TEST_TOKEN_LIFETIMES.DEFAULT_EXPIRES_IN,
access_token: "thisIs.an.accessT0ken",
refresh_token: "thisIsARefreshT0ken",
id_token:
Expand All @@ -624,8 +624,8 @@ export const CONFIDENTIAL_CLIENT_AUTHENTICATION_RESULT = {
status: 200,
body: {
token_type: AuthenticationScheme.BEARER,
expires_in: 3599,
ext_expires_in: 3599,
expires_in: TEST_TOKEN_LIFETIMES.DEFAULT_EXPIRES_IN,
ext_expires_in: TEST_TOKEN_LIFETIMES.DEFAULT_EXPIRES_IN,
access_token: "thisIs.an.accessT0ken",
},
};
Expand Down
Loading