Skip to content

Commit a0d196e

Browse files
Copilotalexr00
andauthored
Detect stale GitHub credentials and prompt re-authentication (#8728)
* Initial plan * Detect "Bad credentials"/401 errors and trigger re-auth Agent-Logs-Url: https://github.com/microsoft/vscode-pull-request-github/sessions/d7437db6-d6f8-4e32-80f2-d39c6660a4f2 Co-authored-by: alexr00 <38270282+alexr00@users.noreply.github.com> * handleAuthError: re-auth only the affected provider Agent-Logs-Url: https://github.com/microsoft/vscode-pull-request-github/sessions/56935e9d-de2d-4ee5-a5b6-44ba4fb409b4 Co-authored-by: alexr00 <38270282+alexr00@users.noreply.github.com>
1 parent e94b30a commit a0d196e

3 files changed

Lines changed: 191 additions & 2 deletions

File tree

src/github/credentials.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ export class CredentialStore extends Disposable {
6161
private _scopes: string[] = SCOPES_OLD;
6262
private _scopesEnterprise: string[] = SCOPES_OLD;
6363
private _isSamling: boolean = false;
64+
private _handlingAuthError: Map<AuthProvider, Promise<AuthResult>> = new Map();
65+
private _lastAuthErrorHandledAt: Map<AuthProvider, number> = new Map();
66+
// Cooldown long enough to absorb retries from in-flight requests that were
67+
// issued with the now-invalid token, but short enough that a token that
68+
// is invalidated again soon after re-auth will still trigger another prompt.
69+
private static readonly AUTH_ERROR_COOLDOWN_MS = 60_000;
6470

6571
private _onDidChangeSessions: vscode.EventEmitter<vscode.AuthenticationSessionsChangeEvent> = new vscode.EventEmitter();
6672
public readonly onDidChangeSessions = this._onDidChangeSessions.event;
@@ -285,6 +291,51 @@ export class CredentialStore extends Disposable {
285291
return this.doCreate({ forceNewSession: reason ? { detail: reason } : true });
286292
}
287293

294+
/**
295+
* Handles authentication errors that surface from API calls (e.g. "Bad credentials"
296+
* or 401 Unauthorized). Triggers a re-authentication prompt for the affected
297+
* provider, deduplicating concurrent requests so we don't show multiple prompts
298+
* when many in-flight calls fail at once.
299+
*/
300+
public async handleAuthError(authProviderId: AuthProvider): Promise<AuthResult> {
301+
// Only prompt if we currently believe we are authenticated for this provider.
302+
// Otherwise the regular sign-in flow will handle it.
303+
if (!this.isAuthenticated(authProviderId)) {
304+
return { canceled: true };
305+
}
306+
const inFlight = this._handlingAuthError.get(authProviderId);
307+
if (inFlight) {
308+
return inFlight;
309+
}
310+
// In-flight requests that were issued with the now-invalid token may continue
311+
// to fail with auth errors for a short period after a successful re-auth.
312+
// Suppress re-prompting for a cooldown window to avoid repeatedly nagging the
313+
// user.
314+
const lastHandled = this._lastAuthErrorHandledAt.get(authProviderId);
315+
if (lastHandled !== undefined && (Date.now() - lastHandled) < CredentialStore.AUTH_ERROR_COOLDOWN_MS) {
316+
return { canceled: true };
317+
}
318+
Logger.appendLine(`Detected invalid GitHub${getGitHubSuffix(authProviderId)} credentials; prompting for re-authentication.`, CredentialStore.ID);
319+
/* __GDPR__
320+
"auth.badCredentials" : {}
321+
*/
322+
this._telemetry.sendTelemetryEvent('auth.badCredentials');
323+
const reason = vscode.l10n.t('Your GitHub{0} authentication session is no longer valid. Please sign in again.', getGitHubSuffix(authProviderId));
324+
const promise = (async () => {
325+
try {
326+
// Force re-auth only for the affected provider, not both. Going through
327+
// recreate()/doCreate() would prompt re-auth for both GitHub.com and
328+
// GitHub Enterprise when both are configured.
329+
return await this.initialize(authProviderId, { forceNewSession: { detail: reason } });
330+
} finally {
331+
this._handlingAuthError.delete(authProviderId);
332+
this._lastAuthErrorHandledAt.set(authProviderId, Date.now());
333+
}
334+
})();
335+
this._handlingAuthError.set(authProviderId, promise);
336+
return promise;
337+
}
338+
288339
public async reset() {
289340
this._githubAPI = undefined;
290341
this._githubEnterpriseAPI = undefined;
@@ -561,7 +612,9 @@ export class CredentialStore extends Disposable {
561612
},
562613
});
563614

564-
const rateLogger = new RateLogger(this._telemetry, isEnterprise(authProviderId));
615+
const rateLogger = new RateLogger(this._telemetry, isEnterprise(authProviderId), (_e) => {
616+
void this.handleAuthError(authProviderId);
617+
});
565618
const github: GitHub = {
566619
octokit: new LoggingOctokit(octokit, rateLogger),
567620
graphql: new LoggingApolloClient(graphql, rateLogger),

src/github/loggingOctokit.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,35 @@ function isObject(value: unknown): value is Record<string, unknown> {
4444
return typeof value === 'object' && value !== null;
4545
}
4646

47+
/**
48+
* Detects whether an error from a REST (Octokit) or GraphQL (Apollo) call
49+
* indicates that the GitHub authentication token is no longer valid. This
50+
* happens when the token has been revoked or has expired and surfaces as
51+
* either a 401 status, a "Bad credentials" message (REST), or a
52+
* "401 Unauthorized" network error (GraphQL).
53+
*/
54+
export function isAuthError(e: unknown): boolean {
55+
if (!isObject(e)) {
56+
return false;
57+
}
58+
if (e.status === 401) {
59+
return true;
60+
}
61+
const networkError = e.networkError;
62+
if (isObject(networkError) && networkError.statusCode === 401) {
63+
return true;
64+
}
65+
if (typeof e.message === 'string') {
66+
if (e.message.includes('Bad credentials')) {
67+
return true;
68+
}
69+
if (e.message.includes('401 Unauthorized')) {
70+
return true;
71+
}
72+
}
73+
return false;
74+
}
75+
4776
export function getErrorCode(e: unknown): string | undefined {
4877
if (!isObject(e)) {
4978
return undefined;
@@ -94,7 +123,7 @@ export class RateLogger {
94123
private hasLoggedLowRateLimit: boolean = false;
95124
private readonly _isInsiders: boolean;
96125

97-
constructor(private readonly telemetry: ITelemetry, private readonly errorOnFlood: boolean) {
126+
constructor(private readonly telemetry: ITelemetry, private readonly errorOnFlood: boolean, private readonly authErrorHandler?: (e: unknown) => void) {
98127
this._isInsiders = vscode.env.appName.toLowerCase().includes('insider');
99128
}
100129

@@ -187,6 +216,14 @@ export class RateLogger {
187216
}
188217
*/
189218
this.telemetry.sendTelemetryErrorEvent('pr.apiCallFailed', properties);
219+
220+
if (this.authErrorHandler && isAuthError(e)) {
221+
try {
222+
this.authErrorHandler(e);
223+
} catch {
224+
// Ignore errors from the auth error handler so they don't propagate.
225+
}
226+
}
190227
});
191228
}
192229

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { default as assert } from 'assert';
7+
import { isAuthError, RateLogger } from '../../github/loggingOctokit';
8+
import { MockTelemetry } from '../mocks/mockTelemetry';
9+
10+
describe('loggingOctokit', () => {
11+
describe('isAuthError', () => {
12+
it('returns true for an Octokit-style 401 with "Bad credentials" message', () => {
13+
const e: any = new Error('Bad credentials');
14+
e.status = 401;
15+
assert.strictEqual(isAuthError(e), true);
16+
});
17+
18+
it('returns true for an error whose message is exactly "Bad credentials"', () => {
19+
assert.strictEqual(isAuthError(new Error('Bad credentials')), true);
20+
});
21+
22+
it('returns true for an error whose message includes "Bad credentials"', () => {
23+
assert.strictEqual(isAuthError(new Error('HttpError: Bad credentials - https://docs.github.com/rest')), true);
24+
});
25+
26+
it('returns true for a GraphQL networkError with statusCode 401', () => {
27+
const e: any = new Error('Network error');
28+
e.networkError = { statusCode: 401 };
29+
assert.strictEqual(isAuthError(e), true);
30+
});
31+
32+
it('returns true for a GraphQL error message including "401 Unauthorized"', () => {
33+
assert.strictEqual(isAuthError(new Error('Response not successful: Received status code 401 Unauthorized')), true);
34+
});
35+
36+
it('returns false for unrelated errors', () => {
37+
const e: any = new Error('Not Found');
38+
e.status = 404;
39+
assert.strictEqual(isAuthError(e), false);
40+
assert.strictEqual(isAuthError(new Error('Server Error')), false);
41+
assert.strictEqual(isAuthError(undefined), false);
42+
assert.strictEqual(isAuthError(null), false);
43+
assert.strictEqual(isAuthError('Bad credentials'), false);
44+
});
45+
});
46+
47+
describe('RateLogger.logApiError', () => {
48+
it('invokes the auth error handler when the API call fails with a 401/Bad credentials', async () => {
49+
const telemetry = new MockTelemetry();
50+
let handlerCalls = 0;
51+
const handler = () => { handlerCalls++; };
52+
const rateLogger = new RateLogger(telemetry, false, handler);
53+
54+
const e: any = new Error('Bad credentials');
55+
e.status = 401;
56+
rateLogger.logApiError('/test', Promise.reject(e));
57+
58+
// allow microtasks to run
59+
await new Promise(resolve => setImmediate(resolve));
60+
assert.strictEqual(handlerCalls, 1);
61+
});
62+
63+
it('does not invoke the auth error handler for non-auth errors', async () => {
64+
const telemetry = new MockTelemetry();
65+
let handlerCalls = 0;
66+
const handler = () => { handlerCalls++; };
67+
const rateLogger = new RateLogger(telemetry, false, handler);
68+
69+
const e: any = new Error('Not Found');
70+
e.status = 404;
71+
rateLogger.logApiError('/test', Promise.reject(e));
72+
73+
await new Promise(resolve => setImmediate(resolve));
74+
assert.strictEqual(handlerCalls, 0);
75+
});
76+
77+
it('swallows exceptions thrown by the auth error handler', async () => {
78+
const telemetry = new MockTelemetry();
79+
const handler = () => { throw new Error('handler failure'); };
80+
const rateLogger = new RateLogger(telemetry, false, handler);
81+
82+
const e: any = new Error('Bad credentials');
83+
e.status = 401;
84+
// Should not throw.
85+
rateLogger.logApiError('/test', Promise.reject(e));
86+
await new Promise(resolve => setImmediate(resolve));
87+
});
88+
89+
it('works without an auth error handler', async () => {
90+
const telemetry = new MockTelemetry();
91+
const rateLogger = new RateLogger(telemetry, false);
92+
93+
const e: any = new Error('Bad credentials');
94+
e.status = 401;
95+
rateLogger.logApiError('/test', Promise.reject(e));
96+
await new Promise(resolve => setImmediate(resolve));
97+
});
98+
});
99+
});

0 commit comments

Comments
 (0)