Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/platform/authentication/node/copilotTokenManager.ts
13400 views
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 { RequestType } from '@vscode/copilot-api';
7
import { Emitter } from '../../../util/vs/base/common/event';
8
import { Disposable, toDisposable } from '../../../util/vs/base/common/lifecycle';
9
import { SyncDescriptor } from '../../../util/vs/platform/instantiation/common/descriptors';
10
import { IConfigurationService } from '../../configuration/common/configurationService';
11
import { ICAPIClientService } from '../../endpoint/common/capiClient';
12
import { IDomainService } from '../../endpoint/common/domainService';
13
import { IEnvService, isScenarioAutomation } from '../../env/common/envService';
14
import { BaseOctoKitService } from '../../github/common/githubService';
15
import { NullBaseOctoKitService } from '../../github/common/nullOctokitServiceImpl';
16
import { ILogService } from '../../log/common/logService';
17
import { FetchOptions, IFetcherService, Response, jsonVerboseError } from '../../networking/common/fetcherService';
18
import { ITelemetryService } from '../../telemetry/common/telemetry';
19
import { TelemetryData } from '../../telemetry/common/telemetryData';
20
import { CopilotToken, CopilotUserInfo, ErrorEnvelope, ExtendedTokenInfo, StandardErrorEnvelope, TokenEnvelope, TokenInfoOrError, TokenValidationResult, containsVSCodeOrg, createTestExtendedTokenInfo, isErrorEnvelope, isStandardErrorEnvelope, validateTokenEnvelope } from '../common/copilotToken';
21
import { CheckCopilotToken, ICopilotTokenManager, NotGitHubLoginFailed, nowSeconds } from '../common/copilotTokenManager';
22
23
/**
24
* Result of fetching a Copilot token from the server.
25
* Includes HTTP status info and the validated response body.
26
*/
27
type FetchTokenResult = {
28
ok: boolean;
29
status: number;
30
statusText: string;
31
} & (
32
// success
33
| { body: TokenEnvelope; kind: 'token' }
34
// Copilot-specific error
35
| { body: ErrorEnvelope; kind: 'error-envelope' }
36
// Standard error - e.g., rate limiting
37
| { body: StandardErrorEnvelope; kind: 'error' }
38
// Parse failures (either from failed Fetches or invalid JSON)
39
| { body: undefined; kind: 'parse-failed'; parseError: string }
40
);
41
42
export const tokenErrorString = `Tests: either GITHUB_PAT, GITHUB_OAUTH_TOKEN, or GITHUB_OAUTH_TOKEN+VSCODE_COPILOT_CHAT_TOKEN must be set unless running from an IS_SCENARIO_AUTOMATION environment. Run "npm run get_token" to get credentials.`;
43
44
export function createStaticGitHubTokenProvider(): (() => string) | undefined {
45
const pat = process.env.GITHUB_PAT;
46
const oauthToken = process.env.GITHUB_OAUTH_TOKEN;
47
48
// In automation scenarios, NoAuth/BYOK-only scenarios are expected to not have any tokens set.
49
if (isScenarioAutomation && !pat && !oauthToken) {
50
return undefined;
51
}
52
53
return () => {
54
if (pat) {
55
return pat;
56
}
57
58
if (oauthToken) {
59
return oauthToken;
60
}
61
62
throw new Error(tokenErrorString);
63
};
64
}
65
66
export function getOrCreateTestingCopilotTokenManager(deviceId: string): SyncDescriptor<ICopilotTokenManager & CheckCopilotToken> {
67
if (process.env.VSCODE_COPILOT_CHAT_TOKEN) {
68
return new SyncDescriptor(StaticExtendedTokenInfoCopilotTokenManager, [process.env.VSCODE_COPILOT_CHAT_TOKEN]);
69
}
70
71
if (process.env.GITHUB_OAUTH_TOKEN) {
72
return new SyncDescriptor(CopilotTokenManagerFromGitHubToken, [process.env.GITHUB_OAUTH_TOKEN, 'unknown']);
73
}
74
75
if (process.env.GITHUB_PAT) {
76
return new SyncDescriptor(FixedCopilotTokenManager, [process.env.GITHUB_PAT]);
77
}
78
79
// In automation scenarios, NoAuth/BYOK-only scenarios are expected to not have any tokens set.
80
if (isScenarioAutomation) {
81
return new SyncDescriptor(CopilotTokenManagerFromDeviceId, [deviceId]);
82
}
83
84
throw new Error(tokenErrorString);
85
}
86
87
//TODO: Move this to common
88
export abstract class BaseCopilotTokenManager extends Disposable implements ICopilotTokenManager {
89
declare readonly _serviceBrand: undefined;
90
91
protected _isDisposed = false;
92
93
//#region Events
94
private readonly _copilotTokenRefreshEmitter = this._register(new Emitter<void>());
95
readonly onDidCopilotTokenRefresh = this._copilotTokenRefreshEmitter.event;
96
97
//#endregion
98
constructor(
99
protected readonly _baseOctokitservice: BaseOctoKitService,
100
protected readonly _logService: ILogService,
101
protected readonly _telemetryService: ITelemetryService,
102
protected readonly _domainService: IDomainService,
103
protected readonly _capiClientService: ICAPIClientService,
104
protected readonly _fetcherService: IFetcherService,
105
protected readonly _envService: IEnvService
106
) {
107
super();
108
this._register(toDisposable(() => this._isDisposed = true));
109
}
110
111
//#region Property getters and setters
112
private _copilotToken: ExtendedTokenInfo | undefined;
113
get copilotToken(): ExtendedTokenInfo | undefined {
114
return this._copilotToken;
115
}
116
set copilotToken(token: ExtendedTokenInfo | undefined) {
117
if (token !== this._copilotToken) {
118
this._copilotToken = token;
119
this._copilotTokenRefreshEmitter.fire();
120
}
121
}
122
123
//#endregion
124
//#region Abstract methods
125
abstract getCopilotToken(force?: boolean): Promise<CopilotToken>;
126
127
//#endregion
128
//#region Public methods
129
resetCopilotToken(httpError?: number): void {
130
if (httpError !== undefined) {
131
this._telemetryService.sendGHTelemetryEvent('auth.reset_token_' + httpError);
132
}
133
this._logService.debug(`Resetting copilot token on HTTP error ${httpError || 'unknown'}`);
134
this.copilotToken = undefined;
135
}
136
137
/**
138
* Fetches a Copilot token from the GitHub token.
139
* @param githubToken A GitHub token to mint a Copilot token from.
140
* @returns A Copilot token info or an error.
141
* @todo this should be not be public, but it is for now to allow testing.
142
*/
143
async authFromGitHubToken(githubToken: string, ghUsername: string): Promise<TokenInfoOrError & NotGitHubLoginFailed> {
144
return this.doAuthFromGitHubTokenOrDevDeviceId({ githubToken, ghUsername });
145
}
146
147
/**
148
* Fetches a Copilot token from the devDeviceId.
149
* @param devDeviceId A device ID to mint a Copilot token from.
150
* @returns A Copilot token info or an error.
151
* @todo this should be not be public, but it is for now to allow testing.
152
*/
153
async authFromDevDeviceId(devDeviceId: string): Promise<TokenInfoOrError & NotGitHubLoginFailed> {
154
return this.doAuthFromGitHubTokenOrDevDeviceId({ devDeviceId });
155
}
156
157
private async doAuthFromGitHubTokenOrDevDeviceId(
158
context: { githubToken: string; ghUsername: string } | { devDeviceId: string }
159
): Promise<TokenInfoOrError & NotGitHubLoginFailed> {
160
this._telemetryService.sendGHTelemetryEvent('auth.new_login');
161
162
let result: FetchTokenResult;
163
let userInfo: CopilotUserInfo | undefined;
164
let ghUsername: string | undefined;
165
try {
166
if ('githubToken' in context) {
167
ghUsername = context.ghUsername;
168
[result, userInfo] = (await Promise.all([
169
this.fetchCopilotTokenFromGitHubToken(context.githubToken),
170
this.fetchCopilotUserInfo(context.githubToken)
171
]));
172
} else {
173
result = await this.fetchCopilotTokenFromDevDeviceId(context.devDeviceId);
174
}
175
} catch (e) {
176
this._logService.warn('Failed to get copilot token due to fetch throwing: ' + (e.message || String(e)));
177
return { kind: 'failure', reason: 'RequestFailed', message: e.message || String(e) };
178
}
179
180
// Handle HTTP errors
181
if (!result.ok) {
182
this._logService.warn(`Failed to get copilot token due to status ${result.status} ${result.statusText}`);
183
const data = TelemetryData.createAndMarkAsIssued({
184
status: result.status.toString(),
185
status_text: result.statusText,
186
});
187
this._telemetryService.sendGHTelemetryErrorEvent('auth.invalid_token', data.properties, data.measurements);
188
// TODO: Look at telemetry to see if this even happens
189
// because looking at the backend code, 401s aren't expected here
190
if (result.status === 401) {
191
this._logService.warn('Failed to get copilot token due to 401 status');
192
this._telemetryService.sendGHTelemetryErrorEvent('auth.unknown_401');
193
return { kind: 'failure', reason: 'HTTP401' };
194
}
195
}
196
197
// Copilot Errors
198
if (result.kind === 'error-envelope') {
199
this._logService.warn(`Failed to get copilot token due to: ${result.body.error_details.message}`);
200
this._telemetryService.sendGHTelemetryErrorEvent('auth.request_read_failed');
201
return { kind: 'failure', reason: 'NotAuthorized', ...result.body.error_details };
202
}
203
204
// Standard Errors like rate limiting
205
if (result.kind === 'error') {
206
if (result.body.message?.startsWith('API rate limit exceeded')) {
207
this._logService.warn('Failed to get copilot token due to exceeding API rate limit');
208
this._telemetryService.sendGHTelemetryErrorEvent('auth.rate_limited');
209
return { kind: 'failure', reason: 'RateLimited' };
210
}
211
this._logService.warn(`Failed to get copilot token due to: ${result.body.message}`);
212
return { kind: 'failure', reason: 'NotAuthorized' };
213
}
214
215
// Parse errors
216
if (result.kind === 'parse-failed') {
217
this._logService.warn(`Failed to get copilot token due to: ${result.parseError}`);
218
this._telemetryService.sendGHTelemetryErrorEvent('auth.request_read_failed');
219
return { kind: 'failure', reason: 'ParseFailed', message: result.parseError };
220
}
221
222
// Success - we have a validated TokenEnvelope
223
const tokenInfo = result.body;
224
225
const expires_at = tokenInfo.expires_at;
226
// some users have clocks adjusted ahead, expires_at will immediately be less than current clock time;
227
// adjust expires_at to the refresh time + a buffer to avoid expiring the token before the refresh can fire.
228
tokenInfo.expires_at = nowSeconds() + tokenInfo.refresh_in + 60; // extra buffer to allow refresh to happen successfully
229
230
// extend the token envelope
231
const login = ghUsername ?? 'unknown';
232
const extendedInfo: ExtendedTokenInfo = {
233
...tokenInfo,
234
copilot_plan: userInfo?.copilot_plan ?? tokenInfo.sku ?? '',
235
quota_snapshots: userInfo?.quota_snapshots,
236
quota_reset_date: userInfo?.quota_reset_date,
237
codex_agent_enabled: userInfo?.codex_agent_enabled,
238
organization_login_list: userInfo?.organization_login_list ?? [],
239
username: login,
240
isVscodeTeamMember: containsVSCodeOrg(tokenInfo.organization_list ?? []),
241
};
242
const telemetryData = TelemetryData.createAndMarkAsIssued(
243
{},
244
{
245
adjusted_expires_at: tokenInfo.expires_at,
246
expires_at: expires_at, // track original expires_at
247
current_time: nowSeconds(),
248
}
249
);
250
251
this._telemetryService.sendGHTelemetryEvent('auth.new_token', telemetryData.properties, telemetryData.measurements);
252
253
return { kind: 'success', ...extendedInfo };
254
}
255
256
//#endregion
257
258
//#region Private methods
259
private async fetchCopilotTokenFromGitHubToken(githubToken: string): Promise<FetchTokenResult> {
260
const options: FetchOptions = {
261
callSite: 'copilot-token-github',
262
headers: {
263
Authorization: `token ${githubToken}`,
264
'X-GitHub-Api-Version': '2025-04-01'
265
},
266
retryFallbacks: true,
267
expectJSON: true,
268
};
269
const response = await this._capiClientService.makeRequest<Response>(options, { type: RequestType.CopilotToken });
270
return this.parseTokenResponse(response);
271
}
272
273
private async fetchCopilotTokenFromDevDeviceId(devDeviceId: string): Promise<FetchTokenResult> {
274
const options: FetchOptions = {
275
callSite: 'copilot-token-device',
276
headers: {
277
'X-GitHub-Api-Version': '2025-04-01',
278
'Editor-Device-Id': `${devDeviceId}`
279
},
280
retryFallbacks: true,
281
expectJSON: true,
282
};
283
const response = await this._capiClientService.makeRequest<Response>(options, { type: RequestType.CopilotNLToken });
284
return this.parseTokenResponse(response);
285
}
286
287
/**
288
* Parses and validates a token endpoint response.
289
* Returns a structured result with HTTP status and validated body.
290
*/
291
private async parseTokenResponse(response: Response): Promise<FetchTokenResult> {
292
const httpInfo = { ok: response.ok, status: response.status, statusText: response.statusText };
293
294
let parsed: unknown;
295
try {
296
parsed = await jsonVerboseError(response);
297
} catch (err) {
298
return { ...httpInfo, body: undefined, kind: 'parse-failed', parseError: err.message || String(err) };
299
}
300
301
const validationResult = validateTokenEnvelope(parsed);
302
if (validationResult.valid) {
303
this.sendTokenValidationTelemetry(validationResult);
304
return { ...httpInfo, body: validationResult.envelope, kind: 'token' };
305
}
306
if (isErrorEnvelope(parsed)) {
307
return { ...httpInfo, body: parsed, kind: 'error-envelope' };
308
}
309
if (isStandardErrorEnvelope(parsed)) {
310
return { ...httpInfo, body: parsed, kind: 'error' };
311
}
312
313
// Token validation failed entirely - send telemetry for the failed case
314
this.sendTokenValidationTelemetry(validationResult);
315
return { ...httpInfo, body: undefined, kind: 'parse-failed', parseError: 'Response is not valid: ' + JSON.stringify(parsed) };
316
}
317
318
/**
319
* Sends telemetry when token validation uses fallback strategy or fails entirely.
320
* This helps track server schema drift over time.
321
*/
322
private sendTokenValidationTelemetry(validationResult: TokenValidationResult): void {
323
if (validationResult.strategy === 'strict') {
324
// We were able to validate strictly as expected - no telemetry needed
325
return;
326
}
327
328
/* __GDPR__
329
"copilotTokenFetching.validation" : {
330
"owner": "TylerLeonhardt",
331
"comment": "Track token envelope validation strategy to detect server schema drift.",
332
"strategy": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The validation strategy used: 'fallback' or 'failed'" },
333
"strictError": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The error from strict validation, if any" },
334
"fallbackError": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The error from fallback validation, if failed" }
335
}
336
*/
337
this._telemetryService.sendMSFTTelemetryEvent('copilotTokenFetching.validation', {
338
strategy: validationResult.strategy,
339
strictError: validationResult.strictError,
340
fallbackError: validationResult.fallbackError,
341
});
342
}
343
344
private async fetchCopilotUserInfo(githubToken: string): Promise<CopilotUserInfo> {
345
const options: FetchOptions = {
346
callSite: 'copilot-token-user-info',
347
headers: {
348
Authorization: `token ${githubToken}`,
349
'X-GitHub-Api-Version': '2025-04-01',
350
},
351
retryFallbacks: true,
352
expectJSON: true,
353
};
354
const response = await this._capiClientService.makeRequest<Response>(options, { type: RequestType.CopilotUserInfo });
355
const data = await response.json();
356
return data;
357
}
358
}
359
360
//#region FixedCopilotTokenManager
361
362
/**
363
* A `CopilotTokenManager` that always returns the same token.
364
* Mostly only useful for short periods, e.g. tests or single completion requests,
365
* as these tokens typically expire after a few hours.
366
* @todo Move this to a test layer
367
*/
368
369
export class FixedCopilotTokenManager extends BaseCopilotTokenManager implements CheckCopilotToken {
370
constructor(
371
private _completionsToken: string,
372
@ILogService logService: ILogService,
373
@ITelemetryService telemetryService: ITelemetryService,
374
@ICAPIClientService capiClientService: ICAPIClientService,
375
@IDomainService domainService: IDomainService,
376
@IFetcherService fetcherService: IFetcherService,
377
@IEnvService envService: IEnvService
378
) {
379
super(new NullBaseOctoKitService(capiClientService, fetcherService, logService, telemetryService), logService, telemetryService, domainService, capiClientService, fetcherService, envService);
380
this.copilotToken = createTestExtendedTokenInfo({ token: _completionsToken, username: 'fixedTokenManager', copilot_plan: 'unknown' });
381
}
382
383
set completionsToken(token: string) {
384
this._completionsToken = token;
385
this.copilotToken = createTestExtendedTokenInfo({ token, username: 'fixedTokenManager', copilot_plan: 'unknown' });
386
}
387
get completionsToken(): string {
388
return this._completionsToken;
389
}
390
391
async getCopilotToken(): Promise<CopilotToken> {
392
return new CopilotToken(this.copilotToken!);
393
}
394
395
async checkCopilotToken(): Promise<{ status: 'OK' }> {
396
// assume it's valid
397
return { status: 'OK' };
398
}
399
}
400
401
//#endregion
402
403
//#region StaticExtendedTokenInfoCopilotTokenManager
404
405
/**
406
* Use the `StaticExtendedTokenInfoCopilotTokenManager` when you have a base64, JSON-encoded `ExtendedTokenInfo`
407
* in an automation scenario.
408
*/
409
export class StaticExtendedTokenInfoCopilotTokenManager extends BaseCopilotTokenManager implements CheckCopilotToken {
410
private readonly _initialToken: ExtendedTokenInfo;
411
412
constructor(
413
serializedToken: string,
414
@ILogService logService: ILogService,
415
@ITelemetryService telemetryService: ITelemetryService,
416
@ICAPIClientService capiClientService: ICAPIClientService,
417
@IDomainService domainService: IDomainService,
418
@IFetcherService fetcherService: IFetcherService,
419
@IEnvService envService: IEnvService
420
) {
421
super(new NullBaseOctoKitService(capiClientService, fetcherService, logService, telemetryService), logService, telemetryService, domainService, capiClientService, fetcherService, envService);
422
const data = Buffer.from(serializedToken, 'base64').toString('utf8');
423
this._initialToken = JSON.parse(data);
424
}
425
426
override async getCopilotToken(): Promise<CopilotToken> {
427
if (!this.copilotToken) {
428
this.copilotToken = { ...this._initialToken };
429
}
430
431
return new CopilotToken(this._initialToken);
432
}
433
434
async checkCopilotToken(): Promise<{ status: 'OK' }> {
435
return { status: 'OK' };
436
}
437
}
438
//#endregion
439
440
//#region RefreshableCopilotTokenManager
441
442
/**
443
* Generic token manager that handles token caching and refresh logic.
444
* Takes an authentication function to fetch new tokens.
445
*/
446
export abstract class RefreshableCopilotTokenManager extends BaseCopilotTokenManager implements CheckCopilotToken {
447
protected abstract authenticateAndGetToken(): Promise<TokenInfoOrError & NotGitHubLoginFailed>;
448
449
async getCopilotToken(force?: boolean): Promise<CopilotToken> {
450
if (!this.copilotToken || this.copilotToken.expires_at < nowSeconds() + (60 * 5 /* 5min */) || force) {
451
const tokenResult = await this.authenticateAndGetToken();
452
if (tokenResult.kind === 'failure') {
453
throw Error(
454
`Failed to get copilot token: ${tokenResult.reason.toString()} ${tokenResult.message ?? ''}`
455
);
456
}
457
this.copilotToken = { ...tokenResult };
458
}
459
return new CopilotToken(this.copilotToken);
460
}
461
462
async checkCopilotToken() {
463
if (!this.copilotToken || this.copilotToken.expires_at < nowSeconds()) {
464
const tokenResult = await this.authenticateAndGetToken();
465
if (tokenResult.kind === 'failure') {
466
return tokenResult;
467
}
468
this.copilotToken = { ...tokenResult };
469
}
470
const result: { status: 'OK' } = {
471
status: 'OK',
472
};
473
return result;
474
}
475
}
476
477
//#endregion
478
479
//#region CopilotTokenManagerFromDeviceId
480
481
export class CopilotTokenManagerFromDeviceId extends RefreshableCopilotTokenManager {
482
483
constructor(
484
private readonly deviceId: string,
485
@ILogService logService: ILogService,
486
@ITelemetryService telemetryService: ITelemetryService,
487
@IDomainService domainService: IDomainService,
488
@ICAPIClientService capiClientService: ICAPIClientService,
489
@IFetcherService fetcherService: IFetcherService,
490
@IEnvService envService: IEnvService,
491
@IConfigurationService protected readonly configurationService: IConfigurationService
492
) {
493
super(new NullBaseOctoKitService(capiClientService, fetcherService, logService, telemetryService), logService, telemetryService, domainService, capiClientService, fetcherService, envService);
494
}
495
496
protected async authenticateAndGetToken(): Promise<TokenInfoOrError & NotGitHubLoginFailed> {
497
return this.authFromDevDeviceId(this.deviceId);
498
}
499
}
500
501
//#endregion
502
503
//#region CopilotTokenManagerFromGitHubToken
504
505
/**
506
* Given a GitHub token, return a Copilot token, refreshing it as needed.
507
* The caller that initializes the object is responsible for checking telemetry consent before
508
* using the object.
509
*/
510
export class CopilotTokenManagerFromGitHubToken extends RefreshableCopilotTokenManager {
511
512
constructor(
513
private readonly githubToken: string,
514
private readonly githubUsername: string,
515
@ILogService logService: ILogService,
516
@ITelemetryService telemetryService: ITelemetryService,
517
@IDomainService domainService: IDomainService,
518
@ICAPIClientService capiClientService: ICAPIClientService,
519
@IFetcherService fetcherService: IFetcherService,
520
@IEnvService envService: IEnvService,
521
@IConfigurationService protected readonly configurationService: IConfigurationService
522
) {
523
super(new NullBaseOctoKitService(capiClientService, fetcherService, logService, telemetryService), logService, telemetryService, domainService, capiClientService, fetcherService, envService);
524
}
525
526
protected async authenticateAndGetToken(): Promise<TokenInfoOrError & NotGitHubLoginFailed> {
527
return this.authFromGitHubToken(this.githubToken, this.githubUsername);
528
}
529
}
530
531