diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index ecef8e15e..c376f932c 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -492,6 +492,7 @@ private Uri BuildAuthorizationUrl( } var scope = GetScopeParameter(protectedResourceMetadata); + scope = AugmentScopeWithOfflineAccess(scope, authServerMetadata); if (!string.IsNullOrEmpty(scope)) { queryParamsDictionary["scope"] = scope!; @@ -726,6 +727,37 @@ private async Task PerformDynamicClientRegistrationAsync( return _configuredScopes; } + /// + /// Augments the scope parameter with offline_access if the authorization server advertises it in + /// scopes_supported and it is not already present. This signals to OIDC-flavored authorization servers + /// that the client desires a refresh token, per SEP-2207. + /// + private static string? AugmentScopeWithOfflineAccess(string? scope, AuthorizationServerMetadata authServerMetadata) + { + const string OfflineAccess = "offline_access"; + + if (authServerMetadata.ScopesSupported?.Contains(OfflineAccess) is not true) + { + return scope; + } + + if (scope is null) + { + return OfflineAccess; + } + + // Check if offline_access is already in the scope string (space-separated tokens). + foreach (var token in scope.Split(' ')) + { + if (token == OfflineAccess) + { + return scope; + } + } + + return scope + " " + OfflineAccess; + } + /// /// Verifies that the resource URI in the metadata exactly matches the original request URL as required by the RFC. /// Per RFC: The resource value must be identical to the URL that the client used to make the request to the resource server. diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs index c4979fb10..4f6e0ce94 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -1261,4 +1261,108 @@ public async Task CanAuthenticate_WithLegacyServerUsingDefaultEndpointFallback() await using var client = await McpClient.CreateAsync( transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); } + + [Fact] + public async Task AuthorizationFlow_AppendsOfflineAccess_WhenServerAdvertisesIt() + { + TestOAuthServer.IncludeOfflineAccessInMetadata = true; + await using var app = await StartMcpServerAsync(); + + string? requestedScope = null; + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationRedirectDelegate = (uri, redirect, ct) => + { + var query = QueryHelpers.ParseQuery(uri.Query); + requestedScope = query["scope"].ToString(); + return HandleAuthorizationUrlAsync(uri, redirect, ct); + }, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(requestedScope); + Assert.Contains("offline_access", requestedScope!.Split(' ')); + } + + [Fact] + public async Task AuthorizationFlow_DoesNotAppendOfflineAccess_WhenServerDoesNotAdvertiseIt() + { + // IncludeOfflineAccessInMetadata defaults to false, so the AS will not advertise offline_access. + await using var app = await StartMcpServerAsync(); + + string? requestedScope = null; + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationRedirectDelegate = (uri, redirect, ct) => + { + var query = QueryHelpers.ParseQuery(uri.Query); + requestedScope = query["scope"].ToString(); + return HandleAuthorizationUrlAsync(uri, redirect, ct); + }, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(requestedScope); + Assert.DoesNotContain("offline_access", requestedScope!.Split(' ')); + } + + [Fact] + public async Task AuthorizationFlow_DoesNotDuplicateOfflineAccess_WhenAlreadyPresent() + { + TestOAuthServer.IncludeOfflineAccessInMetadata = true; + + // Configure the PRM to already include offline_access in its scopes. + Builder.Services.Configure(McpAuthenticationDefaults.AuthenticationScheme, options => + { + options.ResourceMetadata!.ScopesSupported = ["mcp:tools", "offline_access"]; + }); + + await using var app = await StartMcpServerAsync(); + + string? requestedScope = null; + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationRedirectDelegate = (uri, redirect, ct) => + { + var query = QueryHelpers.ParseQuery(uri.Query); + requestedScope = query["scope"].ToString(); + return HandleAuthorizationUrlAsync(uri, redirect, ct); + }, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(requestedScope); + var scopeTokens = requestedScope!.Split(' '); + Assert.Single(scopeTokens, t => t == "offline_access"); + } } diff --git a/tests/ModelContextProtocol.TestOAuthServer/Program.cs b/tests/ModelContextProtocol.TestOAuthServer/Program.cs index e882ecbef..a65c5e4ab 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/Program.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/Program.cs @@ -78,6 +78,16 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor /// public bool ExpectResource { get; set; } = true; + /// + /// Gets or sets a value indicating whether the authorization server advertises support for + /// offline_access in its scopes_supported metadata. This simulates an OIDC-flavored + /// authorization server that issues refresh tokens when the client requests the offline_access scope. + /// + /// + /// The default value is false. + /// + public bool IncludeOfflineAccessInMetadata { get; set; } + public HashSet DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase); public IReadOnlyCollection MetadataRequests => _metadataRequests.ToArray(); @@ -188,7 +198,9 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null) ResponseTypesSupported = ["code"], SubjectTypesSupported = ["public"], IdTokenSigningAlgValuesSupported = ["RS256"], - ScopesSupported = ["openid", "profile", "email", "mcp:tools"], + ScopesSupported = IncludeOfflineAccessInMetadata + ? ["openid", "profile", "email", "mcp:tools", "offline_access"] + : ["openid", "profile", "email", "mcp:tools"], TokenEndpointAuthMethodsSupported = ["client_secret_post"], ClaimsSupported = ["sub", "iss", "name", "email", "aud"], CodeChallengeMethodsSupported = ["S256"],