Path: blob/main/src/vs/workbench/api/common/extHostAuthentication.ts
3296 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)[];88const optionsStr = keys.sort().map(key => `${key}:${!!options[key]}`).join(', ');8990// old shape, remove next milestone91if (92'challenge' in scopesOrRequest93&& typeof scopesOrRequest.challenge === 'string'94&& !scopesOrRequest.wwwAuthenticate95) {96scopesOrRequest = {97wwwAuthenticate: scopesOrRequest.challenge,98scopes: scopesOrRequest.scopes99};100}101102let singlerKey: string;103if (isAuthenticationWWWAuthenticateRequest(scopesOrRequest)) {104const challenge = scopesOrRequest as vscode.AuthenticationWWWAuthenticateRequest;105const challengeStr = challenge.wwwAuthenticate;106const scopesStr = challenge.scopes ? [...challenge.scopes].sort().join(' ') : '';107singlerKey = `${extensionId} ${providerId} challenge:${challengeStr} ${scopesStr} ${optionsStr}`;108} else {109const sortedScopes = [...scopesOrRequest].sort().join(' ');110singlerKey = `${extensionId} ${providerId} ${sortedScopes} ${optionsStr}`;111}112113return await this._getSessionTaskSingler.getOrCreate(singlerKey, async () => {114const extensionName = requestingExtension.displayName || requestingExtension.name;115return this._proxy.$getSession(providerId, scopesOrRequest, extensionId, extensionName, options);116});117}118119async getAccounts(providerId: string) {120return await this._proxy.$getAccounts(providerId);121}122123registerAuthenticationProvider(id: string, label: string, provider: vscode.AuthenticationProvider, options?: vscode.AuthenticationProviderOptions): vscode.Disposable {124// register125void this._providerOperations.queue(id, async () => {126// This use to be synchronous, but that wasn't an accurate representation because the main thread127// may have unregistered the provider in the meantime. I don't see how this could really be done128// synchronously, so we just say first one wins.129if (this._authenticationProviders.get(id)) {130this._logService.error(`An authentication provider with id '${id}' is already registered. The existing provider will not be replaced.`);131return;132}133const listener = provider.onDidChangeSessions(e => this._proxy.$sendDidChangeSessions(id, e));134this._authenticationProviders.set(id, { label, provider, disposable: listener, options: options ?? { supportsMultipleAccounts: false } });135await this._proxy.$registerAuthenticationProvider(id, label, options?.supportsMultipleAccounts ?? false, options?.supportedAuthorizationServers, options?.supportsChallenges);136});137138// unregister139return new Disposable(() => {140void this._providerOperations.queue(id, async () => {141const providerData = this._authenticationProviders.get(id);142if (providerData) {143providerData.disposable?.dispose();144this._authenticationProviders.delete(id);145await this._proxy.$unregisterAuthenticationProvider(id);146}147});148});149}150151$createSession(providerId: string, scopes: string[], options: vscode.AuthenticationProviderSessionOptions): Promise<vscode.AuthenticationSession> {152return this._providerOperations.queue(providerId, async () => {153const providerData = this._authenticationProviders.get(providerId);154if (providerData) {155options.authorizationServer = URI.revive(options.authorizationServer);156return await providerData.provider.createSession(scopes, options);157}158159throw new Error(`Unable to find authentication provider with handle: ${providerId}`);160});161}162163$removeSession(providerId: string, sessionId: string): Promise<void> {164return this._providerOperations.queue(providerId, async () => {165const providerData = this._authenticationProviders.get(providerId);166if (providerData) {167return await providerData.provider.removeSession(sessionId);168}169170throw new Error(`Unable to find authentication provider with handle: ${providerId}`);171});172}173174$getSessions(providerId: string, scopes: ReadonlyArray<string> | undefined, options: vscode.AuthenticationProviderSessionOptions): Promise<ReadonlyArray<vscode.AuthenticationSession>> {175return this._providerOperations.queue(providerId, async () => {176const providerData = this._authenticationProviders.get(providerId);177if (providerData) {178options.authorizationServer = URI.revive(options.authorizationServer);179return await providerData.provider.getSessions(scopes, options);180}181182throw new Error(`Unable to find authentication provider with handle: ${providerId}`);183});184}185186$getSessionsFromChallenges(providerId: string, constraint: vscode.AuthenticationConstraint, options: vscode.AuthenticationProviderSessionOptions): Promise<ReadonlyArray<vscode.AuthenticationSession>> {187return this._providerOperations.queue(providerId, async () => {188const providerData = this._authenticationProviders.get(providerId);189if (providerData) {190const provider = providerData.provider;191// Check if provider supports challenges192if (typeof provider.getSessionsFromChallenges === 'function') {193options.authorizationServer = URI.revive(options.authorizationServer);194return await provider.getSessionsFromChallenges(constraint, options);195}196throw new Error(`Authentication provider with handle: ${providerId} does not support getSessionsFromChallenges`);197}198199throw new Error(`Unable to find authentication provider with handle: ${providerId}`);200});201}202203$createSessionFromChallenges(providerId: string, constraint: vscode.AuthenticationConstraint, options: vscode.AuthenticationProviderSessionOptions): Promise<vscode.AuthenticationSession> {204return this._providerOperations.queue(providerId, async () => {205const providerData = this._authenticationProviders.get(providerId);206if (providerData) {207const provider = providerData.provider;208// Check if provider supports challenges209if (typeof provider.createSessionFromChallenges === 'function') {210options.authorizationServer = URI.revive(options.authorizationServer);211return await provider.createSessionFromChallenges(constraint, options);212}213throw new Error(`Authentication provider with handle: ${providerId} does not support createSessionFromChallenges`);214}215216throw new Error(`Unable to find authentication provider with handle: ${providerId}`);217});218}219220$onDidChangeAuthenticationSessions(id: string, label: string, extensionIdFilter?: string[]) {221// Don't fire events for the internal auth providers222if (!id.startsWith(INTERNAL_AUTH_PROVIDER_PREFIX)) {223this._onDidChangeSessions.fire({ provider: { id, label }, extensionIdFilter });224}225return Promise.resolve();226}227228$onDidUnregisterAuthenticationProvider(id: string): Promise<void> {229return this._providerOperations.queue(id, async () => {230const providerData = this._authenticationProviders.get(id);231if (providerData) {232providerData.disposable?.dispose();233this._authenticationProviders.delete(id);234}235});236}237238async $registerDynamicAuthProvider(239authorizationServerComponents: UriComponents,240serverMetadata: IAuthorizationServerMetadata,241resourceMetadata: IAuthorizationProtectedResourceMetadata | undefined,242clientId: string | undefined,243clientSecret: string | undefined,244initialTokens: IAuthorizationToken[] | undefined245): Promise<string> {246if (!clientId) {247const authorizationServer = URI.revive(authorizationServerComponents);248if (serverMetadata.registration_endpoint) {249try {250const registration = await fetchDynamicRegistration(serverMetadata, this._initData.environment.appName, resourceMetadata?.scopes_supported);251clientId = registration.client_id;252clientSecret = registration.client_secret;253} catch (err) {254this._logService.warn(`Dynamic registration failed for ${authorizationServer.toString()}: ${err.message}. Prompting user for client ID and client secret...`);255}256}257// Still no client id so dynamic client registration was either not supported or failed258if (!clientId) {259this._logService.info(`Prompting user for client registration details for ${authorizationServer.toString()}`);260const clientDetails = await this._proxy.$promptForClientRegistration(authorizationServer.toString());261if (!clientDetails) {262throw new Error('User did not provide client details');263}264clientId = clientDetails.clientId;265clientSecret = clientDetails.clientSecret;266this._logService.info(`User provided client registration for ${authorizationServer.toString()}`);267if (clientSecret) {268this._logService.trace(`User provided client secret for ${authorizationServer.toString()}`);269} else {270this._logService.trace(`User did not provide client secret for ${authorizationServer.toString()}`);271}272}273}274const provider = new this._dynamicAuthProviderCtor(275this._extHostWindow,276this._extHostUrls,277this._initData,278this._extHostProgress,279this._extHostLoggerService,280this._proxy,281URI.revive(authorizationServerComponents),282serverMetadata,283resourceMetadata,284clientId,285clientSecret,286this._onDidDynamicAuthProviderTokensChange,287initialTokens || []288);289290// Use the sequencer to ensure dynamic provider registration is serialized291await this._providerOperations.queue(provider.id, async () => {292this._authenticationProviders.set(293provider.id,294{295label: provider.label,296provider,297disposable: Disposable.from(298provider,299provider.onDidChangeSessions(e => this._proxy.$sendDidChangeSessions(provider.id, e)),300provider.onDidChangeClientId(() => this._proxy.$sendDidChangeDynamicProviderInfo({301providerId: provider.id,302clientId: provider.clientId,303clientSecret: provider.clientSecret304}))305),306options: { supportsMultipleAccounts: false }307}308);309await this._proxy.$registerDynamicAuthenticationProvider(provider.id, provider.label, provider.authorizationServer, provider.clientId, provider.clientSecret);310});311312return provider.id;313}314315async $onDidChangeDynamicAuthProviderTokens(authProviderId: string, clientId: string, tokens: IAuthorizationToken[]): Promise<void> {316this._onDidDynamicAuthProviderTokensChange.fire({ authProviderId, clientId, tokens });317}318}319320class TaskSingler<T> {321private _inFlightPromises = new Map<string, Promise<T>>();322getOrCreate(key: string, promiseFactory: () => Promise<T>) {323const inFlight = this._inFlightPromises.get(key);324if (inFlight) {325return inFlight;326}327328const promise = promiseFactory().finally(() => this._inFlightPromises.delete(key));329this._inFlightPromises.set(key, promise);330331return promise;332}333}334335export class DynamicAuthProvider implements vscode.AuthenticationProvider {336readonly id: string;337readonly label: string;338339private _onDidChangeSessions = new Emitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>();340readonly onDidChangeSessions = this._onDidChangeSessions.event;341342private readonly _onDidChangeClientId = new Emitter<void>();343readonly onDidChangeClientId = this._onDidChangeClientId.event;344345private readonly _tokenStore: TokenStore;346347protected readonly _createFlows: Array<{348label: string;349handler: (scopes: string[], progress: vscode.Progress<{ message: string }>, token: vscode.CancellationToken) => Promise<IAuthorizationTokenResponse>;350}>;351352protected readonly _logger: ILogger;353private readonly _disposable: DisposableStore;354355constructor(356@IExtHostWindow protected readonly _extHostWindow: IExtHostWindow,357@IExtHostUrlsService protected readonly _extHostUrls: IExtHostUrlsService,358@IExtHostInitDataService protected readonly _initData: IExtHostInitDataService,359@IExtHostProgress private readonly _extHostProgress: IExtHostProgress,360@ILoggerService loggerService: ILoggerService,361protected readonly _proxy: MainThreadAuthenticationShape,362readonly authorizationServer: URI,363protected readonly _serverMetadata: IAuthorizationServerMetadata,364protected readonly _resourceMetadata: IAuthorizationProtectedResourceMetadata | undefined,365protected _clientId: string,366protected _clientSecret: string | undefined,367onDidDynamicAuthProviderTokensChange: Emitter<{ authProviderId: string; clientId: string; tokens: IAuthorizationToken[] }>,368initialTokens: IAuthorizationToken[],369) {370const stringifiedServer = authorizationServer.toString(true);371// Auth Provider Id is a combination of the authorization server and the resource, if provided.372this.id = _resourceMetadata?.resource373? stringifiedServer + ' ' + _resourceMetadata?.resource374: stringifiedServer;375// Auth Provider label is just the resource name if provided, otherwise the authority of the authorization server.376this.label = _resourceMetadata?.resource_name ?? this.authorizationServer.authority;377378this._logger = loggerService.createLogger(this.id, { name: this.label });379this._disposable = new DisposableStore();380this._disposable.add(this._onDidChangeSessions);381const scopedEvent = Event.chain(onDidDynamicAuthProviderTokensChange.event, $ => $382.filter(e => e.authProviderId === this.id && e.clientId === _clientId)383.map(e => e.tokens)384);385this._tokenStore = this._disposable.add(new TokenStore(386{387onDidChange: scopedEvent,388set: (tokens) => _proxy.$setSessionsForDynamicAuthProvider(this.id, this.clientId, tokens),389},390initialTokens,391this._logger392));393this._disposable.add(this._tokenStore.onDidChangeSessions(e => this._onDidChangeSessions.fire(e)));394// Will be extended later to support other flows395this._createFlows = [];396if (_serverMetadata.authorization_endpoint) {397this._createFlows.push({398label: nls.localize('url handler', "URL Handler"),399handler: (scopes, progress, token) => this._createWithUrlHandler(scopes, progress, token)400});401}402}403404get clientId(): string {405return this._clientId;406}407408get clientSecret(): string | undefined {409return this._clientSecret;410}411412async getSessions(scopes: readonly string[] | undefined, _options: vscode.AuthenticationProviderSessionOptions): Promise<vscode.AuthenticationSession[]> {413this._logger.info(`Getting sessions for scopes: ${scopes?.join(' ') ?? 'all'}`);414if (!scopes) {415return this._tokenStore.sessions;416}417// The oauth spec says tthat order doesn't matter so we sort the scopes for easy comparison418// https://datatracker.ietf.org/doc/html/rfc6749#section-3.3419// TODO@TylerLeonhardt: Do this for all scope handling in the auth APIs420const sortedScopes = [...scopes].sort();421const scopeStr = scopes.join(' ');422let sessions = this._tokenStore.sessions.filter(session => arraysEqual([...session.scopes].sort(), sortedScopes));423this._logger.info(`Found ${sessions.length} sessions for scopes: ${scopeStr}`);424if (sessions.length) {425const newTokens: IAuthorizationToken[] = [];426const removedTokens: IAuthorizationToken[] = [];427const tokenMap = new Map<string, IAuthorizationToken>(this._tokenStore.tokens.map(token => [token.access_token, token]));428for (const session of sessions) {429const token = tokenMap.get(session.accessToken);430if (token && token.expires_in) {431const now = Date.now();432const expiresInMS = token.expires_in * 1000;433// Check if the token is about to expire in 5 minutes or if it is expired434if (now > token.created_at + expiresInMS - (5 * 60 * 1000)) {435this._logger.info(`Token for session ${session.id} is about to expire, refreshing...`);436removedTokens.push(token);437if (!token.refresh_token) {438// No refresh token available, cannot refresh439this._logger.warn(`No refresh token available for scopes ${session.scopes.join(' ')}. Throwing away token.`);440continue;441}442try {443const newToken = await this.exchangeRefreshTokenForToken(token.refresh_token);444// TODO@TylerLeonhardt: When the core scope handling doesn't care about order, this check should be445// updated to not care about order446if (newToken.scope !== scopeStr) {447this._logger.warn(`Token scopes '${newToken.scope}' do not match requested scopes '${scopeStr}'. Overwriting token with what was requested...`);448newToken.scope = scopeStr;449}450this._logger.info(`Successfully created a new token for scopes ${session.scopes.join(' ')}.`);451newTokens.push(newToken);452} catch (err) {453this._logger.error(`Failed to refresh token: ${err}`);454}455456}457}458}459if (newTokens.length || removedTokens.length) {460this._tokenStore.update({ added: newTokens, removed: removedTokens });461// Since we updated the tokens, we need to re-filter the sessions462// to get the latest state463sessions = this._tokenStore.sessions.filter(session => arraysEqual([...session.scopes].sort(), sortedScopes));464}465this._logger.info(`Found ${sessions.length} sessions for scopes: ${scopeStr}`);466return sessions;467}468return [];469}470471async createSession(scopes: string[], _options: vscode.AuthenticationProviderSessionOptions): Promise<vscode.AuthenticationSession> {472this._logger.info(`Creating session for scopes: ${scopes.join(' ')}`);473let token: IAuthorizationTokenResponse | undefined;474for (let i = 0; i < this._createFlows.length; i++) {475const { handler } = this._createFlows[i];476try {477token = await this._extHostProgress.withProgressFromSource(478{ label: this.label, id: this.id },479{480location: ProgressLocation.Notification,481title: nls.localize('authenticatingTo', "Authenticating to '{0}'", this.label),482cancellable: true483},484(progress, token) => handler(scopes, progress, token));485if (token) {486break;487}488} catch (err) {489const nextMode = this._createFlows[i + 1]?.label;490if (!nextMode) {491break; // No more flows to try492}493const message = isCancellationError(err)494? nls.localize('userCanceledContinue', "Having trouble authenticating to '{0}'? Would you like to try a different way? ({1})", this.label, nextMode)495: nls.localize('continueWith', "You have not yet finished authenticating to '{0}'. Would you like to try a different way? ({1})", this.label, nextMode);496497const result = await this._proxy.$showContinueNotification(message);498if (!result) {499throw new CancellationError();500}501this._logger.error(`Failed to create token via flow '${nextMode}': ${err}`);502}503}504if (!token) {505throw new Error('Failed to create authentication token');506}507if (token.scope !== scopes.join(' ')) {508this._logger.warn(`Token scopes '${token.scope}' do not match requested scopes '${scopes.join(' ')}'. Overwriting token with what was requested...`);509token.scope = scopes.join(' ');510}511512// Store session for later retrieval513this._tokenStore.update({ added: [{ ...token, created_at: Date.now() }], removed: [] });514const session = this._tokenStore.sessions.find(t => t.accessToken === token.access_token)!;515this._logger.info(`Created session for scopes: ${token.scope}`);516return session;517}518519async removeSession(sessionId: string): Promise<void> {520this._logger.info(`Removing session with id: ${sessionId}`);521const session = this._tokenStore.sessions.find(session => session.id === sessionId);522if (!session) {523this._logger.error(`Session with id ${sessionId} not found`);524return;525}526const token = this._tokenStore.tokens.find(token => token.access_token === session.accessToken);527if (!token) {528this._logger.error(`Failed to retrieve token for removed session: ${session.id}`);529return;530}531this._tokenStore.update({ added: [], removed: [token] });532this._logger.info(`Removed token for session: ${session.id} with scopes: ${session.scopes.join(' ')}`);533}534535dispose(): void {536this._disposable.dispose();537}538539private async _createWithUrlHandler(scopes: string[], progress: vscode.Progress<IProgressStep>, token: vscode.CancellationToken): Promise<IAuthorizationTokenResponse> {540if (!this._serverMetadata.authorization_endpoint) {541throw new Error('Authorization Endpoint required');542}543if (!this._serverMetadata.token_endpoint) {544throw new Error('Token endpoint not available in server metadata');545}546547// Generate PKCE code verifier (random string) and code challenge (SHA-256 hash of verifier)548const codeVerifier = this.generateRandomString(64);549const codeChallenge = await this.generateCodeChallenge(codeVerifier);550551// Generate a random state value to prevent CSRF552const nonce = this.generateRandomString(32);553const callbackUri = URI.parse(`${this._initData.environment.appUriScheme}://dynamicauthprovider/${this.authorizationServer.authority}/authorize?nonce=${nonce}`);554let state: URI;555try {556state = await this._extHostUrls.createAppUri(callbackUri);557} catch (error) {558throw new Error(`Failed to create external URI: ${error}`);559}560561// Prepare the authorization request URL562const authorizationUrl = new URL(this._serverMetadata.authorization_endpoint);563authorizationUrl.searchParams.append('client_id', this._clientId);564authorizationUrl.searchParams.append('response_type', 'code');565authorizationUrl.searchParams.append('state', state.toString());566authorizationUrl.searchParams.append('code_challenge', codeChallenge);567authorizationUrl.searchParams.append('code_challenge_method', 'S256');568const scopeString = scopes.join(' ');569if (scopeString) {570// If non-empty scopes are provided, include scope parameter in the request571authorizationUrl.searchParams.append('scope', scopeString);572}573if (this._resourceMetadata?.resource) {574// If a resource is specified, include it in the request575authorizationUrl.searchParams.append('resource', this._resourceMetadata.resource);576}577578// Use a redirect URI that matches what was registered during dynamic registration579const redirectUri = 'https://vscode.dev/redirect';580authorizationUrl.searchParams.append('redirect_uri', redirectUri);581582const promise = this.waitForAuthorizationCode(callbackUri);583584// Open the browser for user authorization585this._logger.info(`Opening authorization URL for scopes: ${scopeString}`);586this._logger.trace(`Authorization URL: ${authorizationUrl.toString()}`);587const opened = await this._extHostWindow.openUri(authorizationUrl.toString(), {});588if (!opened) {589throw new CancellationError();590}591progress.report({592message: nls.localize('completeAuth', "Complete the authentication in the browser window that has opened."),593});594595// Wait for the authorization code via a redirect596let code: string | undefined;597try {598const response = await raceCancellationError(promise, token);599code = response.code;600} catch (err) {601if (isCancellationError(err)) {602this._logger.info('Authorization code request was cancelled by the user.');603throw err;604}605this._logger.error(`Failed to receive authorization code: ${err}`);606throw new Error(`Failed to receive authorization code: ${err}`);607}608this._logger.info(`Authorization code received for scopes: ${scopeString}`);609610// Exchange the authorization code for tokens611const tokenResponse = await this.exchangeCodeForToken(code, codeVerifier, redirectUri);612return tokenResponse;613}614615protected generateRandomString(length: number): string {616const array = new Uint8Array(length);617crypto.getRandomValues(array);618return Array.from(array)619.map(b => b.toString(16).padStart(2, '0'))620.join('')621.substring(0, length);622}623624protected async generateCodeChallenge(codeVerifier: string): Promise<string> {625const encoder = new TextEncoder();626const data = encoder.encode(codeVerifier);627const digest = await crypto.subtle.digest('SHA-256', data);628629// Base64url encode the digest630return encodeBase64(VSBuffer.wrap(new Uint8Array(digest)), false, false)631.replace(/\+/g, '-')632.replace(/\//g, '_')633.replace(/=+$/, '');634}635636private async waitForAuthorizationCode(expectedState: URI): Promise<{ code: string }> {637const result = await this._proxy.$waitForUriHandler(expectedState);638// Extract the code parameter directly from the query string. NOTE, URLSearchParams does not work here because639// it will decode the query string and we need to keep it encoded.640const codeMatch = /[?&]code=([^&]+)/.exec(result.query || '');641if (!codeMatch || codeMatch.length < 2) {642// No code parameter found in the query string643throw new Error('Authentication failed: No authorization code received');644}645return { code: codeMatch[1] };646}647648protected async exchangeCodeForToken(code: string, codeVerifier: string, redirectUri: string): Promise<IAuthorizationTokenResponse> {649if (!this._serverMetadata.token_endpoint) {650throw new Error('Token endpoint not available in server metadata');651}652653const tokenRequest = new URLSearchParams();654tokenRequest.append('client_id', this._clientId);655tokenRequest.append('grant_type', 'authorization_code');656tokenRequest.append('code', code);657tokenRequest.append('redirect_uri', redirectUri);658tokenRequest.append('code_verifier', codeVerifier);659660// Add resource indicator if available (RFC 8707)661if (this._resourceMetadata?.resource) {662tokenRequest.append('resource', this._resourceMetadata.resource);663}664665// Add client secret if available666if (this._clientSecret) {667tokenRequest.append('client_secret', this._clientSecret);668}669670this._logger.info('Exchanging authorization code for token...');671this._logger.trace(`Url: ${this._serverMetadata.token_endpoint}`);672this._logger.trace(`Token request body: ${tokenRequest.toString()}`);673let response: Response;674try {675response = await fetch(this._serverMetadata.token_endpoint, {676method: 'POST',677headers: {678'Content-Type': 'application/x-www-form-urlencoded',679'Accept': 'application/json'680},681body: tokenRequest.toString()682});683} catch (err) {684this._logger.error(`Failed to exchange authorization code for token: ${err}`);685throw new Error(`Failed to exchange authorization code for token: ${err}`);686}687688if (!response.ok) {689const text = await response.text();690throw new Error(`Token exchange failed: ${response.status} ${response.statusText} - ${text}`);691}692693const result = await response.json();694if (isAuthorizationTokenResponse(result)) {695this._logger.info(`Successfully exchanged authorization code for token.`);696return result;697} else if (isAuthorizationErrorResponse(result) && result.error === AuthorizationErrorType.InvalidClient) {698this._logger.warn(`Client ID (${this._clientId}) was invalid, generated a new one.`);699await this._generateNewClientId();700throw new Error(`Client ID was invalid, generated a new one. Please try again.`);701}702throw new Error(`Invalid authorization token response: ${JSON.stringify(result)}`);703}704705protected async exchangeRefreshTokenForToken(refreshToken: string): Promise<IAuthorizationToken> {706if (!this._serverMetadata.token_endpoint) {707throw new Error('Token endpoint not available in server metadata');708}709710const tokenRequest = new URLSearchParams();711tokenRequest.append('client_id', this._clientId);712tokenRequest.append('grant_type', 'refresh_token');713tokenRequest.append('refresh_token', refreshToken);714715// Add resource indicator if available (RFC 8707)716if (this._resourceMetadata?.resource) {717tokenRequest.append('resource', this._resourceMetadata.resource);718}719720// Add client secret if available721if (this._clientSecret) {722tokenRequest.append('client_secret', this._clientSecret);723}724725const response = await fetch(this._serverMetadata.token_endpoint, {726method: 'POST',727headers: {728'Content-Type': 'application/x-www-form-urlencoded',729'Accept': 'application/json'730},731body: tokenRequest.toString()732});733734const result = await response.json();735if (isAuthorizationTokenResponse(result)) {736return {737...result,738created_at: Date.now(),739};740} else if (isAuthorizationErrorResponse(result) && result.error === AuthorizationErrorType.InvalidClient) {741this._logger.warn(`Client ID (${this._clientId}) was invalid, generated a new one.`);742await this._generateNewClientId();743throw new Error(`Client ID was invalid, generated a new one. Please try again.`);744}745throw new Error(`Invalid authorization token response: ${JSON.stringify(result)}`);746}747748protected async _generateNewClientId(): Promise<void> {749try {750const registration = await fetchDynamicRegistration(this._serverMetadata, this._initData.environment.appName, this._resourceMetadata?.scopes_supported);751this._clientId = registration.client_id;752this._clientSecret = registration.client_secret;753this._onDidChangeClientId.fire();754} catch (err) {755// When DCR fails, try to prompt the user for a client ID and client secret756this._logger.info(`Dynamic registration failed for ${this.authorizationServer.toString()}: ${err}. Prompting user for client ID and client secret.`);757758try {759const clientDetails = await this._proxy.$promptForClientRegistration(this.authorizationServer.toString());760if (!clientDetails) {761throw new Error('User did not provide client details');762}763this._clientId = clientDetails.clientId;764this._clientSecret = clientDetails.clientSecret;765this._logger.info(`User provided client ID for ${this.authorizationServer.toString()}`);766if (clientDetails.clientSecret) {767this._logger.info(`User provided client secret for ${this.authorizationServer.toString()}`);768} else {769this._logger.info(`User did not provide client secret for ${this.authorizationServer.toString()} (optional)`);770}771772this._onDidChangeClientId.fire();773} catch (promptErr) {774this._logger.error(`Failed to fetch new client ID and user did not provide one: ${err}`);775throw new Error(`Failed to fetch new client ID and user did not provide one: ${err}`);776}777}778}779}780781type IAuthorizationToken = IAuthorizationTokenResponse & {782/**783* The time when the token was created, in milliseconds since the epoch.784*/785created_at: number;786};787788class TokenStore implements Disposable {789private readonly _tokensObservable: ISettableObservable<IAuthorizationToken[]>;790private readonly _sessionsObservable: IObservable<vscode.AuthenticationSession[]>;791792private readonly _onDidChangeSessions = new Emitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>();793readonly onDidChangeSessions = this._onDidChangeSessions.event;794795private readonly _disposable: DisposableStore;796797constructor(798private readonly _persistence: { onDidChange: Event<IAuthorizationToken[]>; set: (tokens: IAuthorizationToken[]) => void },799initialTokens: IAuthorizationToken[],800private readonly _logger: ILogger801) {802this._disposable = new DisposableStore();803this._tokensObservable = observableValue<IAuthorizationToken[]>('tokens', initialTokens);804this._sessionsObservable = derivedOpts(805{ equalsFn: (a, b) => arraysEqual(a, b, (a, b) => a.accessToken === b.accessToken) },806(reader) => this._tokensObservable.read(reader).map(t => this._getSessionFromToken(t))807);808this._disposable.add(this._registerChangeEventAutorun());809this._disposable.add(this._persistence.onDidChange((tokens) => this._tokensObservable.set(tokens, undefined)));810}811812get tokens(): IAuthorizationToken[] {813return this._tokensObservable.get();814}815816get sessions(): vscode.AuthenticationSession[] {817return this._sessionsObservable.get();818}819820dispose() {821this._disposable.dispose();822}823824update({ added, removed }: { added: IAuthorizationToken[]; removed: IAuthorizationToken[] }): void {825this._logger.trace(`Updating tokens: added ${added.length}, removed ${removed.length}`);826const currentTokens = [...this._tokensObservable.get()];827for (const token of removed) {828const index = currentTokens.findIndex(t => t.access_token === token.access_token);829if (index !== -1) {830currentTokens.splice(index, 1);831}832}833for (const token of added) {834const index = currentTokens.findIndex(t => t.access_token === token.access_token);835if (index === -1) {836currentTokens.push(token);837} else {838currentTokens[index] = token;839}840}841if (added.length || removed.length) {842this._tokensObservable.set(currentTokens, undefined);843void this._persistence.set(currentTokens);844}845this._logger.trace(`Tokens updated: ${currentTokens.length} tokens stored.`);846}847848private _registerChangeEventAutorun(): IDisposable {849let previousSessions: vscode.AuthenticationSession[] = [];850return autorun((reader) => {851this._logger.trace('Checking for session changes...');852const currentSessions = this._sessionsObservable.read(reader);853if (previousSessions === currentSessions) {854this._logger.trace('No session changes detected.');855return;856}857858if (!currentSessions || currentSessions.length === 0) {859// If currentSessions is undefined, all previous sessions are considered removed860this._logger.trace('All sessions removed.');861if (previousSessions.length > 0) {862this._onDidChangeSessions.fire({863added: [],864removed: previousSessions,865changed: []866});867previousSessions = [];868}869return;870}871872const added: vscode.AuthenticationSession[] = [];873const removed: vscode.AuthenticationSession[] = [];874875// Find added sessions876for (const current of currentSessions) {877const exists = previousSessions.some(prev => prev.accessToken === current.accessToken);878if (!exists) {879added.push(current);880}881}882883// Find removed sessions884for (const prev of previousSessions) {885const exists = currentSessions.some(current => current.accessToken === prev.accessToken);886if (!exists) {887removed.push(prev);888}889}890891// Fire the event if there are any changes892if (added.length > 0 || removed.length > 0) {893this._logger.trace(`Sessions changed: added ${added.length}, removed ${removed.length}`);894this._onDidChangeSessions.fire({ added, removed, changed: [] });895}896897// Update previous sessions reference898previousSessions = currentSessions;899});900}901902private _getSessionFromToken(token: IAuthorizationTokenResponse): vscode.AuthenticationSession {903let claims: IAuthorizationJWTClaims | undefined;904if (token.id_token) {905try {906claims = getClaimsFromJWT(token.id_token);907} catch (e) {908// log909}910}911if (!claims) {912try {913claims = getClaimsFromJWT(token.access_token);914} catch (e) {915// log916}917}918const scopes = token.scope919? token.scope.split(' ')920: claims?.scope921? claims.scope.split(' ')922: [];923return {924id: stringHash(token.access_token, 0).toString(),925accessToken: token.access_token,926account: {927id: claims?.sub || 'unknown',928// TODO: Don't say MCP...929label: claims?.preferred_username || claims?.name || claims?.email || 'MCP',930},931scopes: scopes,932idToken: token.id_token933};934}935}936937938