Microsoft authentication keeps using an expired refresh token after successful sign-in
- 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
Assessment
This issue has not been assessed yet.