Path: blob/main/extensions/copilot/src/platform/authentication/node/copilotTokenManager.ts
13400 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 { RequestType } from '@vscode/copilot-api';6import { Emitter } from '../../../util/vs/base/common/event';7import { Disposable, toDisposable } from '../../../util/vs/base/common/lifecycle';8import { SyncDescriptor } from '../../../util/vs/platform/instantiation/common/descriptors';9import { IConfigurationService } from '../../configuration/common/configurationService';10import { ICAPIClientService } from '../../endpoint/common/capiClient';11import { IDomainService } from '../../endpoint/common/domainService';12import { IEnvService, isScenarioAutomation } from '../../env/common/envService';13import { BaseOctoKitService } from '../../github/common/githubService';14import { NullBaseOctoKitService } from '../../github/common/nullOctokitServiceImpl';15import { ILogService } from '../../log/common/logService';16import { FetchOptions, IFetcherService, Response, jsonVerboseError } from '../../networking/common/fetcherService';17import { ITelemetryService } from '../../telemetry/common/telemetry';18import { TelemetryData } from '../../telemetry/common/telemetryData';19import { CopilotToken, CopilotUserInfo, ErrorEnvelope, ExtendedTokenInfo, StandardErrorEnvelope, TokenEnvelope, TokenInfoOrError, TokenValidationResult, containsVSCodeOrg, createTestExtendedTokenInfo, isErrorEnvelope, isStandardErrorEnvelope, validateTokenEnvelope } from '../common/copilotToken';20import { CheckCopilotToken, ICopilotTokenManager, NotGitHubLoginFailed, nowSeconds } from '../common/copilotTokenManager';2122/**23* Result of fetching a Copilot token from the server.24* Includes HTTP status info and the validated response body.25*/26type FetchTokenResult = {27ok: boolean;28status: number;29statusText: string;30} & (31// success32| { body: TokenEnvelope; kind: 'token' }33// Copilot-specific error34| { body: ErrorEnvelope; kind: 'error-envelope' }35// Standard error - e.g., rate limiting36| { body: StandardErrorEnvelope; kind: 'error' }37// Parse failures (either from failed Fetches or invalid JSON)38| { body: undefined; kind: 'parse-failed'; parseError: string }39);4041export 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.`;4243export function createStaticGitHubTokenProvider(): (() => string) | undefined {44const pat = process.env.GITHUB_PAT;45const oauthToken = process.env.GITHUB_OAUTH_TOKEN;4647// In automation scenarios, NoAuth/BYOK-only scenarios are expected to not have any tokens set.48if (isScenarioAutomation && !pat && !oauthToken) {49return undefined;50}5152return () => {53if (pat) {54return pat;55}5657if (oauthToken) {58return oauthToken;59}6061throw new Error(tokenErrorString);62};63}6465export function getOrCreateTestingCopilotTokenManager(deviceId: string): SyncDescriptor<ICopilotTokenManager & CheckCopilotToken> {66if (process.env.VSCODE_COPILOT_CHAT_TOKEN) {67return new SyncDescriptor(StaticExtendedTokenInfoCopilotTokenManager, [process.env.VSCODE_COPILOT_CHAT_TOKEN]);68}6970if (process.env.GITHUB_OAUTH_TOKEN) {71return new SyncDescriptor(CopilotTokenManagerFromGitHubToken, [process.env.GITHUB_OAUTH_TOKEN, 'unknown']);72}7374if (process.env.GITHUB_PAT) {75return new SyncDescriptor(FixedCopilotTokenManager, [process.env.GITHUB_PAT]);76}7778// In automation scenarios, NoAuth/BYOK-only scenarios are expected to not have any tokens set.79if (isScenarioAutomation) {80return new SyncDescriptor(CopilotTokenManagerFromDeviceId, [deviceId]);81}8283throw new Error(tokenErrorString);84}8586//TODO: Move this to common87export abstract class BaseCopilotTokenManager extends Disposable implements ICopilotTokenManager {88declare readonly _serviceBrand: undefined;8990protected _isDisposed = false;9192//#region Events93private readonly _copilotTokenRefreshEmitter = this._register(new Emitter<void>());94readonly onDidCopilotTokenRefresh = this._copilotTokenRefreshEmitter.event;9596//#endregion97constructor(98protected readonly _baseOctokitservice: BaseOctoKitService,99protected readonly _logService: ILogService,100protected readonly _telemetryService: ITelemetryService,101protected readonly _domainService: IDomainService,102protected readonly _capiClientService: ICAPIClientService,103protected readonly _fetcherService: IFetcherService,104protected readonly _envService: IEnvService105) {106super();107this._register(toDisposable(() => this._isDisposed = true));108}109110//#region Property getters and setters111private _copilotToken: ExtendedTokenInfo | undefined;112get copilotToken(): ExtendedTokenInfo | undefined {113return this._copilotToken;114}115set copilotToken(token: ExtendedTokenInfo | undefined) {116if (token !== this._copilotToken) {117this._copilotToken = token;118this._copilotTokenRefreshEmitter.fire();119}120}121122//#endregion123//#region Abstract methods124abstract getCopilotToken(force?: boolean): Promise<CopilotToken>;125126//#endregion127//#region Public methods128resetCopilotToken(httpError?: number): void {129if (httpError !== undefined) {130this._telemetryService.sendGHTelemetryEvent('auth.reset_token_' + httpError);131}132this._logService.debug(`Resetting copilot token on HTTP error ${httpError || 'unknown'}`);133this.copilotToken = undefined;134}135136/**137* Fetches a Copilot token from the GitHub token.138* @param githubToken A GitHub token to mint a Copilot token from.139* @returns A Copilot token info or an error.140* @todo this should be not be public, but it is for now to allow testing.141*/142async authFromGitHubToken(githubToken: string, ghUsername: string): Promise<TokenInfoOrError & NotGitHubLoginFailed> {143return this.doAuthFromGitHubTokenOrDevDeviceId({ githubToken, ghUsername });144}145146/**147* Fetches a Copilot token from the devDeviceId.148* @param devDeviceId A device ID to mint a Copilot token from.149* @returns A Copilot token info or an error.150* @todo this should be not be public, but it is for now to allow testing.151*/152async authFromDevDeviceId(devDeviceId: string): Promise<TokenInfoOrError & NotGitHubLoginFailed> {153return this.doAuthFromGitHubTokenOrDevDeviceId({ devDeviceId });154}155156private async doAuthFromGitHubTokenOrDevDeviceId(157context: { githubToken: string; ghUsername: string } | { devDeviceId: string }158): Promise<TokenInfoOrError & NotGitHubLoginFailed> {159this._telemetryService.sendGHTelemetryEvent('auth.new_login');160161let result: FetchTokenResult;162let userInfo: CopilotUserInfo | undefined;163let ghUsername: string | undefined;164try {165if ('githubToken' in context) {166ghUsername = context.ghUsername;167[result, userInfo] = (await Promise.all([168this.fetchCopilotTokenFromGitHubToken(context.githubToken),169this.fetchCopilotUserInfo(context.githubToken)170]));171} else {172result = await this.fetchCopilotTokenFromDevDeviceId(context.devDeviceId);173}174} catch (e) {175this._logService.warn('Failed to get copilot token due to fetch throwing: ' + (e.message || String(e)));176return { kind: 'failure', reason: 'RequestFailed', message: e.message || String(e) };177}178179// Handle HTTP errors180if (!result.ok) {181this._logService.warn(`Failed to get copilot token due to status ${result.status} ${result.statusText}`);182const data = TelemetryData.createAndMarkAsIssued({183status: result.status.toString(),184status_text: result.statusText,185});186this._telemetryService.sendGHTelemetryErrorEvent('auth.invalid_token', data.properties, data.measurements);187// TODO: Look at telemetry to see if this even happens188// because looking at the backend code, 401s aren't expected here189if (result.status === 401) {190this._logService.warn('Failed to get copilot token due to 401 status');191this._telemetryService.sendGHTelemetryErrorEvent('auth.unknown_401');192return { kind: 'failure', reason: 'HTTP401' };193}194}195196// Copilot Errors197if (result.kind === 'error-envelope') {198this._logService.warn(`Failed to get copilot token due to: ${result.body.error_details.message}`);199this._telemetryService.sendGHTelemetryErrorEvent('auth.request_read_failed');200return { kind: 'failure', reason: 'NotAuthorized', ...result.body.error_details };201}202203// Standard Errors like rate limiting204if (result.kind === 'error') {205if (result.body.message?.startsWith('API rate limit exceeded')) {206this._logService.warn('Failed to get copilot token due to exceeding API rate limit');207this._telemetryService.sendGHTelemetryErrorEvent('auth.rate_limited');208return { kind: 'failure', reason: 'RateLimited' };209}210this._logService.warn(`Failed to get copilot token due to: ${result.body.message}`);211return { kind: 'failure', reason: 'NotAuthorized' };212}213214// Parse errors215if (result.kind === 'parse-failed') {216this._logService.warn(`Failed to get copilot token due to: ${result.parseError}`);217this._telemetryService.sendGHTelemetryErrorEvent('auth.request_read_failed');218return { kind: 'failure', reason: 'ParseFailed', message: result.parseError };219}220221// Success - we have a validated TokenEnvelope222const tokenInfo = result.body;223224const expires_at = tokenInfo.expires_at;225// some users have clocks adjusted ahead, expires_at will immediately be less than current clock time;226// adjust expires_at to the refresh time + a buffer to avoid expiring the token before the refresh can fire.227tokenInfo.expires_at = nowSeconds() + tokenInfo.refresh_in + 60; // extra buffer to allow refresh to happen successfully228229// extend the token envelope230const login = ghUsername ?? 'unknown';231const extendedInfo: ExtendedTokenInfo = {232...tokenInfo,233copilot_plan: userInfo?.copilot_plan ?? tokenInfo.sku ?? '',234quota_snapshots: userInfo?.quota_snapshots,235quota_reset_date: userInfo?.quota_reset_date,236codex_agent_enabled: userInfo?.codex_agent_enabled,237organization_login_list: userInfo?.organization_login_list ?? [],238username: login,239isVscodeTeamMember: containsVSCodeOrg(tokenInfo.organization_list ?? []),240};241const telemetryData = TelemetryData.createAndMarkAsIssued(242{},243{244adjusted_expires_at: tokenInfo.expires_at,245expires_at: expires_at, // track original expires_at246current_time: nowSeconds(),247}248);249250this._telemetryService.sendGHTelemetryEvent('auth.new_token', telemetryData.properties, telemetryData.measurements);251252return { kind: 'success', ...extendedInfo };253}254255//#endregion256257//#region Private methods258private async fetchCopilotTokenFromGitHubToken(githubToken: string): Promise<FetchTokenResult> {259const options: FetchOptions = {260callSite: 'copilot-token-github',261headers: {262Authorization: `token ${githubToken}`,263'X-GitHub-Api-Version': '2025-04-01'264},265retryFallbacks: true,266expectJSON: true,267};268const response = await this._capiClientService.makeRequest<Response>(options, { type: RequestType.CopilotToken });269return this.parseTokenResponse(response);270}271272private async fetchCopilotTokenFromDevDeviceId(devDeviceId: string): Promise<FetchTokenResult> {273const options: FetchOptions = {274callSite: 'copilot-token-device',275headers: {276'X-GitHub-Api-Version': '2025-04-01',277'Editor-Device-Id': `${devDeviceId}`278},279retryFallbacks: true,280expectJSON: true,281};282const response = await this._capiClientService.makeRequest<Response>(options, { type: RequestType.CopilotNLToken });283return this.parseTokenResponse(response);284}285286/**287* Parses and validates a token endpoint response.288* Returns a structured result with HTTP status and validated body.289*/290private async parseTokenResponse(response: Response): Promise<FetchTokenResult> {291const httpInfo = { ok: response.ok, status: response.status, statusText: response.statusText };292293let parsed: unknown;294try {295parsed = await jsonVerboseError(response);296} catch (err) {297return { ...httpInfo, body: undefined, kind: 'parse-failed', parseError: err.message || String(err) };298}299300const validationResult = validateTokenEnvelope(parsed);301if (validationResult.valid) {302this.sendTokenValidationTelemetry(validationResult);303return { ...httpInfo, body: validationResult.envelope, kind: 'token' };304}305if (isErrorEnvelope(parsed)) {306return { ...httpInfo, body: parsed, kind: 'error-envelope' };307}308if (isStandardErrorEnvelope(parsed)) {309return { ...httpInfo, body: parsed, kind: 'error' };310}311312// Token validation failed entirely - send telemetry for the failed case313this.sendTokenValidationTelemetry(validationResult);314return { ...httpInfo, body: undefined, kind: 'parse-failed', parseError: 'Response is not valid: ' + JSON.stringify(parsed) };315}316317/**318* Sends telemetry when token validation uses fallback strategy or fails entirely.319* This helps track server schema drift over time.320*/321private sendTokenValidationTelemetry(validationResult: TokenValidationResult): void {322if (validationResult.strategy === 'strict') {323// We were able to validate strictly as expected - no telemetry needed324return;325}326327/* __GDPR__328"copilotTokenFetching.validation" : {329"owner": "TylerLeonhardt",330"comment": "Track token envelope validation strategy to detect server schema drift.",331"strategy": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The validation strategy used: 'fallback' or 'failed'" },332"strictError": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The error from strict validation, if any" },333"fallbackError": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The error from fallback validation, if failed" }334}335*/336this._telemetryService.sendMSFTTelemetryEvent('copilotTokenFetching.validation', {337strategy: validationResult.strategy,338strictError: validationResult.strictError,339fallbackError: validationResult.fallbackError,340});341}342343private async fetchCopilotUserInfo(githubToken: string): Promise<CopilotUserInfo> {344const options: FetchOptions = {345callSite: 'copilot-token-user-info',346headers: {347Authorization: `token ${githubToken}`,348'X-GitHub-Api-Version': '2025-04-01',349},350retryFallbacks: true,351expectJSON: true,352};353const response = await this._capiClientService.makeRequest<Response>(options, { type: RequestType.CopilotUserInfo });354const data = await response.json();355return data;356}357}358359//#region FixedCopilotTokenManager360361/**362* A `CopilotTokenManager` that always returns the same token.363* Mostly only useful for short periods, e.g. tests or single completion requests,364* as these tokens typically expire after a few hours.365* @todo Move this to a test layer366*/367368export class FixedCopilotTokenManager extends BaseCopilotTokenManager implements CheckCopilotToken {369constructor(370private _completionsToken: string,371@ILogService logService: ILogService,372@ITelemetryService telemetryService: ITelemetryService,373@ICAPIClientService capiClientService: ICAPIClientService,374@IDomainService domainService: IDomainService,375@IFetcherService fetcherService: IFetcherService,376@IEnvService envService: IEnvService377) {378super(new NullBaseOctoKitService(capiClientService, fetcherService, logService, telemetryService), logService, telemetryService, domainService, capiClientService, fetcherService, envService);379this.copilotToken = createTestExtendedTokenInfo({ token: _completionsToken, username: 'fixedTokenManager', copilot_plan: 'unknown' });380}381382set completionsToken(token: string) {383this._completionsToken = token;384this.copilotToken = createTestExtendedTokenInfo({ token, username: 'fixedTokenManager', copilot_plan: 'unknown' });385}386get completionsToken(): string {387return this._completionsToken;388}389390async getCopilotToken(): Promise<CopilotToken> {391return new CopilotToken(this.copilotToken!);392}393394async checkCopilotToken(): Promise<{ status: 'OK' }> {395// assume it's valid396return { status: 'OK' };397}398}399400//#endregion401402//#region StaticExtendedTokenInfoCopilotTokenManager403404/**405* Use the `StaticExtendedTokenInfoCopilotTokenManager` when you have a base64, JSON-encoded `ExtendedTokenInfo`406* in an automation scenario.407*/408export class StaticExtendedTokenInfoCopilotTokenManager extends BaseCopilotTokenManager implements CheckCopilotToken {409private readonly _initialToken: ExtendedTokenInfo;410411constructor(412serializedToken: string,413@ILogService logService: ILogService,414@ITelemetryService telemetryService: ITelemetryService,415@ICAPIClientService capiClientService: ICAPIClientService,416@IDomainService domainService: IDomainService,417@IFetcherService fetcherService: IFetcherService,418@IEnvService envService: IEnvService419) {420super(new NullBaseOctoKitService(capiClientService, fetcherService, logService, telemetryService), logService, telemetryService, domainService, capiClientService, fetcherService, envService);421const data = Buffer.from(serializedToken, 'base64').toString('utf8');422this._initialToken = JSON.parse(data);423}424425override async getCopilotToken(): Promise<CopilotToken> {426if (!this.copilotToken) {427this.copilotToken = { ...this._initialToken };428}429430return new CopilotToken(this._initialToken);431}432433async checkCopilotToken(): Promise<{ status: 'OK' }> {434return { status: 'OK' };435}436}437//#endregion438439//#region RefreshableCopilotTokenManager440441/**442* Generic token manager that handles token caching and refresh logic.443* Takes an authentication function to fetch new tokens.444*/445export abstract class RefreshableCopilotTokenManager extends BaseCopilotTokenManager implements CheckCopilotToken {446protected abstract authenticateAndGetToken(): Promise<TokenInfoOrError & NotGitHubLoginFailed>;447448async getCopilotToken(force?: boolean): Promise<CopilotToken> {449if (!this.copilotToken || this.copilotToken.expires_at < nowSeconds() + (60 * 5 /* 5min */) || force) {450const tokenResult = await this.authenticateAndGetToken();451if (tokenResult.kind === 'failure') {452throw Error(453`Failed to get copilot token: ${tokenResult.reason.toString()} ${tokenResult.message ?? ''}`454);455}456this.copilotToken = { ...tokenResult };457}458return new CopilotToken(this.copilotToken);459}460461async checkCopilotToken() {462if (!this.copilotToken || this.copilotToken.expires_at < nowSeconds()) {463const tokenResult = await this.authenticateAndGetToken();464if (tokenResult.kind === 'failure') {465return tokenResult;466}467this.copilotToken = { ...tokenResult };468}469const result: { status: 'OK' } = {470status: 'OK',471};472return result;473}474}475476//#endregion477478//#region CopilotTokenManagerFromDeviceId479480export class CopilotTokenManagerFromDeviceId extends RefreshableCopilotTokenManager {481482constructor(483private readonly deviceId: string,484@ILogService logService: ILogService,485@ITelemetryService telemetryService: ITelemetryService,486@IDomainService domainService: IDomainService,487@ICAPIClientService capiClientService: ICAPIClientService,488@IFetcherService fetcherService: IFetcherService,489@IEnvService envService: IEnvService,490@IConfigurationService protected readonly configurationService: IConfigurationService491) {492super(new NullBaseOctoKitService(capiClientService, fetcherService, logService, telemetryService), logService, telemetryService, domainService, capiClientService, fetcherService, envService);493}494495protected async authenticateAndGetToken(): Promise<TokenInfoOrError & NotGitHubLoginFailed> {496return this.authFromDevDeviceId(this.deviceId);497}498}499500//#endregion501502//#region CopilotTokenManagerFromGitHubToken503504/**505* Given a GitHub token, return a Copilot token, refreshing it as needed.506* The caller that initializes the object is responsible for checking telemetry consent before507* using the object.508*/509export class CopilotTokenManagerFromGitHubToken extends RefreshableCopilotTokenManager {510511constructor(512private readonly githubToken: string,513private readonly githubUsername: string,514@ILogService logService: ILogService,515@ITelemetryService telemetryService: ITelemetryService,516@IDomainService domainService: IDomainService,517@ICAPIClientService capiClientService: ICAPIClientService,518@IFetcherService fetcherService: IFetcherService,519@IEnvService envService: IEnvService,520@IConfigurationService protected readonly configurationService: IConfigurationService521) {522super(new NullBaseOctoKitService(capiClientService, fetcherService, logService, telemetryService), logService, telemetryService, domainService, capiClientService, fetcherService, envService);523}524525protected async authenticateAndGetToken(): Promise<TokenInfoOrError & NotGitHubLoginFailed> {526return this.authFromGitHubToken(this.githubToken, this.githubUsername);527}528}529530531