mirror of
https://github.com/microsoft/vscode.git
synced 2026-02-21 10:17:25 +00:00
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:
committed by
GitHub
parent
1e03c9074c
commit
86efdcd2c1
@@ -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": [{
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ export interface ICodeActionContribution {
|
||||
export interface IAuthenticationContribution {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly issuerGlobs?: string[];
|
||||
}
|
||||
|
||||
export interface IWalkthroughStep {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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({
|
||||
|
||||
30
src/vscode-dts/vscode.proposed.authIssuers.d.ts
vendored
Normal file
30
src/vscode-dts/vscode.proposed.authIssuers.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user