Skip to content

feat: Add client secret expiry tracking and automatic renew for DCR#4194

Draft
Sanskarzz wants to merge 2 commits intostacklok:mainfrom
Sanskarzz:autosecretDCR
Draft

feat: Add client secret expiry tracking and automatic renew for DCR#4194
Sanskarzz wants to merge 2 commits intostacklok:mainfrom
Sanskarzz:autosecretDCR

Conversation

@Sanskarzz
Copy link
Contributor

[WIP] Pending Local E2E testing with Keycloak

Summary

Implements client secret expiry tracking and automatic renewal for Dynamic Client Registration (DCR)

Fixes: #3631

ToolHive already stored the client_id and client_secret from DCR responses, but discarded the registration_access_token, registration_client_uri, and client_secret_expires_at fields. Without these, there was no way to detect or renew an expired client secret, causing silent authentication failures for long-running workloads on providers like Keycloak that issue expiring secrets.

This PR implements the full RFC 7591 / RFC 7592 secret lifecycle in three phases:

  1. Store all DCR response metadata through the persistence pipeline
  2. Detect expiry proactively (24 h buffer) and on session restore
  3. Renew the secret via RFC 7592 §2.2 before it becomes a problem

Note
All the phases are added in the same PR.


Changes

pkg/auth/remote/persisting_token_source.go

Extended ClientCredentialsPersister function type signature:

// Before
type ClientCredentialsPersister func(clientID, clientSecret string) error

// After
type ClientCredentialsPersister func(
    clientID string,
    clientSecret string,
    secretExpiry time.Time,           // zero = never expires (RFC 7591 §3.2.1)
    registrationAccessToken string,   // for RFC 7592 management operations
    registrationClientURI string,     // endpoint for RFC 7592 updates
) error

pkg/auth/remote/config.go

  • Added CachedRegClientURI field to store registration_client_uri as plain text
  • Updated ClearCachedClientCredentials() to also clear the new field

pkg/auth/discovery/discovery.go

  • Added DCR renewal metadata fields to OAuthFlowConfig (used as the threading vehicle from handleDynamicRegistration through to the result)
  • Extended OAuthFlowResult with SecretExpiry, RegistrationAccessToken, RegistrationClientURI
  • Updated handleDynamicRegistration() to capture all three from DynamicClientRegistrationResponse:
    • ClientSecretExpiresAt > 0time.Unix(...) (zero if the field is 0, meaning never expires)
    • RegistrationAccessToken and RegistrationClientURI copied as-is
  • Updated newOAuthFlow() to populate the new fields in OAuthFlowResult

pkg/auth/remote/handler.go

  • Updated wrapWithPersistence() to pass all 5 arguments to clientCredentialsPersister
  • Added "time" import
  • resolveClientCredentials() now proactively calls renewClientSecret() when the secret is expiring within 24 h; renewal failures are soft-logged and execution continues
  • tryRestoreFromCachedTokens() now checks expiry before attempting token refresh:
    • Within 24 h buffer + renewal fails → warning, continue with existing secret
    • Past expiry + renewal fails → hard error, forces a fresh OAuth flow

pkg/runner/runner.go

Updated SetClientCredentialsPersister callback to match the new 5-argument signature and persist:

  • CachedSecretExpiry — stored directly in config
  • registrationAccessToken — stored securely in the secret manager, reference saved to CachedRegTokenRef
  • registrationClientURI — stored as plain text in CachedRegClientURI

pkg/auth/remote/secret_renewal.go (new file)

Implements RFC 7592 §2.2 client secret renewal:

  • isSecretExpiredOrExpiringSoon() — returns true when the secret is within secretExpiryBuffer (24 h) of expiry or already past it; false for zero expiry (never expires)
  • renewClientSecret(ctx) — sends an authenticated HTTP PUT to registration_client_uri per RFC 7592 §2.2:
    • Retrieves registrationAccessToken from the secret manager
    • Validates registration_client_uri (must be HTTPS or localhost)
    • Sends current client metadata as the request body (required by RFC 7592)
    • Parses the response (RFC 7592 §2.1), extracts new client_secret, client_secret_expires_at, and optionally a rotated registration_access_token
    • Persists everything via clientCredentialsPersister
  • validateRegistrationClientURI(uri) — validates the URI is HTTPS (or localhost for development)

pkg/auth/remote/secret_renewal_test.go (new file)

16 new tests covering:

Test Coverage
TestIsSecretExpiredOrExpiringSoon (5 cases) Zero, far-future, within-buffer, past-expiry, at-boundary
TestValidateRegistrationClientURI (6 cases) Empty, HTTPS, HTTP non-localhost rejected, localhost/127.0.0.1 allowed, invalid URL
TestRenewClientSecret_MissingConfig (3 cases) No URI, no token ref, no secret provider
TestRenewClientSecret_Success Happy path with mock RFC 7592 server; token rotation
TestRenewClientSecret_ServerError HTTP 401 from server
TestRenewClientSecret_NoPersister Server OK but no persister configured
TestRenewClientSecret_ZeroExpiryInResponse client_secret_expires_at: 0time.Time{} (never expires)

Breaking Changes

Warning

ClientCredentialsPersister function type signature changed.

Any code outside this repository that implements or calls ClientCredentialsPersister must be updated to use the new 5-parameter signature. Within this repository, the only call site is pkg/runner/runner.go, which is updated in this PR.


Backward Compatibility

Scenario Behaviour
Provider does not issue expiring secrets (client_secret_expires_at = 0) CachedSecretExpiry is time.Time{}. isSecretExpiredOrExpiringSoon() returns false. No renewal is attempted.
Provider does not support RFC 7592 (no registration_access_token / URI) renewClientSecret() returns an immediate error. Caller logs a warning and continues with the existing secret.
Provider does issue expiring secrets and supports RFC 7592 Secret renewed automatically within 24 h of expiry.
Renewal fails but secret is still within its validity window Soft warning logged, existing secret used.
Renewal fails and secret is past its expiry Hard error returned; caller performs a fresh OAuth flow.

RFC References

  • RFC 7591 3.2.1client_secret_expires_at, registration_access_token, registration_client_uri in registration response
  • RFC 7592 2.2 — Client Update Request (PUT with Bearer token)
  • RFC 7592 2.1 — Client Information Response (response format for read/update)

Test Results

ok  github.com/stacklok/toolhive/pkg/auth               PASS
ok  github.com/stacklok/toolhive/pkg/auth/awssts        PASS
ok  github.com/stacklok/toolhive/pkg/auth/discovery     PASS
ok  github.com/stacklok/toolhive/pkg/auth/oauth         PASS
ok  github.com/stacklok/toolhive/pkg/auth/remote        PASS  (16 new tests)
ok  github.com/stacklok/toolhive/pkg/auth/secrets       PASS
ok  github.com/stacklok/toolhive/pkg/auth/tokenexchange PASS
ok  github.com/stacklok/toolhive/pkg/auth/upstreamswap  PASS
ok  github.com/stacklok/toolhive/pkg/auth/upstreamtoken PASS

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Test plan

  • Unit tests (task test)
  • E2E tests (task test-e2e)
  • Linting (task lint-fix)
  • Manual testing (describe below) Pending

Signed-off-by: Sanskarzz <sanskar.gur@gmail.com>
@github-actions github-actions bot added the size/L Large PR: 600-999 lines changed label Mar 17, 2026
@codecov
Copy link

codecov bot commented Mar 17, 2026

Codecov Report

❌ Patch coverage is 47.46835% with 83 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.82%. Comparing base (837906c) to head (294c25f).

Files with missing lines Patch % Lines
pkg/auth/remote/handler.go 0.00% 27 Missing ⚠️
pkg/runner/runner.go 0.00% 25 Missing ⚠️
pkg/auth/remote/secret_renewal.go 78.72% 10 Missing and 10 partials ⚠️
pkg/auth/discovery/discovery.go 0.00% 11 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4194      +/-   ##
==========================================
- Coverage   69.04%   68.82%   -0.23%     
==========================================
  Files         468      469       +1     
  Lines       47003    47185     +182     
==========================================
+ Hits        32453    32473      +20     
- Misses      11974    12072      +98     
- Partials     2576     2640      +64     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions github-actions bot added size/XL Extra large PR: 1000+ lines changed and removed size/L Large PR: 600-999 lines changed labels Mar 17, 2026
Copy link
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 transformation

Alternative:

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.

@github-actions github-actions bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Mar 18, 2026
Signed-off-by: Sanskarzz <sanskar.gur@gmail.com>
@github-actions github-actions bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Mar 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL Extra large PR: 1000+ lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement Client Secret Expiry and Renewal for Dynamic Client Registration

1 participant