diff --git a/extensions/github-authentication/package.json b/extensions/github-authentication/package.json index 4ac2b23047d..0dc940131b4 100644 --- a/extensions/github-authentication/package.json +++ b/extensions/github-authentication/package.json @@ -17,6 +17,9 @@ "ui", "workspace" ], + "enabledApiProposals": [ + "authIssuers" + ], "activationEvents": [], "capabilities": { "virtualWorkspaces": true, @@ -31,11 +34,17 @@ "authentication": [ { "label": "GitHub", - "id": "github" + "id": "github", + "issuerGlobs": [ + "https://github.com/login/oauth" + ] }, { "label": "GitHub Enterprise Server", - "id": "github-enterprise" + "id": "github-enterprise", + "issuerGlobs": [ + "*" + ] } ], "configuration": [{ diff --git a/extensions/github-authentication/src/github.ts b/extensions/github-authentication/src/github.ts index d67fc99e08b..c56cb96dae5 100644 --- a/extensions/github-authentication/src/github.ts +++ b/extensions/github-authentication/src/github.ts @@ -137,7 +137,17 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid this._disposable = vscode.Disposable.from( this._telemetryReporter, - vscode.authentication.registerAuthenticationProvider(type, this._githubServer.friendlyName, this, { supportsMultipleAccounts: true }), + vscode.authentication.registerAuthenticationProvider( + type, + this._githubServer.friendlyName, + this, + { + supportsMultipleAccounts: true, + supportedIssuers: [ + ghesUri ?? vscode.Uri.parse('https://github.com/login/oauth') + ] + } + ), this.context.secrets.onDidChange(() => this.checkForUpdates()) ); } diff --git a/extensions/github-authentication/tsconfig.json b/extensions/github-authentication/tsconfig.json index 5e4713e9f3b..841f6f09adf 100644 --- a/extensions/github-authentication/tsconfig.json +++ b/extensions/github-authentication/tsconfig.json @@ -12,6 +12,7 @@ }, "include": [ "src/**/*", - "../../src/vscode-dts/vscode.d.ts" + "../../src/vscode-dts/vscode.d.ts", + "../../src/vscode-dts/vscode.proposed.authIssuers.d.ts" ] } diff --git a/extensions/microsoft-authentication/package.json b/extensions/microsoft-authentication/package.json index 6ce848eb7ec..90e530f5561 100644 --- a/extensions/microsoft-authentication/package.json +++ b/extensions/microsoft-authentication/package.json @@ -15,7 +15,8 @@ "activationEvents": [], "enabledApiProposals": [ "idToken", - "nativeWindowHandle" + "nativeWindowHandle", + "authIssuers" ], "capabilities": { "virtualWorkspaces": true, @@ -31,7 +32,10 @@ "authentication": [ { "label": "Microsoft", - "id": "microsoft" + "id": "microsoft", + "issuerGlobs": [ + "https://login.microsoftonline.com/*/v2.0" + ] }, { "label": "Microsoft Sovereign Cloud", diff --git a/extensions/microsoft-authentication/src/AADHelper.ts b/extensions/microsoft-authentication/src/AADHelper.ts index 6070e882654..d5316b1266c 100644 --- a/extensions/microsoft-authentication/src/AADHelper.ts +++ b/extensions/microsoft-authentication/src/AADHelper.ts @@ -203,7 +203,7 @@ export class AzureActiveDirectoryService { return this._sessionChangeEmitter.event; } - public getSessions(scopes?: string[], account?: vscode.AuthenticationSessionAccountInformation): Promise { + public getSessions(scopes: string[] | undefined, { account, issuer }: vscode.AuthenticationProviderSessionOptions = {}): Promise { if (!scopes) { this._logger.info('Getting sessions for all scopes...'); const sessions = this._tokens @@ -226,6 +226,12 @@ export class AzureActiveDirectoryService { if (!modifiedScopes.includes('offline_access')) { modifiedScopes.push('offline_access'); } + if (issuer) { + const tenant = issuer.path.split('/')[1]; + if (tenant) { + modifiedScopes.push(`VSCODE_TENANT:${tenant}`); + } + } modifiedScopes = modifiedScopes.sort(); const modifiedScopesStr = modifiedScopes.join(' '); @@ -237,7 +243,7 @@ export class AzureActiveDirectoryService { scopeStr: modifiedScopesStr, // filter our special scopes scopesToSend: modifiedScopes.filter(s => !s.startsWith('VSCODE_')).join(' '), - tenant: this.getTenantId(scopes), + tenant: this.getTenantId(modifiedScopes), }; this._logger.trace(`[${scopeData.scopeStr}] Queued getting sessions` + account ? ` for ${account?.label}` : ''); @@ -297,7 +303,7 @@ export class AzureActiveDirectoryService { .map(result => (result as PromiseFulfilledResult).value); } - public createSession(scopes: string[], account?: vscode.AuthenticationSessionAccountInformation): Promise { + public createSession(scopes: string[], { account, issuer }: vscode.AuthenticationProviderSessionOptions = {}): Promise { let modifiedScopes = [...scopes]; if (!modifiedScopes.includes('openid')) { modifiedScopes.push('openid'); @@ -311,6 +317,12 @@ export class AzureActiveDirectoryService { if (!modifiedScopes.includes('offline_access')) { modifiedScopes.push('offline_access'); } + if (issuer) { + const tenant = issuer.path.split('/')[1]; + if (tenant) { + modifiedScopes.push(`VSCODE_TENANT:${tenant}`); + } + } modifiedScopes = modifiedScopes.sort(); const scopeData: IScopeData = { originalScopes: scopes, @@ -319,7 +331,7 @@ export class AzureActiveDirectoryService { // filter our special scopes scopesToSend: modifiedScopes.filter(s => !s.startsWith('VSCODE_')).join(' '), clientId: this.getClientId(scopes), - tenant: this.getTenantId(scopes), + tenant: this.getTenantId(modifiedScopes), }; this._logger.trace(`[${scopeData.scopeStr}] Queued creating session`); diff --git a/extensions/microsoft-authentication/src/common/scopeData.ts b/extensions/microsoft-authentication/src/common/scopeData.ts index 88a0aad68cc..f3b4d37dbb6 100644 --- a/extensions/microsoft-authentication/src/common/scopeData.ts +++ b/extensions/microsoft-authentication/src/common/scopeData.ts @@ -3,6 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Uri } from 'vscode'; + const DEFAULT_CLIENT_ID = 'aebc6443-996d-45c2-90f0-388ff96faa56'; const DEFAULT_TENANT = 'organizations'; @@ -43,14 +45,14 @@ export class ScopeData { */ readonly tenantId: string | undefined; - constructor(readonly originalScopes: readonly string[] = []) { + constructor(readonly originalScopes: readonly string[] = [], issuer?: Uri) { const modifiedScopes = [...originalScopes]; modifiedScopes.sort(); this.allScopes = modifiedScopes; this.scopeStr = modifiedScopes.join(' '); this.scopesToSend = this.getScopesToSend(modifiedScopes); this.clientId = this.getClientId(this.allScopes); - this.tenant = this.getTenant(this.allScopes); + this.tenant = this.getTenant(this.allScopes, issuer); this.tenantId = this.getTenantId(this.tenant); } @@ -63,7 +65,14 @@ export class ScopeData { }, undefined) ?? DEFAULT_CLIENT_ID; } - private getTenant(scopes: string[]): string { + private getTenant(scopes: string[], issuer?: Uri): string { + if (issuer?.path) { + // Get tenant portion of URL + const tenant = issuer.path.split('/')[1]; + if (tenant) { + return tenant; + } + } return scopes.reduce((prev, current) => { if (current.startsWith('VSCODE_TENANT:')) { return current.split('VSCODE_TENANT:')[1]; diff --git a/extensions/microsoft-authentication/src/common/test/scopeData.test.ts b/extensions/microsoft-authentication/src/common/test/scopeData.test.ts index 4c70e4fd07c..54eae0cb39b 100644 --- a/extensions/microsoft-authentication/src/common/test/scopeData.test.ts +++ b/extensions/microsoft-authentication/src/common/test/scopeData.test.ts @@ -5,6 +5,7 @@ import * as assert from 'assert'; import { ScopeData } from '../scopeData'; +import { Uri } from 'vscode'; suite('ScopeData', () => { test('should include default scopes if not present', () => { @@ -73,4 +74,22 @@ suite('ScopeData', () => { const scopeData = new ScopeData(['custom_scope', 'VSCODE_TENANT:some_guid']); assert.strictEqual(scopeData.tenantId, 'some_guid'); }); + + test('should extract tenant from issuer URL path', () => { + const issuer = Uri.parse('https://login.microsoftonline.com/tenant123/oauth2/v2.0'); + const scopeData = new ScopeData(['custom_scope'], issuer); + assert.strictEqual(scopeData.tenant, 'tenant123'); + }); + + test('should fallback to default tenant if issuer URL has no path segments', () => { + const issuer = Uri.parse('https://login.microsoftonline.com'); + const scopeData = new ScopeData(['custom_scope'], issuer); + assert.strictEqual(scopeData.tenant, 'organizations'); + }); + + test('should prioritize issuer URL over VSCODE_TENANT scope', () => { + const issuer = Uri.parse('https://login.microsoftonline.com/url_tenant/oauth2/v2.0'); + const scopeData = new ScopeData(['custom_scope', 'VSCODE_TENANT:scope_tenant'], issuer); + assert.strictEqual(scopeData.tenant, 'url_tenant'); + }); }); diff --git a/extensions/microsoft-authentication/src/extensionV1.ts b/extensions/microsoft-authentication/src/extensionV1.ts index 2cf794dd628..ba78ed2367c 100644 --- a/extensions/microsoft-authentication/src/extensionV1.ts +++ b/extensions/microsoft-authentication/src/extensionV1.ts @@ -122,49 +122,59 @@ export async function activate(context: vscode.ExtensionContext, telemetryReport Environment.AzureCloud); await loginService.initialize(); - context.subscriptions.push(vscode.authentication.registerAuthenticationProvider('microsoft', 'Microsoft', { - onDidChangeSessions: loginService.onDidChangeSessions, - getSessions: (scopes: string[], options?: vscode.AuthenticationProviderSessionOptions) => loginService.getSessions(scopes, options?.account), - createSession: async (scopes: string[], options?: vscode.AuthenticationProviderSessionOptions) => { - try { - /* __GDPR__ - "login" : { - "owner": "TylerLeonhardt", - "comment": "Used to determine the usage of the Microsoft Auth Provider.", - "scopes": { "classification": "PublicNonPersonalData", "purpose": "FeatureInsight", "comment": "Used to determine what scope combinations are being requested." } - } - */ - telemetryReporter.sendTelemetryEvent('login', { - // Get rid of guids from telemetry. - scopes: JSON.stringify(scopes.map(s => s.replace(/[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}/i, '{guid}'))), - }); + context.subscriptions.push(vscode.authentication.registerAuthenticationProvider( + 'microsoft', + 'Microsoft', + { + onDidChangeSessions: loginService.onDidChangeSessions, + getSessions: (scopes: string[], options?: vscode.AuthenticationProviderSessionOptions) => loginService.getSessions(scopes, options), + createSession: async (scopes: string[], options?: vscode.AuthenticationProviderSessionOptions) => { + try { + /* __GDPR__ + "login" : { + "owner": "TylerLeonhardt", + "comment": "Used to determine the usage of the Microsoft Auth Provider.", + "scopes": { "classification": "PublicNonPersonalData", "purpose": "FeatureInsight", "comment": "Used to determine what scope combinations are being requested." } + } + */ + telemetryReporter.sendTelemetryEvent('login', { + // Get rid of guids from telemetry. + scopes: JSON.stringify(scopes.map(s => s.replace(/[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}/i, '{guid}'))), + }); - return await loginService.createSession(scopes, options?.account); - } catch (e) { - /* __GDPR__ - "loginFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users run into issues with the login flow." } - */ - telemetryReporter.sendTelemetryEvent('loginFailed'); + return await loginService.createSession(scopes, options); + } catch (e) { + /* __GDPR__ + "loginFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users run into issues with the login flow." } + */ + telemetryReporter.sendTelemetryEvent('loginFailed'); - throw e; + throw e; + } + }, + removeSession: async (id: string) => { + try { + /* __GDPR__ + "logout" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users log out." } + */ + telemetryReporter.sendTelemetryEvent('logout'); + + await loginService.removeSessionById(id); + } catch (e) { + /* __GDPR__ + "logoutFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often fail to log out." } + */ + telemetryReporter.sendTelemetryEvent('logoutFailed'); + } } }, - removeSession: async (id: string) => { - try { - /* __GDPR__ - "logout" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users log out." } - */ - telemetryReporter.sendTelemetryEvent('logout'); - - await loginService.removeSessionById(id); - } catch (e) { - /* __GDPR__ - "logoutFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often fail to log out." } - */ - telemetryReporter.sendTelemetryEvent('logoutFailed'); - } + { + supportsMultipleAccounts: true, + supportedIssuers: [ + vscode.Uri.parse('https://login.microsoftonline.com/*/v2.0') + ] } - }, { supportsMultipleAccounts: true })); + )); let microsoftSovereignCloudAuthProviderDisposable = await initMicrosoftSovereignCloudAuthProvider(context, telemetryReporter, uriHandler, betterSecretStorage); diff --git a/extensions/microsoft-authentication/src/extensionV2.ts b/extensions/microsoft-authentication/src/extensionV2.ts index 978603ad132..2f6bc9551bf 100644 --- a/extensions/microsoft-authentication/src/extensionV2.ts +++ b/extensions/microsoft-authentication/src/extensionV2.ts @@ -7,7 +7,7 @@ import { Environment, EnvironmentParameters } from '@azure/ms-rest-azure-env'; import Logger from './logger'; import { MsalAuthProvider } from './node/authProvider'; import { UriEventHandler } from './UriEventHandler'; -import { authentication, commands, ExtensionContext, l10n, window, workspace, Disposable } from 'vscode'; +import { authentication, commands, ExtensionContext, l10n, window, workspace, Disposable, Uri } from 'vscode'; import { MicrosoftAuthenticationTelemetryReporter, MicrosoftSovereignCloudAuthenticationTelemetryReporter } from './common/telemetryReporter'; async function initMicrosoftSovereignCloudAuthProvider( @@ -79,7 +79,12 @@ export async function activate(context: ExtensionContext, mainTelemetryReporter: 'microsoft', 'Microsoft', authProvider, - { supportsMultipleAccounts: true } + { + supportsMultipleAccounts: true, + supportedIssuers: [ + Uri.parse('https://login.microsoftonline.com/*/v2.0') + ] + } )); let microsoftSovereignCloudAuthProviderDisposable = await initMicrosoftSovereignCloudAuthProvider(context, uriHandler); diff --git a/extensions/microsoft-authentication/src/node/authProvider.ts b/extensions/microsoft-authentication/src/node/authProvider.ts index 4e72ce92b54..2780374e2c4 100644 --- a/extensions/microsoft-authentication/src/node/authProvider.ts +++ b/extensions/microsoft-authentication/src/node/authProvider.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { AccountInfo, AuthenticationResult, ClientAuthError, ClientAuthErrorCodes, ServerError } from '@azure/msal-node'; -import { AuthenticationGetSessionOptions, AuthenticationProvider, AuthenticationProviderAuthenticationSessionsChangeEvent, AuthenticationProviderSessionOptions, AuthenticationSession, AuthenticationSessionAccountInformation, CancellationError, EventEmitter, ExtensionContext, ExtensionKind, l10n, LogOutputChannel, window } from 'vscode'; +import { AuthenticationGetSessionOptions, AuthenticationProvider, AuthenticationProviderAuthenticationSessionsChangeEvent, AuthenticationProviderSessionOptions, AuthenticationSession, AuthenticationSessionAccountInformation, CancellationError, EventEmitter, ExtensionContext, ExtensionKind, l10n, LogOutputChannel, Uri, window } from 'vscode'; import { Environment } from '@azure/ms-rest-azure-env'; import { CachedPublicClientApplicationManager } from './publicClientCache'; import { UriEventHandler } from '../UriEventHandler'; @@ -154,9 +154,9 @@ export class MsalAuthProvider implements AuthenticationProvider { //#region AuthenticationProvider methods - async getSessions(scopes: string[] | undefined, options?: AuthenticationGetSessionOptions): Promise { + async getSessions(scopes: string[] | undefined, options: AuthenticationGetSessionOptions = {}): Promise { const askingForAll = scopes === undefined; - const scopeData = new ScopeData(scopes); + const scopeData = new ScopeData(scopes, options?.issuer); // Do NOT use `scopes` beyond this place in the code. Use `scopeData` instead. this._logger.info('[getSessions]', askingForAll ? '[all]' : `[${scopeData.scopeStr}]`, 'starting'); @@ -186,7 +186,7 @@ export class MsalAuthProvider implements AuthenticationProvider { } async createSession(scopes: readonly string[], options: AuthenticationProviderSessionOptions): Promise { - const scopeData = new ScopeData(scopes); + const scopeData = new ScopeData(scopes, options.issuer); // Do NOT use `scopes` beyond this place in the code. Use `scopeData` instead. this._logger.info('[createSession]', `[${scopeData.scopeStr}]`, 'starting'); diff --git a/extensions/microsoft-authentication/tsconfig.json b/extensions/microsoft-authentication/tsconfig.json index b40c2eb8716..dc4571cacda 100644 --- a/extensions/microsoft-authentication/tsconfig.json +++ b/extensions/microsoft-authentication/tsconfig.json @@ -23,6 +23,7 @@ "src/**/*", "../../src/vscode-dts/vscode.d.ts", "../../src/vscode-dts/vscode.proposed.idToken.d.ts", - "../../src/vscode-dts/vscode.proposed.nativeWindowHandle.d.ts" + "../../src/vscode-dts/vscode.proposed.nativeWindowHandle.d.ts", + "../../src/vscode-dts/vscode.proposed.authIssuers.d.ts" ] } diff --git a/src/vs/platform/extensions/common/extensions.ts b/src/vs/platform/extensions/common/extensions.ts index f0a4ef36557..9974a54d333 100644 --- a/src/vs/platform/extensions/common/extensions.ts +++ b/src/vs/platform/extensions/common/extensions.ts @@ -104,6 +104,7 @@ export interface ICodeActionContribution { export interface IAuthenticationContribution { readonly id: string; readonly label: string; + readonly issuerGlobs?: string[]; } export interface IWalkthroughStep { diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 1d494043e19..180bc1b1e88 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -19,6 +19,9 @@ const _allApiProposals = { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.aiTextSearchProvider.d.ts', version: 2 }, + authIssuers: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.authIssuers.d.ts', + }, authLearnMore: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.authLearnMore.d.ts', }, diff --git a/src/vs/workbench/api/browser/mainThreadAuthentication.ts b/src/vs/workbench/api/browser/mainThreadAuthentication.ts index 64413947986..db00ad2aa60 100644 --- a/src/vs/workbench/api/browser/mainThreadAuthentication.ts +++ b/src/vs/workbench/api/browser/mainThreadAuthentication.ts @@ -34,6 +34,7 @@ export interface AuthenticationGetSessionOptions { forceNewSession?: boolean | AuthenticationInteractiveOptions; silent?: boolean; account?: AuthenticationSessionAccount; + issuer?: UriComponents; } export class MainThreadAuthenticationProvider extends Disposable implements IAuthenticationProvider { @@ -45,6 +46,7 @@ export class MainThreadAuthenticationProvider extends Disposable implements IAut public readonly id: string, public readonly label: string, public readonly supportsMultipleAccounts: boolean, + public readonly issuers: ReadonlyArray, private readonly notificationService: INotificationService, onDidChangeSessionsEmitter: Emitter, ) { @@ -98,7 +100,7 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu })); } - async $registerAuthenticationProvider(id: string, label: string, supportsMultipleAccounts: boolean): Promise { + async $registerAuthenticationProvider(id: string, label: string, supportsMultipleAccounts: boolean, supportedIssuers: UriComponents[] = []): Promise { if (!this.authenticationService.declaredProviders.find(p => p.id === id)) { // If telemetry shows that this is not happening much, we can instead throw an error here. this.logService.warn(`Authentication provider ${id} was not declared in the Extension Manifest.`); @@ -111,7 +113,8 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu } const emitter = new Emitter(); this._registrations.set(id, emitter); - const provider = new MainThreadAuthenticationProvider(this._proxy, id, label, supportsMultipleAccounts, this.notificationService, emitter); + const supportedIssuerUris = supportedIssuers.map(i => URI.revive(i)); + const provider = new MainThreadAuthenticationProvider(this._proxy, id, label, supportsMultipleAccounts, supportedIssuerUris, this.notificationService, emitter); this.authenticationService.registerAuthenticationProvider(id, provider); } @@ -203,7 +206,8 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu } private async doGetSession(providerId: string, scopes: string[], extensionId: string, extensionName: string, options: AuthenticationGetSessionOptions): Promise { - const sessions = await this.authenticationService.getSessions(providerId, scopes, options.account, true); + const issuer = URI.revive(options.issuer); + const sessions = await this.authenticationService.getSessions(providerId, scopes, options.account, true, issuer); const provider = this.authenticationService.getProvider(providerId); // Error cases @@ -268,7 +272,14 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu } else { const accountToCreate: AuthenticationSessionAccount | undefined = options.account ?? matchingAccountPreferenceSession?.account; do { - session = await this.authenticationService.createSession(providerId, scopes, { activateImmediate: true, account: accountToCreate }); + session = await this.authenticationService.createSession( + providerId, + scopes, + { + activateImmediate: true, + account: accountToCreate, + issuer + }); } while ( accountToCreate && accountToCreate.label !== session.account.label diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 92066e32b4d..9c65df21dca 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -298,6 +298,9 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I ) { checkProposedApiEnabled(extension, 'authLearnMore'); } + if (options?.issuer) { + checkProposedApiEnabled(extension, 'authIssuers'); + } return extHostAuthentication.getSession(extension, providerId, scopes, options as any); }, getAccounts(providerId: string) { @@ -312,6 +315,9 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I return _asExtensionEvent(extHostAuthentication.getExtensionScopedSessionsEvent(extension.identifier.value)); }, registerAuthenticationProvider(id: string, label: string, provider: vscode.AuthenticationProvider, options?: vscode.AuthenticationProviderOptions): vscode.Disposable { + if (options?.supportedIssuers) { + checkProposedApiEnabled(extension, 'authIssuers'); + } return extHostAuthentication.registerAuthenticationProvider(id, label, provider, options); } }; diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 1b49019836c..0d175b9d310 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -181,7 +181,7 @@ export interface AuthenticationGetSessionOptions { } export interface MainThreadAuthenticationShape extends IDisposable { - $registerAuthenticationProvider(id: string, label: string, supportsMultipleAccounts: boolean): void; + $registerAuthenticationProvider(id: string, label: string, supportsMultipleAccounts: boolean, supportedIssuers?: UriComponents[]): void; $unregisterAuthenticationProvider(id: string): void; $ensureProvider(id: string): Promise; $sendDidChangeSessions(providerId: string, event: AuthenticationSessionsChangeEvent): void; diff --git a/src/vs/workbench/api/common/extHostAuthentication.ts b/src/vs/workbench/api/common/extHostAuthentication.ts index e705e38df50..ee09391811b 100644 --- a/src/vs/workbench/api/common/extHostAuthentication.ts +++ b/src/vs/workbench/api/common/extHostAuthentication.ts @@ -11,6 +11,7 @@ import { IExtensionDescription, ExtensionIdentifier } from '../../../platform/ex import { INTERNAL_AUTH_PROVIDER_PREFIX } from '../../services/authentication/common/authentication.js'; import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; import { IExtHostRpcService } from './extHostRpcService.js'; +import { URI } from '../../../base/common/uri.js'; export interface IExtHostAuthentication extends ExtHostAuthentication { } export const IExtHostAuthentication = createDecorator('IExtHostAuthentication'); @@ -88,7 +89,7 @@ export class ExtHostAuthentication implements ExtHostAuthenticationShape { this._authenticationProviders.set(id, { label, provider, options: options ?? { supportsMultipleAccounts: false } }); const listener = provider.onDidChangeSessions(e => this._proxy.$sendDidChangeSessions(id, e)); - this._proxy.$registerAuthenticationProvider(id, label, options?.supportsMultipleAccounts ?? false); + this._proxy.$registerAuthenticationProvider(id, label, options?.supportsMultipleAccounts ?? false, options?.supportedIssuers); return new Disposable(() => { listener.dispose(); @@ -100,6 +101,7 @@ export class ExtHostAuthentication implements ExtHostAuthenticationShape { async $createSession(providerId: string, scopes: string[], options: vscode.AuthenticationProviderSessionOptions): Promise { const providerData = this._authenticationProviders.get(providerId); if (providerData) { + options.issuer = URI.revive(options.issuer); return await providerData.provider.createSession(scopes, options); } @@ -118,6 +120,7 @@ export class ExtHostAuthentication implements ExtHostAuthenticationShape { async $getSessions(providerId: string, scopes: ReadonlyArray | undefined, options: vscode.AuthenticationProviderSessionOptions): Promise> { const providerData = this._authenticationProviders.get(providerId); if (providerData) { + options.issuer = URI.revive(options.issuer); return await providerData.provider.getSessions(scopes, options); } diff --git a/src/vs/workbench/contrib/authentication/browser/authentication.contribution.ts b/src/vs/workbench/contrib/authentication/browser/authentication.contribution.ts index 59a151ce08c..1694e0ccbfd 100644 --- a/src/vs/workbench/contrib/authentication/browser/authentication.contribution.ts +++ b/src/vs/workbench/contrib/authentication/browser/authentication.contribution.ts @@ -42,6 +42,7 @@ class AuthenticationDataRenderer extends Disposable implements IExtensionFeature const headers = [ localize('authenticationlabel', "Label"), localize('authenticationid', "ID"), + localize('authenticationMcpAuthorizationServers', "MCP Authorization Servers") ]; const rows: IRowData[][] = authentication @@ -50,6 +51,7 @@ class AuthenticationDataRenderer extends Disposable implements IExtensionFeature return [ auth.label, auth.id, + (auth.issuerGlobs ?? []).join(',\n') ]; }); diff --git a/src/vs/workbench/services/authentication/browser/authenticationService.ts b/src/vs/workbench/services/authentication/browser/authenticationService.ts index 34bae5b5101..16c03601fae 100644 --- a/src/vs/workbench/services/authentication/browser/authenticationService.ts +++ b/src/vs/workbench/services/authentication/browser/authenticationService.ts @@ -18,6 +18,8 @@ import { ActivationKind, IExtensionService } from '../../extensions/common/exten import { ILogService } from '../../../../platform/log/common/log.js'; import { IJSONSchema } from '../../../../base/common/jsonSchema.js'; import { ExtensionsRegistry } from '../../extensions/common/extensionsRegistry.js'; +import { match } from '../../../../base/common/glob.js'; +import { URI } from '../../../../base/common/uri.js'; export function getAuthenticationProviderActivationEvent(id: string): string { return `onAuthenticationRequest:${id}`; } @@ -250,10 +252,17 @@ export class AuthenticationService extends Disposable implements IAuthentication return accounts; } - async getSessions(id: string, scopes?: string[], account?: AuthenticationSessionAccount, activateImmediate: boolean = false): Promise> { + async getSessions(id: string, scopes?: string[], account?: AuthenticationSessionAccount, activateImmediate: boolean = false, issuer?: URI): Promise> { const authProvider = this._authenticationProviders.get(id) || await this.tryActivateProvider(id, activateImmediate); if (authProvider) { - return await authProvider.getSessions(scopes, { account }); + // Check if the issuer is in the list of supported issuers + if (issuer) { + const issuerStr = issuer.toString(true); + if (!authProvider.issuers?.some(i => match(i.toString(true), issuerStr))) { + throw new Error(`The issuer '${issuerStr}' is not supported by the authentication provider '${id}'.`); + } + } + return await authProvider.getSessions(scopes, { account, issuer }); } else { throw new Error(`No authentication provider '${id}' is currently registered.`); } @@ -263,7 +272,8 @@ export class AuthenticationService extends Disposable implements IAuthentication const authProvider = this._authenticationProviders.get(id) || await this.tryActivateProvider(id, !!options?.activateImmediate); if (authProvider) { return await authProvider.createSession(scopes, { - account: options?.account + account: options?.account, + issuer: options?.issuer }); } else { throw new Error(`No authentication provider '${id}' is currently registered.`); @@ -279,6 +289,22 @@ export class AuthenticationService extends Disposable implements IAuthentication } } + // Not used yet but will be... + async getOrActivateProviderIdForIssuer(issuer: URI): Promise { + const issuerStr = issuer.toString(true); + const providers = this._declaredProviders + .filter(p => !!p.issuerGlobs?.some(i => match(i, issuerStr))); + // TODO:@TylerLeonhardt fan out? + for (const provider of providers) { + const activeProvider = await this.tryActivateProvider(provider.id, true); + // Check the resolved issuers + if (activeProvider.issuers?.some(i => match(i.toString(true), issuerStr))) { + return activeProvider.id; + } + } + return undefined; + } + private async tryActivateProvider(providerId: string, activateImmediate: boolean): Promise { await this._extensionService.activateByEvent(getAuthenticationProviderActivationEvent(providerId), activateImmediate ? ActivationKind.Immediate : ActivationKind.Normal); let provider = this._authenticationProviders.get(providerId); diff --git a/src/vs/workbench/services/authentication/common/authentication.ts b/src/vs/workbench/services/authentication/common/authentication.ts index f3643cf2365..bf2d0d588d0 100644 --- a/src/vs/workbench/services/authentication/common/authentication.ts +++ b/src/vs/workbench/services/authentication/common/authentication.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { Event } from '../../../../base/common/event.js'; +import { URI } from '../../../../base/common/uri.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; /** @@ -32,6 +33,7 @@ export interface AuthenticationSessionsChangeEvent { export interface AuthenticationProviderInformation { id: string; label: string; + issuerGlobs?: ReadonlyArray; } export interface IAuthenticationCreateSessionOptions { @@ -41,6 +43,11 @@ export interface IAuthenticationCreateSessionOptions { * attempt to return the sessions that are only related to this account. */ account?: AuthenticationSessionAccount; + /** + * The issuer URI to use for this creation request. If passed in, first we validate that + * the provider can use this issuer, then it is passed down to the auth provider. + */ + issuer?: URI; } export interface AllowedExtension { @@ -138,11 +145,14 @@ export interface IAuthenticationService { /** * Gets all sessions that satisfy the given scopes from the provider with the given id + * TODO:@TylerLeonhardt Refactor this to use an options bag for account and issuer * @param id The id of the provider to ask for a session * @param scopes The scopes for the session + * @param account The account for the session * @param activateImmediate If true, the provider should activate immediately if it is not already + * @param issuer The issuer for the session */ - getSessions(id: string, scopes?: string[], account?: AuthenticationSessionAccount, activateImmediate?: boolean): Promise>; + getSessions(id: string, scopes?: string[], account?: AuthenticationSessionAccount, activateImmediate?: boolean, issuer?: URI): Promise>; /** * Creates an AuthenticationSession with the given provider and scopes @@ -158,6 +168,12 @@ export interface IAuthenticationService { * @param sessionId The id of the session to remove */ removeSession(providerId: string, sessionId: string): Promise; + + /** + * Gets a provider id for a specified issuer + * @param issuer The issuer url that this provider is responsible for + */ + getOrActivateProviderIdForIssuer(issuer: URI): Promise; } // TODO: Move this into MainThreadAuthentication @@ -224,6 +240,11 @@ export interface IAuthenticationProviderSessionOptions { * attempt to return the sessions that are only related to this account. */ account?: AuthenticationSessionAccount; + /** + * The issuer that is being asked about. If this is passed in, the provider should + * attempt to return sessions that are only related to this issuer. + */ + issuer?: URI; } /** @@ -240,6 +261,11 @@ export interface IAuthenticationProvider { */ readonly label: string; + /** + * The resolved issuers. These can still contain globs, but should be concrete URIs + */ + readonly issuers?: ReadonlyArray; + /** * Indicates whether the authentication provider supports multiple accounts. */ diff --git a/src/vs/workbench/services/authentication/test/browser/authenticationService.test.ts b/src/vs/workbench/services/authentication/test/browser/authenticationService.test.ts index c4b40487937..4d7ee6f8e80 100644 --- a/src/vs/workbench/services/authentication/test/browser/authenticationService.test.ts +++ b/src/vs/workbench/services/authentication/test/browser/authenticationService.test.ts @@ -6,6 +6,7 @@ import assert from 'assert'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { URI } from '../../../../../base/common/uri.js'; import { AuthenticationAccessService } from '../../browser/authenticationAccessService.js'; import { AuthenticationService } from '../../browser/authenticationService.js'; import { AuthenticationProviderInformation, AuthenticationSessionsChangeEvent, IAuthenticationProvider } from '../../common/authentication.js'; @@ -127,10 +128,104 @@ suite('AuthenticationService', () => { // Assert that the retrieved provider is the same as the registered provider assert.deepEqual(retrievedProvider, provider); }); + + test('getOrActivateProviderIdForIssuer - should return undefined when no provider matches the issuer', async () => { + const issuer = URI.parse('https://example.com'); + const result = await authenticationService.getOrActivateProviderIdForIssuer(issuer); + assert.strictEqual(result, undefined); + }); + + test('getOrActivateProviderIdForIssuer - should return provider id if issuerGlobs matches and issuers match', async () => { + // Register a declared provider with an issuer glob + const provider: AuthenticationProviderInformation = { + id: 'github', + label: 'GitHub', + issuerGlobs: ['https://github.com/*'] + }; + authenticationService.registerDeclaredAuthenticationProvider(provider); + + // Register an authentication provider with matching issuers + const authProvider = createProvider({ + id: 'github', + label: 'GitHub', + issuers: [URI.parse('https://github.com/login')] + }); + authenticationService.registerAuthenticationProvider('github', authProvider); + + // Test with a matching URI + const issuer = URI.parse('https://github.com/login'); + const result = await authenticationService.getOrActivateProviderIdForIssuer(issuer); + + // Verify the result + assert.strictEqual(result, 'github'); + }); + + test('getOrActivateProviderIdForIssuer - should return undefined if issuerGlobs match but issuers do not match', async () => { + // Register a declared provider with an issuer glob + const provider: AuthenticationProviderInformation = { + id: 'github', + label: 'GitHub', + issuerGlobs: ['https://github.com/*'] + }; + authenticationService.registerDeclaredAuthenticationProvider(provider); + + // Register an authentication provider with non-matching issuers + const authProvider = createProvider({ + id: 'github', + label: 'GitHub', + issuers: [URI.parse('https://github.com/different')] + }); + authenticationService.registerAuthenticationProvider('github', authProvider); + + // Test with a non-matching URI + const issuer = URI.parse('https://github.com/login'); + const result = await authenticationService.getOrActivateProviderIdForIssuer(issuer); + + // Verify the result + assert.strictEqual(result, undefined); + }); + + test('getOrActivateProviderIdForIssuer - should check multiple providers and return the first match', async () => { + // Register two declared providers with issuer globs + const provider1: AuthenticationProviderInformation = { + id: 'github', + label: 'GitHub', + issuerGlobs: ['https://github.com/*'] + }; + const provider2: AuthenticationProviderInformation = { + id: 'microsoft', + label: 'Microsoft', + issuerGlobs: ['https://login.microsoftonline.com/*'] + }; + authenticationService.registerDeclaredAuthenticationProvider(provider1); + authenticationService.registerDeclaredAuthenticationProvider(provider2); + + // Register authentication providers + const githubProvider = createProvider({ + id: 'github', + label: 'GitHub', + issuers: [URI.parse('https://github.com/different')] + }); + authenticationService.registerAuthenticationProvider('github', githubProvider); + + const microsoftProvider = createProvider({ + id: 'microsoft', + label: 'Microsoft', + issuers: [URI.parse('https://login.microsoftonline.com/common')] + }); + authenticationService.registerAuthenticationProvider('microsoft', microsoftProvider); + + // Test with a URI that should match the second provider + const issuer = URI.parse('https://login.microsoftonline.com/common'); + const result = await authenticationService.getOrActivateProviderIdForIssuer(issuer); + + // Verify the result + assert.strictEqual(result, 'microsoft'); + }); }); suite('authenticationSessions', () => { - test('getSessions', async () => { + test('getSessions - base case', async () => { let isCalled = false; const provider = createProvider({ getSessions: async () => { @@ -145,6 +240,19 @@ suite('AuthenticationService', () => { assert.ok(isCalled); }); + test('getSessions - issuer is not registered', async () => { + let isCalled = false; + const provider = createProvider({ + getSessions: async () => { + isCalled = true; + return [createSession()]; + }, + }); + authenticationService.registerAuthenticationProvider(provider.id, provider); + assert.rejects(() => authenticationService.getSessions(provider.id, [], undefined, undefined, URI.parse('https://example.com'))); + assert.ok(!isCalled); + }); + test('createSession', async () => { const emitter = new Emitter(); const provider = createProvider({ diff --git a/src/vscode-dts/vscode.proposed.authIssuers.d.ts b/src/vscode-dts/vscode.proposed.authIssuers.d.ts new file mode 100644 index 00000000000..57afe5d03b7 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.authIssuers.d.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + export interface AuthenticationProviderOptions { + /** + * When specified, this provider will be associated with these issuers. They can still contain globs + * just like their extension contribution counterparts. + */ + readonly supportedIssuers?: Uri[]; + } + + export interface AuthenticationProviderSessionOptions { + /** + * When specified, the authentication provider will use the provided issuer URL to + * authenticate the user. This is only used when a provider `supportsIssuerOverride` is set to true + */ + issuer?: Uri; + } + + export interface AuthenticationGetSessionOptions { + /** + * When specified, the authentication provider will use the provided issuer URL to + * authenticate the user. This is only used when a provider `supportsIssuerOverride` is set to true + */ + issuer?: Uri; + } +}