Append offline_access to authorization scope when AS advertises it (SEP-2207) (#1479)

This commit is contained in:
Copilot 2026-05-19 14:47:10 -07:00 • committed by GitHub
commit 74788af04d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 149 additions and 1 deletions

View file

@ -1261,4 +1261,108 @@ public class AuthTests : OAuthTestBase
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<McpAuthenticationOptions>(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");
}
}

View file

@ -78,6 +78,16 @@ public sealed class Program
/// </remarks>
public bool ExpectResource { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether the authorization server advertises support for
/// <c>offline_access</c> in its <c>scopes_supported</c> metadata. This simulates an OIDC-flavored
/// authorization server that issues refresh tokens when the client requests the <c>offline_access</c> scope.
/// </summary>
/// <remarks>
/// The default value is <c>false</c>.
/// </remarks>
public bool IncludeOfflineAccessInMetadata { get; set; }
public HashSet<string> DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase);
public IReadOnlyCollection<string> MetadataRequests => _metadataRequests.ToArray();
@ -188,7 +198,9 @@ public sealed class Program
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"],