Skip to content

Commit 9cc6418

Browse files
Copilotstephentoub
andauthored
Add offline_access scope augmentation per SEP-2207
Agent-Logs-Url: https://github.com/modelcontextprotocol/csharp-sdk/sessions/4d13c15e-a0f6-4d0f-9525-5f0b9e0c000b Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
1 parent 97e817d commit 9cc6418

3 files changed

Lines changed: 149 additions & 1 deletion

File tree

src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,7 @@ private Uri BuildAuthorizationUrl(
492492
}
493493

494494
var scope = GetScopeParameter(protectedResourceMetadata);
495+
scope = AugmentScopeWithOfflineAccess(scope, authServerMetadata);
495496
if (!string.IsNullOrEmpty(scope))
496497
{
497498
queryParamsDictionary["scope"] = scope!;
@@ -726,6 +727,37 @@ private async Task PerformDynamicClientRegistrationAsync(
726727
return _configuredScopes;
727728
}
728729

730+
/// <summary>
731+
/// Augments the scope parameter with <c>offline_access</c> if the authorization server advertises it in
732+
/// <c>scopes_supported</c> and it is not already present. This signals to OIDC-flavored authorization servers
733+
/// that the client desires a refresh token, per SEP-2207.
734+
/// </summary>
735+
private static string? AugmentScopeWithOfflineAccess(string? scope, AuthorizationServerMetadata authServerMetadata)
736+
{
737+
const string offlineAccess = "offline_access";
738+
739+
if (authServerMetadata.ScopesSupported is null || !authServerMetadata.ScopesSupported.Contains(offlineAccess))
740+
{
741+
return scope;
742+
}
743+
744+
if (scope is null)
745+
{
746+
return offlineAccess;
747+
}
748+
749+
// Check if offline_access is already in the scope string (space-separated tokens).
750+
foreach (var token in scope.Split(' '))
751+
{
752+
if (token == offlineAccess)
753+
{
754+
return scope;
755+
}
756+
}
757+
758+
return scope + " " + offlineAccess;
759+
}
760+
729761
/// <summary>
730762
/// Verifies that the resource URI in the metadata exactly matches the original request URL as required by the RFC.
731763
/// Per RFC: The resource value must be identical to the URL that the client used to make the request to the resource server.

tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1261,4 +1261,108 @@ public async Task CanAuthenticate_WithLegacyServerUsingDefaultEndpointFallback()
12611261
await using var client = await McpClient.CreateAsync(
12621262
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
12631263
}
1264+
1265+
[Fact]
1266+
public async Task AuthorizationFlow_AppendsOfflineAccess_WhenServerAdvertisesIt()
1267+
{
1268+
TestOAuthServer.IncludeOfflineAccessInMetadata = true;
1269+
await using var app = await StartMcpServerAsync();
1270+
1271+
string? requestedScope = null;
1272+
1273+
await using var transport = new HttpClientTransport(new()
1274+
{
1275+
Endpoint = new(McpServerUrl),
1276+
OAuth = new()
1277+
{
1278+
ClientId = "demo-client",
1279+
ClientSecret = "demo-secret",
1280+
RedirectUri = new Uri("http://localhost:1179/callback"),
1281+
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
1282+
{
1283+
var query = QueryHelpers.ParseQuery(uri.Query);
1284+
requestedScope = query["scope"].ToString();
1285+
return HandleAuthorizationUrlAsync(uri, redirect, ct);
1286+
},
1287+
},
1288+
}, HttpClient, LoggerFactory);
1289+
1290+
await using var client = await McpClient.CreateAsync(
1291+
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
1292+
1293+
Assert.NotNull(requestedScope);
1294+
Assert.Contains("offline_access", requestedScope!.Split(' '));
1295+
}
1296+
1297+
[Fact]
1298+
public async Task AuthorizationFlow_DoesNotAppendOfflineAccess_WhenServerDoesNotAdvertiseIt()
1299+
{
1300+
// IncludeOfflineAccessInMetadata defaults to false, so the AS will not advertise offline_access.
1301+
await using var app = await StartMcpServerAsync();
1302+
1303+
string? requestedScope = null;
1304+
1305+
await using var transport = new HttpClientTransport(new()
1306+
{
1307+
Endpoint = new(McpServerUrl),
1308+
OAuth = new()
1309+
{
1310+
ClientId = "demo-client",
1311+
ClientSecret = "demo-secret",
1312+
RedirectUri = new Uri("http://localhost:1179/callback"),
1313+
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
1314+
{
1315+
var query = QueryHelpers.ParseQuery(uri.Query);
1316+
requestedScope = query["scope"].ToString();
1317+
return HandleAuthorizationUrlAsync(uri, redirect, ct);
1318+
},
1319+
},
1320+
}, HttpClient, LoggerFactory);
1321+
1322+
await using var client = await McpClient.CreateAsync(
1323+
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
1324+
1325+
Assert.NotNull(requestedScope);
1326+
Assert.DoesNotContain("offline_access", requestedScope!.Split(' '));
1327+
}
1328+
1329+
[Fact]
1330+
public async Task AuthorizationFlow_DoesNotDuplicateOfflineAccess_WhenAlreadyPresent()
1331+
{
1332+
TestOAuthServer.IncludeOfflineAccessInMetadata = true;
1333+
1334+
// Configure the PRM to already include offline_access in its scopes.
1335+
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
1336+
{
1337+
options.ResourceMetadata!.ScopesSupported = ["mcp:tools", "offline_access"];
1338+
});
1339+
1340+
await using var app = await StartMcpServerAsync();
1341+
1342+
string? requestedScope = null;
1343+
1344+
await using var transport = new HttpClientTransport(new()
1345+
{
1346+
Endpoint = new(McpServerUrl),
1347+
OAuth = new()
1348+
{
1349+
ClientId = "demo-client",
1350+
ClientSecret = "demo-secret",
1351+
RedirectUri = new Uri("http://localhost:1179/callback"),
1352+
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
1353+
{
1354+
var query = QueryHelpers.ParseQuery(uri.Query);
1355+
requestedScope = query["scope"].ToString();
1356+
return HandleAuthorizationUrlAsync(uri, redirect, ct);
1357+
},
1358+
},
1359+
}, HttpClient, LoggerFactory);
1360+
1361+
await using var client = await McpClient.CreateAsync(
1362+
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
1363+
1364+
Assert.NotNull(requestedScope);
1365+
var scopeTokens = requestedScope!.Split(' ');
1366+
Assert.Single(scopeTokens, t => t == "offline_access");
1367+
}
12641368
}

tests/ModelContextProtocol.TestOAuthServer/Program.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,16 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor
7878
/// </remarks>
7979
public bool ExpectResource { get; set; } = true;
8080

81+
/// <summary>
82+
/// Gets or sets a value indicating whether the authorization server advertises support for
83+
/// <c>offline_access</c> in its <c>scopes_supported</c> metadata. This simulates an OIDC-flavored
84+
/// authorization server that issues refresh tokens when the client requests the <c>offline_access</c> scope.
85+
/// </summary>
86+
/// <remarks>
87+
/// The default value is <c>false</c>.
88+
/// </remarks>
89+
public bool IncludeOfflineAccessInMetadata { get; set; }
90+
8191
public HashSet<string> DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase);
8292
public IReadOnlyCollection<string> MetadataRequests => _metadataRequests.ToArray();
8393

@@ -188,7 +198,9 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null)
188198
ResponseTypesSupported = ["code"],
189199
SubjectTypesSupported = ["public"],
190200
IdTokenSigningAlgValuesSupported = ["RS256"],
191-
ScopesSupported = ["openid", "profile", "email", "mcp:tools"],
201+
ScopesSupported = IncludeOfflineAccessInMetadata
202+
? ["openid", "profile", "email", "mcp:tools", "offline_access"]
203+
: ["openid", "profile", "email", "mcp:tools"],
192204
TokenEndpointAuthMethodsSupported = ["client_secret_post"],
193205
ClaimsSupported = ["sub", "iss", "name", "email", "aud"],
194206
CodeChallengeMethodsSupported = ["S256"],

0 commit comments

Comments
 (0)