162 lines
6.7 KiB
JavaScript
162 lines
6.7 KiB
JavaScript
// Copyright (c) Microsoft Corporation.
|
|
// Licensed under the MIT License.
|
|
import { delay } from "@azure/core-util";
|
|
// Default options for the cycler if none are provided
|
|
export const DEFAULT_CYCLER_OPTIONS = {
|
|
forcedRefreshWindowInMs: 1000, // Force waiting for a refresh 1s before the token expires
|
|
retryIntervalInMs: 3000, // Allow refresh attempts every 3s
|
|
refreshWindowInMs: 1000 * 60 * 2, // Start refreshing 2m before expiry
|
|
};
|
|
/**
|
|
* Converts an an unreliable access token getter (which may resolve with null)
|
|
* into an AccessTokenGetter by retrying the unreliable getter in a regular
|
|
* interval.
|
|
*
|
|
* @param getAccessToken - A function that produces a promise of an access token that may fail by returning null.
|
|
* @param retryIntervalInMs - The time (in milliseconds) to wait between retry attempts.
|
|
* @param refreshTimeout - The timestamp after which the refresh attempt will fail, throwing an exception.
|
|
* @returns - A promise that, if it resolves, will resolve with an access token.
|
|
*/
|
|
async function beginRefresh(getAccessToken, retryIntervalInMs, refreshTimeout) {
|
|
// This wrapper handles exceptions gracefully as long as we haven't exceeded
|
|
// the timeout.
|
|
async function tryGetAccessToken() {
|
|
if (Date.now() < refreshTimeout) {
|
|
try {
|
|
return await getAccessToken();
|
|
}
|
|
catch (_a) {
|
|
return null;
|
|
}
|
|
}
|
|
else {
|
|
const finalToken = await getAccessToken();
|
|
// Timeout is up, so throw if it's still null
|
|
if (finalToken === null) {
|
|
throw new Error("Failed to refresh access token.");
|
|
}
|
|
return finalToken;
|
|
}
|
|
}
|
|
let token = await tryGetAccessToken();
|
|
while (token === null) {
|
|
await delay(retryIntervalInMs);
|
|
token = await tryGetAccessToken();
|
|
}
|
|
return token;
|
|
}
|
|
/**
|
|
* Creates a token cycler from a credential, scopes, and optional settings.
|
|
*
|
|
* A token cycler represents a way to reliably retrieve a valid access token
|
|
* from a TokenCredential. It will handle initializing the token, refreshing it
|
|
* when it nears expiration, and synchronizes refresh attempts to avoid
|
|
* concurrency hazards.
|
|
*
|
|
* @param credential - the underlying TokenCredential that provides the access
|
|
* token
|
|
* @param tokenCyclerOptions - optionally override default settings for the cycler
|
|
*
|
|
* @returns - a function that reliably produces a valid access token
|
|
*/
|
|
export function createTokenCycler(credential, tokenCyclerOptions) {
|
|
let refreshWorker = null;
|
|
let token = null;
|
|
let tenantId;
|
|
const options = Object.assign(Object.assign({}, DEFAULT_CYCLER_OPTIONS), tokenCyclerOptions);
|
|
/**
|
|
* This little holder defines several predicates that we use to construct
|
|
* the rules of refreshing the token.
|
|
*/
|
|
const cycler = {
|
|
/**
|
|
* Produces true if a refresh job is currently in progress.
|
|
*/
|
|
get isRefreshing() {
|
|
return refreshWorker !== null;
|
|
},
|
|
/**
|
|
* Produces true if the cycler SHOULD refresh (we are within the refresh
|
|
* window and not already refreshing)
|
|
*/
|
|
get shouldRefresh() {
|
|
var _a;
|
|
if (cycler.isRefreshing) {
|
|
return false;
|
|
}
|
|
if ((token === null || token === void 0 ? void 0 : token.refreshAfterTimestamp) && token.refreshAfterTimestamp < Date.now()) {
|
|
return true;
|
|
}
|
|
return ((_a = token === null || token === void 0 ? void 0 : token.expiresOnTimestamp) !== null && _a !== void 0 ? _a : 0) - options.refreshWindowInMs < Date.now();
|
|
},
|
|
/**
|
|
* Produces true if the cycler MUST refresh (null or nearly-expired
|
|
* token).
|
|
*/
|
|
get mustRefresh() {
|
|
return (token === null || token.expiresOnTimestamp - options.forcedRefreshWindowInMs < Date.now());
|
|
},
|
|
};
|
|
/**
|
|
* Starts a refresh job or returns the existing job if one is already
|
|
* running.
|
|
*/
|
|
function refresh(scopes, getTokenOptions) {
|
|
var _a;
|
|
if (!cycler.isRefreshing) {
|
|
// We bind `scopes` here to avoid passing it around a lot
|
|
const tryGetAccessToken = () => credential.getToken(scopes, getTokenOptions);
|
|
// Take advantage of promise chaining to insert an assignment to `token`
|
|
// before the refresh can be considered done.
|
|
refreshWorker = beginRefresh(tryGetAccessToken, options.retryIntervalInMs,
|
|
// If we don't have a token, then we should timeout immediately
|
|
(_a = token === null || token === void 0 ? void 0 : token.expiresOnTimestamp) !== null && _a !== void 0 ? _a : Date.now())
|
|
.then((_token) => {
|
|
refreshWorker = null;
|
|
token = _token;
|
|
tenantId = getTokenOptions.tenantId;
|
|
return token;
|
|
})
|
|
.catch((reason) => {
|
|
// We also should reset the refresher if we enter a failed state. All
|
|
// existing awaiters will throw, but subsequent requests will start a
|
|
// new retry chain.
|
|
refreshWorker = null;
|
|
token = null;
|
|
tenantId = undefined;
|
|
throw reason;
|
|
});
|
|
}
|
|
return refreshWorker;
|
|
}
|
|
return async (scopes, tokenOptions) => {
|
|
//
|
|
// Simple rules:
|
|
// - If we MUST refresh, then return the refresh task, blocking
|
|
// the pipeline until a token is available.
|
|
// - If we SHOULD refresh, then run refresh but don't return it
|
|
// (we can still use the cached token).
|
|
// - Return the token, since it's fine if we didn't return in
|
|
// step 1.
|
|
//
|
|
const hasClaimChallenge = Boolean(tokenOptions.claims);
|
|
const tenantIdChanged = tenantId !== tokenOptions.tenantId;
|
|
if (hasClaimChallenge) {
|
|
// If we've received a claim, we know the existing token isn't valid
|
|
// We want to clear it so that that refresh worker won't use the old expiration time as a timeout
|
|
token = null;
|
|
}
|
|
// If the tenantId passed in token options is different to the one we have
|
|
// Or if we are in claim challenge and the token was rejected and a new access token need to be issued, we need to
|
|
// refresh the token with the new tenantId or token.
|
|
const mustRefresh = tenantIdChanged || hasClaimChallenge || cycler.mustRefresh;
|
|
if (mustRefresh) {
|
|
return refresh(scopes, tokenOptions);
|
|
}
|
|
if (cycler.shouldRefresh) {
|
|
refresh(scopes, tokenOptions);
|
|
}
|
|
return token;
|
|
};
|
|
}
|
|
//# sourceMappingURL=tokenCycler.js.map
|