Repository navigation
feat: add MetadataAddress support to ClientCredentialsClient for OIDC discovery - #358
paulomorgado wants to merge 4 commits into
Conversation
f9fe6ef to
b8719d9
Compare
|
Hi @paulomorgado, thanks for this PR. Discovery support for client credentials is something we'd like to have, and we've spent some time as a team looking at how it should fit into the library. We'd like to take it in a slightly different direction, mainly so it matches how discovery already works elsewhere in this repo. The introspection handler ( Concretely, that means:
Your tests are a good base. Most of the scenarios (precedence, missing token endpoint, failed request, endpoint reuse) still apply, and we'd add a couple for concurrent first requests and issuer validation. Would you like to rework the PR along these lines? If you'd rather not, no problem at all. Just let us know and we'll pick it up from here and credit you in our PR. |
|
Hi @bhazen, Let me look into this, but I think I can handle it, |
|
Hi @bhazen, Check out the latest commit. |
bhazen
left a comment
There was a problem hiding this comment.
Thanks @paulomorgado for making the changes based on the direction we discussed
A few things about this round of changes:
-
CI failure.
ConventionTests.All_async_methods_should_end_with_Async_and_have_cancellation_token_as_last_parameterfails onAsyncLazy<T>.Value. Details inline. -
Discovery isn't set up if
Authorityis configured late. Details inline onPostConfigureClientCredentialsClient. -
Test organization. The discovery tests added to
ClientTokenManagementTests.cswould sit better inClientCredentialsDiscoveryTests.cs, where the existing helpers would cut most of their setup. Could you move them over, apart fromMissing_tokenEndpoint_and_authority_throw_exceptionandTokenEndpoint_should_take_precedence_over_authority, which fit where they are? -
One missing test. Nothing covers DPoP with
Authority. A test that the proof'shtuis the discovered token endpoint (including the nonce retry, if it's easy to include) would close that gap. The code already does the right thing, so this is just about locking it in. -
Merge conflict with
main. There's a conflict inServiceCollectionExtensions.cssince main added a validator registration on the same line. Keeping both lines resolves it:services.TryAddEnumerable(ServiceDescriptor.Singleton<IValidateOptions<ClientCredentialsTokenManagementOptions>, ClientCredentialsTokenManagementOptions.Validator>()); services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<ClientCredentialsClient>, PostConfigureClientCredentialsClient>());
There are also a couple of small nits inline. Thanks again for turning this around so quickly.
| private readonly Lock _initializationLock = new(); | ||
| private Task<T>? _task; | ||
|
|
||
| public Task<T> Value |
There was a problem hiding this comment.
This trips ConventionTests.All_async_methods_should_end_with_Async_and_have_cancellation_token_as_last_parameter, since the getter returns a Task without the Async suffix or a CancellationToken.
We'd like to keep the Value shape so it matches the other AsyncLazy copies in this repo (introspection and IdentityModel). Could you add an exemption for AsyncLazy<> in that convention test instead, with a short comment explaining why? The per-caller cancellation you already have via WaitAsync(ct) in ClientCredentialsTokenClient covers the token concern.
There was a problem hiding this comment.
Added an exemption specifically for AsyncLazy<>, with a short comment explaining the repository’s Value pattern and per-caller cancellation through WaitAsync(ct). The property remains unchanged, and the convention checks still apply to other types. The convention test now passes locally.
| { | ||
| public void PostConfigure(string? name, ClientCredentialsClient options) | ||
| { | ||
| if (options.TokenEndpoint is null && !string.IsNullOrWhiteSpace(options.Authority)) |
There was a problem hiding this comment.
This only creates the lazy resolver if Authority is already set when this post-configure runs. Because it's registered inside AddClientCredentialsTokenManagement, it runs before any PostConfigure<ClientCredentialsClient> the app registers afterwards. If an app sets Authority there, validation passes (it runs after all post-configures) but every token request throws "No TokenEndpoint or Authority configured".
Could this create the lazy whenever TokenEndpoint is null, and check Authority inside the factory instead? The client token code would then just need to handle the missing-Authority case from the factory's exception. A test that sets Authority in a later PostConfigure would lock this in.
There was a problem hiding this comment.
The post-configurer now creates the lazy resolver whenever TokenEndpoint is null. The Authority check happens inside the factory, so it sees the value after all post-configures have run.
Added a regression that sets Authority in a later PostConfigure, plus coverage confirming that an explicit TokenEndpoint supplied there still takes precedence. Missing authority is reported by the factory before making any HTTP request.
|
|
||
| if (string.IsNullOrWhiteSpace(disco.TokenEndpoint)) | ||
| { | ||
| throw new InvalidOperationException($"No token endpoint found in discovery document for client {options.ClientId}"); |
There was a problem hiding this comment.
Small one: the other messages in this flow identify the client by its name (for client {clientName}), but this one uses options.ClientId. Could you pass name through from PostConfigure and use that here so they're consistent?
There was a problem hiding this comment.
Passed name from PostConfigure into the discovery factory and used it in the missing-token-endpoint message instead of ClientId. The regression now checks for the configured client name, which differs from the OAuth client ID. The new missing-authority error uses the same name.
| } | ||
|
|
||
| private HttpClient GetHttpClient(ClientCredentialsClient client) | ||
| internal static HttpClient GetHttpClient(ClientCredentialsClient client, IHttpClientFactory httpClientFactory) |
There was a problem hiding this comment.
Nit, optional: reusing this so discovery picks the same HttpClient as the token request makes sense. Since it's now shared, would it read better as an internal method on ClientCredentialsClient (or a small internal helper), rather than PostConfigureClientCredentialsClient calling into ClientCredentialsTokenClient?
There was a problem hiding this comment.
Moved GetHttpClient onto ClientCredentialsClient as an internal instance method. Both discovery and token requests now use it, so the post-configurer no longer calls into ClientCredentialsTokenClient.
Selection precedence is unchanged: an explicitly supplied HttpClient, then the configured named client, then the default backchannel client.
Added MetadataAddress to ClientCredentialsClient for OIDC discovery. Validation now requires TokenEndpoint or MetadataAddress. ClientCredentialsTokenClient resolves token endpoint from metadata if needed, with improved error handling. Tests updated for new scenarios and API changes.
Require an explicit metadata document URI, fetch metadata JSON directly, validate only the discovered token endpoint, enforce HTTPS except for localhost/127.0.0.1 loopback HTTP, reject malformed or cross-authority endpoints, skip JWKS/signing-key retrieval, and expand tests for explicit URLs, ignored fields, malformed JSON, loopback exceptions, and endpoint validation.
Replace MetadataAddress with Authority and expose DiscoveryPolicy. Use GetDiscoveryDocumentAsync for standard discovery and validation. Initialize shared, retryable discovery lazily during post-configuration, retaining the resolved endpoint for the options lifetime without mutating TokenEndpoint. Isolate caller cancellation from shared discovery. Preserve explicit TokenEndpoint precedence and configured backchannels. Map discovery failures to InvalidOperationException consistently with introspection, preserving underlying exceptions.
Create lazy discovery whenever TokenEndpoint is absent and defer Authority validation until discovery runs, allowing later PostConfigure callbacks. Use configured client names in discovery errors and move shared HTTP client selection onto ClientCredentialsClient. Consolidate discovery tests and cover late configuration, explicit endpoint precedence, and DPoP endpoint binding including nonce retry. Exempt AsyncLazy from async conventions while preserving its Value API.
ae20782 to
176ab0d
Compare
paulomorgado
left a comment
There was a problem hiding this comment.
Addressed these points, including the inline comments. I moved the discovery tests into ClientCredentialsDiscoveryTests and reused its setup helpers, leaving Missing_tokenEndpoint_and_authority_throw_exception and TokenEndpoint_should_take_precedence_over_authority in ClientTokenManagementTests.
Added coverage using real DPoP proofs to check that htu is the discovered token endpoint on both the initial request and the nonce retry, including checking the retry’s nonce.
Rebased onto main and resolved the registration conflict by keeping both the management-options validator and the discovery post-configurer. All 125 targeted tests pass locally, including the convention test.
| private readonly Lock _initializationLock = new(); | ||
| private Task<T>? _task; | ||
|
|
||
| public Task<T> Value |
There was a problem hiding this comment.
Added an exemption specifically for AsyncLazy<>, with a short comment explaining the repository’s Value pattern and per-caller cancellation through WaitAsync(ct). The property remains unchanged, and the convention checks still apply to other types. The convention test now passes locally.
| } | ||
|
|
||
| private HttpClient GetHttpClient(ClientCredentialsClient client) | ||
| internal static HttpClient GetHttpClient(ClientCredentialsClient client, IHttpClientFactory httpClientFactory) |
There was a problem hiding this comment.
Moved GetHttpClient onto ClientCredentialsClient as an internal instance method. Both discovery and token requests now use it, so the post-configurer no longer calls into ClientCredentialsTokenClient.
Selection precedence is unchanged: an explicitly supplied HttpClient, then the configured named client, then the default backchannel client.
|
|
||
| if (string.IsNullOrWhiteSpace(disco.TokenEndpoint)) | ||
| { | ||
| throw new InvalidOperationException($"No token endpoint found in discovery document for client {options.ClientId}"); |
There was a problem hiding this comment.
Passed name from PostConfigure into the discovery factory and used it in the missing-token-endpoint message instead of ClientId. The regression now checks for the configured client name, which differs from the OAuth client ID. The new missing-authority error uses the same name.
| { | ||
| public void PostConfigure(string? name, ClientCredentialsClient options) | ||
| { | ||
| if (options.TokenEndpoint is null && !string.IsNullOrWhiteSpace(options.Authority)) |
There was a problem hiding this comment.
The post-configurer now creates the lazy resolver whenever TokenEndpoint is null. The Authority check happens inside the factory, so it sees the value after all post-configures have run.
Added a regression that sets Authority in a later PostConfigure, plus coverage confirming that an explicit TokenEndpoint supplied there still takes precedence. Missing authority is reported by the factory before making any HTTP request.


Summary
This change adds
MetadataAddresssupport to client-credentials token acquisition so callers can rely on discovery metadata instead of always hardcodingTokenEndpoint.It aligns
ClientCredentialsClientwith established ASP.NET authentication configuration patterns while preserving full backward compatibility for existingTokenEndpoint-based setups.Motivation
Today,
ClientCredentialsClientrequiresTokenEndpointup front. That makes configuration less flexible in environments where endpoint details are discovered dynamically from OpenID Connect metadata.This PR enables that dynamic discovery flow by allowing
MetadataAddressas an alternative configuration source.What changed
Public API
Uri? MetadataAddresstoClientCredentialsClient.Validation rules
ClientId, andTokenEndpointorMetadataAddress.Runtime token endpoint resolution
ClientCredentialsTokenClient, ifTokenEndpointis missing andMetadataAddressis provided, the token endpoint is resolved via discovery (GetDiscoveryDocumentAsync).InvalidOperationExceptionis raised.TokenEndpointtakes precedence.Parallel with ASP.NET Core
MetadataAddresssemanticsThis implementation intentionally mirrors the concept used by:
OpenIdConnectOptions.MetadataAddressJwtBearerOptions.MetadataAddressIn both APIs,
MetadataAddressis the discovery endpoint for obtaining metadata.This PR brings the same model to client token management:
Behavioral notes
TokenEndpointcontinue to work unchanged.TokenEndpointis used directly (no discovery round-trip).Test coverage added/updated
TokenEndpointandMetadataAddressthrows.TokenEndpoint+ validMetadataAddressdiscovers and requests token successfully.TokenEndpointprecedence overMetadataAddress.token_endpointthrows expected exception.