Implement Client Secret Expiry and Renewal for Dynamic Client Registration#5377
Conversation
There was a problem hiding this comment.
Large PR Detected
This PR exceeds 1000 lines of changes and requires justification before it can be reviewed.
How to unblock this PR:
Add a section to your PR description with the following format:
## Large PR Justification
[Explain why this PR must be large, such as:]
- Generated code that cannot be split
- Large refactoring that must be atomic
- Multiple related changes that would break if separated
- Migration or data transformationAlternative:
Consider splitting this PR into smaller, focused changes (< 1000 lines each) for easier review and reduced risk.
See our Contributing Guidelines for more details.
This review will be automatically dismissed once you add the justification section.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #5377 +/- ##
==========================================
+ Coverage 71.35% 71.44% +0.09%
==========================================
Files 693 694 +1
Lines 70630 70866 +236
==========================================
+ Hits 50395 50630 +235
+ Misses 16600 16582 -18
- Partials 3635 3654 +19 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Large PR justification has been provided. Thank you!
|
✅ Large PR justification has been provided. The size review has been dismissed and this PR can now proceed with normal review. |
aponcedeleonch
left a comment
There was a problem hiding this comment.
This is solid, well-tested work, and it follows a lot of the repo conventions closely (durable-write-before-memory ordering, draining response bodies, generated mocks, backward-compatible zero-expiry handling). Build and the touched-package tests pass for me.
One blocking thing: pkg/state/runconfig.go adds four exported symbols (ReadRunConfigJSON, LoadRunConfigOfType, LoadRunConfigWithFunc, and the RunConfigReadJSONFunc type) that aren't referenced anywhere in the tree, plus the import-alias churn that only exists to support them. It's unrelated to the DCR secret-renewal feature and trips the one-logical-change scope rule. Could you drop it from this PR, or split it into a separate PR where those functions actually get used?
Two non-blocking notes:
- secret_renewal.go around line 513 hardcodes redirect_uris, grant_types, and response_types in the RFC 7592 PUT body rather than echoing what the client was registered with. Since 7592 treats the update as a full replacement, a provider could end up mutating those if the client was registered differently. Might be worth persisting and replaying the original metadata, or at least documenting the assumption.
- After renewal, the runner swaps Config.RemoteAuthConfig for a fresh struct while the handler keeps its original pointer. It works because the secret-manager key names are reused, but a one-line comment noting that coupling would help the next reader.
I didn't run the full Keycloak flow end to end, so my confidence on runtime behavior rests on the unit tests and your documented manual testing. Once the pkg/state code is out, I'm happy with this.
|
@aponcedeleonch Thanks for the careful review. For the two notes:
|
aponcedeleonch
left a comment
There was a problem hiding this comment.
Follow-up review after the pkg/state/runconfig.go scope fix and the two documentation comments — thanks for addressing those. This pass combines an autonomous code-reviewer agent pass with my own read of the current diff; I verified the main finding empirically with an instrumented test rather than just tracing the code.
Blocking: the double-renewal issue (see inline) is a confirmed regression against the PR description's own "prevented double-renewal" claim — I reproduced it with a temporary test showing 2 RFC 7592 PUT requests for a single restore attempt when renewal fails. The missing test coverage on persistClientCredentials is the other blocker, given it implements the PR's core safety claims (secret-ref reuse, durable-write ordering) with zero direct tests.
Everything else below is a suggestion or nit, not blocking. No secrets/tokens are logged anywhere in the diff, response-body draining is correct, and the durable-write-before-memory-mutation ordering in persistClientCredentials is correct — solid work overall.
Signed-off-by: Sanskarzz <sanskar.gur@gmail.com>
Signed-off-by: Sanskarzz <sanskar.gur@gmail.com>
Signed-off-by: Sanskarzz <sanskar.gur@gmail.com>
Signed-off-by: Sanskarzz <sanskar.gur@gmail.com>
Signed-off-by: Sanskarzz <sanskar.gur@gmail.com>
Signed-off-by: Sanskarzz <sanskar.gur@gmail.com>
Signed-off-by: Sanskarzz <sanskar.gur@gmail.com>
Signed-off-by: Sanskarzz <sanskar.gur@gmail.com>
Signed-off-by: Sanskarzz <sanskar.gur@gmail.com>
Signed-off-by: Sanskarzz <sanskar.gur@gmail.com>
Summary
ToolHive persisted dynamically registered OAuth client credentials, but did not preserve the RFC 7591 and RFC 7592 metadata needed to renew expiring client secrets. Long-running remote-auth workloads could therefore fail once the provider rejected a token refresh that used an expired DCR client secret.
This PR adds the missing DCR client-secret lifecycle:
client_secret_expires_at,registration_access_token,registration_client_uri, token endpoint auth method, and the callback port used during DCR.PUTper restore attempt, including failure paths.RemoteAuthConfigpointer after durable state is saved, keeping the runner and auth handler synchronized.Fixes #3631
Type of change
Test plan
task test)task test-e2e)task lint-fix)Manual Keycloak validation covered the full renewal flow locally:
localhostfor browser-based OAuth.mcp-server-audiencedefault client scope to the DCR client.cached_reg_token_ref,cached_reg_client_uri,cached_token_auth_method, andcached_dcr_callback_port.cached_secret_expiryinto the renewal window and verified startup performed exactly one RFC 7592 renewal before restoring cached OAuth tokens.client_secret_expires_at: 0response clearscached_secret_expiryback to the zero time value.API Compatibility
v1beta1API, OR theapi-break-allowedlabel is applied and the migration guidance is described above.Changes
Does this introduce a user-facing change?
Yes. Remote-auth workloads that use DCR can now continue operating when an OAuth provider issues expiring client secrets, provided the provider also returns RFC 7592 registration management metadata.
Providers that return
client_secret_expires_at: 0or omit RFC 7592 metadata remain backward compatible: ToolHive treats the secret as non-expiring or logs a warning and falls back to the existing secret until re-authentication is required.Special notes for reviewers
registration_client_uriis stored in config because it is endpoint metadata, not a secret.PUTreplays ToolHive's fixed redirect URI, grant type, and response type metadata. Persisting and replaying arbitrary provider-specific registration metadata is a follow-up concern.Manual Keycloak test procedure
Manual Testing: DCR Client Secret Expiry and RFC 7592 Renewal
What This Validates
cached_secret_expirycached_reg_token_refcached_reg_client_uricached_token_auth_methodcached_dcr_callback_portPUT.Reference: Keycloak documents its client registration endpoint as
/realms/<realm>/clients-registrations/<provider>and the OpenID Connect DCR provider as/realms/<realm>/clients-registrations/openid-connect[/<client id>]: https://www.keycloak.org/securing-apps/client-registrationPrerequisites
dockerorpodmankindkubectlhelmkotaskjqUse separate terminals for long-running port-forward and
thv runcommands.1. Create a Kind Cluster and Deploy ToolHive Operator
Wait for the operator:
Expected result:
Running.2. Deploy Keycloak
Local port
8080must be free while this task performs its built-in realm setup. The later8081port-forward does not replace this requirement becausedeploy/keycloak/setup-realm.shcurrently useshttp://localhost:8080.For this local browser-based OAuth flow, make Keycloak advertise
localhostinstead of the in-clusterkeycloakhostname. Without this, Keycloak redirects the browser tokeycloak:<port>and can lose its restart-login cookie.If the hostname patch restarts Keycloak, the development H2 realm data can be reset. Re-run the realm setup once after the patched Keycloak is ready. The setup script currently expects Keycloak on local port
8080, so start a temporary8080port-forward in another terminal while this script runs:Then run:
Start a persistent Keycloak port-forward in a separate terminal and keep it running.
The example uses local port
8081because8080is often already occupied:In another terminal, fetch an admin token. Keycloak admin tokens are short-lived, so re-run this block whenever a later admin API command returns
401or an empty response:Expected result:
ADMIN_TOKENis non-empty.localhostconsistently for Keycloak admin, DCR, and ToolHive OAuth calls. Mixinglocalhostandkeycloakin browser flows can lose Keycloak's restart-login cookie.3. Deploy an Authenticated Remote MCP Server
Start the MCP server port-forward in a separate terminal:
Expected result:
http://localhost:9090.4. Create a Confidential DCR Client in Keycloak
ToolHive's normal CLI DCR path registers a public PKCE client. To exercise client-secret renewal with Keycloak, seed ToolHive with a confidential DCR client created through Keycloak's OpenID Connect client registration endpoint.
Create a Keycloak client-registration initial access token:
Use that initial access token for DCR:
Expected result:
client_id,client_secret,registration_access_token, andregistration_client_uri.registration_client_uriuseshttp://localhost:8081/.... If it still useshttp://keycloak:8081/..., Keycloak is still advertising the old hostname; recheck the hostname patch above before continuing.If this returns
403, rerun without-fto see Keycloak's error body:5. Give the DCR Client the MCP Server Audience
The sample
mcpserver-with-auth.yamlrequires themcp-serveraudience. Assign the existingmcp-server-audienceclient scope to the DCR client.Expected result:
401 Unauthorizedfrom this admin endpoint meansADMIN_TOKENexpired; rerun this step's first command to refresh it.6. Configure ToolHive Secrets
Use the encrypted provider for writable local secrets.
Expected result:
7. Create an Initial OAuth Session
Run a local ToolHive proxy against the authenticated remote MCP server.
Remove any stale workload with the same name before starting a new run:
Complete the browser login:
toolhive-useruser123After authentication succeeds and the workload starts, stop it without deleting the saved run config:
Expected result:
8. Seed Cached DCR Renewal Metadata
Force the cached secret into the proactive renewal window by setting the expiry to one hour from now.
Linux:
EXPIRY="$(date -u -d '+1 hour' +%Y-%m-%dT%H:%M:%SZ)"macOS:
EXPIRY="$(date -u -v+1H +%Y-%m-%dT%H:%M:%SZ)"Patch the saved run config:
Expected result:
cached_secret_expiryis about one hour in the future.9. Trigger Automatic Renewal
Save the old registration token:
Start the saved workload again so ToolHive reads the patched run config:
Wait up to one minute for the detached proxy to finish renewal:
Expected debug logs:
Cached client secret is expiring or expired; attempting renewal before token restoreAttempting RFC 7592 client secret renewalSuccessfully renewed client secret via RFC 7592Inspect only the proxy-log lines written by this startup. This avoids counting renewal attempts from earlier runs:
After startup succeeds, stop it before patching the config again:
Validate persistence:
Expected result:
OAUTH_CLIENT_SECRET_*orOAUTH_REG_TOKEN_*reference in the run config.NEW_REG_TOKENdiffers fromOLD_REG_TOKEN.cached_secret_expiryis no longer within the forced one-hour renewal window, or is cleared if Keycloak omitsclient_secret_expires_at.10. Check for Double Renewal
Count renewal attempts in the isolated step 9 output:
Expected result:
Attempting RFC 7592 client secret renewallog appears for a single startup.11. Validate Soft Failure While Expiring Soon
Patch the registration URI to a failing non-root path and keep the expiry in the future:
Start the workload again:
Expected result:
Client secret renewal failed.Stop the workload before restoring the config:
Restore the valid registration URI:
12. Validate Hard Failure After Expiry
Patch the expiry into the past and use the failing URI again:
Start the workload again:
Expected result:
client secret expired atandrenewal failed.Restore the valid registration URI if you want to keep testing:
13. Cleanup
Stop all port-forwards, then remove the test cluster:
Optional local cleanup:
Notes and Limitations
client_secretorclient_secret_expires_atin that path.client_secret_expires_atduring ToolHive-managed DCR, the manual state seeding in step 8 is not needed. Wait until the cached expiry is within 24 hours, then perform the restart in step 9.Large PR Justification
This is a new feature package with a large test suite, and it needs to land as one coherent phase.