diff --git a/dist/cjs/client/auth.js b/dist/cjs/client/auth.js index c2e4fa91d26f5336889f6afa416147db75fc4872..a100af7bbf30bbddbd799b291f21917d9455f8fb 100644 --- a/dist/cjs/client/auth.js +++ b/dist/cjs/client/auth.js @@ -246,7 +246,33 @@ async function authInternal(provider, { serverUrl, authorizationCode, scope, res // 2. PRM scopes_supported // 3. Client metadata scope (user-configured fallback) // The resolved scope is used consistently for both DCR and the authorization request. - const resolvedScope = scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope; + // Treat scopes_supported as an availability declaration, never as the + // permission set to request. Combine challenge-required scopes with the + // client-selected least-privilege set and add offline_access only when the + // server advertises both that scope and refresh-token support. + const requiredScopes = scope?.split(/\s+/).filter(Boolean) ?? []; + const selectedScopes = provider.clientMetadata.scope?.split(/\s+/).filter(Boolean) ?? []; + const advertisedScopes = metadata?.scopes_supported ?? resourceMetadata?.scopes_supported; + if (advertisedScopes) { + const unsupportedScope = selectedScopes.find(selectedScope => !advertisedScopes.includes(selectedScope)); + if (unsupportedScope) { + throw new Error(`The selected OAuth scope is not advertised by the authorization server or protected resource: ${unsupportedScope}`); + } + } + const resolvedScopes = [...new Set([...requiredScopes, ...selectedScopes])]; + // Some providers publish the only workable permission set in discovery + // and omit scope from their 401 challenge. Preserve least-privilege + // selection when configured, but restore the SDK's advertised fallback + // when neither the provider challenge nor the admin selected scopes. + if (resolvedScopes.length === 0 && advertisedScopes) { + resolvedScopes.push(...new Set(advertisedScopes)); + } + if (resolvedScopes.includes('offline_access') && + !requiredScopes.includes('offline_access') && + (!advertisedScopes?.includes('offline_access') || !metadata?.grant_types_supported?.includes('refresh_token'))) { + resolvedScopes.splice(resolvedScopes.indexOf('offline_access'), 1); + } + const resolvedScope = resolvedScopes.length > 0 ? resolvedScopes.join(' ') : undefined; // Handle client registration if needed let clientInformation = await Promise.resolve(provider.clientInformation()); if (!clientInformation) { diff --git a/dist/cjs/client/index.d.ts b/dist/cjs/client/index.d.ts index 6f567a193626587a2730b5a49293ca5dfd4181ea..e42d261b43ae1cc60f6de9d014401f69c52b4dc7 100644 --- a/dist/cjs/client/index.d.ts +++ b/dist/cjs/client/index.d.ts @@ -20,6 +20,11 @@ export declare function getSupportedElicitationModes(capabilities: ClientCapabil supportsUrlMode: boolean; }; export type ClientOptions = ProtocolOptions & { + /** + * Protocol version to send in the initialize request. + * @default LATEST_PROTOCOL_VERSION + */ + protocolVersion?: string; /** * Capabilities to advertise as being supported by this client. */ diff --git a/dist/cjs/client/index.js b/dist/cjs/client/index.js index 6ac1da14dc7f6211ae70f7711c124b76098816d8..075e4475f51aec1332b8d333a3c9972ac6875bce 100644 --- a/dist/cjs/client/index.js +++ b/dist/cjs/client/index.js @@ -109,6 +109,7 @@ class Client extends protocol_js_1.Protocol { this._cachedRequiredTaskTools = new Set(); this._listChangedDebounceTimers = new Map(); this._capabilities = options?.capabilities ?? {}; + this._protocolVersion = options?.protocolVersion ?? types_js_1.LATEST_PROTOCOL_VERSION; this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new ajv_provider_js_1.AjvJsonSchemaValidator(); // Store list changed config for setup after connection (when we know server capabilities) if (options?.listChanged) { @@ -297,7 +298,7 @@ class Client extends protocol_js_1.Protocol { const result = await this.request({ method: 'initialize', params: { - protocolVersion: types_js_1.LATEST_PROTOCOL_VERSION, + protocolVersion: this._protocolVersion, capabilities: this._capabilities, clientInfo: this._clientInfo } diff --git a/dist/esm/client/auth.js b/dist/esm/client/auth.js index e183040fc2bba22ca1ccc784984f3310854403b7..57ef181b35a629f966085a0c0a39064590b597ef 100644 --- a/dist/esm/client/auth.js +++ b/dist/esm/client/auth.js @@ -216,12 +216,33 @@ async function authInternal(provider, { serverUrl, authorizationCode, scope, res }); } const resource = await selectResourceURL(serverUrl, provider, resourceMetadata); - // Apply scope selection strategy (SEP-835): - // 1. WWW-Authenticate scope (passed via `scope` param) - // 2. PRM scopes_supported - // 3. Client metadata scope (user-configured fallback) - // The resolved scope is used consistently for both DCR and the authorization request. - const resolvedScope = scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope; + // Treat scopes_supported as an availability declaration, never as the + // permission set to request. Combine challenge-required scopes with the + // client-selected least-privilege set and add offline_access only when the + // server advertises both that scope and refresh-token support. + const requiredScopes = scope?.split(/\s+/).filter(Boolean) ?? []; + const selectedScopes = provider.clientMetadata.scope?.split(/\s+/).filter(Boolean) ?? []; + const advertisedScopes = metadata?.scopes_supported ?? resourceMetadata?.scopes_supported; + if (advertisedScopes) { + const unsupportedScope = selectedScopes.find(selectedScope => !advertisedScopes.includes(selectedScope)); + if (unsupportedScope) { + throw new Error(`The selected OAuth scope is not advertised by the authorization server or protected resource: ${unsupportedScope}`); + } + } + const resolvedScopes = [...new Set([...requiredScopes, ...selectedScopes])]; + // Some providers publish the only workable permission set in discovery + // and omit scope from their 401 challenge. Preserve least-privilege + // selection when configured, but restore the SDK's advertised fallback + // when neither the provider challenge nor the admin selected scopes. + if (resolvedScopes.length === 0 && advertisedScopes) { + resolvedScopes.push(...new Set(advertisedScopes)); + } + if (resolvedScopes.includes('offline_access') && + !requiredScopes.includes('offline_access') && + (!advertisedScopes?.includes('offline_access') || !metadata?.grant_types_supported?.includes('refresh_token'))) { + resolvedScopes.splice(resolvedScopes.indexOf('offline_access'), 1); + } + const resolvedScope = resolvedScopes.length > 0 ? resolvedScopes.join(' ') : undefined; // Handle client registration if needed let clientInformation = await Promise.resolve(provider.clientInformation()); if (!clientInformation) { diff --git a/dist/esm/client/index.d.ts b/dist/esm/client/index.d.ts index 6f567a193626587a2730b5a49293ca5dfd4181ea..e42d261b43ae1cc60f6de9d014401f69c52b4dc7 100644 --- a/dist/esm/client/index.d.ts +++ b/dist/esm/client/index.d.ts @@ -20,6 +20,11 @@ export declare function getSupportedElicitationModes(capabilities: ClientCapabil supportsUrlMode: boolean; }; export type ClientOptions = ProtocolOptions & { + /** + * Protocol version to send in the initialize request. + * @default LATEST_PROTOCOL_VERSION + */ + protocolVersion?: string; /** * Capabilities to advertise as being supported by this client. */ diff --git a/dist/esm/client/index.js b/dist/esm/client/index.js index 49b12c6cd918c457420fef7ad5528a9443d1a191..cb70263ab608c9f4a426f72362ec208215bc6ea7 100644 --- a/dist/esm/client/index.js +++ b/dist/esm/client/index.js @@ -105,6 +105,7 @@ export class Client extends Protocol { this._cachedRequiredTaskTools = new Set(); this._listChangedDebounceTimers = new Map(); this._capabilities = options?.capabilities ?? {}; + this._protocolVersion = options?.protocolVersion ?? LATEST_PROTOCOL_VERSION; this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new AjvJsonSchemaValidator(); // Store list changed config for setup after connection (when we know server capabilities) if (options?.listChanged) { @@ -293,7 +294,7 @@ export class Client extends Protocol { const result = await this.request({ method: 'initialize', params: { - protocolVersion: LATEST_PROTOCOL_VERSION, + protocolVersion: this._protocolVersion, capabilities: this._capabilities, clientInfo: this._clientInfo }