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",
"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": [{

View File

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

View File

@@ -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"
]
}

View File

@@ -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",

View File

@@ -203,7 +203,7 @@ export class AzureActiveDirectoryService {
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) {
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<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];
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`);

View File

@@ -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<string | undefined>((prev, current) => {
if (current.startsWith('VSCODE_TENANT:')) {
return current.split('VSCODE_TENANT:')[1];

View File

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

View File

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

View File

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

View File

@@ -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<AuthenticationSession[]> {
async getSessions(scopes: string[] | undefined, options: AuthenticationGetSessionOptions = {}): Promise<AuthenticationSession[]> {
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<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.
this._logger.info('[createSession]', `[${scopeData.scopeStr}]`, 'starting');

View File

@@ -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"
]
}

View File

@@ -104,6 +104,7 @@ export interface ICodeActionContribution {
export interface IAuthenticationContribution {
readonly id: string;
readonly label: string;
readonly issuerGlobs?: string[];
}
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',
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',
},

View File

@@ -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<URI>,
private readonly notificationService: INotificationService,
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 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<AuthenticationSessionsChangeEvent>();
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<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);
// 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

View File

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

View File

@@ -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<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 { 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>('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<vscode.AuthenticationSession> {
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<string> | undefined, options: vscode.AuthenticationProviderSessionOptions): Promise<ReadonlyArray<vscode.AuthenticationSession>> {
const providerData = this._authenticationProviders.get(providerId);
if (providerData) {
options.issuer = URI.revive(options.issuer);
return await providerData.provider.getSessions(scopes, options);
}

View File

@@ -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')
];
});

View File

@@ -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<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);
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<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> {
await this._extensionService.activateByEvent(getAuthenticationProviderActivationEvent(providerId), activateImmediate ? ActivationKind.Immediate : ActivationKind.Normal);
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.
*--------------------------------------------------------------------------------------------*/
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<string>;
}
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<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
@@ -158,6 +168,12 @@ export interface IAuthenticationService {
* @param sessionId The id of the session to remove
*/
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
@@ -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<URI>;
/**
* Indicates whether the authentication provider supports multiple accounts.
*/

View File

@@ -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<AuthenticationSessionsChangeEvent>();
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;
}
}