Support incremental scope consent (SEP-835) (#1084)

This commit is contained in:
Stephen Halter 2025-12-11 19:51:47 -08:00 • committed by GitHub
commit 31f8d20a1a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -26,6 +26,8 @@ public sealed class Program
private readonly ConcurrentDictionary<string, TokenInfo> _tokens = new();
private readonly ConcurrentDictionary<string, ClientInfo> _clients = new();
private readonly ConcurrentQueue<string> _metadataRequests = new();
private readonly RSA _rsa;
private readonly string _keyId;
@ -46,7 +48,6 @@ public sealed class Program
}
// Track if we've already issued an already-expired token for the CanAuthenticate_WithTokenRefresh test which uses the test-refresh-client registration.
public bool HasIssuedExpiredToken { get; set; }
public bool HasRefreshedToken { get; set; }
/// <summary>
@ -59,6 +60,9 @@ public sealed class Program
/// </remarks>
public bool ClientIdMetadataDocumentSupported { get; set; } = true;
public HashSet<string> DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase);
public IReadOnlyCollection<string> MetadataRequests => _metadataRequests.ToArray();
/// <summary>
/// Entry point for the application.
/// </summary>
@ -111,24 +115,15 @@ public sealed class Program
var app = builder.Build();
// Set up the demo client
var clientId = "demo-client";
var clientSecret = "demo-secret";
_clients[clientId] = new ClientInfo
{
ClientId = clientId,
RequiresClientSecret = true,
ClientSecret = clientSecret,
RedirectUris = ["http://localhost:1179/callback"],
};
// When this client ID is used, the first token issued will already be expired to make
// testing the refresh flow easier.
_clients["test-refresh-client"] = new ClientInfo
{
ClientId = "test-refresh-client",
RequiresClientSecret = true,
ClientSecret = "test-refresh-secret",
RedirectUris = ["http://localhost:1179/callback"],
};
@ -146,42 +141,62 @@ public sealed class Program
};
// The MCP spec tells the client to use /.well-known/oauth-authorization-server but AddJwtBearer looks for
// /.well-known/openid-configuration by default. To make things easier, we support both with the same response
// which seems to be common. Ex. https://github.com/keycloak/keycloak/pull/29628
// /.well-known/openid-configuration by default.
//
// The requirements for these endpoints are at https://www.rfc-editor.org/rfc/rfc8414 and
// https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata respectively.
// They do differ, but it's close enough at least for our current testing to use the same response for both.
// See https://gist.github.com/localden/26d8bcf641703c08a5d8741aa9c3336c
string[] metadataEndpoints = ["/.well-known/oauth-authorization-server", "/.well-known/openid-configuration"];
foreach (var metadataEndpoint in metadataEndpoints)
IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null)
{
// OAuth 2.0 Authorization Server Metadata (RFC 8414)
app.MapGet(metadataEndpoint, () =>
{
var metadata = new OAuthServerMetadata
{
Issuer = _url,
AuthorizationEndpoint = $"{_url}/authorize",
TokenEndpoint = $"{_url}/token",
JwksUri = $"{_url}/.well-known/jwks.json",
ResponseTypesSupported = ["code"],
SubjectTypesSupported = ["public"],
IdTokenSigningAlgValuesSupported = ["RS256"],
ScopesSupported = ["openid", "profile", "email", "mcp:tools"],
TokenEndpointAuthMethodsSupported = ["client_secret_post"],
ClaimsSupported = ["sub", "iss", "name", "email", "aud"],
CodeChallengeMethodsSupported = ["S256"],
GrantTypesSupported = ["authorization_code", "refresh_token"],
IntrospectionEndpoint = $"{_url}/introspect",
RegistrationEndpoint = $"{_url}/register",
ClientIdMetadataDocumentSupported = ClientIdMetadataDocumentSupported,
};
_metadataRequests.Enqueue(context.Request.Path);
return Results.Ok(metadata);
});
if (DisabledMetadataPaths.Contains(context.Request.Path))
{
return Results.NotFound();
}
if (!string.IsNullOrEmpty(issuerPath))
{
issuerPath = $"/{issuerPath}";
}
var metadata = new OAuthServerMetadata
{
Issuer = $"{_url}{issuerPath}",
AuthorizationEndpoint = $"{_url}/authorize",
TokenEndpoint = $"{_url}/token",
JwksUri = $"{_url}/.well-known/jwks.json",
ResponseTypesSupported = ["code"],
SubjectTypesSupported = ["public"],
IdTokenSigningAlgValuesSupported = ["RS256"],
ScopesSupported = ["openid", "profile", "email", "mcp:tools"],
TokenEndpointAuthMethodsSupported = ["client_secret_post"],
ClaimsSupported = ["sub", "iss", "name", "email", "aud"],
CodeChallengeMethodsSupported = ["S256"],
GrantTypesSupported = ["authorization_code", "refresh_token"],
IntrospectionEndpoint = $"{_url}/introspect",
RegistrationEndpoint = $"{_url}/register",
ClientIdMetadataDocumentSupported = ClientIdMetadataDocumentSupported,
};
return Results.Ok(metadata);
}
app.MapGet("/.well-known/oauth-authorization-server", HandleMetadataRequest);
app.MapGet("/.well-known/openid-configuration", HandleMetadataRequest);
app.MapGet("/.well-known/oauth-authorization-server/{**issuerPath}", HandleMetadataRequest);
app.MapGet("/.well-known/openid-configuration/{**issuerPath}", HandleMetadataRequest);
app.MapGet("/{**fullPath}", (HttpContext context, string fullPath) =>
{
if (fullPath.EndsWith("/.well-known/openid-configuration", StringComparison.OrdinalIgnoreCase))
{
return HandleMetadataRequest(context, fullPath[..^"/.well-known/openid-configuration".Length]);
}
return Results.NotFound();
});
// JWKS endpoint to expose the public key
app.MapGet("/.well-known/jwks.json", () =>
{
@ -564,14 +579,6 @@ public sealed class Program
{
var expiresIn = TimeSpan.FromHours(1);
var issuedAt = DateTimeOffset.UtcNow;
// For test-refresh-client, make the first token expired to test refresh functionality.
if (clientId == "test-refresh-client" && !HasIssuedExpiredToken)
{
HasIssuedExpiredToken = true;
expiresIn = TimeSpan.FromHours(-1);
}
var expiresAt = issuedAt.Add(expiresIn);
var jwtId = Guid.NewGuid().ToString();
@ -580,7 +587,7 @@ public sealed class Program
{
{ "alg", "RS256" },
{ "typ", "JWT" },
{ "kid", _keyId }
{ "kid", _keyId },
};
var payload = new Dictionary<string, string>
@ -593,7 +600,7 @@ public sealed class Program
{ "jti", jwtId },
{ "iat", issuedAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) },
{ "exp", expiresAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) },
{ "scope", string.Join(" ", scopes) }
{ "scope", string.Join(" ", scopes) },
};
// Create JWT token