Skip to content

Commit 72cae43

Browse files
authored
[core-rest-pipeline] Improve robustness of tokenCycler (Azure#29638)
### Packages impacted by this PR `core-rest-pipeline` `ts-http-runtime` ### Issues associated with this PR Azure#29608 ### Describe the problem that is addressed by this PR When debugging a recent issue from Storage, it was reported that in the case of the tokenCycler being unable to retrieve a new access token from a CAE challenge, it would fall back to delaying for the maximum lifetime of the current (invalid) access token instead of failing immediately. This change improves the robustness of this scenario by invaliding the existing cached token when claims are received. ### Are there test cases added in this PR? _(If not, why?)_ Yes, I added a test and confirmed it was timing out before the fix was added.
1 parent fa4fd3a commit 72cae43

File tree

4 files changed

+171
-7
lines changed

4 files changed

+171
-7
lines changed

sdk/core/core-rest-pipeline/src/util/tokenCycler.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,13 +201,23 @@ export function createTokenCycler(
201201
// step 1.
202202
//
203203

204+
const hasClaimChallenge = Boolean(tokenOptions.claims);
205+
const tenantIdChanged = tenantId !== tokenOptions.tenantId;
206+
207+
if (hasClaimChallenge) {
208+
// If we've received a claim, we know the existing token isn't valid
209+
// We want to clear it so that that refresh worker won't use the old expiration time as a timeout
210+
token = null;
211+
}
212+
204213
// If the tenantId passed in token options is different to the one we have
205214
// Or if we are in claim challenge and the token was rejected and a new access token need to be issued, we need to
206215
// refresh the token with the new tenantId or token.
207-
const mustRefresh =
208-
tenantId !== tokenOptions.tenantId || Boolean(tokenOptions.claims) || cycler.mustRefresh;
216+
const mustRefresh = tenantIdChanged || hasClaimChallenge || cycler.mustRefresh;
209217

210-
if (mustRefresh) return refresh(scopes, tokenOptions);
218+
if (mustRefresh) {
219+
return refresh(scopes, tokenOptions);
220+
}
211221

212222
if (cycler.shouldRefresh) {
213223
refresh(scopes, tokenOptions);

sdk/core/core-rest-pipeline/test/bearerTokenAuthenticationPolicy.spec.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22
// Licensed under the MIT license.
33

44
import { AccessToken, TokenCredential } from "@azure/core-auth";
5-
import type { PipelinePolicy, PipelineResponse, SendRequest } from "../src/index.js";
5+
import type {
6+
AuthorizeRequestOnChallengeOptions,
7+
PipelinePolicy,
8+
PipelineResponse,
9+
SendRequest,
10+
} from "../src/index.js";
611
import {
712
bearerTokenAuthenticationPolicy,
813
createHttpHeaders,
@@ -238,6 +243,75 @@ describe("BearerTokenAuthenticationPolicy", function () {
238243
);
239244
});
240245

246+
it("does not wait for token expiry after receiving an authentication challenge", async () => {
247+
// tokens can live for a long time
248+
const oneDayInMs = 24 * 60 * 60 * 1000;
249+
const tokenExpiration = Date.now() + oneDayInMs;
250+
const getToken = vi.fn<[], Promise<AccessToken | null>>();
251+
252+
// first time getToken is called to put the header on the initial request
253+
getToken.mockResolvedValueOnce({
254+
token: "mock-token",
255+
expiresOnTimestamp: tokenExpiration,
256+
});
257+
// simulate failure of retriving the token, rejecting with an error would also work
258+
// but returning null exercises a slightly different code path for better coverage
259+
getToken.mockResolvedValueOnce(null);
260+
const credential: TokenCredential = {
261+
getToken,
262+
};
263+
const scopes = ["test-scope"];
264+
const request = createPipelineRequest({ url: "https://example.com" });
265+
266+
async function authorizeRequestOnChallenge(
267+
options: AuthorizeRequestOnChallengeOptions,
268+
): Promise<boolean> {
269+
// this will trigger a second call into getToken, which should fail
270+
// what we don't want is to wait for expiresOnTimestamp of the original credential before giving up
271+
const token = await options.getAccessToken(scopes, {
272+
claims: '{"access_token":{"nbf":{"essential":true, "value":"1603742800"}}}',
273+
});
274+
if (token) {
275+
options.request.headers.set("Authorization", `Bearer ${token.token}`);
276+
return true;
277+
}
278+
return false;
279+
}
280+
281+
const policy = bearerTokenAuthenticationPolicy({
282+
scopes,
283+
credential,
284+
challengeCallbacks: {
285+
authorizeRequestOnChallenge,
286+
},
287+
});
288+
const next = vi.fn<Parameters<SendRequest>, ReturnType<SendRequest>>();
289+
290+
// first response is an auth challenge
291+
const challengeResponse: PipelineResponse = {
292+
headers: createHttpHeaders({
293+
"WWW-Authenticate": [
294+
`Bearer authorization_uri="https://login.windows-ppe.net/", error="invalid_token"`,
295+
`error_description="User session has been revoked"`,
296+
`claims="eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTYwMzc0MjgwMCJ9fX0="`,
297+
].join(", "),
298+
}),
299+
request,
300+
status: 401,
301+
};
302+
303+
next.mockResolvedValueOnce(challengeResponse);
304+
305+
let error: Error | undefined;
306+
try {
307+
await policy.sendRequest(request, next);
308+
} catch (e: any) {
309+
error = e;
310+
}
311+
assert.isDefined(error);
312+
assert.equal(error?.message, "Failed to refresh access token.");
313+
});
314+
241315
function createBearerTokenPolicy(
242316
scopes: string | string[],
243317
credential: TokenCredential,

sdk/core/ts-http-runtime/src/util/tokenCycler.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,13 +201,23 @@ export function createTokenCycler(
201201
// step 1.
202202
//
203203

204+
const hasClaimChallenge = Boolean(tokenOptions.claims);
205+
const tenantIdChanged = tenantId !== tokenOptions.tenantId;
206+
207+
if (hasClaimChallenge) {
208+
// If we've received a claim, we know the existing token isn't valid
209+
// We want to clear it so that that refresh worker won't use the old expiration time as a timeout
210+
token = null;
211+
}
212+
204213
// If the tenantId passed in token options is different to the one we have
205214
// Or if we are in claim challenge and the token was rejected and a new access token need to be issued, we need to
206215
// refresh the token with the new tenantId or token.
207-
const mustRefresh =
208-
tenantId !== tokenOptions.tenantId || Boolean(tokenOptions.claims) || cycler.mustRefresh;
216+
const mustRefresh = tenantIdChanged || hasClaimChallenge || cycler.mustRefresh;
209217

210-
if (mustRefresh) return refresh(scopes, tokenOptions);
218+
if (mustRefresh) {
219+
return refresh(scopes, tokenOptions);
220+
}
211221

212222
if (cycler.shouldRefresh) {
213223
refresh(scopes, tokenOptions);

sdk/core/ts-http-runtime/test/bearerTokenAuthenticationPolicy.spec.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import { describe, it, assert, expect, vi, beforeEach, afterEach } from "vitest";
55
import { AccessToken, TokenCredential } from "../src/auth/tokenCredential.js";
66
import {
7+
AuthorizeRequestOnChallengeOptions,
78
PipelinePolicy,
89
PipelineResponse,
910
SendRequest,
@@ -240,6 +241,75 @@ describe("BearerTokenAuthenticationPolicy", function () {
240241
);
241242
});
242243

244+
it("does not wait for token expiry after receiving an authentication challenge", async () => {
245+
// tokens can live for a long time
246+
const oneDayInMs = 24 * 60 * 60 * 1000;
247+
const tokenExpiration = Date.now() + oneDayInMs;
248+
const getToken = vi.fn<[], Promise<AccessToken | null>>();
249+
250+
// first time getToken is called to put the header on the initial request
251+
getToken.mockResolvedValueOnce({
252+
token: "mock-token",
253+
expiresOnTimestamp: tokenExpiration,
254+
});
255+
// simulate failure of retriving the token, rejecting with an error would also work
256+
// but returning null exercises a slightly different code path for better coverage
257+
getToken.mockResolvedValueOnce(null);
258+
const credential: TokenCredential = {
259+
getToken,
260+
};
261+
const scopes = ["test-scope"];
262+
const request = createPipelineRequest({ url: "https://example.com" });
263+
264+
async function authorizeRequestOnChallenge(
265+
options: AuthorizeRequestOnChallengeOptions,
266+
): Promise<boolean> {
267+
// this will trigger a second call into getToken, which should fail
268+
// what we don't want is to wait for expiresOnTimestamp of the original credential before giving up
269+
const token = await options.getAccessToken(scopes, {
270+
claims: '{"access_token":{"nbf":{"essential":true, "value":"1603742800"}}}',
271+
});
272+
if (token) {
273+
options.request.headers.set("Authorization", `Bearer ${token.token}`);
274+
return true;
275+
}
276+
return false;
277+
}
278+
279+
const policy = bearerTokenAuthenticationPolicy({
280+
scopes,
281+
credential,
282+
challengeCallbacks: {
283+
authorizeRequestOnChallenge,
284+
},
285+
});
286+
const next = vi.fn<Parameters<SendRequest>, ReturnType<SendRequest>>();
287+
288+
// first response is an auth challenge
289+
const challengeResponse: PipelineResponse = {
290+
headers: createHttpHeaders({
291+
"WWW-Authenticate": [
292+
`Bearer authorization_uri="https://login.windows-ppe.net/", error="invalid_token"`,
293+
`error_description="User session has been revoked"`,
294+
`claims="eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTYwMzc0MjgwMCJ9fX0="`,
295+
].join(", "),
296+
}),
297+
request,
298+
status: 401,
299+
};
300+
301+
next.mockResolvedValueOnce(challengeResponse);
302+
303+
let error: Error | undefined;
304+
try {
305+
await policy.sendRequest(request, next);
306+
} catch (e: any) {
307+
error = e;
308+
}
309+
assert.isDefined(error);
310+
assert.equal(error?.message, "Failed to refresh access token.");
311+
});
312+
243313
function createBearerTokenPolicy(
244314
scopes: string | string[],
245315
credential: TokenCredential,

0 commit comments

Comments
 (0)