From 7c252d6d55ca90f4104c55613597eb4004500d40 Mon Sep 17 00:00:00 2001 From: Helio Machado <0x2b3bfa0+git@googlemail.com> Date: Mon, 11 May 2026 03:58:41 +0400 Subject: [PATCH 01/10] feat: per-connector overrides for idTokens and refreshTokens expiry Adds an optional `expiry` block to each static connector in the YAML config. Fields left unset inherit the top-level `expiry` configuration. Supported overrides: - `idTokens` - `refreshTokens.absoluteLifetime` - `refreshTokens.validIfNotUsedFor` - `refreshTokens.reuseInterval` - `refreshTokens.disableRotation` Per-connector `idTokens` and `refreshTokens.absoluteLifetime` are rejected at config load if they exceed the corresponding global value, since signing key retention is derived from the global maximums. `authRequests` and `deviceRequests` are intentionally left global: `deviceRequests` is issued before the user picks a connector, and `authRequests` have no meaningful connector-scoped semantics. Addresses the per-connector variant of the need raised in #3557. Signed-off-by: Helio Machado <0x2b3bfa0+git@googlemail.com> --- cmd/dex/config.go | 32 ++++- cmd/dex/connector_expiry_test.go | 206 +++++++++++++++++++++++++++++++ cmd/dex/serve.go | 107 ++++++++++++++++ config.yaml.dist | 19 +++ server/connector_expiry_test.go | 129 +++++++++++++++++++ server/introspectionhandler.go | 2 +- server/oauth2.go | 20 ++- server/refreshhandlers.go | 14 ++- server/server.go | 24 +++- 9 files changed, 540 insertions(+), 13 deletions(-) create mode 100644 cmd/dex/connector_expiry_test.go create mode 100644 server/connector_expiry_test.go diff --git a/cmd/dex/config.go b/cmd/dex/config.go index 12d3557cbc..45fd9076ff 100644 --- a/cmd/dex/config.go +++ b/cmd/dex/config.go @@ -559,6 +559,32 @@ type Connector struct { Config server.ConnectorConfig `json:"config"` GrantTypes []string `json:"grantTypes"` + + // Expiry, when set, overrides the corresponding fields of the top-level + // expiry config for tokens issued through this connector. Any field left + // unset falls back to the global value. + Expiry *ConnectorExpiry `json:"expiry,omitempty"` +} + +// ConnectorExpiry holds per-connector overrides for token lifetimes. +type ConnectorExpiry struct { + // IDTokens overrides expiry.idTokens for tokens issued through this connector. + IDTokens string `json:"idTokens"` + + // RefreshTokens overrides expiry.refreshTokens for sessions established + // through this connector. Any field left unset falls back to the global + // refresh token policy. + RefreshTokens *ConnectorRefreshToken `json:"refreshTokens"` +} + +// ConnectorRefreshToken holds per-connector refresh token policy overrides. +// Fields mirror the top-level RefreshToken struct; a nil pointer on DisableRotation +// signals "inherit the global value", while the other string fields inherit when empty. +type ConnectorRefreshToken struct { + DisableRotation *bool `json:"disableRotation"` + ReuseInterval string `json:"reuseInterval"` + AbsoluteLifetime string `json:"absoluteLifetime"` + ValidIfNotUsedFor string `json:"validIfNotUsedFor"` } // UnmarshalJSON allows Connector to implement the unmarshaler interface to @@ -569,8 +595,9 @@ func (c *Connector) UnmarshalJSON(b []byte) error { Name string `json:"name"` ID string `json:"id"` - Config json.RawMessage `json:"config"` - GrantTypes []string `json:"grantTypes"` + Config json.RawMessage `json:"config"` + GrantTypes []string `json:"grantTypes"` + Expiry *ConnectorExpiry `json:"expiry,omitempty"` } if err := configUnmarshaller(b, &conn); err != nil { return fmt.Errorf("parse connector: %v", err) @@ -613,6 +640,7 @@ func (c *Connector) UnmarshalJSON(b []byte) error { ID: conn.ID, Config: connConfig, GrantTypes: conn.GrantTypes, + Expiry: conn.Expiry, } return nil } diff --git a/cmd/dex/connector_expiry_test.go b/cmd/dex/connector_expiry_test.go new file mode 100644 index 0000000000..162e304550 --- /dev/null +++ b/cmd/dex/connector_expiry_test.go @@ -0,0 +1,206 @@ +package main + +import ( + "encoding/json" + "log/slog" + "testing" + "time" + + "github.com/ghodss/yaml" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildConnectorExpiryOverrides_NoConnectors(t *testing.T) { + overrides, err := buildConnectorExpiryOverrides( + slog.New(slog.DiscardHandler), nil, 24*time.Hour, RefreshToken{}, + ) + require.NoError(t, err) + assert.Empty(t, overrides) +} + +func TestBuildConnectorExpiryOverrides_NoExpiryField(t *testing.T) { + overrides, err := buildConnectorExpiryOverrides( + slog.New(slog.DiscardHandler), + []Connector{{ID: "c1", Type: "mock", Name: "c1"}}, + 24*time.Hour, RefreshToken{}, + ) + require.NoError(t, err) + assert.Empty(t, overrides, "connector without expiry field should not appear in override map") +} + +func TestBuildConnectorExpiryOverrides_IDTokens(t *testing.T) { + overrides, err := buildConnectorExpiryOverrides( + slog.New(slog.DiscardHandler), + []Connector{{ + ID: "c1", Type: "mock", Name: "c1", + Expiry: &ConnectorExpiry{IDTokens: "15m"}, + }}, + 24*time.Hour, RefreshToken{}, + ) + require.NoError(t, err) + require.Contains(t, overrides, "c1") + assert.Equal(t, 15*time.Minute, overrides["c1"].IDTokensValidFor) + assert.Nil(t, overrides["c1"].RefreshTokenPolicy) +} + +func TestBuildConnectorExpiryOverrides_IDTokensExceedsGlobal(t *testing.T) { + _, err := buildConnectorExpiryOverrides( + slog.New(slog.DiscardHandler), + []Connector{{ + ID: "c1", Type: "mock", Name: "c1", + Expiry: &ConnectorExpiry{IDTokens: "48h"}, + }}, + 24*time.Hour, RefreshToken{}, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds the global expiry.idTokens") +} + +func TestBuildConnectorExpiryOverrides_IDTokensInvalidDuration(t *testing.T) { + _, err := buildConnectorExpiryOverrides( + slog.New(slog.DiscardHandler), + []Connector{{ + ID: "c1", Type: "mock", Name: "c1", + Expiry: &ConnectorExpiry{IDTokens: "not-a-duration"}, + }}, + 24*time.Hour, RefreshToken{}, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "parse expiry.idTokens") +} + +func TestBuildConnectorExpiryOverrides_RefreshTokensOverrideAndInherit(t *testing.T) { + disable := true + global := RefreshToken{ + DisableRotation: false, + ReuseInterval: "3s", + AbsoluteLifetime: "100h", + ValidIfNotUsedFor: "24h", + } + overrides, err := buildConnectorExpiryOverrides( + slog.New(slog.DiscardHandler), + []Connector{{ + ID: "c1", Type: "mock", Name: "c1", + Expiry: &ConnectorExpiry{ + RefreshTokens: &ConnectorRefreshToken{ + DisableRotation: &disable, + AbsoluteLifetime: "10h", + // ReuseInterval and ValidIfNotUsedFor omitted: inherit from global + }, + }, + }}, + 24*time.Hour, global, + ) + require.NoError(t, err) + require.Contains(t, overrides, "c1") + + policy := overrides["c1"].RefreshTokenPolicy + require.NotNil(t, policy) + assert.False(t, policy.RotationEnabled(), "DisableRotation=true should disable rotation") + + // Probe the numeric fields via the boundary-crossing behavior of the policy methods, + // using time.Now offsets since the policy's internal clock uses time.Now. + now := time.Now() + assert.False(t, policy.CompletelyExpired(now.Add(-9*time.Hour)), "9h < absoluteLifetime=10h") + assert.True(t, policy.CompletelyExpired(now.Add(-11*time.Hour)), "11h > absoluteLifetime=10h") + + assert.False(t, policy.ExpiredBecauseUnused(now.Add(-23*time.Hour)), "23h < validIfNotUsedFor=24h") + assert.True(t, policy.ExpiredBecauseUnused(now.Add(-25*time.Hour)), "25h > validIfNotUsedFor=24h") + + assert.True(t, policy.AllowedToReuse(now.Add(-2*time.Second)), "2s < reuseInterval=3s") + assert.False(t, policy.AllowedToReuse(now.Add(-4*time.Second)), "4s > reuseInterval=3s") +} + +func TestBuildConnectorExpiryOverrides_RefreshAbsoluteLifetimeExceedsGlobal(t *testing.T) { + _, err := buildConnectorExpiryOverrides( + slog.New(slog.DiscardHandler), + []Connector{{ + ID: "c1", Type: "mock", Name: "c1", + Expiry: &ConnectorExpiry{ + RefreshTokens: &ConnectorRefreshToken{AbsoluteLifetime: "500h"}, + }, + }}, + 24*time.Hour, RefreshToken{AbsoluteLifetime: "100h"}, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds the global") +} + +func TestBuildConnectorExpiryOverrides_MultipleConnectors(t *testing.T) { + overrides, err := buildConnectorExpiryOverrides( + slog.New(slog.DiscardHandler), + []Connector{ + {ID: "a", Type: "mock", Name: "a"}, + { + ID: "b", Type: "mock", Name: "b", + Expiry: &ConnectorExpiry{IDTokens: "30m"}, + }, + { + ID: "c", Type: "mock", Name: "c", + Expiry: &ConnectorExpiry{ + RefreshTokens: &ConnectorRefreshToken{AbsoluteLifetime: "12h"}, + }, + }, + }, + 24*time.Hour, RefreshToken{AbsoluteLifetime: "48h"}, + ) + require.NoError(t, err) + assert.NotContains(t, overrides, "a") + assert.Equal(t, 30*time.Minute, overrides["b"].IDTokensValidFor) + require.NotNil(t, overrides["c"].RefreshTokenPolicy) + + // Confirm 12h absoluteLifetime took effect. + now := time.Now() + policy := overrides["c"].RefreshTokenPolicy + assert.False(t, policy.CompletelyExpired(now.Add(-11*time.Hour))) + assert.True(t, policy.CompletelyExpired(now.Add(-13*time.Hour))) +} + +// TestConnectorExpiryYAMLRoundtrip verifies that the per-connector `expiry` +// block is parsed from YAML into the Connector struct. +func TestConnectorExpiryYAMLRoundtrip(t *testing.T) { + raw := []byte(` +type: mockCallback +id: mock +name: Mock +expiry: + idTokens: "10m" + refreshTokens: + absoluteLifetime: "8h" + validIfNotUsedFor: "2h" + disableRotation: true +`) + + // Convert YAML to JSON like the main config loader does, then let + // Connector.UnmarshalJSON do its work. + jsonBytes, err := yaml.YAMLToJSON(raw) + require.NoError(t, err) + + var c Connector + require.NoError(t, json.Unmarshal(jsonBytes, &c)) + + require.NotNil(t, c.Expiry) + assert.Equal(t, "10m", c.Expiry.IDTokens) + require.NotNil(t, c.Expiry.RefreshTokens) + assert.Equal(t, "8h", c.Expiry.RefreshTokens.AbsoluteLifetime) + assert.Equal(t, "2h", c.Expiry.RefreshTokens.ValidIfNotUsedFor) + require.NotNil(t, c.Expiry.RefreshTokens.DisableRotation) + assert.True(t, *c.Expiry.RefreshTokens.DisableRotation) +} + +// TestConnectorNoExpiryYAMLLeavesNil verifies that a connector without an +// `expiry` block yields a nil pointer (the fallback-to-global signal). +func TestConnectorNoExpiryYAMLLeavesNil(t *testing.T) { + raw := []byte(` +type: mockCallback +id: mock +name: Mock +`) + jsonBytes, err := yaml.YAMLToJSON(raw) + require.NoError(t, err) + + var c Connector + require.NoError(t, json.Unmarshal(jsonBytes, &c)) + assert.Nil(t, c.Expiry) +} diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index 2e134007ab..a6d2ee5981 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -418,6 +418,14 @@ func runServe(options serveOptions) error { serverConfig.RefreshTokenPolicy = refreshTokenPolicy + connectorExpiryOverrides, err := buildConnectorExpiryOverrides( + logger, c.StaticConnectors, idTokensValidFor, c.Expiry.RefreshTokens, + ) + if err != nil { + return fmt.Errorf("invalid connector expiry config: %v", err) + } + serverConfig.ConnectorExpiryOverrides = connectorExpiryOverrides + if featureflags.SessionsEnabled.Enabled() { sessionConfig, err := parseSessionConfig(c.Sessions) if err != nil { @@ -832,6 +840,105 @@ func parseSessionConfig(s *Sessions) (*server.SessionConfig, error) { return sc, nil } +// buildConnectorExpiryOverrides parses per-connector `expiry` overrides, enforcing +// that any lifetime with signing-key-retention implications does not exceed the +// corresponding global maximum. Connectors without an override are omitted from +// the returned map and inherit the global policy at runtime. +func buildConnectorExpiryOverrides( + logger *slog.Logger, + connectors []Connector, + globalIDTokens time.Duration, + globalRefresh RefreshToken, +) (map[string]server.ConnectorExpiryOverride, error) { + var ( + globalRefreshAbsolute time.Duration + err error + ) + if globalRefresh.AbsoluteLifetime != "" { + globalRefreshAbsolute, err = time.ParseDuration(globalRefresh.AbsoluteLifetime) + if err != nil { + return nil, fmt.Errorf("parse global refresh token absoluteLifetime: %v", err) + } + } + + overrides := map[string]server.ConnectorExpiryOverride{} + for _, conn := range connectors { + if conn.Expiry == nil { + continue + } + + var override server.ConnectorExpiryOverride + + if conn.Expiry.IDTokens != "" { + d, err := time.ParseDuration(conn.Expiry.IDTokens) + if err != nil { + return nil, fmt.Errorf("connector %q: parse expiry.idTokens: %v", conn.ID, err) + } + if d > globalIDTokens { + return nil, fmt.Errorf( + "connector %q: expiry.idTokens (%s) exceeds the global expiry.idTokens (%s); "+ + "per-connector lifetimes must not exceed the global maximum since signing key retention is derived from it", + conn.ID, d, globalIDTokens, + ) + } + override.IDTokensValidFor = d + logger.Info("config connector id tokens", "connector_id", conn.ID, "valid_for", d) + } + + if rt := conn.Expiry.RefreshTokens; rt != nil { + // Validate absoluteLifetime against the global cap. + if rt.AbsoluteLifetime != "" && globalRefreshAbsolute > 0 { + d, err := time.ParseDuration(rt.AbsoluteLifetime) + if err != nil { + return nil, fmt.Errorf("connector %q: parse expiry.refreshTokens.absoluteLifetime: %v", conn.ID, err) + } + if d > globalRefreshAbsolute { + return nil, fmt.Errorf( + "connector %q: expiry.refreshTokens.absoluteLifetime (%s) exceeds the global value (%s)", + conn.ID, d, globalRefreshAbsolute, + ) + } + } + + disableRotation := globalRefresh.DisableRotation + if rt.DisableRotation != nil { + disableRotation = *rt.DisableRotation + } + validIfNotUsedFor := globalRefresh.ValidIfNotUsedFor + if rt.ValidIfNotUsedFor != "" { + validIfNotUsedFor = rt.ValidIfNotUsedFor + } + absoluteLifetime := globalRefresh.AbsoluteLifetime + if rt.AbsoluteLifetime != "" { + absoluteLifetime = rt.AbsoluteLifetime + } + reuseInterval := globalRefresh.ReuseInterval + if rt.ReuseInterval != "" { + reuseInterval = rt.ReuseInterval + } + + policy, err := server.NewRefreshTokenPolicy( + logger, disableRotation, validIfNotUsedFor, absoluteLifetime, reuseInterval, + ) + if err != nil { + return nil, fmt.Errorf("connector %q: refresh token policy: %v", conn.ID, err) + } + override.RefreshTokenPolicy = policy + logger.Info("config connector refresh tokens", + "connector_id", conn.ID, + "valid_if_not_used_for", validIfNotUsedFor, + "absolute_lifetime", absoluteLifetime, + "reuse_interval", reuseInterval, + "rotation_enabled", !disableRotation, + ) + } + + overrides[conn.ID] = override + } + + return overrides, nil +} + func buildMFAProviders(authenticators []MFAAuthenticator, issuerURL string, logger *slog.Logger) map[string]server.MFAProvider { if len(authenticators) == 0 { return nil diff --git a/config.yaml.dist b/config.yaml.dist index d2d31c80a9..6b80782971 100644 --- a/config.yaml.dist +++ b/config.yaml.dist @@ -194,6 +194,25 @@ web: # For LDAP nested group resolution, set groupSearch.userMatchers[].recursionGroupAttr # in the connector config. See: https://dexidp.io/docs/connectors/ldap/ # connectors: [] +# +# Per-connector expiry overrides. Any field left unset inherits the top-level +# `expiry` configuration. Per-connector values must not exceed the global +# maximum (idTokens and refreshTokens.absoluteLifetime), since signing key +# retention is derived from those global limits. +# connectors: +# - type: oidc +# id: partner +# name: Partner SSO +# expiry: +# idTokens: "15m" +# refreshTokens: +# absoluteLifetime: "24h" +# validIfNotUsedFor: "1h" +# config: +# issuer: https://accounts.partner.example +# clientID: ... +# clientSecret: ... +# redirectURI: https://dex.example/callback # Enable the password database. # diff --git a/server/connector_expiry_test.go b/server/connector_expiry_test.go new file mode 100644 index 0000000000..9462d4e298 --- /dev/null +++ b/server/connector_expiry_test.go @@ -0,0 +1,129 @@ +package server + +import ( + "context" + "log/slog" + "net/url" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexidp/dex/server/signer" + "github.com/dexidp/dex/storage" + "github.com/dexidp/dex/storage/memory" +) + +func TestIDTokensValidForConn(t *testing.T) { + override := &RefreshTokenPolicy{} + s := &Server{ + idTokensValidFor: time.Hour, + connectorExpiryOverrides: map[string]ConnectorExpiryOverride{ + "shortlived": {IDTokensValidFor: 5 * time.Minute}, + "refreshonly": {RefreshTokenPolicy: override}, + }, + } + + assert.Equal(t, 5*time.Minute, s.idTokensValidForConn("shortlived"), + "per-connector override should win") + assert.Equal(t, time.Hour, s.idTokensValidForConn("refreshonly"), + "zero IDTokensValidFor should fall back to global") + assert.Equal(t, time.Hour, s.idTokensValidForConn("unknown"), + "missing entry should fall back to global") +} + +func TestRefreshTokenPolicyForConn(t *testing.T) { + global := &RefreshTokenPolicy{rotateRefreshTokens: true} + perConnector := &RefreshTokenPolicy{rotateRefreshTokens: false} + + s := &Server{ + refreshTokenPolicy: global, + connectorExpiryOverrides: map[string]ConnectorExpiryOverride{ + "custom": {RefreshTokenPolicy: perConnector}, + "idonly": {IDTokensValidFor: time.Minute}, + "nilpolicy": {}, + }, + } + + assert.Same(t, perConnector, s.refreshTokenPolicyForConn("custom"), + "per-connector override should win") + assert.Same(t, global, s.refreshTokenPolicyForConn("idonly"), + "nil per-connector policy should fall back to global") + assert.Same(t, global, s.refreshTokenPolicyForConn("nilpolicy"), + "empty override should fall back to global") + assert.Same(t, global, s.refreshTokenPolicyForConn("unknown"), + "missing entry should fall back to global") +} + +func TestIDTokensValidForConnNoOverrides(t *testing.T) { + s := &Server{idTokensValidFor: 42 * time.Minute} + assert.Equal(t, 42*time.Minute, s.idTokensValidForConn("any"), + "nil override map must not panic and must return global") +} + +// TestNewIDTokenUsesConnectorOverride verifies that newIDToken applies the +// per-connector idTokensValidFor override at issuance time, not the global. +func TestNewIDTokenUsesConnectorOverride(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + logger := slog.New(slog.DiscardHandler) + store := memory.New(logger) + + now := time.Now().UTC() + err := store.UpdateKeys(ctx, func(keys storage.Keys) (storage.Keys, error) { + keys.SigningKey = &jose.JSONWebKey{ + Key: testKey, + KeyID: "test-rs256", + Algorithm: string(jose.RS256), + Use: "sig", + } + keys.SigningKeyPub = &jose.JSONWebKey{ + Key: testKey.Public(), + KeyID: "test-rs256", + Algorithm: string(jose.RS256), + Use: "sig", + } + keys.NextRotation = now.Add(time.Hour) + return keys, nil + }) + require.NoError(t, err) + + localConfig := signer.LocalConfig{ + KeysRotationPeriod: time.Hour.String(), + Algorithm: jose.RS256, + } + sig, err := localConfig.Open(ctx, store, time.Hour, func() time.Time { return now }, logger) + require.NoError(t, err) + sig.Start(ctx) + + issuerURL, err := url.Parse("https://issuer.example.com") + require.NoError(t, err) + + s := &Server{ + signer: sig, + issuerURL: *issuerURL, + logger: logger, + now: func() time.Time { return now }, + idTokensValidFor: time.Hour, + connectorExpiryOverrides: map[string]ConnectorExpiryOverride{ + "short": {IDTokensValidFor: 5 * time.Minute}, + }, + } + + _, expiryShort, err := s.newIDToken(ctx, "client", + storage.Claims{UserID: "u1", Username: "alice"}, + []string{"openid"}, "n", "", "", "short", time.Time{}) + require.NoError(t, err) + assert.Equal(t, now.Add(5*time.Minute), expiryShort.UTC(), + "per-connector override must apply") + + _, expiryGlobal, err := s.newIDToken(ctx, "client", + storage.Claims{UserID: "u1", Username: "alice"}, + []string{"openid"}, "n", "", "", "unknown", time.Time{}) + require.NoError(t, err) + assert.Equal(t, now.Add(time.Hour), expiryGlobal.UTC(), + "unknown connector must fall back to global") +} diff --git a/server/introspectionhandler.go b/server/introspectionhandler.go index dd7cce8387..341ba2e7fe 100644 --- a/server/introspectionhandler.go +++ b/server/introspectionhandler.go @@ -226,7 +226,7 @@ func (s *Server) introspectRefreshToken(ctx context.Context, token string) (*Int ClientID: rCtx.storageToken.ClientID, IssuedAt: rCtx.storageToken.CreatedAt.Unix(), NotBefore: rCtx.storageToken.CreatedAt.Unix(), - Expiry: rCtx.storageToken.CreatedAt.Add(s.refreshTokenPolicy.absoluteLifetime).Unix(), + Expiry: rCtx.storageToken.CreatedAt.Add(s.refreshTokenPolicyForConn(rCtx.storageToken.ConnectorID).absoluteLifetime).Unix(), Subject: subjectString, Username: rCtx.storageToken.Claims.PreferredUsername, Audience: getAudience(rCtx.storageToken.ClientID, rCtx.scopes), diff --git a/server/oauth2.go b/server/oauth2.go index 6e91facfb6..cf8d15d0c6 100644 --- a/server/oauth2.go +++ b/server/oauth2.go @@ -343,9 +343,27 @@ func genSubject(userID string, connID string) (string, error) { return internal.Marshal(sub) } +// idTokensValidForConn returns the ID token lifetime for the given connector, +// falling back to the server-wide value when no per-connector override is set. +func (s *Server) idTokensValidForConn(connID string) time.Duration { + if o, ok := s.connectorExpiryOverrides[connID]; ok && o.IDTokensValidFor > 0 { + return o.IDTokensValidFor + } + return s.idTokensValidFor +} + +// refreshTokenPolicyForConn returns the refresh token policy for the given +// connector, falling back to the server-wide policy when no override is set. +func (s *Server) refreshTokenPolicyForConn(connID string) *RefreshTokenPolicy { + if o, ok := s.connectorExpiryOverrides[connID]; ok && o.RefreshTokenPolicy != nil { + return o.RefreshTokenPolicy + } + return s.refreshTokenPolicy +} + func (s *Server) newIDToken(ctx context.Context, clientID string, claims storage.Claims, scopes []string, nonce, accessToken, code, connID string, authTime time.Time) (idToken string, expiry time.Time, err error) { issuedAt := s.now() - expiry = issuedAt.Add(s.idTokensValidFor) + expiry = issuedAt.Add(s.idTokensValidForConn(connID)) subjectString, err := genSubject(claims.UserID, connID) if err != nil { diff --git a/server/refreshhandlers.go b/server/refreshhandlers.go index 7d3c7d4cd5..910846abd4 100644 --- a/server/refreshhandlers.go +++ b/server/refreshhandlers.go @@ -176,9 +176,11 @@ func (s *Server) getRefreshTokenFromStorage(ctx context.Context, clientID *strin return nil, &refreshError{msg: errInvalidGrant, desc: invalidErr.desc, code: http.StatusBadRequest} } + policy := s.refreshTokenPolicyForConn(refresh.ConnectorID) + if refresh.Token != token.Token { switch { - case !s.refreshTokenPolicy.AllowedToReuse(refresh.LastUsed): + case !policy.AllowedToReuse(refresh.LastUsed): fallthrough case refresh.ObsoleteToken != token.Token: fallthrough @@ -188,12 +190,12 @@ func (s *Server) getRefreshTokenFromStorage(ctx context.Context, clientID *strin } } - if s.refreshTokenPolicy.CompletelyExpired(refresh.CreatedAt) { + if policy.CompletelyExpired(refresh.CreatedAt) { s.logger.ErrorContext(ctx, "refresh token expired", "token_id", refresh.ID) return nil, expiredErr } - if s.refreshTokenPolicy.ExpiredBecauseUnused(refresh.LastUsed) { + if policy.ExpiredBecauseUnused(refresh.LastUsed) { s.logger.ErrorContext(ctx, "refresh token expired due to inactivity", "token_id", refresh.ID) return nil, expiredErr } @@ -337,9 +339,11 @@ func (s *Server) updateRefreshToken(ctx context.Context, rCtx *refreshContext, u // stored in UserIdentity at the time of the last interactive login. This aligns with the // behavior of other identity brokers (e.g., Keycloak, Auth0) that treat downstream sessions // independently from the upstream provider session lifetime. + policy := s.refreshTokenPolicyForConn(rCtx.storageToken.ConnectorID) + refreshTokenUpdater := func(old storage.RefreshToken) (storage.RefreshToken, error) { - rotationEnabled := s.refreshTokenPolicy.RotationEnabled() - reusingAllowed := s.refreshTokenPolicy.AllowedToReuse(old.LastUsed) + rotationEnabled := policy.RotationEnabled() + reusingAllowed := policy.AllowedToReuse(old.LastUsed) switch { case !rotationEnabled && reusingAllowed: diff --git a/server/server.go b/server/server.go index a3ebd63857..582205a8c0 100644 --- a/server/server.go +++ b/server/server.go @@ -113,6 +113,11 @@ type Config struct { // Refresh token expiration settings RefreshTokenPolicy *RefreshTokenPolicy + // ConnectorExpiryOverrides, keyed by connector ID, overrides the global + // IDTokensValidFor and/or RefreshTokenPolicy for tokens issued through that + // connector. Missing entries or unset fields fall back to the global values. + ConnectorExpiryOverrides map[string]ConnectorExpiryOverride + // If set, the server will use this connector to handle password grants PasswordConnector string @@ -207,6 +212,13 @@ func value(val, defaultValue time.Duration) time.Duration { return val } +// ConnectorExpiryOverride carries per-connector token lifetime overrides. +// A zero IDTokensValidFor or nil RefreshTokenPolicy inherits the global value. +type ConnectorExpiryOverride struct { + IDTokensValidFor time.Duration + RefreshTokenPolicy *RefreshTokenPolicy +} + // Server is the top level object. type Server struct { issuerURL url.URL @@ -245,6 +257,9 @@ type Server struct { refreshTokenPolicy *RefreshTokenPolicy + // connectorExpiryOverrides holds per-connector overrides for token lifetimes. + connectorExpiryOverrides map[string]ConnectorExpiryOverride + logger *slog.Logger signer signer.Signer @@ -365,10 +380,11 @@ func newServer(ctx context.Context, c Config) (*Server, error) { supportedResponseTypes: supportedRes, supportedGrantTypes: supportedGrants, pkce: c.PKCE, - idTokensValidFor: value(c.IDTokensValidFor, 24*time.Hour), - authRequestsValidFor: value(c.AuthRequestsValidFor, 24*time.Hour), - deviceRequestsValidFor: value(c.DeviceRequestsValidFor, 5*time.Minute), - refreshTokenPolicy: c.RefreshTokenPolicy, + idTokensValidFor: value(c.IDTokensValidFor, 24*time.Hour), + authRequestsValidFor: value(c.AuthRequestsValidFor, 24*time.Hour), + deviceRequestsValidFor: value(c.DeviceRequestsValidFor, 5*time.Minute), + refreshTokenPolicy: c.RefreshTokenPolicy, + connectorExpiryOverrides: c.ConnectorExpiryOverrides, skipApproval: c.SkipApprovalScreen, alwaysShowLogin: c.AlwaysShowLoginScreen, now: now, From ddbd48e0341de23736b5e87bab44febcebece22b Mon Sep 17 00:00:00 2001 From: Helio Machado <0x2b3bfa0+git@googlemail.com> Date: Mon, 11 May 2026 04:14:53 +0400 Subject: [PATCH 02/10] chore: clean up per-connector expiry implementation - Re-home tests into the existing per-source-file test files: cmd/dex/serve_test.go (buildConnectorExpiryOverrides tests), cmd/dex/config_test.go (YAML roundtrip), and server/oauth2_test.go (resolver and newIDToken tests). - Extract parseConnectorExpiry to flatten the override-vs-inherit logic. - Drop the refresh-token absoluteLifetime cap: refresh tokens are validated against storage, not signing keys, so key retention does not constrain their lifetime. Scope the safety check to idTokens. - Shorten error messages to match the project's terse style. - Apply golangci-lint fmt (struct field alignment in server/server.go). Signed-off-by: Helio Machado <0x2b3bfa0+git@googlemail.com> --- cmd/dex/config_test.go | 63 ++++++++++ cmd/dex/connector_expiry_test.go | 206 ------------------------------- cmd/dex/serve.go | 140 ++++++++++----------- cmd/dex/serve_test.go | 133 ++++++++++++++++++++ config.yaml.dist | 5 +- server/connector_expiry_test.go | 129 ------------------- server/oauth2_test.go | 112 +++++++++++++++++ server/server.go | 32 ++--- 8 files changed, 389 insertions(+), 431 deletions(-) delete mode 100644 cmd/dex/connector_expiry_test.go delete mode 100644 server/connector_expiry_test.go diff --git a/cmd/dex/config_test.go b/cmd/dex/config_test.go index 2a08ab1eb5..408710724d 100644 --- a/cmd/dex/config_test.go +++ b/cmd/dex/config_test.go @@ -675,3 +675,66 @@ enablePasswordDB: true }) } } + +func TestConnectorExpiryYAMLRoundtrip(t *testing.T) { + raw := []byte(` +type: mockCallback +id: mock +name: Mock +expiry: + idTokens: "10m" + refreshTokens: + absoluteLifetime: "8h" + validIfNotUsedFor: "2h" + disableRotation: true +`) + + jsonBytes, err := yaml.YAMLToJSON(raw) + if err != nil { + t.Fatalf("YAMLToJSON: %v", err) + } + + var c Connector + if err := json.Unmarshal(jsonBytes, &c); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if c.Expiry == nil { + t.Fatal("expected non-nil Expiry") + } + if got, want := c.Expiry.IDTokens, "10m"; got != want { + t.Errorf("IDTokens = %q, want %q", got, want) + } + if c.Expiry.RefreshTokens == nil { + t.Fatal("expected non-nil RefreshTokens") + } + if got, want := c.Expiry.RefreshTokens.AbsoluteLifetime, "8h"; got != want { + t.Errorf("AbsoluteLifetime = %q, want %q", got, want) + } + if got, want := c.Expiry.RefreshTokens.ValidIfNotUsedFor, "2h"; got != want { + t.Errorf("ValidIfNotUsedFor = %q, want %q", got, want) + } + if c.Expiry.RefreshTokens.DisableRotation == nil || !*c.Expiry.RefreshTokens.DisableRotation { + t.Errorf("DisableRotation = %v, want *true", c.Expiry.RefreshTokens.DisableRotation) + } +} + +func TestConnectorNoExpiryYAMLLeavesNil(t *testing.T) { + raw := []byte(` +type: mockCallback +id: mock +name: Mock +`) + jsonBytes, err := yaml.YAMLToJSON(raw) + if err != nil { + t.Fatalf("YAMLToJSON: %v", err) + } + + var c Connector + if err := json.Unmarshal(jsonBytes, &c); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if c.Expiry != nil { + t.Errorf("Expiry = %+v, want nil", c.Expiry) + } +} diff --git a/cmd/dex/connector_expiry_test.go b/cmd/dex/connector_expiry_test.go deleted file mode 100644 index 162e304550..0000000000 --- a/cmd/dex/connector_expiry_test.go +++ /dev/null @@ -1,206 +0,0 @@ -package main - -import ( - "encoding/json" - "log/slog" - "testing" - "time" - - "github.com/ghodss/yaml" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestBuildConnectorExpiryOverrides_NoConnectors(t *testing.T) { - overrides, err := buildConnectorExpiryOverrides( - slog.New(slog.DiscardHandler), nil, 24*time.Hour, RefreshToken{}, - ) - require.NoError(t, err) - assert.Empty(t, overrides) -} - -func TestBuildConnectorExpiryOverrides_NoExpiryField(t *testing.T) { - overrides, err := buildConnectorExpiryOverrides( - slog.New(slog.DiscardHandler), - []Connector{{ID: "c1", Type: "mock", Name: "c1"}}, - 24*time.Hour, RefreshToken{}, - ) - require.NoError(t, err) - assert.Empty(t, overrides, "connector without expiry field should not appear in override map") -} - -func TestBuildConnectorExpiryOverrides_IDTokens(t *testing.T) { - overrides, err := buildConnectorExpiryOverrides( - slog.New(slog.DiscardHandler), - []Connector{{ - ID: "c1", Type: "mock", Name: "c1", - Expiry: &ConnectorExpiry{IDTokens: "15m"}, - }}, - 24*time.Hour, RefreshToken{}, - ) - require.NoError(t, err) - require.Contains(t, overrides, "c1") - assert.Equal(t, 15*time.Minute, overrides["c1"].IDTokensValidFor) - assert.Nil(t, overrides["c1"].RefreshTokenPolicy) -} - -func TestBuildConnectorExpiryOverrides_IDTokensExceedsGlobal(t *testing.T) { - _, err := buildConnectorExpiryOverrides( - slog.New(slog.DiscardHandler), - []Connector{{ - ID: "c1", Type: "mock", Name: "c1", - Expiry: &ConnectorExpiry{IDTokens: "48h"}, - }}, - 24*time.Hour, RefreshToken{}, - ) - require.Error(t, err) - assert.Contains(t, err.Error(), "exceeds the global expiry.idTokens") -} - -func TestBuildConnectorExpiryOverrides_IDTokensInvalidDuration(t *testing.T) { - _, err := buildConnectorExpiryOverrides( - slog.New(slog.DiscardHandler), - []Connector{{ - ID: "c1", Type: "mock", Name: "c1", - Expiry: &ConnectorExpiry{IDTokens: "not-a-duration"}, - }}, - 24*time.Hour, RefreshToken{}, - ) - require.Error(t, err) - assert.Contains(t, err.Error(), "parse expiry.idTokens") -} - -func TestBuildConnectorExpiryOverrides_RefreshTokensOverrideAndInherit(t *testing.T) { - disable := true - global := RefreshToken{ - DisableRotation: false, - ReuseInterval: "3s", - AbsoluteLifetime: "100h", - ValidIfNotUsedFor: "24h", - } - overrides, err := buildConnectorExpiryOverrides( - slog.New(slog.DiscardHandler), - []Connector{{ - ID: "c1", Type: "mock", Name: "c1", - Expiry: &ConnectorExpiry{ - RefreshTokens: &ConnectorRefreshToken{ - DisableRotation: &disable, - AbsoluteLifetime: "10h", - // ReuseInterval and ValidIfNotUsedFor omitted: inherit from global - }, - }, - }}, - 24*time.Hour, global, - ) - require.NoError(t, err) - require.Contains(t, overrides, "c1") - - policy := overrides["c1"].RefreshTokenPolicy - require.NotNil(t, policy) - assert.False(t, policy.RotationEnabled(), "DisableRotation=true should disable rotation") - - // Probe the numeric fields via the boundary-crossing behavior of the policy methods, - // using time.Now offsets since the policy's internal clock uses time.Now. - now := time.Now() - assert.False(t, policy.CompletelyExpired(now.Add(-9*time.Hour)), "9h < absoluteLifetime=10h") - assert.True(t, policy.CompletelyExpired(now.Add(-11*time.Hour)), "11h > absoluteLifetime=10h") - - assert.False(t, policy.ExpiredBecauseUnused(now.Add(-23*time.Hour)), "23h < validIfNotUsedFor=24h") - assert.True(t, policy.ExpiredBecauseUnused(now.Add(-25*time.Hour)), "25h > validIfNotUsedFor=24h") - - assert.True(t, policy.AllowedToReuse(now.Add(-2*time.Second)), "2s < reuseInterval=3s") - assert.False(t, policy.AllowedToReuse(now.Add(-4*time.Second)), "4s > reuseInterval=3s") -} - -func TestBuildConnectorExpiryOverrides_RefreshAbsoluteLifetimeExceedsGlobal(t *testing.T) { - _, err := buildConnectorExpiryOverrides( - slog.New(slog.DiscardHandler), - []Connector{{ - ID: "c1", Type: "mock", Name: "c1", - Expiry: &ConnectorExpiry{ - RefreshTokens: &ConnectorRefreshToken{AbsoluteLifetime: "500h"}, - }, - }}, - 24*time.Hour, RefreshToken{AbsoluteLifetime: "100h"}, - ) - require.Error(t, err) - assert.Contains(t, err.Error(), "exceeds the global") -} - -func TestBuildConnectorExpiryOverrides_MultipleConnectors(t *testing.T) { - overrides, err := buildConnectorExpiryOverrides( - slog.New(slog.DiscardHandler), - []Connector{ - {ID: "a", Type: "mock", Name: "a"}, - { - ID: "b", Type: "mock", Name: "b", - Expiry: &ConnectorExpiry{IDTokens: "30m"}, - }, - { - ID: "c", Type: "mock", Name: "c", - Expiry: &ConnectorExpiry{ - RefreshTokens: &ConnectorRefreshToken{AbsoluteLifetime: "12h"}, - }, - }, - }, - 24*time.Hour, RefreshToken{AbsoluteLifetime: "48h"}, - ) - require.NoError(t, err) - assert.NotContains(t, overrides, "a") - assert.Equal(t, 30*time.Minute, overrides["b"].IDTokensValidFor) - require.NotNil(t, overrides["c"].RefreshTokenPolicy) - - // Confirm 12h absoluteLifetime took effect. - now := time.Now() - policy := overrides["c"].RefreshTokenPolicy - assert.False(t, policy.CompletelyExpired(now.Add(-11*time.Hour))) - assert.True(t, policy.CompletelyExpired(now.Add(-13*time.Hour))) -} - -// TestConnectorExpiryYAMLRoundtrip verifies that the per-connector `expiry` -// block is parsed from YAML into the Connector struct. -func TestConnectorExpiryYAMLRoundtrip(t *testing.T) { - raw := []byte(` -type: mockCallback -id: mock -name: Mock -expiry: - idTokens: "10m" - refreshTokens: - absoluteLifetime: "8h" - validIfNotUsedFor: "2h" - disableRotation: true -`) - - // Convert YAML to JSON like the main config loader does, then let - // Connector.UnmarshalJSON do its work. - jsonBytes, err := yaml.YAMLToJSON(raw) - require.NoError(t, err) - - var c Connector - require.NoError(t, json.Unmarshal(jsonBytes, &c)) - - require.NotNil(t, c.Expiry) - assert.Equal(t, "10m", c.Expiry.IDTokens) - require.NotNil(t, c.Expiry.RefreshTokens) - assert.Equal(t, "8h", c.Expiry.RefreshTokens.AbsoluteLifetime) - assert.Equal(t, "2h", c.Expiry.RefreshTokens.ValidIfNotUsedFor) - require.NotNil(t, c.Expiry.RefreshTokens.DisableRotation) - assert.True(t, *c.Expiry.RefreshTokens.DisableRotation) -} - -// TestConnectorNoExpiryYAMLLeavesNil verifies that a connector without an -// `expiry` block yields a nil pointer (the fallback-to-global signal). -func TestConnectorNoExpiryYAMLLeavesNil(t *testing.T) { - raw := []byte(` -type: mockCallback -id: mock -name: Mock -`) - jsonBytes, err := yaml.YAMLToJSON(raw) - require.NoError(t, err) - - var c Connector - require.NoError(t, json.Unmarshal(jsonBytes, &c)) - assert.Nil(t, c.Expiry) -} diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index a6d2ee5981..63d2f1397d 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -840,103 +840,89 @@ func parseSessionConfig(s *Sessions) (*server.SessionConfig, error) { return sc, nil } -// buildConnectorExpiryOverrides parses per-connector `expiry` overrides, enforcing -// that any lifetime with signing-key-retention implications does not exceed the -// corresponding global maximum. Connectors without an override are omitted from -// the returned map and inherit the global policy at runtime. +// buildConnectorExpiryOverrides parses per-connector `expiry` overrides. +// Per-connector `idTokens` must not exceed the global `expiry.idTokens` value, +// since the signer's key retention window is sized from it. Refresh token +// fields have no such constraint: they are validated against storage, not +// against signing keys. Connectors without an override are omitted from the +// returned map and inherit the global policy at runtime. func buildConnectorExpiryOverrides( logger *slog.Logger, connectors []Connector, globalIDTokens time.Duration, globalRefresh RefreshToken, ) (map[string]server.ConnectorExpiryOverride, error) { - var ( - globalRefreshAbsolute time.Duration - err error - ) - if globalRefresh.AbsoluteLifetime != "" { - globalRefreshAbsolute, err = time.ParseDuration(globalRefresh.AbsoluteLifetime) - if err != nil { - return nil, fmt.Errorf("parse global refresh token absoluteLifetime: %v", err) - } - } - overrides := map[string]server.ConnectorExpiryOverride{} for _, conn := range connectors { if conn.Expiry == nil { continue } - var override server.ConnectorExpiryOverride - - if conn.Expiry.IDTokens != "" { - d, err := time.ParseDuration(conn.Expiry.IDTokens) - if err != nil { - return nil, fmt.Errorf("connector %q: parse expiry.idTokens: %v", conn.ID, err) - } - if d > globalIDTokens { - return nil, fmt.Errorf( - "connector %q: expiry.idTokens (%s) exceeds the global expiry.idTokens (%s); "+ - "per-connector lifetimes must not exceed the global maximum since signing key retention is derived from it", - conn.ID, d, globalIDTokens, - ) - } - override.IDTokensValidFor = d - logger.Info("config connector id tokens", "connector_id", conn.ID, "valid_for", d) + override, err := parseConnectorExpiry(logger, conn, globalIDTokens, globalRefresh) + if err != nil { + return nil, fmt.Errorf("connector %q: %v", conn.ID, err) } + overrides[conn.ID] = override + } - if rt := conn.Expiry.RefreshTokens; rt != nil { - // Validate absoluteLifetime against the global cap. - if rt.AbsoluteLifetime != "" && globalRefreshAbsolute > 0 { - d, err := time.ParseDuration(rt.AbsoluteLifetime) - if err != nil { - return nil, fmt.Errorf("connector %q: parse expiry.refreshTokens.absoluteLifetime: %v", conn.ID, err) - } - if d > globalRefreshAbsolute { - return nil, fmt.Errorf( - "connector %q: expiry.refreshTokens.absoluteLifetime (%s) exceeds the global value (%s)", - conn.ID, d, globalRefreshAbsolute, - ) - } - } + return overrides, nil +} - disableRotation := globalRefresh.DisableRotation - if rt.DisableRotation != nil { - disableRotation = *rt.DisableRotation - } - validIfNotUsedFor := globalRefresh.ValidIfNotUsedFor - if rt.ValidIfNotUsedFor != "" { - validIfNotUsedFor = rt.ValidIfNotUsedFor - } - absoluteLifetime := globalRefresh.AbsoluteLifetime - if rt.AbsoluteLifetime != "" { - absoluteLifetime = rt.AbsoluteLifetime - } - reuseInterval := globalRefresh.ReuseInterval - if rt.ReuseInterval != "" { - reuseInterval = rt.ReuseInterval - } +func parseConnectorExpiry( + logger *slog.Logger, + conn Connector, + globalIDTokens time.Duration, + globalRefresh RefreshToken, +) (server.ConnectorExpiryOverride, error) { + var override server.ConnectorExpiryOverride - policy, err := server.NewRefreshTokenPolicy( - logger, disableRotation, validIfNotUsedFor, absoluteLifetime, reuseInterval, - ) - if err != nil { - return nil, fmt.Errorf("connector %q: refresh token policy: %v", conn.ID, err) - } - override.RefreshTokenPolicy = policy - logger.Info("config connector refresh tokens", - "connector_id", conn.ID, - "valid_if_not_used_for", validIfNotUsedFor, - "absolute_lifetime", absoluteLifetime, - "reuse_interval", reuseInterval, - "rotation_enabled", !disableRotation, - ) + if conn.Expiry.IDTokens != "" { + d, err := time.ParseDuration(conn.Expiry.IDTokens) + if err != nil { + return override, fmt.Errorf("parse expiry.idTokens: %v", err) + } + if d > globalIDTokens { + return override, fmt.Errorf("expiry.idTokens (%s) exceeds the global value (%s)", d, globalIDTokens) } + override.IDTokensValidFor = d + logger.Info("config connector id tokens", "connector_id", conn.ID, "valid_for", d) + } - overrides[conn.ID] = override + rt := conn.Expiry.RefreshTokens + if rt == nil { + return override, nil } - return overrides, nil + disableRotation := globalRefresh.DisableRotation + if rt.DisableRotation != nil { + disableRotation = *rt.DisableRotation + } + validIfNotUsedFor := rt.ValidIfNotUsedFor + if validIfNotUsedFor == "" { + validIfNotUsedFor = globalRefresh.ValidIfNotUsedFor + } + absoluteLifetime := rt.AbsoluteLifetime + if absoluteLifetime == "" { + absoluteLifetime = globalRefresh.AbsoluteLifetime + } + reuseInterval := rt.ReuseInterval + if reuseInterval == "" { + reuseInterval = globalRefresh.ReuseInterval + } + + policy, err := server.NewRefreshTokenPolicy(logger, disableRotation, validIfNotUsedFor, absoluteLifetime, reuseInterval) + if err != nil { + return override, fmt.Errorf("refresh token policy: %v", err) + } + override.RefreshTokenPolicy = policy + logger.Info("config connector refresh tokens", + "connector_id", conn.ID, + "valid_if_not_used_for", validIfNotUsedFor, + "absolute_lifetime", absoluteLifetime, + "reuse_interval", reuseInterval, + "rotation_enabled", !disableRotation, + ) + return override, nil } func buildMFAProviders(authenticators []MFAAuthenticator, issuerURL string, logger *slog.Logger) map[string]server.MFAProvider { diff --git a/cmd/dex/serve_test.go b/cmd/dex/serve_test.go index 12d0c0fff4..1915861413 100644 --- a/cmd/dex/serve_test.go +++ b/cmd/dex/serve_test.go @@ -3,7 +3,9 @@ package main import ( "log/slog" "testing" + "time" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -27,3 +29,134 @@ func TestNewLogger(t *testing.T) { require.Equal(t, (*slog.Logger)(nil), logger) }) } + +func TestBuildConnectorExpiryOverrides_NoConnectors(t *testing.T) { + overrides, err := buildConnectorExpiryOverrides( + slog.New(slog.DiscardHandler), nil, 24*time.Hour, RefreshToken{}, + ) + require.NoError(t, err) + assert.Empty(t, overrides) +} + +func TestBuildConnectorExpiryOverrides_NoExpiryField(t *testing.T) { + overrides, err := buildConnectorExpiryOverrides( + slog.New(slog.DiscardHandler), + []Connector{{ID: "c1", Type: "mock", Name: "c1"}}, + 24*time.Hour, RefreshToken{}, + ) + require.NoError(t, err) + assert.Empty(t, overrides, "connector without expiry field should not appear in override map") +} + +func TestBuildConnectorExpiryOverrides_IDTokens(t *testing.T) { + overrides, err := buildConnectorExpiryOverrides( + slog.New(slog.DiscardHandler), + []Connector{{ + ID: "c1", Type: "mock", Name: "c1", + Expiry: &ConnectorExpiry{IDTokens: "15m"}, + }}, + 24*time.Hour, RefreshToken{}, + ) + require.NoError(t, err) + require.Contains(t, overrides, "c1") + assert.Equal(t, 15*time.Minute, overrides["c1"].IDTokensValidFor) + assert.Nil(t, overrides["c1"].RefreshTokenPolicy) +} + +func TestBuildConnectorExpiryOverrides_IDTokensExceedsGlobal(t *testing.T) { + _, err := buildConnectorExpiryOverrides( + slog.New(slog.DiscardHandler), + []Connector{{ + ID: "c1", Type: "mock", Name: "c1", + Expiry: &ConnectorExpiry{IDTokens: "48h"}, + }}, + 24*time.Hour, RefreshToken{}, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "expiry.idTokens (48h0m0s) exceeds the global value (24h0m0s)") +} + +func TestBuildConnectorExpiryOverrides_IDTokensInvalidDuration(t *testing.T) { + _, err := buildConnectorExpiryOverrides( + slog.New(slog.DiscardHandler), + []Connector{{ + ID: "c1", Type: "mock", Name: "c1", + Expiry: &ConnectorExpiry{IDTokens: "not-a-duration"}, + }}, + 24*time.Hour, RefreshToken{}, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "parse expiry.idTokens") +} + +func TestBuildConnectorExpiryOverrides_RefreshTokensOverrideAndInherit(t *testing.T) { + disable := true + global := RefreshToken{ + DisableRotation: false, + ReuseInterval: "3s", + AbsoluteLifetime: "100h", + ValidIfNotUsedFor: "24h", + } + overrides, err := buildConnectorExpiryOverrides( + slog.New(slog.DiscardHandler), + []Connector{{ + ID: "c1", Type: "mock", Name: "c1", + Expiry: &ConnectorExpiry{ + RefreshTokens: &ConnectorRefreshToken{ + DisableRotation: &disable, + AbsoluteLifetime: "10h", + // ReuseInterval and ValidIfNotUsedFor omitted: inherit from global + }, + }, + }}, + 24*time.Hour, global, + ) + require.NoError(t, err) + require.Contains(t, overrides, "c1") + + policy := overrides["c1"].RefreshTokenPolicy + require.NotNil(t, policy) + assert.False(t, policy.RotationEnabled(), "DisableRotation=true should disable rotation") + + // Probe the numeric fields via the boundary-crossing behavior of the policy methods, + // using time.Now offsets since the policy's internal clock uses time.Now. + now := time.Now() + assert.False(t, policy.CompletelyExpired(now.Add(-9*time.Hour)), "9h < absoluteLifetime=10h") + assert.True(t, policy.CompletelyExpired(now.Add(-11*time.Hour)), "11h > absoluteLifetime=10h") + + assert.False(t, policy.ExpiredBecauseUnused(now.Add(-23*time.Hour)), "23h < validIfNotUsedFor=24h") + assert.True(t, policy.ExpiredBecauseUnused(now.Add(-25*time.Hour)), "25h > validIfNotUsedFor=24h") + + assert.True(t, policy.AllowedToReuse(now.Add(-2*time.Second)), "2s < reuseInterval=3s") + assert.False(t, policy.AllowedToReuse(now.Add(-4*time.Second)), "4s > reuseInterval=3s") +} + +func TestBuildConnectorExpiryOverrides_MultipleConnectors(t *testing.T) { + overrides, err := buildConnectorExpiryOverrides( + slog.New(slog.DiscardHandler), + []Connector{ + {ID: "a", Type: "mock", Name: "a"}, + { + ID: "b", Type: "mock", Name: "b", + Expiry: &ConnectorExpiry{IDTokens: "30m"}, + }, + { + ID: "c", Type: "mock", Name: "c", + Expiry: &ConnectorExpiry{ + RefreshTokens: &ConnectorRefreshToken{AbsoluteLifetime: "12h"}, + }, + }, + }, + 24*time.Hour, RefreshToken{AbsoluteLifetime: "48h"}, + ) + require.NoError(t, err) + assert.NotContains(t, overrides, "a") + assert.Equal(t, 30*time.Minute, overrides["b"].IDTokensValidFor) + require.NotNil(t, overrides["c"].RefreshTokenPolicy) + + // Confirm 12h absoluteLifetime took effect. + now := time.Now() + policy := overrides["c"].RefreshTokenPolicy + assert.False(t, policy.CompletelyExpired(now.Add(-11*time.Hour))) + assert.True(t, policy.CompletelyExpired(now.Add(-13*time.Hour))) +} diff --git a/config.yaml.dist b/config.yaml.dist index 6b80782971..5815add5ad 100644 --- a/config.yaml.dist +++ b/config.yaml.dist @@ -196,9 +196,8 @@ web: # connectors: [] # # Per-connector expiry overrides. Any field left unset inherits the top-level -# `expiry` configuration. Per-connector values must not exceed the global -# maximum (idTokens and refreshTokens.absoluteLifetime), since signing key -# retention is derived from those global limits. +# `expiry` configuration. Per-connector `idTokens` must not exceed the global +# `expiry.idTokens`, since the signer's key retention window is sized from it. # connectors: # - type: oidc # id: partner diff --git a/server/connector_expiry_test.go b/server/connector_expiry_test.go deleted file mode 100644 index 9462d4e298..0000000000 --- a/server/connector_expiry_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package server - -import ( - "context" - "log/slog" - "net/url" - "testing" - "time" - - "github.com/go-jose/go-jose/v4" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/dexidp/dex/server/signer" - "github.com/dexidp/dex/storage" - "github.com/dexidp/dex/storage/memory" -) - -func TestIDTokensValidForConn(t *testing.T) { - override := &RefreshTokenPolicy{} - s := &Server{ - idTokensValidFor: time.Hour, - connectorExpiryOverrides: map[string]ConnectorExpiryOverride{ - "shortlived": {IDTokensValidFor: 5 * time.Minute}, - "refreshonly": {RefreshTokenPolicy: override}, - }, - } - - assert.Equal(t, 5*time.Minute, s.idTokensValidForConn("shortlived"), - "per-connector override should win") - assert.Equal(t, time.Hour, s.idTokensValidForConn("refreshonly"), - "zero IDTokensValidFor should fall back to global") - assert.Equal(t, time.Hour, s.idTokensValidForConn("unknown"), - "missing entry should fall back to global") -} - -func TestRefreshTokenPolicyForConn(t *testing.T) { - global := &RefreshTokenPolicy{rotateRefreshTokens: true} - perConnector := &RefreshTokenPolicy{rotateRefreshTokens: false} - - s := &Server{ - refreshTokenPolicy: global, - connectorExpiryOverrides: map[string]ConnectorExpiryOverride{ - "custom": {RefreshTokenPolicy: perConnector}, - "idonly": {IDTokensValidFor: time.Minute}, - "nilpolicy": {}, - }, - } - - assert.Same(t, perConnector, s.refreshTokenPolicyForConn("custom"), - "per-connector override should win") - assert.Same(t, global, s.refreshTokenPolicyForConn("idonly"), - "nil per-connector policy should fall back to global") - assert.Same(t, global, s.refreshTokenPolicyForConn("nilpolicy"), - "empty override should fall back to global") - assert.Same(t, global, s.refreshTokenPolicyForConn("unknown"), - "missing entry should fall back to global") -} - -func TestIDTokensValidForConnNoOverrides(t *testing.T) { - s := &Server{idTokensValidFor: 42 * time.Minute} - assert.Equal(t, 42*time.Minute, s.idTokensValidForConn("any"), - "nil override map must not panic and must return global") -} - -// TestNewIDTokenUsesConnectorOverride verifies that newIDToken applies the -// per-connector idTokensValidFor override at issuance time, not the global. -func TestNewIDTokenUsesConnectorOverride(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - logger := slog.New(slog.DiscardHandler) - store := memory.New(logger) - - now := time.Now().UTC() - err := store.UpdateKeys(ctx, func(keys storage.Keys) (storage.Keys, error) { - keys.SigningKey = &jose.JSONWebKey{ - Key: testKey, - KeyID: "test-rs256", - Algorithm: string(jose.RS256), - Use: "sig", - } - keys.SigningKeyPub = &jose.JSONWebKey{ - Key: testKey.Public(), - KeyID: "test-rs256", - Algorithm: string(jose.RS256), - Use: "sig", - } - keys.NextRotation = now.Add(time.Hour) - return keys, nil - }) - require.NoError(t, err) - - localConfig := signer.LocalConfig{ - KeysRotationPeriod: time.Hour.String(), - Algorithm: jose.RS256, - } - sig, err := localConfig.Open(ctx, store, time.Hour, func() time.Time { return now }, logger) - require.NoError(t, err) - sig.Start(ctx) - - issuerURL, err := url.Parse("https://issuer.example.com") - require.NoError(t, err) - - s := &Server{ - signer: sig, - issuerURL: *issuerURL, - logger: logger, - now: func() time.Time { return now }, - idTokensValidFor: time.Hour, - connectorExpiryOverrides: map[string]ConnectorExpiryOverride{ - "short": {IDTokensValidFor: 5 * time.Minute}, - }, - } - - _, expiryShort, err := s.newIDToken(ctx, "client", - storage.Claims{UserID: "u1", Username: "alice"}, - []string{"openid"}, "n", "", "", "short", time.Time{}) - require.NoError(t, err) - assert.Equal(t, now.Add(5*time.Minute), expiryShort.UTC(), - "per-connector override must apply") - - _, expiryGlobal, err := s.newIDToken(ctx, "client", - storage.Claims{UserID: "u1", Username: "alice"}, - []string{"openid"}, "n", "", "", "unknown", time.Time{}) - require.NoError(t, err) - assert.Equal(t, now.Add(time.Hour), expiryGlobal.UTC(), - "unknown connector must fall back to global") -} diff --git a/server/oauth2_test.go b/server/oauth2_test.go index 403b497b92..52bf65959d 100644 --- a/server/oauth2_test.go +++ b/server/oauth2_test.go @@ -1269,3 +1269,115 @@ func TestParseAuthorizationRequest_IDTokenHint(t *testing.T) { assert.Equal(t, "", hintSubject) }) } + +func TestIDTokensValidForConn(t *testing.T) { + override := &RefreshTokenPolicy{} + s := &Server{ + idTokensValidFor: time.Hour, + connectorExpiryOverrides: map[string]ConnectorExpiryOverride{ + "shortlived": {IDTokensValidFor: 5 * time.Minute}, + "refreshonly": {RefreshTokenPolicy: override}, + }, + } + + assert.Equal(t, 5*time.Minute, s.idTokensValidForConn("shortlived"), + "per-connector override should win") + assert.Equal(t, time.Hour, s.idTokensValidForConn("refreshonly"), + "zero IDTokensValidFor should fall back to global") + assert.Equal(t, time.Hour, s.idTokensValidForConn("unknown"), + "missing entry should fall back to global") +} + +func TestRefreshTokenPolicyForConn(t *testing.T) { + global := &RefreshTokenPolicy{rotateRefreshTokens: true} + perConnector := &RefreshTokenPolicy{rotateRefreshTokens: false} + + s := &Server{ + refreshTokenPolicy: global, + connectorExpiryOverrides: map[string]ConnectorExpiryOverride{ + "custom": {RefreshTokenPolicy: perConnector}, + "idonly": {IDTokensValidFor: time.Minute}, + "nilpolicy": {}, + }, + } + + assert.Same(t, perConnector, s.refreshTokenPolicyForConn("custom"), + "per-connector override should win") + assert.Same(t, global, s.refreshTokenPolicyForConn("idonly"), + "nil per-connector policy should fall back to global") + assert.Same(t, global, s.refreshTokenPolicyForConn("nilpolicy"), + "empty override should fall back to global") + assert.Same(t, global, s.refreshTokenPolicyForConn("unknown"), + "missing entry should fall back to global") +} + +func TestIDTokensValidForConnNoOverrides(t *testing.T) { + s := &Server{idTokensValidFor: 42 * time.Minute} + assert.Equal(t, 42*time.Minute, s.idTokensValidForConn("any"), + "nil override map must not panic and must return global") +} + +// TestNewIDTokenUsesConnectorOverride verifies that newIDToken applies the +// per-connector idTokensValidFor override at issuance time, not the global. +func TestNewIDTokenUsesConnectorOverride(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + logger := slog.New(slog.DiscardHandler) + store := memory.New(logger) + + now := time.Now().UTC() + err := store.UpdateKeys(ctx, func(keys storage.Keys) (storage.Keys, error) { + keys.SigningKey = &jose.JSONWebKey{ + Key: testKey, + KeyID: "test-rs256", + Algorithm: string(jose.RS256), + Use: "sig", + } + keys.SigningKeyPub = &jose.JSONWebKey{ + Key: testKey.Public(), + KeyID: "test-rs256", + Algorithm: string(jose.RS256), + Use: "sig", + } + keys.NextRotation = now.Add(time.Hour) + return keys, nil + }) + require.NoError(t, err) + + localConfig := signer.LocalConfig{ + KeysRotationPeriod: time.Hour.String(), + Algorithm: jose.RS256, + } + sig, err := localConfig.Open(ctx, store, time.Hour, func() time.Time { return now }, logger) + require.NoError(t, err) + sig.Start(ctx) + + issuerURL, err := url.Parse("https://issuer.example.com") + require.NoError(t, err) + + s := &Server{ + signer: sig, + issuerURL: *issuerURL, + logger: logger, + now: func() time.Time { return now }, + idTokensValidFor: time.Hour, + connectorExpiryOverrides: map[string]ConnectorExpiryOverride{ + "short": {IDTokensValidFor: 5 * time.Minute}, + }, + } + + _, expiryShort, err := s.newIDToken(ctx, "client", + storage.Claims{UserID: "u1", Username: "alice"}, + []string{"openid"}, "n", "", "", "short", time.Time{}) + require.NoError(t, err) + assert.Equal(t, now.Add(5*time.Minute), expiryShort.UTC(), + "per-connector override must apply") + + _, expiryGlobal, err := s.newIDToken(ctx, "client", + storage.Claims{UserID: "u1", Username: "alice"}, + []string{"openid"}, "n", "", "", "unknown", time.Time{}) + require.NoError(t, err) + assert.Equal(t, now.Add(time.Hour), expiryGlobal.UTC(), + "unknown connector must fall back to global") +} diff --git a/server/server.go b/server/server.go index 582205a8c0..1599be6ad5 100644 --- a/server/server.go +++ b/server/server.go @@ -374,27 +374,27 @@ func newServer(ctx context.Context, c Config) (*Server, error) { } s := &Server{ - issuerURL: *issuerURL, - connectors: make(map[string]Connector), - storage: newKeyCacher(c.Storage, now), - supportedResponseTypes: supportedRes, - supportedGrantTypes: supportedGrants, - pkce: c.PKCE, + issuerURL: *issuerURL, + connectors: make(map[string]Connector), + storage: newKeyCacher(c.Storage, now), + supportedResponseTypes: supportedRes, + supportedGrantTypes: supportedGrants, + pkce: c.PKCE, idTokensValidFor: value(c.IDTokensValidFor, 24*time.Hour), authRequestsValidFor: value(c.AuthRequestsValidFor, 24*time.Hour), deviceRequestsValidFor: value(c.DeviceRequestsValidFor, 5*time.Minute), refreshTokenPolicy: c.RefreshTokenPolicy, connectorExpiryOverrides: c.ConnectorExpiryOverrides, - skipApproval: c.SkipApprovalScreen, - alwaysShowLogin: c.AlwaysShowLoginScreen, - now: now, - templates: tmpls, - passwordConnector: c.PasswordConnector, - logger: c.Logger, - signer: c.Signer, - sessionConfig: c.SessionConfig, - mfaProviders: c.MFAProviders, - defaultMFAChain: c.DefaultMFAChain, + skipApproval: c.SkipApprovalScreen, + alwaysShowLogin: c.AlwaysShowLoginScreen, + now: now, + templates: tmpls, + passwordConnector: c.PasswordConnector, + logger: c.Logger, + signer: c.Signer, + sessionConfig: c.SessionConfig, + mfaProviders: c.MFAProviders, + defaultMFAChain: c.DefaultMFAChain, } // Retrieves connector objects in backend storage. This list includes the static connectors From f8082759c290c22ea75adf207bca1b0f87daf0c1 Mon Sep 17 00:00:00 2001 From: Helio Machado <0x2b3bfa0+git@googlemail.com> Date: Mon, 11 May 2026 04:28:45 +0400 Subject: [PATCH 03/10] test: trim per-connector expiry test suite - Drop tests that rewrapped stdlib behavior (invalid duration parse), tautological cases (empty input), and wall-clock lifetime probes that really test RefreshTokenPolicy. - Keep only the validation-rule test and one consolidated case covering override/inherit for both idTokens and refresh tokens. - Remove the YAML roundtrip tests: they exercise struct tags, not our own unmarshaler logic. Signed-off-by: Helio Machado <0x2b3bfa0+git@googlemail.com> --- cmd/dex/config_test.go | 63 ------------------------- cmd/dex/serve_test.go | 104 ++++------------------------------------- server/oauth2_test.go | 6 --- 3 files changed, 8 insertions(+), 165 deletions(-) diff --git a/cmd/dex/config_test.go b/cmd/dex/config_test.go index 408710724d..2a08ab1eb5 100644 --- a/cmd/dex/config_test.go +++ b/cmd/dex/config_test.go @@ -675,66 +675,3 @@ enablePasswordDB: true }) } } - -func TestConnectorExpiryYAMLRoundtrip(t *testing.T) { - raw := []byte(` -type: mockCallback -id: mock -name: Mock -expiry: - idTokens: "10m" - refreshTokens: - absoluteLifetime: "8h" - validIfNotUsedFor: "2h" - disableRotation: true -`) - - jsonBytes, err := yaml.YAMLToJSON(raw) - if err != nil { - t.Fatalf("YAMLToJSON: %v", err) - } - - var c Connector - if err := json.Unmarshal(jsonBytes, &c); err != nil { - t.Fatalf("Unmarshal: %v", err) - } - - if c.Expiry == nil { - t.Fatal("expected non-nil Expiry") - } - if got, want := c.Expiry.IDTokens, "10m"; got != want { - t.Errorf("IDTokens = %q, want %q", got, want) - } - if c.Expiry.RefreshTokens == nil { - t.Fatal("expected non-nil RefreshTokens") - } - if got, want := c.Expiry.RefreshTokens.AbsoluteLifetime, "8h"; got != want { - t.Errorf("AbsoluteLifetime = %q, want %q", got, want) - } - if got, want := c.Expiry.RefreshTokens.ValidIfNotUsedFor, "2h"; got != want { - t.Errorf("ValidIfNotUsedFor = %q, want %q", got, want) - } - if c.Expiry.RefreshTokens.DisableRotation == nil || !*c.Expiry.RefreshTokens.DisableRotation { - t.Errorf("DisableRotation = %v, want *true", c.Expiry.RefreshTokens.DisableRotation) - } -} - -func TestConnectorNoExpiryYAMLLeavesNil(t *testing.T) { - raw := []byte(` -type: mockCallback -id: mock -name: Mock -`) - jsonBytes, err := yaml.YAMLToJSON(raw) - if err != nil { - t.Fatalf("YAMLToJSON: %v", err) - } - - var c Connector - if err := json.Unmarshal(jsonBytes, &c); err != nil { - t.Fatalf("Unmarshal: %v", err) - } - if c.Expiry != nil { - t.Errorf("Expiry = %+v, want nil", c.Expiry) - } -} diff --git a/cmd/dex/serve_test.go b/cmd/dex/serve_test.go index 1915861413..18aebaf4e4 100644 --- a/cmd/dex/serve_test.go +++ b/cmd/dex/serve_test.go @@ -30,39 +30,6 @@ func TestNewLogger(t *testing.T) { }) } -func TestBuildConnectorExpiryOverrides_NoConnectors(t *testing.T) { - overrides, err := buildConnectorExpiryOverrides( - slog.New(slog.DiscardHandler), nil, 24*time.Hour, RefreshToken{}, - ) - require.NoError(t, err) - assert.Empty(t, overrides) -} - -func TestBuildConnectorExpiryOverrides_NoExpiryField(t *testing.T) { - overrides, err := buildConnectorExpiryOverrides( - slog.New(slog.DiscardHandler), - []Connector{{ID: "c1", Type: "mock", Name: "c1"}}, - 24*time.Hour, RefreshToken{}, - ) - require.NoError(t, err) - assert.Empty(t, overrides, "connector without expiry field should not appear in override map") -} - -func TestBuildConnectorExpiryOverrides_IDTokens(t *testing.T) { - overrides, err := buildConnectorExpiryOverrides( - slog.New(slog.DiscardHandler), - []Connector{{ - ID: "c1", Type: "mock", Name: "c1", - Expiry: &ConnectorExpiry{IDTokens: "15m"}, - }}, - 24*time.Hour, RefreshToken{}, - ) - require.NoError(t, err) - require.Contains(t, overrides, "c1") - assert.Equal(t, 15*time.Minute, overrides["c1"].IDTokensValidFor) - assert.Nil(t, overrides["c1"].RefreshTokenPolicy) -} - func TestBuildConnectorExpiryOverrides_IDTokensExceedsGlobal(t *testing.T) { _, err := buildConnectorExpiryOverrides( slog.New(slog.DiscardHandler), @@ -76,62 +43,8 @@ func TestBuildConnectorExpiryOverrides_IDTokensExceedsGlobal(t *testing.T) { assert.Contains(t, err.Error(), "expiry.idTokens (48h0m0s) exceeds the global value (24h0m0s)") } -func TestBuildConnectorExpiryOverrides_IDTokensInvalidDuration(t *testing.T) { - _, err := buildConnectorExpiryOverrides( - slog.New(slog.DiscardHandler), - []Connector{{ - ID: "c1", Type: "mock", Name: "c1", - Expiry: &ConnectorExpiry{IDTokens: "not-a-duration"}, - }}, - 24*time.Hour, RefreshToken{}, - ) - require.Error(t, err) - assert.Contains(t, err.Error(), "parse expiry.idTokens") -} - -func TestBuildConnectorExpiryOverrides_RefreshTokensOverrideAndInherit(t *testing.T) { +func TestBuildConnectorExpiryOverrides(t *testing.T) { disable := true - global := RefreshToken{ - DisableRotation: false, - ReuseInterval: "3s", - AbsoluteLifetime: "100h", - ValidIfNotUsedFor: "24h", - } - overrides, err := buildConnectorExpiryOverrides( - slog.New(slog.DiscardHandler), - []Connector{{ - ID: "c1", Type: "mock", Name: "c1", - Expiry: &ConnectorExpiry{ - RefreshTokens: &ConnectorRefreshToken{ - DisableRotation: &disable, - AbsoluteLifetime: "10h", - // ReuseInterval and ValidIfNotUsedFor omitted: inherit from global - }, - }, - }}, - 24*time.Hour, global, - ) - require.NoError(t, err) - require.Contains(t, overrides, "c1") - - policy := overrides["c1"].RefreshTokenPolicy - require.NotNil(t, policy) - assert.False(t, policy.RotationEnabled(), "DisableRotation=true should disable rotation") - - // Probe the numeric fields via the boundary-crossing behavior of the policy methods, - // using time.Now offsets since the policy's internal clock uses time.Now. - now := time.Now() - assert.False(t, policy.CompletelyExpired(now.Add(-9*time.Hour)), "9h < absoluteLifetime=10h") - assert.True(t, policy.CompletelyExpired(now.Add(-11*time.Hour)), "11h > absoluteLifetime=10h") - - assert.False(t, policy.ExpiredBecauseUnused(now.Add(-23*time.Hour)), "23h < validIfNotUsedFor=24h") - assert.True(t, policy.ExpiredBecauseUnused(now.Add(-25*time.Hour)), "25h > validIfNotUsedFor=24h") - - assert.True(t, policy.AllowedToReuse(now.Add(-2*time.Second)), "2s < reuseInterval=3s") - assert.False(t, policy.AllowedToReuse(now.Add(-4*time.Second)), "4s > reuseInterval=3s") -} - -func TestBuildConnectorExpiryOverrides_MultipleConnectors(t *testing.T) { overrides, err := buildConnectorExpiryOverrides( slog.New(slog.DiscardHandler), []Connector{ @@ -143,20 +56,19 @@ func TestBuildConnectorExpiryOverrides_MultipleConnectors(t *testing.T) { { ID: "c", Type: "mock", Name: "c", Expiry: &ConnectorExpiry{ - RefreshTokens: &ConnectorRefreshToken{AbsoluteLifetime: "12h"}, + RefreshTokens: &ConnectorRefreshToken{DisableRotation: &disable}, }, }, }, - 24*time.Hour, RefreshToken{AbsoluteLifetime: "48h"}, + 24*time.Hour, + RefreshToken{AbsoluteLifetime: "48h"}, ) require.NoError(t, err) - assert.NotContains(t, overrides, "a") + + assert.NotContains(t, overrides, "a", "connector without expiry field should not appear") assert.Equal(t, 30*time.Minute, overrides["b"].IDTokensValidFor) - require.NotNil(t, overrides["c"].RefreshTokenPolicy) - // Confirm 12h absoluteLifetime took effect. - now := time.Now() policy := overrides["c"].RefreshTokenPolicy - assert.False(t, policy.CompletelyExpired(now.Add(-11*time.Hour))) - assert.True(t, policy.CompletelyExpired(now.Add(-13*time.Hour))) + require.NotNil(t, policy) + assert.False(t, policy.RotationEnabled(), "per-connector DisableRotation=true should disable rotation") } diff --git a/server/oauth2_test.go b/server/oauth2_test.go index 52bf65959d..050cf03969 100644 --- a/server/oauth2_test.go +++ b/server/oauth2_test.go @@ -1311,12 +1311,6 @@ func TestRefreshTokenPolicyForConn(t *testing.T) { "missing entry should fall back to global") } -func TestIDTokensValidForConnNoOverrides(t *testing.T) { - s := &Server{idTokensValidFor: 42 * time.Minute} - assert.Equal(t, 42*time.Minute, s.idTokensValidForConn("any"), - "nil override map must not panic and must return global") -} - // TestNewIDTokenUsesConnectorOverride verifies that newIDToken applies the // per-connector idTokensValidFor override at issuance time, not the global. func TestNewIDTokenUsesConnectorOverride(t *testing.T) { From 0b26d1c7031bfca4e907a0b0ab7d67d8fc72663d Mon Sep 17 00:00:00 2001 From: Helio Machado <0x2b3bfa0+git@googlemail.com> Date: Mon, 11 May 2026 04:40:58 +0400 Subject: [PATCH 04/10] refactor: simplify per-connector expiry helpers - Reuse the existing server.value() helper in idTokensValidForConn instead of a hand-rolled zero-check. - Drop the duplicate per-connector refresh-token summary log; thread the connector_id into NewRefreshTokenPolicy via logger.With so the existing per-field logs carry it. - Trim WHAT-only docstrings and tighten the rationale on ConnectorRefreshToken and buildConnectorExpiryOverrides. Signed-off-by: Helio Machado <0x2b3bfa0+git@googlemail.com> --- cmd/dex/config.go | 14 ++++---------- cmd/dex/serve.go | 18 +++++------------- server/oauth2.go | 13 +++---------- server/oauth2_test.go | 2 -- server/server.go | 3 +-- 5 files changed, 13 insertions(+), 37 deletions(-) diff --git a/cmd/dex/config.go b/cmd/dex/config.go index 45fd9076ff..4c436e8a9f 100644 --- a/cmd/dex/config.go +++ b/cmd/dex/config.go @@ -566,20 +566,14 @@ type Connector struct { Expiry *ConnectorExpiry `json:"expiry,omitempty"` } -// ConnectorExpiry holds per-connector overrides for token lifetimes. type ConnectorExpiry struct { - // IDTokens overrides expiry.idTokens for tokens issued through this connector. - IDTokens string `json:"idTokens"` - - // RefreshTokens overrides expiry.refreshTokens for sessions established - // through this connector. Any field left unset falls back to the global - // refresh token policy. + IDTokens string `json:"idTokens"` RefreshTokens *ConnectorRefreshToken `json:"refreshTokens"` } -// ConnectorRefreshToken holds per-connector refresh token policy overrides. -// Fields mirror the top-level RefreshToken struct; a nil pointer on DisableRotation -// signals "inherit the global value", while the other string fields inherit when empty. +// ConnectorRefreshToken mirrors RefreshToken but uses a pointer for +// DisableRotation so that "unset" can be distinguished from "false", +// allowing the field to inherit the global value when nil. type ConnectorRefreshToken struct { DisableRotation *bool `json:"disableRotation"` ReuseInterval string `json:"reuseInterval"` diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index 63d2f1397d..1b1c351003 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -841,11 +841,9 @@ func parseSessionConfig(s *Sessions) (*server.SessionConfig, error) { } // buildConnectorExpiryOverrides parses per-connector `expiry` overrides. -// Per-connector `idTokens` must not exceed the global `expiry.idTokens` value, -// since the signer's key retention window is sized from it. Refresh token -// fields have no such constraint: they are validated against storage, not -// against signing keys. Connectors without an override are omitted from the -// returned map and inherit the global policy at runtime. +// Per-connector `idTokens` must not exceed the global value, since the signer's +// key retention window is sized from it. Refresh-token fields have no such +// constraint: they are validated against storage, not against signing keys. func buildConnectorExpiryOverrides( logger *slog.Logger, connectors []Connector, @@ -910,18 +908,12 @@ func parseConnectorExpiry( reuseInterval = globalRefresh.ReuseInterval } - policy, err := server.NewRefreshTokenPolicy(logger, disableRotation, validIfNotUsedFor, absoluteLifetime, reuseInterval) + connLogger := logger.With("connector_id", conn.ID) + policy, err := server.NewRefreshTokenPolicy(connLogger, disableRotation, validIfNotUsedFor, absoluteLifetime, reuseInterval) if err != nil { return override, fmt.Errorf("refresh token policy: %v", err) } override.RefreshTokenPolicy = policy - logger.Info("config connector refresh tokens", - "connector_id", conn.ID, - "valid_if_not_used_for", validIfNotUsedFor, - "absolute_lifetime", absoluteLifetime, - "reuse_interval", reuseInterval, - "rotation_enabled", !disableRotation, - ) return override, nil } diff --git a/server/oauth2.go b/server/oauth2.go index cf8d15d0c6..6d3681c88e 100644 --- a/server/oauth2.go +++ b/server/oauth2.go @@ -343,20 +343,13 @@ func genSubject(userID string, connID string) (string, error) { return internal.Marshal(sub) } -// idTokensValidForConn returns the ID token lifetime for the given connector, -// falling back to the server-wide value when no per-connector override is set. func (s *Server) idTokensValidForConn(connID string) time.Duration { - if o, ok := s.connectorExpiryOverrides[connID]; ok && o.IDTokensValidFor > 0 { - return o.IDTokensValidFor - } - return s.idTokensValidFor + return value(s.connectorExpiryOverrides[connID].IDTokensValidFor, s.idTokensValidFor) } -// refreshTokenPolicyForConn returns the refresh token policy for the given -// connector, falling back to the server-wide policy when no override is set. func (s *Server) refreshTokenPolicyForConn(connID string) *RefreshTokenPolicy { - if o, ok := s.connectorExpiryOverrides[connID]; ok && o.RefreshTokenPolicy != nil { - return o.RefreshTokenPolicy + if p := s.connectorExpiryOverrides[connID].RefreshTokenPolicy; p != nil { + return p } return s.refreshTokenPolicy } diff --git a/server/oauth2_test.go b/server/oauth2_test.go index 050cf03969..0050493509 100644 --- a/server/oauth2_test.go +++ b/server/oauth2_test.go @@ -1311,8 +1311,6 @@ func TestRefreshTokenPolicyForConn(t *testing.T) { "missing entry should fall back to global") } -// TestNewIDTokenUsesConnectorOverride verifies that newIDToken applies the -// per-connector idTokensValidFor override at issuance time, not the global. func TestNewIDTokenUsesConnectorOverride(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/server/server.go b/server/server.go index 1599be6ad5..03b9c33423 100644 --- a/server/server.go +++ b/server/server.go @@ -213,7 +213,7 @@ func value(val, defaultValue time.Duration) time.Duration { } // ConnectorExpiryOverride carries per-connector token lifetime overrides. -// A zero IDTokensValidFor or nil RefreshTokenPolicy inherits the global value. +// A zero or nil field inherits the global value. type ConnectorExpiryOverride struct { IDTokensValidFor time.Duration RefreshTokenPolicy *RefreshTokenPolicy @@ -257,7 +257,6 @@ type Server struct { refreshTokenPolicy *RefreshTokenPolicy - // connectorExpiryOverrides holds per-connector overrides for token lifetimes. connectorExpiryOverrides map[string]ConnectorExpiryOverride logger *slog.Logger From e86013021bd590287bf53273244f0e5adba4bb00 Mon Sep 17 00:00:00 2001 From: Helio Machado <0x2b3bfa0+git@googlemail.com> Date: Tue, 12 May 2026 00:35:37 +0400 Subject: [PATCH 05/10] refactor: enforce strict hierarchy for per-connector expiry overrides Apply the invariant "per-connector value must not exceed the global value" to every time-based refresh-token field, not just idTokens. A global value left unset means "no ceiling", so an override is accepted. disableRotation stays exempt: it's a policy toggle rather than a quantity, so "stricter" has no natural direction. Rejecting violations at config load rather than silently clamping at issuance keeps operator intent auditable: if an operator later drops the global below a persisted per-connector value, the next startup fails with a clear error. The same rule extends to future per-client overrides with the connector (or global, if unset) as their ceiling. Globals are parsed once into a small refreshCeilings struct and the per-field checks run through a table, so adding a new ceiling-bound field is one line in two places. Signed-off-by: Helio Machado <0x2b3bfa0+git@googlemail.com> --- cmd/dex/serve.go | 83 +++++++++++++++++++++++++++++++++++++------ cmd/dex/serve_test.go | 30 ++++++++++++++++ config.yaml.dist | 5 +-- 3 files changed, 106 insertions(+), 12 deletions(-) diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index 1b1c351003..efb90ce0da 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -840,23 +840,29 @@ func parseSessionConfig(s *Sessions) (*server.SessionConfig, error) { return sc, nil } -// buildConnectorExpiryOverrides parses per-connector `expiry` overrides. -// Per-connector `idTokens` must not exceed the global value, since the signer's -// key retention window is sized from it. Refresh-token fields have no such -// constraint: they are validated against storage, not against signing keys. +// buildConnectorExpiryOverrides parses per-connector `expiry` overrides, +// rejecting any override that would loosen the corresponding global value. +// A global value left unset is treated as "no ceiling". `disableRotation` is +// exempt: it's a policy toggle rather than a quantity, so per-connector values +// are accepted either way. func buildConnectorExpiryOverrides( logger *slog.Logger, connectors []Connector, globalIDTokens time.Duration, globalRefresh RefreshToken, ) (map[string]server.ConnectorExpiryOverride, error) { + refreshCeilings, err := parseRefreshCeilings(globalRefresh) + if err != nil { + return nil, err + } + overrides := map[string]server.ConnectorExpiryOverride{} for _, conn := range connectors { if conn.Expiry == nil { continue } - override, err := parseConnectorExpiry(logger, conn, globalIDTokens, globalRefresh) + override, err := parseConnectorExpiry(logger, conn, globalIDTokens, globalRefresh, refreshCeilings) if err != nil { return nil, fmt.Errorf("connector %q: %v", conn.ID, err) } @@ -866,21 +872,50 @@ func buildConnectorExpiryOverrides( return overrides, nil } +// refreshCeilings holds the parsed global refresh-token ceilings. A zero value +// means "no ceiling" for that field. +type refreshCeilings struct { + absoluteLifetime time.Duration + validIfNotUsedFor time.Duration + reuseInterval time.Duration +} + +func parseRefreshCeilings(g RefreshToken) (refreshCeilings, error) { + var c refreshCeilings + for _, f := range []struct { + name string + value string + dst *time.Duration + }{ + {"expiry.refreshTokens.absoluteLifetime", g.AbsoluteLifetime, &c.absoluteLifetime}, + {"expiry.refreshTokens.validIfNotUsedFor", g.ValidIfNotUsedFor, &c.validIfNotUsedFor}, + {"expiry.refreshTokens.reuseInterval", g.ReuseInterval, &c.reuseInterval}, + } { + if f.value == "" { + continue + } + d, err := time.ParseDuration(f.value) + if err != nil { + return c, fmt.Errorf("parse %s: %v", f.name, err) + } + *f.dst = d + } + return c, nil +} + func parseConnectorExpiry( logger *slog.Logger, conn Connector, globalIDTokens time.Duration, globalRefresh RefreshToken, + ceilings refreshCeilings, ) (server.ConnectorExpiryOverride, error) { var override server.ConnectorExpiryOverride if conn.Expiry.IDTokens != "" { - d, err := time.ParseDuration(conn.Expiry.IDTokens) + d, err := parseWithCeiling("expiry.idTokens", conn.Expiry.IDTokens, globalIDTokens) if err != nil { - return override, fmt.Errorf("parse expiry.idTokens: %v", err) - } - if d > globalIDTokens { - return override, fmt.Errorf("expiry.idTokens (%s) exceeds the global value (%s)", d, globalIDTokens) + return override, err } override.IDTokensValidFor = d logger.Info("config connector id tokens", "connector_id", conn.ID, "valid_for", d) @@ -891,6 +926,23 @@ func parseConnectorExpiry( return override, nil } + for _, f := range []struct { + name string + value string + ceiling time.Duration + }{ + {"expiry.refreshTokens.absoluteLifetime", rt.AbsoluteLifetime, ceilings.absoluteLifetime}, + {"expiry.refreshTokens.validIfNotUsedFor", rt.ValidIfNotUsedFor, ceilings.validIfNotUsedFor}, + {"expiry.refreshTokens.reuseInterval", rt.ReuseInterval, ceilings.reuseInterval}, + } { + if f.value == "" || f.ceiling == 0 { + continue + } + if _, err := parseWithCeiling(f.name, f.value, f.ceiling); err != nil { + return override, err + } + } + disableRotation := globalRefresh.DisableRotation if rt.DisableRotation != nil { disableRotation = *rt.DisableRotation @@ -917,6 +969,17 @@ func parseConnectorExpiry( return override, nil } +func parseWithCeiling(field, value string, ceiling time.Duration) (time.Duration, error) { + d, err := time.ParseDuration(value) + if err != nil { + return 0, fmt.Errorf("parse %s: %v", field, err) + } + if d > ceiling { + return 0, fmt.Errorf("%s (%s) exceeds the global value (%s)", field, d, ceiling) + } + return d, nil +} + func buildMFAProviders(authenticators []MFAAuthenticator, issuerURL string, logger *slog.Logger) map[string]server.MFAProvider { if len(authenticators) == 0 { return nil diff --git a/cmd/dex/serve_test.go b/cmd/dex/serve_test.go index 18aebaf4e4..37f4aca72e 100644 --- a/cmd/dex/serve_test.go +++ b/cmd/dex/serve_test.go @@ -43,6 +43,36 @@ func TestBuildConnectorExpiryOverrides_IDTokensExceedsGlobal(t *testing.T) { assert.Contains(t, err.Error(), "expiry.idTokens (48h0m0s) exceeds the global value (24h0m0s)") } +func TestBuildConnectorExpiryOverrides_RefreshAbsoluteLifetimeExceedsGlobal(t *testing.T) { + _, err := buildConnectorExpiryOverrides( + slog.New(slog.DiscardHandler), + []Connector{{ + ID: "c1", Type: "mock", Name: "c1", + Expiry: &ConnectorExpiry{ + RefreshTokens: &ConnectorRefreshToken{AbsoluteLifetime: "100h"}, + }, + }}, + 24*time.Hour, RefreshToken{AbsoluteLifetime: "48h"}, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "expiry.refreshTokens.absoluteLifetime (100h0m0s) exceeds the global value (48h0m0s)") +} + +func TestBuildConnectorExpiryOverrides_RefreshNoGlobalCeiling(t *testing.T) { + // Global absoluteLifetime unset ("disabled/infinite"); any override passes. + _, err := buildConnectorExpiryOverrides( + slog.New(slog.DiscardHandler), + []Connector{{ + ID: "c1", Type: "mock", Name: "c1", + Expiry: &ConnectorExpiry{ + RefreshTokens: &ConnectorRefreshToken{AbsoluteLifetime: "9000h"}, + }, + }}, + 24*time.Hour, RefreshToken{}, + ) + require.NoError(t, err) +} + func TestBuildConnectorExpiryOverrides(t *testing.T) { disable := true overrides, err := buildConnectorExpiryOverrides( diff --git a/config.yaml.dist b/config.yaml.dist index 5815add5ad..6d06ccbb7d 100644 --- a/config.yaml.dist +++ b/config.yaml.dist @@ -196,8 +196,9 @@ web: # connectors: [] # # Per-connector expiry overrides. Any field left unset inherits the top-level -# `expiry` configuration. Per-connector `idTokens` must not exceed the global -# `expiry.idTokens`, since the signer's key retention window is sized from it. +# `expiry` configuration. Overrides must be at least as strict as the global +# value (i.e. shorter or equal); a value exceeding its global counterpart is +# rejected at config load. Global values left unset impose no ceiling. # connectors: # - type: oidc # id: partner From 468f8eff53e8ddfde5fd3aa17c344229cec67e62 Mon Sep 17 00:00:00 2001 From: Helio Machado <0x2b3bfa0+git@googlemail.com> Date: Wed, 13 May 2026 03:43:44 +0400 Subject: [PATCH 06/10] feat(api): per-connector expiry support in storage and gRPC API Persist per-connector expiry overrides in the storage layer (via a new Expiry field on storage.Connector) so the rule "per-connector value must not exceed the global value" can be enforced at every write path, not just static config load: - Static YAML connectors: validated when loaded into storage and re-validated when the server reads them at startup. Invalid values refuse to start dex. - gRPC CreateConnector / UpdateConnector: validate against the current global ceilings before persisting. Rejected writes leave both the storage and the in-memory override map untouched. - gRPC DeleteConnector: drops the matching in-memory override. - Restart with a stale persisted override (global lowered below it): startup re-validation surfaces the conflict as an error. The validation rule and override-resolution logic live in a new server/expiry.go and are exercised by every code path that can change a connector's expiry, so the hierarchy is preserved without any silent clamping. disableRotation remains exempt: it is a policy toggle, not a quantity, so "stricter" has no natural direction. Storage backends: - storage.Connector gains an optional Expiry field with JSON tags. - SQL: new nullable expiry column + migration. - ent: typed JSON field, regenerated code. - kubernetes / etcd / memory pick up the change through the existing JSON-based serialization. API surface: - proto: ConnectorExpiry / ConnectorRefreshExpiry / ConnectorExpiryUpdate messages, with optional bool DisableRotation for the inherit-vs-override distinction. - ConnectorExpiryUpdate uses the standard "absent leaves alone, present with nil Value clears, present with Value sets" wrapper. Conformance tests now roundtrip Expiry across every backend. Signed-off-by: Helio Machado <0x2b3bfa0+git@googlemail.com> --- api/v2/api.pb.go | 960 ++++++++++++++++---------- api/v2/api.proto | 31 + cmd/dex/config.go | 20 + cmd/dex/serve.go | 144 +--- cmd/dex/serve_test.go | 101 ++- server/api.go | 97 ++- server/api_test.go | 76 +- server/expiry.go | 132 ++++ server/expiry_test.go | 131 ++++ server/oauth2.go | 12 +- server/server.go | 62 +- storage/conformance/conformance.go | 20 + storage/ent/client/connector.go | 22 +- storage/ent/client/types.go | 1 + storage/ent/db/connector.go | 18 +- storage/ent/db/connector/connector.go | 3 + storage/ent/db/connector/where.go | 10 + storage/ent/db/connector_create.go | 11 + storage/ent/db/connector_update.go | 37 + storage/ent/db/migrate/schema.go | 1 + storage/ent/db/mutation.go | 75 +- storage/ent/schema/connector.go | 4 + storage/sql/crud.go | 47 +- storage/sql/migrate.go | 5 + storage/storage.go | 24 + 25 files changed, 1460 insertions(+), 584 deletions(-) create mode 100644 server/expiry.go create mode 100644 server/expiry_test.go diff --git a/api/v2/api.pb.go b/api/v2/api.pb.go index d7f7f16c2e..1a0d2dd4dd 100644 --- a/api/v2/api.pb.go +++ b/api/v2/api.pb.go @@ -1172,12 +1172,15 @@ func (x *ListPasswordResp) GetPasswords() []*Password { // Connector is a strategy used by Dex for authenticating a user against another identity provider type Connector struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` - Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` - Config []byte `protobuf:"bytes,4,opt,name=config,proto3" json:"config,omitempty"` - GrantTypes []string `protobuf:"bytes,5,rep,name=grant_types,json=grantTypes,proto3" json:"grant_types,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + Config []byte `protobuf:"bytes,4,opt,name=config,proto3" json:"config,omitempty"` + GrantTypes []string `protobuf:"bytes,5,rep,name=grant_types,json=grantTypes,proto3" json:"grant_types,omitempty"` + // Per-connector expiry overrides. Each field must be at least as strict + // as the corresponding global value or the write will be rejected. + Expiry *ConnectorExpiry `protobuf:"bytes,6,opt,name=expiry,proto3" json:"expiry,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1247,6 +1250,186 @@ func (x *Connector) GetGrantTypes() []string { return nil } +func (x *Connector) GetExpiry() *ConnectorExpiry { + if x != nil { + return x.Expiry + } + return nil +} + +// ConnectorExpiry holds per-connector overrides for token lifetimes. +// Duration strings use the same format as the top-level expiry config +// ("5m", "24h"). Empty strings mean "inherit the global value". +type ConnectorExpiry struct { + state protoimpl.MessageState `protogen:"open.v1"` + IdTokens string `protobuf:"bytes,1,opt,name=id_tokens,json=idTokens,proto3" json:"id_tokens,omitempty"` + RefreshTokens *ConnectorRefreshExpiry `protobuf:"bytes,2,opt,name=refresh_tokens,json=refreshTokens,proto3" json:"refresh_tokens,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConnectorExpiry) Reset() { + *x = ConnectorExpiry{} + mi := &file_api_v2_api_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConnectorExpiry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConnectorExpiry) ProtoMessage() {} + +func (x *ConnectorExpiry) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConnectorExpiry.ProtoReflect.Descriptor instead. +func (*ConnectorExpiry) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{22} +} + +func (x *ConnectorExpiry) GetIdTokens() string { + if x != nil { + return x.IdTokens + } + return "" +} + +func (x *ConnectorExpiry) GetRefreshTokens() *ConnectorRefreshExpiry { + if x != nil { + return x.RefreshTokens + } + return nil +} + +// ConnectorRefreshExpiry holds per-connector refresh-token policy overrides. +type ConnectorRefreshExpiry struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Absent means "inherit"; present (true/false) overrides the global value. + DisableRotation *bool `protobuf:"varint,1,opt,name=disable_rotation,json=disableRotation,proto3,oneof" json:"disable_rotation,omitempty"` + ReuseInterval string `protobuf:"bytes,2,opt,name=reuse_interval,json=reuseInterval,proto3" json:"reuse_interval,omitempty"` + AbsoluteLifetime string `protobuf:"bytes,3,opt,name=absolute_lifetime,json=absoluteLifetime,proto3" json:"absolute_lifetime,omitempty"` + ValidIfNotUsedFor string `protobuf:"bytes,4,opt,name=valid_if_not_used_for,json=validIfNotUsedFor,proto3" json:"valid_if_not_used_for,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConnectorRefreshExpiry) Reset() { + *x = ConnectorRefreshExpiry{} + mi := &file_api_v2_api_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConnectorRefreshExpiry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConnectorRefreshExpiry) ProtoMessage() {} + +func (x *ConnectorRefreshExpiry) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConnectorRefreshExpiry.ProtoReflect.Descriptor instead. +func (*ConnectorRefreshExpiry) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{23} +} + +func (x *ConnectorRefreshExpiry) GetDisableRotation() bool { + if x != nil && x.DisableRotation != nil { + return *x.DisableRotation + } + return false +} + +func (x *ConnectorRefreshExpiry) GetReuseInterval() string { + if x != nil { + return x.ReuseInterval + } + return "" +} + +func (x *ConnectorRefreshExpiry) GetAbsoluteLifetime() string { + if x != nil { + return x.AbsoluteLifetime + } + return "" +} + +func (x *ConnectorRefreshExpiry) GetValidIfNotUsedFor() string { + if x != nil { + return x.ValidIfNotUsedFor + } + return "" +} + +// ConnectorExpiryUpdate distinguishes "leave the override alone" from +// "change it". An absent ConnectorExpiryUpdate leaves the existing value +// in place; a present one with nil Value clears the override; a present +// one with a non-nil Value installs that override. +type ConnectorExpiryUpdate struct { + state protoimpl.MessageState `protogen:"open.v1"` + Value *ConnectorExpiry `protobuf:"bytes,1,opt,name=value,proto3" json:"value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConnectorExpiryUpdate) Reset() { + *x = ConnectorExpiryUpdate{} + mi := &file_api_v2_api_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConnectorExpiryUpdate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConnectorExpiryUpdate) ProtoMessage() {} + +func (x *ConnectorExpiryUpdate) ProtoReflect() protoreflect.Message { + mi := &file_api_v2_api_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConnectorExpiryUpdate.ProtoReflect.Descriptor instead. +func (*ConnectorExpiryUpdate) Descriptor() ([]byte, []int) { + return file_api_v2_api_proto_rawDescGZIP(), []int{24} +} + +func (x *ConnectorExpiryUpdate) GetValue() *ConnectorExpiry { + if x != nil { + return x.Value + } + return nil +} + // CreateConnectorReq is a request to make a connector. type CreateConnectorReq struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1257,7 +1440,7 @@ type CreateConnectorReq struct { func (x *CreateConnectorReq) Reset() { *x = CreateConnectorReq{} - mi := &file_api_v2_api_proto_msgTypes[22] + mi := &file_api_v2_api_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1269,7 +1452,7 @@ func (x *CreateConnectorReq) String() string { func (*CreateConnectorReq) ProtoMessage() {} func (x *CreateConnectorReq) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[22] + mi := &file_api_v2_api_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1282,7 +1465,7 @@ func (x *CreateConnectorReq) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateConnectorReq.ProtoReflect.Descriptor instead. func (*CreateConnectorReq) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{22} + return file_api_v2_api_proto_rawDescGZIP(), []int{25} } func (x *CreateConnectorReq) GetConnector() *Connector { @@ -1302,7 +1485,7 @@ type CreateConnectorResp struct { func (x *CreateConnectorResp) Reset() { *x = CreateConnectorResp{} - mi := &file_api_v2_api_proto_msgTypes[23] + mi := &file_api_v2_api_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1314,7 +1497,7 @@ func (x *CreateConnectorResp) String() string { func (*CreateConnectorResp) ProtoMessage() {} func (x *CreateConnectorResp) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[23] + mi := &file_api_v2_api_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1327,7 +1510,7 @@ func (x *CreateConnectorResp) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateConnectorResp.ProtoReflect.Descriptor instead. func (*CreateConnectorResp) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{23} + return file_api_v2_api_proto_rawDescGZIP(), []int{26} } func (x *CreateConnectorResp) GetAlreadyExists() bool { @@ -1348,7 +1531,7 @@ type GrantTypes struct { func (x *GrantTypes) Reset() { *x = GrantTypes{} - mi := &file_api_v2_api_proto_msgTypes[24] + mi := &file_api_v2_api_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1360,7 +1543,7 @@ func (x *GrantTypes) String() string { func (*GrantTypes) ProtoMessage() {} func (x *GrantTypes) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[24] + mi := &file_api_v2_api_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1373,7 +1556,7 @@ func (x *GrantTypes) ProtoReflect() protoreflect.Message { // Deprecated: Use GrantTypes.ProtoReflect.Descriptor instead. func (*GrantTypes) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{24} + return file_api_v2_api_proto_rawDescGZIP(), []int{27} } func (x *GrantTypes) GetGrantTypes() []string { @@ -1395,13 +1578,16 @@ type UpdateConnectorReq struct { // An empty grant_types list means unrestricted (all grant types allowed). // If not set (null), grant types are not modified. NewGrantTypes *GrantTypes `protobuf:"bytes,5,opt,name=new_grant_types,json=newGrantTypes,proto3" json:"new_grant_types,omitempty"` + // If set, updates the connector's expiry overrides. If unset, the + // existing expiry is left untouched. + NewExpiry *ConnectorExpiryUpdate `protobuf:"bytes,6,opt,name=new_expiry,json=newExpiry,proto3" json:"new_expiry,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *UpdateConnectorReq) Reset() { *x = UpdateConnectorReq{} - mi := &file_api_v2_api_proto_msgTypes[25] + mi := &file_api_v2_api_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1413,7 +1599,7 @@ func (x *UpdateConnectorReq) String() string { func (*UpdateConnectorReq) ProtoMessage() {} func (x *UpdateConnectorReq) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[25] + mi := &file_api_v2_api_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1426,7 +1612,7 @@ func (x *UpdateConnectorReq) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateConnectorReq.ProtoReflect.Descriptor instead. func (*UpdateConnectorReq) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{25} + return file_api_v2_api_proto_rawDescGZIP(), []int{28} } func (x *UpdateConnectorReq) GetId() string { @@ -1464,6 +1650,13 @@ func (x *UpdateConnectorReq) GetNewGrantTypes() *GrantTypes { return nil } +func (x *UpdateConnectorReq) GetNewExpiry() *ConnectorExpiryUpdate { + if x != nil { + return x.NewExpiry + } + return nil +} + // UpdateConnectorResp returns the response from modifying an existing connector. type UpdateConnectorResp struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1474,7 +1667,7 @@ type UpdateConnectorResp struct { func (x *UpdateConnectorResp) Reset() { *x = UpdateConnectorResp{} - mi := &file_api_v2_api_proto_msgTypes[26] + mi := &file_api_v2_api_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1486,7 +1679,7 @@ func (x *UpdateConnectorResp) String() string { func (*UpdateConnectorResp) ProtoMessage() {} func (x *UpdateConnectorResp) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[26] + mi := &file_api_v2_api_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1499,7 +1692,7 @@ func (x *UpdateConnectorResp) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateConnectorResp.ProtoReflect.Descriptor instead. func (*UpdateConnectorResp) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{26} + return file_api_v2_api_proto_rawDescGZIP(), []int{29} } func (x *UpdateConnectorResp) GetNotFound() bool { @@ -1519,7 +1712,7 @@ type DeleteConnectorReq struct { func (x *DeleteConnectorReq) Reset() { *x = DeleteConnectorReq{} - mi := &file_api_v2_api_proto_msgTypes[27] + mi := &file_api_v2_api_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1531,7 +1724,7 @@ func (x *DeleteConnectorReq) String() string { func (*DeleteConnectorReq) ProtoMessage() {} func (x *DeleteConnectorReq) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[27] + mi := &file_api_v2_api_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1544,7 +1737,7 @@ func (x *DeleteConnectorReq) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteConnectorReq.ProtoReflect.Descriptor instead. func (*DeleteConnectorReq) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{27} + return file_api_v2_api_proto_rawDescGZIP(), []int{30} } func (x *DeleteConnectorReq) GetId() string { @@ -1564,7 +1757,7 @@ type DeleteConnectorResp struct { func (x *DeleteConnectorResp) Reset() { *x = DeleteConnectorResp{} - mi := &file_api_v2_api_proto_msgTypes[28] + mi := &file_api_v2_api_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1576,7 +1769,7 @@ func (x *DeleteConnectorResp) String() string { func (*DeleteConnectorResp) ProtoMessage() {} func (x *DeleteConnectorResp) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[28] + mi := &file_api_v2_api_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1589,7 +1782,7 @@ func (x *DeleteConnectorResp) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteConnectorResp.ProtoReflect.Descriptor instead. func (*DeleteConnectorResp) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{28} + return file_api_v2_api_proto_rawDescGZIP(), []int{31} } func (x *DeleteConnectorResp) GetNotFound() bool { @@ -1608,7 +1801,7 @@ type ListConnectorReq struct { func (x *ListConnectorReq) Reset() { *x = ListConnectorReq{} - mi := &file_api_v2_api_proto_msgTypes[29] + mi := &file_api_v2_api_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1620,7 +1813,7 @@ func (x *ListConnectorReq) String() string { func (*ListConnectorReq) ProtoMessage() {} func (x *ListConnectorReq) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[29] + mi := &file_api_v2_api_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1633,7 +1826,7 @@ func (x *ListConnectorReq) ProtoReflect() protoreflect.Message { // Deprecated: Use ListConnectorReq.ProtoReflect.Descriptor instead. func (*ListConnectorReq) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{29} + return file_api_v2_api_proto_rawDescGZIP(), []int{32} } // ListConnectorResp returns a list of connectors. @@ -1646,7 +1839,7 @@ type ListConnectorResp struct { func (x *ListConnectorResp) Reset() { *x = ListConnectorResp{} - mi := &file_api_v2_api_proto_msgTypes[30] + mi := &file_api_v2_api_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1658,7 +1851,7 @@ func (x *ListConnectorResp) String() string { func (*ListConnectorResp) ProtoMessage() {} func (x *ListConnectorResp) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[30] + mi := &file_api_v2_api_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1671,7 +1864,7 @@ func (x *ListConnectorResp) ProtoReflect() protoreflect.Message { // Deprecated: Use ListConnectorResp.ProtoReflect.Descriptor instead. func (*ListConnectorResp) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{30} + return file_api_v2_api_proto_rawDescGZIP(), []int{33} } func (x *ListConnectorResp) GetConnectors() []*Connector { @@ -1690,7 +1883,7 @@ type VersionReq struct { func (x *VersionReq) Reset() { *x = VersionReq{} - mi := &file_api_v2_api_proto_msgTypes[31] + mi := &file_api_v2_api_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1702,7 +1895,7 @@ func (x *VersionReq) String() string { func (*VersionReq) ProtoMessage() {} func (x *VersionReq) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[31] + mi := &file_api_v2_api_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1715,7 +1908,7 @@ func (x *VersionReq) ProtoReflect() protoreflect.Message { // Deprecated: Use VersionReq.ProtoReflect.Descriptor instead. func (*VersionReq) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{31} + return file_api_v2_api_proto_rawDescGZIP(), []int{34} } // VersionResp holds the version info of components. @@ -1732,7 +1925,7 @@ type VersionResp struct { func (x *VersionResp) Reset() { *x = VersionResp{} - mi := &file_api_v2_api_proto_msgTypes[32] + mi := &file_api_v2_api_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1744,7 +1937,7 @@ func (x *VersionResp) String() string { func (*VersionResp) ProtoMessage() {} func (x *VersionResp) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[32] + mi := &file_api_v2_api_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1757,7 +1950,7 @@ func (x *VersionResp) ProtoReflect() protoreflect.Message { // Deprecated: Use VersionResp.ProtoReflect.Descriptor instead. func (*VersionResp) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{32} + return file_api_v2_api_proto_rawDescGZIP(), []int{35} } func (x *VersionResp) GetServer() string { @@ -1783,7 +1976,7 @@ type DiscoveryReq struct { func (x *DiscoveryReq) Reset() { *x = DiscoveryReq{} - mi := &file_api_v2_api_proto_msgTypes[33] + mi := &file_api_v2_api_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1795,7 +1988,7 @@ func (x *DiscoveryReq) String() string { func (*DiscoveryReq) ProtoMessage() {} func (x *DiscoveryReq) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[33] + mi := &file_api_v2_api_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1808,7 +2001,7 @@ func (x *DiscoveryReq) ProtoReflect() protoreflect.Message { // Deprecated: Use DiscoveryReq.ProtoReflect.Descriptor instead. func (*DiscoveryReq) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{33} + return file_api_v2_api_proto_rawDescGZIP(), []int{36} } // DiscoverResp holds the version oidc disovery info. @@ -1835,7 +2028,7 @@ type DiscoveryResp struct { func (x *DiscoveryResp) Reset() { *x = DiscoveryResp{} - mi := &file_api_v2_api_proto_msgTypes[34] + mi := &file_api_v2_api_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1847,7 +2040,7 @@ func (x *DiscoveryResp) String() string { func (*DiscoveryResp) ProtoMessage() {} func (x *DiscoveryResp) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[34] + mi := &file_api_v2_api_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1860,7 +2053,7 @@ func (x *DiscoveryResp) ProtoReflect() protoreflect.Message { // Deprecated: Use DiscoveryResp.ProtoReflect.Descriptor instead. func (*DiscoveryResp) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{34} + return file_api_v2_api_proto_rawDescGZIP(), []int{37} } func (x *DiscoveryResp) GetIssuer() string { @@ -1982,7 +2175,7 @@ type RefreshTokenRef struct { func (x *RefreshTokenRef) Reset() { *x = RefreshTokenRef{} - mi := &file_api_v2_api_proto_msgTypes[35] + mi := &file_api_v2_api_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1994,7 +2187,7 @@ func (x *RefreshTokenRef) String() string { func (*RefreshTokenRef) ProtoMessage() {} func (x *RefreshTokenRef) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[35] + mi := &file_api_v2_api_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2007,7 +2200,7 @@ func (x *RefreshTokenRef) ProtoReflect() protoreflect.Message { // Deprecated: Use RefreshTokenRef.ProtoReflect.Descriptor instead. func (*RefreshTokenRef) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{35} + return file_api_v2_api_proto_rawDescGZIP(), []int{38} } func (x *RefreshTokenRef) GetId() string { @@ -2049,7 +2242,7 @@ type ListRefreshReq struct { func (x *ListRefreshReq) Reset() { *x = ListRefreshReq{} - mi := &file_api_v2_api_proto_msgTypes[36] + mi := &file_api_v2_api_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2061,7 +2254,7 @@ func (x *ListRefreshReq) String() string { func (*ListRefreshReq) ProtoMessage() {} func (x *ListRefreshReq) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[36] + mi := &file_api_v2_api_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2074,7 +2267,7 @@ func (x *ListRefreshReq) ProtoReflect() protoreflect.Message { // Deprecated: Use ListRefreshReq.ProtoReflect.Descriptor instead. func (*ListRefreshReq) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{36} + return file_api_v2_api_proto_rawDescGZIP(), []int{39} } func (x *ListRefreshReq) GetUserId() string { @@ -2094,7 +2287,7 @@ type ListRefreshResp struct { func (x *ListRefreshResp) Reset() { *x = ListRefreshResp{} - mi := &file_api_v2_api_proto_msgTypes[37] + mi := &file_api_v2_api_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2106,7 +2299,7 @@ func (x *ListRefreshResp) String() string { func (*ListRefreshResp) ProtoMessage() {} func (x *ListRefreshResp) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[37] + mi := &file_api_v2_api_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2119,7 +2312,7 @@ func (x *ListRefreshResp) ProtoReflect() protoreflect.Message { // Deprecated: Use ListRefreshResp.ProtoReflect.Descriptor instead. func (*ListRefreshResp) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{37} + return file_api_v2_api_proto_rawDescGZIP(), []int{40} } func (x *ListRefreshResp) GetRefreshTokens() []*RefreshTokenRef { @@ -2141,7 +2334,7 @@ type RevokeRefreshReq struct { func (x *RevokeRefreshReq) Reset() { *x = RevokeRefreshReq{} - mi := &file_api_v2_api_proto_msgTypes[38] + mi := &file_api_v2_api_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2153,7 +2346,7 @@ func (x *RevokeRefreshReq) String() string { func (*RevokeRefreshReq) ProtoMessage() {} func (x *RevokeRefreshReq) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[38] + mi := &file_api_v2_api_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2166,7 +2359,7 @@ func (x *RevokeRefreshReq) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokeRefreshReq.ProtoReflect.Descriptor instead. func (*RevokeRefreshReq) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{38} + return file_api_v2_api_proto_rawDescGZIP(), []int{41} } func (x *RevokeRefreshReq) GetUserId() string { @@ -2194,7 +2387,7 @@ type RevokeRefreshResp struct { func (x *RevokeRefreshResp) Reset() { *x = RevokeRefreshResp{} - mi := &file_api_v2_api_proto_msgTypes[39] + mi := &file_api_v2_api_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2206,7 +2399,7 @@ func (x *RevokeRefreshResp) String() string { func (*RevokeRefreshResp) ProtoMessage() {} func (x *RevokeRefreshResp) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[39] + mi := &file_api_v2_api_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2219,7 +2412,7 @@ func (x *RevokeRefreshResp) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokeRefreshResp.ProtoReflect.Descriptor instead. func (*RevokeRefreshResp) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{39} + return file_api_v2_api_proto_rawDescGZIP(), []int{42} } func (x *RevokeRefreshResp) GetNotFound() bool { @@ -2239,7 +2432,7 @@ type VerifyPasswordReq struct { func (x *VerifyPasswordReq) Reset() { *x = VerifyPasswordReq{} - mi := &file_api_v2_api_proto_msgTypes[40] + mi := &file_api_v2_api_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2251,7 +2444,7 @@ func (x *VerifyPasswordReq) String() string { func (*VerifyPasswordReq) ProtoMessage() {} func (x *VerifyPasswordReq) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[40] + mi := &file_api_v2_api_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2264,7 +2457,7 @@ func (x *VerifyPasswordReq) ProtoReflect() protoreflect.Message { // Deprecated: Use VerifyPasswordReq.ProtoReflect.Descriptor instead. func (*VerifyPasswordReq) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{40} + return file_api_v2_api_proto_rawDescGZIP(), []int{43} } func (x *VerifyPasswordReq) GetEmail() string { @@ -2291,7 +2484,7 @@ type VerifyPasswordResp struct { func (x *VerifyPasswordResp) Reset() { *x = VerifyPasswordResp{} - mi := &file_api_v2_api_proto_msgTypes[41] + mi := &file_api_v2_api_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2303,7 +2496,7 @@ func (x *VerifyPasswordResp) String() string { func (*VerifyPasswordResp) ProtoMessage() {} func (x *VerifyPasswordResp) ProtoReflect() protoreflect.Message { - mi := &file_api_v2_api_proto_msgTypes[41] + mi := &file_api_v2_api_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2316,7 +2509,7 @@ func (x *VerifyPasswordResp) ProtoReflect() protoreflect.Message { // Deprecated: Use VerifyPasswordResp.ProtoReflect.Descriptor instead. func (*VerifyPasswordResp) Descriptor() ([]byte, []int) { - return file_api_v2_api_proto_rawDescGZIP(), []int{41} + return file_api_v2_api_proto_rawDescGZIP(), []int{44} } func (x *VerifyPasswordResp) GetVerified() bool { @@ -2450,217 +2643,250 @@ var file_api_v2_api_proto_rawDesc = string([]byte{ 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x12, 0x2b, 0x0a, 0x09, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x09, 0x70, 0x61, 0x73, 0x73, 0x77, - 0x6f, 0x72, 0x64, 0x73, 0x22, 0x7c, 0x0a, 0x09, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, - 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, - 0x64, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x6f, 0x6e, - 0x66, 0x69, 0x67, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, - 0x67, 0x12, 0x1f, 0x0a, 0x0b, 0x67, 0x72, 0x61, 0x6e, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x73, - 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x67, 0x72, 0x61, 0x6e, 0x74, 0x54, 0x79, 0x70, - 0x65, 0x73, 0x22, 0x42, 0x0a, 0x12, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x6e, - 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x71, 0x12, 0x2c, 0x0a, 0x09, 0x63, 0x6f, 0x6e, 0x6e, - 0x65, 0x63, 0x74, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x70, - 0x69, 0x2e, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x09, 0x63, 0x6f, 0x6e, - 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x22, 0x3c, 0x0a, 0x13, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, - 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x70, 0x12, 0x25, 0x0a, - 0x0e, 0x61, 0x6c, 0x72, 0x65, 0x61, 0x64, 0x79, 0x5f, 0x65, 0x78, 0x69, 0x73, 0x74, 0x73, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x61, 0x6c, 0x72, 0x65, 0x61, 0x64, 0x79, 0x45, 0x78, - 0x69, 0x73, 0x74, 0x73, 0x22, 0x2d, 0x0a, 0x0a, 0x47, 0x72, 0x61, 0x6e, 0x74, 0x54, 0x79, 0x70, - 0x65, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x67, 0x72, 0x61, 0x6e, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, - 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x67, 0x72, 0x61, 0x6e, 0x74, 0x54, 0x79, - 0x70, 0x65, 0x73, 0x22, 0xb2, 0x01, 0x0a, 0x12, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6f, - 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x71, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x6e, 0x65, - 0x77, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6e, 0x65, - 0x77, 0x54, 0x79, 0x70, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x6e, 0x65, 0x77, 0x5f, 0x6e, 0x61, 0x6d, - 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6e, 0x65, 0x77, 0x4e, 0x61, 0x6d, 0x65, - 0x12, 0x1d, 0x0a, 0x0a, 0x6e, 0x65, 0x77, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x6e, 0x65, 0x77, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, - 0x37, 0x0a, 0x0f, 0x6e, 0x65, 0x77, 0x5f, 0x67, 0x72, 0x61, 0x6e, 0x74, 0x5f, 0x74, 0x79, 0x70, - 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x47, - 0x72, 0x61, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x73, 0x52, 0x0d, 0x6e, 0x65, 0x77, 0x47, 0x72, - 0x61, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x73, 0x22, 0x32, 0x0a, 0x13, 0x55, 0x70, 0x64, 0x61, - 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x70, 0x12, - 0x1b, 0x0a, 0x09, 0x6e, 0x6f, 0x74, 0x5f, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x08, 0x6e, 0x6f, 0x74, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x22, 0x24, 0x0a, 0x12, - 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, - 0x65, 0x71, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, - 0x69, 0x64, 0x22, 0x32, 0x0a, 0x13, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x6e, - 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x70, 0x12, 0x1b, 0x0a, 0x09, 0x6e, 0x6f, 0x74, - 0x5f, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6e, 0x6f, - 0x74, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x22, 0x12, 0x0a, 0x10, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, - 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x71, 0x22, 0x43, 0x0a, 0x11, 0x4c, 0x69, - 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x70, 0x12, - 0x2e, 0x0a, 0x0a, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x18, 0x01, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, - 0x74, 0x6f, 0x72, 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x22, - 0x0c, 0x0a, 0x0a, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x22, 0x37, 0x0a, - 0x0b, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x12, 0x16, 0x0a, 0x06, - 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x65, - 0x72, 0x76, 0x65, 0x72, 0x12, 0x10, 0x0a, 0x03, 0x61, 0x70, 0x69, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x05, 0x52, 0x03, 0x61, 0x70, 0x69, 0x22, 0x0e, 0x0a, 0x0c, 0x44, 0x69, 0x73, 0x63, 0x6f, 0x76, - 0x65, 0x72, 0x79, 0x52, 0x65, 0x71, 0x22, 0xb0, 0x06, 0x0a, 0x0d, 0x44, 0x69, 0x73, 0x63, 0x6f, - 0x76, 0x65, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x69, 0x73, 0x73, 0x75, - 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, - 0x12, 0x35, 0x0a, 0x16, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, - 0x6e, 0x5f, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x15, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, - 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x25, 0x0a, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, - 0x5f, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x0d, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x19, - 0x0a, 0x08, 0x6a, 0x77, 0x6b, 0x73, 0x5f, 0x75, 0x72, 0x69, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x07, 0x6a, 0x77, 0x6b, 0x73, 0x55, 0x72, 0x69, 0x12, 0x2b, 0x0a, 0x11, 0x75, 0x73, 0x65, - 0x72, 0x69, 0x6e, 0x66, 0x6f, 0x5f, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x05, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x75, 0x73, 0x65, 0x72, 0x69, 0x6e, 0x66, 0x6f, 0x45, 0x6e, - 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x42, 0x0a, 0x1d, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, - 0x5f, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, - 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x1b, 0x64, - 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, - 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x35, 0x0a, 0x16, 0x69, 0x6e, - 0x74, 0x72, 0x6f, 0x73, 0x70, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x6e, 0x64, 0x70, - 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x15, 0x69, 0x6e, 0x74, 0x72, - 0x6f, 0x73, 0x70, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, - 0x74, 0x12, 0x32, 0x0a, 0x15, 0x67, 0x72, 0x61, 0x6e, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x73, - 0x5f, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, - 0x52, 0x13, 0x67, 0x72, 0x61, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x73, 0x53, 0x75, 0x70, 0x70, - 0x6f, 0x72, 0x74, 0x65, 0x64, 0x12, 0x38, 0x0a, 0x18, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x5f, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, - 0x64, 0x18, 0x09, 0x20, 0x03, 0x28, 0x09, 0x52, 0x16, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x54, 0x79, 0x70, 0x65, 0x73, 0x53, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x12, - 0x36, 0x0a, 0x17, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x73, - 0x5f, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x09, - 0x52, 0x15, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x54, 0x79, 0x70, 0x65, 0x73, 0x53, 0x75, - 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x12, 0x4f, 0x0a, 0x25, 0x69, 0x64, 0x5f, 0x74, 0x6f, - 0x6b, 0x65, 0x6e, 0x5f, 0x73, 0x69, 0x67, 0x6e, 0x69, 0x6e, 0x67, 0x5f, 0x61, 0x6c, 0x67, 0x5f, - 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x5f, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, - 0x18, 0x0b, 0x20, 0x03, 0x28, 0x09, 0x52, 0x20, 0x69, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x53, - 0x69, 0x67, 0x6e, 0x69, 0x6e, 0x67, 0x41, 0x6c, 0x67, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x53, - 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x12, 0x47, 0x0a, 0x20, 0x63, 0x6f, 0x64, 0x65, - 0x5f, 0x63, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x5f, 0x6d, 0x65, 0x74, 0x68, 0x6f, - 0x64, 0x73, 0x5f, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x18, 0x0c, 0x20, 0x03, - 0x28, 0x09, 0x52, 0x1d, 0x63, 0x6f, 0x64, 0x65, 0x43, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, - 0x65, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x73, 0x53, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, - 0x64, 0x12, 0x29, 0x0a, 0x10, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x73, 0x5f, 0x73, 0x75, 0x70, 0x70, - 0x6f, 0x72, 0x74, 0x65, 0x64, 0x18, 0x0d, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0f, 0x73, 0x63, 0x6f, - 0x70, 0x65, 0x73, 0x53, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x12, 0x50, 0x0a, 0x25, - 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x5f, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x5f, 0x61, - 0x75, 0x74, 0x68, 0x5f, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x73, 0x5f, 0x73, 0x75, 0x70, 0x70, - 0x6f, 0x72, 0x74, 0x65, 0x64, 0x18, 0x0e, 0x20, 0x03, 0x28, 0x09, 0x52, 0x21, 0x74, 0x6f, 0x6b, - 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x41, 0x75, 0x74, 0x68, 0x4d, 0x65, - 0x74, 0x68, 0x6f, 0x64, 0x73, 0x53, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x12, 0x29, - 0x0a, 0x10, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x73, 0x5f, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, - 0x65, 0x64, 0x18, 0x0f, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0f, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x73, - 0x53, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x22, 0x7a, 0x0a, 0x0f, 0x52, 0x65, 0x66, - 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x66, 0x12, 0x0e, 0x0a, 0x02, - 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1b, 0x0a, 0x09, - 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x08, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x72, 0x65, - 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x63, - 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x6c, 0x61, 0x73, 0x74, - 0x5f, 0x75, 0x73, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x6c, 0x61, 0x73, - 0x74, 0x55, 0x73, 0x65, 0x64, 0x22, 0x29, 0x0a, 0x0e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x66, - 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x71, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, - 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, - 0x22, 0x4e, 0x0a, 0x0f, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, - 0x65, 0x73, 0x70, 0x12, 0x3b, 0x0a, 0x0e, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x5f, 0x74, - 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x61, 0x70, - 0x69, 0x2e, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, - 0x66, 0x52, 0x0d, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, - 0x22, 0x48, 0x0a, 0x10, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, - 0x68, 0x52, 0x65, 0x71, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1b, 0x0a, - 0x09, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x08, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x22, 0x30, 0x0a, 0x11, 0x52, 0x65, - 0x76, 0x6f, 0x6b, 0x65, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x73, 0x70, 0x12, - 0x1b, 0x0a, 0x09, 0x6e, 0x6f, 0x74, 0x5f, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x08, 0x6e, 0x6f, 0x74, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x22, 0x45, 0x0a, 0x11, + 0x6f, 0x72, 0x64, 0x73, 0x22, 0xaa, 0x01, 0x0a, 0x09, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, + 0x6f, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, + 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x63, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x12, 0x1f, 0x0a, 0x0b, 0x67, 0x72, 0x61, 0x6e, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, + 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x67, 0x72, 0x61, 0x6e, 0x74, 0x54, 0x79, + 0x70, 0x65, 0x73, 0x12, 0x2c, 0x0a, 0x06, 0x65, 0x78, 0x70, 0x69, 0x72, 0x79, 0x18, 0x06, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, + 0x74, 0x6f, 0x72, 0x45, 0x78, 0x70, 0x69, 0x72, 0x79, 0x52, 0x06, 0x65, 0x78, 0x70, 0x69, 0x72, + 0x79, 0x22, 0x72, 0x0a, 0x0f, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x45, 0x78, + 0x70, 0x69, 0x72, 0x79, 0x12, 0x1b, 0x0a, 0x09, 0x69, 0x64, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, + 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x69, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, + 0x73, 0x12, 0x42, 0x0a, 0x0e, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x5f, 0x74, 0x6f, 0x6b, + 0x65, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x61, 0x70, 0x69, 0x2e, + 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, + 0x45, 0x78, 0x70, 0x69, 0x72, 0x79, 0x52, 0x0d, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, + 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x22, 0xe3, 0x01, 0x0a, 0x16, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, + 0x74, 0x6f, 0x72, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x45, 0x78, 0x70, 0x69, 0x72, 0x79, + 0x12, 0x2e, 0x0a, 0x10, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x72, 0x6f, 0x74, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x48, 0x00, 0x52, 0x0f, 0x64, 0x69, + 0x73, 0x61, 0x62, 0x6c, 0x65, 0x52, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x88, 0x01, 0x01, + 0x12, 0x25, 0x0a, 0x0e, 0x72, 0x65, 0x75, 0x73, 0x65, 0x5f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, + 0x61, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x72, 0x65, 0x75, 0x73, 0x65, 0x49, + 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x12, 0x2b, 0x0a, 0x11, 0x61, 0x62, 0x73, 0x6f, 0x6c, + 0x75, 0x74, 0x65, 0x5f, 0x6c, 0x69, 0x66, 0x65, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x10, 0x61, 0x62, 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x65, 0x4c, 0x69, 0x66, 0x65, + 0x74, 0x69, 0x6d, 0x65, 0x12, 0x30, 0x0a, 0x15, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x5f, 0x69, 0x66, + 0x5f, 0x6e, 0x6f, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x64, 0x5f, 0x66, 0x6f, 0x72, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x11, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x49, 0x66, 0x4e, 0x6f, 0x74, 0x55, + 0x73, 0x65, 0x64, 0x46, 0x6f, 0x72, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x64, 0x69, 0x73, 0x61, 0x62, + 0x6c, 0x65, 0x5f, 0x72, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x43, 0x0a, 0x15, 0x43, + 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x45, 0x78, 0x70, 0x69, 0x72, 0x79, 0x55, 0x70, + 0x64, 0x61, 0x74, 0x65, 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, + 0x74, 0x6f, 0x72, 0x45, 0x78, 0x70, 0x69, 0x72, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, + 0x22, 0x42, 0x0a, 0x12, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, + 0x74, 0x6f, 0x72, 0x52, 0x65, 0x71, 0x12, 0x2c, 0x0a, 0x09, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, + 0x74, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, 0x70, 0x69, 0x2e, + 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x6e, 0x65, + 0x63, 0x74, 0x6f, 0x72, 0x22, 0x3c, 0x0a, 0x13, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6f, + 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x70, 0x12, 0x25, 0x0a, 0x0e, 0x61, + 0x6c, 0x72, 0x65, 0x61, 0x64, 0x79, 0x5f, 0x65, 0x78, 0x69, 0x73, 0x74, 0x73, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x0d, 0x61, 0x6c, 0x72, 0x65, 0x61, 0x64, 0x79, 0x45, 0x78, 0x69, 0x73, + 0x74, 0x73, 0x22, 0x2d, 0x0a, 0x0a, 0x47, 0x72, 0x61, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x73, + 0x12, 0x1f, 0x0a, 0x0b, 0x67, 0x72, 0x61, 0x6e, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x18, + 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x67, 0x72, 0x61, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, + 0x73, 0x22, 0xed, 0x01, 0x0a, 0x12, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x6e, + 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x71, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x6e, 0x65, 0x77, 0x5f, + 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6e, 0x65, 0x77, 0x54, + 0x79, 0x70, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x6e, 0x65, 0x77, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6e, 0x65, 0x77, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1d, + 0x0a, 0x0a, 0x6e, 0x65, 0x77, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x04, 0x20, 0x01, + 0x28, 0x0c, 0x52, 0x09, 0x6e, 0x65, 0x77, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x37, 0x0a, + 0x0f, 0x6e, 0x65, 0x77, 0x5f, 0x67, 0x72, 0x61, 0x6e, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x73, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x47, 0x72, 0x61, + 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x73, 0x52, 0x0d, 0x6e, 0x65, 0x77, 0x47, 0x72, 0x61, 0x6e, + 0x74, 0x54, 0x79, 0x70, 0x65, 0x73, 0x12, 0x39, 0x0a, 0x0a, 0x6e, 0x65, 0x77, 0x5f, 0x65, 0x78, + 0x70, 0x69, 0x72, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x61, 0x70, 0x69, + 0x2e, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x45, 0x78, 0x70, 0x69, 0x72, 0x79, + 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x09, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x69, 0x72, + 0x79, 0x22, 0x32, 0x0a, 0x13, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x6e, 0x65, + 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x70, 0x12, 0x1b, 0x0a, 0x09, 0x6e, 0x6f, 0x74, 0x5f, + 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6e, 0x6f, 0x74, + 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x22, 0x24, 0x0a, 0x12, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, + 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x71, 0x12, 0x0e, 0x0a, 0x02, 0x69, + 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0x32, 0x0a, 0x13, 0x44, + 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, + 0x73, 0x70, 0x12, 0x1b, 0x0a, 0x09, 0x6e, 0x6f, 0x74, 0x5f, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6e, 0x6f, 0x74, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x22, + 0x12, 0x0a, 0x10, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, + 0x52, 0x65, 0x71, 0x22, 0x43, 0x0a, 0x11, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x6e, 0x65, + 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x70, 0x12, 0x2e, 0x0a, 0x0a, 0x63, 0x6f, 0x6e, 0x6e, + 0x65, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x61, + 0x70, 0x69, 0x2e, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x0a, 0x63, 0x6f, + 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x22, 0x0c, 0x0a, 0x0a, 0x56, 0x65, 0x72, 0x73, + 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x22, 0x37, 0x0a, 0x0b, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, + 0x6e, 0x52, 0x65, 0x73, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x12, 0x10, 0x0a, + 0x03, 0x61, 0x70, 0x69, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x61, 0x70, 0x69, 0x22, + 0x0e, 0x0a, 0x0c, 0x44, 0x69, 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x52, 0x65, 0x71, 0x22, + 0xb0, 0x06, 0x0a, 0x0d, 0x44, 0x69, 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x52, 0x65, 0x73, + 0x70, 0x12, 0x16, 0x0a, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x12, 0x35, 0x0a, 0x16, 0x61, 0x75, 0x74, + 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x6e, 0x64, 0x70, 0x6f, + 0x69, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x15, 0x61, 0x75, 0x74, 0x68, 0x6f, + 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, + 0x12, 0x25, 0x0a, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x5f, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, + 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x45, + 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x6a, 0x77, 0x6b, 0x73, 0x5f, + 0x75, 0x72, 0x69, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6a, 0x77, 0x6b, 0x73, 0x55, + 0x72, 0x69, 0x12, 0x2b, 0x0a, 0x11, 0x75, 0x73, 0x65, 0x72, 0x69, 0x6e, 0x66, 0x6f, 0x5f, 0x65, + 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x75, + 0x73, 0x65, 0x72, 0x69, 0x6e, 0x66, 0x6f, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, + 0x42, 0x0a, 0x1d, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, + 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x1b, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, + 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, + 0x69, 0x6e, 0x74, 0x12, 0x35, 0x0a, 0x16, 0x69, 0x6e, 0x74, 0x72, 0x6f, 0x73, 0x70, 0x65, 0x63, + 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x07, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x15, 0x69, 0x6e, 0x74, 0x72, 0x6f, 0x73, 0x70, 0x65, 0x63, 0x74, 0x69, + 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x32, 0x0a, 0x15, 0x67, 0x72, + 0x61, 0x6e, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x5f, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, + 0x74, 0x65, 0x64, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x13, 0x67, 0x72, 0x61, 0x6e, 0x74, + 0x54, 0x79, 0x70, 0x65, 0x73, 0x53, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x12, 0x38, + 0x0a, 0x18, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x73, + 0x5f, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x18, 0x09, 0x20, 0x03, 0x28, 0x09, + 0x52, 0x16, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x54, 0x79, 0x70, 0x65, 0x73, 0x53, + 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x12, 0x36, 0x0a, 0x17, 0x73, 0x75, 0x62, 0x6a, + 0x65, 0x63, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x5f, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, + 0x74, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x09, 0x52, 0x15, 0x73, 0x75, 0x62, 0x6a, 0x65, + 0x63, 0x74, 0x54, 0x79, 0x70, 0x65, 0x73, 0x53, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, + 0x12, 0x4f, 0x0a, 0x25, 0x69, 0x64, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x5f, 0x73, 0x69, 0x67, + 0x6e, 0x69, 0x6e, 0x67, 0x5f, 0x61, 0x6c, 0x67, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x5f, + 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x09, 0x52, + 0x20, 0x69, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x69, 0x6e, 0x67, 0x41, + 0x6c, 0x67, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x53, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, + 0x64, 0x12, 0x47, 0x0a, 0x20, 0x63, 0x6f, 0x64, 0x65, 0x5f, 0x63, 0x68, 0x61, 0x6c, 0x6c, 0x65, + 0x6e, 0x67, 0x65, 0x5f, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x73, 0x5f, 0x73, 0x75, 0x70, 0x70, + 0x6f, 0x72, 0x74, 0x65, 0x64, 0x18, 0x0c, 0x20, 0x03, 0x28, 0x09, 0x52, 0x1d, 0x63, 0x6f, 0x64, + 0x65, 0x43, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, + 0x73, 0x53, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x12, 0x29, 0x0a, 0x10, 0x73, 0x63, + 0x6f, 0x70, 0x65, 0x73, 0x5f, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x18, 0x0d, + 0x20, 0x03, 0x28, 0x09, 0x52, 0x0f, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x73, 0x53, 0x75, 0x70, 0x70, + 0x6f, 0x72, 0x74, 0x65, 0x64, 0x12, 0x50, 0x0a, 0x25, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x5f, 0x65, + 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x6d, 0x65, 0x74, + 0x68, 0x6f, 0x64, 0x73, 0x5f, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x18, 0x0e, + 0x20, 0x03, 0x28, 0x09, 0x52, 0x21, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, + 0x69, 0x6e, 0x74, 0x41, 0x75, 0x74, 0x68, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x73, 0x53, 0x75, + 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x12, 0x29, 0x0a, 0x10, 0x63, 0x6c, 0x61, 0x69, 0x6d, + 0x73, 0x5f, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x18, 0x0f, 0x20, 0x03, 0x28, + 0x09, 0x52, 0x0f, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x73, 0x53, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, + 0x65, 0x64, 0x22, 0x7a, 0x0a, 0x0f, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, + 0x65, 0x6e, 0x52, 0x65, 0x66, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, + 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, + 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, + 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x64, 0x18, 0x06, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x6c, 0x61, 0x73, 0x74, 0x55, 0x73, 0x65, 0x64, 0x22, 0x29, + 0x0a, 0x0e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x71, + 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x4e, 0x0a, 0x0f, 0x4c, 0x69, 0x73, + 0x74, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x73, 0x70, 0x12, 0x3b, 0x0a, 0x0e, + 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x01, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x66, 0x72, 0x65, + 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x66, 0x52, 0x0d, 0x72, 0x65, 0x66, 0x72, + 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x22, 0x48, 0x0a, 0x10, 0x52, 0x65, 0x76, + 0x6f, 0x6b, 0x65, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x71, 0x12, 0x17, 0x0a, + 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, + 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, + 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x6c, 0x69, 0x65, 0x6e, + 0x74, 0x49, 0x64, 0x22, 0x30, 0x0a, 0x11, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x52, 0x65, 0x66, + 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x73, 0x70, 0x12, 0x1b, 0x0a, 0x09, 0x6e, 0x6f, 0x74, 0x5f, + 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6e, 0x6f, 0x74, + 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x22, 0x45, 0x0a, 0x11, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x50, + 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x6d, + 0x61, 0x69, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, + 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x4d, 0x0a, 0x12, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, - 0x71, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, - 0x6f, 0x72, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, - 0x6f, 0x72, 0x64, 0x22, 0x4d, 0x0a, 0x12, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x50, 0x61, 0x73, - 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x12, 0x1a, 0x0a, 0x08, 0x76, 0x65, 0x72, - 0x69, 0x66, 0x69, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x76, 0x65, 0x72, - 0x69, 0x66, 0x69, 0x65, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x6e, 0x6f, 0x74, 0x5f, 0x66, 0x6f, 0x75, - 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6e, 0x6f, 0x74, 0x46, 0x6f, 0x75, - 0x6e, 0x64, 0x32, 0x8b, 0x09, 0x0a, 0x03, 0x44, 0x65, 0x78, 0x12, 0x34, 0x0a, 0x09, 0x47, 0x65, - 0x74, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x12, 0x11, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x47, 0x65, - 0x74, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x1a, 0x12, 0x2e, 0x61, 0x70, 0x69, - 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, - 0x12, 0x3d, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, - 0x12, 0x14, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, - 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x1a, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, - 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, - 0x3d, 0x0a, 0x0c, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x12, - 0x14, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, - 0x6e, 0x74, 0x52, 0x65, 0x71, 0x1a, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x55, 0x70, 0x64, 0x61, - 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x3d, - 0x0a, 0x0c, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x12, 0x14, - 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, - 0x74, 0x52, 0x65, 0x71, 0x1a, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, - 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x38, 0x0a, - 0x0b, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x12, 0x2e, 0x61, - 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, - 0x1a, 0x13, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6c, 0x69, 0x65, 0x6e, - 0x74, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x43, 0x0a, 0x0e, 0x43, 0x72, 0x65, 0x61, 0x74, - 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, - 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, - 0x71, 0x1a, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x61, - 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x43, 0x0a, 0x0e, - 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x16, - 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, - 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x1a, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x55, 0x70, 0x64, - 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x22, - 0x00, 0x12, 0x43, 0x0a, 0x0e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, - 0x6f, 0x72, 0x64, 0x12, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, - 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x1a, 0x17, 0x2e, 0x61, 0x70, - 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, - 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x3e, 0x0a, 0x0d, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x61, - 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x73, 0x12, 0x14, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, - 0x73, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x1a, 0x15, 0x2e, - 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, - 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x0f, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, - 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x12, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, - 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, - 0x65, 0x71, 0x1a, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, - 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x46, - 0x0a, 0x0f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, - 0x72, 0x12, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6f, - 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x71, 0x1a, 0x18, 0x2e, 0x61, 0x70, 0x69, + 0x73, 0x70, 0x12, 0x1a, 0x0a, 0x08, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x65, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x65, 0x64, 0x12, 0x1b, + 0x0a, 0x09, 0x6e, 0x6f, 0x74, 0x5f, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x08, 0x6e, 0x6f, 0x74, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x32, 0x8b, 0x09, 0x0a, 0x03, + 0x44, 0x65, 0x78, 0x12, 0x34, 0x0a, 0x09, 0x47, 0x65, 0x74, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, + 0x12, 0x11, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, + 0x52, 0x65, 0x71, 0x1a, 0x12, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6c, 0x69, + 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x3d, 0x0a, 0x0c, 0x43, 0x72, 0x65, + 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x12, 0x14, 0x2e, 0x61, 0x70, 0x69, 0x2e, + 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x1a, + 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, + 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x3d, 0x0a, 0x0c, 0x55, 0x70, 0x64, 0x61, + 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x12, 0x14, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x55, + 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x1a, 0x15, + 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, + 0x74, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x3d, 0x0a, 0x0c, 0x44, 0x65, 0x6c, 0x65, 0x74, + 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x12, 0x14, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, + 0x6c, 0x65, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x1a, 0x15, 0x2e, + 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, + 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x38, 0x0a, 0x0b, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6c, + 0x69, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x12, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, + 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x1a, 0x13, 0x2e, 0x61, 0x70, 0x69, 0x2e, + 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, + 0x12, 0x43, 0x0a, 0x0e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, + 0x72, 0x64, 0x12, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, + 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x1a, 0x17, 0x2e, 0x61, 0x70, 0x69, + 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, + 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x43, 0x0a, 0x0e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, + 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x55, 0x70, + 0x64, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x1a, + 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, + 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x43, 0x0a, 0x0e, 0x44, 0x65, + 0x6c, 0x65, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x16, 0x2e, 0x61, + 0x70, 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, + 0x64, 0x52, 0x65, 0x71, 0x1a, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, + 0x65, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, + 0x3e, 0x0a, 0x0d, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x73, + 0x12, 0x14, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, + 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x1a, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, + 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, + 0x46, 0x0a, 0x0f, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, + 0x6f, 0x72, 0x12, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, + 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x71, 0x1a, 0x18, 0x2e, 0x61, 0x70, + 0x69, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, + 0x72, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x0f, 0x55, 0x70, 0x64, 0x61, 0x74, + 0x65, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x12, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, - 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x0f, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, - 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x12, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, - 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, - 0x65, 0x71, 0x1a, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, - 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x41, - 0x0a, 0x0e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x73, - 0x12, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x6e, 0x65, - 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x71, 0x1a, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, - 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x70, 0x22, - 0x00, 0x12, 0x31, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, - 0x0f, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, - 0x1a, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, - 0x73, 0x70, 0x22, 0x00, 0x12, 0x37, 0x0a, 0x0c, 0x47, 0x65, 0x74, 0x44, 0x69, 0x73, 0x63, 0x6f, - 0x76, 0x65, 0x72, 0x79, 0x12, 0x11, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x69, 0x73, 0x63, 0x6f, - 0x76, 0x65, 0x72, 0x79, 0x52, 0x65, 0x71, 0x1a, 0x12, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x69, - 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x3a, 0x0a, - 0x0b, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x12, 0x13, 0x2e, 0x61, - 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, - 0x71, 0x1a, 0x14, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x66, 0x72, - 0x65, 0x73, 0x68, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x40, 0x0a, 0x0d, 0x52, 0x65, 0x76, - 0x6f, 0x6b, 0x65, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x12, 0x15, 0x2e, 0x61, 0x70, 0x69, + 0x52, 0x65, 0x71, 0x1a, 0x18, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, + 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, + 0x46, 0x0a, 0x0f, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, + 0x6f, 0x72, 0x12, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, + 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x71, 0x1a, 0x18, 0x2e, 0x61, 0x70, + 0x69, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, + 0x72, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x41, 0x0a, 0x0e, 0x4c, 0x69, 0x73, 0x74, 0x43, + 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x12, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, + 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x71, + 0x1a, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x6e, 0x65, + 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x31, 0x0a, 0x0a, 0x47, 0x65, + 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x0f, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x56, + 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x1a, 0x10, 0x2e, 0x61, 0x70, 0x69, 0x2e, + 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x37, 0x0a, + 0x0c, 0x47, 0x65, 0x74, 0x44, 0x69, 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x12, 0x11, 0x2e, + 0x61, 0x70, 0x69, 0x2e, 0x44, 0x69, 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x52, 0x65, 0x71, + 0x1a, 0x12, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x44, 0x69, 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, + 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x3a, 0x0a, 0x0b, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, + 0x66, 0x72, 0x65, 0x73, 0x68, 0x12, 0x13, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4c, 0x69, 0x73, 0x74, + 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x71, 0x1a, 0x14, 0x2e, 0x61, 0x70, 0x69, + 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x73, 0x70, + 0x22, 0x00, 0x12, 0x40, 0x0a, 0x0d, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x52, 0x65, 0x66, 0x72, + 0x65, 0x73, 0x68, 0x12, 0x15, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, + 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x71, 0x1a, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, - 0x71, 0x1a, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x52, 0x65, - 0x66, 0x72, 0x65, 0x73, 0x68, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x12, 0x43, 0x0a, 0x0e, 0x56, - 0x65, 0x72, 0x69, 0x66, 0x79, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x16, 0x2e, - 0x61, 0x70, 0x69, 0x2e, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, - 0x72, 0x64, 0x52, 0x65, 0x71, 0x1a, 0x17, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x56, 0x65, 0x72, 0x69, - 0x66, 0x79, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, - 0x42, 0x36, 0x0a, 0x12, 0x63, 0x6f, 0x6d, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x6f, 0x73, 0x2e, 0x64, - 0x65, 0x78, 0x2e, 0x61, 0x70, 0x69, 0x5a, 0x20, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, - 0x6f, 0x6d, 0x2f, 0x64, 0x65, 0x78, 0x69, 0x64, 0x70, 0x2f, 0x64, 0x65, 0x78, 0x2f, 0x61, 0x70, - 0x69, 0x2f, 0x76, 0x32, 0x3b, 0x61, 0x70, 0x69, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x73, 0x70, 0x22, 0x00, 0x12, 0x43, 0x0a, 0x0e, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x50, 0x61, + 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x56, 0x65, 0x72, + 0x69, 0x66, 0x79, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x1a, 0x17, + 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x50, 0x61, 0x73, 0x73, 0x77, + 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x42, 0x36, 0x0a, 0x12, 0x63, 0x6f, 0x6d, + 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x6f, 0x73, 0x2e, 0x64, 0x65, 0x78, 0x2e, 0x61, 0x70, 0x69, 0x5a, + 0x20, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x64, 0x65, 0x78, 0x69, + 0x64, 0x70, 0x2f, 0x64, 0x65, 0x78, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x32, 0x3b, 0x61, 0x70, + 0x69, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, }) var ( @@ -2675,50 +2901,53 @@ func file_api_v2_api_proto_rawDescGZIP() []byte { return file_api_v2_api_proto_rawDescData } -var file_api_v2_api_proto_msgTypes = make([]protoimpl.MessageInfo, 42) +var file_api_v2_api_proto_msgTypes = make([]protoimpl.MessageInfo, 45) var file_api_v2_api_proto_goTypes = []any{ - (*Client)(nil), // 0: api.Client - (*ClientInfo)(nil), // 1: api.ClientInfo - (*GetClientReq)(nil), // 2: api.GetClientReq - (*GetClientResp)(nil), // 3: api.GetClientResp - (*CreateClientReq)(nil), // 4: api.CreateClientReq - (*CreateClientResp)(nil), // 5: api.CreateClientResp - (*DeleteClientReq)(nil), // 6: api.DeleteClientReq - (*DeleteClientResp)(nil), // 7: api.DeleteClientResp - (*UpdateClientReq)(nil), // 8: api.UpdateClientReq - (*UpdateClientResp)(nil), // 9: api.UpdateClientResp - (*ListClientReq)(nil), // 10: api.ListClientReq - (*ListClientResp)(nil), // 11: api.ListClientResp - (*Password)(nil), // 12: api.Password - (*CreatePasswordReq)(nil), // 13: api.CreatePasswordReq - (*CreatePasswordResp)(nil), // 14: api.CreatePasswordResp - (*UpdatePasswordReq)(nil), // 15: api.UpdatePasswordReq - (*UpdatePasswordResp)(nil), // 16: api.UpdatePasswordResp - (*DeletePasswordReq)(nil), // 17: api.DeletePasswordReq - (*DeletePasswordResp)(nil), // 18: api.DeletePasswordResp - (*ListPasswordReq)(nil), // 19: api.ListPasswordReq - (*ListPasswordResp)(nil), // 20: api.ListPasswordResp - (*Connector)(nil), // 21: api.Connector - (*CreateConnectorReq)(nil), // 22: api.CreateConnectorReq - (*CreateConnectorResp)(nil), // 23: api.CreateConnectorResp - (*GrantTypes)(nil), // 24: api.GrantTypes - (*UpdateConnectorReq)(nil), // 25: api.UpdateConnectorReq - (*UpdateConnectorResp)(nil), // 26: api.UpdateConnectorResp - (*DeleteConnectorReq)(nil), // 27: api.DeleteConnectorReq - (*DeleteConnectorResp)(nil), // 28: api.DeleteConnectorResp - (*ListConnectorReq)(nil), // 29: api.ListConnectorReq - (*ListConnectorResp)(nil), // 30: api.ListConnectorResp - (*VersionReq)(nil), // 31: api.VersionReq - (*VersionResp)(nil), // 32: api.VersionResp - (*DiscoveryReq)(nil), // 33: api.DiscoveryReq - (*DiscoveryResp)(nil), // 34: api.DiscoveryResp - (*RefreshTokenRef)(nil), // 35: api.RefreshTokenRef - (*ListRefreshReq)(nil), // 36: api.ListRefreshReq - (*ListRefreshResp)(nil), // 37: api.ListRefreshResp - (*RevokeRefreshReq)(nil), // 38: api.RevokeRefreshReq - (*RevokeRefreshResp)(nil), // 39: api.RevokeRefreshResp - (*VerifyPasswordReq)(nil), // 40: api.VerifyPasswordReq - (*VerifyPasswordResp)(nil), // 41: api.VerifyPasswordResp + (*Client)(nil), // 0: api.Client + (*ClientInfo)(nil), // 1: api.ClientInfo + (*GetClientReq)(nil), // 2: api.GetClientReq + (*GetClientResp)(nil), // 3: api.GetClientResp + (*CreateClientReq)(nil), // 4: api.CreateClientReq + (*CreateClientResp)(nil), // 5: api.CreateClientResp + (*DeleteClientReq)(nil), // 6: api.DeleteClientReq + (*DeleteClientResp)(nil), // 7: api.DeleteClientResp + (*UpdateClientReq)(nil), // 8: api.UpdateClientReq + (*UpdateClientResp)(nil), // 9: api.UpdateClientResp + (*ListClientReq)(nil), // 10: api.ListClientReq + (*ListClientResp)(nil), // 11: api.ListClientResp + (*Password)(nil), // 12: api.Password + (*CreatePasswordReq)(nil), // 13: api.CreatePasswordReq + (*CreatePasswordResp)(nil), // 14: api.CreatePasswordResp + (*UpdatePasswordReq)(nil), // 15: api.UpdatePasswordReq + (*UpdatePasswordResp)(nil), // 16: api.UpdatePasswordResp + (*DeletePasswordReq)(nil), // 17: api.DeletePasswordReq + (*DeletePasswordResp)(nil), // 18: api.DeletePasswordResp + (*ListPasswordReq)(nil), // 19: api.ListPasswordReq + (*ListPasswordResp)(nil), // 20: api.ListPasswordResp + (*Connector)(nil), // 21: api.Connector + (*ConnectorExpiry)(nil), // 22: api.ConnectorExpiry + (*ConnectorRefreshExpiry)(nil), // 23: api.ConnectorRefreshExpiry + (*ConnectorExpiryUpdate)(nil), // 24: api.ConnectorExpiryUpdate + (*CreateConnectorReq)(nil), // 25: api.CreateConnectorReq + (*CreateConnectorResp)(nil), // 26: api.CreateConnectorResp + (*GrantTypes)(nil), // 27: api.GrantTypes + (*UpdateConnectorReq)(nil), // 28: api.UpdateConnectorReq + (*UpdateConnectorResp)(nil), // 29: api.UpdateConnectorResp + (*DeleteConnectorReq)(nil), // 30: api.DeleteConnectorReq + (*DeleteConnectorResp)(nil), // 31: api.DeleteConnectorResp + (*ListConnectorReq)(nil), // 32: api.ListConnectorReq + (*ListConnectorResp)(nil), // 33: api.ListConnectorResp + (*VersionReq)(nil), // 34: api.VersionReq + (*VersionResp)(nil), // 35: api.VersionResp + (*DiscoveryReq)(nil), // 36: api.DiscoveryReq + (*DiscoveryResp)(nil), // 37: api.DiscoveryResp + (*RefreshTokenRef)(nil), // 38: api.RefreshTokenRef + (*ListRefreshReq)(nil), // 39: api.ListRefreshReq + (*ListRefreshResp)(nil), // 40: api.ListRefreshResp + (*RevokeRefreshReq)(nil), // 41: api.RevokeRefreshReq + (*RevokeRefreshResp)(nil), // 42: api.RevokeRefreshResp + (*VerifyPasswordReq)(nil), // 43: api.VerifyPasswordReq + (*VerifyPasswordResp)(nil), // 44: api.VerifyPasswordResp } var file_api_v2_api_proto_depIdxs = []int32{ 0, // 0: api.GetClientResp.client:type_name -> api.Client @@ -2727,51 +2956,55 @@ var file_api_v2_api_proto_depIdxs = []int32{ 1, // 3: api.ListClientResp.clients:type_name -> api.ClientInfo 12, // 4: api.CreatePasswordReq.password:type_name -> api.Password 12, // 5: api.ListPasswordResp.passwords:type_name -> api.Password - 21, // 6: api.CreateConnectorReq.connector:type_name -> api.Connector - 24, // 7: api.UpdateConnectorReq.new_grant_types:type_name -> api.GrantTypes - 21, // 8: api.ListConnectorResp.connectors:type_name -> api.Connector - 35, // 9: api.ListRefreshResp.refresh_tokens:type_name -> api.RefreshTokenRef - 2, // 10: api.Dex.GetClient:input_type -> api.GetClientReq - 4, // 11: api.Dex.CreateClient:input_type -> api.CreateClientReq - 8, // 12: api.Dex.UpdateClient:input_type -> api.UpdateClientReq - 6, // 13: api.Dex.DeleteClient:input_type -> api.DeleteClientReq - 10, // 14: api.Dex.ListClients:input_type -> api.ListClientReq - 13, // 15: api.Dex.CreatePassword:input_type -> api.CreatePasswordReq - 15, // 16: api.Dex.UpdatePassword:input_type -> api.UpdatePasswordReq - 17, // 17: api.Dex.DeletePassword:input_type -> api.DeletePasswordReq - 19, // 18: api.Dex.ListPasswords:input_type -> api.ListPasswordReq - 22, // 19: api.Dex.CreateConnector:input_type -> api.CreateConnectorReq - 25, // 20: api.Dex.UpdateConnector:input_type -> api.UpdateConnectorReq - 27, // 21: api.Dex.DeleteConnector:input_type -> api.DeleteConnectorReq - 29, // 22: api.Dex.ListConnectors:input_type -> api.ListConnectorReq - 31, // 23: api.Dex.GetVersion:input_type -> api.VersionReq - 33, // 24: api.Dex.GetDiscovery:input_type -> api.DiscoveryReq - 36, // 25: api.Dex.ListRefresh:input_type -> api.ListRefreshReq - 38, // 26: api.Dex.RevokeRefresh:input_type -> api.RevokeRefreshReq - 40, // 27: api.Dex.VerifyPassword:input_type -> api.VerifyPasswordReq - 3, // 28: api.Dex.GetClient:output_type -> api.GetClientResp - 5, // 29: api.Dex.CreateClient:output_type -> api.CreateClientResp - 9, // 30: api.Dex.UpdateClient:output_type -> api.UpdateClientResp - 7, // 31: api.Dex.DeleteClient:output_type -> api.DeleteClientResp - 11, // 32: api.Dex.ListClients:output_type -> api.ListClientResp - 14, // 33: api.Dex.CreatePassword:output_type -> api.CreatePasswordResp - 16, // 34: api.Dex.UpdatePassword:output_type -> api.UpdatePasswordResp - 18, // 35: api.Dex.DeletePassword:output_type -> api.DeletePasswordResp - 20, // 36: api.Dex.ListPasswords:output_type -> api.ListPasswordResp - 23, // 37: api.Dex.CreateConnector:output_type -> api.CreateConnectorResp - 26, // 38: api.Dex.UpdateConnector:output_type -> api.UpdateConnectorResp - 28, // 39: api.Dex.DeleteConnector:output_type -> api.DeleteConnectorResp - 30, // 40: api.Dex.ListConnectors:output_type -> api.ListConnectorResp - 32, // 41: api.Dex.GetVersion:output_type -> api.VersionResp - 34, // 42: api.Dex.GetDiscovery:output_type -> api.DiscoveryResp - 37, // 43: api.Dex.ListRefresh:output_type -> api.ListRefreshResp - 39, // 44: api.Dex.RevokeRefresh:output_type -> api.RevokeRefreshResp - 41, // 45: api.Dex.VerifyPassword:output_type -> api.VerifyPasswordResp - 28, // [28:46] is the sub-list for method output_type - 10, // [10:28] is the sub-list for method input_type - 10, // [10:10] is the sub-list for extension type_name - 10, // [10:10] is the sub-list for extension extendee - 0, // [0:10] is the sub-list for field type_name + 22, // 6: api.Connector.expiry:type_name -> api.ConnectorExpiry + 23, // 7: api.ConnectorExpiry.refresh_tokens:type_name -> api.ConnectorRefreshExpiry + 22, // 8: api.ConnectorExpiryUpdate.value:type_name -> api.ConnectorExpiry + 21, // 9: api.CreateConnectorReq.connector:type_name -> api.Connector + 27, // 10: api.UpdateConnectorReq.new_grant_types:type_name -> api.GrantTypes + 24, // 11: api.UpdateConnectorReq.new_expiry:type_name -> api.ConnectorExpiryUpdate + 21, // 12: api.ListConnectorResp.connectors:type_name -> api.Connector + 38, // 13: api.ListRefreshResp.refresh_tokens:type_name -> api.RefreshTokenRef + 2, // 14: api.Dex.GetClient:input_type -> api.GetClientReq + 4, // 15: api.Dex.CreateClient:input_type -> api.CreateClientReq + 8, // 16: api.Dex.UpdateClient:input_type -> api.UpdateClientReq + 6, // 17: api.Dex.DeleteClient:input_type -> api.DeleteClientReq + 10, // 18: api.Dex.ListClients:input_type -> api.ListClientReq + 13, // 19: api.Dex.CreatePassword:input_type -> api.CreatePasswordReq + 15, // 20: api.Dex.UpdatePassword:input_type -> api.UpdatePasswordReq + 17, // 21: api.Dex.DeletePassword:input_type -> api.DeletePasswordReq + 19, // 22: api.Dex.ListPasswords:input_type -> api.ListPasswordReq + 25, // 23: api.Dex.CreateConnector:input_type -> api.CreateConnectorReq + 28, // 24: api.Dex.UpdateConnector:input_type -> api.UpdateConnectorReq + 30, // 25: api.Dex.DeleteConnector:input_type -> api.DeleteConnectorReq + 32, // 26: api.Dex.ListConnectors:input_type -> api.ListConnectorReq + 34, // 27: api.Dex.GetVersion:input_type -> api.VersionReq + 36, // 28: api.Dex.GetDiscovery:input_type -> api.DiscoveryReq + 39, // 29: api.Dex.ListRefresh:input_type -> api.ListRefreshReq + 41, // 30: api.Dex.RevokeRefresh:input_type -> api.RevokeRefreshReq + 43, // 31: api.Dex.VerifyPassword:input_type -> api.VerifyPasswordReq + 3, // 32: api.Dex.GetClient:output_type -> api.GetClientResp + 5, // 33: api.Dex.CreateClient:output_type -> api.CreateClientResp + 9, // 34: api.Dex.UpdateClient:output_type -> api.UpdateClientResp + 7, // 35: api.Dex.DeleteClient:output_type -> api.DeleteClientResp + 11, // 36: api.Dex.ListClients:output_type -> api.ListClientResp + 14, // 37: api.Dex.CreatePassword:output_type -> api.CreatePasswordResp + 16, // 38: api.Dex.UpdatePassword:output_type -> api.UpdatePasswordResp + 18, // 39: api.Dex.DeletePassword:output_type -> api.DeletePasswordResp + 20, // 40: api.Dex.ListPasswords:output_type -> api.ListPasswordResp + 26, // 41: api.Dex.CreateConnector:output_type -> api.CreateConnectorResp + 29, // 42: api.Dex.UpdateConnector:output_type -> api.UpdateConnectorResp + 31, // 43: api.Dex.DeleteConnector:output_type -> api.DeleteConnectorResp + 33, // 44: api.Dex.ListConnectors:output_type -> api.ListConnectorResp + 35, // 45: api.Dex.GetVersion:output_type -> api.VersionResp + 37, // 46: api.Dex.GetDiscovery:output_type -> api.DiscoveryResp + 40, // 47: api.Dex.ListRefresh:output_type -> api.ListRefreshResp + 42, // 48: api.Dex.RevokeRefresh:output_type -> api.RevokeRefreshResp + 44, // 49: api.Dex.VerifyPassword:output_type -> api.VerifyPasswordResp + 32, // [32:50] is the sub-list for method output_type + 14, // [14:32] is the sub-list for method input_type + 14, // [14:14] is the sub-list for extension type_name + 14, // [14:14] is the sub-list for extension extendee + 0, // [0:14] is the sub-list for field type_name } func init() { file_api_v2_api_proto_init() } @@ -2779,13 +3012,14 @@ func file_api_v2_api_proto_init() { if File_api_v2_api_proto != nil { return } + file_api_v2_api_proto_msgTypes[23].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_v2_api_proto_rawDesc), len(file_api_v2_api_proto_rawDesc)), NumEnums: 0, - NumMessages: 42, + NumMessages: 45, NumExtensions: 0, NumServices: 1, }, diff --git a/api/v2/api.proto b/api/v2/api.proto index dffe32125c..27a9ea387e 100644 --- a/api/v2/api.proto +++ b/api/v2/api.proto @@ -147,6 +147,34 @@ message Connector { string name = 3; bytes config = 4; repeated string grant_types = 5; + // Per-connector expiry overrides. Each field must be at least as strict + // as the corresponding global value or the write will be rejected. + ConnectorExpiry expiry = 6; +} + +// ConnectorExpiry holds per-connector overrides for token lifetimes. +// Duration strings use the same format as the top-level expiry config +// ("5m", "24h"). Empty strings mean "inherit the global value". +message ConnectorExpiry { + string id_tokens = 1; + ConnectorRefreshExpiry refresh_tokens = 2; +} + +// ConnectorRefreshExpiry holds per-connector refresh-token policy overrides. +message ConnectorRefreshExpiry { + // Absent means "inherit"; present (true/false) overrides the global value. + optional bool disable_rotation = 1; + string reuse_interval = 2; + string absolute_lifetime = 3; + string valid_if_not_used_for = 4; +} + +// ConnectorExpiryUpdate distinguishes "leave the override alone" from +// "change it". An absent ConnectorExpiryUpdate leaves the existing value +// in place; a present one with nil Value clears the override; a present +// one with a non-nil Value installs that override. +message ConnectorExpiryUpdate { + ConnectorExpiry value = 1; } // CreateConnectorReq is a request to make a connector. @@ -176,6 +204,9 @@ message UpdateConnectorReq { // An empty grant_types list means unrestricted (all grant types allowed). // If not set (null), grant types are not modified. GrantTypes new_grant_types = 5; + // If set, updates the connector's expiry overrides. If unset, the + // existing expiry is left untouched. + ConnectorExpiryUpdate new_expiry = 6; } // UpdateConnectorResp returns the response from modifying an existing connector. diff --git a/cmd/dex/config.go b/cmd/dex/config.go index 4c436e8a9f..c94992a76e 100644 --- a/cmd/dex/config.go +++ b/cmd/dex/config.go @@ -652,9 +652,29 @@ func ToStorageConnector(c Connector) (storage.Connector, error) { Name: c.Name, Config: data, GrantTypes: c.GrantTypes, + Expiry: connectorExpiryToStorage(c.Expiry), }, nil } +// connectorExpiryToStorage flattens the YAML config struct into the storage +// type. Both share the same shape; the conversion is mechanical and exists +// purely to decouple the storage package from cmd/dex. +func connectorExpiryToStorage(e *ConnectorExpiry) *storage.ConnectorExpiry { + if e == nil { + return nil + } + out := &storage.ConnectorExpiry{IDTokens: e.IDTokens} + if e.RefreshTokens != nil { + out.RefreshTokens = &storage.ConnectorRefreshExpiry{ + DisableRotation: e.RefreshTokens.DisableRotation, + ReuseInterval: e.RefreshTokens.ReuseInterval, + AbsoluteLifetime: e.RefreshTokens.AbsoluteLifetime, + ValidIfNotUsedFor: e.RefreshTokens.ValidIfNotUsedFor, + } + } + return out +} + // Expiry holds configuration for the validity period of components. type Expiry struct { // SigningKeys defines the duration of time after which the SigningKeys will be rotated. diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index efb90ce0da..8966b6eb62 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -418,13 +418,17 @@ func runServe(options serveOptions) error { serverConfig.RefreshTokenPolicy = refreshTokenPolicy - connectorExpiryOverrides, err := buildConnectorExpiryOverrides( - logger, c.StaticConnectors, idTokensValidFor, c.Expiry.RefreshTokens, - ) + ceilings, err := buildExpiryCeilings(idTokensValidFor, c.Expiry.RefreshTokens) if err != nil { - return fmt.Errorf("invalid connector expiry config: %v", err) + return fmt.Errorf("invalid global expiry config: %v", err) + } + serverConfig.ExpiryCeilings = ceilings + serverConfig.GlobalRefreshDefaults = server.RefreshTokenDefaults{ + DisableRotation: c.Expiry.RefreshTokens.DisableRotation, + ValidIfNotUsedFor: c.Expiry.RefreshTokens.ValidIfNotUsedFor, + AbsoluteLifetime: c.Expiry.RefreshTokens.AbsoluteLifetime, + ReuseInterval: c.Expiry.RefreshTokens.ReuseInterval, } - serverConfig.ConnectorExpiryOverrides = connectorExpiryOverrides if featureflags.SessionsEnabled.Enabled() { sessionConfig, err := parseSessionConfig(c.Sessions) @@ -840,56 +844,19 @@ func parseSessionConfig(s *Sessions) (*server.SessionConfig, error) { return sc, nil } -// buildConnectorExpiryOverrides parses per-connector `expiry` overrides, -// rejecting any override that would loosen the corresponding global value. -// A global value left unset is treated as "no ceiling". `disableRotation` is -// exempt: it's a policy toggle rather than a quantity, so per-connector values -// are accepted either way. -func buildConnectorExpiryOverrides( - logger *slog.Logger, - connectors []Connector, - globalIDTokens time.Duration, - globalRefresh RefreshToken, -) (map[string]server.ConnectorExpiryOverride, error) { - refreshCeilings, err := parseRefreshCeilings(globalRefresh) - if err != nil { - return nil, err - } - - overrides := map[string]server.ConnectorExpiryOverride{} - for _, conn := range connectors { - if conn.Expiry == nil { - continue - } - - override, err := parseConnectorExpiry(logger, conn, globalIDTokens, globalRefresh, refreshCeilings) - if err != nil { - return nil, fmt.Errorf("connector %q: %v", conn.ID, err) - } - overrides[conn.ID] = override - } - - return overrides, nil -} - -// refreshCeilings holds the parsed global refresh-token ceilings. A zero value -// means "no ceiling" for that field. -type refreshCeilings struct { - absoluteLifetime time.Duration - validIfNotUsedFor time.Duration - reuseInterval time.Duration -} - -func parseRefreshCeilings(g RefreshToken) (refreshCeilings, error) { - var c refreshCeilings +// buildExpiryCeilings parses the global expiry config into the ceilings used +// to validate per-connector overrides. The server uses these for both static +// YAML connectors at startup and dynamic API writes at runtime. +func buildExpiryCeilings(globalIDTokens time.Duration, globalRefresh RefreshToken) (server.ExpiryCeilings, error) { + c := server.ExpiryCeilings{IDTokens: globalIDTokens} for _, f := range []struct { name string value string dst *time.Duration }{ - {"expiry.refreshTokens.absoluteLifetime", g.AbsoluteLifetime, &c.absoluteLifetime}, - {"expiry.refreshTokens.validIfNotUsedFor", g.ValidIfNotUsedFor, &c.validIfNotUsedFor}, - {"expiry.refreshTokens.reuseInterval", g.ReuseInterval, &c.reuseInterval}, + {"expiry.refreshTokens.absoluteLifetime", globalRefresh.AbsoluteLifetime, &c.RefreshAbsoluteLifetime}, + {"expiry.refreshTokens.validIfNotUsedFor", globalRefresh.ValidIfNotUsedFor, &c.RefreshValidIfNotUsedFor}, + {"expiry.refreshTokens.reuseInterval", globalRefresh.ReuseInterval, &c.RefreshReuseInterval}, } { if f.value == "" { continue @@ -903,83 +870,6 @@ func parseRefreshCeilings(g RefreshToken) (refreshCeilings, error) { return c, nil } -func parseConnectorExpiry( - logger *slog.Logger, - conn Connector, - globalIDTokens time.Duration, - globalRefresh RefreshToken, - ceilings refreshCeilings, -) (server.ConnectorExpiryOverride, error) { - var override server.ConnectorExpiryOverride - - if conn.Expiry.IDTokens != "" { - d, err := parseWithCeiling("expiry.idTokens", conn.Expiry.IDTokens, globalIDTokens) - if err != nil { - return override, err - } - override.IDTokensValidFor = d - logger.Info("config connector id tokens", "connector_id", conn.ID, "valid_for", d) - } - - rt := conn.Expiry.RefreshTokens - if rt == nil { - return override, nil - } - - for _, f := range []struct { - name string - value string - ceiling time.Duration - }{ - {"expiry.refreshTokens.absoluteLifetime", rt.AbsoluteLifetime, ceilings.absoluteLifetime}, - {"expiry.refreshTokens.validIfNotUsedFor", rt.ValidIfNotUsedFor, ceilings.validIfNotUsedFor}, - {"expiry.refreshTokens.reuseInterval", rt.ReuseInterval, ceilings.reuseInterval}, - } { - if f.value == "" || f.ceiling == 0 { - continue - } - if _, err := parseWithCeiling(f.name, f.value, f.ceiling); err != nil { - return override, err - } - } - - disableRotation := globalRefresh.DisableRotation - if rt.DisableRotation != nil { - disableRotation = *rt.DisableRotation - } - validIfNotUsedFor := rt.ValidIfNotUsedFor - if validIfNotUsedFor == "" { - validIfNotUsedFor = globalRefresh.ValidIfNotUsedFor - } - absoluteLifetime := rt.AbsoluteLifetime - if absoluteLifetime == "" { - absoluteLifetime = globalRefresh.AbsoluteLifetime - } - reuseInterval := rt.ReuseInterval - if reuseInterval == "" { - reuseInterval = globalRefresh.ReuseInterval - } - - connLogger := logger.With("connector_id", conn.ID) - policy, err := server.NewRefreshTokenPolicy(connLogger, disableRotation, validIfNotUsedFor, absoluteLifetime, reuseInterval) - if err != nil { - return override, fmt.Errorf("refresh token policy: %v", err) - } - override.RefreshTokenPolicy = policy - return override, nil -} - -func parseWithCeiling(field, value string, ceiling time.Duration) (time.Duration, error) { - d, err := time.ParseDuration(value) - if err != nil { - return 0, fmt.Errorf("parse %s: %v", field, err) - } - if d > ceiling { - return 0, fmt.Errorf("%s (%s) exceeds the global value (%s)", field, d, ceiling) - } - return d, nil -} - func buildMFAProviders(authenticators []MFAAuthenticator, issuerURL string, logger *slog.Logger) map[string]server.MFAProvider { if len(authenticators) == 0 { return nil diff --git a/cmd/dex/serve_test.go b/cmd/dex/serve_test.go index 37f4aca72e..fe0f04b236 100644 --- a/cmd/dex/serve_test.go +++ b/cmd/dex/serve_test.go @@ -7,6 +7,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/dexidp/dex/server" ) func TestNewLogger(t *testing.T) { @@ -30,75 +32,50 @@ func TestNewLogger(t *testing.T) { }) } -func TestBuildConnectorExpiryOverrides_IDTokensExceedsGlobal(t *testing.T) { - _, err := buildConnectorExpiryOverrides( - slog.New(slog.DiscardHandler), - []Connector{{ - ID: "c1", Type: "mock", Name: "c1", - Expiry: &ConnectorExpiry{IDTokens: "48h"}, - }}, - 24*time.Hour, RefreshToken{}, - ) - require.Error(t, err) - assert.Contains(t, err.Error(), "expiry.idTokens (48h0m0s) exceeds the global value (24h0m0s)") +func TestBuildExpiryCeilings(t *testing.T) { + c, err := buildExpiryCeilings(24*time.Hour, RefreshToken{ + AbsoluteLifetime: "100h", + ValidIfNotUsedFor: "24h", + ReuseInterval: "3s", + }) + require.NoError(t, err) + assert.Equal(t, server.ExpiryCeilings{ + IDTokens: 24 * time.Hour, + RefreshAbsoluteLifetime: 100 * time.Hour, + RefreshValidIfNotUsedFor: 24 * time.Hour, + RefreshReuseInterval: 3 * time.Second, + }, c) } -func TestBuildConnectorExpiryOverrides_RefreshAbsoluteLifetimeExceedsGlobal(t *testing.T) { - _, err := buildConnectorExpiryOverrides( - slog.New(slog.DiscardHandler), - []Connector{{ - ID: "c1", Type: "mock", Name: "c1", - Expiry: &ConnectorExpiry{ - RefreshTokens: &ConnectorRefreshToken{AbsoluteLifetime: "100h"}, - }, - }}, - 24*time.Hour, RefreshToken{AbsoluteLifetime: "48h"}, - ) - require.Error(t, err) - assert.Contains(t, err.Error(), "expiry.refreshTokens.absoluteLifetime (100h0m0s) exceeds the global value (48h0m0s)") +func TestBuildExpiryCeilingsRefreshUnset(t *testing.T) { + c, err := buildExpiryCeilings(24*time.Hour, RefreshToken{}) + require.NoError(t, err) + assert.Equal(t, server.ExpiryCeilings{IDTokens: 24 * time.Hour}, c) } -func TestBuildConnectorExpiryOverrides_RefreshNoGlobalCeiling(t *testing.T) { - // Global absoluteLifetime unset ("disabled/infinite"); any override passes. - _, err := buildConnectorExpiryOverrides( - slog.New(slog.DiscardHandler), - []Connector{{ - ID: "c1", Type: "mock", Name: "c1", - Expiry: &ConnectorExpiry{ - RefreshTokens: &ConnectorRefreshToken{AbsoluteLifetime: "9000h"}, - }, - }}, - 24*time.Hour, RefreshToken{}, - ) - require.NoError(t, err) +func TestBuildExpiryCeilingsInvalidDuration(t *testing.T) { + _, err := buildExpiryCeilings(24*time.Hour, RefreshToken{AbsoluteLifetime: "not-a-duration"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "parse expiry.refreshTokens.absoluteLifetime") } -func TestBuildConnectorExpiryOverrides(t *testing.T) { +func TestConnectorExpiryToStorage(t *testing.T) { disable := true - overrides, err := buildConnectorExpiryOverrides( - slog.New(slog.DiscardHandler), - []Connector{ - {ID: "a", Type: "mock", Name: "a"}, - { - ID: "b", Type: "mock", Name: "b", - Expiry: &ConnectorExpiry{IDTokens: "30m"}, - }, - { - ID: "c", Type: "mock", Name: "c", - Expiry: &ConnectorExpiry{ - RefreshTokens: &ConnectorRefreshToken{DisableRotation: &disable}, - }, - }, + got := connectorExpiryToStorage(&ConnectorExpiry{ + IDTokens: "15m", + RefreshTokens: &ConnectorRefreshToken{ + DisableRotation: &disable, + AbsoluteLifetime: "24h", + ValidIfNotUsedFor: "1h", + ReuseInterval: "3s", }, - 24*time.Hour, - RefreshToken{AbsoluteLifetime: "48h"}, - ) - require.NoError(t, err) - - assert.NotContains(t, overrides, "a", "connector without expiry field should not appear") - assert.Equal(t, 30*time.Minute, overrides["b"].IDTokensValidFor) + }) + require.NotNil(t, got) + assert.Equal(t, "15m", got.IDTokens) + require.NotNil(t, got.RefreshTokens) + assert.Equal(t, "24h", got.RefreshTokens.AbsoluteLifetime) + require.NotNil(t, got.RefreshTokens.DisableRotation) + assert.True(t, *got.RefreshTokens.DisableRotation) - policy := overrides["c"].RefreshTokenPolicy - require.NotNil(t, policy) - assert.False(t, policy.RotationEnabled(), "per-connector DisableRotation=true should disable rotation") + assert.Nil(t, connectorExpiryToStorage(nil)) } diff --git a/server/api.go b/server/api.go index c5ee787478..65a7a8cefb 100644 --- a/server/api.go +++ b/server/api.go @@ -473,6 +473,13 @@ func (d dexAPI) CreateConnector(ctx context.Context, req *api.CreateConnectorReq } } + expiry := connectorExpiryFromProto(req.Connector.Expiry) + if d.server != nil { + if err := ValidateConnectorExpiry(expiry, d.server.ExpiryCeilings()); err != nil { + return nil, fmt.Errorf("invalid expiry: %v", err) + } + } + c := storage.Connector{ ID: req.Connector.Id, Name: req.Connector.Name, @@ -480,6 +487,7 @@ func (d dexAPI) CreateConnector(ctx context.Context, req *api.CreateConnectorReq ResourceVersion: "1", Config: req.Connector.Config, GrantTypes: req.Connector.GrantTypes, + Expiry: expiry, } if err := d.s.CreateConnector(ctx, c); err != nil { if err == storage.ErrAlreadyExists { @@ -489,8 +497,12 @@ func (d dexAPI) CreateConnector(ctx context.Context, req *api.CreateConnectorReq return nil, fmt.Errorf("create connector: %v", err) } - // Make sure we don't reuse stale entries in the cache if d.server != nil { + // Refresh the runtime expiry override before evicting the cached + // connector — both must agree with what was just persisted. + if err := d.server.upsertConnectorExpiryOverride(req.Connector.Id, expiry); err != nil { + d.logger.Error("api: failed to install connector expiry override", "err", err) + } d.server.CloseConnector(req.Connector.Id) } @@ -509,7 +521,8 @@ func (d dexAPI) UpdateConnector(ctx context.Context, req *api.UpdateConnectorReq hasUpdate := len(req.NewConfig) != 0 || req.NewName != "" || req.NewType != "" || - req.NewGrantTypes != nil + req.NewGrantTypes != nil || + req.NewExpiry != nil if !hasUpdate { return nil, errors.New("nothing to update") } @@ -526,6 +539,23 @@ func (d dexAPI) UpdateConnector(ctx context.Context, req *api.UpdateConnectorReq } } + // expiryUpdate captures the new value (possibly nil to clear) when the + // caller has chosen to modify the field, and false when the field should + // be left untouched. + var ( + expiryUpdated bool + newExpiry *storage.ConnectorExpiry + ) + if req.NewExpiry != nil { + expiryUpdated = true + newExpiry = connectorExpiryUpdateFromProto(req.NewExpiry) + if d.server != nil { + if err := ValidateConnectorExpiry(newExpiry, d.server.ExpiryCeilings()); err != nil { + return nil, fmt.Errorf("invalid expiry: %v", err) + } + } + } + updater := func(old storage.Connector) (storage.Connector, error) { if req.NewType != "" { old.Type = req.NewType @@ -543,6 +573,10 @@ func (d dexAPI) UpdateConnector(ctx context.Context, req *api.UpdateConnectorReq old.GrantTypes = req.NewGrantTypes.GrantTypes } + if expiryUpdated { + old.Expiry = newExpiry + } + if rev, err := strconv.Atoi(defaultTo(old.ResourceVersion, "0")); err == nil { old.ResourceVersion = strconv.Itoa(rev + 1) } @@ -558,6 +592,15 @@ func (d dexAPI) UpdateConnector(ctx context.Context, req *api.UpdateConnectorReq return nil, fmt.Errorf("update connector: %v", err) } + if d.server != nil && expiryUpdated { + if err := d.server.upsertConnectorExpiryOverride(req.Id, newExpiry); err != nil { + d.logger.Error("api: failed to refresh connector expiry override", "err", err) + } + d.server.CloseConnector(req.Id) + } else if d.server != nil { + d.server.CloseConnector(req.Id) + } + return &api.UpdateConnectorResp{}, nil } @@ -579,6 +622,12 @@ func (d dexAPI) DeleteConnector(ctx context.Context, req *api.DeleteConnectorReq return nil, fmt.Errorf("delete connector: %v", err) } + if d.server != nil { + // upsert with nil clears any installed override. + _ = d.server.upsertConnectorExpiryOverride(req.Id, nil) + d.server.CloseConnector(req.Id) + } + return &api.DeleteConnectorResp{}, nil } @@ -601,6 +650,7 @@ func (d dexAPI) ListConnectors(ctx context.Context, req *api.ListConnectorReq) ( Type: connector.Type, Config: connector.Config, GrantTypes: connector.GrantTypes, + Expiry: connectorExpiryToProto(connector.Expiry), } connectors = append(connectors, &c) } @@ -617,3 +667,46 @@ func defaultTo[T comparable](v, def T) T { } return v } + +// connectorExpiryFromProto converts an api.ConnectorExpiry into the storage +// type. A nil input yields nil so the caller can persist "no override". +func connectorExpiryFromProto(p *api.ConnectorExpiry) *storage.ConnectorExpiry { + if p == nil { + return nil + } + e := &storage.ConnectorExpiry{IDTokens: p.IdTokens} + if p.RefreshTokens != nil { + e.RefreshTokens = &storage.ConnectorRefreshExpiry{ + DisableRotation: p.RefreshTokens.DisableRotation, + ReuseInterval: p.RefreshTokens.ReuseInterval, + AbsoluteLifetime: p.RefreshTokens.AbsoluteLifetime, + ValidIfNotUsedFor: p.RefreshTokens.ValidIfNotUsedFor, + } + } + return e +} + +// connectorExpiryUpdateFromProto unwraps the optional update envelope. A +// present update with a nil inner Value means "clear the override". +func connectorExpiryUpdateFromProto(p *api.ConnectorExpiryUpdate) *storage.ConnectorExpiry { + if p == nil { + return nil + } + return connectorExpiryFromProto(p.Value) +} + +func connectorExpiryToProto(e *storage.ConnectorExpiry) *api.ConnectorExpiry { + if e == nil { + return nil + } + p := &api.ConnectorExpiry{IdTokens: e.IDTokens} + if e.RefreshTokens != nil { + p.RefreshTokens = &api.ConnectorRefreshExpiry{ + DisableRotation: e.RefreshTokens.DisableRotation, + ReuseInterval: e.RefreshTokens.ReuseInterval, + AbsoluteLifetime: e.RefreshTokens.AbsoluteLifetime, + ValidIfNotUsedFor: e.RefreshTokens.ValidIfNotUsedFor, + } + } + return p +} diff --git a/server/api_test.go b/server/api_test.go index 09cfa6783f..9d2afc6d84 100644 --- a/server/api_test.go +++ b/server/api_test.go @@ -34,13 +34,20 @@ func newLogger(t *testing.T) *slog.Logger { // newAPI constructs a gRCP client connected to a backing server. func newAPI(t *testing.T, s storage.Storage, logger *slog.Logger) *apiClient { + return newAPIWithServer(t, s, logger, nil) +} + +// newAPIWithServer is like newAPI but wires a *Server into the gRPC handlers, +// enabling validation paths that depend on Server state (notably expiry +// override validation). +func newAPIWithServer(t *testing.T, s storage.Storage, logger *slog.Logger, srv *Server) *apiClient { l, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } serv := grpc.NewServer() - api.RegisterDexServer(serv, NewAPI(s, logger, "test", nil)) + api.RegisterDexServer(serv, NewAPI(s, logger, "test", srv)) go serv.Serve(l) // NewClient will retry automatically if the serv.Serve() goroutine @@ -538,6 +545,73 @@ func TestCreateConnector(t *testing.T) { } } +func TestCreateConnectorExpiryHierarchy(t *testing.T) { + t.Setenv("DEX_API_CONNECTORS_CRUD", "true") + + logger := newLogger(t) + s := memory.New(logger) + + srv := &Server{ + logger: logger, + idTokensValidFor: time.Hour, + expiryCeilings: ExpiryCeilings{IDTokens: time.Hour}, + connectorExpiryOverrides: map[string]ConnectorExpiryOverride{}, + connectors: map[string]Connector{}, + } + client := newAPIWithServer(t, s, logger, srv) + defer client.Close() + + ctx := t.Context() + base := &api.Connector{ + Id: "c1", + Name: "c1", + Type: "mockCallback", + Config: []byte(`{}`), + } + + t.Run("override exceeding global is rejected", func(t *testing.T) { + req := &api.CreateConnectorReq{ + Connector: &api.Connector{ + Id: "looser", Name: base.Name, Type: base.Type, Config: base.Config, + Expiry: &api.ConnectorExpiry{IdTokens: "48h"}, + }, + } + if _, err := client.CreateConnector(ctx, req); err == nil { + t.Fatal("expected validation error for override above global") + } else if !strings.Contains(err.Error(), "exceeds the global value") { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("override within ceiling is accepted and installed", func(t *testing.T) { + req := &api.CreateConnectorReq{ + Connector: &api.Connector{ + Id: base.Id, Name: base.Name, Type: base.Type, Config: base.Config, + Expiry: &api.ConnectorExpiry{IdTokens: "10m"}, + }, + } + if _, err := client.CreateConnector(ctx, req); err != nil { + t.Fatalf("create connector: %v", err) + } + if got := srv.idTokensValidForConn(base.Id); got != 10*time.Minute { + t.Fatalf("override not installed: got %s, want 10m", got) + } + }) + + t.Run("update can clear the override", func(t *testing.T) { + req := &api.UpdateConnectorReq{ + Id: base.Id, + NewExpiry: &api.ConnectorExpiryUpdate{}, // present, Value nil = clear + } + if _, err := client.UpdateConnector(ctx, req); err != nil { + t.Fatalf("update connector: %v", err) + } + if got := srv.idTokensValidForConn(base.Id); got != time.Hour { + t.Fatalf("override not cleared: got %s, want 1h", got) + } + }) +} + func TestUpdateConnector(t *testing.T) { t.Setenv("DEX_API_CONNECTORS_CRUD", "true") diff --git a/server/expiry.go b/server/expiry.go new file mode 100644 index 0000000000..61233909d4 --- /dev/null +++ b/server/expiry.go @@ -0,0 +1,132 @@ +package server + +import ( + "fmt" + "log/slog" + "time" + + "github.com/dexidp/dex/storage" +) + +// ExpiryCeilings holds the parsed global expiry values that per-connector +// overrides must not exceed. A zero duration means "no ceiling" — i.e. the +// global value is unset/disabled, so any override is acceptable. +type ExpiryCeilings struct { + IDTokens time.Duration + RefreshAbsoluteLifetime time.Duration + RefreshValidIfNotUsedFor time.Duration + RefreshReuseInterval time.Duration +} + +// RefreshTokenDefaults are the global refresh-token configuration strings. +// Per-connector overrides inherit unset fields from these values when +// constructing a RefreshTokenPolicy. +type RefreshTokenDefaults struct { + DisableRotation bool + ValidIfNotUsedFor string + AbsoluteLifetime string + ReuseInterval string +} + +// ValidateConnectorExpiry rejects per-connector overrides that loosen the +// global policy. DisableRotation is exempt: it's a policy toggle, not a +// quantity, so "stricter" has no natural direction. +// +// This function is the single source of truth for the hierarchy rule. It is +// called from both the static YAML load path and every gRPC API write so +// that no configuration modification can ever bypass it. +func ValidateConnectorExpiry(e *storage.ConnectorExpiry, c ExpiryCeilings) error { + if e == nil { + return nil + } + if err := checkCeiling("expiry.idTokens", e.IDTokens, c.IDTokens); err != nil { + return err + } + if e.RefreshTokens == nil { + return nil + } + for _, f := range []struct { + name string + value string + ceiling time.Duration + }{ + {"expiry.refreshTokens.absoluteLifetime", e.RefreshTokens.AbsoluteLifetime, c.RefreshAbsoluteLifetime}, + {"expiry.refreshTokens.validIfNotUsedFor", e.RefreshTokens.ValidIfNotUsedFor, c.RefreshValidIfNotUsedFor}, + {"expiry.refreshTokens.reuseInterval", e.RefreshTokens.ReuseInterval, c.RefreshReuseInterval}, + } { + if err := checkCeiling(f.name, f.value, f.ceiling); err != nil { + return err + } + } + return nil +} + +func checkCeiling(field, value string, ceiling time.Duration) error { + if value == "" || ceiling == 0 { + return nil + } + d, err := time.ParseDuration(value) + if err != nil { + return fmt.Errorf("parse %s: %v", field, err) + } + if d > ceiling { + return fmt.Errorf("%s (%s) exceeds the global value (%s)", field, d, ceiling) + } + return nil +} + +// buildConnectorExpiryOverride parses a (pre-validated) storage.ConnectorExpiry +// into a ConnectorExpiryOverride. Unset string fields inherit from the global +// refresh defaults so the resulting RefreshTokenPolicy carries the correct +// effective values. +func buildConnectorExpiryOverride( + logger *slog.Logger, + connectorID string, + e *storage.ConnectorExpiry, + defaults RefreshTokenDefaults, +) (ConnectorExpiryOverride, error) { + var override ConnectorExpiryOverride + if e == nil { + return override, nil + } + + if e.IDTokens != "" { + d, err := time.ParseDuration(e.IDTokens) + if err != nil { + return override, fmt.Errorf("parse expiry.idTokens: %v", err) + } + override.IDTokensValidFor = d + } + + rt := e.RefreshTokens + if rt == nil { + return override, nil + } + + disableRotation := defaults.DisableRotation + if rt.DisableRotation != nil { + disableRotation = *rt.DisableRotation + } + validIfNotUsedFor := rt.ValidIfNotUsedFor + if validIfNotUsedFor == "" { + validIfNotUsedFor = defaults.ValidIfNotUsedFor + } + absoluteLifetime := rt.AbsoluteLifetime + if absoluteLifetime == "" { + absoluteLifetime = defaults.AbsoluteLifetime + } + reuseInterval := rt.ReuseInterval + if reuseInterval == "" { + reuseInterval = defaults.ReuseInterval + } + + policy, err := NewRefreshTokenPolicy( + logger.With("connector_id", connectorID), + disableRotation, validIfNotUsedFor, absoluteLifetime, reuseInterval, + ) + if err != nil { + return override, fmt.Errorf("refresh token policy: %v", err) + } + override.RefreshTokenPolicy = policy + return override, nil +} diff --git a/server/expiry_test.go b/server/expiry_test.go new file mode 100644 index 0000000000..9bbce90bf7 --- /dev/null +++ b/server/expiry_test.go @@ -0,0 +1,131 @@ +package server + +import ( + "log/slog" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexidp/dex/storage" +) + +func TestValidateConnectorExpiry_Nil(t *testing.T) { + require.NoError(t, ValidateConnectorExpiry(nil, ExpiryCeilings{})) +} + +func TestValidateConnectorExpiry_IDTokensExceeds(t *testing.T) { + err := ValidateConnectorExpiry( + &storage.ConnectorExpiry{IDTokens: "48h"}, + ExpiryCeilings{IDTokens: 24 * time.Hour}, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "expiry.idTokens (48h0m0s) exceeds the global value (24h0m0s)") +} + +func TestValidateConnectorExpiry_NoCeiling(t *testing.T) { + // Global unset → no ceiling. + require.NoError(t, ValidateConnectorExpiry( + &storage.ConnectorExpiry{IDTokens: "48h"}, + ExpiryCeilings{}, + )) +} + +func TestValidateConnectorExpiry_RefreshAbsoluteLifetimeExceeds(t *testing.T) { + err := ValidateConnectorExpiry( + &storage.ConnectorExpiry{ + RefreshTokens: &storage.ConnectorRefreshExpiry{AbsoluteLifetime: "100h"}, + }, + ExpiryCeilings{RefreshAbsoluteLifetime: 24 * time.Hour}, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "expiry.refreshTokens.absoluteLifetime (100h0m0s) exceeds the global value (24h0m0s)") +} + +func TestValidateConnectorExpiry_AllFieldsBelowCeiling(t *testing.T) { + disable := true + require.NoError(t, ValidateConnectorExpiry( + &storage.ConnectorExpiry{ + IDTokens: "10m", + RefreshTokens: &storage.ConnectorRefreshExpiry{ + DisableRotation: &disable, + ReuseInterval: "1s", + AbsoluteLifetime: "1h", + ValidIfNotUsedFor: "30m", + }, + }, + ExpiryCeilings{ + IDTokens: 1 * time.Hour, + RefreshAbsoluteLifetime: 24 * time.Hour, + RefreshValidIfNotUsedFor: 1 * time.Hour, + RefreshReuseInterval: 3 * time.Second, + }, + )) +} + +func TestBuildConnectorExpiryOverride_Nil(t *testing.T) { + got, err := buildConnectorExpiryOverride(slog.New(slog.DiscardHandler), "c1", nil, RefreshTokenDefaults{}) + require.NoError(t, err) + assert.Zero(t, got.IDTokensValidFor) + assert.Nil(t, got.RefreshTokenPolicy) +} + +func TestBuildConnectorExpiryOverride_IDTokensOnly(t *testing.T) { + got, err := buildConnectorExpiryOverride( + slog.New(slog.DiscardHandler), + "c1", + &storage.ConnectorExpiry{IDTokens: "5m"}, + RefreshTokenDefaults{}, + ) + require.NoError(t, err) + assert.Equal(t, 5*time.Minute, got.IDTokensValidFor) + assert.Nil(t, got.RefreshTokenPolicy) +} + +func TestBuildConnectorExpiryOverride_RefreshInheritsGlobals(t *testing.T) { + disable := true + got, err := buildConnectorExpiryOverride( + slog.New(slog.DiscardHandler), + "c1", + &storage.ConnectorExpiry{ + RefreshTokens: &storage.ConnectorRefreshExpiry{ + DisableRotation: &disable, + AbsoluteLifetime: "1h", + // ValidIfNotUsedFor and ReuseInterval omitted: inherit from defaults + }, + }, + RefreshTokenDefaults{ + DisableRotation: false, + ValidIfNotUsedFor: "30m", + AbsoluteLifetime: "100h", + ReuseInterval: "3s", + }, + ) + require.NoError(t, err) + require.NotNil(t, got.RefreshTokenPolicy) + assert.False(t, got.RefreshTokenPolicy.RotationEnabled(), "DisableRotation=true overrides global") +} + +func TestUpsertConnectorExpiryOverride(t *testing.T) { + s := &Server{ + logger: slog.New(slog.DiscardHandler), + idTokensValidFor: time.Hour, + expiryCeilings: ExpiryCeilings{IDTokens: time.Hour}, + connectorExpiryOverrides: map[string]ConnectorExpiryOverride{}, + } + + // Accept a tighter override. + require.NoError(t, s.upsertConnectorExpiryOverride("c1", &storage.ConnectorExpiry{IDTokens: "5m"})) + assert.Equal(t, 5*time.Minute, s.idTokensValidForConn("c1")) + + // Reject a looser override; map is left untouched. + err := s.upsertConnectorExpiryOverride("c2", &storage.ConnectorExpiry{IDTokens: "48h"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds the global value") + assert.Equal(t, time.Hour, s.idTokensValidForConn("c2"), "rejected override must not be installed") + + // Clearing the override via nil reverts to the global. + require.NoError(t, s.upsertConnectorExpiryOverride("c1", nil)) + assert.Equal(t, time.Hour, s.idTokensValidForConn("c1")) +} diff --git a/server/oauth2.go b/server/oauth2.go index 6d3681c88e..544602fa1c 100644 --- a/server/oauth2.go +++ b/server/oauth2.go @@ -344,12 +344,18 @@ func genSubject(userID string, connID string) (string, error) { } func (s *Server) idTokensValidForConn(connID string) time.Duration { - return value(s.connectorExpiryOverrides[connID].IDTokensValidFor, s.idTokensValidFor) + s.mu.Lock() + o := s.connectorExpiryOverrides[connID] + s.mu.Unlock() + return value(o.IDTokensValidFor, s.idTokensValidFor) } func (s *Server) refreshTokenPolicyForConn(connID string) *RefreshTokenPolicy { - if p := s.connectorExpiryOverrides[connID].RefreshTokenPolicy; p != nil { - return p + s.mu.Lock() + o := s.connectorExpiryOverrides[connID] + s.mu.Unlock() + if o.RefreshTokenPolicy != nil { + return o.RefreshTokenPolicy } return s.refreshTokenPolicy } diff --git a/server/server.go b/server/server.go index 03b9c33423..52ffd67b18 100644 --- a/server/server.go +++ b/server/server.go @@ -113,10 +113,13 @@ type Config struct { // Refresh token expiration settings RefreshTokenPolicy *RefreshTokenPolicy - // ConnectorExpiryOverrides, keyed by connector ID, overrides the global - // IDTokensValidFor and/or RefreshTokenPolicy for tokens issued through that - // connector. Missing entries or unset fields fall back to the global values. - ConnectorExpiryOverrides map[string]ConnectorExpiryOverride + // ExpiryCeilings define the upper bounds against which per-connector + // expiry overrides are validated. A zero duration means "no ceiling". + ExpiryCeilings ExpiryCeilings + + // GlobalRefreshDefaults provide the inheritance roots for per-connector + // refresh-token overrides that leave fields unset. + GlobalRefreshDefaults RefreshTokenDefaults // If set, the server will use this connector to handle password grants PasswordConnector string @@ -257,6 +260,12 @@ type Server struct { refreshTokenPolicy *RefreshTokenPolicy + // expiryCeilings and globalRefreshDefaults are immutable after construction. + expiryCeilings ExpiryCeilings + globalRefreshDefaults RefreshTokenDefaults + + // connectorExpiryOverrides is mutated by API CreateConnector / + // UpdateConnector / DeleteConnector handlers. Protected by mu. connectorExpiryOverrides map[string]ConnectorExpiryOverride logger *slog.Logger @@ -383,7 +392,9 @@ func newServer(ctx context.Context, c Config) (*Server, error) { authRequestsValidFor: value(c.AuthRequestsValidFor, 24*time.Hour), deviceRequestsValidFor: value(c.DeviceRequestsValidFor, 5*time.Minute), refreshTokenPolicy: c.RefreshTokenPolicy, - connectorExpiryOverrides: c.ConnectorExpiryOverrides, + expiryCeilings: c.ExpiryCeilings, + globalRefreshDefaults: c.GlobalRefreshDefaults, + connectorExpiryOverrides: map[string]ConnectorExpiryOverride{}, skipApproval: c.SkipApprovalScreen, alwaysShowLogin: c.AlwaysShowLoginScreen, now: now, @@ -407,6 +418,12 @@ func newServer(ctx context.Context, c Config) (*Server, error) { return nil, errors.New("server: no connectors specified") } + for _, conn := range storageConnectors { + if err := s.upsertConnectorExpiryOverride(conn.ID, conn.Expiry); err != nil { + return nil, fmt.Errorf("server: invalid connector expiry for %q: %v", conn.ID, err) + } + } + var failedCount int for _, conn := range storageConnectors { if _, err := s.OpenConnector(conn); err != nil { @@ -837,12 +854,47 @@ func (s *Server) OpenConnector(conn storage.Connector) (Connector, error) { } // CloseConnector removes the connector from the server's in-memory map. +// The expiry override is intentionally left alone here — call sites that +// need to drop it (DeleteConnector) do so explicitly via +// upsertConnectorExpiryOverride(id, nil) afterwards. func (s *Server) CloseConnector(id string) { s.mu.Lock() delete(s.connectors, id) s.mu.Unlock() } +// ExpiryCeilings returns the global expiry ceilings used to validate +// per-connector overrides. Callers should pass the result to +// ValidateConnectorExpiry before persisting any storage.ConnectorExpiry. +func (s *Server) ExpiryCeilings() ExpiryCeilings { + return s.expiryCeilings +} + +// upsertConnectorExpiryOverride validates the given storage.ConnectorExpiry +// against the server's expiry ceilings and, on success, updates the in-memory +// override map. Callers must invoke this every time a connector's expiry is +// written so that the live token-issuance path reflects the change. +func (s *Server) upsertConnectorExpiryOverride(id string, e *storage.ConnectorExpiry) error { + if err := ValidateConnectorExpiry(e, s.expiryCeilings); err != nil { + return err + } + override, err := buildConnectorExpiryOverride(s.logger, id, e, s.globalRefreshDefaults) + if err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + if e == nil { + delete(s.connectorExpiryOverrides, id) + return nil + } + s.connectorExpiryOverrides[id] = override + if override.IDTokensValidFor > 0 { + s.logger.Info("config connector id tokens", "connector_id", id, "valid_for", override.IDTokensValidFor) + } + return nil +} + // getConnector retrieves the connector object with the given id from the storage // and updates the connector list for server if necessary. func (s *Server) getConnector(ctx context.Context, id string) (Connector, error) { diff --git a/storage/conformance/conformance.go b/storage/conformance/conformance.go index 33ec950cfd..855a7088be 100644 --- a/storage/conformance/conformance.go +++ b/storage/conformance/conformance.go @@ -735,11 +735,19 @@ func testConnectorCRUD(t *testing.T, s storage.Storage) { id2 := storage.NewID() config2 := []byte(`{"redirectURI": "http://127.0.0.1:5556/dex/callback"}`) + disableRotation := true c2 := storage.Connector{ ID: id2, Type: "Mock", Name: "Mock", Config: config2, + Expiry: &storage.ConnectorExpiry{ + IDTokens: "5m", + RefreshTokens: &storage.ConnectorRefreshExpiry{ + DisableRotation: &disableRotation, + AbsoluteLifetime: "24h", + }, + }, } if err := s.CreateConnector(ctx, c2); err != nil { @@ -764,6 +772,7 @@ func testConnectorCRUD(t *testing.T, s storage.Storage) { if err := s.UpdateConnector(ctx, c1.ID, func(old storage.Connector) (storage.Connector, error) { old.Type = "oidc" old.GrantTypes = []string{"urn:ietf:params:oauth:grant-type:token-exchange"} + old.Expiry = &storage.ConnectorExpiry{IDTokens: "10m"} return old, nil }); err != nil { t.Fatalf("failed to update Connector: %v", err) @@ -771,6 +780,17 @@ func testConnectorCRUD(t *testing.T, s storage.Storage) { c1.Type = "oidc" c1.GrantTypes = []string{"urn:ietf:params:oauth:grant-type:token-exchange"} + c1.Expiry = &storage.ConnectorExpiry{IDTokens: "10m"} + getAndCompare(id1, c1) + + // Roundtrip clearing the expiry override. + if err := s.UpdateConnector(ctx, c1.ID, func(old storage.Connector) (storage.Connector, error) { + old.Expiry = nil + return old, nil + }); err != nil { + t.Fatalf("failed to clear connector expiry: %v", err) + } + c1.Expiry = nil getAndCompare(id1, c1) connectorList := []storage.Connector{c1, c2} diff --git a/storage/ent/client/connector.go b/storage/ent/client/connector.go index 21e7aec23c..8b6078f629 100644 --- a/storage/ent/client/connector.go +++ b/storage/ent/client/connector.go @@ -8,15 +8,17 @@ import ( // CreateConnector saves a connector into the database. func (d *Database) CreateConnector(ctx context.Context, connector storage.Connector) error { - _, err := d.client.Connector.Create(). + create := d.client.Connector.Create(). SetID(connector.ID). SetName(connector.Name). SetType(connector.Type). SetResourceVersion(connector.ResourceVersion). SetConfig(connector.Config). - SetGrantTypes(connector.GrantTypes). - Save(ctx) - if err != nil { + SetGrantTypes(connector.GrantTypes) + if connector.Expiry != nil { + create = create.SetExpiry(connector.Expiry) + } + if _, err := create.Save(ctx); err != nil { return convertDBError("create connector: %w", err) } return nil @@ -71,14 +73,18 @@ func (d *Database) UpdateConnector(ctx context.Context, id string, updater func( return rollback(tx, "update connector updating: %w", err) } - _, err = tx.Connector.UpdateOneID(newConnector.ID). + update := tx.Connector.UpdateOneID(newConnector.ID). SetName(newConnector.Name). SetType(newConnector.Type). SetResourceVersion(newConnector.ResourceVersion). SetConfig(newConnector.Config). - SetGrantTypes(newConnector.GrantTypes). - Save(ctx) - if err != nil { + SetGrantTypes(newConnector.GrantTypes) + if newConnector.Expiry == nil { + update = update.ClearExpiry() + } else { + update = update.SetExpiry(newConnector.Expiry) + } + if _, err = update.Save(ctx); err != nil { return rollback(tx, "update connector uploading: %w", err) } diff --git a/storage/ent/client/types.go b/storage/ent/client/types.go index 2e4c21789f..809801bf87 100644 --- a/storage/ent/client/types.go +++ b/storage/ent/client/types.go @@ -109,6 +109,7 @@ func toStorageConnector(c *db.Connector) storage.Connector { Name: c.Name, Config: c.Config, GrantTypes: c.GrantTypes, + Expiry: c.Expiry, } } diff --git a/storage/ent/db/connector.go b/storage/ent/db/connector.go index 812b45f704..b1419c3aac 100644 --- a/storage/ent/db/connector.go +++ b/storage/ent/db/connector.go @@ -9,6 +9,7 @@ import ( "entgo.io/ent" "entgo.io/ent/dialect/sql" + "github.com/dexidp/dex/storage" "github.com/dexidp/dex/storage/ent/db/connector" ) @@ -26,7 +27,9 @@ type Connector struct { // Config holds the value of the "config" field. Config []byte `json:"config,omitempty"` // GrantTypes holds the value of the "grant_types" field. - GrantTypes []string `json:"grant_types,omitempty"` + GrantTypes []string `json:"grant_types,omitempty"` + // Expiry holds the value of the "expiry" field. + Expiry *storage.ConnectorExpiry `json:"expiry,omitempty"` selectValues sql.SelectValues } @@ -35,7 +38,7 @@ func (*Connector) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case connector.FieldConfig, connector.FieldGrantTypes: + case connector.FieldConfig, connector.FieldGrantTypes, connector.FieldExpiry: values[i] = new([]byte) case connector.FieldID, connector.FieldType, connector.FieldName, connector.FieldResourceVersion: values[i] = new(sql.NullString) @@ -92,6 +95,14 @@ func (_m *Connector) assignValues(columns []string, values []any) error { return fmt.Errorf("unmarshal field grant_types: %w", err) } } + case connector.FieldExpiry: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field expiry", values[i]) + } else if value != nil && len(*value) > 0 { + if err := json.Unmarshal(*value, &_m.Expiry); err != nil { + return fmt.Errorf("unmarshal field expiry: %w", err) + } + } default: _m.selectValues.Set(columns[i], values[i]) } @@ -142,6 +153,9 @@ func (_m *Connector) String() string { builder.WriteString(", ") builder.WriteString("grant_types=") builder.WriteString(fmt.Sprintf("%v", _m.GrantTypes)) + builder.WriteString(", ") + builder.WriteString("expiry=") + builder.WriteString(fmt.Sprintf("%v", _m.Expiry)) builder.WriteByte(')') return builder.String() } diff --git a/storage/ent/db/connector/connector.go b/storage/ent/db/connector/connector.go index ca603f4dc3..ddcbc2ee13 100644 --- a/storage/ent/db/connector/connector.go +++ b/storage/ent/db/connector/connector.go @@ -21,6 +21,8 @@ const ( FieldConfig = "config" // FieldGrantTypes holds the string denoting the grant_types field in the database. FieldGrantTypes = "grant_types" + // FieldExpiry holds the string denoting the expiry field in the database. + FieldExpiry = "expiry" // Table holds the table name of the connector in the database. Table = "connectors" ) @@ -33,6 +35,7 @@ var Columns = []string{ FieldResourceVersion, FieldConfig, FieldGrantTypes, + FieldExpiry, } // ValidColumn reports if the column name is valid (part of the table columns). diff --git a/storage/ent/db/connector/where.go b/storage/ent/db/connector/where.go index a09bec6087..96b359518b 100644 --- a/storage/ent/db/connector/where.go +++ b/storage/ent/db/connector/where.go @@ -327,6 +327,16 @@ func GrantTypesNotNil() predicate.Connector { return predicate.Connector(sql.FieldNotNull(FieldGrantTypes)) } +// ExpiryIsNil applies the IsNil predicate on the "expiry" field. +func ExpiryIsNil() predicate.Connector { + return predicate.Connector(sql.FieldIsNull(FieldExpiry)) +} + +// ExpiryNotNil applies the NotNil predicate on the "expiry" field. +func ExpiryNotNil() predicate.Connector { + return predicate.Connector(sql.FieldNotNull(FieldExpiry)) +} + // And groups predicates with the AND operator between them. func And(predicates ...predicate.Connector) predicate.Connector { return predicate.Connector(sql.AndPredicates(predicates...)) diff --git a/storage/ent/db/connector_create.go b/storage/ent/db/connector_create.go index 4e9cc35b47..131a8359bc 100644 --- a/storage/ent/db/connector_create.go +++ b/storage/ent/db/connector_create.go @@ -9,6 +9,7 @@ import ( "entgo.io/ent/dialect/sql/sqlgraph" "entgo.io/ent/schema/field" + "github.com/dexidp/dex/storage" "github.com/dexidp/dex/storage/ent/db/connector" ) @@ -49,6 +50,12 @@ func (_c *ConnectorCreate) SetGrantTypes(v []string) *ConnectorCreate { return _c } +// SetExpiry sets the "expiry" field. +func (_c *ConnectorCreate) SetExpiry(v *storage.ConnectorExpiry) *ConnectorCreate { + _c.mutation.SetExpiry(v) + return _c +} + // SetID sets the "id" field. func (_c *ConnectorCreate) SetID(v string) *ConnectorCreate { _c.mutation.SetID(v) @@ -171,6 +178,10 @@ func (_c *ConnectorCreate) createSpec() (*Connector, *sqlgraph.CreateSpec) { _spec.SetField(connector.FieldGrantTypes, field.TypeJSON, value) _node.GrantTypes = value } + if value, ok := _c.mutation.Expiry(); ok { + _spec.SetField(connector.FieldExpiry, field.TypeJSON, value) + _node.Expiry = value + } return _node, _spec } diff --git a/storage/ent/db/connector_update.go b/storage/ent/db/connector_update.go index d9b58d04f7..24ce68abd3 100644 --- a/storage/ent/db/connector_update.go +++ b/storage/ent/db/connector_update.go @@ -11,6 +11,7 @@ import ( "entgo.io/ent/dialect/sql/sqlgraph" "entgo.io/ent/dialect/sql/sqljson" "entgo.io/ent/schema/field" + "github.com/dexidp/dex/storage" "github.com/dexidp/dex/storage/ent/db/connector" "github.com/dexidp/dex/storage/ent/db/predicate" ) @@ -94,6 +95,18 @@ func (_u *ConnectorUpdate) ClearGrantTypes() *ConnectorUpdate { return _u } +// SetExpiry sets the "expiry" field. +func (_u *ConnectorUpdate) SetExpiry(v *storage.ConnectorExpiry) *ConnectorUpdate { + _u.mutation.SetExpiry(v) + return _u +} + +// ClearExpiry clears the value of the "expiry" field. +func (_u *ConnectorUpdate) ClearExpiry() *ConnectorUpdate { + _u.mutation.ClearExpiry() + return _u +} + // Mutation returns the ConnectorMutation object of the builder. func (_u *ConnectorUpdate) Mutation() *ConnectorMutation { return _u.mutation @@ -176,6 +189,12 @@ func (_u *ConnectorUpdate) sqlSave(ctx context.Context) (_node int, err error) { if _u.mutation.GrantTypesCleared() { _spec.ClearField(connector.FieldGrantTypes, field.TypeJSON) } + if value, ok := _u.mutation.Expiry(); ok { + _spec.SetField(connector.FieldExpiry, field.TypeJSON, value) + } + if _u.mutation.ExpiryCleared() { + _spec.ClearField(connector.FieldExpiry, field.TypeJSON) + } if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil { if _, ok := err.(*sqlgraph.NotFoundError); ok { err = &NotFoundError{connector.Label} @@ -262,6 +281,18 @@ func (_u *ConnectorUpdateOne) ClearGrantTypes() *ConnectorUpdateOne { return _u } +// SetExpiry sets the "expiry" field. +func (_u *ConnectorUpdateOne) SetExpiry(v *storage.ConnectorExpiry) *ConnectorUpdateOne { + _u.mutation.SetExpiry(v) + return _u +} + +// ClearExpiry clears the value of the "expiry" field. +func (_u *ConnectorUpdateOne) ClearExpiry() *ConnectorUpdateOne { + _u.mutation.ClearExpiry() + return _u +} + // Mutation returns the ConnectorMutation object of the builder. func (_u *ConnectorUpdateOne) Mutation() *ConnectorMutation { return _u.mutation @@ -374,6 +405,12 @@ func (_u *ConnectorUpdateOne) sqlSave(ctx context.Context) (_node *Connector, er if _u.mutation.GrantTypesCleared() { _spec.ClearField(connector.FieldGrantTypes, field.TypeJSON) } + if value, ok := _u.mutation.Expiry(); ok { + _spec.SetField(connector.FieldExpiry, field.TypeJSON, value) + } + if _u.mutation.ExpiryCleared() { + _spec.ClearField(connector.FieldExpiry, field.TypeJSON) + } _node = &Connector{config: _u.config} _spec.Assign = _node.assignValues _spec.ScanValues = _node.scanValues diff --git a/storage/ent/db/migrate/schema.go b/storage/ent/db/migrate/schema.go index 9ebf5faa0a..3b0a9031dd 100644 --- a/storage/ent/db/migrate/schema.go +++ b/storage/ent/db/migrate/schema.go @@ -97,6 +97,7 @@ var ( {Name: "resource_version", Type: field.TypeString, Size: 2147483647, SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, {Name: "config", Type: field.TypeBytes}, {Name: "grant_types", Type: field.TypeJSON, Nullable: true}, + {Name: "expiry", Type: field.TypeJSON, Nullable: true}, } // ConnectorsTable holds the schema information for the "connectors" table. ConnectorsTable = &schema.Table{ diff --git a/storage/ent/db/mutation.go b/storage/ent/db/mutation.go index 47f98310f5..8531c003d2 100644 --- a/storage/ent/db/mutation.go +++ b/storage/ent/db/mutation.go @@ -3968,6 +3968,7 @@ type ConnectorMutation struct { _config *[]byte grant_types *[]string appendgrant_types []string + expiry **storage.ConnectorExpiry clearedFields map[string]struct{} done bool oldValue func(context.Context) (*Connector, error) @@ -4287,6 +4288,55 @@ func (m *ConnectorMutation) ResetGrantTypes() { delete(m.clearedFields, connector.FieldGrantTypes) } +// SetExpiry sets the "expiry" field. +func (m *ConnectorMutation) SetExpiry(se *storage.ConnectorExpiry) { + m.expiry = &se +} + +// Expiry returns the value of the "expiry" field in the mutation. +func (m *ConnectorMutation) Expiry() (r *storage.ConnectorExpiry, exists bool) { + v := m.expiry + if v == nil { + return + } + return *v, true +} + +// OldExpiry returns the old "expiry" field's value of the Connector entity. +// If the Connector object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *ConnectorMutation) OldExpiry(ctx context.Context) (v *storage.ConnectorExpiry, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldExpiry is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldExpiry requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldExpiry: %w", err) + } + return oldValue.Expiry, nil +} + +// ClearExpiry clears the value of the "expiry" field. +func (m *ConnectorMutation) ClearExpiry() { + m.expiry = nil + m.clearedFields[connector.FieldExpiry] = struct{}{} +} + +// ExpiryCleared returns if the "expiry" field was cleared in this mutation. +func (m *ConnectorMutation) ExpiryCleared() bool { + _, ok := m.clearedFields[connector.FieldExpiry] + return ok +} + +// ResetExpiry resets all changes to the "expiry" field. +func (m *ConnectorMutation) ResetExpiry() { + m.expiry = nil + delete(m.clearedFields, connector.FieldExpiry) +} + // Where appends a list predicates to the ConnectorMutation builder. func (m *ConnectorMutation) Where(ps ...predicate.Connector) { m.predicates = append(m.predicates, ps...) @@ -4321,7 +4371,7 @@ func (m *ConnectorMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *ConnectorMutation) Fields() []string { - fields := make([]string, 0, 5) + fields := make([]string, 0, 6) if m._type != nil { fields = append(fields, connector.FieldType) } @@ -4337,6 +4387,9 @@ func (m *ConnectorMutation) Fields() []string { if m.grant_types != nil { fields = append(fields, connector.FieldGrantTypes) } + if m.expiry != nil { + fields = append(fields, connector.FieldExpiry) + } return fields } @@ -4355,6 +4408,8 @@ func (m *ConnectorMutation) Field(name string) (ent.Value, bool) { return m.Config() case connector.FieldGrantTypes: return m.GrantTypes() + case connector.FieldExpiry: + return m.Expiry() } return nil, false } @@ -4374,6 +4429,8 @@ func (m *ConnectorMutation) OldField(ctx context.Context, name string) (ent.Valu return m.OldConfig(ctx) case connector.FieldGrantTypes: return m.OldGrantTypes(ctx) + case connector.FieldExpiry: + return m.OldExpiry(ctx) } return nil, fmt.Errorf("unknown Connector field %s", name) } @@ -4418,6 +4475,13 @@ func (m *ConnectorMutation) SetField(name string, value ent.Value) error { } m.SetGrantTypes(v) return nil + case connector.FieldExpiry: + v, ok := value.(*storage.ConnectorExpiry) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetExpiry(v) + return nil } return fmt.Errorf("unknown Connector field %s", name) } @@ -4451,6 +4515,9 @@ func (m *ConnectorMutation) ClearedFields() []string { if m.FieldCleared(connector.FieldGrantTypes) { fields = append(fields, connector.FieldGrantTypes) } + if m.FieldCleared(connector.FieldExpiry) { + fields = append(fields, connector.FieldExpiry) + } return fields } @@ -4468,6 +4535,9 @@ func (m *ConnectorMutation) ClearField(name string) error { case connector.FieldGrantTypes: m.ClearGrantTypes() return nil + case connector.FieldExpiry: + m.ClearExpiry() + return nil } return fmt.Errorf("unknown Connector nullable field %s", name) } @@ -4491,6 +4561,9 @@ func (m *ConnectorMutation) ResetField(name string) error { case connector.FieldGrantTypes: m.ResetGrantTypes() return nil + case connector.FieldExpiry: + m.ResetExpiry() + return nil } return fmt.Errorf("unknown Connector field %s", name) } diff --git a/storage/ent/schema/connector.go b/storage/ent/schema/connector.go index 191092c574..3da5cc44c3 100644 --- a/storage/ent/schema/connector.go +++ b/storage/ent/schema/connector.go @@ -3,6 +3,8 @@ package schema import ( "entgo.io/ent" "entgo.io/ent/schema/field" + + "github.com/dexidp/dex/storage" ) /* Original SQL table: @@ -40,6 +42,8 @@ func (Connector) Fields() []ent.Field { field.Bytes("config"), field.JSON("grant_types", []string{}). Optional(), + field.JSON("expiry", &storage.ConnectorExpiry{}). + Optional(), } } diff --git a/storage/sql/crud.go b/storage/sql/crud.go index a8eaf2fd4b..d239323c9b 100644 --- a/storage/sql/crud.go +++ b/storage/sql/crud.go @@ -1143,15 +1143,19 @@ func (c *conn) CreateConnector(ctx context.Context, connector storage.Connector) if err != nil { return fmt.Errorf("marshal connector grant types: %v", err) } + expiry, err := marshalConnectorExpiry(connector.Expiry) + if err != nil { + return err + } _, err = c.Exec(` insert into connector ( - id, type, name, resource_version, config, grant_types + id, type, name, resource_version, config, grant_types, expiry ) values ( - $1, $2, $3, $4, $5, $6 + $1, $2, $3, $4, $5, $6, $7 ); `, - connector.ID, connector.Type, connector.Name, connector.ResourceVersion, connector.Config, grantTypes, + connector.ID, connector.Type, connector.Name, connector.ResourceVersion, connector.Config, grantTypes, expiry, ) if err != nil { if c.alreadyExistsCheck(err) { @@ -1162,6 +1166,17 @@ func (c *conn) CreateConnector(ctx context.Context, connector storage.Connector) return nil } +func marshalConnectorExpiry(e *storage.ConnectorExpiry) ([]byte, error) { + if e == nil { + return nil, nil + } + b, err := json.Marshal(e) + if err != nil { + return nil, fmt.Errorf("marshal connector expiry: %v", err) + } + return b, nil +} + func (c *conn) UpdateConnector(ctx context.Context, id string, updater func(s storage.Connector) (storage.Connector, error)) error { return c.ExecTx(func(tx *trans) error { connector, err := getConnector(ctx, tx, id) @@ -1177,6 +1192,10 @@ func (c *conn) UpdateConnector(ctx context.Context, id string, updater func(s st if err != nil { return fmt.Errorf("marshal connector grant types: %v", err) } + expiry, err := marshalConnectorExpiry(newConn.Expiry) + if err != nil { + return err + } _, err = tx.Exec(` update connector set @@ -1184,10 +1203,11 @@ func (c *conn) UpdateConnector(ctx context.Context, id string, updater func(s st name = $2, resource_version = $3, config = $4, - grant_types = $5 - where id = $6; + grant_types = $5, + expiry = $6 + where id = $7; `, - newConn.Type, newConn.Name, newConn.ResourceVersion, newConn.Config, grantTypes, connector.ID, + newConn.Type, newConn.Name, newConn.ResourceVersion, newConn.Config, grantTypes, expiry, connector.ID, ) if err != nil { return fmt.Errorf("update connector: %v", err) @@ -1203,16 +1223,16 @@ func (c *conn) GetConnector(ctx context.Context, id string) (storage.Connector, func getConnector(ctx context.Context, q querier, id string) (storage.Connector, error) { return scanConnector(q.QueryRow(` select - id, type, name, resource_version, config, grant_types + id, type, name, resource_version, config, grant_types, expiry from connector where id = $1; `, id)) } func scanConnector(s scanner) (c storage.Connector, err error) { - var grantTypes []byte + var grantTypes, expiry []byte err = s.Scan( - &c.ID, &c.Type, &c.Name, &c.ResourceVersion, &c.Config, &grantTypes, + &c.ID, &c.Type, &c.Name, &c.ResourceVersion, &c.Config, &grantTypes, &expiry, ) if err != nil { if err == sql.ErrNoRows { @@ -1225,13 +1245,20 @@ func scanConnector(s scanner) (c storage.Connector, err error) { return c, fmt.Errorf("unmarshal connector grant types: %v", err) } } + if len(expiry) > 0 { + var e storage.ConnectorExpiry + if err := json.Unmarshal(expiry, &e); err != nil { + return c, fmt.Errorf("unmarshal connector expiry: %v", err) + } + c.Expiry = &e + } return c, nil } func (c *conn) ListConnectors(ctx context.Context) ([]storage.Connector, error) { rows, err := c.Query(` select - id, type, name, resource_version, config, grant_types + id, type, name, resource_version, config, grant_types, expiry from connector; `) if err != nil { diff --git a/storage/sql/migrate.go b/storage/sql/migrate.go index d9b3dbed5c..51bc923968 100644 --- a/storage/sql/migrate.go +++ b/storage/sql/migrate.go @@ -467,4 +467,9 @@ var migrations = []migration{ add column sso_shared_with bytea;`, }, }, + { + stmts: []string{ + `alter table connector add column expiry bytea;`, + }, + }, } diff --git a/storage/storage.go b/storage/storage.go index e68efba097..8cc87964de 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -545,6 +545,30 @@ type Connector struct { // GrantTypes is a list of grant types that this connector is allowed to be used with. // If empty, all grant types are allowed. GrantTypes []string `json:"grantTypes,omitempty"` + + // Expiry, when set, overrides the corresponding fields of the top-level + // expiry config for tokens issued through this connector. Any field left + // unset falls back to the global value. Overrides must be at least as + // strict as their global counterpart. + Expiry *ConnectorExpiry `json:"expiry,omitempty"` +} + +// ConnectorExpiry holds per-connector overrides for token lifetimes. +// The string fields use the same duration format as the top-level expiry +// config (e.g. "5m", "24h"). An empty string means "inherit the global value". +type ConnectorExpiry struct { + IDTokens string `json:"idTokens,omitempty"` + RefreshTokens *ConnectorRefreshExpiry `json:"refreshTokens,omitempty"` +} + +// ConnectorRefreshExpiry holds per-connector refresh-token policy overrides. +// DisableRotation is a *bool so "unset" (inherit) can be distinguished from +// false. The duration strings inherit the global value when empty. +type ConnectorRefreshExpiry struct { + DisableRotation *bool `json:"disableRotation,omitempty"` + ReuseInterval string `json:"reuseInterval,omitempty"` + AbsoluteLifetime string `json:"absoluteLifetime,omitempty"` + ValidIfNotUsedFor string `json:"validIfNotUsedFor,omitempty"` } // VerificationKey is a rotated signing key which can still be used to verify From 517a7496ef368f6a9f26c1cfdc6de44033130029 Mon Sep 17 00:00:00 2001 From: Helio Machado <0x2b3bfa0+git@googlemail.com> Date: Wed, 13 May 2026 04:01:30 +0400 Subject: [PATCH 07/10] feat: enforce disableRotation hierarchy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-connector overrides may now only tighten rotation, not loosen it. Concretely: when the global expiry.refreshTokens.disableRotation is false (rotation enabled — the stricter policy, shorter replay window after compromise), a connector setting disableRotation: true is rejected. The reverse direction (global disabled, connector enables) remains permitted as a tightening. This closes the one gap left by the prior "global is the ceiling" design: ValidateConnectorExpiry now covers every field of ConnectorExpiry instead of leaving disableRotation as an exception. Signed-off-by: Helio Machado <0x2b3bfa0+git@googlemail.com> --- cmd/dex/serve.go | 5 ++++- cmd/dex/serve_test.go | 6 ++++++ config.yaml.dist | 5 +++-- server/expiry.go | 23 +++++++++++++++-------- server/expiry_test.go | 29 +++++++++++++++++++++++++++-- 5 files changed, 55 insertions(+), 13 deletions(-) diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index 8966b6eb62..f53fc055ca 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -848,7 +848,10 @@ func parseSessionConfig(s *Sessions) (*server.SessionConfig, error) { // to validate per-connector overrides. The server uses these for both static // YAML connectors at startup and dynamic API writes at runtime. func buildExpiryCeilings(globalIDTokens time.Duration, globalRefresh RefreshToken) (server.ExpiryCeilings, error) { - c := server.ExpiryCeilings{IDTokens: globalIDTokens} + c := server.ExpiryCeilings{ + IDTokens: globalIDTokens, + RefreshRotationDisabled: globalRefresh.DisableRotation, + } for _, f := range []struct { name string value string diff --git a/cmd/dex/serve_test.go b/cmd/dex/serve_test.go index fe0f04b236..5f27867d08 100644 --- a/cmd/dex/serve_test.go +++ b/cmd/dex/serve_test.go @@ -53,6 +53,12 @@ func TestBuildExpiryCeilingsRefreshUnset(t *testing.T) { assert.Equal(t, server.ExpiryCeilings{IDTokens: 24 * time.Hour}, c) } +func TestBuildExpiryCeilingsRotationDisabled(t *testing.T) { + c, err := buildExpiryCeilings(24*time.Hour, RefreshToken{DisableRotation: true}) + require.NoError(t, err) + assert.True(t, c.RefreshRotationDisabled) +} + func TestBuildExpiryCeilingsInvalidDuration(t *testing.T) { _, err := buildExpiryCeilings(24*time.Hour, RefreshToken{AbsoluteLifetime: "not-a-duration"}) require.Error(t, err) diff --git a/config.yaml.dist b/config.yaml.dist index 6d06ccbb7d..f286d796a8 100644 --- a/config.yaml.dist +++ b/config.yaml.dist @@ -197,8 +197,9 @@ web: # # Per-connector expiry overrides. Any field left unset inherits the top-level # `expiry` configuration. Overrides must be at least as strict as the global -# value (i.e. shorter or equal); a value exceeding its global counterpart is -# rejected at config load. Global values left unset impose no ceiling. +# value: a duration that exceeds its global counterpart, or `disableRotation: +# true` while rotation is enabled globally, is rejected at config load. Global +# values left unset impose no ceiling on the corresponding override. # connectors: # - type: oidc # id: partner diff --git a/server/expiry.go b/server/expiry.go index 61233909d4..502619534a 100644 --- a/server/expiry.go +++ b/server/expiry.go @@ -9,13 +9,20 @@ import ( ) // ExpiryCeilings holds the parsed global expiry values that per-connector -// overrides must not exceed. A zero duration means "no ceiling" — i.e. the -// global value is unset/disabled, so any override is acceptable. +// overrides must not loosen. A zero duration means "no ceiling" — the global +// value is unset/disabled, so any override is acceptable. +// +// RefreshRotationDisabled mirrors the global expiry.refreshTokens.disableRotation +// flag. When rotation is enabled globally, a per-connector override may not +// disable it: rotation-enabled is the stricter policy (shorter replay window +// after compromise), so disabling it at the connector layer would loosen the +// global guarantee. The reverse direction is permitted. type ExpiryCeilings struct { IDTokens time.Duration RefreshAbsoluteLifetime time.Duration RefreshValidIfNotUsedFor time.Duration RefreshReuseInterval time.Duration + RefreshRotationDisabled bool } // RefreshTokenDefaults are the global refresh-token configuration strings. @@ -29,12 +36,9 @@ type RefreshTokenDefaults struct { } // ValidateConnectorExpiry rejects per-connector overrides that loosen the -// global policy. DisableRotation is exempt: it's a policy toggle, not a -// quantity, so "stricter" has no natural direction. -// -// This function is the single source of truth for the hierarchy rule. It is -// called from both the static YAML load path and every gRPC API write so -// that no configuration modification can ever bypass it. +// global policy. This function is the single source of truth for the +// hierarchy rule; it is called from both the static YAML load path and every +// gRPC API write so that no configuration modification can ever bypass it. func ValidateConnectorExpiry(e *storage.ConnectorExpiry, c ExpiryCeilings) error { if e == nil { return nil @@ -58,6 +62,9 @@ func ValidateConnectorExpiry(e *storage.ConnectorExpiry, c ExpiryCeilings) error return err } } + if dr := e.RefreshTokens.DisableRotation; dr != nil && *dr && !c.RefreshRotationDisabled { + return fmt.Errorf("expiry.refreshTokens.disableRotation cannot disable rotation when it is enabled globally") + } return nil } diff --git a/server/expiry_test.go b/server/expiry_test.go index 9bbce90bf7..871da84d77 100644 --- a/server/expiry_test.go +++ b/server/expiry_test.go @@ -44,12 +44,12 @@ func TestValidateConnectorExpiry_RefreshAbsoluteLifetimeExceeds(t *testing.T) { } func TestValidateConnectorExpiry_AllFieldsBelowCeiling(t *testing.T) { - disable := true + enable := false require.NoError(t, ValidateConnectorExpiry( &storage.ConnectorExpiry{ IDTokens: "10m", RefreshTokens: &storage.ConnectorRefreshExpiry{ - DisableRotation: &disable, + DisableRotation: &enable, // tighten: global has it disabled, connector enables it ReuseInterval: "1s", AbsoluteLifetime: "1h", ValidIfNotUsedFor: "30m", @@ -60,7 +60,32 @@ func TestValidateConnectorExpiry_AllFieldsBelowCeiling(t *testing.T) { RefreshAbsoluteLifetime: 24 * time.Hour, RefreshValidIfNotUsedFor: 1 * time.Hour, RefreshReuseInterval: 3 * time.Second, + RefreshRotationDisabled: true, + }, + )) +} + +func TestValidateConnectorExpiry_DisableRotationLoosens(t *testing.T) { + // Global has rotation enabled; connector cannot disable it. + disable := true + err := ValidateConnectorExpiry( + &storage.ConnectorExpiry{ + RefreshTokens: &storage.ConnectorRefreshExpiry{DisableRotation: &disable}, + }, + ExpiryCeilings{}, // RefreshRotationDisabled defaults to false (rotation enabled) + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "disableRotation cannot disable rotation when it is enabled globally") +} + +func TestValidateConnectorExpiry_DisableRotationTightens(t *testing.T) { + // Global has rotation disabled; connector can enable it (stricter). + enable := false + require.NoError(t, ValidateConnectorExpiry( + &storage.ConnectorExpiry{ + RefreshTokens: &storage.ConnectorRefreshExpiry{DisableRotation: &enable}, }, + ExpiryCeilings{RefreshRotationDisabled: true}, )) } From d0443a2b151aa08582c5dd590ecc6784ab5237e9 Mon Sep 17 00:00:00 2001 From: Helio Machado <0x2b3bfa0+git@googlemail.com> Date: Wed, 13 May 2026 06:45:04 +0400 Subject: [PATCH 08/10] refactor: idiomaticity + efficiency cleanup for per-connector expiry - Fix missing Expiry round-trip in the kubernetes storage backend mirror (would silently drop overrides on a real cluster; conformance tests skip without a live cluster). - Honor ContinueOnConnectorFailure in the startup validation loop; a bad persisted override no longer fails startup when the flag is set. - Rename ConnectorRefreshToken -> ConnectorRefreshExpiry for symmetry with the storage / proto types. - Unexport ValidateConnectorExpiry and drop the ExpiryCeilings() method in favor of direct field access (same package). - Inline single-call-site helpers (connectorExpiryToStorage, connectorExpiryToProto, marshalConnectorExpiry) matching the GrantTypes / inline-struct-copy idiom already used in the file. - Drop log spam: NewRefreshTokenPolicy emits per-field Info logs that are useful for the global policy but spam at N connectors x 4 fields. Pass a discard logger from buildConnectorExpiryOverride; add a single audit line in the startup loop per connector that has an override. - Trim WHAT-only docstrings, fix stale mu field comment, tighten the SQL nil-vs-null comment. - Drop omitempty from cmd/dex Connector.Expiry tag for sibling consistency. Signed-off-by: Helio Machado <0x2b3bfa0+git@googlemail.com> --- cmd/dex/config.go | 42 +++++++++------------- cmd/dex/serve_test.go | 36 +++++++++++-------- server/api.go | 70 +++++++++++++++---------------------- server/expiry.go | 55 +++++++++++------------------ server/expiry_test.go | 33 ++++++++++------- server/server.go | 40 +++++++++------------ storage/kubernetes/types.go | 4 +++ storage/sql/crud.go | 31 ++++++++-------- 8 files changed, 141 insertions(+), 170 deletions(-) diff --git a/cmd/dex/config.go b/cmd/dex/config.go index c94992a76e..0d4475ed79 100644 --- a/cmd/dex/config.go +++ b/cmd/dex/config.go @@ -563,18 +563,18 @@ type Connector struct { // Expiry, when set, overrides the corresponding fields of the top-level // expiry config for tokens issued through this connector. Any field left // unset falls back to the global value. - Expiry *ConnectorExpiry `json:"expiry,omitempty"` + Expiry *ConnectorExpiry `json:"expiry"` } type ConnectorExpiry struct { - IDTokens string `json:"idTokens"` - RefreshTokens *ConnectorRefreshToken `json:"refreshTokens"` + IDTokens string `json:"idTokens"` + RefreshTokens *ConnectorRefreshExpiry `json:"refreshTokens"` } -// ConnectorRefreshToken mirrors RefreshToken but uses a pointer for +// ConnectorRefreshExpiry mirrors RefreshToken but uses a pointer for // DisableRotation so that "unset" can be distinguished from "false", // allowing the field to inherit the global value when nil. -type ConnectorRefreshToken struct { +type ConnectorRefreshExpiry struct { DisableRotation *bool `json:"disableRotation"` ReuseInterval string `json:"reuseInterval"` AbsoluteLifetime string `json:"absoluteLifetime"` @@ -591,7 +591,7 @@ func (c *Connector) UnmarshalJSON(b []byte) error { Config json.RawMessage `json:"config"` GrantTypes []string `json:"grantTypes"` - Expiry *ConnectorExpiry `json:"expiry,omitempty"` + Expiry *ConnectorExpiry `json:"expiry"` } if err := configUnmarshaller(b, &conn); err != nil { return fmt.Errorf("parse connector: %v", err) @@ -646,33 +646,25 @@ func ToStorageConnector(c Connector) (storage.Connector, error) { return storage.Connector{}, fmt.Errorf("failed to marshal connector config: %v", err) } - return storage.Connector{ + sc := storage.Connector{ ID: c.ID, Type: c.Type, Name: c.Name, Config: data, GrantTypes: c.GrantTypes, - Expiry: connectorExpiryToStorage(c.Expiry), - }, nil -} - -// connectorExpiryToStorage flattens the YAML config struct into the storage -// type. Both share the same shape; the conversion is mechanical and exists -// purely to decouple the storage package from cmd/dex. -func connectorExpiryToStorage(e *ConnectorExpiry) *storage.ConnectorExpiry { - if e == nil { - return nil } - out := &storage.ConnectorExpiry{IDTokens: e.IDTokens} - if e.RefreshTokens != nil { - out.RefreshTokens = &storage.ConnectorRefreshExpiry{ - DisableRotation: e.RefreshTokens.DisableRotation, - ReuseInterval: e.RefreshTokens.ReuseInterval, - AbsoluteLifetime: e.RefreshTokens.AbsoluteLifetime, - ValidIfNotUsedFor: e.RefreshTokens.ValidIfNotUsedFor, + if c.Expiry != nil { + sc.Expiry = &storage.ConnectorExpiry{IDTokens: c.Expiry.IDTokens} + if rt := c.Expiry.RefreshTokens; rt != nil { + sc.Expiry.RefreshTokens = &storage.ConnectorRefreshExpiry{ + DisableRotation: rt.DisableRotation, + ReuseInterval: rt.ReuseInterval, + AbsoluteLifetime: rt.AbsoluteLifetime, + ValidIfNotUsedFor: rt.ValidIfNotUsedFor, + } } } - return out + return sc, nil } // Expiry holds configuration for the validity period of components. diff --git a/cmd/dex/serve_test.go b/cmd/dex/serve_test.go index 5f27867d08..35d1bc1f4b 100644 --- a/cmd/dex/serve_test.go +++ b/cmd/dex/serve_test.go @@ -65,23 +65,29 @@ func TestBuildExpiryCeilingsInvalidDuration(t *testing.T) { assert.Contains(t, err.Error(), "parse expiry.refreshTokens.absoluteLifetime") } -func TestConnectorExpiryToStorage(t *testing.T) { +func TestToStorageConnectorCarriesExpiry(t *testing.T) { disable := true - got := connectorExpiryToStorage(&ConnectorExpiry{ - IDTokens: "15m", - RefreshTokens: &ConnectorRefreshToken{ - DisableRotation: &disable, - AbsoluteLifetime: "24h", - ValidIfNotUsedFor: "1h", - ReuseInterval: "3s", + sc, err := ToStorageConnector(Connector{ + ID: "c1", Type: "mockCallback", Name: "c1", + Expiry: &ConnectorExpiry{ + IDTokens: "15m", + RefreshTokens: &ConnectorRefreshExpiry{ + DisableRotation: &disable, + AbsoluteLifetime: "24h", + ValidIfNotUsedFor: "1h", + ReuseInterval: "3s", + }, }, }) - require.NotNil(t, got) - assert.Equal(t, "15m", got.IDTokens) - require.NotNil(t, got.RefreshTokens) - assert.Equal(t, "24h", got.RefreshTokens.AbsoluteLifetime) - require.NotNil(t, got.RefreshTokens.DisableRotation) - assert.True(t, *got.RefreshTokens.DisableRotation) + require.NoError(t, err) + require.NotNil(t, sc.Expiry) + assert.Equal(t, "15m", sc.Expiry.IDTokens) + require.NotNil(t, sc.Expiry.RefreshTokens) + assert.Equal(t, "24h", sc.Expiry.RefreshTokens.AbsoluteLifetime) + require.NotNil(t, sc.Expiry.RefreshTokens.DisableRotation) + assert.True(t, *sc.Expiry.RefreshTokens.DisableRotation) - assert.Nil(t, connectorExpiryToStorage(nil)) + sc, err = ToStorageConnector(Connector{ID: "c1", Type: "mockCallback", Name: "c1"}) + require.NoError(t, err) + assert.Nil(t, sc.Expiry) } diff --git a/server/api.go b/server/api.go index 65a7a8cefb..1c41187660 100644 --- a/server/api.go +++ b/server/api.go @@ -475,7 +475,7 @@ func (d dexAPI) CreateConnector(ctx context.Context, req *api.CreateConnectorReq expiry := connectorExpiryFromProto(req.Connector.Expiry) if d.server != nil { - if err := ValidateConnectorExpiry(expiry, d.server.ExpiryCeilings()); err != nil { + if err := validateConnectorExpiry(expiry, d.server.expiryCeilings); err != nil { return nil, fmt.Errorf("invalid expiry: %v", err) } } @@ -498,8 +498,9 @@ func (d dexAPI) CreateConnector(ctx context.Context, req *api.CreateConnectorReq } if d.server != nil { - // Refresh the runtime expiry override before evicting the cached - // connector — both must agree with what was just persisted. + // Validation already passed above, so reaching the error path here means + // a programmer bug. Log and let the storage write win; the inconsistency + // will self-heal on the next restart. if err := d.server.upsertConnectorExpiryOverride(req.Connector.Id, expiry); err != nil { d.logger.Error("api: failed to install connector expiry override", "err", err) } @@ -539,18 +540,19 @@ func (d dexAPI) UpdateConnector(ctx context.Context, req *api.UpdateConnectorReq } } - // expiryUpdate captures the new value (possibly nil to clear) when the - // caller has chosen to modify the field, and false when the field should - // be left untouched. + // The proto has three states (absent / present-with-nil / present-with-value); + // *storage.ConnectorExpiry only has two. expiryUpdated bridges the gap: false + // means "leave alone", true with nil newExpiry means "clear", true with a + // non-nil newExpiry means "install this". var ( expiryUpdated bool newExpiry *storage.ConnectorExpiry ) if req.NewExpiry != nil { expiryUpdated = true - newExpiry = connectorExpiryUpdateFromProto(req.NewExpiry) + newExpiry = connectorExpiryFromProto(req.NewExpiry.Value) if d.server != nil { - if err := ValidateConnectorExpiry(newExpiry, d.server.ExpiryCeilings()); err != nil { + if err := validateConnectorExpiry(newExpiry, d.server.expiryCeilings); err != nil { return nil, fmt.Errorf("invalid expiry: %v", err) } } @@ -592,13 +594,13 @@ func (d dexAPI) UpdateConnector(ctx context.Context, req *api.UpdateConnectorReq return nil, fmt.Errorf("update connector: %v", err) } - if d.server != nil && expiryUpdated { - if err := d.server.upsertConnectorExpiryOverride(req.Id, newExpiry); err != nil { - d.logger.Error("api: failed to refresh connector expiry override", "err", err) + if d.server != nil { + if expiryUpdated { + if err := d.server.upsertConnectorExpiryOverride(req.Id, newExpiry); err != nil { + d.logger.Error("api: failed to refresh connector expiry override", "err", err) + } } d.server.CloseConnector(req.Id) - } else if d.server != nil { - d.server.CloseConnector(req.Id) } return &api.UpdateConnectorResp{}, nil @@ -623,7 +625,8 @@ func (d dexAPI) DeleteConnector(ctx context.Context, req *api.DeleteConnectorReq } if d.server != nil { - // upsert with nil clears any installed override. + // Drop any cached override so a connector re-created with the same id + // starts fresh. upsertConnectorExpiryOverride(_, nil) cannot error. _ = d.server.upsertConnectorExpiryOverride(req.Id, nil) d.server.CloseConnector(req.Id) } @@ -650,7 +653,17 @@ func (d dexAPI) ListConnectors(ctx context.Context, req *api.ListConnectorReq) ( Type: connector.Type, Config: connector.Config, GrantTypes: connector.GrantTypes, - Expiry: connectorExpiryToProto(connector.Expiry), + } + if e := connector.Expiry; e != nil { + c.Expiry = &api.ConnectorExpiry{IdTokens: e.IDTokens} + if rt := e.RefreshTokens; rt != nil { + c.Expiry.RefreshTokens = &api.ConnectorRefreshExpiry{ + DisableRotation: rt.DisableRotation, + ReuseInterval: rt.ReuseInterval, + AbsoluteLifetime: rt.AbsoluteLifetime, + ValidIfNotUsedFor: rt.ValidIfNotUsedFor, + } + } } connectors = append(connectors, &c) } @@ -668,8 +681,6 @@ func defaultTo[T comparable](v, def T) T { return v } -// connectorExpiryFromProto converts an api.ConnectorExpiry into the storage -// type. A nil input yields nil so the caller can persist "no override". func connectorExpiryFromProto(p *api.ConnectorExpiry) *storage.ConnectorExpiry { if p == nil { return nil @@ -685,28 +696,3 @@ func connectorExpiryFromProto(p *api.ConnectorExpiry) *storage.ConnectorExpiry { } return e } - -// connectorExpiryUpdateFromProto unwraps the optional update envelope. A -// present update with a nil inner Value means "clear the override". -func connectorExpiryUpdateFromProto(p *api.ConnectorExpiryUpdate) *storage.ConnectorExpiry { - if p == nil { - return nil - } - return connectorExpiryFromProto(p.Value) -} - -func connectorExpiryToProto(e *storage.ConnectorExpiry) *api.ConnectorExpiry { - if e == nil { - return nil - } - p := &api.ConnectorExpiry{IdTokens: e.IDTokens} - if e.RefreshTokens != nil { - p.RefreshTokens = &api.ConnectorRefreshExpiry{ - DisableRotation: e.RefreshTokens.DisableRotation, - ReuseInterval: e.RefreshTokens.ReuseInterval, - AbsoluteLifetime: e.RefreshTokens.AbsoluteLifetime, - ValidIfNotUsedFor: e.RefreshTokens.ValidIfNotUsedFor, - } - } - return p -} diff --git a/server/expiry.go b/server/expiry.go index 502619534a..fa9d8bb03d 100644 --- a/server/expiry.go +++ b/server/expiry.go @@ -9,14 +9,11 @@ import ( ) // ExpiryCeilings holds the parsed global expiry values that per-connector -// overrides must not loosen. A zero duration means "no ceiling" — the global -// value is unset/disabled, so any override is acceptable. +// overrides must not loosen. A zero duration field means "no ceiling". // -// RefreshRotationDisabled mirrors the global expiry.refreshTokens.disableRotation -// flag. When rotation is enabled globally, a per-connector override may not -// disable it: rotation-enabled is the stricter policy (shorter replay window -// after compromise), so disabling it at the connector layer would loosen the -// global guarantee. The reverse direction is permitted. +// RefreshRotationDisabled blocks the asymmetric case where the global enables +// rotation: a per-connector override cannot disable it, since rotation-enabled +// is the stricter policy. The reverse direction is permitted. type ExpiryCeilings struct { IDTokens time.Duration RefreshAbsoluteLifetime time.Duration @@ -25,9 +22,8 @@ type ExpiryCeilings struct { RefreshRotationDisabled bool } -// RefreshTokenDefaults are the global refresh-token configuration strings. -// Per-connector overrides inherit unset fields from these values when -// constructing a RefreshTokenPolicy. +// RefreshTokenDefaults are the inheritance roots for per-connector overrides +// that leave fields unset. type RefreshTokenDefaults struct { DisableRotation bool ValidIfNotUsedFor string @@ -35,11 +31,11 @@ type RefreshTokenDefaults struct { ReuseInterval string } -// ValidateConnectorExpiry rejects per-connector overrides that loosen the +// validateConnectorExpiry rejects per-connector overrides that loosen the // global policy. This function is the single source of truth for the // hierarchy rule; it is called from both the static YAML load path and every // gRPC API write so that no configuration modification can ever bypass it. -func ValidateConnectorExpiry(e *storage.ConnectorExpiry, c ExpiryCeilings) error { +func validateConnectorExpiry(e *storage.ConnectorExpiry, c ExpiryCeilings) error { if e == nil { return nil } @@ -69,14 +65,14 @@ func ValidateConnectorExpiry(e *storage.ConnectorExpiry, c ExpiryCeilings) error } func checkCeiling(field, value string, ceiling time.Duration) error { - if value == "" || ceiling == 0 { + if value == "" { return nil } d, err := time.ParseDuration(value) if err != nil { return fmt.Errorf("parse %s: %v", field, err) } - if d > ceiling { + if ceiling > 0 && d > ceiling { return fmt.Errorf("%s (%s) exceeds the global value (%s)", field, d, ceiling) } return nil @@ -86,12 +82,7 @@ func checkCeiling(field, value string, ceiling time.Duration) error { // into a ConnectorExpiryOverride. Unset string fields inherit from the global // refresh defaults so the resulting RefreshTokenPolicy carries the correct // effective values. -func buildConnectorExpiryOverride( - logger *slog.Logger, - connectorID string, - e *storage.ConnectorExpiry, - defaults RefreshTokenDefaults, -) (ConnectorExpiryOverride, error) { +func buildConnectorExpiryOverride(e *storage.ConnectorExpiry, defaults RefreshTokenDefaults) (ConnectorExpiryOverride, error) { var override ConnectorExpiryOverride if e == nil { return override, nil @@ -114,22 +105,16 @@ func buildConnectorExpiryOverride( if rt.DisableRotation != nil { disableRotation = *rt.DisableRotation } - validIfNotUsedFor := rt.ValidIfNotUsedFor - if validIfNotUsedFor == "" { - validIfNotUsedFor = defaults.ValidIfNotUsedFor - } - absoluteLifetime := rt.AbsoluteLifetime - if absoluteLifetime == "" { - absoluteLifetime = defaults.AbsoluteLifetime - } - reuseInterval := rt.ReuseInterval - if reuseInterval == "" { - reuseInterval = defaults.ReuseInterval - } - + // NewRefreshTokenPolicy emits one Info line per field at startup; that's + // useful for the single global policy but would spam logs at N connectors × + // 4 fields, on every API write. Pass a discard logger and let the caller + // summarize. policy, err := NewRefreshTokenPolicy( - logger.With("connector_id", connectorID), - disableRotation, validIfNotUsedFor, absoluteLifetime, reuseInterval, + slog.New(slog.DiscardHandler), + disableRotation, + defaultTo(rt.ValidIfNotUsedFor, defaults.ValidIfNotUsedFor), + defaultTo(rt.AbsoluteLifetime, defaults.AbsoluteLifetime), + defaultTo(rt.ReuseInterval, defaults.ReuseInterval), ) if err != nil { return override, fmt.Errorf("refresh token policy: %v", err) diff --git a/server/expiry_test.go b/server/expiry_test.go index 871da84d77..c164670425 100644 --- a/server/expiry_test.go +++ b/server/expiry_test.go @@ -12,11 +12,11 @@ import ( ) func TestValidateConnectorExpiry_Nil(t *testing.T) { - require.NoError(t, ValidateConnectorExpiry(nil, ExpiryCeilings{})) + require.NoError(t, validateConnectorExpiry(nil, ExpiryCeilings{})) } func TestValidateConnectorExpiry_IDTokensExceeds(t *testing.T) { - err := ValidateConnectorExpiry( + err := validateConnectorExpiry( &storage.ConnectorExpiry{IDTokens: "48h"}, ExpiryCeilings{IDTokens: 24 * time.Hour}, ) @@ -24,16 +24,29 @@ func TestValidateConnectorExpiry_IDTokensExceeds(t *testing.T) { assert.Contains(t, err.Error(), "expiry.idTokens (48h0m0s) exceeds the global value (24h0m0s)") } +func TestValidateConnectorExpiry_InvalidDurationNoCeiling(t *testing.T) { + // Garbage strings are rejected even when the global has no ceiling, so + // they can't slip past validation and explode later in NewRefreshTokenPolicy. + err := validateConnectorExpiry( + &storage.ConnectorExpiry{ + RefreshTokens: &storage.ConnectorRefreshExpiry{AbsoluteLifetime: "not-a-duration"}, + }, + ExpiryCeilings{}, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "parse expiry.refreshTokens.absoluteLifetime") +} + func TestValidateConnectorExpiry_NoCeiling(t *testing.T) { // Global unset → no ceiling. - require.NoError(t, ValidateConnectorExpiry( + require.NoError(t, validateConnectorExpiry( &storage.ConnectorExpiry{IDTokens: "48h"}, ExpiryCeilings{}, )) } func TestValidateConnectorExpiry_RefreshAbsoluteLifetimeExceeds(t *testing.T) { - err := ValidateConnectorExpiry( + err := validateConnectorExpiry( &storage.ConnectorExpiry{ RefreshTokens: &storage.ConnectorRefreshExpiry{AbsoluteLifetime: "100h"}, }, @@ -45,7 +58,7 @@ func TestValidateConnectorExpiry_RefreshAbsoluteLifetimeExceeds(t *testing.T) { func TestValidateConnectorExpiry_AllFieldsBelowCeiling(t *testing.T) { enable := false - require.NoError(t, ValidateConnectorExpiry( + require.NoError(t, validateConnectorExpiry( &storage.ConnectorExpiry{ IDTokens: "10m", RefreshTokens: &storage.ConnectorRefreshExpiry{ @@ -68,7 +81,7 @@ func TestValidateConnectorExpiry_AllFieldsBelowCeiling(t *testing.T) { func TestValidateConnectorExpiry_DisableRotationLoosens(t *testing.T) { // Global has rotation enabled; connector cannot disable it. disable := true - err := ValidateConnectorExpiry( + err := validateConnectorExpiry( &storage.ConnectorExpiry{ RefreshTokens: &storage.ConnectorRefreshExpiry{DisableRotation: &disable}, }, @@ -81,7 +94,7 @@ func TestValidateConnectorExpiry_DisableRotationLoosens(t *testing.T) { func TestValidateConnectorExpiry_DisableRotationTightens(t *testing.T) { // Global has rotation disabled; connector can enable it (stricter). enable := false - require.NoError(t, ValidateConnectorExpiry( + require.NoError(t, validateConnectorExpiry( &storage.ConnectorExpiry{ RefreshTokens: &storage.ConnectorRefreshExpiry{DisableRotation: &enable}, }, @@ -90,7 +103,7 @@ func TestValidateConnectorExpiry_DisableRotationTightens(t *testing.T) { } func TestBuildConnectorExpiryOverride_Nil(t *testing.T) { - got, err := buildConnectorExpiryOverride(slog.New(slog.DiscardHandler), "c1", nil, RefreshTokenDefaults{}) + got, err := buildConnectorExpiryOverride(nil, RefreshTokenDefaults{}) require.NoError(t, err) assert.Zero(t, got.IDTokensValidFor) assert.Nil(t, got.RefreshTokenPolicy) @@ -98,8 +111,6 @@ func TestBuildConnectorExpiryOverride_Nil(t *testing.T) { func TestBuildConnectorExpiryOverride_IDTokensOnly(t *testing.T) { got, err := buildConnectorExpiryOverride( - slog.New(slog.DiscardHandler), - "c1", &storage.ConnectorExpiry{IDTokens: "5m"}, RefreshTokenDefaults{}, ) @@ -111,8 +122,6 @@ func TestBuildConnectorExpiryOverride_IDTokensOnly(t *testing.T) { func TestBuildConnectorExpiryOverride_RefreshInheritsGlobals(t *testing.T) { disable := true got, err := buildConnectorExpiryOverride( - slog.New(slog.DiscardHandler), - "c1", &storage.ConnectorExpiry{ RefreshTokens: &storage.ConnectorRefreshExpiry{ DisableRotation: &disable, diff --git a/server/server.go b/server/server.go index 52ffd67b18..2c8d433d38 100644 --- a/server/server.go +++ b/server/server.go @@ -226,7 +226,7 @@ type ConnectorExpiryOverride struct { type Server struct { issuerURL url.URL - // mutex for the connectors map. + // mu guards connectors and connectorExpiryOverrides. mu sync.Mutex // Map of connector IDs to connectors. connectors map[string]Connector @@ -418,14 +418,19 @@ func newServer(ctx context.Context, c Config) (*Server, error) { return nil, errors.New("server: no connectors specified") } + var failedCount int for _, conn := range storageConnectors { if err := s.upsertConnectorExpiryOverride(conn.ID, conn.Expiry); err != nil { - return nil, fmt.Errorf("server: invalid connector expiry for %q: %v", conn.ID, err) + failedCount++ + if c.ContinueOnConnectorFailure { + s.logger.Error("server: invalid connector expiry", "id", conn.ID, "err", err) + continue + } + return nil, fmt.Errorf("server: invalid connector expiry for %s: %v", conn.ID, err) + } + if conn.Expiry != nil { + s.logger.Info("server: connector expiry override installed", "id", conn.ID) } - } - - var failedCount int - for _, conn := range storageConnectors { if _, err := s.OpenConnector(conn); err != nil { failedCount++ if c.ContinueOnConnectorFailure { @@ -854,31 +859,21 @@ func (s *Server) OpenConnector(conn storage.Connector) (Connector, error) { } // CloseConnector removes the connector from the server's in-memory map. -// The expiry override is intentionally left alone here — call sites that -// need to drop it (DeleteConnector) do so explicitly via -// upsertConnectorExpiryOverride(id, nil) afterwards. func (s *Server) CloseConnector(id string) { s.mu.Lock() delete(s.connectors, id) s.mu.Unlock() } -// ExpiryCeilings returns the global expiry ceilings used to validate -// per-connector overrides. Callers should pass the result to -// ValidateConnectorExpiry before persisting any storage.ConnectorExpiry. -func (s *Server) ExpiryCeilings() ExpiryCeilings { - return s.expiryCeilings -} - // upsertConnectorExpiryOverride validates the given storage.ConnectorExpiry -// against the server's expiry ceilings and, on success, updates the in-memory -// override map. Callers must invoke this every time a connector's expiry is -// written so that the live token-issuance path reflects the change. +// and, on success, updates the in-memory override map. Every code path that +// can change a connector's expiry must go through this method so the live +// token-issuance path reflects the change. func (s *Server) upsertConnectorExpiryOverride(id string, e *storage.ConnectorExpiry) error { - if err := ValidateConnectorExpiry(e, s.expiryCeilings); err != nil { + if err := validateConnectorExpiry(e, s.expiryCeilings); err != nil { return err } - override, err := buildConnectorExpiryOverride(s.logger, id, e, s.globalRefreshDefaults) + override, err := buildConnectorExpiryOverride(e, s.globalRefreshDefaults) if err != nil { return err } @@ -889,9 +884,6 @@ func (s *Server) upsertConnectorExpiryOverride(id string, e *storage.ConnectorEx return nil } s.connectorExpiryOverrides[id] = override - if override.IDTokensValidFor > 0 { - s.logger.Info("config connector id tokens", "connector_id", id, "valid_for", override.IDTokensValidFor) - } return nil } diff --git a/storage/kubernetes/types.go b/storage/kubernetes/types.go index d936865ad8..4f868b67bb 100644 --- a/storage/kubernetes/types.go +++ b/storage/kubernetes/types.go @@ -795,6 +795,8 @@ type Connector struct { Config []byte `json:"config,omitempty"` // GrantTypes is a list of grant types that this connector is allowed to be used with. GrantTypes []string `json:"grantTypes,omitempty"` + // Expiry holds per-connector overrides for token lifetimes. + Expiry *storage.ConnectorExpiry `json:"expiry,omitempty"` } func (cli *client) fromStorageConnector(c storage.Connector) Connector { @@ -812,6 +814,7 @@ func (cli *client) fromStorageConnector(c storage.Connector) Connector { Name: c.Name, Config: c.Config, GrantTypes: c.GrantTypes, + Expiry: c.Expiry, } } @@ -823,6 +826,7 @@ func toStorageConnector(c Connector) storage.Connector { ResourceVersion: c.ObjectMeta.ResourceVersion, Config: c.Config, GrantTypes: c.GrantTypes, + Expiry: c.Expiry, } } diff --git a/storage/sql/crud.go b/storage/sql/crud.go index d239323c9b..57edd2dbfd 100644 --- a/storage/sql/crud.go +++ b/storage/sql/crud.go @@ -1143,9 +1143,14 @@ func (c *conn) CreateConnector(ctx context.Context, connector storage.Connector) if err != nil { return fmt.Errorf("marshal connector grant types: %v", err) } - expiry, err := marshalConnectorExpiry(connector.Expiry) - if err != nil { - return err + var expiry []byte + if connector.Expiry != nil { + // Only marshal when set; an unset override must persist as SQL NULL, + // not the literal bytes "null" that json.Marshal(nil) would produce. + expiry, err = json.Marshal(connector.Expiry) + if err != nil { + return fmt.Errorf("marshal connector expiry: %v", err) + } } _, err = c.Exec(` insert into connector ( @@ -1166,17 +1171,6 @@ func (c *conn) CreateConnector(ctx context.Context, connector storage.Connector) return nil } -func marshalConnectorExpiry(e *storage.ConnectorExpiry) ([]byte, error) { - if e == nil { - return nil, nil - } - b, err := json.Marshal(e) - if err != nil { - return nil, fmt.Errorf("marshal connector expiry: %v", err) - } - return b, nil -} - func (c *conn) UpdateConnector(ctx context.Context, id string, updater func(s storage.Connector) (storage.Connector, error)) error { return c.ExecTx(func(tx *trans) error { connector, err := getConnector(ctx, tx, id) @@ -1192,9 +1186,12 @@ func (c *conn) UpdateConnector(ctx context.Context, id string, updater func(s st if err != nil { return fmt.Errorf("marshal connector grant types: %v", err) } - expiry, err := marshalConnectorExpiry(newConn.Expiry) - if err != nil { - return err + var expiry []byte + if newConn.Expiry != nil { + expiry, err = json.Marshal(newConn.Expiry) + if err != nil { + return fmt.Errorf("marshal connector expiry: %v", err) + } } _, err = tx.Exec(` update connector From 295bf4eff57d08bfe11a732c685812d3d7550781 Mon Sep 17 00:00:00 2001 From: Helio Machado <0x2b3bfa0+git@googlemail.com> Date: Wed, 13 May 2026 06:45:54 +0400 Subject: [PATCH 09/10] fix(security): reject zero-disables overrides for refresh expiry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RefreshTokenPolicy.CompletelyExpired and ExpiredBecauseUnused treat a parsed duration of 0 as "expiration disabled". The previous validation compared numerically (d > ceiling), which let "0s" pass under any positive global ceiling — semantically meaning infinite lifetime, strictly looser than the global. A connector administrator (gRPC API write access or YAML config) could install: expiry: refreshTokens: absoluteLifetime: "0s" validIfNotUsedFor: "0s" and end up with refresh tokens that never expire on either axis, while the global policy enforces a positive bound. Stolen tokens issued through that connector would survive indefinitely. Fix: extend checkCeiling with a zeroDisables flag; reject d == 0 in the presence of a positive ceiling for absoluteLifetime and validIfNotUsedFor. idTokens and reuseInterval are exempt: - idTokens: zero falls back to the global via value() at runtime - reuseInterval: zero means "no reuse window" — stricter, not looser Regression tests cover the rejection and the reuseInterval exception. Signed-off-by: Helio Machado <0x2b3bfa0+git@googlemail.com> --- server/expiry.go | 34 ++++++++++++++++++++++++---------- server/expiry_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/server/expiry.go b/server/expiry.go index fa9d8bb03d..553fafc827 100644 --- a/server/expiry.go +++ b/server/expiry.go @@ -39,22 +39,26 @@ func validateConnectorExpiry(e *storage.ConnectorExpiry, c ExpiryCeilings) error if e == nil { return nil } - if err := checkCeiling("expiry.idTokens", e.IDTokens, c.IDTokens); err != nil { + // idTokens: zero means "inherit" at runtime (idTokensValidForConn falls back + // to the global via value()), so "0s" here is harmless. + if err := checkCeiling("expiry.idTokens", e.IDTokens, c.IDTokens, false); err != nil { return err } if e.RefreshTokens == nil { return nil } for _, f := range []struct { - name string - value string - ceiling time.Duration + name string + value string + ceiling time.Duration + zeroDisables bool // RefreshTokenPolicy treats 0 as "expiration disabled" for this field }{ - {"expiry.refreshTokens.absoluteLifetime", e.RefreshTokens.AbsoluteLifetime, c.RefreshAbsoluteLifetime}, - {"expiry.refreshTokens.validIfNotUsedFor", e.RefreshTokens.ValidIfNotUsedFor, c.RefreshValidIfNotUsedFor}, - {"expiry.refreshTokens.reuseInterval", e.RefreshTokens.ReuseInterval, c.RefreshReuseInterval}, + {"expiry.refreshTokens.absoluteLifetime", e.RefreshTokens.AbsoluteLifetime, c.RefreshAbsoluteLifetime, true}, + {"expiry.refreshTokens.validIfNotUsedFor", e.RefreshTokens.ValidIfNotUsedFor, c.RefreshValidIfNotUsedFor, true}, + // reuseInterval: 0 means "no reuse window" — stricter than any positive value, never looser. + {"expiry.refreshTokens.reuseInterval", e.RefreshTokens.ReuseInterval, c.RefreshReuseInterval, false}, } { - if err := checkCeiling(f.name, f.value, f.ceiling); err != nil { + if err := checkCeiling(f.name, f.value, f.ceiling, f.zeroDisables); err != nil { return err } } @@ -64,7 +68,11 @@ func validateConnectorExpiry(e *storage.ConnectorExpiry, c ExpiryCeilings) error return nil } -func checkCeiling(field, value string, ceiling time.Duration) error { +// checkCeiling enforces that a per-connector duration is at least as strict as +// the global ceiling. When zeroDisables is true, an override of 0 is rejected +// in the presence of a positive ceiling because RefreshTokenPolicy treats 0 as +// "no expiration" for that field — strictly looser than any positive global. +func checkCeiling(field, value string, ceiling time.Duration, zeroDisables bool) error { if value == "" { return nil } @@ -72,9 +80,15 @@ func checkCeiling(field, value string, ceiling time.Duration) error { if err != nil { return fmt.Errorf("parse %s: %v", field, err) } - if ceiling > 0 && d > ceiling { + if ceiling <= 0 { + return nil + } + if d > ceiling { return fmt.Errorf("%s (%s) exceeds the global value (%s)", field, d, ceiling) } + if zeroDisables && d == 0 { + return fmt.Errorf("%s cannot be 0 (disables expiration) when the global value (%s) is set", field, ceiling) + } return nil } diff --git a/server/expiry_test.go b/server/expiry_test.go index c164670425..dd8c02dc28 100644 --- a/server/expiry_test.go +++ b/server/expiry_test.go @@ -45,6 +45,40 @@ func TestValidateConnectorExpiry_NoCeiling(t *testing.T) { )) } +func TestValidateConnectorExpiry_RefreshAbsoluteLifetimeZeroDisables(t *testing.T) { + // "0s" parses as zero duration, which RefreshTokenPolicy interprets as + // "no expiration" — strictly looser than any positive global ceiling. + err := validateConnectorExpiry( + &storage.ConnectorExpiry{ + RefreshTokens: &storage.ConnectorRefreshExpiry{AbsoluteLifetime: "0s"}, + }, + ExpiryCeilings{RefreshAbsoluteLifetime: 24 * time.Hour}, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "expiry.refreshTokens.absoluteLifetime cannot be 0") +} + +func TestValidateConnectorExpiry_RefreshValidIfNotUsedForZeroDisables(t *testing.T) { + err := validateConnectorExpiry( + &storage.ConnectorExpiry{ + RefreshTokens: &storage.ConnectorRefreshExpiry{ValidIfNotUsedFor: "0s"}, + }, + ExpiryCeilings{RefreshValidIfNotUsedFor: 1 * time.Hour}, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "expiry.refreshTokens.validIfNotUsedFor cannot be 0") +} + +func TestValidateConnectorExpiry_RefreshReuseIntervalZeroIsStricter(t *testing.T) { + // reuseInterval=0 means "no reuse window" — stricter, not looser. Accept it. + require.NoError(t, validateConnectorExpiry( + &storage.ConnectorExpiry{ + RefreshTokens: &storage.ConnectorRefreshExpiry{ReuseInterval: "0s"}, + }, + ExpiryCeilings{RefreshReuseInterval: 3 * time.Second}, + )) +} + func TestValidateConnectorExpiry_RefreshAbsoluteLifetimeExceeds(t *testing.T) { err := validateConnectorExpiry( &storage.ConnectorExpiry{ From cc371031a040668ef85695306b97e0cf6307c9aa Mon Sep 17 00:00:00 2001 From: Helio Machado <0x2b3bfa0+git@googlemail.com> Date: Wed, 13 May 2026 09:02:15 +0400 Subject: [PATCH 10/10] refactor: consolidate per-connector expiry code and tests Fold server/expiry.go into server/server.go and server/expiry_test.go into server/server_test.go. The validation, ceiling-check, and override-build helpers are server-construction concerns called from NewServer and from API write paths on *Server, so they belong next to ConnectorExpiryOverride and upsertConnectorExpiryOverride rather than in a separate file. Collapse the test functions into table-driven form matching the project's existing idiom (TestRefreshTokenExpirationScenarios / TestRefreshTokenAuthTime in refreshhandlers_test.go): - 11x TestValidateConnectorExpiry_* -> TestValidateConnectorExpiry with 11 t.Run subcases. - 3x TestBuildConnectorExpiryOverride_* -> TestBuildConnectorExpiryOverride with 3 subcases. - 4x TestBuildExpiryCeilings* in cmd/dex/serve_test.go -> one table-driven test with 4 subcases. - TestUpsertConnectorExpiryOverride stays standalone (state-mutating). Other minor cleanups: - errMatch -> wantErrContains for naming consistency with the rest of the package (server_test.go, api_test.go, etc.). - Subcase names use plain prose rather than period/equals punctuation. - disable/enable bool locals renamed to disableRotation/enableRotation so the names don't invert meaning. - Package-level discardLogger var replaces per-call slog.New(slog.DiscardHandler) in buildConnectorExpiryOverride. - Trimmed WHAT comments and the "single source of truth" docstring over-claim on validateConnectorExpiry. Signed-off-by: Helio Machado <0x2b3bfa0+git@googlemail.com> --- cmd/dex/serve_test.go | 80 ++++++++++------- server/expiry.go | 138 ----------------------------- server/expiry_test.go | 199 ------------------------------------------ server/server.go | 129 +++++++++++++++++++++++++++ server/server_test.go | 145 ++++++++++++++++++++++++++++++ 5 files changed, 324 insertions(+), 367 deletions(-) delete mode 100644 server/expiry.go delete mode 100644 server/expiry_test.go diff --git a/cmd/dex/serve_test.go b/cmd/dex/serve_test.go index 35d1bc1f4b..beea3d6480 100644 --- a/cmd/dex/serve_test.go +++ b/cmd/dex/serve_test.go @@ -33,36 +33,56 @@ func TestNewLogger(t *testing.T) { } func TestBuildExpiryCeilings(t *testing.T) { - c, err := buildExpiryCeilings(24*time.Hour, RefreshToken{ - AbsoluteLifetime: "100h", - ValidIfNotUsedFor: "24h", - ReuseInterval: "3s", - }) - require.NoError(t, err) - assert.Equal(t, server.ExpiryCeilings{ - IDTokens: 24 * time.Hour, - RefreshAbsoluteLifetime: 100 * time.Hour, - RefreshValidIfNotUsedFor: 24 * time.Hour, - RefreshReuseInterval: 3 * time.Second, - }, c) -} - -func TestBuildExpiryCeilingsRefreshUnset(t *testing.T) { - c, err := buildExpiryCeilings(24*time.Hour, RefreshToken{}) - require.NoError(t, err) - assert.Equal(t, server.ExpiryCeilings{IDTokens: 24 * time.Hour}, c) -} - -func TestBuildExpiryCeilingsRotationDisabled(t *testing.T) { - c, err := buildExpiryCeilings(24*time.Hour, RefreshToken{DisableRotation: true}) - require.NoError(t, err) - assert.True(t, c.RefreshRotationDisabled) -} - -func TestBuildExpiryCeilingsInvalidDuration(t *testing.T) { - _, err := buildExpiryCeilings(24*time.Hour, RefreshToken{AbsoluteLifetime: "not-a-duration"}) - require.Error(t, err) - assert.Contains(t, err.Error(), "parse expiry.refreshTokens.absoluteLifetime") + tests := []struct { + name string + refresh RefreshToken + want server.ExpiryCeilings + wantErrContains string + }{ + { + name: "all fields set", + refresh: RefreshToken{ + AbsoluteLifetime: "100h", + ValidIfNotUsedFor: "24h", + ReuseInterval: "3s", + }, + want: server.ExpiryCeilings{ + IDTokens: 24 * time.Hour, + RefreshAbsoluteLifetime: 100 * time.Hour, + RefreshValidIfNotUsedFor: 24 * time.Hour, + RefreshReuseInterval: 3 * time.Second, + }, + }, + { + name: "refresh unset", + want: server.ExpiryCeilings{IDTokens: 24 * time.Hour}, + }, + { + name: "rotation disabled propagates", + refresh: RefreshToken{DisableRotation: true}, + want: server.ExpiryCeilings{ + IDTokens: 24 * time.Hour, + RefreshRotationDisabled: true, + }, + }, + { + name: "invalid duration", + refresh: RefreshToken{AbsoluteLifetime: "not-a-duration"}, + wantErrContains: "parse expiry.refreshTokens.absoluteLifetime", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := buildExpiryCeilings(24*time.Hour, tc.refresh) + if tc.wantErrContains != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.wantErrContains) + return + } + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } } func TestToStorageConnectorCarriesExpiry(t *testing.T) { diff --git a/server/expiry.go b/server/expiry.go deleted file mode 100644 index 553fafc827..0000000000 --- a/server/expiry.go +++ /dev/null @@ -1,138 +0,0 @@ -package server - -import ( - "fmt" - "log/slog" - "time" - - "github.com/dexidp/dex/storage" -) - -// ExpiryCeilings holds the parsed global expiry values that per-connector -// overrides must not loosen. A zero duration field means "no ceiling". -// -// RefreshRotationDisabled blocks the asymmetric case where the global enables -// rotation: a per-connector override cannot disable it, since rotation-enabled -// is the stricter policy. The reverse direction is permitted. -type ExpiryCeilings struct { - IDTokens time.Duration - RefreshAbsoluteLifetime time.Duration - RefreshValidIfNotUsedFor time.Duration - RefreshReuseInterval time.Duration - RefreshRotationDisabled bool -} - -// RefreshTokenDefaults are the inheritance roots for per-connector overrides -// that leave fields unset. -type RefreshTokenDefaults struct { - DisableRotation bool - ValidIfNotUsedFor string - AbsoluteLifetime string - ReuseInterval string -} - -// validateConnectorExpiry rejects per-connector overrides that loosen the -// global policy. This function is the single source of truth for the -// hierarchy rule; it is called from both the static YAML load path and every -// gRPC API write so that no configuration modification can ever bypass it. -func validateConnectorExpiry(e *storage.ConnectorExpiry, c ExpiryCeilings) error { - if e == nil { - return nil - } - // idTokens: zero means "inherit" at runtime (idTokensValidForConn falls back - // to the global via value()), so "0s" here is harmless. - if err := checkCeiling("expiry.idTokens", e.IDTokens, c.IDTokens, false); err != nil { - return err - } - if e.RefreshTokens == nil { - return nil - } - for _, f := range []struct { - name string - value string - ceiling time.Duration - zeroDisables bool // RefreshTokenPolicy treats 0 as "expiration disabled" for this field - }{ - {"expiry.refreshTokens.absoluteLifetime", e.RefreshTokens.AbsoluteLifetime, c.RefreshAbsoluteLifetime, true}, - {"expiry.refreshTokens.validIfNotUsedFor", e.RefreshTokens.ValidIfNotUsedFor, c.RefreshValidIfNotUsedFor, true}, - // reuseInterval: 0 means "no reuse window" — stricter than any positive value, never looser. - {"expiry.refreshTokens.reuseInterval", e.RefreshTokens.ReuseInterval, c.RefreshReuseInterval, false}, - } { - if err := checkCeiling(f.name, f.value, f.ceiling, f.zeroDisables); err != nil { - return err - } - } - if dr := e.RefreshTokens.DisableRotation; dr != nil && *dr && !c.RefreshRotationDisabled { - return fmt.Errorf("expiry.refreshTokens.disableRotation cannot disable rotation when it is enabled globally") - } - return nil -} - -// checkCeiling enforces that a per-connector duration is at least as strict as -// the global ceiling. When zeroDisables is true, an override of 0 is rejected -// in the presence of a positive ceiling because RefreshTokenPolicy treats 0 as -// "no expiration" for that field — strictly looser than any positive global. -func checkCeiling(field, value string, ceiling time.Duration, zeroDisables bool) error { - if value == "" { - return nil - } - d, err := time.ParseDuration(value) - if err != nil { - return fmt.Errorf("parse %s: %v", field, err) - } - if ceiling <= 0 { - return nil - } - if d > ceiling { - return fmt.Errorf("%s (%s) exceeds the global value (%s)", field, d, ceiling) - } - if zeroDisables && d == 0 { - return fmt.Errorf("%s cannot be 0 (disables expiration) when the global value (%s) is set", field, ceiling) - } - return nil -} - -// buildConnectorExpiryOverride parses a (pre-validated) storage.ConnectorExpiry -// into a ConnectorExpiryOverride. Unset string fields inherit from the global -// refresh defaults so the resulting RefreshTokenPolicy carries the correct -// effective values. -func buildConnectorExpiryOverride(e *storage.ConnectorExpiry, defaults RefreshTokenDefaults) (ConnectorExpiryOverride, error) { - var override ConnectorExpiryOverride - if e == nil { - return override, nil - } - - if e.IDTokens != "" { - d, err := time.ParseDuration(e.IDTokens) - if err != nil { - return override, fmt.Errorf("parse expiry.idTokens: %v", err) - } - override.IDTokensValidFor = d - } - - rt := e.RefreshTokens - if rt == nil { - return override, nil - } - - disableRotation := defaults.DisableRotation - if rt.DisableRotation != nil { - disableRotation = *rt.DisableRotation - } - // NewRefreshTokenPolicy emits one Info line per field at startup; that's - // useful for the single global policy but would spam logs at N connectors × - // 4 fields, on every API write. Pass a discard logger and let the caller - // summarize. - policy, err := NewRefreshTokenPolicy( - slog.New(slog.DiscardHandler), - disableRotation, - defaultTo(rt.ValidIfNotUsedFor, defaults.ValidIfNotUsedFor), - defaultTo(rt.AbsoluteLifetime, defaults.AbsoluteLifetime), - defaultTo(rt.ReuseInterval, defaults.ReuseInterval), - ) - if err != nil { - return override, fmt.Errorf("refresh token policy: %v", err) - } - override.RefreshTokenPolicy = policy - return override, nil -} diff --git a/server/expiry_test.go b/server/expiry_test.go deleted file mode 100644 index dd8c02dc28..0000000000 --- a/server/expiry_test.go +++ /dev/null @@ -1,199 +0,0 @@ -package server - -import ( - "log/slog" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/dexidp/dex/storage" -) - -func TestValidateConnectorExpiry_Nil(t *testing.T) { - require.NoError(t, validateConnectorExpiry(nil, ExpiryCeilings{})) -} - -func TestValidateConnectorExpiry_IDTokensExceeds(t *testing.T) { - err := validateConnectorExpiry( - &storage.ConnectorExpiry{IDTokens: "48h"}, - ExpiryCeilings{IDTokens: 24 * time.Hour}, - ) - require.Error(t, err) - assert.Contains(t, err.Error(), "expiry.idTokens (48h0m0s) exceeds the global value (24h0m0s)") -} - -func TestValidateConnectorExpiry_InvalidDurationNoCeiling(t *testing.T) { - // Garbage strings are rejected even when the global has no ceiling, so - // they can't slip past validation and explode later in NewRefreshTokenPolicy. - err := validateConnectorExpiry( - &storage.ConnectorExpiry{ - RefreshTokens: &storage.ConnectorRefreshExpiry{AbsoluteLifetime: "not-a-duration"}, - }, - ExpiryCeilings{}, - ) - require.Error(t, err) - assert.Contains(t, err.Error(), "parse expiry.refreshTokens.absoluteLifetime") -} - -func TestValidateConnectorExpiry_NoCeiling(t *testing.T) { - // Global unset → no ceiling. - require.NoError(t, validateConnectorExpiry( - &storage.ConnectorExpiry{IDTokens: "48h"}, - ExpiryCeilings{}, - )) -} - -func TestValidateConnectorExpiry_RefreshAbsoluteLifetimeZeroDisables(t *testing.T) { - // "0s" parses as zero duration, which RefreshTokenPolicy interprets as - // "no expiration" — strictly looser than any positive global ceiling. - err := validateConnectorExpiry( - &storage.ConnectorExpiry{ - RefreshTokens: &storage.ConnectorRefreshExpiry{AbsoluteLifetime: "0s"}, - }, - ExpiryCeilings{RefreshAbsoluteLifetime: 24 * time.Hour}, - ) - require.Error(t, err) - assert.Contains(t, err.Error(), "expiry.refreshTokens.absoluteLifetime cannot be 0") -} - -func TestValidateConnectorExpiry_RefreshValidIfNotUsedForZeroDisables(t *testing.T) { - err := validateConnectorExpiry( - &storage.ConnectorExpiry{ - RefreshTokens: &storage.ConnectorRefreshExpiry{ValidIfNotUsedFor: "0s"}, - }, - ExpiryCeilings{RefreshValidIfNotUsedFor: 1 * time.Hour}, - ) - require.Error(t, err) - assert.Contains(t, err.Error(), "expiry.refreshTokens.validIfNotUsedFor cannot be 0") -} - -func TestValidateConnectorExpiry_RefreshReuseIntervalZeroIsStricter(t *testing.T) { - // reuseInterval=0 means "no reuse window" — stricter, not looser. Accept it. - require.NoError(t, validateConnectorExpiry( - &storage.ConnectorExpiry{ - RefreshTokens: &storage.ConnectorRefreshExpiry{ReuseInterval: "0s"}, - }, - ExpiryCeilings{RefreshReuseInterval: 3 * time.Second}, - )) -} - -func TestValidateConnectorExpiry_RefreshAbsoluteLifetimeExceeds(t *testing.T) { - err := validateConnectorExpiry( - &storage.ConnectorExpiry{ - RefreshTokens: &storage.ConnectorRefreshExpiry{AbsoluteLifetime: "100h"}, - }, - ExpiryCeilings{RefreshAbsoluteLifetime: 24 * time.Hour}, - ) - require.Error(t, err) - assert.Contains(t, err.Error(), "expiry.refreshTokens.absoluteLifetime (100h0m0s) exceeds the global value (24h0m0s)") -} - -func TestValidateConnectorExpiry_AllFieldsBelowCeiling(t *testing.T) { - enable := false - require.NoError(t, validateConnectorExpiry( - &storage.ConnectorExpiry{ - IDTokens: "10m", - RefreshTokens: &storage.ConnectorRefreshExpiry{ - DisableRotation: &enable, // tighten: global has it disabled, connector enables it - ReuseInterval: "1s", - AbsoluteLifetime: "1h", - ValidIfNotUsedFor: "30m", - }, - }, - ExpiryCeilings{ - IDTokens: 1 * time.Hour, - RefreshAbsoluteLifetime: 24 * time.Hour, - RefreshValidIfNotUsedFor: 1 * time.Hour, - RefreshReuseInterval: 3 * time.Second, - RefreshRotationDisabled: true, - }, - )) -} - -func TestValidateConnectorExpiry_DisableRotationLoosens(t *testing.T) { - // Global has rotation enabled; connector cannot disable it. - disable := true - err := validateConnectorExpiry( - &storage.ConnectorExpiry{ - RefreshTokens: &storage.ConnectorRefreshExpiry{DisableRotation: &disable}, - }, - ExpiryCeilings{}, // RefreshRotationDisabled defaults to false (rotation enabled) - ) - require.Error(t, err) - assert.Contains(t, err.Error(), "disableRotation cannot disable rotation when it is enabled globally") -} - -func TestValidateConnectorExpiry_DisableRotationTightens(t *testing.T) { - // Global has rotation disabled; connector can enable it (stricter). - enable := false - require.NoError(t, validateConnectorExpiry( - &storage.ConnectorExpiry{ - RefreshTokens: &storage.ConnectorRefreshExpiry{DisableRotation: &enable}, - }, - ExpiryCeilings{RefreshRotationDisabled: true}, - )) -} - -func TestBuildConnectorExpiryOverride_Nil(t *testing.T) { - got, err := buildConnectorExpiryOverride(nil, RefreshTokenDefaults{}) - require.NoError(t, err) - assert.Zero(t, got.IDTokensValidFor) - assert.Nil(t, got.RefreshTokenPolicy) -} - -func TestBuildConnectorExpiryOverride_IDTokensOnly(t *testing.T) { - got, err := buildConnectorExpiryOverride( - &storage.ConnectorExpiry{IDTokens: "5m"}, - RefreshTokenDefaults{}, - ) - require.NoError(t, err) - assert.Equal(t, 5*time.Minute, got.IDTokensValidFor) - assert.Nil(t, got.RefreshTokenPolicy) -} - -func TestBuildConnectorExpiryOverride_RefreshInheritsGlobals(t *testing.T) { - disable := true - got, err := buildConnectorExpiryOverride( - &storage.ConnectorExpiry{ - RefreshTokens: &storage.ConnectorRefreshExpiry{ - DisableRotation: &disable, - AbsoluteLifetime: "1h", - // ValidIfNotUsedFor and ReuseInterval omitted: inherit from defaults - }, - }, - RefreshTokenDefaults{ - DisableRotation: false, - ValidIfNotUsedFor: "30m", - AbsoluteLifetime: "100h", - ReuseInterval: "3s", - }, - ) - require.NoError(t, err) - require.NotNil(t, got.RefreshTokenPolicy) - assert.False(t, got.RefreshTokenPolicy.RotationEnabled(), "DisableRotation=true overrides global") -} - -func TestUpsertConnectorExpiryOverride(t *testing.T) { - s := &Server{ - logger: slog.New(slog.DiscardHandler), - idTokensValidFor: time.Hour, - expiryCeilings: ExpiryCeilings{IDTokens: time.Hour}, - connectorExpiryOverrides: map[string]ConnectorExpiryOverride{}, - } - - // Accept a tighter override. - require.NoError(t, s.upsertConnectorExpiryOverride("c1", &storage.ConnectorExpiry{IDTokens: "5m"})) - assert.Equal(t, 5*time.Minute, s.idTokensValidForConn("c1")) - - // Reject a looser override; map is left untouched. - err := s.upsertConnectorExpiryOverride("c2", &storage.ConnectorExpiry{IDTokens: "48h"}) - require.Error(t, err) - assert.Contains(t, err.Error(), "exceeds the global value") - assert.Equal(t, time.Hour, s.idTokensValidForConn("c2"), "rejected override must not be installed") - - // Clearing the override via nil reverts to the global. - require.NoError(t, s.upsertConnectorExpiryOverride("c1", nil)) - assert.Equal(t, time.Hour, s.idTokensValidForConn("c1")) -} diff --git a/server/server.go b/server/server.go index 2c8d433d38..fd10621ff7 100644 --- a/server/server.go +++ b/server/server.go @@ -222,6 +222,135 @@ type ConnectorExpiryOverride struct { RefreshTokenPolicy *RefreshTokenPolicy } +// ExpiryCeilings holds the parsed global expiry values that per-connector +// overrides must not loosen. A zero duration field means "no ceiling". +// +// RefreshRotationDisabled blocks the asymmetric case where the global enables +// rotation: a per-connector override cannot disable it, since rotation-enabled +// is the stricter policy. The reverse direction is permitted. +type ExpiryCeilings struct { + IDTokens time.Duration + RefreshAbsoluteLifetime time.Duration + RefreshValidIfNotUsedFor time.Duration + RefreshReuseInterval time.Duration + RefreshRotationDisabled bool +} + +// RefreshTokenDefaults are the inheritance roots for per-connector overrides +// that leave fields unset. +type RefreshTokenDefaults struct { + DisableRotation bool + ValidIfNotUsedFor string + AbsoluteLifetime string + ReuseInterval string +} + +// discardLogger is used when a constructor logs at Info level for global +// startup config but the call is part of a per-connector hot path. +var discardLogger = slog.New(slog.DiscardHandler) + +// validateConnectorExpiry rejects per-connector overrides that loosen the +// global policy. Called from the static YAML load path and from every gRPC +// API write. +func validateConnectorExpiry(e *storage.ConnectorExpiry, c ExpiryCeilings) error { + if e == nil { + return nil + } + // idTokens=0 means "inherit"; idTokensValidForConn falls back to the global. + if err := checkCeiling("expiry.idTokens", e.IDTokens, c.IDTokens, false); err != nil { + return err + } + if e.RefreshTokens == nil { + return nil + } + for _, f := range []struct { + name string + value string + ceiling time.Duration + zeroDisables bool // RefreshTokenPolicy treats 0 as "expiration disabled" for this field + }{ + {"expiry.refreshTokens.absoluteLifetime", e.RefreshTokens.AbsoluteLifetime, c.RefreshAbsoluteLifetime, true}, + {"expiry.refreshTokens.validIfNotUsedFor", e.RefreshTokens.ValidIfNotUsedFor, c.RefreshValidIfNotUsedFor, true}, + {"expiry.refreshTokens.reuseInterval", e.RefreshTokens.ReuseInterval, c.RefreshReuseInterval, false}, + } { + if err := checkCeiling(f.name, f.value, f.ceiling, f.zeroDisables); err != nil { + return err + } + } + if dr := e.RefreshTokens.DisableRotation; dr != nil && *dr && !c.RefreshRotationDisabled { + return fmt.Errorf("expiry.refreshTokens.disableRotation cannot disable rotation when it is enabled globally") + } + return nil +} + +// checkCeiling enforces that a per-connector duration is at least as strict as +// the global ceiling. When zeroDisables is true, an override of 0 is rejected +// in the presence of a positive ceiling because RefreshTokenPolicy treats 0 as +// "no expiration" for that field — strictly looser than any positive global. +func checkCeiling(field, value string, ceiling time.Duration, zeroDisables bool) error { + if value == "" { + return nil + } + d, err := time.ParseDuration(value) + if err != nil { + return fmt.Errorf("parse %s: %v", field, err) + } + if ceiling <= 0 { + return nil + } + if d > ceiling { + return fmt.Errorf("%s (%s) exceeds the global value (%s)", field, d, ceiling) + } + if zeroDisables && d == 0 { + return fmt.Errorf("%s cannot be 0 (disables expiration) when the global value (%s) is set", field, ceiling) + } + return nil +} + +// buildConnectorExpiryOverride parses a (pre-validated) storage.ConnectorExpiry +// into a ConnectorExpiryOverride. Unset string fields inherit from the global +// refresh defaults so the resulting RefreshTokenPolicy carries the correct +// effective values. +func buildConnectorExpiryOverride(e *storage.ConnectorExpiry, defaults RefreshTokenDefaults) (ConnectorExpiryOverride, error) { + var override ConnectorExpiryOverride + if e == nil { + return override, nil + } + + if e.IDTokens != "" { + d, err := time.ParseDuration(e.IDTokens) + if err != nil { + return override, fmt.Errorf("parse expiry.idTokens: %v", err) + } + override.IDTokensValidFor = d + } + + rt := e.RefreshTokens + if rt == nil { + return override, nil + } + + disableRotation := defaults.DisableRotation + if rt.DisableRotation != nil { + disableRotation = *rt.DisableRotation + } + // NewRefreshTokenPolicy emits one Info line per field; useful for the single + // global policy but would spam logs at N connectors × 4 fields on every API + // write. Pass a discard logger and let the caller summarize. + policy, err := NewRefreshTokenPolicy( + discardLogger, + disableRotation, + defaultTo(rt.ValidIfNotUsedFor, defaults.ValidIfNotUsedFor), + defaultTo(rt.AbsoluteLifetime, defaults.AbsoluteLifetime), + defaultTo(rt.ReuseInterval, defaults.ReuseInterval), + ) + if err != nil { + return override, fmt.Errorf("refresh token policy: %v", err) + } + override.RefreshTokenPolicy = policy + return override, nil +} + // Server is the top level object. type Server struct { issuerURL url.URL diff --git a/server/server_test.go b/server/server_test.go index db8f12ce25..5d2f095862 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "io" + "log/slog" "net/http" "net/http/httptest" "net/http/httputil" @@ -2055,3 +2056,147 @@ func TestConnectorFailureHandling(t *testing.T) { }) } } + +func TestValidateConnectorExpiry(t *testing.T) { + disableRotation := true + enableRotation := false + tests := []struct { + name string + expiry *storage.ConnectorExpiry + ceilings ExpiryCeilings + wantErrContains string + }{ + {name: "nil expiry"}, + { + name: "idTokens within ceiling", + expiry: &storage.ConnectorExpiry{IDTokens: "10m"}, + ceilings: ExpiryCeilings{IDTokens: time.Hour}, + }, + { + name: "idTokens exceeds ceiling", + expiry: &storage.ConnectorExpiry{IDTokens: "48h"}, + ceilings: ExpiryCeilings{IDTokens: 24 * time.Hour}, + wantErrContains: "expiry.idTokens (48h0m0s) exceeds the global value", + }, + { + name: "global unset means no ceiling", + expiry: &storage.ConnectorExpiry{IDTokens: "48h"}, + }, + { + name: "invalid duration rejected even without ceiling", + expiry: &storage.ConnectorExpiry{RefreshTokens: &storage.ConnectorRefreshExpiry{AbsoluteLifetime: "not-a-duration"}}, + wantErrContains: "parse expiry.refreshTokens.absoluteLifetime", + }, + { + name: "refresh absoluteLifetime exceeds ceiling", + expiry: &storage.ConnectorExpiry{RefreshTokens: &storage.ConnectorRefreshExpiry{AbsoluteLifetime: "100h"}}, + ceilings: ExpiryCeilings{RefreshAbsoluteLifetime: 24 * time.Hour}, + wantErrContains: "expiry.refreshTokens.absoluteLifetime (100h0m0s) exceeds the global value", + }, + { + name: "refresh absoluteLifetime of zero disables and is rejected", + expiry: &storage.ConnectorExpiry{RefreshTokens: &storage.ConnectorRefreshExpiry{AbsoluteLifetime: "0s"}}, + ceilings: ExpiryCeilings{RefreshAbsoluteLifetime: 24 * time.Hour}, + wantErrContains: "expiry.refreshTokens.absoluteLifetime cannot be 0", + }, + { + name: "refresh validIfNotUsedFor of zero disables and is rejected", + expiry: &storage.ConnectorExpiry{RefreshTokens: &storage.ConnectorRefreshExpiry{ValidIfNotUsedFor: "0s"}}, + ceilings: ExpiryCeilings{RefreshValidIfNotUsedFor: time.Hour}, + wantErrContains: "expiry.refreshTokens.validIfNotUsedFor cannot be 0", + }, + { + name: "refresh reuseInterval of zero is stricter, accepted", + expiry: &storage.ConnectorExpiry{RefreshTokens: &storage.ConnectorRefreshExpiry{ReuseInterval: "0s"}}, + ceilings: ExpiryCeilings{RefreshReuseInterval: 3 * time.Second}, + }, + { + name: "disableRotation cannot loosen global", + expiry: &storage.ConnectorExpiry{RefreshTokens: &storage.ConnectorRefreshExpiry{DisableRotation: &disableRotation}}, + wantErrContains: "disableRotation cannot disable rotation when it is enabled globally", + }, + { + name: "enabling rotation when globally disabled is a tightening", + expiry: &storage.ConnectorExpiry{RefreshTokens: &storage.ConnectorRefreshExpiry{DisableRotation: &enableRotation}}, + ceilings: ExpiryCeilings{RefreshRotationDisabled: true}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := validateConnectorExpiry(tc.expiry, tc.ceilings) + if tc.wantErrContains == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + require.Contains(t, err.Error(), tc.wantErrContains) + }) + } +} + +func TestBuildConnectorExpiryOverride(t *testing.T) { + disableRotation := true + tests := []struct { + name string + expiry *storage.ConnectorExpiry + defaults RefreshTokenDefaults + wantIDTokens time.Duration + wantPolicy bool + wantRotationOn bool + }{ + {name: "nil expiry yields zero override"}, + { + name: "idTokens only", + expiry: &storage.ConnectorExpiry{IDTokens: "5m"}, + wantIDTokens: 5 * time.Minute, + }, + { + name: "refresh override inherits unset fields from defaults", + expiry: &storage.ConnectorExpiry{RefreshTokens: &storage.ConnectorRefreshExpiry{ + DisableRotation: &disableRotation, + AbsoluteLifetime: "1h", + }}, + defaults: RefreshTokenDefaults{ + ValidIfNotUsedFor: "30m", AbsoluteLifetime: "100h", ReuseInterval: "3s", + }, + wantPolicy: true, + wantRotationOn: false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := buildConnectorExpiryOverride(tc.expiry, tc.defaults) + require.NoError(t, err) + require.Equal(t, tc.wantIDTokens, got.IDTokensValidFor) + if !tc.wantPolicy { + require.Nil(t, got.RefreshTokenPolicy) + return + } + require.NotNil(t, got.RefreshTokenPolicy) + require.Equal(t, tc.wantRotationOn, got.RefreshTokenPolicy.RotationEnabled()) + }) + } +} + +func TestUpsertConnectorExpiryOverride(t *testing.T) { + s := &Server{ + logger: slog.New(slog.DiscardHandler), + idTokensValidFor: time.Hour, + expiryCeilings: ExpiryCeilings{IDTokens: time.Hour}, + connectorExpiryOverrides: map[string]ConnectorExpiryOverride{}, + } + + // Accept a tighter override. + require.NoError(t, s.upsertConnectorExpiryOverride("c1", &storage.ConnectorExpiry{IDTokens: "5m"})) + require.Equal(t, 5*time.Minute, s.idTokensValidForConn("c1")) + + // Reject a looser override; map is left untouched. + err := s.upsertConnectorExpiryOverride("c2", &storage.ConnectorExpiry{IDTokens: "48h"}) + require.Error(t, err) + require.Contains(t, err.Error(), "exceeds the global value") + require.Equal(t, time.Hour, s.idTokensValidForConn("c2"), "rejected override must not be installed") + + // Clearing the override via nil reverts to the global. + require.NoError(t, s.upsertConnectorExpiryOverride("c1", nil)) + require.Equal(t, time.Hour, s.idTokensValidForConn("c1")) +}