Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/server/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion docs/server/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion docs/server/swagger.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 34 additions & 1 deletion pkg/auth/discovery/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,12 @@ type OAuthFlowConfig struct {
SkipBrowser bool
Resource string // RFC 8707 resource indicator (optional)
OAuthParams map[string]string

// DCR renewal metadata — populated by handleDynamicRegistration and threaded
// into OAuthFlowResult so callers can persist the data for RFC 7592 operations.
SecretExpiry time.Time // zero means the secret never expires
RegistrationAccessToken string //nolint:gosec // G117: field legitimately holds sensitive data
RegistrationClientURI string
}

// OAuthFlowResult contains the result of an OAuth flow
Expand All @@ -524,6 +530,14 @@ type OAuthFlowResult struct {
// DCR client credentials for persistence (obtained during Dynamic Client Registration)
ClientID string
ClientSecret string //nolint:gosec // G117: field legitimately holds sensitive data

// DCR renewal metadata (RFC 7591 §3.2.1 / RFC 7592).
// SecretExpiry is zero when the provider did not issue an expiring secret.
// RegistrationAccessToken and RegistrationClientURI are empty when the
// provider does not support RFC 7592 management operations.
SecretExpiry time.Time
RegistrationAccessToken string //nolint:gosec // G117: field legitimately holds sensitive data
RegistrationClientURI string
}

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

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

// Store DCR renewal metadata for RFC 7592 operations.
// client_secret_expires_at == 0 means the secret never expires (RFC 7591 §3.2.1).
if registrationResponse.ClientSecretExpiresAt > 0 {
config.SecretExpiry = time.Unix(registrationResponse.ClientSecretExpiresAt, 0)
}
config.RegistrationAccessToken = registrationResponse.RegistrationAccessToken
config.RegistrationClientURI = registrationResponse.RegistrationClientURI

if registrationResponse.RegistrationAccessToken != "" {
slog.Debug("DCR response includes registration access token for RFC 7592 operations")
}

return nil
}

Expand Down Expand Up @@ -707,6 +736,10 @@ func newOAuthFlow(ctx context.Context, oauthConfig *oauth.Config, config *OAuthF
Expiry: tokenResult.Expiry,
ClientID: oauthConfig.ClientID,
ClientSecret: oauthConfig.ClientSecret,
// DCR renewal metadata — populated only when dynamic registration was performed.
SecretExpiry: config.SecretExpiry,
RegistrationAccessToken: config.RegistrationAccessToken,
RegistrationClientURI: config.RegistrationClientURI,
}, nil
}

Expand Down
8 changes: 7 additions & 1 deletion pkg/auth/remote/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,14 @@ type Config struct {
// ClientSecretExpiresAt indicates when the client secret expires (if provided by the DCR server).
// A zero value means the secret does not expire.
CachedSecretExpiry time.Time `json:"cached_secret_expiry,omitempty" yaml:"cached_secret_expiry,omitempty"`
// RegistrationAccessToken is used to update/delete the client registration.
// CachedRegTokenRef is a secret manager reference to the registration_access_token
// returned in the DCR response. Used for RFC 7592 client update operations.
// Stored as a secret reference since it's sensitive.
CachedRegTokenRef string `json:"cached_reg_token_ref,omitempty" yaml:"cached_reg_token_ref,omitempty"`
// CachedRegClientURI is the registration_client_uri from the DCR response.
// This is the endpoint used for RFC 7592 client read/update/delete operations.
// Stored as plain text since it is not sensitive.
CachedRegClientURI string `json:"cached_reg_client_uri,omitempty" yaml:"cached_reg_client_uri,omitempty"`
}

// BearerTokenEnvVarName is the environment variable name used for bearer token authentication.
Expand Down Expand Up @@ -165,6 +170,7 @@ func (c *Config) ClearCachedClientCredentials() {
c.CachedClientSecretRef = ""
c.CachedSecretExpiry = time.Time{}
c.CachedRegTokenRef = ""
c.CachedRegClientURI = ""
}

// DefaultResourceIndicator derives the resource indicator (RFC 8707) from the remote server URL.
Expand Down
44 changes: 43 additions & 1 deletion pkg/auth/remote/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"context"
"fmt"
"log/slog"
"time"

"golang.org/x/oauth2"

Expand Down Expand Up @@ -187,7 +188,13 @@ func (h *Handler) wrapWithPersistence(result *discovery.OAuthFlowResult) oauth2.
// Persist DCR client credentials if available (for servers that use Dynamic Client Registration)
// Only persist if client_id exists - client_secret may be empty for PKCE flows
if h.clientCredentialsPersister != nil && result.ClientID != "" {
if err := h.clientCredentialsPersister(result.ClientID, result.ClientSecret); err != nil {
if err := h.clientCredentialsPersister(
result.ClientID,
result.ClientSecret,
result.SecretExpiry,
result.RegistrationAccessToken,
result.RegistrationClientURI,
); err != nil {
slog.Warn("Failed to persist DCR client credentials", "error", err)
} else {
slog.Debug("Successfully persisted DCR client credentials for future restarts")
Expand All @@ -205,6 +212,8 @@ func (h *Handler) wrapWithPersistence(result *discovery.OAuthFlowResult) oauth2.

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

// Proactively renew the client secret if it is expiring soon (RFC 7592)
if h.isSecretExpiredOrExpiringSoon() {
slog.Info("Cached client secret is expiring soon, attempting renewal",
"expiry", h.config.CachedSecretExpiry)
if renewErr := h.renewClientSecret(ctx); renewErr != nil {
slog.Warn("Failed to proactively renew client secret; continuing with existing secret",
"error", renewErr)
} else {
slog.Debug("Successfully renewed client secret ahead of expiry")
}
}

// Client secret is stored securely and may be empty for PKCE flows
if h.config.CachedClientSecretRef != "" && h.secretProvider != nil {
cachedClientSecret, err := h.secretProvider.GetSecret(ctx, h.config.CachedClientSecretRef)
Expand All @@ -242,6 +263,27 @@ func (h *Handler) tryRestoreFromCachedTokens(
return nil, fmt.Errorf("secret provider not configured, cannot restore cached tokens")
}

// Check if the cached client secret is expired before attempting token refresh.
// If it has fully expired and renewal also fails we must force a fresh OAuth flow.
if h.isSecretExpiredOrExpiringSoon() {
slog.Info("Cached client secret is expiring or expired; attempting renewal before token restore",
"expiry", h.config.CachedSecretExpiry)
if renewErr := h.renewClientSecret(ctx); renewErr != nil {
slog.Warn("Client secret renewal failed", "error", renewErr)
// Hard-fail only when the secret is already past its expiry.
// If we are still in the buffer window the existing secret may work.
if !h.config.CachedSecretExpiry.IsZero() && time.Now().After(h.config.CachedSecretExpiry) {
return nil, fmt.Errorf(
"client secret expired at %v and renewal failed: %w",
h.config.CachedSecretExpiry, renewErr)
}
// Still within buffer — log and continue with the existing (still-valid) secret
slog.Warn("Proceeding with expiring client secret after failed renewal attempt")
} else {
slog.Debug("Successfully renewed client secret before token restore")
}
}

refreshToken, err := h.secretProvider.GetSecret(ctx, h.config.CachedRefreshTokenRef)
if err != nil {
return nil, fmt.Errorf("failed to retrieve cached refresh token: %w", err)
Expand Down
18 changes: 16 additions & 2 deletions pkg/auth/remote/persisting_token_source.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,22 @@ import (
type TokenPersister func(refreshToken string, expiry time.Time) error

// ClientCredentialsPersister is called when DCR client credentials need to be persisted.
// This is used to store client_id and client_secret obtained during Dynamic Client Registration.
type ClientCredentialsPersister func(clientID, clientSecret string) error
// This is used to store client_id, client_secret, and renewal metadata obtained during
// Dynamic Client Registration (RFC 7591) and needed for secret renewal (RFC 7592).
//
// Parameters:
// - clientID: the registered client ID (public, stored as plain text)
// - clientSecret: the registered client secret (sensitive, stored via secret manager)
// - secretExpiry: when the client secret expires; zero value means it never expires
// - registrationAccessToken: bearer token for RFC 7592 management operations (sensitive)
// - registrationClientURI: endpoint for RFC 7592 client update/read operations (plain text)
type ClientCredentialsPersister func(
clientID string,
clientSecret string,
secretExpiry time.Time,
registrationAccessToken string,
registrationClientURI string,
) error

// PersistingTokenSource wraps an oauth2.TokenSource and persists tokens
// whenever they are refreshed. This enables session restoration across
Expand Down
Loading
Loading