SEP-2207: Refresh token guidance (#1523)
This commit is contained in:
parent
6711ed9ae8
commit
9d083923d5
3 changed files with 268 additions and 9 deletions
|
|
@ -566,6 +566,38 @@ export async function auth(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects scopes per the MCP spec and augment for refresh token support.
|
||||
*/
|
||||
export function determineScope(options: {
|
||||
requestedScope?: string;
|
||||
resourceMetadata?: OAuthProtectedResourceMetadata;
|
||||
authServerMetadata?: AuthorizationServerMetadata;
|
||||
clientMetadata: OAuthClientMetadata;
|
||||
}): string | undefined {
|
||||
const { requestedScope, resourceMetadata, authServerMetadata, clientMetadata } = options;
|
||||
|
||||
// Scope selection priority (MCP spec):
|
||||
// 1. WWW-Authenticate header scope
|
||||
// 2. PRM scopes_supported
|
||||
// 3. clientMetadata.scope (SDK fallback)
|
||||
// 4. Omit scope parameter
|
||||
let effectiveScope = requestedScope || resourceMetadata?.scopes_supported?.join(' ') || clientMetadata.scope;
|
||||
|
||||
// SEP-2207: Append offline_access when the AS advertises it
|
||||
// and the client supports the refresh_token grant.
|
||||
if (
|
||||
effectiveScope &&
|
||||
authServerMetadata?.scopes_supported?.includes('offline_access') &&
|
||||
!effectiveScope.split(' ').includes('offline_access') &&
|
||||
clientMetadata.grant_types?.includes('refresh_token')
|
||||
) {
|
||||
effectiveScope = `${effectiveScope} offline_access`;
|
||||
}
|
||||
|
||||
return effectiveScope;
|
||||
}
|
||||
|
||||
async function authInternal(
|
||||
provider: OAuthClientProvider,
|
||||
{
|
||||
|
|
@ -659,12 +691,13 @@ async function authInternal(
|
|||
await provider.saveResourceUrl?.(String(resource));
|
||||
}
|
||||
|
||||
// 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;
|
||||
// Scope selection used consistently for DCR and the authorization request.
|
||||
const resolvedScope = determineScope({
|
||||
requestedScope: scope,
|
||||
resourceMetadata,
|
||||
authServerMetadata: metadata,
|
||||
clientMetadata: provider.clientMetadata
|
||||
});
|
||||
|
||||
// Handle client registration if needed
|
||||
let clientInformation = await Promise.resolve(provider.clientInformation());
|
||||
|
|
@ -718,7 +751,7 @@ async function authInternal(
|
|||
metadata,
|
||||
resource,
|
||||
authorizationCode,
|
||||
scope,
|
||||
scope: resolvedScope,
|
||||
fetchFn
|
||||
});
|
||||
|
||||
|
|
@ -1360,7 +1393,7 @@ export async function startAuthorization(
|
|||
authorizationUrl.searchParams.set('scope', scope);
|
||||
}
|
||||
|
||||
if (scope?.includes('offline_access')) {
|
||||
if (scope?.split(' ').includes('offline_access')) {
|
||||
// if the request includes the OIDC-only "offline_access" scope,
|
||||
// we need to set the prompt to "consent" to ensure the user is prompted to grant offline access
|
||||
// https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type { OAuthClientProvider } from '../../src/client/auth.js';
|
|||
import {
|
||||
auth,
|
||||
buildDiscoveryUrls,
|
||||
determineScope,
|
||||
discoverAuthorizationServerMetadata,
|
||||
discoverOAuthMetadata,
|
||||
discoverOAuthProtectedResourceMetadata,
|
||||
|
|
@ -3734,4 +3735,227 @@ describe('OAuth Authorization', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('determineScope', () => {
|
||||
const baseClientMetadata = {
|
||||
redirect_uris: ['http://localhost:3000/callback'],
|
||||
client_name: 'Test Client'
|
||||
};
|
||||
|
||||
describe('MCP Scope Selection Strategy', () => {
|
||||
it('returns explicit requestedScope as-is (priority 1)', () => {
|
||||
const result = determineScope({
|
||||
requestedScope: 'files:read',
|
||||
resourceMetadata: {
|
||||
resource: 'https://api.example.com/',
|
||||
scopes_supported: ['mcp:read', 'mcp:write']
|
||||
},
|
||||
clientMetadata: {
|
||||
...baseClientMetadata,
|
||||
scope: 'fallback:scope'
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toBe('files:read');
|
||||
});
|
||||
|
||||
it('uses PRM scopes_supported when no explicit scope (priority 2)', () => {
|
||||
const result = determineScope({
|
||||
resourceMetadata: {
|
||||
resource: 'https://api.example.com/',
|
||||
scopes_supported: ['mcp:read', 'mcp:write', 'mcp:admin']
|
||||
},
|
||||
clientMetadata: {
|
||||
...baseClientMetadata,
|
||||
scope: 'fallback:scope'
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toBe('mcp:read mcp:write mcp:admin');
|
||||
});
|
||||
|
||||
it('falls back to clientMetadata.scope when no PRM scopes (priority 3)', () => {
|
||||
const result = determineScope({
|
||||
resourceMetadata: {
|
||||
resource: 'https://api.example.com/'
|
||||
},
|
||||
clientMetadata: {
|
||||
...baseClientMetadata,
|
||||
scope: 'client:default'
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toBe('client:default');
|
||||
});
|
||||
|
||||
it('returns undefined when no scope source available (priority 4)', () => {
|
||||
const result = determineScope({
|
||||
clientMetadata: baseClientMetadata
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when PRM has no scopes_supported and clientMetadata has no scope', () => {
|
||||
const result = determineScope({
|
||||
resourceMetadata: {
|
||||
resource: 'https://api.example.com/'
|
||||
},
|
||||
clientMetadata: baseClientMetadata
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SEP-2207: offline_access scope augmentation', () => {
|
||||
const asMetadataWithOfflineAccess = {
|
||||
issuer: 'https://auth.example.com',
|
||||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||||
token_endpoint: 'https://auth.example.com/token',
|
||||
response_types_supported: ['code'] as string[],
|
||||
scopes_supported: ['openid', 'profile', 'offline_access']
|
||||
};
|
||||
|
||||
const asMetadataWithoutOfflineAccess = {
|
||||
issuer: 'https://auth.example.com',
|
||||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||||
token_endpoint: 'https://auth.example.com/token',
|
||||
response_types_supported: ['code'] as string[],
|
||||
scopes_supported: ['openid', 'profile']
|
||||
};
|
||||
|
||||
const clientMetadataWithRefreshToken = {
|
||||
...baseClientMetadata,
|
||||
grant_types: ['authorization_code', 'refresh_token']
|
||||
};
|
||||
|
||||
it('augments explicit scope with offline_access', () => {
|
||||
const result = determineScope({
|
||||
requestedScope: 'mcp:read mcp:write',
|
||||
resourceMetadata: {
|
||||
resource: 'https://api.example.com/',
|
||||
scopes_supported: ['mcp:read', 'mcp:write']
|
||||
},
|
||||
authServerMetadata: asMetadataWithOfflineAccess,
|
||||
clientMetadata: clientMetadataWithRefreshToken
|
||||
});
|
||||
|
||||
expect(result).toBe('mcp:read mcp:write offline_access');
|
||||
});
|
||||
|
||||
it('adds offline_access when AS supports it and client grant_types includes refresh_token', () => {
|
||||
const result = determineScope({
|
||||
resourceMetadata: {
|
||||
resource: 'https://api.example.com/',
|
||||
scopes_supported: ['mcp:read', 'mcp:write']
|
||||
},
|
||||
authServerMetadata: asMetadataWithOfflineAccess,
|
||||
clientMetadata: clientMetadataWithRefreshToken
|
||||
});
|
||||
|
||||
expect(result).toBe('mcp:read mcp:write offline_access');
|
||||
});
|
||||
|
||||
it('adds offline_access when using clientMetadata.scope fallback', () => {
|
||||
const result = determineScope({
|
||||
authServerMetadata: asMetadataWithOfflineAccess,
|
||||
clientMetadata: {
|
||||
...clientMetadataWithRefreshToken,
|
||||
scope: 'mcp:tools'
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toBe('mcp:tools offline_access');
|
||||
});
|
||||
|
||||
it('does NOT augment when no other scopes are present', () => {
|
||||
const result = determineScope({
|
||||
authServerMetadata: asMetadataWithOfflineAccess,
|
||||
clientMetadata: clientMetadataWithRefreshToken
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does NOT augment when AS metadata lacks offline_access', () => {
|
||||
const result = determineScope({
|
||||
resourceMetadata: {
|
||||
resource: 'https://api.example.com/',
|
||||
scopes_supported: ['mcp:read', 'mcp:write']
|
||||
},
|
||||
authServerMetadata: asMetadataWithoutOfflineAccess,
|
||||
clientMetadata: clientMetadataWithRefreshToken
|
||||
});
|
||||
|
||||
expect(result).toBe('mcp:read mcp:write');
|
||||
});
|
||||
|
||||
it('does NOT augment when AS metadata is undefined', () => {
|
||||
const result = determineScope({
|
||||
resourceMetadata: {
|
||||
resource: 'https://api.example.com/',
|
||||
scopes_supported: ['mcp:read', 'mcp:write']
|
||||
},
|
||||
clientMetadata: clientMetadataWithRefreshToken
|
||||
});
|
||||
|
||||
expect(result).toBe('mcp:read mcp:write');
|
||||
});
|
||||
|
||||
it('does NOT augment when offline_access already in clientMetadata.scope', () => {
|
||||
const result = determineScope({
|
||||
authServerMetadata: asMetadataWithOfflineAccess,
|
||||
clientMetadata: {
|
||||
...clientMetadataWithRefreshToken,
|
||||
scope: 'mcp:tools offline_access'
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toBe('mcp:tools offline_access');
|
||||
});
|
||||
|
||||
it('does NOT augment when non-compliant PRM already includes offline_access', () => {
|
||||
const result = determineScope({
|
||||
resourceMetadata: {
|
||||
resource: 'https://api.example.com/',
|
||||
scopes_supported: ['mcp:read', 'offline_access', 'mcp:write']
|
||||
},
|
||||
authServerMetadata: asMetadataWithOfflineAccess,
|
||||
clientMetadata: clientMetadataWithRefreshToken
|
||||
});
|
||||
|
||||
expect(result).toBe('mcp:read offline_access mcp:write');
|
||||
});
|
||||
|
||||
it('does NOT augment when grant_types omits refresh_token', () => {
|
||||
const result = determineScope({
|
||||
resourceMetadata: {
|
||||
resource: 'https://api.example.com/',
|
||||
scopes_supported: ['mcp:read', 'mcp:write']
|
||||
},
|
||||
authServerMetadata: asMetadataWithOfflineAccess,
|
||||
clientMetadata: {
|
||||
...baseClientMetadata,
|
||||
grant_types: ['authorization_code']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toBe('mcp:read mcp:write');
|
||||
});
|
||||
|
||||
it('does NOT augment when grant_types is undefined (respects OAuth defaults)', () => {
|
||||
const result = determineScope({
|
||||
resourceMetadata: {
|
||||
resource: 'https://api.example.com/',
|
||||
scopes_supported: ['mcp:read', 'mcp:write']
|
||||
},
|
||||
authServerMetadata: asMetadataWithOfflineAccess,
|
||||
clientMetadata: baseClientMetadata
|
||||
});
|
||||
|
||||
expect(result).toBe('mcp:read mcp:write');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -188,7 +188,9 @@ registerScenarios(
|
|||
'auth/scope-retry-limit',
|
||||
'auth/token-endpoint-auth-basic',
|
||||
'auth/token-endpoint-auth-post',
|
||||
'auth/token-endpoint-auth-none'
|
||||
'auth/token-endpoint-auth-none',
|
||||
'auth/offline-access-scope',
|
||||
'auth/offline-access-not-supported'
|
||||
],
|
||||
runAuthClient
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue