From 841e39ebb5e430af53d7c643954e7f92bf85713f Mon Sep 17 00:00:00 2001 From: Sid Uppal Date: Sat, 28 Mar 2026 23:33:11 -0700 Subject: [PATCH] feat(apps): expose skipCache option on token acquisition methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add optional `skipCache` parameter to `getBotToken()`, `getGraphToken()`, and `getAppGraphToken()` that threads through to MSAL's `acquireTokenByClientCredential({ skipCache })`. This allows callers to bypass the MSAL token cache after receiving a 401, forcing a fresh token acquisition from Azure AD. Without this, retry-on-401 logic is ineffective because MSAL returns the same cached (stale) token. Covers all three credential paths that use `acquireTokenByClientCredential`: - Client credentials (clientId + clientSecret) - Federated identity credentials (managed identity + client assertion) Fully backward-compatible — `skipCache` defaults to `false`, so all existing callers are unaffected. Closes #493 --- packages/apps/src/app.ts | 8 ++-- packages/apps/src/token-manager.spec.ts | 62 +++++++++++++++++++++++-- packages/apps/src/token-manager.ts | 22 ++++----- 3 files changed, 73 insertions(+), 19 deletions(-) diff --git a/packages/apps/src/app.ts b/packages/apps/src/app.ts index 337b29ec0..7c8e224bb 100644 --- a/packages/apps/src/app.ts +++ b/packages/apps/src/app.ts @@ -622,9 +622,9 @@ export class App { /// Token /// - protected async getBotToken() { + protected async getBotToken(skipCache?: boolean) { if (!this.tokenManager) return; - return await this.tokenManager.getBotToken(); + return await this.tokenManager.getBotToken(skipCache); } protected async getUserToken( @@ -640,8 +640,8 @@ export class App { return res.token; } - protected async getAppGraphToken(tenantId?: string) { + protected async getAppGraphToken(tenantId?: string, skipCache?: boolean) { if (!this.tokenManager) return; - return await this.tokenManager.getGraphToken(tenantId); + return await this.tokenManager.getGraphToken(tenantId, skipCache); } } diff --git a/packages/apps/src/token-manager.spec.ts b/packages/apps/src/token-manager.spec.ts index 56534f6cc..27121a0fd 100644 --- a/packages/apps/src/token-manager.spec.ts +++ b/packages/apps/src/token-manager.spec.ts @@ -89,7 +89,8 @@ describe('TokenManager', () => { ); expect(mockAcquireTokenByClientCredential).toHaveBeenCalledWith({ - scopes: ['https://api.botframework.com/.default'] + scopes: ['https://api.botframework.com/.default'], + skipCache: false }); expect(token).not.toBeNull(); @@ -141,7 +142,8 @@ describe('TokenManager', () => { const token = await tokenManager.getGraphToken(); expect(mockAcquireTokenByClientCredential).toHaveBeenCalledWith({ - scopes: ['https://graph.microsoft.com/.default'] + scopes: ['https://graph.microsoft.com/.default'], + skipCache: false }); expect(token).not.toBeNull(); @@ -216,6 +218,56 @@ describe('TokenManager', () => { }); }); + describe('skipCache support', () => { + it('getBotToken should pass skipCache to MSAL when true', async () => { + mockAcquireTokenByClientCredential.mockResolvedValue(createMockAuthResult('fresh-bot-token')); + + const tokenManager = new TokenManager(mockOptions, logger); + await tokenManager.getBotToken(true); + + expect(mockAcquireTokenByClientCredential).toHaveBeenCalledWith({ + scopes: ['https://api.botframework.com/.default'], + skipCache: true + }); + }); + + it('getBotToken should not set skipCache when called without argument', async () => { + mockAcquireTokenByClientCredential.mockResolvedValue(createMockAuthResult('cached-bot-token')); + + const tokenManager = new TokenManager(mockOptions, logger); + await tokenManager.getBotToken(); + + expect(mockAcquireTokenByClientCredential).toHaveBeenCalledWith({ + scopes: ['https://api.botframework.com/.default'], + skipCache: false + }); + }); + + it('getGraphToken should pass skipCache to MSAL when true', async () => { + mockAcquireTokenByClientCredential.mockResolvedValue(createMockAuthResult('fresh-graph-token')); + + const tokenManager = new TokenManager(mockOptions, logger); + await tokenManager.getGraphToken(undefined, true); + + expect(mockAcquireTokenByClientCredential).toHaveBeenCalledWith({ + scopes: ['https://graph.microsoft.com/.default'], + skipCache: true + }); + }); + + it('getGraphToken should not set skipCache when called without argument', async () => { + mockAcquireTokenByClientCredential.mockResolvedValue(createMockAuthResult('cached-graph-token')); + + const tokenManager = new TokenManager(mockOptions, logger); + await tokenManager.getGraphToken(); + + expect(mockAcquireTokenByClientCredential).toHaveBeenCalledWith({ + scopes: ['https://graph.microsoft.com/.default'], + skipCache: false + }); + }); + }); + describe('TokenCredentials provider', () => { it('should use token provider for bot token', async () => { const mockTokenProvider = jest.fn().mockResolvedValue('mock-provider-token'); @@ -407,7 +459,8 @@ describe('TokenManager', () => { ); expect(mockConfidentialAcquireToken).toHaveBeenCalledWith({ - scopes: ['https://api.botframework.com/.default'] + scopes: ['https://api.botframework.com/.default'], + skipCache: false }); expect(token).not.toBeNull(); @@ -452,7 +505,8 @@ describe('TokenManager', () => { ); expect(mockConfidentialAcquireToken).toHaveBeenCalledWith({ - scopes: ['https://api.botframework.com/.default'] + scopes: ['https://api.botframework.com/.default'], + skipCache: false }); expect(token).not.toBeNull(); diff --git a/packages/apps/src/token-manager.ts b/packages/apps/src/token-manager.ts index d8bd0451a..fb59b90eb 100644 --- a/packages/apps/src/token-manager.ts +++ b/packages/apps/src/token-manager.ts @@ -66,12 +66,12 @@ export class TokenManager { this.credentials = this.initializeCredentials(options); } - async getBotToken(): Promise { - return await this.getToken(DEFAULT_BOT_TOKEN_SCOPE, this.resolveTenantId(undefined, DEFAULT_TENANT_FOR_BOT_TOKEN)); + async getBotToken(skipCache?: boolean): Promise { + return await this.getToken(DEFAULT_BOT_TOKEN_SCOPE, this.resolveTenantId(undefined, DEFAULT_TENANT_FOR_BOT_TOKEN), skipCache); } - async getGraphToken(tenantId?: string): Promise { - return await this.getToken(DEFAULT_GRAPH_TOKEN_SCOPE, this.resolveTenantId(tenantId, DEFAULT_TENANT_FOR_GRAPH_TOKEN)); + async getGraphToken(tenantId?: string, skipCache?: boolean): Promise { + return await this.getToken(DEFAULT_GRAPH_TOKEN_SCOPE, this.resolveTenantId(tenantId, DEFAULT_TENANT_FOR_GRAPH_TOKEN), skipCache); } private initializeCredentials(options: TokenManagerOptions): Credentials | undefined { @@ -117,26 +117,26 @@ export class TokenManager { return undefined; } - private async getToken(scope: string, tenantId: string): Promise { + private async getToken(scope: string, tenantId: string, skipCache?: boolean): Promise { if (!this.credentials) { return null; } if (isClientCredentials(this.credentials)) { - return this.getTokenWithClientCredentials(this.credentials, scope, tenantId); + return this.getTokenWithClientCredentials(this.credentials, scope, tenantId, skipCache); } else if (isTokenCredentials(this.credentials)) { return this.getTokenWithTokenProvider(this.credentials, scope, tenantId); } else if (isFederatedIdentityCredentials(this.credentials)) { - return this.getTokenWithFederatedCredentials(this.credentials, scope, tenantId); + return this.getTokenWithFederatedCredentials(this.credentials, scope, tenantId, skipCache); } else { return this.getTokenWithManagedIdentity(this.credentials, scope); } } - private async getTokenWithClientCredentials(credentials: ClientCredentials, scope: string, tenantId: string): Promise { + private async getTokenWithClientCredentials(credentials: ClientCredentials, scope: string, tenantId: string, skipCache?: boolean): Promise { const confidentialClient = this.getConfidentialClient(credentials, tenantId); - const result = await confidentialClient.acquireTokenByClientCredential({ scopes: [scope] }); + const result = await confidentialClient.acquireTokenByClientCredential({ scopes: [scope], skipCache: skipCache ?? false }); return this.handleTokenResponse(result); } @@ -155,7 +155,7 @@ export class TokenManager { return this.handleTokenResponse(result); } - private async getTokenWithFederatedCredentials(credentials: FederatedIdentityCredentials, scope: string, tenantId: string) { + private async getTokenWithFederatedCredentials(credentials: FederatedIdentityCredentials, scope: string, tenantId: string, skipCache?: boolean) { const managedIdentityClient = this.getManagedIdentityClient(credentials); const managedIdentityTokenRes = await managedIdentityClient.acquireToken({ resource: 'api://AzureADTokenExchange' }); const confidentialClient = new ConfidentialClientApplication({ @@ -168,7 +168,7 @@ export class TokenManager { loggerOptions: this.buildLoggerOptions() } }); - const result = await confidentialClient.acquireTokenByClientCredential({ scopes: [scope] }); + const result = await confidentialClient.acquireTokenByClientCredential({ scopes: [scope], skipCache: skipCache ?? false }); return this.handleTokenResponse(result); }