Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/platform/authentication/common/copilotToken.ts
13401 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 { CopilotUserQuotaInfo } from '../../chat/common/chatQuotaService';
7
import { vArray, vBoolean, vEnum, vNullable, vNumber, vObj, vRequired, vString } from '../../configuration/common/validator';
8
9
/**
10
* A function used to determine if the org list contains an internal organization
11
* @param orgList The list of organizations the user is a member of
12
* Whether or not it contains an internal org
13
*/
14
export function containsInternalOrg(orgList: string[]): boolean {
15
return containsGitHubOrg(orgList) || containsMicrosoftOrg(orgList);
16
}
17
18
/**
19
* A function used to determine if the org list contains a GitHub organization
20
* @param orgList The list of organizations the user is a member of
21
* Whether or not it contains a GitHub org
22
*/
23
function containsGitHubOrg(orgList: string[]): boolean {
24
const GITHUB_ORGANIZATIONS = ['4535c7beffc844b46bb1ed4aa04d759a'];
25
// Check if the user is part of an allowed organization.
26
for (const org of orgList) {
27
if (GITHUB_ORGANIZATIONS.includes(org)) {
28
return true;
29
}
30
}
31
return false;
32
}
33
34
/**
35
* A function used to determine if the org list contains a Microsoft organization
36
* @param orgList The list of organizations the user is a member of
37
* Whether or not it contains a Microsoft org
38
*/
39
function containsMicrosoftOrg(orgList: string[]): boolean {
40
const MICROSOFT_ORGANIZATIONS = ['a5db0bcaae94032fe715fb34a5e4bce2', '7184f66dfcee98cb5f08a1cb936d5225',
41
'1cb18ac6eedd49b43d74a1c5beb0b955', 'ea9395b9a9248c05ee6847cbd24355ed'];
42
// Check if the user is part of a Microsoft organization.
43
for (const org of orgList) {
44
if (MICROSOFT_ORGANIZATIONS.includes(org)) {
45
return true;
46
}
47
}
48
return false;
49
}
50
51
/**
52
* A function used to determine if the org list contains a VS Code organization
53
* @param orgList The list of organizations the user is a member of
54
* Whether or not it contains a VS Code org
55
*/
56
export function containsVSCodeOrg(orgList: string[]): boolean {
57
const VSCODE_ORGANIZATIONS = ['551cca60ce19654d894e786220822482'];
58
// Check if the user is part of a VS Code organization.
59
for (const org of orgList) {
60
if (VSCODE_ORGANIZATIONS.includes(org)) {
61
return true;
62
}
63
}
64
return false;
65
}
66
67
export class CopilotToken {
68
private readonly tokenMap: Map<string, string>;
69
constructor(private readonly _info: ExtendedTokenInfo) {
70
this.tokenMap = this.parseToken(_info.token);
71
}
72
73
private parseToken(token: string): Map<string, string> {
74
const result = new Map<string, string>();
75
const firstPart = token?.split(':')[0];
76
const fields = firstPart?.split(';');
77
for (const field of fields) {
78
const [key, value] = field.split('=');
79
result.set(key, value);
80
}
81
return result;
82
}
83
84
get token(): string {
85
return this._info.token;
86
}
87
88
get sku(): CopilotSku | undefined {
89
return this._info.sku;
90
}
91
92
/**
93
* Evaluates `has_cfi_access?` which is defined as `!has_cfb_access? && !has_cfe_access?`
94
* (cfb = copilot for business, cfe = copilot for enterprise).
95
* So it's also true for copilot free users.
96
*/
97
get isIndividual(): boolean {
98
return this._info.individual ?? false;
99
}
100
101
get organizationList(): string[] {
102
return this._info.organization_list || [];
103
}
104
105
/**
106
* Returns the list of organization logins that provide Copilot access to the user.
107
* These are the organizations through which the user has a Copilot subscription (Business/Enterprise).
108
*/
109
get organizationLoginList(): string[] {
110
return this._info.organization_login_list || [];
111
}
112
113
get enterpriseList(): number[] {
114
return this._info.enterprise_list || [];
115
}
116
117
get endpoints(): Endpoints | undefined {
118
return this._info.endpoints;
119
}
120
121
get isInternal() {
122
return containsInternalOrg(this.organizationList);
123
}
124
125
get isMicrosoftInternal(): boolean {
126
return containsMicrosoftOrg(this.organizationList);
127
}
128
129
get isGitHubInternal(): boolean {
130
return containsGitHubOrg(this.organizationList);
131
}
132
133
get isFreeUser(): boolean {
134
return this.sku === 'free_limited_copilot';
135
}
136
137
get isNoAuthUser(): boolean {
138
return this.sku === 'no_auth_limited_copilot';
139
}
140
141
get isChatQuotaExceeded(): boolean {
142
return this.isFreeUser && (this._info.limited_user_quotas?.chat ?? 1) <= 0;
143
}
144
145
get isCompletionsQuotaExceeded(): boolean {
146
return this.isFreeUser && (this._info.limited_user_quotas?.completions ?? 1) <= 0;
147
}
148
149
get codeQuoteEnabled(): boolean {
150
return this._info.code_quote_enabled ?? false;
151
}
152
153
get isVscodeTeamMember(): boolean {
154
return this._info.isVscodeTeamMember || containsVSCodeOrg(this.organizationList);
155
}
156
157
get codexAgentEnabled(): boolean {
158
return this._info.codex_agent_enabled ?? false;
159
}
160
161
get copilotPlan(): 'free' | 'individual' | 'individual_pro' | 'business' | 'enterprise' {
162
if (this.isFreeUser) {
163
return 'free';
164
}
165
const plan = this._info.copilot_plan;
166
switch (plan) {
167
case 'individual':
168
case 'individual_pro':
169
case 'business':
170
case 'enterprise':
171
return plan;
172
default:
173
// Default to 'individual' for unexpected values
174
return 'individual';
175
}
176
}
177
178
get quotaInfo() {
179
return { quota_snapshots: this._info.quota_snapshots, quota_reset_date: this._info.quota_reset_date };
180
}
181
182
get username(): string {
183
return this._info.username;
184
}
185
186
private _isTelemetryEnabled: boolean | undefined;
187
isTelemetryEnabled(): boolean {
188
if (this._isTelemetryEnabled === undefined) {
189
this._isTelemetryEnabled = this._info.telemetry === 'enabled';
190
}
191
return this._isTelemetryEnabled;
192
}
193
194
private _isPublicSuggestionsEnabled: boolean | undefined;
195
isPublicSuggestionsEnabled(): boolean {
196
if (this._isPublicSuggestionsEnabled === undefined) {
197
this._isPublicSuggestionsEnabled = this._info.public_suggestions === 'enabled';
198
}
199
return this._isPublicSuggestionsEnabled;
200
}
201
202
isCopilotIgnoreEnabled(): boolean {
203
return this._info.copilotignore_enabled ?? false;
204
}
205
206
get isCopilotCodeReviewEnabled(): boolean {
207
return this._info.code_review_enabled ?? (this.getTokenValue('ccr') === '1');
208
}
209
210
isEditorPreviewFeaturesEnabled(): boolean {
211
// Editor preview features are disabled if the flag is present and set to 0
212
return this.getTokenValue('editor_preview_features') !== '0';
213
}
214
215
isMcpEnabled(): boolean {
216
// MCP is disabled if the flag is present and set to 0
217
return this.getTokenValue('mcp') !== '0';
218
}
219
220
isClientBYOKEnabled(): boolean {
221
return this.getTokenValue('client_byok') === '1';
222
}
223
224
getTokenValue(key: string): string | undefined {
225
return this.tokenMap.get(key);
226
}
227
228
isExpandedClientSideIndexingEnabled(): boolean {
229
return this._info.blackbird_clientside_indexing === true;
230
}
231
232
isFcv1(): boolean {
233
return this.tokenMap.get('fcv1') === '1';
234
}
235
236
/**
237
* Is snippy in blocking mode
238
*/
239
isSn(): boolean {
240
return this.tokenMap.get('sn') === '1';
241
}
242
}
243
244
/**
245
* Details of the user's telemetry consent status we get from the server during token retrieval.
246
*
247
* `unconfigured` is a transitional state for pre-GA that indicates the user is in the Technical Preview
248
* and needs to be asked about telemetry consent client-side. It can be removed post-GA as the server
249
* will never return it again.
250
*
251
* `enabled` indicates that they agreed to full telemetry.
252
*
253
* `disabled` indicates that they opted out of full telemetry so we can only send the core messages
254
* that users cannot opt-out of.
255
*
256
*/
257
export type UserTelemetryChoice = 'enabled' | 'disabled';
258
259
/**
260
* A notification we get from the server during token retrieval. Needs to be presented to the user.
261
* Used for both success notifications (user_notification) and error notifications (error_details).
262
*/
263
export interface NotificationEnvelope {
264
message: string;
265
notification_id: TokenErrorNotificationId | string;
266
title: string;
267
url: string;
268
}
269
270
//#region CopilotSku Types
271
272
/**
273
* Well-known SKU values that are checked in source code.
274
* The actual SKU can be any string - these are just the ones we explicitly handle.
275
*/
276
export type WellKnownSku =
277
| 'free_limited_copilot'
278
| 'no_auth_limited_copilot';
279
280
/**
281
* User's access type/SKU from the Copilot token endpoint.
282
* This is a string that can be any SKU value - use WellKnownSku for type-safe comparisons.
283
*/
284
export type CopilotSku = WellKnownSku | string;
285
286
//#endregion
287
288
//#region Endpoints
289
290
export interface Endpoints {
291
api?: string;
292
'origin-tracker'?: string;
293
proxy?: string;
294
telemetry?: string;
295
}
296
297
//#endregion
298
299
/**
300
* A server response containing a Copilot token and metadata associated with it.
301
* This is the success response (HTTP 200) from the /copilot_internal/v2/token endpoint.
302
*/
303
export interface TokenEnvelope {
304
// Required fields
305
/** HMAC-signed token for Copilot proxy authentication. v2 format: fields:mac where fields are ';' separated key=value pairs. */
306
token: string;
307
/** Unix timestamp (seconds) when token expires. */
308
expires_at: number;
309
/** Seconds until client should request a new token. */
310
refresh_in: number;
311
/** User's access type/SKU. */
312
sku: CopilotSku;
313
/** Whether user has Copilot Individual access. */
314
individual: boolean;
315
316
// Feature flags
317
/** Whether client-side indexing for Blackbird is enabled. */
318
blackbird_clientside_indexing: boolean;
319
/** Whether code quote/citation is enabled. */
320
code_quote_enabled: boolean;
321
/** Whether Copilot code review is enabled. */
322
code_review_enabled: boolean;
323
/** Whether code search is enabled. */
324
codesearch: boolean;
325
/** Whether content exclusion (.copilotignore) is enabled. */
326
copilotignore_enabled: boolean;
327
/** Whether VS Code electron fetcher v2 is enabled. */
328
vsc_electron_fetcher_v2: boolean;
329
330
// Consent settings
331
/** 'enabled', 'disabled', or 'unconfigured' for public code suggestions. */
332
public_suggestions: 'enabled' | 'disabled' | 'unconfigured';
333
/** 'enabled' or 'disabled' for telemetry. */
334
telemetry: 'enabled' | 'disabled';
335
336
// Optional fields
337
/** SKU-isolated endpoints. */
338
endpoints?: Endpoints;
339
/** Enterprise IDs if user has enterprise access. */
340
enterprise_list?: number[] | null;
341
/** Quota remaining for free/limited users. Null for non-free users. */
342
limited_user_quotas?: { chat: number; completions: number } | null;
343
/** Unix timestamp when quotas reset for free/limited users. Null for non-free users. */
344
limited_user_reset_date?: number | null;
345
/** Organization tracking IDs if user has org access. */
346
organization_list?: string[];
347
/** Notification to show in editor on successful token retrieval. */
348
user_notification?: NotificationEnvelope;
349
}
350
351
/**
352
* The shape of an error response (HTTP 403) from the server when token retrieval fails.
353
*/
354
export interface ErrorEnvelope {
355
/** Whether user can sign up for Copilot Free. Null when not applicable. */
356
can_signup_for_limited?: boolean | null;
357
/** Detailed error information including notification_id. */
358
error_details: NotificationEnvelope;
359
/** Generic error message with TOS text. */
360
message: string;
361
/** Optional reason string. */
362
reason?: string;
363
}
364
365
/**
366
* The shape of a standard error response from the server. Used for generic errors like rate limiting.
367
*/
368
export interface StandardErrorEnvelope {
369
message: string; // e.g., "API rate limit exceeded for user ID 12345. ..."
370
documentation_url: string; // "https://developer.github.com/rest/overview/rate-limits-for-the-rest-api"
371
status: string; // "403"
372
}
373
374
//#region Validators
375
376
const notificationEnvelopeValidator = vObj({
377
message: vRequired(vString()),
378
notification_id: vRequired(vString()),
379
title: vRequired(vString()),
380
url: vRequired(vString()),
381
});
382
383
const errorEnvelopeValidator = vObj({
384
can_signup_for_limited: vNullable(vBoolean()),
385
error_details: vRequired(notificationEnvelopeValidator),
386
message: vRequired(vString()),
387
reason: vString(),
388
});
389
390
const tokenEnvelopeValidator = vObj({
391
token: vRequired(vString()),
392
expires_at: vRequired(vNumber()),
393
refresh_in: vRequired(vNumber()),
394
sku: vString(),
395
individual: vBoolean(),
396
blackbird_clientside_indexing: vBoolean(),
397
code_quote_enabled: vBoolean(),
398
code_review_enabled: vBoolean(),
399
codesearch: vBoolean(),
400
copilotignore_enabled: vBoolean(),
401
vsc_electron_fetcher_v2: vBoolean(),
402
public_suggestions: vEnum('enabled', 'disabled', 'unconfigured'),
403
telemetry: vEnum('enabled', 'disabled'),
404
endpoints: vObj({
405
api: vString(),
406
'origin-tracker': vString(),
407
proxy: vString(),
408
telemetry: vString(),
409
}),
410
enterprise_list: vNullable(vArray(vNumber())),
411
limited_user_quotas: vNullable(vObj({
412
chat: vRequired(vNumber()),
413
completions: vRequired(vNumber()),
414
})),
415
limited_user_reset_date: vNullable(vNumber()),
416
organization_list: vArray(vString()),
417
user_notification: notificationEnvelopeValidator,
418
});
419
420
const standardErrorEnvelopeValidator = vObj({
421
message: vRequired(vString()),
422
documentation_url: vRequired(vString()),
423
status: vRequired(vString()),
424
});
425
426
/**
427
* Fallback validator that only checks the critical fields required for token functionality.
428
* Used when the strict validator fails, allowing the client to continue working even if
429
* the server adds/changes non-critical fields.
430
*/
431
const tokenEnvelopeCriticalValidator = vObj({
432
token: vRequired(vString()),
433
expires_at: vRequired(vNumber()),
434
refresh_in: vRequired(vNumber()),
435
});
436
437
/**
438
* Result of validating a token envelope with the two-tier validation strategy.
439
*/
440
export type TokenValidationResult =
441
| { valid: true; strategy: 'strict'; envelope: TokenEnvelope }
442
| { valid: true; strategy: 'fallback'; strictError: string; envelope: TokenEnvelope; fallbackError?: string }
443
| { valid: false; strategy: 'failed'; strictError: string; fallbackError: string };
444
445
/**
446
* Validates a token envelope using a two-tier strategy:
447
* 1. First tries strict validation against the full schema.
448
* 2. If that fails, falls back to validating only critical fields (token, expires_at, refresh_in).
449
*
450
* This allows the client to continue working even if the server changes non-critical fields,
451
* while providing telemetry data to track schema drift.
452
*/
453
export function validateTokenEnvelope(obj: unknown): TokenValidationResult {
454
const strictResult = tokenEnvelopeValidator.validate(obj);
455
if (strictResult.error === undefined) {
456
return { valid: true, strategy: 'strict', envelope: strictResult.content };
457
}
458
459
const strictError = strictResult.error.message;
460
461
const fallbackResult = tokenEnvelopeCriticalValidator.validate(obj);
462
if (fallbackResult.error === undefined) {
463
return {
464
valid: true,
465
strategy: 'fallback',
466
strictError,
467
// Use the full payload, not the validator result, to preserve all server fields
468
envelope: obj as TokenEnvelope
469
};
470
}
471
472
return {
473
valid: false,
474
strategy: 'failed',
475
strictError,
476
fallbackError: fallbackResult.error.message,
477
};
478
}
479
480
export function isTokenEnvelope(obj: unknown): obj is TokenEnvelope {
481
return validateTokenEnvelope(obj).valid;
482
}
483
484
export function isErrorEnvelope(obj: unknown): obj is ErrorEnvelope {
485
return errorEnvelopeValidator.validate(obj).error === undefined;
486
}
487
488
export function isStandardErrorEnvelope(obj: unknown): obj is StandardErrorEnvelope {
489
return standardErrorEnvelopeValidator.validate(obj).error === undefined;
490
}
491
492
//#endregion
493
494
495
/**
496
* Combined response type from the /copilot_internal/v2/token endpoint.
497
* Can be either a success (TokenEnvelope) or error (ErrorEnvelope) response.
498
*/
499
export type CopilotTokenResponse = TokenEnvelope | ErrorEnvelope | StandardErrorEnvelope;
500
501
/**
502
* A server response containing the user info for the copilot user from the /copilot_internal/user endpoint
503
*/
504
export interface CopilotUserInfo extends CopilotUserQuotaInfo {
505
access_type_sku: string;
506
analytics_tracking_id: string;
507
assigned_date: string;
508
can_signup_for_limited: boolean;
509
copilot_plan: string;
510
organization_login_list: string[];
511
organization_list: Array<{
512
login: string;
513
name: string | null;
514
}>;
515
codex_agent_enabled?: boolean;
516
}
517
518
/**
519
* The token envelope extended with additional metadata that is helpful to have.
520
* This includes information from both the token endpoint and the user info endpoint.
521
*/
522
export type ExtendedTokenInfo = TokenEnvelope & {
523
// Extended fields added by client
524
username: string;
525
isVscodeTeamMember: boolean;
526
} & Pick<CopilotUserInfo, 'copilot_plan' | 'quota_snapshots' | 'quota_reset_date' | 'codex_agent_enabled' | 'organization_login_list'>;
527
528
/**
529
* Creates a minimal ExtendedTokenInfo for testing purposes.
530
* All required TokenEnvelope fields are populated with sensible defaults.
531
*/
532
export function createTestExtendedTokenInfo(overrides?: Partial<ExtendedTokenInfo>): ExtendedTokenInfo {
533
return {
534
// Required token envelope fields
535
token: 'test-token',
536
expires_at: 0,
537
refresh_in: 0,
538
sku: 'free_limited_copilot',
539
individual: true,
540
// Feature flags
541
blackbird_clientside_indexing: false,
542
code_quote_enabled: false,
543
code_review_enabled: false,
544
codesearch: false,
545
copilotignore_enabled: false,
546
vsc_electron_fetcher_v2: false,
547
// Consent settings
548
public_suggestions: 'enabled',
549
telemetry: 'enabled',
550
// Extended fields
551
username: 'testuser',
552
isVscodeTeamMember: false,
553
copilot_plan: 'free',
554
organization_login_list: [],
555
// Apply overrides
556
...overrides,
557
};
558
}
559
560
/**
561
* Reasons for token retrieval failures.
562
*/
563
export type TokenErrorReason =
564
/** User doesn't have Copilot access or authorization failed. Includes detailed error_details from server with notification_id specifying the specific authorization issue. */
565
'NotAuthorized' |
566
/** Network request failed - no response received from the server (connection failed, endpoint unreachable, etc.). */
567
'RequestFailed' |
568
/** Server response could not be parsed as JSON (malformed or unexpected response format). */
569
'ParseFailed' |
570
/** User not authenticated with GitHub through VS Code. Only returned from VS Code integration layer, not from platform token minting. */
571
'GitHubLoginFailed' |
572
/** Server returned 401 Unauthorized HTTP status. */
573
'HTTP401' |
574
/** GitHub API rate limit exceeded (403 status with rate limit message). */
575
'RateLimited';
576
577
export const enum TokenErrorNotificationId {
578
NoCopilotAccess = 'no_copilot_access',
579
NotSignedUp = 'not_signed_up',
580
SubscriptionEnded = 'subscription_ended',
581
EnterPriseManagedUserAccount = 'enterprise_managed_user_account',
582
FeatureFlagBlocked = 'feature_flag_blocked',
583
SpammyUser = 'spammy_user',
584
BillingLocked = 'billing_locked',
585
TradeRestricted = 'trade_restricted',
586
TradeRestrictedCountry = 'trade_restricted_country',
587
CodespacesDemoInactive = 'codespaces_demo_inactive',
588
SnippyNotConfigured = 'snippy_not_configured',
589
ExpiredCoupon = 'expired_coupon',
590
RevokedCoupon = 'revoked_coupon',
591
GoHttpClient = 'go_http_client',
592
ProgrammaticTokenGeneration = 'programmatic_token_generation',
593
AccessRevoked = 'access_revoked',
594
ServerError = 'server_error'
595
}
596
597
/**
598
* Notification IDs that appear in user_notification on success responses.
599
*/
600
export type SuccessNotificationId =
601
| 'subscription_trial_ending'
602
| 'subscription_trial_ended'
603
| 'subscription_ending'
604
| 'free_over_limits'
605
| 'codespaces_demo_welcome'
606
| `copilot_seat_added_${number}`;
607
608
export type TokenError = {
609
reason: TokenErrorReason;
610
notification_id?: TokenErrorNotificationId | string;
611
message?: string;
612
/** URL for action button to help user resolve the error. */
613
url?: string;
614
/** Title for the action button. */
615
title?: string;
616
};
617
618
export type TokenInfoOrError = ({ kind: 'success' } & ExtendedTokenInfo) | ({ kind: 'failure' } & TokenError);
619
620