Skip to content

Commit fcd6b58

Browse files
committed
feat: Add client secret expiry tracking and automatic renew for DCR
Signed-off-by: Sanskarzz <sanskar.gur@gmail.com>
1 parent 0de510d commit fcd6b58

7 files changed

Lines changed: 741 additions & 7 deletions

File tree

pkg/auth/discovery/discovery.go

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,12 @@ type OAuthFlowConfig struct {
509509
SkipBrowser bool
510510
Resource string // RFC 8707 resource indicator (optional)
511511
OAuthParams map[string]string
512+
513+
// DCR renewal metadata — populated by handleDynamicRegistration and threaded
514+
// into OAuthFlowResult so callers can persist the data for RFC 7592 operations.
515+
SecretExpiry time.Time // zero means the secret never expires
516+
RegistrationAccessToken string //nolint:gosec // G117: field legitimately holds sensitive data
517+
RegistrationClientURI string
512518
}
513519

514520
// OAuthFlowResult contains the result of an OAuth flow
@@ -524,6 +530,14 @@ type OAuthFlowResult struct {
524530
// DCR client credentials for persistence (obtained during Dynamic Client Registration)
525531
ClientID string
526532
ClientSecret string //nolint:gosec // G117: field legitimately holds sensitive data
533+
534+
// DCR renewal metadata (RFC 7591 §3.2.1 / RFC 7592).
535+
// SecretExpiry is zero when the provider did not issue an expiring secret.
536+
// RegistrationAccessToken and RegistrationClientURI are empty when the
537+
// provider does not support RFC 7592 management operations.
538+
SecretExpiry time.Time
539+
RegistrationAccessToken string //nolint:gosec // G117: field legitimately holds sensitive data
540+
RegistrationClientURI string
527541
}
528542

529543
func shouldDynamicallyRegisterClient(config *OAuthFlowConfig) bool {
@@ -581,7 +595,10 @@ func PerformOAuthFlow(ctx context.Context, issuer string, config *OAuthFlowConfi
581595
return newOAuthFlow(ctx, oauthConfig, config)
582596
}
583597

584-
// handleDynamicRegistration handles the dynamic client registration process
598+
// handleDynamicRegistration handles the dynamic client registration process.
599+
// It populates config with the client credentials AND the DCR renewal metadata
600+
// (SecretExpiry, RegistrationAccessToken, RegistrationClientURI) so that
601+
// callers can persist the full RFC 7592 context for later secret renewal.
585602
func handleDynamicRegistration(ctx context.Context, issuer string, config *OAuthFlowConfig) error {
586603
discoveredDoc, err := getDiscoveryDocument(ctx, issuer, config)
587604
if err != nil {
@@ -602,6 +619,18 @@ func handleDynamicRegistration(ctx context.Context, issuer string, config *OAuth
602619
config.TokenURL = discoveredDoc.TokenEndpoint
603620
}
604621

622+
// Store DCR renewal metadata for RFC 7592 operations.
623+
// client_secret_expires_at == 0 means the secret never expires (RFC 7591 §3.2.1).
624+
if registrationResponse.ClientSecretExpiresAt > 0 {
625+
config.SecretExpiry = time.Unix(registrationResponse.ClientSecretExpiresAt, 0)
626+
}
627+
config.RegistrationAccessToken = registrationResponse.RegistrationAccessToken
628+
config.RegistrationClientURI = registrationResponse.RegistrationClientURI
629+
630+
if registrationResponse.RegistrationAccessToken != "" {
631+
slog.Debug("DCR response includes registration access token for RFC 7592 operations")
632+
}
633+
605634
return nil
606635
}
607636

@@ -707,6 +736,10 @@ func newOAuthFlow(ctx context.Context, oauthConfig *oauth.Config, config *OAuthF
707736
Expiry: tokenResult.Expiry,
708737
ClientID: oauthConfig.ClientID,
709738
ClientSecret: oauthConfig.ClientSecret,
739+
// DCR renewal metadata — populated only when dynamic registration was performed.
740+
SecretExpiry: config.SecretExpiry,
741+
RegistrationAccessToken: config.RegistrationAccessToken,
742+
RegistrationClientURI: config.RegistrationClientURI,
710743
}, nil
711744
}
712745

pkg/auth/remote/config.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,14 @@ type Config struct {
6363
// ClientSecretExpiresAt indicates when the client secret expires (if provided by the DCR server).
6464
// A zero value means the secret does not expire.
6565
CachedSecretExpiry time.Time `json:"cached_secret_expiry,omitempty" yaml:"cached_secret_expiry,omitempty"`
66-
// RegistrationAccessToken is used to update/delete the client registration.
66+
// CachedRegTokenRef is a secret manager reference to the registration_access_token
67+
// returned in the DCR response. Used for RFC 7592 client update operations.
6768
// Stored as a secret reference since it's sensitive.
6869
CachedRegTokenRef string `json:"cached_reg_token_ref,omitempty" yaml:"cached_reg_token_ref,omitempty"`
70+
// CachedRegClientURI is the registration_client_uri from the DCR response.
71+
// This is the endpoint used for RFC 7592 client read/update/delete operations.
72+
// Stored as plain text since it is not sensitive.
73+
CachedRegClientURI string `json:"cached_reg_client_uri,omitempty" yaml:"cached_reg_client_uri,omitempty"`
6974
}
7075

7176
// BearerTokenEnvVarName is the environment variable name used for bearer token authentication.
@@ -165,6 +170,7 @@ func (c *Config) ClearCachedClientCredentials() {
165170
c.CachedClientSecretRef = ""
166171
c.CachedSecretExpiry = time.Time{}
167172
c.CachedRegTokenRef = ""
173+
c.CachedRegClientURI = ""
168174
}
169175

170176
// DefaultResourceIndicator derives the resource indicator (RFC 8707) from the remote server URL.

pkg/auth/remote/handler.go

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"context"
88
"fmt"
99
"log/slog"
10+
"time"
1011

1112
"golang.org/x/oauth2"
1213

@@ -187,7 +188,13 @@ func (h *Handler) wrapWithPersistence(result *discovery.OAuthFlowResult) oauth2.
187188
// Persist DCR client credentials if available (for servers that use Dynamic Client Registration)
188189
// Only persist if client_id exists - client_secret may be empty for PKCE flows
189190
if h.clientCredentialsPersister != nil && result.ClientID != "" {
190-
if err := h.clientCredentialsPersister(result.ClientID, result.ClientSecret); err != nil {
191+
if err := h.clientCredentialsPersister(
192+
result.ClientID,
193+
result.ClientSecret,
194+
result.SecretExpiry,
195+
result.RegistrationAccessToken,
196+
result.RegistrationClientURI,
197+
); err != nil {
191198
slog.Warn("Failed to persist DCR client credentials", "error", err)
192199
} else {
193200
slog.Debug("Successfully persisted DCR client credentials for future restarts")
@@ -205,6 +212,8 @@ func (h *Handler) wrapWithPersistence(result *discovery.OAuthFlowResult) oauth2.
205212

206213
// resolveClientCredentials returns the client ID and secret to use, preferring
207214
// cached DCR credentials over statically configured ones.
215+
// If the cached client secret is expiring soon, it attempts renewal via RFC 7592
216+
// before returning the credentials.
208217
func (h *Handler) resolveClientCredentials(ctx context.Context) (clientID, clientSecret string) {
209218
// First try to use statically configured credentials
210219
clientID = h.config.ClientID
@@ -216,6 +225,18 @@ func (h *Handler) resolveClientCredentials(ctx context.Context) (clientID, clien
216225
clientID = h.config.CachedClientID
217226
slog.Debug("Using cached DCR client credentials", "client_id", clientID)
218227

228+
// Proactively renew the client secret if it is expiring soon (RFC 7592)
229+
if h.isSecretExpiredOrExpiringSoon() {
230+
slog.Info("Cached client secret is expiring soon, attempting renewal",
231+
"expiry", h.config.CachedSecretExpiry)
232+
if renewErr := h.renewClientSecret(ctx); renewErr != nil {
233+
slog.Warn("Failed to proactively renew client secret; continuing with existing secret",
234+
"error", renewErr)
235+
} else {
236+
slog.Debug("Successfully renewed client secret ahead of expiry")
237+
}
238+
}
239+
219240
// Client secret is stored securely and may be empty for PKCE flows
220241
if h.config.CachedClientSecretRef != "" && h.secretProvider != nil {
221242
cachedClientSecret, err := h.secretProvider.GetSecret(ctx, h.config.CachedClientSecretRef)
@@ -242,6 +263,27 @@ func (h *Handler) tryRestoreFromCachedTokens(
242263
return nil, fmt.Errorf("secret provider not configured, cannot restore cached tokens")
243264
}
244265

266+
// Check if the cached client secret is expired before attempting token refresh.
267+
// If it has fully expired and renewal also fails we must force a fresh OAuth flow.
268+
if h.isSecretExpiredOrExpiringSoon() {
269+
slog.Info("Cached client secret is expiring or expired; attempting renewal before token restore",
270+
"expiry", h.config.CachedSecretExpiry)
271+
if renewErr := h.renewClientSecret(ctx); renewErr != nil {
272+
slog.Warn("Client secret renewal failed", "error", renewErr)
273+
// Hard-fail only when the secret is already past its expiry.
274+
// If we are still in the buffer window the existing secret may work.
275+
if !h.config.CachedSecretExpiry.IsZero() && time.Now().After(h.config.CachedSecretExpiry) {
276+
return nil, fmt.Errorf(
277+
"client secret expired at %v and renewal failed: %w",
278+
h.config.CachedSecretExpiry, renewErr)
279+
}
280+
// Still within buffer — log and continue with the existing (still-valid) secret
281+
slog.Warn("Proceeding with expiring client secret after failed renewal attempt")
282+
} else {
283+
slog.Debug("Successfully renewed client secret before token restore")
284+
}
285+
}
286+
245287
refreshToken, err := h.secretProvider.GetSecret(ctx, h.config.CachedRefreshTokenRef)
246288
if err != nil {
247289
return nil, fmt.Errorf("failed to retrieve cached refresh token: %w", err)

pkg/auth/remote/persisting_token_source.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,22 @@ import (
1818
type TokenPersister func(refreshToken string, expiry time.Time) error
1919

2020
// ClientCredentialsPersister is called when DCR client credentials need to be persisted.
21-
// This is used to store client_id and client_secret obtained during Dynamic Client Registration.
22-
type ClientCredentialsPersister func(clientID, clientSecret string) error
21+
// This is used to store client_id, client_secret, and renewal metadata obtained during
22+
// Dynamic Client Registration (RFC 7591) and needed for secret renewal (RFC 7592).
23+
//
24+
// Parameters:
25+
// - clientID: the registered client ID (public, stored as plain text)
26+
// - clientSecret: the registered client secret (sensitive, stored via secret manager)
27+
// - secretExpiry: when the client secret expires; zero value means it never expires
28+
// - registrationAccessToken: bearer token for RFC 7592 management operations (sensitive)
29+
// - registrationClientURI: endpoint for RFC 7592 client update/read operations (plain text)
30+
type ClientCredentialsPersister func(
31+
clientID string,
32+
clientSecret string,
33+
secretExpiry time.Time,
34+
registrationAccessToken string,
35+
registrationClientURI string,
36+
) error
2337

2438
// PersistingTokenSource wraps an oauth2.TokenSource and persists tokens
2539
// whenever they are refreshed. This enables session restoration across

0 commit comments

Comments
 (0)