Support incremental scope consent (SEP-835) (#1084)
This commit is contained in:
parent
828d51e9d4
commit
31f8d20a1a
1 changed files with 782 additions and 394 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue