Path: blob/main/extensions/copilot/src/platform/authentication/common/copilotToken.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { CopilotUserQuotaInfo } from '../../chat/common/chatQuotaService';6import { vArray, vBoolean, vEnum, vNullable, vNumber, vObj, vRequired, vString } from '../../configuration/common/validator';78/**9* A function used to determine if the org list contains an internal organization10* @param orgList The list of organizations the user is a member of11* Whether or not it contains an internal org12*/13export function containsInternalOrg(orgList: string[]): boolean {14return containsGitHubOrg(orgList) || containsMicrosoftOrg(orgList);15}1617/**18* A function used to determine if the org list contains a GitHub organization19* @param orgList The list of organizations the user is a member of20* Whether or not it contains a GitHub org21*/22function containsGitHubOrg(orgList: string[]): boolean {23const GITHUB_ORGANIZATIONS = ['4535c7beffc844b46bb1ed4aa04d759a'];24// Check if the user is part of an allowed organization.25for (const org of orgList) {26if (GITHUB_ORGANIZATIONS.includes(org)) {27return true;28}29}30return false;31}3233/**34* A function used to determine if the org list contains a Microsoft organization35* @param orgList The list of organizations the user is a member of36* Whether or not it contains a Microsoft org37*/38function containsMicrosoftOrg(orgList: string[]): boolean {39const MICROSOFT_ORGANIZATIONS = ['a5db0bcaae94032fe715fb34a5e4bce2', '7184f66dfcee98cb5f08a1cb936d5225',40'1cb18ac6eedd49b43d74a1c5beb0b955', 'ea9395b9a9248c05ee6847cbd24355ed'];41// Check if the user is part of a Microsoft organization.42for (const org of orgList) {43if (MICROSOFT_ORGANIZATIONS.includes(org)) {44return true;45}46}47return false;48}4950/**51* A function used to determine if the org list contains a VS Code organization52* @param orgList The list of organizations the user is a member of53* Whether or not it contains a VS Code org54*/55export function containsVSCodeOrg(orgList: string[]): boolean {56const VSCODE_ORGANIZATIONS = ['551cca60ce19654d894e786220822482'];57// Check if the user is part of a VS Code organization.58for (const org of orgList) {59if (VSCODE_ORGANIZATIONS.includes(org)) {60return true;61}62}63return false;64}6566export class CopilotToken {67private readonly tokenMap: Map<string, string>;68constructor(private readonly _info: ExtendedTokenInfo) {69this.tokenMap = this.parseToken(_info.token);70}7172private parseToken(token: string): Map<string, string> {73const result = new Map<string, string>();74const firstPart = token?.split(':')[0];75const fields = firstPart?.split(';');76for (const field of fields) {77const [key, value] = field.split('=');78result.set(key, value);79}80return result;81}8283get token(): string {84return this._info.token;85}8687get sku(): CopilotSku | undefined {88return this._info.sku;89}9091/**92* Evaluates `has_cfi_access?` which is defined as `!has_cfb_access? && !has_cfe_access?`93* (cfb = copilot for business, cfe = copilot for enterprise).94* So it's also true for copilot free users.95*/96get isIndividual(): boolean {97return this._info.individual ?? false;98}99100get organizationList(): string[] {101return this._info.organization_list || [];102}103104/**105* Returns the list of organization logins that provide Copilot access to the user.106* These are the organizations through which the user has a Copilot subscription (Business/Enterprise).107*/108get organizationLoginList(): string[] {109return this._info.organization_login_list || [];110}111112get enterpriseList(): number[] {113return this._info.enterprise_list || [];114}115116get endpoints(): Endpoints | undefined {117return this._info.endpoints;118}119120get isInternal() {121return containsInternalOrg(this.organizationList);122}123124get isMicrosoftInternal(): boolean {125return containsMicrosoftOrg(this.organizationList);126}127128get isGitHubInternal(): boolean {129return containsGitHubOrg(this.organizationList);130}131132get isFreeUser(): boolean {133return this.sku === 'free_limited_copilot';134}135136get isNoAuthUser(): boolean {137return this.sku === 'no_auth_limited_copilot';138}139140get isChatQuotaExceeded(): boolean {141return this.isFreeUser && (this._info.limited_user_quotas?.chat ?? 1) <= 0;142}143144get isCompletionsQuotaExceeded(): boolean {145return this.isFreeUser && (this._info.limited_user_quotas?.completions ?? 1) <= 0;146}147148get codeQuoteEnabled(): boolean {149return this._info.code_quote_enabled ?? false;150}151152get isVscodeTeamMember(): boolean {153return this._info.isVscodeTeamMember || containsVSCodeOrg(this.organizationList);154}155156get codexAgentEnabled(): boolean {157return this._info.codex_agent_enabled ?? false;158}159160get copilotPlan(): 'free' | 'individual' | 'individual_pro' | 'business' | 'enterprise' {161if (this.isFreeUser) {162return 'free';163}164const plan = this._info.copilot_plan;165switch (plan) {166case 'individual':167case 'individual_pro':168case 'business':169case 'enterprise':170return plan;171default:172// Default to 'individual' for unexpected values173return 'individual';174}175}176177get quotaInfo() {178return { quota_snapshots: this._info.quota_snapshots, quota_reset_date: this._info.quota_reset_date };179}180181get username(): string {182return this._info.username;183}184185private _isTelemetryEnabled: boolean | undefined;186isTelemetryEnabled(): boolean {187if (this._isTelemetryEnabled === undefined) {188this._isTelemetryEnabled = this._info.telemetry === 'enabled';189}190return this._isTelemetryEnabled;191}192193private _isPublicSuggestionsEnabled: boolean | undefined;194isPublicSuggestionsEnabled(): boolean {195if (this._isPublicSuggestionsEnabled === undefined) {196this._isPublicSuggestionsEnabled = this._info.public_suggestions === 'enabled';197}198return this._isPublicSuggestionsEnabled;199}200201isCopilotIgnoreEnabled(): boolean {202return this._info.copilotignore_enabled ?? false;203}204205get isCopilotCodeReviewEnabled(): boolean {206return this._info.code_review_enabled ?? (this.getTokenValue('ccr') === '1');207}208209isEditorPreviewFeaturesEnabled(): boolean {210// Editor preview features are disabled if the flag is present and set to 0211return this.getTokenValue('editor_preview_features') !== '0';212}213214isMcpEnabled(): boolean {215// MCP is disabled if the flag is present and set to 0216return this.getTokenValue('mcp') !== '0';217}218219isClientBYOKEnabled(): boolean {220return this.getTokenValue('client_byok') === '1';221}222223getTokenValue(key: string): string | undefined {224return this.tokenMap.get(key);225}226227isExpandedClientSideIndexingEnabled(): boolean {228return this._info.blackbird_clientside_indexing === true;229}230231isFcv1(): boolean {232return this.tokenMap.get('fcv1') === '1';233}234235/**236* Is snippy in blocking mode237*/238isSn(): boolean {239return this.tokenMap.get('sn') === '1';240}241}242243/**244* Details of the user's telemetry consent status we get from the server during token retrieval.245*246* `unconfigured` is a transitional state for pre-GA that indicates the user is in the Technical Preview247* and needs to be asked about telemetry consent client-side. It can be removed post-GA as the server248* will never return it again.249*250* `enabled` indicates that they agreed to full telemetry.251*252* `disabled` indicates that they opted out of full telemetry so we can only send the core messages253* that users cannot opt-out of.254*255*/256export type UserTelemetryChoice = 'enabled' | 'disabled';257258/**259* A notification we get from the server during token retrieval. Needs to be presented to the user.260* Used for both success notifications (user_notification) and error notifications (error_details).261*/262export interface NotificationEnvelope {263message: string;264notification_id: TokenErrorNotificationId | string;265title: string;266url: string;267}268269//#region CopilotSku Types270271/**272* Well-known SKU values that are checked in source code.273* The actual SKU can be any string - these are just the ones we explicitly handle.274*/275export type WellKnownSku =276| 'free_limited_copilot'277| 'no_auth_limited_copilot';278279/**280* User's access type/SKU from the Copilot token endpoint.281* This is a string that can be any SKU value - use WellKnownSku for type-safe comparisons.282*/283export type CopilotSku = WellKnownSku | string;284285//#endregion286287//#region Endpoints288289export interface Endpoints {290api?: string;291'origin-tracker'?: string;292proxy?: string;293telemetry?: string;294}295296//#endregion297298/**299* A server response containing a Copilot token and metadata associated with it.300* This is the success response (HTTP 200) from the /copilot_internal/v2/token endpoint.301*/302export interface TokenEnvelope {303// Required fields304/** HMAC-signed token for Copilot proxy authentication. v2 format: fields:mac where fields are ';' separated key=value pairs. */305token: string;306/** Unix timestamp (seconds) when token expires. */307expires_at: number;308/** Seconds until client should request a new token. */309refresh_in: number;310/** User's access type/SKU. */311sku: CopilotSku;312/** Whether user has Copilot Individual access. */313individual: boolean;314315// Feature flags316/** Whether client-side indexing for Blackbird is enabled. */317blackbird_clientside_indexing: boolean;318/** Whether code quote/citation is enabled. */319code_quote_enabled: boolean;320/** Whether Copilot code review is enabled. */321code_review_enabled: boolean;322/** Whether code search is enabled. */323codesearch: boolean;324/** Whether content exclusion (.copilotignore) is enabled. */325copilotignore_enabled: boolean;326/** Whether VS Code electron fetcher v2 is enabled. */327vsc_electron_fetcher_v2: boolean;328329// Consent settings330/** 'enabled', 'disabled', or 'unconfigured' for public code suggestions. */331public_suggestions: 'enabled' | 'disabled' | 'unconfigured';332/** 'enabled' or 'disabled' for telemetry. */333telemetry: 'enabled' | 'disabled';334335// Optional fields336/** SKU-isolated endpoints. */337endpoints?: Endpoints;338/** Enterprise IDs if user has enterprise access. */339enterprise_list?: number[] | null;340/** Quota remaining for free/limited users. Null for non-free users. */341limited_user_quotas?: { chat: number; completions: number } | null;342/** Unix timestamp when quotas reset for free/limited users. Null for non-free users. */343limited_user_reset_date?: number | null;344/** Organization tracking IDs if user has org access. */345organization_list?: string[];346/** Notification to show in editor on successful token retrieval. */347user_notification?: NotificationEnvelope;348}349350/**351* The shape of an error response (HTTP 403) from the server when token retrieval fails.352*/353export interface ErrorEnvelope {354/** Whether user can sign up for Copilot Free. Null when not applicable. */355can_signup_for_limited?: boolean | null;356/** Detailed error information including notification_id. */357error_details: NotificationEnvelope;358/** Generic error message with TOS text. */359message: string;360/** Optional reason string. */361reason?: string;362}363364/**365* The shape of a standard error response from the server. Used for generic errors like rate limiting.366*/367export interface StandardErrorEnvelope {368message: string; // e.g., "API rate limit exceeded for user ID 12345. ..."369documentation_url: string; // "https://developer.github.com/rest/overview/rate-limits-for-the-rest-api"370status: string; // "403"371}372373//#region Validators374375const notificationEnvelopeValidator = vObj({376message: vRequired(vString()),377notification_id: vRequired(vString()),378title: vRequired(vString()),379url: vRequired(vString()),380});381382const errorEnvelopeValidator = vObj({383can_signup_for_limited: vNullable(vBoolean()),384error_details: vRequired(notificationEnvelopeValidator),385message: vRequired(vString()),386reason: vString(),387});388389const tokenEnvelopeValidator = vObj({390token: vRequired(vString()),391expires_at: vRequired(vNumber()),392refresh_in: vRequired(vNumber()),393sku: vString(),394individual: vBoolean(),395blackbird_clientside_indexing: vBoolean(),396code_quote_enabled: vBoolean(),397code_review_enabled: vBoolean(),398codesearch: vBoolean(),399copilotignore_enabled: vBoolean(),400vsc_electron_fetcher_v2: vBoolean(),401public_suggestions: vEnum('enabled', 'disabled', 'unconfigured'),402telemetry: vEnum('enabled', 'disabled'),403endpoints: vObj({404api: vString(),405'origin-tracker': vString(),406proxy: vString(),407telemetry: vString(),408}),409enterprise_list: vNullable(vArray(vNumber())),410limited_user_quotas: vNullable(vObj({411chat: vRequired(vNumber()),412completions: vRequired(vNumber()),413})),414limited_user_reset_date: vNullable(vNumber()),415organization_list: vArray(vString()),416user_notification: notificationEnvelopeValidator,417});418419const standardErrorEnvelopeValidator = vObj({420message: vRequired(vString()),421documentation_url: vRequired(vString()),422status: vRequired(vString()),423});424425/**426* Fallback validator that only checks the critical fields required for token functionality.427* Used when the strict validator fails, allowing the client to continue working even if428* the server adds/changes non-critical fields.429*/430const tokenEnvelopeCriticalValidator = vObj({431token: vRequired(vString()),432expires_at: vRequired(vNumber()),433refresh_in: vRequired(vNumber()),434});435436/**437* Result of validating a token envelope with the two-tier validation strategy.438*/439export type TokenValidationResult =440| { valid: true; strategy: 'strict'; envelope: TokenEnvelope }441| { valid: true; strategy: 'fallback'; strictError: string; envelope: TokenEnvelope; fallbackError?: string }442| { valid: false; strategy: 'failed'; strictError: string; fallbackError: string };443444/**445* Validates a token envelope using a two-tier strategy:446* 1. First tries strict validation against the full schema.447* 2. If that fails, falls back to validating only critical fields (token, expires_at, refresh_in).448*449* This allows the client to continue working even if the server changes non-critical fields,450* while providing telemetry data to track schema drift.451*/452export function validateTokenEnvelope(obj: unknown): TokenValidationResult {453const strictResult = tokenEnvelopeValidator.validate(obj);454if (strictResult.error === undefined) {455return { valid: true, strategy: 'strict', envelope: strictResult.content };456}457458const strictError = strictResult.error.message;459460const fallbackResult = tokenEnvelopeCriticalValidator.validate(obj);461if (fallbackResult.error === undefined) {462return {463valid: true,464strategy: 'fallback',465strictError,466// Use the full payload, not the validator result, to preserve all server fields467envelope: obj as TokenEnvelope468};469}470471return {472valid: false,473strategy: 'failed',474strictError,475fallbackError: fallbackResult.error.message,476};477}478479export function isTokenEnvelope(obj: unknown): obj is TokenEnvelope {480return validateTokenEnvelope(obj).valid;481}482483export function isErrorEnvelope(obj: unknown): obj is ErrorEnvelope {484return errorEnvelopeValidator.validate(obj).error === undefined;485}486487export function isStandardErrorEnvelope(obj: unknown): obj is StandardErrorEnvelope {488return standardErrorEnvelopeValidator.validate(obj).error === undefined;489}490491//#endregion492493494/**495* Combined response type from the /copilot_internal/v2/token endpoint.496* Can be either a success (TokenEnvelope) or error (ErrorEnvelope) response.497*/498export type CopilotTokenResponse = TokenEnvelope | ErrorEnvelope | StandardErrorEnvelope;499500/**501* A server response containing the user info for the copilot user from the /copilot_internal/user endpoint502*/503export interface CopilotUserInfo extends CopilotUserQuotaInfo {504access_type_sku: string;505analytics_tracking_id: string;506assigned_date: string;507can_signup_for_limited: boolean;508copilot_plan: string;509organization_login_list: string[];510organization_list: Array<{511login: string;512name: string | null;513}>;514codex_agent_enabled?: boolean;515}516517/**518* The token envelope extended with additional metadata that is helpful to have.519* This includes information from both the token endpoint and the user info endpoint.520*/521export type ExtendedTokenInfo = TokenEnvelope & {522// Extended fields added by client523username: string;524isVscodeTeamMember: boolean;525} & Pick<CopilotUserInfo, 'copilot_plan' | 'quota_snapshots' | 'quota_reset_date' | 'codex_agent_enabled' | 'organization_login_list'>;526527/**528* Creates a minimal ExtendedTokenInfo for testing purposes.529* All required TokenEnvelope fields are populated with sensible defaults.530*/531export function createTestExtendedTokenInfo(overrides?: Partial<ExtendedTokenInfo>): ExtendedTokenInfo {532return {533// Required token envelope fields534token: 'test-token',535expires_at: 0,536refresh_in: 0,537sku: 'free_limited_copilot',538individual: true,539// Feature flags540blackbird_clientside_indexing: false,541code_quote_enabled: false,542code_review_enabled: false,543codesearch: false,544copilotignore_enabled: false,545vsc_electron_fetcher_v2: false,546// Consent settings547public_suggestions: 'enabled',548telemetry: 'enabled',549// Extended fields550username: 'testuser',551isVscodeTeamMember: false,552copilot_plan: 'free',553organization_login_list: [],554// Apply overrides555...overrides,556};557}558559/**560* Reasons for token retrieval failures.561*/562export type TokenErrorReason =563/** User doesn't have Copilot access or authorization failed. Includes detailed error_details from server with notification_id specifying the specific authorization issue. */564'NotAuthorized' |565/** Network request failed - no response received from the server (connection failed, endpoint unreachable, etc.). */566'RequestFailed' |567/** Server response could not be parsed as JSON (malformed or unexpected response format). */568'ParseFailed' |569/** User not authenticated with GitHub through VS Code. Only returned from VS Code integration layer, not from platform token minting. */570'GitHubLoginFailed' |571/** Server returned 401 Unauthorized HTTP status. */572'HTTP401' |573/** GitHub API rate limit exceeded (403 status with rate limit message). */574'RateLimited';575576export const enum TokenErrorNotificationId {577NoCopilotAccess = 'no_copilot_access',578NotSignedUp = 'not_signed_up',579SubscriptionEnded = 'subscription_ended',580EnterPriseManagedUserAccount = 'enterprise_managed_user_account',581FeatureFlagBlocked = 'feature_flag_blocked',582SpammyUser = 'spammy_user',583BillingLocked = 'billing_locked',584TradeRestricted = 'trade_restricted',585TradeRestrictedCountry = 'trade_restricted_country',586CodespacesDemoInactive = 'codespaces_demo_inactive',587SnippyNotConfigured = 'snippy_not_configured',588ExpiredCoupon = 'expired_coupon',589RevokedCoupon = 'revoked_coupon',590GoHttpClient = 'go_http_client',591ProgrammaticTokenGeneration = 'programmatic_token_generation',592AccessRevoked = 'access_revoked',593ServerError = 'server_error'594}595596/**597* Notification IDs that appear in user_notification on success responses.598*/599export type SuccessNotificationId =600| 'subscription_trial_ending'601| 'subscription_trial_ended'602| 'subscription_ending'603| 'free_over_limits'604| 'codespaces_demo_welcome'605| `copilot_seat_added_${number}`;606607export type TokenError = {608reason: TokenErrorReason;609notification_id?: TokenErrorNotificationId | string;610message?: string;611/** URL for action button to help user resolve the error. */612url?: string;613/** Title for the action button. */614title?: string;615};616617export type TokenInfoOrError = ({ kind: 'success' } & ExtendedTokenInfo) | ({ kind: 'failure' } & TokenError);618619620