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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
960 changes: 597 additions & 363 deletions api/v2/api.pb.go

Large diffs are not rendered by default.

31 changes: 31 additions & 0 deletions api/v2/api.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
42 changes: 38 additions & 4 deletions cmd/dex/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,26 @@ 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"`
}

type ConnectorExpiry struct {
IDTokens string `json:"idTokens"`
RefreshTokens *ConnectorRefreshExpiry `json:"refreshTokens"`
}

// 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 ConnectorRefreshExpiry 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
Expand All @@ -569,8 +589,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"`
}
if err := configUnmarshaller(b, &conn); err != nil {
return fmt.Errorf("parse connector: %v", err)
Expand Down Expand Up @@ -613,6 +634,7 @@ func (c *Connector) UnmarshalJSON(b []byte) error {
ID: conn.ID,
Config: connConfig,
GrantTypes: conn.GrantTypes,
Expiry: conn.Expiry,
}
return nil
}
Expand All @@ -624,13 +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,
}, nil
}
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 sc, nil
}

// Expiry holds configuration for the validity period of components.
Expand Down
41 changes: 41 additions & 0 deletions cmd/dex/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,18 @@ func runServe(options serveOptions) error {

serverConfig.RefreshTokenPolicy = refreshTokenPolicy

ceilings, err := buildExpiryCeilings(idTokensValidFor, c.Expiry.RefreshTokens)
if err != nil {
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,
}

if featureflags.SessionsEnabled.Enabled() {
sessionConfig, err := parseSessionConfig(c.Sessions)
if err != nil {
Expand Down Expand Up @@ -832,6 +844,35 @@ func parseSessionConfig(s *Sessions) (*server.SessionConfig, error) {
return sc, nil
}

// 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,
RefreshRotationDisabled: globalRefresh.DisableRotation,
}
for _, f := range []struct {
name string
value string
dst *time.Duration
}{
{"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
}
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 buildMFAProviders(authenticators []MFAAuthenticator, issuerURL string, logger *slog.Logger) map[string]server.MFAProvider {
if len(authenticators) == 0 {
return nil
Expand Down
84 changes: 84 additions & 0 deletions cmd/dex/serve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@ package main
import (
"log/slog"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/dexidp/dex/server"
)

func TestNewLogger(t *testing.T) {
Expand All @@ -27,3 +31,83 @@ func TestNewLogger(t *testing.T) {
require.Equal(t, (*slog.Logger)(nil), logger)
})
}

func TestBuildExpiryCeilings(t *testing.T) {
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) {
disable := true
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.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)

sc, err = ToStorageConnector(Connector{ID: "c1", Type: "mockCallback", Name: "c1"})
require.NoError(t, err)
assert.Nil(t, sc.Expiry)
}
20 changes: 20 additions & 0 deletions config.yaml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,26 @@ 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. Overrides must be at least as strict as the global
# 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
# 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.
#
Expand Down
Loading
Loading