Append offline_access to authorization scope when AS advertises it (SEP-2207) (#1479)
This commit is contained in:
parent
b8c4d951ec
commit
74788af04d
3 changed files with 149 additions and 1 deletions
|
|
@ -492,6 +492,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
}
|
}
|
||||||
|
|
||||||
var scope = GetScopeParameter(protectedResourceMetadata);
|
var scope = GetScopeParameter(protectedResourceMetadata);
|
||||||
|
scope = AugmentScopeWithOfflineAccess(scope, authServerMetadata);
|
||||||
if (!string.IsNullOrEmpty(scope))
|
if (!string.IsNullOrEmpty(scope))
|
||||||
{
|
{
|
||||||
queryParamsDictionary["scope"] = scope!;
|
queryParamsDictionary["scope"] = scope!;
|
||||||
|
|
@ -726,6 +727,37 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
return _configuredScopes;
|
return _configuredScopes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Augments the scope parameter with <c>offline_access</c> if the authorization server advertises it in
|
||||||
|
/// <c>scopes_supported</c> and it is not already present. This signals to OIDC-flavored authorization servers
|
||||||
|
/// that the client desires a refresh token, per SEP-2207.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that the resource URI in the metadata exactly matches the original request URL as required by the RFC.
|
/// 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.
|
/// Per RFC: The resource value must be identical to the URL that the client used to make the request to the resource server.
|
||||||
|
|
|
||||||
|
|
@ -1261,4 +1261,108 @@ public class AuthTests : OAuthTestBase
|
||||||
await using var client = await McpClient.CreateAsync(
|
await using var client = await McpClient.CreateAsync(
|
||||||
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,16 @@ public sealed class Program
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public bool ExpectResource { get; set; } = true;
|
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 HashSet<string> DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
public IReadOnlyCollection<string> MetadataRequests => _metadataRequests.ToArray();
|
public IReadOnlyCollection<string> MetadataRequests => _metadataRequests.ToArray();
|
||||||
|
|
||||||
|
|
@ -188,7 +198,9 @@ public sealed class Program
|
||||||
ResponseTypesSupported = ["code"],
|
ResponseTypesSupported = ["code"],
|
||||||
SubjectTypesSupported = ["public"],
|
SubjectTypesSupported = ["public"],
|
||||||
IdTokenSigningAlgValuesSupported = ["RS256"],
|
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"],
|
TokenEndpointAuthMethodsSupported = ["client_secret_post"],
|
||||||
ClaimsSupported = ["sub", "iss", "name", "email", "aud"],
|
ClaimsSupported = ["sub", "iss", "name", "email", "aud"],
|
||||||
CodeChallengeMethodsSupported = ["S256"],
|
CodeChallengeMethodsSupported = ["S256"],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue