Path: blob/main/src/vs/workbench/api/common/extHostAuthentication.ts
5222 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 type * as vscode from 'vscode';6import * as nls from '../../../nls.js';7import { Emitter, Event } from '../../../base/common/event.js';8import { MainContext, MainThreadAuthenticationShape, ExtHostAuthenticationShape } from './extHost.protocol.js';9import { Disposable, ProgressLocation } from './extHostTypes.js';10import { IExtensionDescription, ExtensionIdentifier } from '../../../platform/extensions/common/extensions.js';11import { INTERNAL_AUTH_PROVIDER_PREFIX, isAuthenticationWwwAuthenticateRequest } from '../../services/authentication/common/authentication.js';12import { createDecorator } from '../../../platform/instantiation/common/instantiation.js';13import { IExtHostRpcService } from './extHostRpcService.js';14import { URI, UriComponents } from '../../../base/common/uri.js';15import { AuthorizationErrorType, fetchDynamicRegistration, getClaimsFromJWT, IAuthorizationJWTClaims, IAuthorizationProtectedResourceMetadata, IAuthorizationServerMetadata, IAuthorizationTokenResponse, isAuthorizationErrorResponse, isAuthorizationTokenResponse } from '../../../base/common/oauth.js';16import { IExtHostWindow } from './extHostWindow.js';17import { IExtHostInitDataService } from './extHostInitDataService.js';18import { ILogger, ILoggerService, ILogService } from '../../../platform/log/common/log.js';19import { autorun, derivedOpts, IObservable, ISettableObservable, observableValue } from '../../../base/common/observable.js';20import { stringHash } from '../../../base/common/hash.js';21import { DisposableStore, IDisposable } from '../../../base/common/lifecycle.js';22import { IExtHostUrlsService } from './extHostUrls.js';23import { encodeBase64, VSBuffer } from '../../../base/common/buffer.js';24import { equals as arraysEqual } from '../../../base/common/arrays.js';25import { IExtHostProgress } from './extHostProgress.js';26import { IProgressStep } from '../../../platform/progress/common/progress.js';27import { CancellationError, isCancellationError } from '../../../base/common/errors.js';28import { raceCancellationError, SequencerByKey } from '../../../base/common/async.js';2930export interface IExtHostAuthentication extends ExtHostAuthentication { }31export const IExtHostAuthentication = createDecorator<IExtHostAuthentication>('IExtHostAuthentication');3233interface ProviderWithMetadata {34label: string;35provider: vscode.AuthenticationProvider;36disposable?: vscode.Disposable;37options: vscode.AuthenticationProviderOptions;38}3940export class ExtHostAuthentication implements ExtHostAuthenticationShape {4142declare _serviceBrand: undefined;4344protected readonly _dynamicAuthProviderCtor = DynamicAuthProvider;4546private _proxy: MainThreadAuthenticationShape;47private _authenticationProviders: Map<string, ProviderWithMetadata> = new Map<string, ProviderWithMetadata>();48private _providerOperations = new SequencerByKey<string>();4950private _onDidChangeSessions = new Emitter<vscode.AuthenticationSessionsChangeEvent & { extensionIdFilter?: string[] }>();51private _getSessionTaskSingler = new TaskSingler<vscode.AuthenticationSession | undefined>();5253private _onDidDynamicAuthProviderTokensChange = new Emitter<{ authProviderId: string; clientId: string; tokens: IAuthorizationToken[] }>();5455constructor(56@IExtHostRpcService extHostRpc: IExtHostRpcService,57@IExtHostInitDataService private readonly _initData: IExtHostInitDataService,58@IExtHostWindow private readonly _extHostWindow: IExtHostWindow,59@IExtHostUrlsService private readonly _extHostUrls: IExtHostUrlsService,60@IExtHostProgress private readonly _extHostProgress: IExtHostProgress,61@ILoggerService private readonly _extHostLoggerService: ILoggerService,62@ILogService private readonly _logService: ILogService,63) {64this._proxy = extHostRpc.getProxy(MainContext.MainThreadAuthentication);65}6667/**68* This sets up an event that will fire when the auth sessions change with a built-in filter for the extensionId69* if a session change only affects a specific extension.70* @param extensionId The extension that is interested in the event.71* @returns An event with a built-in filter for the extensionId72*/73getExtensionScopedSessionsEvent(extensionId: string): Event<vscode.AuthenticationSessionsChangeEvent> {74const normalizedExtensionId = extensionId.toLowerCase();75return Event.chain(this._onDidChangeSessions.event, ($) => $76.filter(e => !e.extensionIdFilter || e.extensionIdFilter.includes(normalizedExtensionId))77.map(e => ({ provider: e.provider }))78);79}8081async getSession(requestingExtension: IExtensionDescription, providerId: string, scopesOrRequest: readonly string[] | vscode.AuthenticationWwwAuthenticateRequest, options: vscode.AuthenticationGetSessionOptions & ({ createIfNone: true } | { forceNewSession: true } | { forceNewSession: vscode.AuthenticationForceNewSessionOptions })): Promise<vscode.AuthenticationSession>;82async getSession(requestingExtension: IExtensionDescription, providerId: string, scopesOrRequest: readonly string[] | vscode.AuthenticationWwwAuthenticateRequest, options: vscode.AuthenticationGetSessionOptions & { forceNewSession: true }): Promise<vscode.AuthenticationSession>;83async getSession(requestingExtension: IExtensionDescription, providerId: string, scopesOrRequest: readonly string[] | vscode.AuthenticationWwwAuthenticateRequest, options: vscode.AuthenticationGetSessionOptions & { forceNewSession: vscode.AuthenticationForceNewSessionOptions }): Promise<vscode.AuthenticationSession>;84async getSession(requestingExtension: IExtensionDescription, providerId: string, scopesOrRequest: readonly string[] | vscode.AuthenticationWwwAuthenticateRequest, options: vscode.AuthenticationGetSessionOptions): Promise<vscode.AuthenticationSession | undefined>;85async getSession(requestingExtension: IExtensionDescription, providerId: string, scopesOrRequest: readonly string[] | vscode.AuthenticationWwwAuthenticateRequest, options: vscode.AuthenticationGetSessionOptions = {}): Promise<vscode.AuthenticationSession | undefined> {86const extensionId = ExtensionIdentifier.toKey(requestingExtension.identifier);87const keys: (keyof vscode.AuthenticationGetSessionOptions)[] = Object.keys(options) as (keyof vscode.AuthenticationGetSessionOptions)[];88// TODO: pull this out into a utility function somewhere89const optionsStr = keys90.map(key => {91switch (key) {92case 'account':93return `${key}:${options.account?.id}`;94case 'createIfNone':95case 'forceNewSession': {96const value = typeof options[key] === 'boolean'97? `${options[key]}`98: `'${options[key]?.detail}/${options[key]?.learnMore?.toString()}'`;99return `${key}:${value}`;100}101case 'authorizationServer':102return `${key}:${options.authorizationServer?.toString(true)}`;103default:104return `${key}:${!!options[key]}`;105}106})107.sort()108.join(', ');109110let singlerKey: string;111if (isAuthenticationWwwAuthenticateRequest(scopesOrRequest)) {112const challenge = scopesOrRequest as vscode.AuthenticationWwwAuthenticateRequest;113const challengeStr = challenge.wwwAuthenticate;114const scopesStr = challenge.fallbackScopes ? [...challenge.fallbackScopes].sort().join(' ') : '';115singlerKey = `${extensionId} ${providerId} challenge:${challengeStr} ${scopesStr} ${optionsStr}`;116} else {117const sortedScopes = [...scopesOrRequest].sort().join(' ');118singlerKey = `${extensionId} ${providerId} ${sortedScopes} ${optionsStr}`;119}120121return await this._getSessionTaskSingler.getOrCreate(singlerKey, async () => {122await this._proxy.$ensureProvider(providerId);123const extensionName = requestingExtension.displayName || requestingExtension.name;124return this._proxy.$getSession(providerId, scopesOrRequest, extensionId, extensionName, options);125});126}127128async getAccounts(providerId: string) {129await this._proxy.$ensureProvider(providerId);130return await this._proxy.$getAccounts(providerId);131}132133registerAuthenticationProvider(id: string, label: string, provider: vscode.AuthenticationProvider, options?: vscode.AuthenticationProviderOptions): vscode.Disposable {134// register135void this._providerOperations.queue(id, async () => {136// This use to be synchronous, but that wasn't an accurate representation because the main thread137// may have unregistered the provider in the meantime. I don't see how this could really be done138// synchronously, so we just say first one wins.139if (this._authenticationProviders.get(id)) {140this._logService.error(`An authentication provider with id '${id}' is already registered. The existing provider will not be replaced.`);141return;142}143const listener = provider.onDidChangeSessions(e => this._proxy.$sendDidChangeSessions(id, e));144this._authenticationProviders.set(id, { label, provider, disposable: listener, options: options ?? { supportsMultipleAccounts: false } });145await this._proxy.$registerAuthenticationProvider({146id,147label,148supportsMultipleAccounts: options?.supportsMultipleAccounts ?? false,149supportedAuthorizationServers: options?.supportedAuthorizationServers,150supportsChallenges: options?.supportsChallenges151});152});153154// unregister155return new Disposable(() => {156void this._providerOperations.queue(id, async () => {157const providerData = this._authenticationProviders.get(id);158if (providerData) {159providerData.disposable?.dispose();160this._authenticationProviders.delete(id);161await this._proxy.$unregisterAuthenticationProvider(id);162}163});164});165}166167$createSession(providerId: string, scopes: string[], options: vscode.AuthenticationProviderSessionOptions): Promise<vscode.AuthenticationSession> {168return this._providerOperations.queue(providerId, async () => {169const providerData = this._authenticationProviders.get(providerId);170if (providerData) {171options.authorizationServer = URI.revive(options.authorizationServer);172return await providerData.provider.createSession(scopes, options);173}174175throw new Error(`Unable to find authentication provider with handle: ${providerId}`);176});177}178179$removeSession(providerId: string, sessionId: string): Promise<void> {180return this._providerOperations.queue(providerId, async () => {181const providerData = this._authenticationProviders.get(providerId);182if (providerData) {183return await providerData.provider.removeSession(sessionId);184}185186throw new Error(`Unable to find authentication provider with handle: ${providerId}`);187});188}189190$getSessions(providerId: string, scopes: ReadonlyArray<string> | undefined, options: vscode.AuthenticationProviderSessionOptions): Promise<ReadonlyArray<vscode.AuthenticationSession>> {191return this._providerOperations.queue(providerId, async () => {192const providerData = this._authenticationProviders.get(providerId);193if (providerData) {194options.authorizationServer = URI.revive(options.authorizationServer);195return await providerData.provider.getSessions(scopes, options);196}197198throw new Error(`Unable to find authentication provider with handle: ${providerId}`);199});200}201202$getSessionsFromChallenges(providerId: string, constraint: vscode.AuthenticationConstraint, options: vscode.AuthenticationProviderSessionOptions): Promise<ReadonlyArray<vscode.AuthenticationSession>> {203return this._providerOperations.queue(providerId, async () => {204const providerData = this._authenticationProviders.get(providerId);205if (providerData) {206const provider = providerData.provider;207// Check if provider supports challenges208if (typeof provider.getSessionsFromChallenges === 'function') {209options.authorizationServer = URI.revive(options.authorizationServer);210return await provider.getSessionsFromChallenges(constraint, options);211}212throw new Error(`Authentication provider with handle: ${providerId} does not support getSessionsFromChallenges`);213}214215throw new Error(`Unable to find authentication provider with handle: ${providerId}`);216});217}218219$createSessionFromChallenges(providerId: string, constraint: vscode.AuthenticationConstraint, options: vscode.AuthenticationProviderSessionOptions): Promise<vscode.AuthenticationSession> {220return this._providerOperations.queue(providerId, async () => {221const providerData = this._authenticationProviders.get(providerId);222if (providerData) {223const provider = providerData.provider;224// Check if provider supports challenges225if (typeof provider.createSessionFromChallenges === 'function') {226options.authorizationServer = URI.revive(options.authorizationServer);227return await provider.createSessionFromChallenges(constraint, options);228}229throw new Error(`Authentication provider with handle: ${providerId} does not support createSessionFromChallenges`);230}231232throw new Error(`Unable to find authentication provider with handle: ${providerId}`);233});234}235236$onDidChangeAuthenticationSessions(id: string, label: string, extensionIdFilter?: string[]) {237// Don't fire events for the internal auth providers238if (!id.startsWith(INTERNAL_AUTH_PROVIDER_PREFIX)) {239this._onDidChangeSessions.fire({ provider: { id, label }, extensionIdFilter });240}241return Promise.resolve();242}243244$onDidUnregisterAuthenticationProvider(id: string): Promise<void> {245return this._providerOperations.queue(id, async () => {246const providerData = this._authenticationProviders.get(id);247if (providerData) {248providerData.disposable?.dispose();249this._authenticationProviders.delete(id);250}251});252}253254async $registerDynamicAuthProvider(255authorizationServerComponents: UriComponents,256serverMetadata: IAuthorizationServerMetadata,257resourceMetadata: IAuthorizationProtectedResourceMetadata | undefined,258clientId: string | undefined,259clientSecret: string | undefined,260initialTokens: IAuthorizationToken[] | undefined261): Promise<string> {262if (!clientId) {263const authorizationServer = URI.revive(authorizationServerComponents);264if (serverMetadata.registration_endpoint) {265try {266const registration = await fetchDynamicRegistration(serverMetadata, this._initData.environment.appName, resourceMetadata?.scopes_supported);267clientId = registration.client_id;268clientSecret = registration.client_secret;269} catch (err) {270this._logService.warn(`Dynamic registration failed for ${authorizationServer.toString()}: ${err.message}. Prompting user for client ID and client secret...`);271}272}273// Still no client id so dynamic client registration was either not supported or failed274if (!clientId) {275this._logService.info(`Prompting user for client registration details for ${authorizationServer.toString()}`);276const clientDetails = await this._proxy.$promptForClientRegistration(authorizationServer.toString());277if (!clientDetails) {278throw new Error('User did not provide client details');279}280clientId = clientDetails.clientId;281clientSecret = clientDetails.clientSecret;282this._logService.info(`User provided client registration for ${authorizationServer.toString()}`);283if (clientSecret) {284this._logService.trace(`User provided client secret for ${authorizationServer.toString()}`);285} else {286this._logService.trace(`User did not provide client secret for ${authorizationServer.toString()}`);287}288}289}290const provider = new this._dynamicAuthProviderCtor(291this._extHostWindow,292this._extHostUrls,293this._initData,294this._extHostProgress,295this._extHostLoggerService,296this._proxy,297URI.revive(authorizationServerComponents),298serverMetadata,299resourceMetadata,300clientId,301clientSecret,302this._onDidDynamicAuthProviderTokensChange,303initialTokens || []304);305306// Use the sequencer to ensure dynamic provider registration is serialized307await this._providerOperations.queue(provider.id, async () => {308this._authenticationProviders.set(309provider.id,310{311label: provider.label,312provider,313disposable: Disposable.from(314provider,315provider.onDidChangeSessions(e => this._proxy.$sendDidChangeSessions(provider.id, e)),316provider.onDidChangeClientId(() => this._proxy.$sendDidChangeDynamicProviderInfo({317providerId: provider.id,318clientId: provider.clientId,319clientSecret: provider.clientSecret320}))321),322options: { supportsMultipleAccounts: true }323}324);325326await this._proxy.$registerDynamicAuthenticationProvider({327id: provider.id,328label: provider.label,329supportsMultipleAccounts: true,330authorizationServer: authorizationServerComponents,331resourceServer: resourceMetadata ? URI.parse(resourceMetadata.resource) : undefined,332clientId: provider.clientId,333clientSecret: provider.clientSecret334});335});336337338339340return provider.id;341}342343async $onDidChangeDynamicAuthProviderTokens(authProviderId: string, clientId: string, tokens: IAuthorizationToken[]): Promise<void> {344this._onDidDynamicAuthProviderTokensChange.fire({ authProviderId, clientId, tokens });345}346}347348class TaskSingler<T> {349private _inFlightPromises = new Map<string, Promise<T>>();350getOrCreate(key: string, promiseFactory: () => Promise<T>) {351const inFlight = this._inFlightPromises.get(key);352if (inFlight) {353return inFlight;354}355356const promise = promiseFactory().finally(() => this._inFlightPromises.delete(key));357this._inFlightPromises.set(key, promise);358359return promise;360}361}362363export class DynamicAuthProvider implements vscode.AuthenticationProvider {364readonly id: string;365readonly label: string;366367private _onDidChangeSessions = new Emitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>();368readonly onDidChangeSessions = this._onDidChangeSessions.event;369370private readonly _onDidChangeClientId = new Emitter<void>();371readonly onDidChangeClientId = this._onDidChangeClientId.event;372373private readonly _tokenStore: TokenStore;374375protected readonly _createFlows: Array<{376label: string;377handler: (scopes: string[], progress: vscode.Progress<{ message: string }>, token: vscode.CancellationToken) => Promise<IAuthorizationTokenResponse>;378}>;379380protected readonly _logger: ILogger;381private readonly _disposable: DisposableStore;382383constructor(384@IExtHostWindow protected readonly _extHostWindow: IExtHostWindow,385@IExtHostUrlsService protected readonly _extHostUrls: IExtHostUrlsService,386@IExtHostInitDataService protected readonly _initData: IExtHostInitDataService,387@IExtHostProgress private readonly _extHostProgress: IExtHostProgress,388@ILoggerService loggerService: ILoggerService,389protected readonly _proxy: MainThreadAuthenticationShape,390readonly authorizationServer: URI,391protected readonly _serverMetadata: IAuthorizationServerMetadata,392protected readonly _resourceMetadata: IAuthorizationProtectedResourceMetadata | undefined,393protected _clientId: string,394protected _clientSecret: string | undefined,395onDidDynamicAuthProviderTokensChange: Emitter<{ authProviderId: string; clientId: string; tokens: IAuthorizationToken[] }>,396initialTokens: IAuthorizationToken[],397) {398const stringifiedServer = authorizationServer.toString(true);399// Auth Provider Id is a combination of the authorization server and the resource, if provided.400this.id = _resourceMetadata?.resource401? stringifiedServer + ' ' + _resourceMetadata?.resource402: stringifiedServer;403// Auth Provider label is just the resource name if provided, otherwise the authority of the authorization server.404this.label = _resourceMetadata?.resource_name ?? this.authorizationServer.authority;405406this._logger = loggerService.createLogger(this.id, { name: `Auth: ${this.label}` });407this._disposable = new DisposableStore();408this._disposable.add(this._onDidChangeSessions);409const scopedEvent = Event.chain(onDidDynamicAuthProviderTokensChange.event, $ => $410.filter(e => e.authProviderId === this.id && e.clientId === _clientId)411.map(e => e.tokens)412);413this._tokenStore = this._disposable.add(new TokenStore(414{415onDidChange: scopedEvent,416set: (tokens) => _proxy.$setSessionsForDynamicAuthProvider(this.id, this.clientId, tokens),417},418initialTokens,419this._logger420));421this._disposable.add(this._tokenStore.onDidChangeSessions(e => this._onDidChangeSessions.fire(e)));422// Will be extended later to support other flows423this._createFlows = [];424if (_serverMetadata.authorization_endpoint) {425this._createFlows.push({426label: nls.localize('url handler', "URL Handler"),427handler: (scopes, progress, token) => this._createWithUrlHandler(scopes, progress, token)428});429}430}431432get clientId(): string {433return this._clientId;434}435436get clientSecret(): string | undefined {437return this._clientSecret;438}439440async getSessions(scopes: readonly string[] | undefined, _options: vscode.AuthenticationProviderSessionOptions): Promise<vscode.AuthenticationSession[]> {441this._logger.info(`Getting sessions for scopes: ${scopes?.join(' ') ?? 'all'}`);442if (!scopes) {443return this._tokenStore.sessions;444}445// The oauth spec says tthat order doesn't matter so we sort the scopes for easy comparison446// https://datatracker.ietf.org/doc/html/rfc6749#section-3.3447// TODO@TylerLeonhardt: Do this for all scope handling in the auth APIs448const sortedScopes = [...scopes].sort();449const scopeStr = scopes.join(' ');450let sessions = this._tokenStore.sessions.filter(session => arraysEqual([...session.scopes].sort(), sortedScopes));451this._logger.info(`Found ${sessions.length} sessions for scopes: ${scopeStr}`);452if (sessions.length) {453const newTokens: IAuthorizationToken[] = [];454const removedTokens: IAuthorizationToken[] = [];455const tokenMap = new Map<string, IAuthorizationToken>(this._tokenStore.tokens.map(token => [token.access_token, token]));456for (const session of sessions) {457const token = tokenMap.get(session.accessToken);458if (token && token.expires_in) {459const now = Date.now();460const expiresInMS = token.expires_in * 1000;461// Check if the token is about to expire in 5 minutes or if it is expired462if (now > token.created_at + expiresInMS - (5 * 60 * 1000)) {463this._logger.info(`Token for session ${session.id} is about to expire, refreshing...`);464removedTokens.push(token);465if (!token.refresh_token) {466// No refresh token available, cannot refresh467this._logger.warn(`No refresh token available for scopes ${session.scopes.join(' ')}. Throwing away token.`);468continue;469}470try {471const newToken = await this.exchangeRefreshTokenForToken(token.refresh_token);472// TODO@TylerLeonhardt: When the core scope handling doesn't care about order, this check should be473// updated to not care about order474if (newToken.scope !== scopeStr) {475this._logger.warn(`Token scopes '${newToken.scope}' do not match requested scopes '${scopeStr}'. Overwriting token with what was requested...`);476newToken.scope = scopeStr;477}478this._logger.info(`Successfully created a new token for scopes ${session.scopes.join(' ')}.`);479newTokens.push(newToken);480} catch (err) {481this._logger.error(`Failed to refresh token: ${err}`);482}483484}485}486}487if (newTokens.length || removedTokens.length) {488this._tokenStore.update({ added: newTokens, removed: removedTokens });489// Since we updated the tokens, we need to re-filter the sessions490// to get the latest state491sessions = this._tokenStore.sessions.filter(session => arraysEqual([...session.scopes].sort(), sortedScopes));492}493this._logger.info(`Found ${sessions.length} sessions for scopes: ${scopeStr}`);494return sessions;495}496return [];497}498499async createSession(scopes: string[], _options: vscode.AuthenticationProviderSessionOptions): Promise<vscode.AuthenticationSession> {500this._logger.info(`Creating session for scopes: ${scopes.join(' ')}`);501let token: IAuthorizationTokenResponse | undefined;502for (let i = 0; i < this._createFlows.length; i++) {503const { handler } = this._createFlows[i];504try {505token = await this._extHostProgress.withProgressFromSource(506{ label: this.label, id: this.id },507{508location: ProgressLocation.Notification,509title: nls.localize('authenticatingTo', "Authenticating to '{0}'", this.label),510cancellable: true511},512(progress, token) => handler(scopes, progress, token));513if (token) {514break;515}516} catch (err) {517const nextMode = this._createFlows[i + 1]?.label;518if (!nextMode) {519break; // No more flows to try520}521const message = isCancellationError(err)522? nls.localize('userCanceledContinue', "Having trouble authenticating to '{0}'? Would you like to try a different way? ({1})", this.label, nextMode)523: nls.localize('continueWith', "You have not yet finished authenticating to '{0}'. Would you like to try a different way? ({1})", this.label, nextMode);524525const result = await this._proxy.$showContinueNotification(message);526if (!result) {527throw new CancellationError();528}529this._logger.error(`Failed to create token via flow '${nextMode}': ${err}`);530}531}532if (!token) {533throw new Error('Failed to create authentication token');534}535if (token.scope !== scopes.join(' ')) {536this._logger.warn(`Token scopes '${token.scope}' do not match requested scopes '${scopes.join(' ')}'. Overwriting token with what was requested...`);537token.scope = scopes.join(' ');538}539540// Store session for later retrieval541this._tokenStore.update({ added: [{ ...token, created_at: Date.now() }], removed: [] });542const session = this._tokenStore.sessions.find(t => t.accessToken === token.access_token)!;543this._logger.info(`Created ${token.refresh_token ? 'refreshable' : 'non-refreshable'} session for scopes: ${token.scope}${token.expires_in ? ` that expires in ${token.expires_in} seconds` : ''}`);544return session;545}546547async removeSession(sessionId: string): Promise<void> {548this._logger.info(`Removing session with id: ${sessionId}`);549const session = this._tokenStore.sessions.find(session => session.id === sessionId);550if (!session) {551this._logger.error(`Session with id ${sessionId} not found`);552return;553}554const token = this._tokenStore.tokens.find(token => token.access_token === session.accessToken);555if (!token) {556this._logger.error(`Failed to retrieve token for removed session: ${session.id}`);557return;558}559this._tokenStore.update({ added: [], removed: [token] });560this._logger.info(`Removed token for session: ${session.id} with scopes: ${session.scopes.join(' ')}`);561}562563dispose(): void {564this._disposable.dispose();565}566567private async _createWithUrlHandler(scopes: string[], progress: vscode.Progress<IProgressStep>, token: vscode.CancellationToken): Promise<IAuthorizationTokenResponse> {568if (!this._serverMetadata.authorization_endpoint) {569throw new Error('Authorization Endpoint required');570}571if (!this._serverMetadata.token_endpoint) {572throw new Error('Token endpoint not available in server metadata');573}574575// Generate PKCE code verifier (random string) and code challenge (SHA-256 hash of verifier)576const codeVerifier = this.generateRandomString(64);577const codeChallenge = await this.generateCodeChallenge(codeVerifier);578579// Generate a random state value to prevent CSRF580const nonce = this.generateRandomString(32);581const callbackUri = URI.parse(`${this._initData.environment.appUriScheme}://dynamicauthprovider/${this.authorizationServer.authority}/authorize?nonce=${nonce}`);582let state: URI;583try {584state = await this._extHostUrls.createAppUri(callbackUri);585} catch (error) {586throw new Error(`Failed to create external URI: ${error}`);587}588589// Prepare the authorization request URL590const authorizationUrl = new URL(this._serverMetadata.authorization_endpoint);591authorizationUrl.searchParams.append('client_id', this._clientId);592authorizationUrl.searchParams.append('response_type', 'code');593authorizationUrl.searchParams.append('state', state.toString());594authorizationUrl.searchParams.append('code_challenge', codeChallenge);595authorizationUrl.searchParams.append('code_challenge_method', 'S256');596const scopeString = scopes.join(' ');597if (scopeString) {598// If non-empty scopes are provided, include scope parameter in the request599authorizationUrl.searchParams.append('scope', scopeString);600}601if (this._resourceMetadata?.resource) {602// If a resource is specified, include it in the request603authorizationUrl.searchParams.append('resource', this._resourceMetadata.resource);604}605606// Use a redirect URI that matches what was registered during dynamic registration607const redirectUri = 'https://vscode.dev/redirect';608authorizationUrl.searchParams.append('redirect_uri', redirectUri);609610const promise = this.waitForAuthorizationCode(callbackUri);611612// Open the browser for user authorization613this._logger.info(`Opening authorization URL for scopes: ${scopeString}`);614this._logger.trace(`Authorization URL: ${authorizationUrl.toString()}`);615const opened = await this._extHostWindow.openUri(authorizationUrl.toString(), {});616if (!opened) {617throw new CancellationError();618}619progress.report({620message: nls.localize('completeAuth', "Complete the authentication in the browser window that has opened."),621});622623// Wait for the authorization code via a redirect624let code: string | undefined;625try {626const response = await raceCancellationError(promise, token);627code = response.code;628} catch (err) {629if (isCancellationError(err)) {630this._logger.info('Authorization code request was cancelled by the user.');631throw err;632}633this._logger.error(`Failed to receive authorization code: ${err}`);634throw new Error(`Failed to receive authorization code: ${err}`);635}636this._logger.info(`Authorization code received for scopes: ${scopeString}`);637638// Exchange the authorization code for tokens639const tokenResponse = await this.exchangeCodeForToken(code, codeVerifier, redirectUri);640return tokenResponse;641}642643protected generateRandomString(length: number): string {644const array = new Uint8Array(length);645crypto.getRandomValues(array);646return Array.from(array)647.map(b => b.toString(16).padStart(2, '0'))648.join('')649.substring(0, length);650}651652protected async generateCodeChallenge(codeVerifier: string): Promise<string> {653const encoder = new TextEncoder();654const data = encoder.encode(codeVerifier);655const digest = await crypto.subtle.digest('SHA-256', data);656657// Base64url encode the digest658return encodeBase64(VSBuffer.wrap(new Uint8Array(digest)), false, false)659.replace(/\+/g, '-')660.replace(/\//g, '_')661.replace(/=+$/, '');662}663664private async waitForAuthorizationCode(expectedState: URI): Promise<{ code: string }> {665const result = await this._proxy.$waitForUriHandler(expectedState);666// Extract the code parameter directly from the query string. NOTE, URLSearchParams does not work here because667// it will decode the query string and we need to keep it encoded.668const codeMatch = /[?&]code=([^&]+)/.exec(result.query || '');669if (!codeMatch || codeMatch.length < 2) {670// No code parameter found in the query string671throw new Error('Authentication failed: No authorization code received');672}673return { code: codeMatch[1] };674}675676protected async exchangeCodeForToken(code: string, codeVerifier: string, redirectUri: string): Promise<IAuthorizationTokenResponse> {677if (!this._serverMetadata.token_endpoint) {678throw new Error('Token endpoint not available in server metadata');679}680681const tokenRequest = new URLSearchParams();682tokenRequest.append('client_id', this._clientId);683tokenRequest.append('grant_type', 'authorization_code');684tokenRequest.append('code', code);685tokenRequest.append('redirect_uri', redirectUri);686tokenRequest.append('code_verifier', codeVerifier);687688// Add resource indicator if available (RFC 8707)689if (this._resourceMetadata?.resource) {690tokenRequest.append('resource', this._resourceMetadata.resource);691}692693// Add client secret if available694if (this._clientSecret) {695tokenRequest.append('client_secret', this._clientSecret);696}697698this._logger.info('Exchanging authorization code for token...');699this._logger.trace(`Url: ${this._serverMetadata.token_endpoint}`);700this._logger.trace(`Token request body: ${tokenRequest.toString()}`);701let response: Response;702try {703response = await fetch(this._serverMetadata.token_endpoint, {704method: 'POST',705headers: {706'Content-Type': 'application/x-www-form-urlencoded',707'Accept': 'application/json'708},709body: tokenRequest.toString()710});711} catch (err) {712this._logger.error(`Failed to exchange authorization code for token: ${err}`);713throw new Error(`Failed to exchange authorization code for token: ${err}`);714}715716if (!response.ok) {717const text = await response.text();718throw new Error(`Token exchange failed: ${response.status} ${response.statusText} - ${text}`);719}720721const result = await response.json();722if (isAuthorizationTokenResponse(result)) {723this._logger.info(`Successfully exchanged authorization code for token.`);724return result;725} else if (isAuthorizationErrorResponse(result) && result.error === AuthorizationErrorType.InvalidClient) {726this._logger.warn(`Client ID (${this._clientId}) was invalid, generated a new one.`);727await this._generateNewClientId();728throw new Error(`Client ID was invalid, generated a new one. Please try again.`);729}730throw new Error(`Invalid authorization token response: ${JSON.stringify(result)}`);731}732733protected async exchangeRefreshTokenForToken(refreshToken: string): Promise<IAuthorizationToken> {734if (!this._serverMetadata.token_endpoint) {735throw new Error('Token endpoint not available in server metadata');736}737738const tokenRequest = new URLSearchParams();739tokenRequest.append('client_id', this._clientId);740tokenRequest.append('grant_type', 'refresh_token');741tokenRequest.append('refresh_token', refreshToken);742743// Add resource indicator if available (RFC 8707)744if (this._resourceMetadata?.resource) {745tokenRequest.append('resource', this._resourceMetadata.resource);746}747748// Add client secret if available749if (this._clientSecret) {750tokenRequest.append('client_secret', this._clientSecret);751}752753const response = await fetch(this._serverMetadata.token_endpoint, {754method: 'POST',755headers: {756'Content-Type': 'application/x-www-form-urlencoded',757'Accept': 'application/json'758},759body: tokenRequest.toString()760});761762const result = await response.json();763if (isAuthorizationTokenResponse(result)) {764return {765...result,766created_at: Date.now(),767};768} else if (isAuthorizationErrorResponse(result) && result.error === AuthorizationErrorType.InvalidClient) {769this._logger.warn(`Client ID (${this._clientId}) was invalid, generated a new one.`);770await this._generateNewClientId();771throw new Error(`Client ID was invalid, generated a new one. Please try again.`);772}773throw new Error(`Invalid authorization token response: ${JSON.stringify(result)}`);774}775776protected async _generateNewClientId(): Promise<void> {777try {778const registration = await fetchDynamicRegistration(this._serverMetadata, this._initData.environment.appName, this._resourceMetadata?.scopes_supported);779this._clientId = registration.client_id;780this._clientSecret = registration.client_secret;781this._onDidChangeClientId.fire();782} catch (err) {783// When DCR fails, try to prompt the user for a client ID and client secret784this._logger.info(`Dynamic registration failed for ${this.authorizationServer.toString()}: ${err}. Prompting user for client ID and client secret.`);785786try {787const clientDetails = await this._proxy.$promptForClientRegistration(this.authorizationServer.toString());788if (!clientDetails) {789throw new Error('User did not provide client details');790}791this._clientId = clientDetails.clientId;792this._clientSecret = clientDetails.clientSecret;793this._logger.info(`User provided client ID for ${this.authorizationServer.toString()}`);794if (clientDetails.clientSecret) {795this._logger.info(`User provided client secret for ${this.authorizationServer.toString()}`);796} else {797this._logger.info(`User did not provide client secret for ${this.authorizationServer.toString()} (optional)`);798}799800this._onDidChangeClientId.fire();801} catch (promptErr) {802this._logger.error(`Failed to fetch new client ID and user did not provide one: ${err}`);803throw new Error(`Failed to fetch new client ID and user did not provide one: ${err}`);804}805}806}807}808809type IAuthorizationToken = IAuthorizationTokenResponse & {810/**811* The time when the token was created, in milliseconds since the epoch.812*/813created_at: number;814};815816class TokenStore implements Disposable {817private readonly _tokensObservable: ISettableObservable<IAuthorizationToken[]>;818private readonly _sessionsObservable: IObservable<vscode.AuthenticationSession[]>;819820private readonly _onDidChangeSessions = new Emitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>();821readonly onDidChangeSessions = this._onDidChangeSessions.event;822823private readonly _disposable: DisposableStore;824825constructor(826private readonly _persistence: { onDidChange: Event<IAuthorizationToken[]>; set: (tokens: IAuthorizationToken[]) => void },827initialTokens: IAuthorizationToken[],828private readonly _logger: ILogger829) {830this._disposable = new DisposableStore();831this._tokensObservable = observableValue<IAuthorizationToken[]>('tokens', initialTokens);832this._sessionsObservable = derivedOpts(833{ equalsFn: (a, b) => arraysEqual(a, b, (a, b) => a.accessToken === b.accessToken) },834(reader) => this._tokensObservable.read(reader).map(t => this._getSessionFromToken(t))835);836this._disposable.add(this._registerChangeEventAutorun());837this._disposable.add(this._persistence.onDidChange((tokens) => this._tokensObservable.set(tokens, undefined)));838}839840get tokens(): IAuthorizationToken[] {841return this._tokensObservable.get();842}843844get sessions(): vscode.AuthenticationSession[] {845return this._sessionsObservable.get();846}847848dispose() {849this._disposable.dispose();850}851852update({ added, removed }: { added: IAuthorizationToken[]; removed: IAuthorizationToken[] }): void {853this._logger.trace(`Updating tokens: added ${added.length}, removed ${removed.length}`);854const currentTokens = [...this._tokensObservable.get()];855for (const token of removed) {856const index = currentTokens.findIndex(t => t.access_token === token.access_token);857if (index !== -1) {858currentTokens.splice(index, 1);859}860}861for (const token of added) {862const index = currentTokens.findIndex(t => t.access_token === token.access_token);863if (index === -1) {864currentTokens.push(token);865} else {866currentTokens[index] = token;867}868}869if (added.length || removed.length) {870this._tokensObservable.set(currentTokens, undefined);871void this._persistence.set(currentTokens);872}873this._logger.trace(`Tokens updated: ${currentTokens.length} tokens stored.`);874}875876private _registerChangeEventAutorun(): IDisposable {877let previousSessions: vscode.AuthenticationSession[] = [];878return autorun((reader) => {879this._logger.trace('Checking for session changes...');880const currentSessions = this._sessionsObservable.read(reader);881if (previousSessions === currentSessions) {882this._logger.trace('No session changes detected.');883return;884}885886if (!currentSessions || currentSessions.length === 0) {887// If currentSessions is undefined, all previous sessions are considered removed888this._logger.trace('All sessions removed.');889if (previousSessions.length > 0) {890this._onDidChangeSessions.fire({891added: [],892removed: previousSessions,893changed: []894});895previousSessions = [];896}897return;898}899900const added: vscode.AuthenticationSession[] = [];901const removed: vscode.AuthenticationSession[] = [];902903// Find added sessions904for (const current of currentSessions) {905const exists = previousSessions.some(prev => prev.accessToken === current.accessToken);906if (!exists) {907added.push(current);908}909}910911// Find removed sessions912for (const prev of previousSessions) {913const exists = currentSessions.some(current => current.accessToken === prev.accessToken);914if (!exists) {915removed.push(prev);916}917}918919// Fire the event if there are any changes920if (added.length > 0 || removed.length > 0) {921this._logger.trace(`Sessions changed: added ${added.length}, removed ${removed.length}`);922this._onDidChangeSessions.fire({ added, removed, changed: [] });923}924925// Update previous sessions reference926previousSessions = currentSessions;927});928}929930private _getSessionFromToken(token: IAuthorizationTokenResponse): vscode.AuthenticationSession {931let claims: IAuthorizationJWTClaims | undefined;932if (token.id_token) {933try {934claims = getClaimsFromJWT(token.id_token);935} catch (e) {936// log937}938}939if (!claims) {940try {941claims = getClaimsFromJWT(token.access_token);942} catch (e) {943// log944}945}946const scopes = token.scope947? token.scope.split(' ')948: claims?.scope949? claims.scope.split(' ')950: [];951return {952id: stringHash(token.access_token, 0).toString(),953accessToken: token.access_token,954account: {955id: claims?.sub || 'unknown',956// TODO: Don't say MCP...957label: claims?.preferred_username || claims?.name || claims?.email || 'MCP',958},959scopes: scopes,960idToken: token.id_token961};962}963}964965966