microsoft / microsoft/vscode

Microsoft authentication keeps using an expired refresh token after successful sign-in

Open
#335,639 0 comments 0 reactions 1 assignee Claimed by @TylerLeonhardt View on GitHub
Dominant language
TypeScript
Stars
193k
Forks
42.4k
PR merge metrics
PR metrics pending

Description

Does this issue occur when all extensions are disabled?: Not tested

- VS Code version: 1.137.0
- OS: macOS
- Reproduction runtime: Node.js 24.18.0

## Problem

I investigated this after Settings Sync continued to show “Sign in to Sync Settings” following a successful Microsoft sign-in on macOS. Logs showed `createSession` returning a session, followed by `getSessions` returning no sessions after `AADSTS700082`. I can no longer reproduce the original sign-in failure with my real account. Instead, the tests use a simulated account, synthetic tokens, and simulated identity-server responses with the real MSAL library and VS Code authentication code to reproduce the token-cache defect described below. The same regression tests fail on the original code and pass with the proposed fix.

The Microsoft authentication extension's current MSAL dependency can repeatedly reload a rejected refresh token from persisted storage. MSAL removes the token from memory on an `invalid_grant` / `bad_token` response, but does not call the persistence callback on that failure path.

When a rejected family refresh token coexists with a fresh application refresh token for the same account, repeated silent requests keep submitting the family token and failing despite the fresh credential being available.

## Reproduction in the VS Code extension host

The [test-only branch](https://github.com/gaspardruan/vscode/tree/test/microsoft-authentication-stale-refresh-token) adds only `extensions/microsoft-authentication/src/node/test/tokenCache.test.ts`; all production code is unchanged.

The tests call the existing `CachedPublicClientApplication.acquireTokenSilent` method. They install real MSAL and `SecretStorageCachePlugin` instances as test dependencies, with simulated identity responses and in-memory SecretStorage. Neither the silent-acquisition method nor the persistence methods are mocked. This covers VS Code's authentication wrapper, but does not exercise the browser or Settings Sync UI.

1. Fetch the test-only branch into a VS Code development checkout:

```sh
git fetch https://github.com/gaspardruan/vscode.git test/microsoft-authentication-stale-refresh-token
git switch --detach FETCH_HEAD
```

2. Install dependencies and build this checkout following the repository's [development instructions](https://github.com/microsoft/vscode/wiki/How-to-Contribute):
```sh
fnm install
fnm use
npm install
npm run electron
npm run watch
```

3. After the initial compilation finishes without errors, open a second terminal at the repository root and run the new regression suite:

```sh
fnm use
npm run test-extension -- -l microsoft-authentication --grep "Microsoft authentication token cache"
```

The existing `.vscode-test.js` configuration discovers the new test file automatically.

**Expected before the fix:** 2 passing, 8 failing in the cache suite. Recovery fails with `invalid_grant` / `AADSTS700082`, with `CachedPublicClientApplication.acquireTokenSilent` in the stack.

To compare the fix, fetch the [fix branch](https://github.com/gaspardruan/vscode/tree/fix/microsoft-authentication-stale-refresh-token), rebuild, and run the same test command:

```sh
git fetch https://github.com/gaspardruan/vscode.git fix/microsoft-authentication-stale-refresh-token
git switch --detach FETCH_HEAD
```

**Expected after the fix:** all 10 cache tests pass. The test file is byte-for-byte identical on the two branches.

To run all Microsoft authentication extension tests, omit the filter:

```sh
npm run test-extension -- -l microsoft-authentication
```

## Auxiliary standalone MSAL reproduction

This is a deterministic token-cache reproduction using real MSAL code, simulated identity responses, and an in-memory persistence plugin. It does not require real credentials, a browser, or waiting for a token to expire. It reproduces the dependency behavior used by the extension, rather than a full Settings Sync UI flow.

1. In an empty directory, install the tested dependencies:

```sh
npm init -y
npm install --save-exact @azure/msal-node@5.4.0 @azure/msal-common@16.11.1
```

2. Save the script below as `reproduce-token-cache.cjs`.
3. Run:

```sh
node reproduce-token-cache.cjs
```

The script performs two authorization-code exchanges for one account: the first returns a family refresh token, and the second returns an application refresh token. It then makes two silent requests with `forceRefresh: true`. The simulated server rejects the family token with `invalid_grant` / `bad_token` / `AADSTS700082` and accepts the application token.

Standalone reproduction script

```js
const assert = require('node:assert/strict');
const { PublicClientApplication } = require('@azure/msal-node');

const clientId = '00000000-0000-4000-8000-000000000001';
const tenant = '00000000-0000-4000-8000-000000000002';
const uid = '00000000-0000-4000-8000-000000000003';
const environment = 'login.microsoftonline.com';
const authority = `https://${environment}/${tenant}`;
const encode = value => Buffer.from(JSON.stringify(value)).toString('base64url');
const now = Math.floor(Date.now() / 1000);
const idToken = `${encode({alg:'none'})}.${encode({aud:clientId,iss:`${authority}/v2.0`,iat:now,exp:now+3600,tid:tenant,oid:uid,sub:uid,preferred_username:'test@example.com'})}.signature`;
const requests = [];
let stored;
let issueFamilyToken = true;
const plugin = {
async beforeCacheAccess(context) { if (stored) context.tokenCache.deserialize(stored); },
async afterCacheAccess(context) { if (context.cacheHasChanged) stored = context.tokenCache.serialize(); }
};
const networkClient = {
async sendGetRequestAsync(url) { throw new Error(`Unexpected GET: ${url}`); },
async sendPostRequestAsync(url, options) {
const body = new URLSearchParams(options.body);
requests.push({grant:body.get('grant_type'),refreshToken:body.get('refresh_token')});
if (body.get('refresh_token') === 'expired-family-token') {
return {headers:{},status:400,body:{error:'invalid_grant',error_description:'AADSTS700082: The refresh token has expired due to inactivity.',suberror:'bad_token',error_codes:[700082]}};
}
return {headers:{},status:200,body:{token_type:'Bearer',scope:'User.Read openid profile offline_access',expires_in:3600,ext_expires_in:3600,access_token:'fresh-access-token',refresh_token:issueFamilyToken ? 'expired-family-token' : 'fresh-application-token',foci:issueFamilyToken ? '1' : undefined,id_token:idToken,client_info:encode({uid,utid:tenant})}};
}
};
const config = {
auth:{clientId,authority,cloudDiscoveryMetadata:JSON.stringify({metadata:[{preferred_network:environment,preferred_cache:environment,aliases:[environment]}]}),authorityMetadata:JSON.stringify({authorization_endpoint:`${authority}/oauth2/v2.0/authorize`,token_endpoint:`${authority}/oauth2/v2.0/token`,issuer:`${authority}/v2.0`,jwks_uri:`${authority}/discovery/v2.0/keys`})},
system:{networkClient},cache:{cachePlugin:plugin}
};
async function main() {
const pca = new PublicClientApplication(config);
// Populate an account through the real authorization-code response path.
await pca.acquireTokenByCode({code:'synthetic-code',redirectUri:'http://localhost',scopes:['User.Read']});
issueFamilyToken = false;
// A fresh interactive code exchange does not remove the stale family token.
await pca.acquireTokenByCode({code:'new-synthetic-code',redirectUri:'http://localhost',scopes:['User.Read']});
const accounts = await pca.getAllAccounts();
assert.equal(accounts.length,1);
for (let attempt = 0; attempt < 2; attempt++) {
await assert.rejects(pca.acquireTokenSilent({account:accounts[0],authority,scopes:['User.Read'],forceRefresh:true}),{errorCode:'invalid_grant',subError:'bad_token'});
console.log(JSON.stringify({attempt:attempt+1,persistedTokens:Object.values(JSON.parse(stored).RefreshToken).map(t=>t.secret),memoryTokens:Object.values(JSON.parse(pca.getTokenCache().serialize()).RefreshToken).map(t=>t.secret)}));
}
assert.deepEqual(requests.filter(r => r.grant === 'refresh_token').map(r => r.refreshToken), ['expired-family-token', 'expired-family-token']);
assert.deepEqual(Object.values(JSON.parse(stored).RefreshToken).map(t => t.secret).sort(), ['expired-family-token', 'fresh-application-token']);
assert.deepEqual(Object.values(JSON.parse(pca.getTokenCache().serialize()).RefreshToken).map(t => t.secret), ['fresh-application-token']);
console.log('Reproduced: both silent requests reused the rejected family token; only the in-memory cache removed it.');
}
main().catch(e=>{console.error(e);process.exitCode=1;});
```

The script prints the persisted and in-memory tokens after each rejected request, then exits with code 0 when all assertions confirm the defect. Exit code 0 means the bug was reproduced, not that authentication succeeded.

## Actual result

Silent authentication fails with `invalid_grant` / `bad_token`. The rejected token remains persisted:

```text
Persisted tokens: expired-family-token, fresh-application-token
In-memory tokens: fresh-application-token
```

The next request reloads the rejected family token from persistence and submits it again.

## Expected result

A refresh token that MSAL has removed as invalid should also be removed from the persisted cache. A subsequent silent request should be able to use the remaining application refresh token instead of repeatedly reloading the rejected credential.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.