From 7eafde21494aed48306b6d899d91b59349ae4293 Mon Sep 17 00:00:00 2001 From: Jeremy Meng Date: Fri, 14 May 2021 16:32:13 -0700 Subject: [PATCH] [core-rest-pipeline] expose tokenCycler So that other libraries can re-use its auto-refreshing mechanism. --- common/config/rush/common-versions.json | 4 +- .../confidential-ledger-rest/package.json | 2 +- .../container-registry/package.json | 2 +- .../src/containerRegistryChallengeHandler.ts | 3 +- .../container-registry/src/tokenCycler.ts | 220 ------------------ sdk/core-rest/core-client/package.json | 2 +- .../review/core-rest-pipeline.api.md | 15 ++ sdk/core/core-rest-pipeline/src/index.ts | 1 + .../src/util/tokenCycler.ts | 22 +- .../core-rest-pipeline/package.json | 2 +- 10 files changed, 32 insertions(+), 241 deletions(-) delete mode 100644 sdk/containerregistry/container-registry/src/tokenCycler.ts diff --git a/common/config/rush/common-versions.json b/common/config/rush/common-versions.json index 1be4c1dbac9e..493f8ca83055 100644 --- a/common/config/rush/common-versions.json +++ b/common/config/rush/common-versions.json @@ -64,8 +64,8 @@ // @azure/test-utils-perfstress should depend on lowest version of @azure/core-http for maximum compatibility, allowing test // projects to choose a higher version if desired. "@azure/core-http": ["^1.0.0"], - // @azure/container-registry is using the beta.1 version for CAE support - "@azure/core-rest-pipeline": ["1.1.0-beta.1"], + // @azure/container-registry is using the beta.2 version for CAE support + "@azure/core-rest-pipeline": ["1.1.0-beta.2"], // @azure/event-processor-host is on a much lower major version "@azure/ms-rest-nodeauth": ["^0.9.2"], // Idenity is moving from v1 to v2. Moving all packages to v2 is going to take a bit of time, in the mean time we could use v2 on the perf-identity tests. diff --git a/sdk/confidentialledger/confidential-ledger-rest/package.json b/sdk/confidentialledger/confidential-ledger-rest/package.json index 3c2d40fb86c8..0bb7682257cd 100644 --- a/sdk/confidentialledger/confidential-ledger-rest/package.json +++ b/sdk/confidentialledger/confidential-ledger-rest/package.json @@ -86,7 +86,7 @@ "dependencies": { "@azure/core-auth": "^1.3.0", "@azure-rest/core-client": "1.0.0-beta.2", - "@azure/core-rest-pipeline": "1.1.0-beta.1", + "@azure/core-rest-pipeline": "1.1.0-beta.2", "@azure/logger": "^1.0.0", "tslib": "^2.0.0" }, diff --git a/sdk/containerregistry/container-registry/package.json b/sdk/containerregistry/container-registry/package.json index a0f07474a646..241d83783939 100644 --- a/sdk/containerregistry/container-registry/package.json +++ b/sdk/containerregistry/container-registry/package.json @@ -76,7 +76,7 @@ "dependencies": { "@azure/core-auth": "^1.3.0", "@azure/core-client": "^1.0.0", - "@azure/core-rest-pipeline": "1.1.0-beta.1", + "@azure/core-rest-pipeline": "1.1.0-beta.2", "@azure/core-paging": "^1.1.1", "@azure/core-tracing": "1.0.0-preview.11", "@azure/core-util": "^1.0.0-beta.1", diff --git a/sdk/containerregistry/container-registry/src/containerRegistryChallengeHandler.ts b/sdk/containerregistry/container-registry/src/containerRegistryChallengeHandler.ts index 8d79aff3e8f6..e6cc0d312cb6 100644 --- a/sdk/containerregistry/container-registry/src/containerRegistryChallengeHandler.ts +++ b/sdk/containerregistry/container-registry/src/containerRegistryChallengeHandler.ts @@ -5,6 +5,8 @@ import { GetTokenOptions } from "@azure/core-auth"; import { AuthorizeRequestOnChallengeOptions, ChallengeCallbacks, + AccessTokenRefresher, + createTokenCycler, AuthorizeRequestOptions } from "@azure/core-rest-pipeline"; import { parseWWWAuthenticate } from "./wwwAuthenticateParser"; @@ -12,7 +14,6 @@ import { ContainerRegistryGetTokenOptions, ContainerRegistryRefreshTokenCredential } from "./containerRegistryTokenCredential"; -import { AccessTokenRefresher, createTokenCycler } from "./tokenCycler"; const fiveMinutesInMs = 5 * 60 * 1000; diff --git a/sdk/containerregistry/container-registry/src/tokenCycler.ts b/sdk/containerregistry/container-registry/src/tokenCycler.ts deleted file mode 100644 index 4c488a6b812d..000000000000 --- a/sdk/containerregistry/container-registry/src/tokenCycler.ts +++ /dev/null @@ -1,220 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { AccessToken, GetTokenOptions, TokenCredential } from "@azure/core-auth"; - -function delay(t: number, value?: T): Promise { - return new Promise((resolve) => setTimeout(() => resolve(value), t)); -} -/** - * A function that gets a promise of an access token and allows providing - * options. - * - * @param options - the options to pass to the underlying token provider - */ -export type AccessTokenGetter = ( - scopes: string | string[], - options: T -) => Promise; - -/** - * The response of the - */ -export interface AccessTokenRefresher { - cachedToken?: AccessToken; - getToken: AccessTokenGetter; -} - -export interface TokenCyclerOptions { - /** - * The window of time before token expiration during which the token will be - * considered unusable due to risk of the token expiring before sending the - * request. - * - * This will only become meaningful if the refresh fails for over - * (refreshWindow - forcedRefreshWindow) milliseconds. - */ - forcedRefreshWindowInMs: number; - /** - * Interval in milliseconds to retry failed token refreshes. - */ - retryIntervalInMs: number; - /** - * The window of time before token expiration during which - * we will attempt to refresh the token. - */ - refreshWindowInMs: number; -} - -// Default options for the cycler if none are provided -export const DEFAULT_CYCLER_OPTIONS: TokenCyclerOptions = { - 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: () => Promise, - retryIntervalInMs: number, - refreshTimeout: number -): Promise { - // This wrapper handles exceptions gracefully as long as we haven't exceeded - // the timeout. - async function tryGetAccessToken(): Promise { - if (Date.now() < refreshTimeout) { - try { - return await getAccessToken(); - } catch { - 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: AccessToken | null = 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: TokenCredential, - tokenCyclerOptions?: Partial -): AccessTokenRefresher { - let refreshWorker: Promise | null = null; - let token: AccessToken | null = null; - - const options = { - ...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(): boolean { - return refreshWorker !== null; - }, - /** - * Produces true if the cycler SHOULD refresh (we are within the refresh - * window and not already refreshing) - */ - get shouldRefresh(): boolean { - return ( - !cycler.isRefreshing && - (token?.expiresOnTimestamp ?? 0) - options.refreshWindowInMs < Date.now() - ); - }, - /** - * Produces true if the cycler MUST refresh (null or nearly-expired - * token). - */ - get mustRefresh(): boolean { - 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: string | string[], getTokenOptions: T): Promise { - if (!cycler.isRefreshing) { - // We bind `scopes` here to avoid passing it around a lot - const tryGetAccessToken = (): Promise => - 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 - token?.expiresOnTimestamp ?? Date.now() - ) - .then((_token) => { - refreshWorker = null; - token = _token; - 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; - throw reason; - }); - } - - return refreshWorker as Promise; - } - - return { - get cachedToken(): AccessToken | undefined { - return token || undefined; - }, - getToken: async (scopes: string | string[], tokenOptions: T): Promise => { - // - // 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. - // - if (cycler.mustRefresh) return refresh(scopes, tokenOptions); - - if (cycler.shouldRefresh) { - refresh(scopes, tokenOptions); - } - - return token as AccessToken; - } - }; -} diff --git a/sdk/core-rest/core-client/package.json b/sdk/core-rest/core-client/package.json index 085729c53047..34237c8e51a8 100644 --- a/sdk/core-rest/core-client/package.json +++ b/sdk/core-rest/core-client/package.json @@ -70,7 +70,7 @@ "prettier": "@azure/eslint-plugin-azure-sdk/prettier.json", "dependencies": { "@azure/core-auth": "^1.3.0", - "@azure/core-rest-pipeline": "1.1.0-beta.1", + "@azure/core-rest-pipeline": "1.1.0-beta.2", "tslib": "^2.0.0" }, "devDependencies": { diff --git a/sdk/core/core-rest-pipeline/review/core-rest-pipeline.api.md b/sdk/core/core-rest-pipeline/review/core-rest-pipeline.api.md index 5357630d144b..0615693678a6 100644 --- a/sdk/core/core-rest-pipeline/review/core-rest-pipeline.api.md +++ b/sdk/core/core-rest-pipeline/review/core-rest-pipeline.api.md @@ -11,6 +11,16 @@ import { GetTokenOptions } from '@azure/core-auth'; import { OperationTracingOptions } from '@azure/core-tracing'; import { TokenCredential } from '@azure/core-auth'; +// @public +export interface AccessTokenRefresher { + // (undocumented) + cachedToken: AccessToken | null; + // Warning: (ae-forgotten-export) The symbol "AccessTokenGetter" needs to be exported by the entry point index.d.ts + // + // (undocumented) + getToken: AccessTokenGetter; +} + // @public export interface AddPipelineOptions { afterPhase?: PipelinePhase; @@ -89,6 +99,11 @@ export function createPipelineFromOptions(options: InternalPipelineOptions): Pip // @public export function createPipelineRequest(options: PipelineRequestOptions): PipelineRequest; +// Warning: (ae-forgotten-export) The symbol "TokenCyclerOptions" needs to be exported by the entry point index.d.ts +// +// @public +export function createTokenCycler(credential: TokenCredential, tokenCyclerOptions?: Partial): AccessTokenRefresher; + // @public export function decompressResponsePolicy(): PipelinePolicy; diff --git a/sdk/core/core-rest-pipeline/src/index.ts b/sdk/core/core-rest-pipeline/src/index.ts index bfeb13ee0002..8080820eef03 100644 --- a/sdk/core/core-rest-pipeline/src/index.ts +++ b/sdk/core/core-rest-pipeline/src/index.ts @@ -77,3 +77,4 @@ export { AuthorizeRequestOnChallengeOptions } from "./policies/bearerTokenChallengeAuthenticationPolicy"; export { ndJsonPolicy, ndJsonPolicyName } from "./policies/ndJsonPolicy"; +export { AccessTokenRefresher, createTokenCycler } from "./util/tokenCycler"; diff --git a/sdk/core/core-rest-pipeline/src/util/tokenCycler.ts b/sdk/core/core-rest-pipeline/src/util/tokenCycler.ts index 4e096a667110..9f58cf00b949 100644 --- a/sdk/core/core-rest-pipeline/src/util/tokenCycler.ts +++ b/sdk/core/core-rest-pipeline/src/util/tokenCycler.ts @@ -10,17 +10,17 @@ import { delay } from "./helpers"; * * @param options - the options to pass to the underlying token provider */ -export type AccessTokenGetter = ( +export type AccessTokenGetter = ( scopes: string | string[], - options: GetTokenOptions + options: T ) => Promise; /** * The response of the */ -export interface AccessTokenRefresher { +export interface AccessTokenRefresher { cachedToken: AccessToken | null; - getToken: AccessTokenGetter; + getToken: AccessTokenGetter; } export interface TokenCyclerOptions { @@ -112,10 +112,10 @@ async function beginRefresh( * * @returns - a function that reliably produces a valid access token */ -export function createTokenCycler( +export function createTokenCycler( credential: TokenCredential, tokenCyclerOptions?: Partial -): AccessTokenRefresher { +): AccessTokenRefresher { let refreshWorker: Promise | null = null; let token: AccessToken | null = null; @@ -160,10 +160,7 @@ export function createTokenCycler( * Starts a refresh job or returns the existing job if one is already * running. */ - function refresh( - scopes: string | string[], - getTokenOptions: GetTokenOptions - ): Promise { + function refresh(scopes: string | string[], getTokenOptions: T): Promise { if (!cycler.isRefreshing) { // We bind `scopes` here to avoid passing it around a lot const tryGetAccessToken = (): Promise => @@ -199,10 +196,7 @@ export function createTokenCycler( get cachedToken(): AccessToken | null { return token; }, - getToken: async ( - scopes: string | string[], - tokenOptions: GetTokenOptions - ): Promise => { + getToken: async (scopes: string | string[], tokenOptions: T): Promise => { // // Simple rules: // - If we MUST refresh, then return the refresh task, blocking diff --git a/sdk/core/perf-tests/core-rest-pipeline/package.json b/sdk/core/perf-tests/core-rest-pipeline/package.json index d04fb4063e32..ed5879eb5f70 100644 --- a/sdk/core/perf-tests/core-rest-pipeline/package.json +++ b/sdk/core/perf-tests/core-rest-pipeline/package.json @@ -7,7 +7,7 @@ "author": "", "license": "ISC", "dependencies": { - "@azure/core-rest-pipeline": "1.1.0-beta.1", + "@azure/core-rest-pipeline": "1.1.0-beta.2", "@azure/core-auth": "^1.3.0", "@azure/test-utils-perfstress": "^1.0.0", "dotenv": "^8.2.0"