Path: blob/main/extensions/copilot/src/platform/authentication/common/authentication.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*--------------------------------------------------------------------------------------------*/4import type { AuthenticationGetSessionOptions, AuthenticationGetSessionPresentationOptions, AuthenticationSession } from 'vscode';5import { createServiceIdentifier } from '../../../util/common/services';67/**8* A stricter version of {@link AuthenticationGetSessionPresentationOptions} that requires9* a `detail` message explaining why authentication is needed. This forces callers to provide10* meaningful context to the user instead of passing a bare `true` or `{}`.11*/12export type StrictAuthenticationPresentationOptions = AuthenticationGetSessionPresentationOptions & { detail: string };13import { Emitter, Event } from '../../../util/vs/base/common/event';14import { Disposable } from '../../../util/vs/base/common/lifecycle';15import { derived } from '../../../util/vs/base/common/observableInternal';16import { AuthPermissionMode, AuthProviderId, ConfigKey, IConfigurationService } from '../../configuration/common/configurationService';17import { ILogService } from '../../log/common/logService';18import { CopilotToken } from './copilotToken';19import { ICopilotTokenManager } from './copilotTokenManager';20import { ICopilotTokenStore } from './copilotTokenStore';2122// Minimum set of scopes needed for Copilot to work23export const GITHUB_SCOPE_USER_EMAIL = ['user:email'];2425// Old list of scopes still used for backwards compatibility26export const GITHUB_SCOPE_READ_USER = ['read:user'];2728// The same scopes that GitHub Pull Request, GitHub Repositories, and others use29export const GITHUB_SCOPE_ALIGNED = ['read:user', 'user:email', 'repo', 'workflow'];3031export class MinimalModeError extends Error {32constructor() {33super('The authentication service is in minimal mode.');34this.name = 'MinimalModeError';35}36}3738export const IAuthenticationService = createServiceIdentifier<IAuthenticationService>('IAuthenticationService');39export interface IAuthenticationService {4041readonly _serviceBrand: undefined;4243/**44* Whether the authentication service is in minimal mode. If true, the authentication service will not attempt to45* fetch the permissive token. This means that:46* * {@link getGitHubSession} interactive flows with 'permissive' kind will always throw an error47* * {@link getGitHubSession} silent flows with 'permissive' kind and {@link permissiveGitHubSession} will always return undefined48*/49readonly isMinimalMode: boolean;5051/**52* Event emitter that will fire an event every time the authentication status changes. This is used for example to detect when the user53* logs out of GitHub or when they log in with a more permissive token.54*55* @note For best practice of handling of the user's authentication state, you should react to this event.56*/57readonly onDidAuthenticationChange: Event<void>;5859/**60* @deprecated Use {@link onDidAuthenticationChange} instead. This event fires when the access token changes and not the copilot token.61*/62readonly onDidAccessTokenChange: Event<void>;6364/**65* Checks if there is currently any session available in the cache. Does not make any network requests and does not66* call out to the underlying authentication provider.67*68* @note See {@link getAnyGitHubToken} for more information and for an async version by calling {@link getGitHubSession} with kind 'any' and `{ silent: true }`.69* @note For best practice of handling of the user's authentication state, you should react to {@link onDidAuthenticationChange}.70* @note This token will have at least the `user:email` scope to be able to access the minimum Copilot API.71*/72readonly anyGitHubSession: AuthenticationSession | undefined;7374/**75* Checks if there is currently a permissive session available in the cache. Does not make any network requests and does not76* call out to the underlying authentication provider.77*78* @note See {@link getPermissiveGitHubToken} for more information and for an async version by calling {@link getGitHubSession} with kind 'permissive' and `{ silent: true }`.79* @note For best practice of handling of the user's authentication state, you should react to {@link onDidAuthenticationChange}.80* @returns undefined if no auth session is available or Minimal Mode is enabled. Otherwise, returns an auth session with the `repo` scope.81*/82readonly permissiveGitHubSession: AuthenticationSession | undefined;8384/**85* Gets a GitHub session capable of calling GitHub APIs.86* @param kind - The kind of session that you need. **Your choice here should be thoughtful.**87* - 'permissive': You need a session that can access the user's private repositories or needs write access.88* - 'any': You only need a session that can access public information about the user.89* @param options - Options for getting the session.90* @returns Promise<AuthenticationSession> - The requested authentication session.91* @throws MinimalModeError - If kind is 'permissive' and the authentication service is in minimal mode.92* @throws Error - If no session is acquired (user cancels).93*/94getGitHubSession(kind: 'permissive' | 'any', options: AuthenticationGetSessionOptions & { createIfNone: StrictAuthenticationPresentationOptions }): Promise<AuthenticationSession>;9596/**97* Gets a GitHub session capable of calling GitHub APIs.98* @param kind - The kind of session that you need. **Your choice here should be thoughtful.**99* - 'permissive': You need a session that can access the user's private repositories or needs write access.100* - 'any': You only need a session that can access public information about the user.101* @param options - Options for getting the session.102* @returns Promise<AuthenticationSession> - The requested authentication session.103* @throws MinimalModeError - If kind is 'permissive' and the authentication service is in minimal mode.104* @throws Error - If no session is acquired (user cancels).105*/106getGitHubSession(kind: 'permissive' | 'any', options: AuthenticationGetSessionOptions & { forceNewSession: StrictAuthenticationPresentationOptions }): Promise<AuthenticationSession>;107108/**109* Gets a GitHub session capable of calling GitHub APIs.110* @param kind - The kind of session that you need. **Your choice here should be thoughtful.**111* - 'permissive': You need a session that can access the user's private repositories or needs write access.112* - 'any': You only need a session that can access public information about the user.113* @param options - Options for getting the session.114* @returns Promise<AuthenticationSession> - The requested authentication session. OR115* @returns Promise<undefined> - If no session is available or kind is 'permissive' and the authentication service is in minimal mode.116* @see {@link isMinimalMode} for more information about minimal mode.117*/118getGitHubSession(kind: 'permissive' | 'any', options: Omit<AuthenticationGetSessionOptions, 'createIfNone' | 'forceNewSession'>): Promise<AuthenticationSession | undefined>;119120/**121* Checks if there is currently a Copilot token available in the cache. Does not make any network requests.122* See {@link getCopilotToken} for more information and for an async version.123*124* @note we omit token here because it is possibly expired. If you need it, use {@link getCopilotToken} instead as it includes a refresh mechanism.125* @note For best practice of handling of the user's authentication state, you should react to {@link onDidAuthenticationChange}.126*/127readonly copilotToken: Omit<CopilotToken, 'token'> | undefined;128129130/**131* Return the token needed to authenticate with the speculative decoding endpoint.132* This token is public as it is set via a request to the ChatMLFetcher and reset either via expiration or a 403 response from the SD endpoint.133* @note There is no guarantee this is a valid token and it can still reject due to 403 with the SD endpoint134*/135speculativeDecodingEndpointToken: string | undefined;136137/**138* Return a currently valid Copilot token, retrieving a fresh one if139* necessary.140*141* @param force will force a refresh of the token, even if not expired142* @returns a Copilot token or throws an error if none is found.143* @note For best practice of handling of the user's authentication state, you should react to {@link onDidAuthenticationChange}.144*/145getCopilotToken(force?: boolean): Promise<CopilotToken>;146147/**148* Drop the current Copilot token as we received an HTTP error while trying149* to use it that indicates it's no longer valid.150*/151resetCopilotToken(httpError?: number): void;152153/**154* Fired when the authentication state changes for ado.155*/156readonly onDidAdoAuthenticationChange: Event<void>;157158/**159* Returns a valid Azure DevOps session for the user160*/161getAdoAccessTokenBase64(options?: AuthenticationGetSessionOptions): Promise<string | undefined>;162}163164export abstract class BaseAuthenticationService extends Disposable implements IAuthenticationService {165declare readonly _serviceBrand: undefined;166167private readonly _onDidAuthenticationChange = this._register(new Emitter<void>());168readonly onDidAuthenticationChange: Event<void> = this._onDidAuthenticationChange.event;169170protected fireAuthenticationChange(source: string): void {171const hasSession = !!this.copilotToken;172this._logService.info(`AuthenticationService: firing onDidAuthenticationChange from ${source}. Has token: ${hasSession}`);173this._onDidAuthenticationChange.fire();174}175176protected readonly _onDidAccessTokenChange = this._register(new Emitter<void>());177readonly onDidAccessTokenChange: Event<void> = this._onDidAccessTokenChange.event;178179protected readonly _onDidAdoAuthenticationChange = this._register(new Emitter<void>());180readonly onDidAdoAuthenticationChange: Event<void> = this._onDidAdoAuthenticationChange.event;181182constructor(183@ILogService protected readonly _logService: ILogService,184@ICopilotTokenStore protected readonly _tokenStore: ICopilotTokenStore,185@ICopilotTokenManager private readonly _tokenManager: ICopilotTokenManager,186@IConfigurationService protected readonly _configurationService: IConfigurationService,187) {188super();189this._register(_tokenManager.onDidCopilotTokenRefresh(() => {190this._logService.debug('Handling CopilotToken refresh.');191void this._handleAuthChangeEvent();192}));193}194195//#region isMinimalMode196197protected _isMinimalMode = derived(r => this._configurationService.getConfigObservable(ConfigKey.Shared.AuthPermissions).read(r) === AuthPermissionMode.Minimal);198get isMinimalMode(): boolean {199return this._isMinimalMode.get();200}201202//#endregion203204//#region Any GitHub Token205206protected _anyGitHubSession: AuthenticationSession | undefined;207get anyGitHubSession(): AuthenticationSession | undefined {208return this._anyGitHubSession;209}210211//#endregion212213//#region Permissive GitHub Token214215protected _permissiveGitHubSession: AuthenticationSession | undefined;216get permissiveGitHubSession(): AuthenticationSession | undefined {217return this._permissiveGitHubSession;218}219220//#endregion221222//#region GitHub Session223224abstract getGitHubSession(kind: 'permissive' | 'any', options: AuthenticationGetSessionOptions & { createIfNone: StrictAuthenticationPresentationOptions }): Promise<AuthenticationSession>;225abstract getGitHubSession(kind: 'permissive' | 'any', options: AuthenticationGetSessionOptions & { forceNewSession: StrictAuthenticationPresentationOptions }): Promise<AuthenticationSession>;226abstract getGitHubSession(kind: 'permissive' | 'any', options: Omit<AuthenticationGetSessionOptions, 'createIfNone' | 'forceNewSession'>): Promise<AuthenticationSession | undefined>;227228//#endregion229230//#region Ado231232protected _anyAdoSession: AuthenticationSession | undefined;233get anyAdoSession(): AuthenticationSession | undefined {234return this._anyAdoSession;235}236protected abstract getAnyAdoSession(options?: AuthenticationGetSessionOptions): Promise<AuthenticationSession | undefined>;237238//#endregion239240//#region Copilot Token241242private _copilotTokenError: Error | undefined;243get copilotToken(): CopilotToken | undefined {244return this._tokenStore.copilotToken;245}246async getCopilotToken(force?: boolean): Promise<CopilotToken> {247try {248const token = await this._tokenManager.getCopilotToken(force);249this._tokenStore.copilotToken = token;250this._copilotTokenError = undefined;251return token;252} catch (afterError) {253this._tokenStore.copilotToken = undefined;254const beforeError = this._copilotTokenError;255this._copilotTokenError = afterError;256// This handles the case where the user still can't get a Copilot Token,257// but the error has change. I.e. They go from being not signed in (no copilot token can be minted)258// to an account that doesn't have a valid subscription (no copilot token can be minted).259// NOTE: if either error is undefined, this event should be fired elsewhere already.260if (beforeError && afterError && beforeError.message !== afterError.message) {261this.fireAuthenticationChange('getCopilotToken error change');262}263throw afterError;264}265}266267resetCopilotToken(httpError?: number): void {268this._tokenStore.copilotToken = undefined;269this._tokenManager.resetCopilotToken(httpError);270}271272//#endregion273274// #region Speculative decoding endpoint token275public speculativeDecodingEndpointToken: string | undefined;276// #endregion277278//#region ADO Token279abstract getAdoAccessTokenBase64(options?: AuthenticationGetSessionOptions): Promise<string | undefined>;280//#endregion281282protected async _handleAuthChangeEvent(): Promise<void> {283const anyGitHubSessionBefore = this._anyGitHubSession;284const permissiveGitHubSessionBefore = this._permissiveGitHubSession;285const anyAdoSessionBefore = this._anyAdoSession;286const copilotTokenBefore = this._tokenStore.copilotToken;287const copilotTokenErrorBefore = this._copilotTokenError;288289// Update caches290const resolved = await Promise.allSettled([291this.getGitHubSession('any', { silent: true }),292this.getGitHubSession('permissive', { silent: true }),293this.getAnyAdoSession({ silent: true }),294]);295for (const res of resolved) {296if (res.status === 'rejected') {297this._logService.error(`Error getting a session: ${res.reason}`);298}299}300301if (302anyGitHubSessionBefore?.accessToken !== this._anyGitHubSession?.accessToken ||303permissiveGitHubSessionBefore?.accessToken !== this._permissiveGitHubSession?.accessToken304) {305this._onDidAccessTokenChange.fire();306this._logService.debug('Auth state changed, minting a new CopilotToken...');307// The auth state has changed, so mint a new Copilot token308try {309await this.getCopilotToken(true);310} catch (e) {311// Ignore errors312}313this._logService.debug('Minted a new CopilotToken.');314return;315}316317if (anyAdoSessionBefore?.accessToken !== this._anyAdoSession?.accessToken) {318this._logService.debug(`Ado auth state changed, firing event. Had token before: ${!!anyAdoSessionBefore?.accessToken}. Has token now: ${!!this._anyAdoSession?.accessToken}.`);319this._onDidAdoAuthenticationChange.fire();320}321322// Auth state hasn't changed, but the Copilot token might have323try {324await this.getCopilotToken();325} catch (e) {326// Ignore errors327}328329if (copilotTokenBefore?.token !== this._tokenStore.copilotToken?.token ||330// React to errors changing too (i.e. I go from zero session to a session that doesn't have Copilot access)331copilotTokenErrorBefore?.message !== this._copilotTokenError?.message332) {333this._logService.debug('CopilotToken state changed, firing event.');334this.fireAuthenticationChange('handleAuthChangeEvent');335}336this._logService.debug('Finished handling auth change event.');337}338}339340export function authProviderId(configurationService: IConfigurationService): AuthProviderId {341return (342configurationService.getConfig(ConfigKey.Shared.AuthProvider) === AuthProviderId.GitHubEnterprise343? AuthProviderId.GitHubEnterprise344: AuthProviderId.GitHub345);346}347348349