Introduce Issuer handling in the Authentication stack (#248948)

Mostly plumbing... this enables:
```
vscode.authentication.getSession('microsoft', scopes, { issuer: "https://login.microsoftonline.com/common/v2.0" });
```
And the respective API for an auth providers to handle it being passed in.

This props up work in MCP land which needs a way to map an issuer to an auth provider... but I certainly see utility outside of that space.

Fixes https://github.com/microsoft/vscode/issues/248775#issuecomment-2876711396
This commit is contained in:
Tyler James Leonhardt
2025-05-14 14:02:15 -07:00
committed by GitHub
parent 1e03c9074c
commit 86efdcd2c1
22 changed files with 365 additions and 69 deletions

View File

@@ -17,6 +17,9 @@
"ui", "ui",
"workspace" "workspace"
], ],
"enabledApiProposals": [
"authIssuers"
],
"activationEvents": [], "activationEvents": [],
"capabilities": { "capabilities": {
"virtualWorkspaces": true, "virtualWorkspaces": true,
@@ -31,11 +34,17 @@
"authentication": [ "authentication": [
{ {
"label": "GitHub", "label": "GitHub",
"id": "github" "id": "github",
"issuerGlobs": [
"https://github.com/login/oauth"
]
}, },
{ {
"label": "GitHub Enterprise Server", "label": "GitHub Enterprise Server",
"id": "github-enterprise" "id": "github-enterprise",
"issuerGlobs": [
"*"
]
} }
], ],
"configuration": [{ "configuration": [{

View File

@@ -137,7 +137,17 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid
this._disposable = vscode.Disposable.from( this._disposable = vscode.Disposable.from(
this._telemetryReporter, 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()) this.context.secrets.onDidChange(() => this.checkForUpdates())
); );
} }

View File

@@ -12,6 +12,7 @@
}, },
"include": [ "include": [
"src/**/*", "src/**/*",
"../../src/vscode-dts/vscode.d.ts" "../../src/vscode-dts/vscode.d.ts",
"../../src/vscode-dts/vscode.proposed.authIssuers.d.ts"
] ]
} }

View File

@@ -15,7 +15,8 @@
"activationEvents": [], "activationEvents": [],
"enabledApiProposals": [ "enabledApiProposals": [
"idToken", "idToken",
"nativeWindowHandle" "nativeWindowHandle",
"authIssuers"
], ],
"capabilities": { "capabilities": {
"virtualWorkspaces": true, "virtualWorkspaces": true,
@@ -31,7 +32,10 @@
"authentication": [ "authentication": [
{ {
"label": "Microsoft", "label": "Microsoft",
"id": "microsoft" "id": "microsoft",
"issuerGlobs": [
"https://login.microsoftonline.com/*/v2.0"
]
}, },
{ {
"label": "Microsoft Sovereign Cloud", "label": "Microsoft Sovereign Cloud",

View File

@@ -203,7 +203,7 @@ export class AzureActiveDirectoryService {
return this._sessionChangeEmitter.event; return this._sessionChangeEmitter.event;
} }
public getSessions(scopes?: string[], account?: vscode.AuthenticationSessionAccountInformation): Promise<vscode.AuthenticationSession[]> { public getSessions(scopes: string[] | undefined, { account, issuer }: vscode.AuthenticationProviderSessionOptions = {}): Promise<vscode.AuthenticationSession[]> {
if (!scopes) { if (!scopes) {
this._logger.info('Getting sessions for all scopes...'); this._logger.info('Getting sessions for all scopes...');
const sessions = this._tokens const sessions = this._tokens
@@ -226,6 +226,12 @@ export class AzureActiveDirectoryService {
if (!modifiedScopes.includes('offline_access')) { if (!modifiedScopes.includes('offline_access')) {
modifiedScopes.push('offline_access'); modifiedScopes.push('offline_access');
} }
if (issuer) {
const tenant = issuer.path.split('/')[1];
if (tenant) {
modifiedScopes.push(`VSCODE_TENANT:${tenant}`);
}
}
modifiedScopes = modifiedScopes.sort(); modifiedScopes = modifiedScopes.sort();
const modifiedScopesStr = modifiedScopes.join(' '); const modifiedScopesStr = modifiedScopes.join(' ');
@@ -237,7 +243,7 @@ export class AzureActiveDirectoryService {
scopeStr: modifiedScopesStr, scopeStr: modifiedScopesStr,
// filter our special scopes // filter our special scopes
scopesToSend: modifiedScopes.filter(s => !s.startsWith('VSCODE_')).join(' '), 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}` : ''); this._logger.trace(`[${scopeData.scopeStr}] Queued getting sessions` + account ? ` for ${account?.label}` : '');
@@ -297,7 +303,7 @@ export class AzureActiveDirectoryService {
.map(result => (result as PromiseFulfilledResult<vscode.AuthenticationSession>).value); .map(result => (result as PromiseFulfilledResult<vscode.AuthenticationSession>).value);
} }
public createSession(scopes: string[], account?: vscode.AuthenticationSessionAccountInformation): Promise<vscode.AuthenticationSession> { public createSession(scopes: string[], { account, issuer }: vscode.AuthenticationProviderSessionOptions = {}): Promise<vscode.AuthenticationSession> {
let modifiedScopes = [...scopes]; let modifiedScopes = [...scopes];
if (!modifiedScopes.includes('openid')) { if (!modifiedScopes.includes('openid')) {
modifiedScopes.push('openid'); modifiedScopes.push('openid');
@@ -311,6 +317,12 @@ export class AzureActiveDirectoryService {
if (!modifiedScopes.includes('offline_access')) { if (!modifiedScopes.includes('offline_access')) {
modifiedScopes.push('offline_access'); modifiedScopes.push('offline_access');
} }
if (issuer) {
const tenant = issuer.path.split('/')[1];
if (tenant) {
modifiedScopes.push(`VSCODE_TENANT:${tenant}`);
}
}
modifiedScopes = modifiedScopes.sort(); modifiedScopes = modifiedScopes.sort();
const scopeData: IScopeData = { const scopeData: IScopeData = {
originalScopes: scopes, originalScopes: scopes,
@@ -319,7 +331,7 @@ export class AzureActiveDirectoryService {
// filter our special scopes // filter our special scopes
scopesToSend: modifiedScopes.filter(s => !s.startsWith('VSCODE_')).join(' '), scopesToSend: modifiedScopes.filter(s => !s.startsWith('VSCODE_')).join(' '),
clientId: this.getClientId(scopes), clientId: this.getClientId(scopes),
tenant: this.getTenantId(scopes), tenant: this.getTenantId(modifiedScopes),
}; };
this._logger.trace(`[${scopeData.scopeStr}] Queued creating session`); this._logger.trace(`[${scopeData.scopeStr}] Queued creating session`);

View File

@@ -3,6 +3,8 @@
* Licensed under the MIT License. See License.txt in the project root for license information. * 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_CLIENT_ID = 'aebc6443-996d-45c2-90f0-388ff96faa56';
const DEFAULT_TENANT = 'organizations'; const DEFAULT_TENANT = 'organizations';
@@ -43,14 +45,14 @@ export class ScopeData {
*/ */
readonly tenantId: string | undefined; readonly tenantId: string | undefined;
constructor(readonly originalScopes: readonly string[] = []) { constructor(readonly originalScopes: readonly string[] = [], issuer?: Uri) {
const modifiedScopes = [...originalScopes]; const modifiedScopes = [...originalScopes];
modifiedScopes.sort(); modifiedScopes.sort();
this.allScopes = modifiedScopes; this.allScopes = modifiedScopes;
this.scopeStr = modifiedScopes.join(' '); this.scopeStr = modifiedScopes.join(' ');
this.scopesToSend = this.getScopesToSend(modifiedScopes); this.scopesToSend = this.getScopesToSend(modifiedScopes);
this.clientId = this.getClientId(this.allScopes); 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); this.tenantId = this.getTenantId(this.tenant);
} }
@@ -63,7 +65,14 @@ export class ScopeData {
}, undefined) ?? DEFAULT_CLIENT_ID; }, 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<string | undefined>((prev, current) => { return scopes.reduce<string | undefined>((prev, current) => {
if (current.startsWith('VSCODE_TENANT:')) { if (current.startsWith('VSCODE_TENANT:')) {
return current.split('VSCODE_TENANT:')[1]; return current.split('VSCODE_TENANT:')[1];

View File

@@ -5,6 +5,7 @@
import * as assert from 'assert'; import * as assert from 'assert';
import { ScopeData } from '../scopeData'; import { ScopeData } from '../scopeData';
import { Uri } from 'vscode';
suite('ScopeData', () => { suite('ScopeData', () => {
test('should include default scopes if not present', () => { test('should include default scopes if not present', () => {
@@ -73,4 +74,22 @@ suite('ScopeData', () => {
const scopeData = new ScopeData(['custom_scope', 'VSCODE_TENANT:some_guid']); const scopeData = new ScopeData(['custom_scope', 'VSCODE_TENANT:some_guid']);
assert.strictEqual(scopeData.tenantId, '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');
});
}); });

View File

@@ -122,49 +122,59 @@ export async function activate(context: vscode.ExtensionContext, telemetryReport
Environment.AzureCloud); Environment.AzureCloud);
await loginService.initialize(); await loginService.initialize();
context.subscriptions.push(vscode.authentication.registerAuthenticationProvider('microsoft', 'Microsoft', { context.subscriptions.push(vscode.authentication.registerAuthenticationProvider(
onDidChangeSessions: loginService.onDidChangeSessions, 'microsoft',
getSessions: (scopes: string[], options?: vscode.AuthenticationProviderSessionOptions) => loginService.getSessions(scopes, options?.account), 'Microsoft',
createSession: async (scopes: string[], options?: vscode.AuthenticationProviderSessionOptions) => { {
try { onDidChangeSessions: loginService.onDidChangeSessions,
/* __GDPR__ getSessions: (scopes: string[], options?: vscode.AuthenticationProviderSessionOptions) => loginService.getSessions(scopes, options),
"login" : { createSession: async (scopes: string[], options?: vscode.AuthenticationProviderSessionOptions) => {
"owner": "TylerLeonhardt", try {
"comment": "Used to determine the usage of the Microsoft Auth Provider.", /* __GDPR__
"scopes": { "classification": "PublicNonPersonalData", "purpose": "FeatureInsight", "comment": "Used to determine what scope combinations are being requested." } "login" : {
} "owner": "TylerLeonhardt",
*/ "comment": "Used to determine the usage of the Microsoft Auth Provider.",
telemetryReporter.sendTelemetryEvent('login', { "scopes": { "classification": "PublicNonPersonalData", "purpose": "FeatureInsight", "comment": "Used to determine what scope combinations are being requested." }
// 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}'))), */
}); 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); return await loginService.createSession(scopes, options);
} catch (e) { } catch (e) {
/* __GDPR__ /* __GDPR__
"loginFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users run into issues with the login flow." } "loginFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users run into issues with the login flow." }
*/ */
telemetryReporter.sendTelemetryEvent('loginFailed'); 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 { supportsMultipleAccounts: true,
/* __GDPR__ supportedIssuers: [
"logout" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users log out." } vscode.Uri.parse('https://login.microsoftonline.com/*/v2.0')
*/ ]
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 })); ));
let microsoftSovereignCloudAuthProviderDisposable = await initMicrosoftSovereignCloudAuthProvider(context, telemetryReporter, uriHandler, betterSecretStorage); let microsoftSovereignCloudAuthProviderDisposable = await initMicrosoftSovereignCloudAuthProvider(context, telemetryReporter, uriHandler, betterSecretStorage);

View File

@@ -7,7 +7,7 @@ import { Environment, EnvironmentParameters } from '@azure/ms-rest-azure-env';
import Logger from './logger'; import Logger from './logger';
import { MsalAuthProvider } from './node/authProvider'; import { MsalAuthProvider } from './node/authProvider';
import { UriEventHandler } from './UriEventHandler'; 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'; import { MicrosoftAuthenticationTelemetryReporter, MicrosoftSovereignCloudAuthenticationTelemetryReporter } from './common/telemetryReporter';
async function initMicrosoftSovereignCloudAuthProvider( async function initMicrosoftSovereignCloudAuthProvider(
@@ -79,7 +79,12 @@ export async function activate(context: ExtensionContext, mainTelemetryReporter:
'microsoft', 'microsoft',
'Microsoft', 'Microsoft',
authProvider, authProvider,
{ supportsMultipleAccounts: true } {
supportsMultipleAccounts: true,
supportedIssuers: [
Uri.parse('https://login.microsoftonline.com/*/v2.0')
]
}
)); ));
let microsoftSovereignCloudAuthProviderDisposable = await initMicrosoftSovereignCloudAuthProvider(context, uriHandler); let microsoftSovereignCloudAuthProviderDisposable = await initMicrosoftSovereignCloudAuthProvider(context, uriHandler);

View File

@@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information. * 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 { 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 { Environment } from '@azure/ms-rest-azure-env';
import { CachedPublicClientApplicationManager } from './publicClientCache'; import { CachedPublicClientApplicationManager } from './publicClientCache';
import { UriEventHandler } from '../UriEventHandler'; import { UriEventHandler } from '../UriEventHandler';
@@ -154,9 +154,9 @@ export class MsalAuthProvider implements AuthenticationProvider {
//#region AuthenticationProvider methods //#region AuthenticationProvider methods
async getSessions(scopes: string[] | undefined, options?: AuthenticationGetSessionOptions): Promise<AuthenticationSession[]> { async getSessions(scopes: string[] | undefined, options: AuthenticationGetSessionOptions = {}): Promise<AuthenticationSession[]> {
const askingForAll = scopes === undefined; 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. // Do NOT use `scopes` beyond this place in the code. Use `scopeData` instead.
this._logger.info('[getSessions]', askingForAll ? '[all]' : `[${scopeData.scopeStr}]`, 'starting'); 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<AuthenticationSession> { async createSession(scopes: readonly string[], options: AuthenticationProviderSessionOptions): Promise<AuthenticationSession> {
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. // Do NOT use `scopes` beyond this place in the code. Use `scopeData` instead.
this._logger.info('[createSession]', `[${scopeData.scopeStr}]`, 'starting'); this._logger.info('[createSession]', `[${scopeData.scopeStr}]`, 'starting');

View File

@@ -23,6 +23,7 @@
"src/**/*", "src/**/*",
"../../src/vscode-dts/vscode.d.ts", "../../src/vscode-dts/vscode.d.ts",
"../../src/vscode-dts/vscode.proposed.idToken.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"
] ]
} }

View File

@@ -104,6 +104,7 @@ export interface ICodeActionContribution {
export interface IAuthenticationContribution { export interface IAuthenticationContribution {
readonly id: string; readonly id: string;
readonly label: string; readonly label: string;
readonly issuerGlobs?: string[];
} }
export interface IWalkthroughStep { export interface IWalkthroughStep {

View File

@@ -19,6 +19,9 @@ const _allApiProposals = {
proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.aiTextSearchProvider.d.ts', proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.aiTextSearchProvider.d.ts',
version: 2 version: 2
}, },
authIssuers: {
proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.authIssuers.d.ts',
},
authLearnMore: { authLearnMore: {
proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.authLearnMore.d.ts', proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.authLearnMore.d.ts',
}, },

View File

@@ -34,6 +34,7 @@ export interface AuthenticationGetSessionOptions {
forceNewSession?: boolean | AuthenticationInteractiveOptions; forceNewSession?: boolean | AuthenticationInteractiveOptions;
silent?: boolean; silent?: boolean;
account?: AuthenticationSessionAccount; account?: AuthenticationSessionAccount;
issuer?: UriComponents;
} }
export class MainThreadAuthenticationProvider extends Disposable implements IAuthenticationProvider { export class MainThreadAuthenticationProvider extends Disposable implements IAuthenticationProvider {
@@ -45,6 +46,7 @@ export class MainThreadAuthenticationProvider extends Disposable implements IAut
public readonly id: string, public readonly id: string,
public readonly label: string, public readonly label: string,
public readonly supportsMultipleAccounts: boolean, public readonly supportsMultipleAccounts: boolean,
public readonly issuers: ReadonlyArray<URI>,
private readonly notificationService: INotificationService, private readonly notificationService: INotificationService,
onDidChangeSessionsEmitter: Emitter<AuthenticationSessionsChangeEvent>, onDidChangeSessionsEmitter: Emitter<AuthenticationSessionsChangeEvent>,
) { ) {
@@ -98,7 +100,7 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu
})); }));
} }
async $registerAuthenticationProvider(id: string, label: string, supportsMultipleAccounts: boolean): Promise<void> { async $registerAuthenticationProvider(id: string, label: string, supportsMultipleAccounts: boolean, supportedIssuers: UriComponents[] = []): Promise<void> {
if (!this.authenticationService.declaredProviders.find(p => p.id === id)) { 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. // 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.`); 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<AuthenticationSessionsChangeEvent>(); const emitter = new Emitter<AuthenticationSessionsChangeEvent>();
this._registrations.set(id, 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); 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<AuthenticationSession | undefined> { private async doGetSession(providerId: string, scopes: string[], extensionId: string, extensionName: string, options: AuthenticationGetSessionOptions): Promise<AuthenticationSession | undefined> {
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); const provider = this.authenticationService.getProvider(providerId);
// Error cases // Error cases
@@ -268,7 +272,14 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu
} else { } else {
const accountToCreate: AuthenticationSessionAccount | undefined = options.account ?? matchingAccountPreferenceSession?.account; const accountToCreate: AuthenticationSessionAccount | undefined = options.account ?? matchingAccountPreferenceSession?.account;
do { 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 ( } while (
accountToCreate accountToCreate
&& accountToCreate.label !== session.account.label && accountToCreate.label !== session.account.label

View File

@@ -298,6 +298,9 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I
) { ) {
checkProposedApiEnabled(extension, 'authLearnMore'); checkProposedApiEnabled(extension, 'authLearnMore');
} }
if (options?.issuer) {
checkProposedApiEnabled(extension, 'authIssuers');
}
return extHostAuthentication.getSession(extension, providerId, scopes, options as any); return extHostAuthentication.getSession(extension, providerId, scopes, options as any);
}, },
getAccounts(providerId: string) { getAccounts(providerId: string) {
@@ -312,6 +315,9 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I
return _asExtensionEvent(extHostAuthentication.getExtensionScopedSessionsEvent(extension.identifier.value)); return _asExtensionEvent(extHostAuthentication.getExtensionScopedSessionsEvent(extension.identifier.value));
}, },
registerAuthenticationProvider(id: string, label: string, provider: vscode.AuthenticationProvider, options?: vscode.AuthenticationProviderOptions): vscode.Disposable { 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); return extHostAuthentication.registerAuthenticationProvider(id, label, provider, options);
} }
}; };

View File

@@ -181,7 +181,7 @@ export interface AuthenticationGetSessionOptions {
} }
export interface MainThreadAuthenticationShape extends IDisposable { 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; $unregisterAuthenticationProvider(id: string): void;
$ensureProvider(id: string): Promise<void>; $ensureProvider(id: string): Promise<void>;
$sendDidChangeSessions(providerId: string, event: AuthenticationSessionsChangeEvent): void; $sendDidChangeSessions(providerId: string, event: AuthenticationSessionsChangeEvent): void;

View File

@@ -11,6 +11,7 @@ import { IExtensionDescription, ExtensionIdentifier } from '../../../platform/ex
import { INTERNAL_AUTH_PROVIDER_PREFIX } from '../../services/authentication/common/authentication.js'; import { INTERNAL_AUTH_PROVIDER_PREFIX } from '../../services/authentication/common/authentication.js';
import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; import { createDecorator } from '../../../platform/instantiation/common/instantiation.js';
import { IExtHostRpcService } from './extHostRpcService.js'; import { IExtHostRpcService } from './extHostRpcService.js';
import { URI } from '../../../base/common/uri.js';
export interface IExtHostAuthentication extends ExtHostAuthentication { } export interface IExtHostAuthentication extends ExtHostAuthentication { }
export const IExtHostAuthentication = createDecorator<IExtHostAuthentication>('IExtHostAuthentication'); export const IExtHostAuthentication = createDecorator<IExtHostAuthentication>('IExtHostAuthentication');
@@ -88,7 +89,7 @@ export class ExtHostAuthentication implements ExtHostAuthenticationShape {
this._authenticationProviders.set(id, { label, provider, options: options ?? { supportsMultipleAccounts: false } }); this._authenticationProviders.set(id, { label, provider, options: options ?? { supportsMultipleAccounts: false } });
const listener = provider.onDidChangeSessions(e => this._proxy.$sendDidChangeSessions(id, e)); 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(() => { return new Disposable(() => {
listener.dispose(); listener.dispose();
@@ -100,6 +101,7 @@ export class ExtHostAuthentication implements ExtHostAuthenticationShape {
async $createSession(providerId: string, scopes: string[], options: vscode.AuthenticationProviderSessionOptions): Promise<vscode.AuthenticationSession> { async $createSession(providerId: string, scopes: string[], options: vscode.AuthenticationProviderSessionOptions): Promise<vscode.AuthenticationSession> {
const providerData = this._authenticationProviders.get(providerId); const providerData = this._authenticationProviders.get(providerId);
if (providerData) { if (providerData) {
options.issuer = URI.revive(options.issuer);
return await providerData.provider.createSession(scopes, options); return await providerData.provider.createSession(scopes, options);
} }
@@ -118,6 +120,7 @@ export class ExtHostAuthentication implements ExtHostAuthenticationShape {
async $getSessions(providerId: string, scopes: ReadonlyArray<string> | undefined, options: vscode.AuthenticationProviderSessionOptions): Promise<ReadonlyArray<vscode.AuthenticationSession>> { async $getSessions(providerId: string, scopes: ReadonlyArray<string> | undefined, options: vscode.AuthenticationProviderSessionOptions): Promise<ReadonlyArray<vscode.AuthenticationSession>> {
const providerData = this._authenticationProviders.get(providerId); const providerData = this._authenticationProviders.get(providerId);
if (providerData) { if (providerData) {
options.issuer = URI.revive(options.issuer);
return await providerData.provider.getSessions(scopes, options); return await providerData.provider.getSessions(scopes, options);
} }

View File

@@ -42,6 +42,7 @@ class AuthenticationDataRenderer extends Disposable implements IExtensionFeature
const headers = [ const headers = [
localize('authenticationlabel', "Label"), localize('authenticationlabel', "Label"),
localize('authenticationid', "ID"), localize('authenticationid', "ID"),
localize('authenticationMcpAuthorizationServers', "MCP Authorization Servers")
]; ];
const rows: IRowData[][] = authentication const rows: IRowData[][] = authentication
@@ -50,6 +51,7 @@ class AuthenticationDataRenderer extends Disposable implements IExtensionFeature
return [ return [
auth.label, auth.label,
auth.id, auth.id,
(auth.issuerGlobs ?? []).join(',\n')
]; ];
}); });

View File

@@ -18,6 +18,8 @@ import { ActivationKind, IExtensionService } from '../../extensions/common/exten
import { ILogService } from '../../../../platform/log/common/log.js'; import { ILogService } from '../../../../platform/log/common/log.js';
import { IJSONSchema } from '../../../../base/common/jsonSchema.js'; import { IJSONSchema } from '../../../../base/common/jsonSchema.js';
import { ExtensionsRegistry } from '../../extensions/common/extensionsRegistry.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}`; } export function getAuthenticationProviderActivationEvent(id: string): string { return `onAuthenticationRequest:${id}`; }
@@ -250,10 +252,17 @@ export class AuthenticationService extends Disposable implements IAuthentication
return accounts; return accounts;
} }
async getSessions(id: string, scopes?: string[], account?: AuthenticationSessionAccount, activateImmediate: boolean = false): Promise<ReadonlyArray<AuthenticationSession>> { async getSessions(id: string, scopes?: string[], account?: AuthenticationSessionAccount, activateImmediate: boolean = false, issuer?: URI): Promise<ReadonlyArray<AuthenticationSession>> {
const authProvider = this._authenticationProviders.get(id) || await this.tryActivateProvider(id, activateImmediate); const authProvider = this._authenticationProviders.get(id) || await this.tryActivateProvider(id, activateImmediate);
if (authProvider) { 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 { } else {
throw new Error(`No authentication provider '${id}' is currently registered.`); 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); const authProvider = this._authenticationProviders.get(id) || await this.tryActivateProvider(id, !!options?.activateImmediate);
if (authProvider) { if (authProvider) {
return await authProvider.createSession(scopes, { return await authProvider.createSession(scopes, {
account: options?.account account: options?.account,
issuer: options?.issuer
}); });
} else { } else {
throw new Error(`No authentication provider '${id}' is currently registered.`); 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<string | undefined> {
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<IAuthenticationProvider> { private async tryActivateProvider(providerId: string, activateImmediate: boolean): Promise<IAuthenticationProvider> {
await this._extensionService.activateByEvent(getAuthenticationProviderActivationEvent(providerId), activateImmediate ? ActivationKind.Immediate : ActivationKind.Normal); await this._extensionService.activateByEvent(getAuthenticationProviderActivationEvent(providerId), activateImmediate ? ActivationKind.Immediate : ActivationKind.Normal);
let provider = this._authenticationProviders.get(providerId); let provider = this._authenticationProviders.get(providerId);

View File

@@ -3,6 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information. * Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/ *--------------------------------------------------------------------------------------------*/
import { Event } from '../../../../base/common/event.js'; import { Event } from '../../../../base/common/event.js';
import { URI } from '../../../../base/common/uri.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
/** /**
@@ -32,6 +33,7 @@ export interface AuthenticationSessionsChangeEvent {
export interface AuthenticationProviderInformation { export interface AuthenticationProviderInformation {
id: string; id: string;
label: string; label: string;
issuerGlobs?: ReadonlyArray<string>;
} }
export interface IAuthenticationCreateSessionOptions { export interface IAuthenticationCreateSessionOptions {
@@ -41,6 +43,11 @@ export interface IAuthenticationCreateSessionOptions {
* attempt to return the sessions that are only related to this account. * attempt to return the sessions that are only related to this account.
*/ */
account?: AuthenticationSessionAccount; 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 { 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 * 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 id The id of the provider to ask for a session
* @param scopes The scopes for the 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 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<ReadonlyArray<AuthenticationSession>>; getSessions(id: string, scopes?: string[], account?: AuthenticationSessionAccount, activateImmediate?: boolean, issuer?: URI): Promise<ReadonlyArray<AuthenticationSession>>;
/** /**
* Creates an AuthenticationSession with the given provider and scopes * 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 * @param sessionId The id of the session to remove
*/ */
removeSession(providerId: string, sessionId: string): Promise<void>; removeSession(providerId: string, sessionId: string): Promise<void>;
/**
* Gets a provider id for a specified issuer
* @param issuer The issuer url that this provider is responsible for
*/
getOrActivateProviderIdForIssuer(issuer: URI): Promise<string | undefined>;
} }
// TODO: Move this into MainThreadAuthentication // TODO: Move this into MainThreadAuthentication
@@ -224,6 +240,11 @@ export interface IAuthenticationProviderSessionOptions {
* attempt to return the sessions that are only related to this account. * attempt to return the sessions that are only related to this account.
*/ */
account?: AuthenticationSessionAccount; 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; readonly label: string;
/**
* The resolved issuers. These can still contain globs, but should be concrete URIs
*/
readonly issuers?: ReadonlyArray<URI>;
/** /**
* Indicates whether the authentication provider supports multiple accounts. * Indicates whether the authentication provider supports multiple accounts.
*/ */

View File

@@ -6,6 +6,7 @@
import assert from 'assert'; import assert from 'assert';
import { Emitter, Event } from '../../../../../base/common/event.js'; import { Emitter, Event } from '../../../../../base/common/event.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
import { URI } from '../../../../../base/common/uri.js';
import { AuthenticationAccessService } from '../../browser/authenticationAccessService.js'; import { AuthenticationAccessService } from '../../browser/authenticationAccessService.js';
import { AuthenticationService } from '../../browser/authenticationService.js'; import { AuthenticationService } from '../../browser/authenticationService.js';
import { AuthenticationProviderInformation, AuthenticationSessionsChangeEvent, IAuthenticationProvider } from '../../common/authentication.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 that the retrieved provider is the same as the registered provider
assert.deepEqual(retrievedProvider, 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', () => { suite('authenticationSessions', () => {
test('getSessions', async () => { test('getSessions - base case', async () => {
let isCalled = false; let isCalled = false;
const provider = createProvider({ const provider = createProvider({
getSessions: async () => { getSessions: async () => {
@@ -145,6 +240,19 @@ suite('AuthenticationService', () => {
assert.ok(isCalled); 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 () => { test('createSession', async () => {
const emitter = new Emitter<AuthenticationSessionsChangeEvent>(); const emitter = new Emitter<AuthenticationSessionsChangeEvent>();
const provider = createProvider({ const provider = createProvider({

View File

@@ -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;
}
}