Add support for Client ID Metadata Documents to enable URL-based client registration (#1023)

This commit is contained in:
Mackinnon Buck 2025-11-25 16:17:47 -08:00 • committed by GitHub
commit 5a65af8ed7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 202 additions and 11 deletions

View file

@ -66,4 +66,10 @@ internal sealed class AuthorizationServerMetadata
/// </summary>
[JsonPropertyName("scopes_supported")]
public List<string>? ScopesSupported { get; set; }
/// <summary>
/// Indicates if the server supports OAuth Client ID Metadata Documents.
/// </summary>
[JsonPropertyName("client_id_metadata_document_supported")]
public bool ClientIdMetadataDocumentSupported { get; set; }
}

View file

@ -23,6 +23,17 @@ public sealed class ClientOAuthOptions
/// </remarks>
public string? ClientSecret { get; set; }
/// <summary>
/// Gets or sets the HTTPS URL pointing to this client's metadata document.
/// </summary>
/// <remarks>
/// When specified, and when the authorization server metadata reports
/// <c>client_id_metadata_document_supported = true</c>, the OAuth client will respond to
/// challenges by sending this URL as the client identifier instead of performing dynamic
/// client registration.
/// </remarks>
public Uri? ClientMetadataDocumentUri { get; set; }
/// <summary>
/// Gets or sets the OAuth scopes to request.
/// </summary>

View file

@ -30,6 +30,7 @@ internal sealed partial class ClientOAuthProvider
private readonly IDictionary<string, string> _additionalAuthorizationParameters;
private readonly Func<IReadOnlyList<Uri>, Uri?> _authServerSelector;
private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate;
private readonly Uri? _clientMetadataDocumentUri;
// _dcrClientName, _dcrClientUri, _dcrInitialAccessToken and _dcrResponseDelegate are used for dynamic client registration (RFC 7591)
private readonly string? _dcrClientName;
@ -74,6 +75,7 @@ internal sealed partial class ClientOAuthProvider
_redirectUri = options.RedirectUri ?? throw new ArgumentException("ClientOAuthOptions.RedirectUri must configured.", nameof(options));
_scopes = options.Scopes?.ToArray();
_additionalAuthorizationParameters = options.AdditionalAuthorizationParameters;
_clientMetadataDocumentUri = options.ClientMetadataDocumentUri;
// Set up authorization server selection strategy
_authServerSelector = options.AuthServerSelector ?? DefaultAuthServerSelector;
@ -221,7 +223,7 @@ internal sealed partial class ClientOAuthProvider
_authServerMetadata = authServerMetadata;
// The existing access token must be invalid to have resulted in a 401 response, but refresh might still work.
if (await _tokenCache.GetTokensAsync(cancellationToken).ConfigureAwait(false) is { RefreshToken: {} refreshToken })
if (await _tokenCache.GetTokensAsync(cancellationToken).ConfigureAwait(false) is { RefreshToken: { } refreshToken })
{
var refreshedTokens = await RefreshTokenAsync(refreshToken, protectedResourceMetadata.Resource, authServerMetadata, cancellationToken).ConfigureAwait(false);
if (refreshedTokens is not null)
@ -231,10 +233,18 @@ internal sealed partial class ClientOAuthProvider
}
}
// Perform dynamic client registration if needed
// Assign a client ID if necessary
if (string.IsNullOrEmpty(_clientId))
{
await PerformDynamicClientRegistrationAsync(authServerMetadata, cancellationToken).ConfigureAwait(false);
// Try using a client metadata document before falling back to dynamic client registration
if (authServerMetadata.ClientIdMetadataDocumentSupported && _clientMetadataDocumentUri is not null)
{
ApplyClientIdMetadataDocument(_clientMetadataDocumentUri);
}
else
{
await PerformDynamicClientRegistrationAsync(authServerMetadata, cancellationToken).ConfigureAwait(false);
}
}
// Perform the OAuth flow
@ -243,6 +253,23 @@ internal sealed partial class ClientOAuthProvider
LogOAuthAuthorizationCompleted();
}
private void ApplyClientIdMetadataDocument(Uri metadataUri)
{
if (!IsValidClientMetadataDocumentUri(metadataUri))
{
ThrowFailedToHandleUnauthorizedResponse(
$"{nameof(ClientOAuthOptions.ClientMetadataDocumentUri)} must be an HTTPS URL with a non-root absolute path. Value: '{metadataUri}'.");
}
_clientId = metadataUri.AbsoluteUri;
// See: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00#section-3
static bool IsValidClientMetadataDocumentUri(Uri uri)
=> uri.IsAbsoluteUri
&& string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)
&& uri.AbsolutePath.Length > 1; // AbsolutePath always starts with "/"
}
private async Task<AuthorizationServerMetadata> GetAuthServerMetadataAsync(Uri authServerUri, CancellationToken cancellationToken)
{
if (authServerUri.OriginalString.Length == 0 ||