diff --git a/extensions/microsoft-authentication/package.json b/extensions/microsoft-authentication/package.json index 3bbe3bcc233..d99aa66b044 100644 --- a/extensions/microsoft-authentication/package.json +++ b/extensions/microsoft-authentication/package.json @@ -16,7 +16,8 @@ "enabledApiProposals": [ "idToken", "nativeWindowHandle", - "authIssuers" + "authIssuers", + "authenticationChallenges" ], "capabilities": { "virtualWorkspaces": true, diff --git a/extensions/microsoft-authentication/src/common/scopeData.ts b/extensions/microsoft-authentication/src/common/scopeData.ts index c9c36f4a87e..17549f5db1b 100644 --- a/extensions/microsoft-authentication/src/common/scopeData.ts +++ b/extensions/microsoft-authentication/src/common/scopeData.ts @@ -45,11 +45,17 @@ export class ScopeData { */ readonly tenantId: string | undefined; - constructor(readonly originalScopes: readonly string[] = [], authorizationServer?: Uri) { + /** + * The claims to include in the token request. + */ + readonly claims?: string; + + constructor(readonly originalScopes: readonly string[] = [], claims?: string, authorizationServer?: Uri) { const modifiedScopes = [...originalScopes]; modifiedScopes.sort(); this.allScopes = modifiedScopes; this.scopeStr = modifiedScopes.join(' '); + this.claims = claims; this.scopesToSend = this.getScopesToSend(modifiedScopes); this.clientId = this.getClientId(this.allScopes); this.tenant = this.getTenant(this.allScopes, authorizationServer); diff --git a/extensions/microsoft-authentication/src/common/test/scopeData.test.ts b/extensions/microsoft-authentication/src/common/test/scopeData.test.ts index e30d0b7ed18..9c057c359c0 100644 --- a/extensions/microsoft-authentication/src/common/test/scopeData.test.ts +++ b/extensions/microsoft-authentication/src/common/test/scopeData.test.ts @@ -75,21 +75,31 @@ suite('ScopeData', () => { assert.strictEqual(scopeData.tenantId, 'some_guid'); }); + test('should not return claims', () => { + const scopeData = new ScopeData(['custom_scope']); + assert.strictEqual(scopeData.claims, undefined); + }); + + test('should return claims', () => { + const scopeData = new ScopeData(['custom_scope'], 'test'); + assert.strictEqual(scopeData.claims, 'test'); + }); + test('should extract tenant from authorization server URL path', () => { const authorizationServer = Uri.parse('https://login.microsoftonline.com/tenant123/oauth2/v2.0'); - const scopeData = new ScopeData(['custom_scope'], authorizationServer); + const scopeData = new ScopeData(['custom_scope'], undefined, authorizationServer); assert.strictEqual(scopeData.tenant, 'tenant123'); }); test('should fallback to default tenant if authorization server URL has no path segments', () => { const authorizationServer = Uri.parse('https://login.microsoftonline.com'); - const scopeData = new ScopeData(['custom_scope'], authorizationServer); + const scopeData = new ScopeData(['custom_scope'], undefined, authorizationServer); assert.strictEqual(scopeData.tenant, 'organizations'); }); test('should prioritize authorization server URL over VSCODE_TENANT scope', () => { const authorizationServer = Uri.parse('https://login.microsoftonline.com/url_tenant/oauth2/v2.0'); - const scopeData = new ScopeData(['custom_scope', 'VSCODE_TENANT:scope_tenant'], authorizationServer); + const scopeData = new ScopeData(['custom_scope', 'VSCODE_TENANT:scope_tenant'], undefined, authorizationServer); assert.strictEqual(scopeData.tenant, 'url_tenant'); }); }); diff --git a/extensions/microsoft-authentication/src/extensionV2.ts b/extensions/microsoft-authentication/src/extensionV2.ts index 3760f0f6ede..3469c85a6aa 100644 --- a/extensions/microsoft-authentication/src/extensionV2.ts +++ b/extensions/microsoft-authentication/src/extensionV2.ts @@ -60,7 +60,7 @@ async function initMicrosoftSovereignCloudAuthProvider( 'microsoft-sovereign-cloud', authProviderName, authProvider, - { supportsMultipleAccounts: true } + { supportsMultipleAccounts: true, supportsChallenges: true } ); context.subscriptions.push(disposable); return disposable; @@ -81,6 +81,7 @@ export async function activate(context: ExtensionContext, mainTelemetryReporter: authProvider, { supportsMultipleAccounts: true, + supportsChallenges: true, supportedAuthorizationServers: [ Uri.parse('https://login.microsoftonline.com/*/v2.0') ] diff --git a/extensions/microsoft-authentication/src/node/authProvider.ts b/extensions/microsoft-authentication/src/node/authProvider.ts index 924346fb50c..62fad9b97e9 100644 --- a/extensions/microsoft-authentication/src/node/authProvider.ts +++ b/extensions/microsoft-authentication/src/node/authProvider.ts @@ -2,8 +2,8 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * 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, Uri, window } from 'vscode'; +import { AccountInfo, AuthenticationResult, ClientAuthError, ClientAuthErrorCodes, ServerError, SilentFlowRequest } from '@azure/msal-node'; +import { AuthenticationChallenge, AuthenticationConstraint, 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'; @@ -14,6 +14,7 @@ import { EventBufferer } from '../common/event'; import { BetterTokenStorage } from '../betterSecretStorage'; import { IStoredSession } from '../AADHelper'; import { ExtensionHost, getMsalFlows } from './flows'; +import { base64Decode } from './buffer'; const redirectUri = 'https://vscode.dev/redirect'; const MSA_TID = '9188040d-6c67-4c5b-b112-36a304b66dad'; @@ -153,7 +154,7 @@ export class MsalAuthProvider implements AuthenticationProvider { async getSessions(scopes: string[] | undefined, options: AuthenticationGetSessionOptions = {}): Promise { const askingForAll = scopes === undefined; - const scopeData = new ScopeData(scopes, options?.authorizationServer); + const scopeData = new ScopeData(scopes, undefined, options?.authorizationServer); // Do NOT use `scopes` beyond this place in the code. Use `scopeData` instead. this._logger.info('[getSessions]', askingForAll ? '[all]' : `[${scopeData.scopeStr}]`, 'starting'); @@ -183,7 +184,7 @@ export class MsalAuthProvider implements AuthenticationProvider { } async createSession(scopes: readonly string[], options: AuthenticationProviderSessionOptions): Promise { - const scopeData = new ScopeData(scopes, options.authorizationServer); + const scopeData = new ScopeData(scopes, undefined, options.authorizationServer); // Do NOT use `scopes` beyond this place in the code. Use `scopeData` instead. this._logger.info('[createSession]', `[${scopeData.scopeStr}]`, 'starting'); @@ -284,6 +285,139 @@ export class MsalAuthProvider implements AuthenticationProvider { this._logger.info('[removeSession]', sessionId, `attempted to remove ${promises.length} sessions`); } + async getSessionsFromChallenges(constraint: AuthenticationConstraint, options: AuthenticationProviderSessionOptions): Promise { + this._logger.info('[getSessionsFromChallenges]', 'starting with', constraint.challenges.length, 'challenges'); + + // Use scopes from constraint if provided, otherwise extract from challenges + const scopes = constraint.scopes?.length ? [...constraint.scopes] : this.extractScopesFromChallenges(constraint.challenges); + const claims = this.extractClaimsFromChallenges(constraint.challenges); + if (!claims) { + throw new Error('No claims found in authentication challenges'); + } + const scopeData = new ScopeData(scopes, claims, options?.authorizationServer); + this._logger.info('[getSessionsFromChallenges]', `[${scopeData.scopeStr}]`, 'with claims:', scopeData.claims); + + const cachedPca = await this._publicClientManager.getOrCreate(scopeData.clientId); + const sessions = await this.getAllSessionsForPca(cachedPca, scopeData, options?.account); + + this._logger.info('[getSessionsFromChallenges]', 'returning', sessions.length, 'sessions'); + return sessions; + } + + async createSessionFromChallenges(constraint: AuthenticationConstraint, options: AuthenticationProviderSessionOptions): Promise { + this._logger.info('[createSessionFromChallenges]', 'starting with', constraint.challenges.length, 'challenges'); + + // Use scopes from constraint if provided, otherwise extract from challenges + const scopes = constraint.scopes?.length ? [...constraint.scopes] : this.extractScopesFromChallenges(constraint.challenges); + const claims = this.extractClaimsFromChallenges(constraint.challenges); + + // Use scopes if available, otherwise fall back to default scopes + const effectiveScopes = scopes.length > 0 ? scopes : ['https://graph.microsoft.com/User.Read']; + + const scopeData = new ScopeData(effectiveScopes, claims, options.authorizationServer); + this._logger.info('[createSessionFromChallenges]', `[${scopeData.scopeStr}]`, 'starting with claims:', claims); + + const cachedPca = await this._publicClientManager.getOrCreate(scopeData.clientId); + + // Used for showing a friendlier message to the user when the explicitly cancel a flow. + let userCancelled: boolean | undefined; + const yes = l10n.t('Yes'); + const no = l10n.t('No'); + const promptToContinue = async (mode: string) => { + if (userCancelled === undefined) { + // We haven't had a failure yet so wait to prompt + return; + } + const message = userCancelled + ? l10n.t('Having trouble logging in? Would you like to try a different way? ({0})', mode) + : l10n.t('You have not yet finished authorizing this extension to use your Microsoft Account. Would you like to try a different way? ({0})', mode); + const result = await window.showWarningMessage(message, yes, no); + if (result !== yes) { + throw new CancellationError(); + } + }; + + const isNodeEnvironment = typeof process !== 'undefined' && typeof process?.versions?.node === 'string'; + const flows = getMsalFlows({ + extensionHost: isNodeEnvironment + ? this._context.extension.extensionKind === ExtensionKind.UI ? ExtensionHost.Local : ExtensionHost.Remote + : ExtensionHost.WebWorker, + }); + + const authority = new URL(scopeData.tenant, this._env.activeDirectoryEndpointUrl).toString(); + let lastError: Error | undefined; + for (const flow of flows) { + if (flow !== flows[0]) { + try { + await promptToContinue(flow.label); + } finally { + this._telemetryReporter.sendLoginFailedEvent(); + } + } + try { + // Create the authentication request with claims if provided + const authRequest = { + cachedPca, + authority, + scopes: scopeData.scopesToSend, + loginHint: options.account?.label, + windowHandle: window.nativeHandle ? Buffer.from(window.nativeHandle) : undefined, + logger: this._logger, + uriHandler: this._uriHandler, + claims: scopeData.claims + }; + + const result = await flow.trigger(authRequest); + + const session = this.sessionFromAuthenticationResult(result, scopeData.originalScopes); + this._telemetryReporter.sendLoginEvent(session.scopes); + this._logger.info('[createSessionFromChallenges]', `[${scopeData.scopeStr}]`, 'returned session'); + return session; + } catch (e) { + lastError = e as Error; + if (e instanceof ClientAuthError && e.errorCode === ClientAuthErrorCodes.userCanceled) { + this._logger.info('[createSessionFromChallenges]', `[${scopeData.scopeStr}]`, 'user cancelled'); + userCancelled = true; + continue; + } + this._logger.error('[createSessionFromChallenges]', `[${scopeData.scopeStr}]`, 'error', e); + throw e; + } + } + + this._telemetryReporter.sendLoginFailedEvent(); + throw lastError ?? new Error('No auth flow succeeded'); + } + + private extractScopesFromChallenges(challenges: readonly AuthenticationChallenge[]): string[] { + const scopes: string[] = []; + for (const challenge of challenges) { + if (challenge.scheme.toLowerCase() === 'bearer' && challenge.params.scope) { + scopes.push(...challenge.params.scope.split(' ')); + } + } + return scopes; + } + + private extractClaimsFromChallenges(challenges: readonly AuthenticationChallenge[]): string | undefined { + for (const challenge of challenges) { + if (challenge.scheme.toLowerCase() === 'bearer' && challenge.params.claims) { + try { + return base64Decode(challenge.params.claims); + } catch (e) { + this._logger.warn('[extractClaimsFromChallenges]', 'failed to decode claims... checking if it is already JSON', e); + try { + JSON.parse(challenge.params.claims); + return challenge.params.claims; + } catch (e) { + this._logger.error('[extractClaimsFromChallenges]', 'failed to parse claims as JSON... returning undefined', e); + } + } + } + } + return undefined; + } + //#endregion private async getAllSessionsForPca( @@ -341,11 +475,18 @@ export class MsalAuthProvider implements AuthenticationProvider { forceRefresh = true; } } + // When claims are present, force refresh to ensure we get a token that satisfies the claims + let claims: string | undefined; + if (scopeData.claims) { + forceRefresh = true; + claims = scopeData.claims; + } const result = await cachedPca.acquireTokenSilent({ account, authority, scopes: scopeData.scopesToSend, redirectUri, + claims, forceRefresh }); sessions.push(this.sessionFromAuthenticationResult(result, scopeData.originalScopes)); diff --git a/extensions/microsoft-authentication/src/node/cachedPublicClientApplication.ts b/extensions/microsoft-authentication/src/node/cachedPublicClientApplication.ts index ac581a4c88e..ab6392684d5 100644 --- a/extensions/microsoft-authentication/src/node/cachedPublicClientApplication.ts +++ b/extensions/microsoft-authentication/src/node/cachedPublicClientApplication.ts @@ -112,7 +112,7 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] id token is expired or about to expire. Forcing refresh...`); const newRequest = this._isBrokerAvailable // HACK: Broker doesn't support forceRefresh so we need to pass in claims which will force a refresh - ? { ...request, claims: '{ "id_token": {}}' } + ? { ...request, claims: request.claims ?? '{ "id_token": {}}' } : { ...request, forceRefresh: true }; result = await this._sequencer.queue(() => this._pca.acquireTokenSilent(newRequest)); this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] got forced result`); @@ -130,7 +130,7 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica // there has been a situation where both tokens are expired. if (this._isBrokerAvailable) { this._logger.error(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] forcing refresh with different claims...`); - const newRequest = { ...request, claims: '{ "access_token": {}}' }; + const newRequest = { ...request, claims: request.claims ?? '{ "access_token": {}}' }; result = await this._sequencer.queue(() => this._pca.acquireTokenSilent(newRequest)); this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}] [${request.account.username}] got forced result with different claims`); const newIdTokenExpirationInSecs = (result.idTokenClaims as { exp?: number }).exp; diff --git a/extensions/microsoft-authentication/src/node/flows.ts b/extensions/microsoft-authentication/src/node/flows.ts index ac678d8313d..4ab7d894b5c 100644 --- a/extensions/microsoft-authentication/src/node/flows.ts +++ b/extensions/microsoft-authentication/src/node/flows.ts @@ -31,6 +31,7 @@ interface IMsalFlowTriggerOptions { windowHandle?: Buffer; logger: LogOutputChannel; uriHandler: UriEventHandler; + claims?: string; } interface IMsalFlow { @@ -46,7 +47,7 @@ class DefaultLoopbackFlow implements IMsalFlow { supportsWebWorkerExtensionHost: false }; - async trigger({ cachedPca, authority, scopes, loginHint, windowHandle, logger }: IMsalFlowTriggerOptions): Promise { + async trigger({ cachedPca, authority, scopes, claims, loginHint, windowHandle, logger }: IMsalFlowTriggerOptions): Promise { logger.info('Trying default msal flow...'); return await cachedPca.acquireTokenInteractive({ openBrowser: async (url: string) => { await env.openExternal(Uri.parse(url)); }, @@ -56,7 +57,8 @@ class DefaultLoopbackFlow implements IMsalFlow { errorTemplate: loopbackTemplate, loginHint, prompt: loginHint ? undefined : 'select_account', - windowHandle + windowHandle, + claims }); } } @@ -68,7 +70,7 @@ class UrlHandlerFlow implements IMsalFlow { supportsWebWorkerExtensionHost: false }; - async trigger({ cachedPca, authority, scopes, loginHint, windowHandle, logger, uriHandler }: IMsalFlowTriggerOptions): Promise { + async trigger({ cachedPca, authority, scopes, claims, loginHint, windowHandle, logger, uriHandler }: IMsalFlowTriggerOptions): Promise { logger.info('Trying protocol handler flow...'); const loopbackClient = new UriHandlerLoopbackClient(uriHandler, redirectUri, logger); return await cachedPca.acquireTokenInteractive({ @@ -78,7 +80,8 @@ class UrlHandlerFlow implements IMsalFlow { loopbackClient, loginHint, prompt: loginHint ? undefined : 'select_account', - windowHandle + windowHandle, + claims }); } } diff --git a/extensions/microsoft-authentication/tsconfig.json b/extensions/microsoft-authentication/tsconfig.json index e36d26389b5..59947e2d995 100644 --- a/extensions/microsoft-authentication/tsconfig.json +++ b/extensions/microsoft-authentication/tsconfig.json @@ -23,6 +23,7 @@ "../../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.authIssuers.d.ts" + "../../src/vscode-dts/vscode.proposed.authIssuers.d.ts", + "../../src/vscode-dts/vscode.proposed.authenticationChallenges.d.ts" ] } diff --git a/src/vs/base/common/oauth.ts b/src/vs/base/common/oauth.ts index b9f90dd7a20..b6f05a53616 100644 --- a/src/vs/base/common/oauth.ts +++ b/src/vs/base/common/oauth.ts @@ -775,21 +775,99 @@ export async function fetchDynamicRegistration(serverMetadata: IAuthorizationSer throw new Error(`Invalid authorization dynamic client registration response: ${JSON.stringify(registration)}`); } +export interface IAuthenticationChallenge { + scheme: string; + params: Record; +} -export function parseWWWAuthenticateHeader(wwwAuthenticateHeaderValue: string) { - const parts = wwwAuthenticateHeaderValue.split(' '); - const scheme = parts[0]; - const params: Record = {}; +export function parseWWWAuthenticateHeader(wwwAuthenticateHeaderValue: string): IAuthenticationChallenge[] { + const challenges: IAuthenticationChallenge[] = []; - if (parts.length > 1) { - const attributes = parts.slice(1).join(' ').split(','); - attributes.forEach(attr => { - const [key, value] = attr.split('=').map(s => s.trim().replace(/"/g, '')); - params[key] = value; - }); + // According to RFC 7235, multiple challenges are separated by commas + // But parameters within a challenge can also be separated by commas + // We need to identify scheme names to know where challenges start + + // First, split by commas while respecting quoted strings + const tokens: string[] = []; + let current = ''; + let inQuotes = false; + + for (let i = 0; i < wwwAuthenticateHeaderValue.length; i++) { + const char = wwwAuthenticateHeaderValue[i]; + + if (char === '"') { + inQuotes = !inQuotes; + current += char; + } else if (char === ',' && !inQuotes) { + if (current.trim()) { + tokens.push(current.trim()); + } + current = ''; + } else { + current += char; + } } - return { scheme, params }; + if (current.trim()) { + tokens.push(current.trim()); + } + + // Now process tokens to identify challenges + // A challenge starts with a scheme name (a token that doesn't contain '=' and is followed by parameters or is standalone) + let currentChallenge: { scheme: string; params: Record } | undefined; + + for (const token of tokens) { + const hasEquals = token.includes('='); + + if (!hasEquals) { + // This token doesn't have '=', so it's likely a scheme name + if (currentChallenge) { + challenges.push(currentChallenge); + } + currentChallenge = { scheme: token.trim(), params: {} }; + } else { + // This token has '=', it could be: + // 1. A parameter for the current challenge + // 2. A new challenge that starts with "Scheme param=value" + + const spaceIndex = token.indexOf(' '); + if (spaceIndex > 0) { + const beforeSpace = token.substring(0, spaceIndex); + const afterSpace = token.substring(spaceIndex + 1); + + // Check if what's before the space looks like a scheme name (no '=') + if (!beforeSpace.includes('=') && afterSpace.includes('=')) { + // This is a new challenge starting with "Scheme param=value" + if (currentChallenge) { + challenges.push(currentChallenge); + } + currentChallenge = { scheme: beforeSpace.trim(), params: {} }; + + // Parse the parameter part + const [key, value] = afterSpace.split('=').map(s => s.trim().replace(/"/g, '')); + if (key && value !== undefined) { + currentChallenge.params[key] = value; + } + continue; + } + } + + // This is a parameter for the current challenge + if (currentChallenge) { + const [key, value] = token.split('=').map(s => s.trim().replace(/"/g, '')); + if (key && value !== undefined) { + currentChallenge.params[key] = value; + } + } + } + } + + // Don't forget the last challenge + if (currentChallenge) { + challenges.push(currentChallenge); + } + + return challenges; } export function getClaimsFromJWT(token: string): IAuthorizationJWTClaims { diff --git a/src/vs/base/test/common/oauth.test.ts b/src/vs/base/test/common/oauth.test.ts index e1246e5be60..0ab74d9fa17 100644 --- a/src/vs/base/test/common/oauth.test.ts +++ b/src/vs/base/test/common/oauth.test.ts @@ -240,21 +240,40 @@ suite('OAuth', () => { suite('Parsing Functions', () => { test('parseWWWAuthenticateHeader should correctly parse simple header', () => { const result = parseWWWAuthenticateHeader('Bearer'); - assert.strictEqual(result.scheme, 'Bearer'); - assert.deepStrictEqual(result.params, {}); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].scheme, 'Bearer'); + assert.deepStrictEqual(result[0].params, {}); }); test('parseWWWAuthenticateHeader should correctly parse header with parameters', () => { const result = parseWWWAuthenticateHeader('Bearer realm="api", error="invalid_token", error_description="The access token expired"'); - assert.strictEqual(result.scheme, 'Bearer'); - assert.deepStrictEqual(result.params, { + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].scheme, 'Bearer'); + assert.deepStrictEqual(result[0].params, { realm: 'api', error: 'invalid_token', error_description: 'The access token expired' }); }); + test('parseWWWAuthenticateHeader should correctly parse multiple', () => { + const result = parseWWWAuthenticateHeader('Bearer realm="api", error="invalid_token", error_description="The access token expired", Basic realm="hi"'); + + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0].scheme, 'Bearer'); + assert.deepStrictEqual(result[0].params, { + realm: 'api', + error: 'invalid_token', + error_description: 'The access token expired' + }); + assert.strictEqual(result[1].scheme, 'Basic'); + assert.deepStrictEqual(result[1].params, { + realm: 'hi' + }); + }); + + test('getClaimsFromJWT should correctly parse a JWT token', () => { // Create a sample JWT with known payload const payload: IAuthorizationJWTClaims = { diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 6292b923b14..88aaf45c161 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -31,6 +31,9 @@ const _allApiProposals = { authSession: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.authSession.d.ts', }, + authenticationChallenges: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.authenticationChallenges.d.ts', + }, canonicalUriProvider: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.canonicalUriProvider.d.ts', }, diff --git a/src/vs/workbench/api/browser/mainThreadAuthentication.ts b/src/vs/workbench/api/browser/mainThreadAuthentication.ts index e713623fe51..0dd908e72ec 100644 --- a/src/vs/workbench/api/browser/mainThreadAuthentication.ts +++ b/src/vs/workbench/api/browser/mainThreadAuthentication.ts @@ -6,8 +6,8 @@ import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; import * as nls from '../../../nls.js'; import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; -import { AuthenticationSession, AuthenticationSessionsChangeEvent, IAuthenticationProvider, IAuthenticationService, IAuthenticationExtensionsService, AuthenticationSessionAccount, IAuthenticationProviderSessionOptions } from '../../services/authentication/common/authentication.js'; -import { ExtHostAuthenticationShape, ExtHostContext, MainContext, MainThreadAuthenticationShape } from '../common/extHost.protocol.js'; +import { AuthenticationSession, AuthenticationSessionsChangeEvent, IAuthenticationProvider, IAuthenticationService, IAuthenticationExtensionsService, AuthenticationSessionAccount, IAuthenticationProviderSessionOptions, isAuthenticationSessionRequest, IAuthenticationConstraint } from '../../services/authentication/common/authentication.js'; +import { AuthenticationSessionRequest, ExtHostAuthenticationShape, ExtHostContext, MainContext, MainThreadAuthenticationShape } from '../common/extHost.protocol.js'; import { IDialogService, IPromptButton } from '../../../platform/dialogs/common/dialogs.js'; import Severity from '../../../base/common/severity.js'; import { INotificationService } from '../../../platform/notification/common/notification.js'; @@ -44,12 +44,12 @@ export interface AuthenticationGetSessionOptions { authorizationServer?: UriComponents; } -export class MainThreadAuthenticationProvider extends Disposable implements IAuthenticationProvider { +class MainThreadAuthenticationProvider extends Disposable implements IAuthenticationProvider { readonly onDidChangeSessions: Event; constructor( - private readonly _proxy: ExtHostAuthenticationShape, + protected readonly _proxy: ExtHostAuthenticationShape, public readonly id: string, public readonly label: string, public readonly supportsMultipleAccounts: boolean, @@ -73,6 +73,35 @@ export class MainThreadAuthenticationProvider extends Disposable implements IAut } } +class MainThreadAuthenticationProviderWithChallenges extends MainThreadAuthenticationProvider implements IAuthenticationProvider { + + constructor( + proxy: ExtHostAuthenticationShape, + id: string, + label: string, + supportsMultipleAccounts: boolean, + authorizationServers: ReadonlyArray, + onDidChangeSessionsEmitter: Emitter, + ) { + super( + proxy, + id, + label, + supportsMultipleAccounts, + authorizationServers, + onDidChangeSessionsEmitter + ); + } + + getSessionsFromChallenges(constraint: IAuthenticationConstraint, options: IAuthenticationProviderSessionOptions): Promise { + return this._proxy.$getSessionsFromChallenges(this.id, constraint, options); + } + + createSessionFromChallenges(constraint: IAuthenticationConstraint, options: IAuthenticationProviderSessionOptions): Promise { + return this._proxy.$createSessionFromChallenges(this.id, constraint, options); + } +} + @extHostNamedCustomer(MainContext.MainThreadAuthentication) export class MainThreadAuthentication extends Disposable implements MainThreadAuthenticationShape { private readonly _proxy: ExtHostAuthenticationShape; @@ -142,7 +171,7 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu })); } - async $registerAuthenticationProvider(id: string, label: string, supportsMultipleAccounts: boolean, supportedAuthorizationServer: UriComponents[] = []): Promise { + async $registerAuthenticationProvider(id: string, label: string, supportsMultipleAccounts: boolean, supportedAuthorizationServer: UriComponents[] = [], supportsChallenges?: boolean): Promise { if (!this.authenticationService.declaredProviders.find(p => p.id === id)) { // If telemetry shows that this is not happening much, we can instead throw an error here. this.logService.warn(`Authentication provider ${id} was not declared in the Extension Manifest.`); @@ -156,7 +185,10 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu const emitter = new Emitter(); this._registrations.set(id, emitter); const supportedAuthorizationServerUris = supportedAuthorizationServer.map(i => URI.revive(i)); - const provider = new MainThreadAuthenticationProvider(this._proxy, id, label, supportsMultipleAccounts, supportedAuthorizationServerUris, emitter); + const provider = + supportsChallenges + ? new MainThreadAuthenticationProviderWithChallenges(this._proxy, id, label, supportsMultipleAccounts, supportedAuthorizationServerUris, emitter) + : new MainThreadAuthenticationProvider(this._proxy, id, label, supportsMultipleAccounts, supportedAuthorizationServerUris, emitter); this.authenticationService.registerAuthenticationProvider(id, provider); } @@ -322,9 +354,9 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu return result.result === chosenAccountLabel; } - private async doGetSession(providerId: string, scopes: string[], extensionId: string, extensionName: string, options: AuthenticationGetSessionOptions): Promise { + private async doGetSession(providerId: string, scopeListOrRequest: ReadonlyArray | AuthenticationSessionRequest, extensionId: string, extensionName: string, options: AuthenticationGetSessionOptions): Promise { const authorizationServer = URI.revive(options.authorizationServer); - const sessions = await this.authenticationService.getSessions(providerId, scopes, { account: options.account, authorizationServer }, true); + const sessions = await this.authenticationService.getSessions(providerId, scopeListOrRequest, { account: options.account, authorizationServer }, true); const provider = this.authenticationService.getProvider(providerId); // Error cases @@ -341,7 +373,7 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu if (options.clearSessionPreference) { // Clearing the session preference is usually paired with createIfNone, so just remove the preference and // defer to the rest of the logic in this function to choose the session. - this._removeAccountPreference(extensionId, providerId, scopes); + this.authenticationExtensionsService.removeAccountPreference(extensionId, providerId); } const matchingAccountPreferenceSession = @@ -349,7 +381,7 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu options.account // We only support one session per account per set of scopes so grab the first one here ? sessions[0] - : this._getAccountPreference(extensionId, providerId, scopes, sessions); + : this._getAccountPreference(extensionId, providerId, sessions); // Check if the sessions we have are valid if (!options.forceNewSession && sessions.length) { @@ -384,14 +416,14 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu let session: AuthenticationSession; if (sessions?.length && !options.forceNewSession) { session = provider.supportsMultipleAccounts && !options.account - ? await this.authenticationExtensionsService.selectSession(providerId, extensionId, extensionName, scopes, sessions) + ? await this.authenticationExtensionsService.selectSession(providerId, extensionId, extensionName, scopeListOrRequest, sessions) : sessions[0]; } else { const accountToCreate: AuthenticationSessionAccount | undefined = options.account ?? matchingAccountPreferenceSession?.account; do { session = await this.authenticationService.createSession( providerId, - scopes, + scopeListOrRequest, { activateImmediate: true, account: accountToCreate, @@ -406,7 +438,7 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu this.authenticationAccessService.updateAllowedExtensions(providerId, session.account.label, [{ id: extensionId, name: extensionName, allowed: true }]); this.authenticationExtensionsService.updateNewSessionRequests(providerId, [session]); - this._updateAccountPreference(extensionId, providerId, session); + this.authenticationExtensionsService.updateAccountPreference(extensionId, providerId, session.account); return session; } @@ -423,18 +455,22 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu // If there is a potential session, but the extension doesn't have access to it, use the "grant access" flow, // otherwise request a new one. sessions.length - ? this.authenticationExtensionsService.requestSessionAccess(providerId, extensionId, extensionName, scopes, sessions) - : await this.authenticationExtensionsService.requestNewSession(providerId, scopes, extensionId, extensionName); + ? this.authenticationExtensionsService.requestSessionAccess(providerId, extensionId, extensionName, scopeListOrRequest, sessions) + : await this.authenticationExtensionsService.requestNewSession(providerId, scopeListOrRequest, extensionId, extensionName); } return undefined; } - async $getSession(providerId: string, scopes: string[], extensionId: string, extensionName: string, options: AuthenticationGetSessionOptions): Promise { - this.sendClientIdUsageTelemetry(extensionId, providerId, scopes); - const session = await this.doGetSession(providerId, scopes, extensionId, extensionName, options); + async $getSession(providerId: string, scopeListOrRequest: ReadonlyArray | AuthenticationSessionRequest, extensionId: string, extensionName: string, options: AuthenticationGetSessionOptions): Promise { + const scopes = isAuthenticationSessionRequest(scopeListOrRequest) ? scopeListOrRequest.scopes : scopeListOrRequest; + if (scopes) { + this.sendClientIdUsageTelemetry(extensionId, providerId, scopes); + } + const session = await this.doGetSession(providerId, scopeListOrRequest, extensionId, extensionName, options); if (session) { this.sendProviderUsageTelemetry(extensionId, providerId); + const scopes = isAuthenticationSessionRequest(scopeListOrRequest) ? scopeListOrRequest.scopes : scopeListOrRequest; this.authenticationUsageService.addAccountUsage(providerId, session.account.label, scopes, extensionId, extensionName); } @@ -451,7 +487,7 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu // due to the adoption of the Microsoft broker. // Remove this in a few iterations. private _sentClientIdUsageEvents = new Set(); - private sendClientIdUsageTelemetry(extensionId: string, providerId: string, scopes: string[]): void { + private sendClientIdUsageTelemetry(extensionId: string, providerId: string, scopes: readonly string[]): void { const containsVSCodeClientIdScope = scopes.some(scope => scope.startsWith('VSCODE_CLIENT_ID:')); const key = `${extensionId}|${providerId}|${containsVSCodeClientIdScope}`; if (this._sentClientIdUsageEvents.has(key)) { @@ -486,7 +522,7 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu //#region Account Preferences // TODO@TylerLeonhardt: Update this after a few iterations to no longer fallback to the session preference - private _getAccountPreference(extensionId: string, providerId: string, scopes: string[], sessions: ReadonlyArray): AuthenticationSession | undefined { + private _getAccountPreference(extensionId: string, providerId: string, sessions: ReadonlyArray): AuthenticationSession | undefined { if (sessions.length === 0) { return undefined; } @@ -495,29 +531,8 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu const session = sessions.find(session => session.account.label === accountNamePreference); return session; } - - const sessionIdPreference = this.authenticationExtensionsService.getSessionPreference(providerId, extensionId, scopes); - if (sessionIdPreference) { - const session = sessions.find(session => session.id === sessionIdPreference); - if (session) { - // Migrate the session preference to the account preference - this.authenticationExtensionsService.updateAccountPreference(extensionId, providerId, session.account); - return session; - } - } return undefined; } - - private _updateAccountPreference(extensionId: string, providerId: string, session: AuthenticationSession): void { - this.authenticationExtensionsService.updateAccountPreference(extensionId, providerId, session.account); - this.authenticationExtensionsService.updateSessionPreference(providerId, extensionId, session); - } - - private _removeAccountPreference(extensionId: string, providerId: string, scopes: string[]): void { - this.authenticationExtensionsService.removeAccountPreference(extensionId, providerId); - this.authenticationExtensionsService.removeSessionPreference(providerId, extensionId, scopes); - } - //#endregion async $showDeviceCodeModal(userCode: string, verificationUri: string): Promise { diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index c55e63ca03d..28ba34128d8 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -301,7 +301,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I })(); const authentication: typeof vscode.authentication = { - getSession(providerId: string, scopes: readonly string[], options?: vscode.AuthenticationGetSessionOptions) { + getSession(providerId: string, scopesOrChallenge: readonly string[] | vscode.AuthenticationSessionRequest, options?: vscode.AuthenticationGetSessionOptions) { + if (!Array.isArray(scopesOrChallenge)) { + checkProposedApiEnabled(extension, 'authenticationChallenges'); + } if ( (typeof options?.forceNewSession === 'object' && options.forceNewSession.learnMore) || (typeof options?.createIfNone === 'object' && options.createIfNone.learnMore) @@ -311,7 +314,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I if (options?.authorizationServer) { checkProposedApiEnabled(extension, 'authIssuers'); } - return extHostAuthentication.getSession(extension, providerId, scopes, options as any); + return extHostAuthentication.getSession(extension, providerId, scopesOrChallenge, options as any); }, getAccounts(providerId: string) { return extHostAuthentication.getAccounts(providerId); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 700c1aae795..fd27047a975 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -183,12 +183,28 @@ export interface AuthenticationGetSessionOptions { account?: AuthenticationSessionAccount; } +export interface AuthenticationChallenge { + scheme: string; + params: Record; +} + +export interface AuthenticationSessionRequest { + challenge: string; + scopes?: readonly string[]; +} + +//TODO: I don't love the name of this interface... +export interface AuthenticationConstraint { + challenges: readonly AuthenticationChallenge[]; + scopes?: readonly string[]; +} + export interface MainThreadAuthenticationShape extends IDisposable { - $registerAuthenticationProvider(id: string, label: string, supportsMultipleAccounts: boolean, supportedAuthorizationServers?: UriComponents[]): Promise; + $registerAuthenticationProvider(id: string, label: string, supportsMultipleAccounts: boolean, supportedAuthorizationServers?: UriComponents[], supportsChallenges?: boolean): Promise; $unregisterAuthenticationProvider(id: string): Promise; $ensureProvider(id: string): Promise; $sendDidChangeSessions(providerId: string, event: AuthenticationSessionsChangeEvent): Promise; - $getSession(providerId: string, scopes: readonly string[], extensionId: string, extensionName: string, options: AuthenticationGetSessionOptions): Promise; + $getSession(providerId: string, scopeListOrRequest: ReadonlyArray | AuthenticationSessionRequest, extensionId: string, extensionName: string, options: AuthenticationGetSessionOptions): Promise; $getAccounts(providerId: string): Promise>; $removeSession(providerId: string, sessionId: string): Promise; $waitForUriHandler(expectedUri: UriComponents): Promise; @@ -1987,6 +2003,8 @@ export interface ExtHostLabelServiceShape { export interface ExtHostAuthenticationShape { $getSessions(id: string, scopes: string[] | undefined, options: IAuthenticationGetSessionsOptions): Promise>; $createSession(id: string, scopes: string[], options: IAuthenticationCreateSessionOptions): Promise; + $getSessionsFromChallenges(id: string, constraint: AuthenticationConstraint, options: IAuthenticationGetSessionsOptions): Promise>; + $createSessionFromChallenges(id: string, constraint: AuthenticationConstraint, options: IAuthenticationCreateSessionOptions): Promise; $removeSession(id: string, sessionId: string): Promise; $onDidChangeAuthenticationSessions(id: string, label: string, extensionIdFilter?: string[]): Promise; $onDidUnregisterAuthenticationProvider(id: string): Promise; diff --git a/src/vs/workbench/api/common/extHostAuthentication.ts b/src/vs/workbench/api/common/extHostAuthentication.ts index 49a46ee8daf..580adf22874 100644 --- a/src/vs/workbench/api/common/extHostAuthentication.ts +++ b/src/vs/workbench/api/common/extHostAuthentication.ts @@ -9,7 +9,7 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { MainContext, MainThreadAuthenticationShape, ExtHostAuthenticationShape } from './extHost.protocol.js'; import { Disposable, ProgressLocation } from './extHostTypes.js'; import { IExtensionDescription, ExtensionIdentifier } from '../../../platform/extensions/common/extensions.js'; -import { INTERNAL_AUTH_PROVIDER_PREFIX } from '../../services/authentication/common/authentication.js'; +import { INTERNAL_AUTH_PROVIDER_PREFIX, isAuthenticationSessionRequest } from '../../services/authentication/common/authentication.js'; import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; import { IExtHostRpcService } from './extHostRpcService.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; @@ -79,19 +79,30 @@ export class ExtHostAuthentication implements ExtHostAuthenticationShape { ); } - async getSession(requestingExtension: IExtensionDescription, providerId: string, scopes: readonly string[], options: vscode.AuthenticationGetSessionOptions & ({ createIfNone: true } | { forceNewSession: true } | { forceNewSession: vscode.AuthenticationForceNewSessionOptions })): Promise; - async getSession(requestingExtension: IExtensionDescription, providerId: string, scopes: readonly string[], options: vscode.AuthenticationGetSessionOptions & { forceNewSession: true }): Promise; - async getSession(requestingExtension: IExtensionDescription, providerId: string, scopes: readonly string[], options: vscode.AuthenticationGetSessionOptions & { forceNewSession: vscode.AuthenticationForceNewSessionOptions }): Promise; - async getSession(requestingExtension: IExtensionDescription, providerId: string, scopes: readonly string[], options: vscode.AuthenticationGetSessionOptions): Promise; - async getSession(requestingExtension: IExtensionDescription, providerId: string, scopes: readonly string[], options: vscode.AuthenticationGetSessionOptions = {}): Promise { + async getSession(requestingExtension: IExtensionDescription, providerId: string, scopesOrRequest: readonly string[] | vscode.AuthenticationSessionRequest, options: vscode.AuthenticationGetSessionOptions & ({ createIfNone: true } | { forceNewSession: true } | { forceNewSession: vscode.AuthenticationForceNewSessionOptions })): Promise; + async getSession(requestingExtension: IExtensionDescription, providerId: string, scopesOrRequest: readonly string[] | vscode.AuthenticationSessionRequest, options: vscode.AuthenticationGetSessionOptions & { forceNewSession: true }): Promise; + async getSession(requestingExtension: IExtensionDescription, providerId: string, scopesOrRequest: readonly string[] | vscode.AuthenticationSessionRequest, options: vscode.AuthenticationGetSessionOptions & { forceNewSession: vscode.AuthenticationForceNewSessionOptions }): Promise; + async getSession(requestingExtension: IExtensionDescription, providerId: string, scopesOrRequest: readonly string[] | vscode.AuthenticationSessionRequest, options: vscode.AuthenticationGetSessionOptions): Promise; + async getSession(requestingExtension: IExtensionDescription, providerId: string, scopesOrRequest: readonly string[] | vscode.AuthenticationSessionRequest, options: vscode.AuthenticationGetSessionOptions = {}): Promise { const extensionId = ExtensionIdentifier.toKey(requestingExtension.identifier); - const sortedScopes = [...scopes].sort().join(' '); const keys: (keyof vscode.AuthenticationGetSessionOptions)[] = Object.keys(options) as (keyof vscode.AuthenticationGetSessionOptions)[]; const optionsStr = keys.sort().map(key => `${key}:${!!options[key]}`).join(', '); - return await this._getSessionTaskSingler.getOrCreate(`${extensionId} ${providerId} ${sortedScopes} ${optionsStr}`, async () => { + + let singlerKey: string; + if (isAuthenticationSessionRequest(scopesOrRequest)) { + const challenge = scopesOrRequest as vscode.AuthenticationSessionRequest; + const challengeStr = challenge.challenge; + const scopesStr = challenge.scopes ? [...challenge.scopes].sort().join(' ') : ''; + singlerKey = `${extensionId} ${providerId} challenge:${challengeStr} ${scopesStr} ${optionsStr}`; + } else { + const sortedScopes = [...scopesOrRequest].sort().join(' '); + singlerKey = `${extensionId} ${providerId} ${sortedScopes} ${optionsStr}`; + } + + return await this._getSessionTaskSingler.getOrCreate(singlerKey, async () => { await this._proxy.$ensureProvider(providerId); const extensionName = requestingExtension.displayName || requestingExtension.name; - return this._proxy.$getSession(providerId, scopes, extensionId, extensionName, options); + return this._proxy.$getSession(providerId, scopesOrRequest, extensionId, extensionName, options); }); } @@ -112,7 +123,7 @@ export class ExtHostAuthentication implements ExtHostAuthenticationShape { } const listener = provider.onDidChangeSessions(e => this._proxy.$sendDidChangeSessions(id, e)); this._authenticationProviders.set(id, { label, provider, disposable: listener, options: options ?? { supportsMultipleAccounts: false } }); - await this._proxy.$registerAuthenticationProvider(id, label, options?.supportsMultipleAccounts ?? false, options?.supportedAuthorizationServers); + await this._proxy.$registerAuthenticationProvider(id, label, options?.supportsMultipleAccounts ?? false, options?.supportedAuthorizationServers, options?.supportsChallenges); }); // unregister @@ -163,6 +174,40 @@ export class ExtHostAuthentication implements ExtHostAuthenticationShape { }); } + $getSessionsFromChallenges(providerId: string, constraint: vscode.AuthenticationConstraint, options: vscode.AuthenticationProviderSessionOptions): Promise> { + return this._providerOperations.queue(providerId, async () => { + const providerData = this._authenticationProviders.get(providerId); + if (providerData) { + const provider = providerData.provider; + // Check if provider supports challenges + if (typeof provider.getSessionsFromChallenges === 'function') { + options.authorizationServer = URI.revive(options.authorizationServer); + return await provider.getSessionsFromChallenges(constraint, options); + } + throw new Error(`Authentication provider with handle: ${providerId} does not support getSessionsFromChallenges`); + } + + throw new Error(`Unable to find authentication provider with handle: ${providerId}`); + }); + } + + $createSessionFromChallenges(providerId: string, constraint: vscode.AuthenticationConstraint, options: vscode.AuthenticationProviderSessionOptions): Promise { + return this._providerOperations.queue(providerId, async () => { + const providerData = this._authenticationProviders.get(providerId); + if (providerData) { + const provider = providerData.provider; + // Check if provider supports challenges + if (typeof provider.createSessionFromChallenges === 'function') { + options.authorizationServer = URI.revive(options.authorizationServer); + return await provider.createSessionFromChallenges(constraint, options); + } + throw new Error(`Authentication provider with handle: ${providerId} does not support createSessionFromChallenges`); + } + + throw new Error(`Unable to find authentication provider with handle: ${providerId}`); + }); + } + $onDidChangeAuthenticationSessions(id: string, label: string, extensionIdFilter?: string[]) { // Don't fire events for the internal auth providers if (!id.startsWith(INTERNAL_AUTH_PROVIDER_PREFIX)) { diff --git a/src/vs/workbench/api/common/extHostMcp.ts b/src/vs/workbench/api/common/extHostMcp.ts index 567c6010954..1e250ba7340 100644 --- a/src/vs/workbench/api/common/extHostMcp.ts +++ b/src/vs/workbench/api/common/extHostMcp.ts @@ -320,9 +320,12 @@ class McpHTTPHandle extends Disposable { let resourceMetadataChallenge: string | undefined; if (originalResponse.headers.has('WWW-Authenticate')) { const authHeader = originalResponse.headers.get('WWW-Authenticate')!; - const { scheme, params } = parseWWWAuthenticateHeader(authHeader); - if (scheme === 'Bearer' && params['resource_metadata']) { - resourceMetadataChallenge = params['resource_metadata']; + const challenges = parseWWWAuthenticateHeader(authHeader); + for (const challenge of challenges) { + if (challenge.scheme === 'Bearer' && challenge.params['resource_metadata']) { + resourceMetadataChallenge = challenge.params['resource_metadata']; + break; + } } } // Second, fetch that url's well-known server metadata diff --git a/src/vs/workbench/api/test/browser/mainThreadAuthentication.integrationTest.ts b/src/vs/workbench/api/test/browser/mainThreadAuthentication.integrationTest.ts index 1393e7ab092..ec2e3294f0d 100644 --- a/src/vs/workbench/api/test/browser/mainThreadAuthentication.integrationTest.ts +++ b/src/vs/workbench/api/test/browser/mainThreadAuthentication.integrationTest.ts @@ -116,7 +116,9 @@ suite('MainThreadAuthentication', () => { $removeSession: () => Promise.resolve(), $onDidChangeAuthenticationSessions: () => Promise.resolve(), $registerDynamicAuthProvider: () => Promise.resolve('test'), - $onDidChangeDynamicAuthProviderTokens: () => Promise.resolve() + $onDidChangeDynamicAuthProviderTokens: () => Promise.resolve(), + $getSessionsFromChallenges: () => Promise.resolve([]), + $createSessionFromChallenges: () => Promise.resolve({} as any), }; rpcProtocol.set(ExtHostContext.ExtHostAuthentication, mockExtHost); diff --git a/src/vs/workbench/services/authentication/browser/authenticationExtensionsService.ts b/src/vs/workbench/services/authentication/browser/authenticationExtensionsService.ts index 214f7b52e92..2ff21f165f0 100644 --- a/src/vs/workbench/services/authentication/browser/authenticationExtensionsService.ts +++ b/src/vs/workbench/services/authentication/browser/authenticationExtensionsService.ts @@ -16,7 +16,7 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo import { IActivityService, NumberBadge } from '../../activity/common/activity.js'; import { IAuthenticationAccessService } from './authenticationAccessService.js'; import { IAuthenticationUsageService } from './authenticationUsageService.js'; -import { AuthenticationSession, IAuthenticationProvider, IAuthenticationService, IAuthenticationExtensionsService, AuthenticationSessionAccount } from '../common/authentication.js'; +import { AuthenticationSession, IAuthenticationProvider, IAuthenticationService, IAuthenticationExtensionsService, AuthenticationSessionAccount, IAuthenticationSessionRequest, isAuthenticationSessionRequest } from '../common/authentication.js'; import { Emitter } from '../../../../base/common/event.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; @@ -287,7 +287,7 @@ export class AuthenticationExtensionsService extends Disposable implements IAuth /** * This function should be used only when there are sessions to disambiguate. */ - async selectSession(providerId: string, extensionId: string, extensionName: string, scopes: string[], availableSessions: AuthenticationSession[]): Promise { + async selectSession(providerId: string, extensionId: string, extensionName: string, scopeListOrRequest: ReadonlyArray | IAuthenticationSessionRequest, availableSessions: AuthenticationSession[]): Promise { const allAccounts = await this._authenticationService.getAccounts(providerId); if (!allAccounts.length) { throw new Error('No accounts available'); @@ -333,7 +333,7 @@ export class AuthenticationExtensionsService extends Disposable implements IAuth if (!session) { const account = quickPick.selectedItems[0].account; try { - session = await this._authenticationService.createSession(providerId, scopes, { account }); + session = await this._authenticationService.createSession(providerId, scopeListOrRequest, { account }); } catch (e) { reject(e); return; @@ -359,7 +359,7 @@ export class AuthenticationExtensionsService extends Disposable implements IAuth }); } - private async completeSessionAccessRequest(provider: IAuthenticationProvider, extensionId: string, extensionName: string, scopes: string[]): Promise { + private async completeSessionAccessRequest(provider: IAuthenticationProvider, extensionId: string, extensionName: string, scopeListOrRequest: ReadonlyArray | IAuthenticationSessionRequest): Promise { const providerRequests = this._sessionAccessRequestItems.get(provider.id) || {}; const existingRequest = providerRequests[extensionId]; if (!existingRequest) { @@ -374,7 +374,7 @@ export class AuthenticationExtensionsService extends Disposable implements IAuth let session: AuthenticationSession | undefined; if (provider.supportsMultipleAccounts) { try { - session = await this.selectSession(provider.id, extensionId, extensionName, scopes, possibleSessions); + session = await this.selectSession(provider.id, extensionId, extensionName, scopeListOrRequest, possibleSessions); } catch (_) { // ignore cancel } @@ -390,7 +390,7 @@ export class AuthenticationExtensionsService extends Disposable implements IAuth } } - requestSessionAccess(providerId: string, extensionId: string, extensionName: string, scopes: string[], possibleSessions: AuthenticationSession[]): void { + requestSessionAccess(providerId: string, extensionId: string, extensionName: string, scopeListOrRequest: ReadonlyArray | IAuthenticationSessionRequest, possibleSessions: AuthenticationSession[]): void { const providerRequests = this._sessionAccessRequestItems.get(providerId) || {}; const hasExistingRequest = providerRequests[extensionId]; if (hasExistingRequest) { @@ -415,7 +415,7 @@ export class AuthenticationExtensionsService extends Disposable implements IAuth const accessCommand = CommandsRegistry.registerCommand({ id: `${providerId}${extensionId}Access`, handler: async (accessor) => { - this.completeSessionAccessRequest(provider, extensionId, extensionName, scopes); + this.completeSessionAccessRequest(provider, extensionId, extensionName, scopeListOrRequest); } }); @@ -424,7 +424,7 @@ export class AuthenticationExtensionsService extends Disposable implements IAuth this.updateBadgeCount(); } - async requestNewSession(providerId: string, scopes: string[], extensionId: string, extensionName: string): Promise { + async requestNewSession(providerId: string, scopeListOrRequest: ReadonlyArray | IAuthenticationSessionRequest, extensionId: string, extensionName: string): Promise { if (!this._authenticationService.isAuthenticationProviderRegistered(providerId)) { // Activate has already been called for the authentication provider, but it cannot block on registering itself // since this is sync and returns a disposable. So, wait for registration event to fire that indicates the @@ -447,10 +447,12 @@ export class AuthenticationExtensionsService extends Disposable implements IAuth } const providerRequests = this._signInRequestItems.get(providerId); - const scopesList = scopes.join(SCOPESLIST_SEPARATOR); + const signInRequestKey = isAuthenticationSessionRequest(scopeListOrRequest) + ? `${scopeListOrRequest.challenge}:${scopeListOrRequest.scopes?.join(SCOPESLIST_SEPARATOR) ?? ''}` + : `${scopeListOrRequest.join(SCOPESLIST_SEPARATOR)}`; const extensionHasExistingRequest = providerRequests - && providerRequests[scopesList] - && providerRequests[scopesList].requestingExtensionIds.includes(extensionId); + && providerRequests[signInRequestKey] + && providerRequests[signInRequestKey].requestingExtensionIds.includes(extensionId); if (extensionHasExistingRequest) { return; @@ -476,7 +478,7 @@ export class AuthenticationExtensionsService extends Disposable implements IAuth id: commandId, handler: async (accessor) => { const authenticationService = accessor.get(IAuthenticationService); - const session = await authenticationService.createSession(providerId, scopes); + const session = await authenticationService.createSession(providerId, scopeListOrRequest); this._authenticationAccessService.updateAllowedExtensions(providerId, session.account.label, [{ id: extensionId, name: extensionName, allowed: true }]); this._updateAccountAndSessionPreferences(providerId, extensionId, session); @@ -485,16 +487,16 @@ export class AuthenticationExtensionsService extends Disposable implements IAuth if (providerRequests) { - const existingRequest = providerRequests[scopesList] || { disposables: [], requestingExtensionIds: [] }; + const existingRequest = providerRequests[signInRequestKey] || { disposables: [], requestingExtensionIds: [] }; - providerRequests[scopesList] = { + providerRequests[signInRequestKey] = { disposables: [...existingRequest.disposables, menuItem, signInCommand], requestingExtensionIds: [...existingRequest.requestingExtensionIds, extensionId] }; this._signInRequestItems.set(providerId, providerRequests); } else { this._signInRequestItems.set(providerId, { - [scopesList]: { + [signInRequestKey]: { disposables: [menuItem, signInCommand], requestingExtensionIds: [extensionId] } diff --git a/src/vs/workbench/services/authentication/browser/authenticationService.ts b/src/vs/workbench/services/authentication/browser/authenticationService.ts index fc0dac3399c..00fb314f366 100644 --- a/src/vs/workbench/services/authentication/browser/authenticationService.ts +++ b/src/vs/workbench/services/authentication/browser/authenticationService.ts @@ -12,7 +12,7 @@ import { InstantiationType, registerSingleton } from '../../../../platform/insta import { IProductService } from '../../../../platform/product/common/productService.js'; import { ISecretStorageService } from '../../../../platform/secrets/common/secrets.js'; import { IAuthenticationAccessService } from './authenticationAccessService.js'; -import { AuthenticationProviderInformation, AuthenticationSession, AuthenticationSessionAccount, AuthenticationSessionsChangeEvent, IAuthenticationCreateSessionOptions, IAuthenticationGetSessionsOptions, IAuthenticationProvider, IAuthenticationProviderHostDelegate, IAuthenticationService } from '../common/authentication.js'; +import { AuthenticationProviderInformation, AuthenticationSession, AuthenticationSessionAccount, AuthenticationSessionsChangeEvent, IAuthenticationCreateSessionOptions, IAuthenticationGetSessionsOptions, IAuthenticationProvider, IAuthenticationProviderHostDelegate, IAuthenticationService, IAuthenticationSessionRequest, isAuthenticationSessionRequest } from '../common/authentication.js'; import { IBrowserWorkbenchEnvironmentService } from '../../environment/browser/environmentService.js'; import { ActivationKind, IExtensionService } from '../../extensions/common/extensions.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -20,7 +20,7 @@ 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'; -import { IAuthorizationProtectedResourceMetadata, IAuthorizationServerMetadata } from '../../../../base/common/oauth.js'; +import { IAuthorizationProtectedResourceMetadata, IAuthorizationServerMetadata, parseWWWAuthenticateHeader } from '../../../../base/common/oauth.js'; import { raceCancellation, raceTimeout } from '../../../../base/common/async.js'; import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; @@ -276,7 +276,7 @@ export class AuthenticationService extends Disposable implements IAuthentication return accounts; } - async getSessions(id: string, scopes?: string[], options?: IAuthenticationGetSessionsOptions, activateImmediate: boolean = false): Promise> { + async getSessions(id: string, scopeListOrRequest?: ReadonlyArray | IAuthenticationSessionRequest, options?: IAuthenticationGetSessionsOptions, activateImmediate: boolean = false): Promise> { if (this._disposedSource.token.isCancellationRequested) { return []; } @@ -291,20 +291,38 @@ export class AuthenticationService extends Disposable implements IAuthentication throw new Error(`The authorization server '${authServerStr}' is not supported by the authentication provider '${id}'.`); } } - return await authProvider.getSessions(scopes, { ...options }); + if (isAuthenticationSessionRequest(scopeListOrRequest)) { + if (!authProvider.getSessionsFromChallenges) { + throw new Error(`The authentication provider '${id}' does not support getting sessions from challenges.`); + } + return await authProvider.getSessionsFromChallenges( + { challenges: parseWWWAuthenticateHeader(scopeListOrRequest.challenge), scopes: scopeListOrRequest.scopes }, + { ...options } + ); + } + return await authProvider.getSessions(scopeListOrRequest ? [...scopeListOrRequest] : undefined, { ...options }); } else { throw new Error(`No authentication provider '${id}' is currently registered.`); } } - async createSession(id: string, scopes: string[], options?: IAuthenticationCreateSessionOptions): Promise { + async createSession(id: string, scopeListOrRequest: ReadonlyArray | IAuthenticationSessionRequest, options?: IAuthenticationCreateSessionOptions): Promise { if (this._disposedSource.token.isCancellationRequested) { throw new Error('Authentication service is disposed.'); } const authProvider = this._authenticationProviders.get(id) || await this.tryActivateProvider(id, !!options?.activateImmediate); if (authProvider) { - return await authProvider.createSession(scopes, { ...options }); + if (isAuthenticationSessionRequest(scopeListOrRequest)) { + if (!authProvider.createSessionFromChallenges) { + throw new Error(`The authentication provider '${id}' does not support creating sessions from challenges.`); + } + return await authProvider.createSessionFromChallenges( + { challenges: parseWWWAuthenticateHeader(scopeListOrRequest.challenge), scopes: scopeListOrRequest.scopes }, + { ...options } + ); + } + return await authProvider.createSession([...scopeListOrRequest], { ...options }); } else { throw new Error(`No authentication provider '${id}' is currently registered.`); } diff --git a/src/vs/workbench/services/authentication/browser/authenticationUsageService.ts b/src/vs/workbench/services/authentication/browser/authenticationUsageService.ts index bd843115efc..a4e558bd49c 100644 --- a/src/vs/workbench/services/authentication/browser/authenticationUsageService.ts +++ b/src/vs/workbench/services/authentication/browser/authenticationUsageService.ts @@ -50,7 +50,7 @@ export interface IAuthenticationUsageService { * @param extensionId The id of the extension to add a usage for * @param extensionName The name of the extension to add a usage for */ - addAccountUsage(providerId: string, accountName: string, scopes: ReadonlyArray, extensionId: string, extensionName: string): void; + addAccountUsage(providerId: string, accountName: string, scopes: ReadonlyArray | undefined, extensionId: string, extensionName: string): void; } export class AuthenticationUsageService extends Disposable implements IAuthenticationUsageService { @@ -119,7 +119,7 @@ export class AuthenticationUsageService extends Disposable implements IAuthentic this._storageService.remove(accountKey, StorageScope.APPLICATION); } - addAccountUsage(providerId: string, accountName: string, scopes: string[], extensionId: string, extensionName: string): void { + addAccountUsage(providerId: string, accountName: string, scopes: string[] | undefined, extensionId: string, extensionName: string): void { const accountKey = `${providerId}-${accountName}-usages`; const usages = this.readAccountUsages(providerId, accountName); diff --git a/src/vs/workbench/services/authentication/common/authentication.ts b/src/vs/workbench/services/authentication/common/authentication.ts index 52aa20fd08d..7740368e797 100644 --- a/src/vs/workbench/services/authentication/common/authentication.ts +++ b/src/vs/workbench/services/authentication/common/authentication.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from '../../../../base/common/event.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; -import { IAuthorizationProtectedResourceMetadata, IAuthorizationServerMetadata } from '../../../../base/common/oauth.js'; +import { IAuthenticationChallenge, IAuthorizationProtectedResourceMetadata, IAuthorizationServerMetadata } from '../../../../base/common/oauth.js'; import { URI } from '../../../../base/common/uri.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; @@ -62,6 +62,46 @@ export interface IAuthenticationCreateSessionOptions { [key: string]: any; } +export interface IAuthenticationSessionRequest { + /** + * The raw WWW-Authenticate header value that triggered this challenge. + * This will be parsed by the authentication provider to extract the necessary + * challenge information. + */ + readonly challenge: string; + + /** + * Optional scopes for the session. If not provided, the authentication provider + * may use default scopes or extract them from the challenge. + */ + readonly scopes?: readonly string[]; +} + +export function isAuthenticationSessionRequest(obj: unknown): obj is IAuthenticationSessionRequest { + return typeof obj === 'object' + && obj !== null + && 'challenge' in obj + && typeof obj.challenge === 'string'; +} + +/** + * Represents constraints for authentication, including challenges and optional scopes. + * This is used when creating or retrieving sessions that must satisfy specific authentication + * requirements from WWW-Authenticate headers. + */ +export interface IAuthenticationConstraint { + /** + * Array of authentication challenges parsed from WWW-Authenticate headers. + */ + readonly challenges: readonly IAuthenticationChallenge[]; + + /** + * Optional scopes for the session. If not provided, the authentication provider + * may extract scopes from the challenges or use default scopes. + */ + readonly scopes?: readonly string[]; +} + /** * Options for getting authentication sessions via the service. */ @@ -197,7 +237,7 @@ export interface IAuthenticationService { * @param options Additional options for getting sessions * @param activateImmediate If true, the provider should activate immediately if it is not already */ - getSessions(id: string, scopes?: string[], options?: IAuthenticationGetSessionsOptions, activateImmediate?: boolean): Promise>; + getSessions(id: string, scopeListOrRequest?: ReadonlyArray | IAuthenticationSessionRequest, options?: IAuthenticationGetSessionsOptions, activateImmediate?: boolean): Promise>; /** * Creates an AuthenticationSession with the given provider and scopes @@ -205,7 +245,7 @@ export interface IAuthenticationService { * @param scopes The scopes to request * @param options Additional options for creating the session */ - createSession(providerId: string, scopes: string[], options?: IAuthenticationCreateSessionOptions): Promise; + createSession(providerId: string, scopeListOrRequest: ReadonlyArray | IAuthenticationSessionRequest, options?: IAuthenticationCreateSessionOptions): Promise; /** * Removes the session with the given id from the provider with the given id @@ -315,9 +355,9 @@ export interface IAuthenticationExtensionsService { * @param scopes */ removeSessionPreference(providerId: string, extensionId: string, scopes: string[]): void; - selectSession(providerId: string, extensionId: string, extensionName: string, scopes: string[], possibleSessions: readonly AuthenticationSession[]): Promise; - requestSessionAccess(providerId: string, extensionId: string, extensionName: string, scopes: string[], possibleSessions: readonly AuthenticationSession[]): void; - requestNewSession(providerId: string, scopes: string[], extensionId: string, extensionName: string): Promise; + selectSession(providerId: string, extensionId: string, extensionName: string, scopeListOrRequest: ReadonlyArray | IAuthenticationSessionRequest, possibleSessions: readonly AuthenticationSession[]): Promise; + requestSessionAccess(providerId: string, extensionId: string, extensionName: string, scopeListOrRequest: ReadonlyArray | IAuthenticationSessionRequest, possibleSessions: readonly AuthenticationSession[]): void; + requestNewSession(providerId: string, scopeListOrRequest: ReadonlyArray | IAuthenticationSessionRequest, extensionId: string, extensionName: string): Promise; updateNewSessionRequests(providerId: string, addedSessions: readonly AuthenticationSession[]): void; } @@ -402,6 +442,25 @@ export interface IAuthenticationProvider { */ createSession(scopes: string[], options: IAuthenticationProviderSessionOptions): Promise; + /** + * Get existing sessions that match the given authentication constraints. + * + * @param constraint The authentication constraint containing challenges and optional scopes + * @param options Options for the session request + * @returns A thenable that resolves to an array of existing authentication sessions + */ + getSessionsFromChallenges?(constraint: IAuthenticationConstraint, options: IAuthenticationProviderSessionOptions): Promise; + + /** + * Create a new session based on authentication constraints. + * This is called when no existing session matches the constraint requirements. + * + * @param constraint The authentication constraint containing challenges and optional scopes + * @param options Options for the session creation + * @returns A thenable that resolves to a new authentication session + */ + createSessionFromChallenges?(constraint: IAuthenticationConstraint, options: IAuthenticationProviderSessionOptions): Promise; + /** * Removes the session corresponding to the specified session ID. * If the removal is successful, the `onDidChangeSessions` event should be fired. diff --git a/src/vscode-dts/vscode.proposed.authenticationChallenges.d.ts b/src/vscode-dts/vscode.proposed.authenticationChallenges.d.ts new file mode 100644 index 00000000000..46e65c06fe7 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.authenticationChallenges.d.ts @@ -0,0 +1,155 @@ +/*--------------------------------------------------------------------------------------------- + * 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' { + + // https://github.com/microsoft/vscode/issues/260156 + + /********** + * "Extension asking for auth" API + *******/ + + /** + * Represents parameters for creating a session based on an authentication challenge. + * This is used when an API returns a 401 with a WWW-Authenticate header indicating + * that additional authentication steps or claims are required. + */ + export interface AuthenticationSessionRequest { + /** + * The raw WWW-Authenticate header value that triggered this challenge. + * This will be parsed by the authentication provider to extract the necessary + * challenge information. + */ + readonly challenge: string; + + /** + * Optional scopes for the session. If not provided, the authentication provider + * may use default scopes or extract them from the challenge. + */ + readonly scopes?: readonly string[]; + } + + export namespace authentication { + /** + * Get an authentication session matching the desired scopes. Rejects if a provider with providerId is not + * registered, or if the user does not consent to sharing authentication information with + * the extension. If there are multiple sessions with the same scopes, the user will be shown a + * quickpick to select which account they would like to use. + * + * Currently, there are only two authentication providers that are contributed from built in extensions + * to the editor that implement GitHub and Microsoft authentication: their providerId's are 'github' and 'microsoft'. + * @param providerId The id of the provider to use + * @param scopes A list of scopes representing the permissions requested. These are dependent on the authentication provider + * @param options The {@link AuthenticationGetSessionOptions} to use + * @returns A thenable that resolves to an authentication session + */ + export function getSession(providerId: string, scopeListOrRequest: ReadonlyArray | AuthenticationSessionRequest, options: AuthenticationGetSessionOptions & { /** */createIfNone: true | AuthenticationGetSessionPresentationOptions }): Thenable; + + /** + * Get an authentication session matching the desired scopes. Rejects if a provider with providerId is not + * registered, or if the user does not consent to sharing authentication information with + * the extension. If there are multiple sessions with the same scopes, the user will be shown a + * quickpick to select which account they would like to use. + * + * Currently, there are only two authentication providers that are contributed from built in extensions + * to the editor that implement GitHub and Microsoft authentication: their providerId's are 'github' and 'microsoft'. + * @param providerId The id of the provider to use + * @param scopes A list of scopes representing the permissions requested. These are dependent on the authentication provider + * @param options The {@link AuthenticationGetSessionOptions} to use + * @returns A thenable that resolves to an authentication session + */ + export function getSession(providerId: string, scopeListOrRequest: ReadonlyArray | AuthenticationSessionRequest, options: AuthenticationGetSessionOptions & { /** literal-type defines return type */forceNewSession: true | AuthenticationGetSessionPresentationOptions | AuthenticationForceNewSessionOptions }): Thenable; + + /** + * Get an authentication session matching the desired scopes. Rejects if a provider with providerId is not + * registered, or if the user does not consent to sharing authentication information with + * the extension. If there are multiple sessions with the same scopes, the user will be shown a + * quickpick to select which account they would like to use. + * + * Currently, there are only two authentication providers that are contributed from built in extensions + * to the editor that implement GitHub and Microsoft authentication: their providerId's are 'github' and 'microsoft'. + * @param providerId The id of the provider to use + * @param scopes A list of scopes representing the permissions requested. These are dependent on the authentication provider + * @param options The {@link AuthenticationGetSessionOptions} to use + * @returns A thenable that resolves to an authentication session if available, or undefined if there are no sessions + */ + export function getSession(providerId: string, scopeListOrRequest: ReadonlyArray | AuthenticationSessionRequest, options?: AuthenticationGetSessionOptions): Thenable; + } + + + /********** + * "Extension providing auth" API + * NOTE: This doesn't need to be finalized with the above + *******/ + + /** + * Represents an authentication challenge from a WWW-Authenticate header. + * This is used to handle cases where additional authentication steps are required, + * such as when mandatory multi-factor authentication (MFA) is enforced. + */ + export interface AuthenticationChallenge { + /** + * The authentication scheme (e.g., 'Bearer'). + */ + readonly scheme: string; + + /** + * Parameters for the authentication challenge. + * For Bearer challenges, this may include 'claims', 'scope', 'realm', etc. + */ + readonly params: Record; + } + + /** + * Represents constraints for authentication, including challenges and optional scopes. + * This is used when creating or retrieving sessions that must satisfy specific authentication + * requirements from WWW-Authenticate headers. + */ + export interface AuthenticationConstraint { + /** + * Array of authentication challenges parsed from WWW-Authenticate headers. + */ + readonly challenges: readonly AuthenticationChallenge[]; + + /** + * Optional scopes for the session. If not provided, the authentication provider + * may extract scopes from the challenges or use default scopes. + */ + readonly scopes?: readonly string[]; + } + + /** + * An authentication provider that supports challenge-based authentication. + * This extends the base AuthenticationProvider with methods to handle authentication + * challenges from WWW-Authenticate headers. + * + * TODO: Enforce that both of these functions should be defined by creating a new AuthenticationProviderWithChallenges interface. + * But this can be done later since this part doesn't need finalization. + */ + export interface AuthenticationProvider { + /** + * Get existing sessions that match the given authentication constraints. + * + * @param constraint The authentication constraint containing challenges and optional scopes + * @param options Options for the session request + * @returns A thenable that resolves to an array of existing authentication sessions + */ + getSessionsFromChallenges?(constraint: AuthenticationConstraint, options: AuthenticationProviderSessionOptions): Thenable; + + /** + * Create a new session based on authentication constraints. + * This is called when no existing session matches the constraint requirements. + * + * @param constraint The authentication constraint containing challenges and optional scopes + * @param options Options for the session creation + * @returns A thenable that resolves to a new authentication session + */ + createSessionFromChallenges?(constraint: AuthenticationConstraint, options: AuthenticationProviderSessionOptions): Thenable; + } + + export interface AuthenticationProviderOptions { + supportsChallenges?: boolean; + } +}