Skip to content

Commit 9d08392

Browse files
authored
SEP-2207: Refresh token guidance (#1523)
1 parent 6711ed9 commit 9d08392

3 files changed

Lines changed: 268 additions & 9 deletions

File tree

packages/client/src/client/auth.ts

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,38 @@ export async function auth(
566566
}
567567
}
568568

569+
/**
570+
* Selects scopes per the MCP spec and augment for refresh token support.
571+
*/
572+
export function determineScope(options: {
573+
requestedScope?: string;
574+
resourceMetadata?: OAuthProtectedResourceMetadata;
575+
authServerMetadata?: AuthorizationServerMetadata;
576+
clientMetadata: OAuthClientMetadata;
577+
}): string | undefined {
578+
const { requestedScope, resourceMetadata, authServerMetadata, clientMetadata } = options;
579+
580+
// Scope selection priority (MCP spec):
581+
// 1. WWW-Authenticate header scope
582+
// 2. PRM scopes_supported
583+
// 3. clientMetadata.scope (SDK fallback)
584+
// 4. Omit scope parameter
585+
let effectiveScope = requestedScope || resourceMetadata?.scopes_supported?.join(' ') || clientMetadata.scope;
586+
587+
// SEP-2207: Append offline_access when the AS advertises it
588+
// and the client supports the refresh_token grant.
589+
if (
590+
effectiveScope &&
591+
authServerMetadata?.scopes_supported?.includes('offline_access') &&
592+
!effectiveScope.split(' ').includes('offline_access') &&
593+
clientMetadata.grant_types?.includes('refresh_token')
594+
) {
595+
effectiveScope = `${effectiveScope} offline_access`;
596+
}
597+
598+
return effectiveScope;
599+
}
600+
569601
async function authInternal(
570602
provider: OAuthClientProvider,
571603
{
@@ -659,12 +691,13 @@ async function authInternal(
659691
await provider.saveResourceUrl?.(String(resource));
660692
}
661693

662-
// Apply scope selection strategy (SEP-835):
663-
// 1. WWW-Authenticate scope (passed via `scope` param)
664-
// 2. PRM scopes_supported
665-
// 3. Client metadata scope (user-configured fallback)
666-
// The resolved scope is used consistently for both DCR and the authorization request.
667-
const resolvedScope = scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope;
694+
// Scope selection used consistently for DCR and the authorization request.
695+
const resolvedScope = determineScope({
696+
requestedScope: scope,
697+
resourceMetadata,
698+
authServerMetadata: metadata,
699+
clientMetadata: provider.clientMetadata
700+
});
668701

669702
// Handle client registration if needed
670703
let clientInformation = await Promise.resolve(provider.clientInformation());
@@ -718,7 +751,7 @@ async function authInternal(
718751
metadata,
719752
resource,
720753
authorizationCode,
721-
scope,
754+
scope: resolvedScope,
722755
fetchFn
723756
});
724757

@@ -1360,7 +1393,7 @@ export async function startAuthorization(
13601393
authorizationUrl.searchParams.set('scope', scope);
13611394
}
13621395

1363-
if (scope?.includes('offline_access')) {
1396+
if (scope?.split(' ').includes('offline_access')) {
13641397
// if the request includes the OIDC-only "offline_access" scope,
13651398
// we need to set the prompt to "consent" to ensure the user is prompted to grant offline access
13661399
// https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess

packages/client/test/client/auth.test.ts

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { OAuthClientProvider } from '../../src/client/auth.js';
77
import {
88
auth,
99
buildDiscoveryUrls,
10+
determineScope,
1011
discoverAuthorizationServerMetadata,
1112
discoverOAuthMetadata,
1213
discoverOAuthProtectedResourceMetadata,
@@ -3734,4 +3735,227 @@ describe('OAuth Authorization', () => {
37343735
});
37353736
});
37363737
});
3738+
3739+
describe('determineScope', () => {
3740+
const baseClientMetadata = {
3741+
redirect_uris: ['http://localhost:3000/callback'],
3742+
client_name: 'Test Client'
3743+
};
3744+
3745+
describe('MCP Scope Selection Strategy', () => {
3746+
it('returns explicit requestedScope as-is (priority 1)', () => {
3747+
const result = determineScope({
3748+
requestedScope: 'files:read',
3749+
resourceMetadata: {
3750+
resource: 'https://api.example.com/',
3751+
scopes_supported: ['mcp:read', 'mcp:write']
3752+
},
3753+
clientMetadata: {
3754+
...baseClientMetadata,
3755+
scope: 'fallback:scope'
3756+
}
3757+
});
3758+
3759+
expect(result).toBe('files:read');
3760+
});
3761+
3762+
it('uses PRM scopes_supported when no explicit scope (priority 2)', () => {
3763+
const result = determineScope({
3764+
resourceMetadata: {
3765+
resource: 'https://api.example.com/',
3766+
scopes_supported: ['mcp:read', 'mcp:write', 'mcp:admin']
3767+
},
3768+
clientMetadata: {
3769+
...baseClientMetadata,
3770+
scope: 'fallback:scope'
3771+
}
3772+
});
3773+
3774+
expect(result).toBe('mcp:read mcp:write mcp:admin');
3775+
});
3776+
3777+
it('falls back to clientMetadata.scope when no PRM scopes (priority 3)', () => {
3778+
const result = determineScope({
3779+
resourceMetadata: {
3780+
resource: 'https://api.example.com/'
3781+
},
3782+
clientMetadata: {
3783+
...baseClientMetadata,
3784+
scope: 'client:default'
3785+
}
3786+
});
3787+
3788+
expect(result).toBe('client:default');
3789+
});
3790+
3791+
it('returns undefined when no scope source available (priority 4)', () => {
3792+
const result = determineScope({
3793+
clientMetadata: baseClientMetadata
3794+
});
3795+
3796+
expect(result).toBeUndefined();
3797+
});
3798+
3799+
it('returns undefined when PRM has no scopes_supported and clientMetadata has no scope', () => {
3800+
const result = determineScope({
3801+
resourceMetadata: {
3802+
resource: 'https://api.example.com/'
3803+
},
3804+
clientMetadata: baseClientMetadata
3805+
});
3806+
3807+
expect(result).toBeUndefined();
3808+
});
3809+
});
3810+
3811+
describe('SEP-2207: offline_access scope augmentation', () => {
3812+
const asMetadataWithOfflineAccess = {
3813+
issuer: 'https://auth.example.com',
3814+
authorization_endpoint: 'https://auth.example.com/authorize',
3815+
token_endpoint: 'https://auth.example.com/token',
3816+
response_types_supported: ['code'] as string[],
3817+
scopes_supported: ['openid', 'profile', 'offline_access']
3818+
};
3819+
3820+
const asMetadataWithoutOfflineAccess = {
3821+
issuer: 'https://auth.example.com',
3822+
authorization_endpoint: 'https://auth.example.com/authorize',
3823+
token_endpoint: 'https://auth.example.com/token',
3824+
response_types_supported: ['code'] as string[],
3825+
scopes_supported: ['openid', 'profile']
3826+
};
3827+
3828+
const clientMetadataWithRefreshToken = {
3829+
...baseClientMetadata,
3830+
grant_types: ['authorization_code', 'refresh_token']
3831+
};
3832+
3833+
it('augments explicit scope with offline_access', () => {
3834+
const result = determineScope({
3835+
requestedScope: 'mcp:read mcp:write',
3836+
resourceMetadata: {
3837+
resource: 'https://api.example.com/',
3838+
scopes_supported: ['mcp:read', 'mcp:write']
3839+
},
3840+
authServerMetadata: asMetadataWithOfflineAccess,
3841+
clientMetadata: clientMetadataWithRefreshToken
3842+
});
3843+
3844+
expect(result).toBe('mcp:read mcp:write offline_access');
3845+
});
3846+
3847+
it('adds offline_access when AS supports it and client grant_types includes refresh_token', () => {
3848+
const result = determineScope({
3849+
resourceMetadata: {
3850+
resource: 'https://api.example.com/',
3851+
scopes_supported: ['mcp:read', 'mcp:write']
3852+
},
3853+
authServerMetadata: asMetadataWithOfflineAccess,
3854+
clientMetadata: clientMetadataWithRefreshToken
3855+
});
3856+
3857+
expect(result).toBe('mcp:read mcp:write offline_access');
3858+
});
3859+
3860+
it('adds offline_access when using clientMetadata.scope fallback', () => {
3861+
const result = determineScope({
3862+
authServerMetadata: asMetadataWithOfflineAccess,
3863+
clientMetadata: {
3864+
...clientMetadataWithRefreshToken,
3865+
scope: 'mcp:tools'
3866+
}
3867+
});
3868+
3869+
expect(result).toBe('mcp:tools offline_access');
3870+
});
3871+
3872+
it('does NOT augment when no other scopes are present', () => {
3873+
const result = determineScope({
3874+
authServerMetadata: asMetadataWithOfflineAccess,
3875+
clientMetadata: clientMetadataWithRefreshToken
3876+
});
3877+
3878+
expect(result).toBeUndefined();
3879+
});
3880+
3881+
it('does NOT augment when AS metadata lacks offline_access', () => {
3882+
const result = determineScope({
3883+
resourceMetadata: {
3884+
resource: 'https://api.example.com/',
3885+
scopes_supported: ['mcp:read', 'mcp:write']
3886+
},
3887+
authServerMetadata: asMetadataWithoutOfflineAccess,
3888+
clientMetadata: clientMetadataWithRefreshToken
3889+
});
3890+
3891+
expect(result).toBe('mcp:read mcp:write');
3892+
});
3893+
3894+
it('does NOT augment when AS metadata is undefined', () => {
3895+
const result = determineScope({
3896+
resourceMetadata: {
3897+
resource: 'https://api.example.com/',
3898+
scopes_supported: ['mcp:read', 'mcp:write']
3899+
},
3900+
clientMetadata: clientMetadataWithRefreshToken
3901+
});
3902+
3903+
expect(result).toBe('mcp:read mcp:write');
3904+
});
3905+
3906+
it('does NOT augment when offline_access already in clientMetadata.scope', () => {
3907+
const result = determineScope({
3908+
authServerMetadata: asMetadataWithOfflineAccess,
3909+
clientMetadata: {
3910+
...clientMetadataWithRefreshToken,
3911+
scope: 'mcp:tools offline_access'
3912+
}
3913+
});
3914+
3915+
expect(result).toBe('mcp:tools offline_access');
3916+
});
3917+
3918+
it('does NOT augment when non-compliant PRM already includes offline_access', () => {
3919+
const result = determineScope({
3920+
resourceMetadata: {
3921+
resource: 'https://api.example.com/',
3922+
scopes_supported: ['mcp:read', 'offline_access', 'mcp:write']
3923+
},
3924+
authServerMetadata: asMetadataWithOfflineAccess,
3925+
clientMetadata: clientMetadataWithRefreshToken
3926+
});
3927+
3928+
expect(result).toBe('mcp:read offline_access mcp:write');
3929+
});
3930+
3931+
it('does NOT augment when grant_types omits refresh_token', () => {
3932+
const result = determineScope({
3933+
resourceMetadata: {
3934+
resource: 'https://api.example.com/',
3935+
scopes_supported: ['mcp:read', 'mcp:write']
3936+
},
3937+
authServerMetadata: asMetadataWithOfflineAccess,
3938+
clientMetadata: {
3939+
...baseClientMetadata,
3940+
grant_types: ['authorization_code']
3941+
}
3942+
});
3943+
3944+
expect(result).toBe('mcp:read mcp:write');
3945+
});
3946+
3947+
it('does NOT augment when grant_types is undefined (respects OAuth defaults)', () => {
3948+
const result = determineScope({
3949+
resourceMetadata: {
3950+
resource: 'https://api.example.com/',
3951+
scopes_supported: ['mcp:read', 'mcp:write']
3952+
},
3953+
authServerMetadata: asMetadataWithOfflineAccess,
3954+
clientMetadata: baseClientMetadata
3955+
});
3956+
3957+
expect(result).toBe('mcp:read mcp:write');
3958+
});
3959+
});
3960+
});
37373961
});

test/conformance/src/everythingClient.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,9 @@ registerScenarios(
188188
'auth/scope-retry-limit',
189189
'auth/token-endpoint-auth-basic',
190190
'auth/token-endpoint-auth-post',
191-
'auth/token-endpoint-auth-none'
191+
'auth/token-endpoint-auth-none',
192+
'auth/offline-access-scope',
193+
'auth/offline-access-not-supported'
192194
],
193195
runAuthClient
194196
);

0 commit comments

Comments
 (0)