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
23 changes: 23 additions & 0 deletions cmd/dex/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ func (c Config) Validate() error {
{c.GRPC.TLSMinVersion != "" && c.GRPC.TLSMinVersion != "1.2" && c.GRPC.TLSMinVersion != "1.3", "supported TLS versions are: 1.2, 1.3"},
{c.GRPC.TLSMaxVersion != "" && c.GRPC.TLSMaxVersion != "1.2" && c.GRPC.TLSMaxVersion != "1.3", "supported TLS versions are: 1.2, 1.3"},
{c.GRPC.TLSMaxVersion != "" && c.GRPC.TLSMinVersion != "" && c.GRPC.TLSMinVersion > c.GRPC.TLSMaxVersion, "TLSMinVersion greater than TLSMaxVersion"},
{c.OAuth2.DynamicClientRegistration.Enabled && c.OAuth2.DynamicClientRegistration.InitialAccessToken == "" && c.OAuth2.DynamicClientRegistration.InitialAccessTokenEnv == "", "dynamic client registration requires an initial access token"},
{c.OAuth2.DynamicClientRegistration.InitialAccessToken != "" && c.OAuth2.DynamicClientRegistration.InitialAccessTokenEnv != "", "dynamic client registration initialAccessToken and initialAccessTokenEnv are mutually exclusive"},
}

var checkErrors []string
Expand Down Expand Up @@ -252,6 +254,27 @@ type OAuth2 struct {
PasswordConnector string `json:"passwordConnector"`
// PKCE configuration
PKCE PKCE `json:"pkce"`
// DynamicClientRegistration controls the protected RFC 7591 endpoint.
DynamicClientRegistration DynamicClientRegistration `json:"dynamicClientRegistration"`
}

// DynamicClientRegistration is disabled by default. When enabled, clients
// must authenticate with the configured initial access token.
type DynamicClientRegistration struct {
Enabled bool `json:"enabled"`
InitialAccessToken string `json:"initialAccessToken"`
InitialAccessTokenEnv string `json:"initialAccessTokenEnv"`
}

func (c DynamicClientRegistration) token() (string, error) {
if c.InitialAccessTokenEnv == "" {
return c.InitialAccessToken, nil
}
token := os.Getenv(c.InitialAccessTokenEnv)
if token == "" {
return "", fmt.Errorf("dynamic client registration environment variable %q is unset or empty", c.InitialAccessTokenEnv)
}
return token, nil
}

// PKCE holds the PKCE (Proof Key for Code Exchange) configuration.
Expand Down
54 changes: 54 additions & 0 deletions cmd/dex/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,53 @@ func TestInvalidConfiguration(t *testing.T) {
}
}

func TestDynamicClientRegistrationConfiguration(t *testing.T) {
validConfig := func() Config {
return Config{
Issuer: "http://127.0.0.1:5556/dex",
Storage: Storage{Type: "sqlite3", Config: &sql.SQLite3{File: "examples/dex.db"}},
Web: Web{HTTP: "127.0.0.1:5556"},
}
}

t.Run("requires token source", func(t *testing.T) {
configuration := validConfig()
configuration.OAuth2.DynamicClientRegistration.Enabled = true

err := configuration.Validate()
require.ErrorContains(t, err, "dynamic client registration requires an initial access token")
})

t.Run("token sources are mutually exclusive", func(t *testing.T) {
configuration := validConfig()
configuration.OAuth2.DynamicClientRegistration = DynamicClientRegistration{
Enabled: true,
InitialAccessToken: "token",
InitialAccessTokenEnv: "DEX_REGISTRATION_TOKEN",
}

err := configuration.Validate()
require.ErrorContains(t, err, "initialAccessToken and initialAccessTokenEnv are mutually exclusive")
})

t.Run("reads token from environment", func(t *testing.T) {
t.Setenv("DEX_REGISTRATION_TOKEN", "token")
configuration := DynamicClientRegistration{InitialAccessTokenEnv: "DEX_REGISTRATION_TOKEN"}

token, err := configuration.token()
require.NoError(t, err)
require.Equal(t, "token", token)
})

t.Run("rejects empty environment token", func(t *testing.T) {
t.Setenv("DEX_REGISTRATION_TOKEN", "")
configuration := DynamicClientRegistration{InitialAccessTokenEnv: "DEX_REGISTRATION_TOKEN"}

_, err := configuration.token()
require.EqualError(t, err, `dynamic client registration environment variable "DEX_REGISTRATION_TOKEN" is unset or empty`)
})
}

// TestInvalidRefreshTokenLifetime: a misspelled lifetime must not read as the
// default, leaving tokens the client wanted bound outliving the session.
func TestInvalidRefreshTokenLifetime(t *testing.T) {
Expand Down Expand Up @@ -122,6 +169,9 @@ oauth2:
grantTypes:
- refresh_token
- "urn:ietf:params:oauth:grant-type:token-exchange"
dynamicClientRegistration:
enabled: true
initialAccessTokenEnv: DEX_REGISTRATION_INITIAL_ACCESS_TOKEN

connectors:
- type: mockCallback
Expand Down Expand Up @@ -218,6 +268,10 @@ additionalFeatures: [
"refresh_token",
"urn:ietf:params:oauth:grant-type:token-exchange",
},
DynamicClientRegistration: DynamicClientRegistration{
Enabled: true,
InitialAccessTokenEnv: "DEX_REGISTRATION_INITIAL_ACCESS_TOKEN",
},
},
StaticConnectors: []Connector{
{
Expand Down
9 changes: 9 additions & 0 deletions cmd/dex/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,15 @@ func runServe(options serveOptions) error {
MFAProviders: buildMFAProviders(c.MFA.Authenticators, c.Issuer, logger),
DefaultMFAChain: c.MFA.DefaultMFAChain,
}
if c.OAuth2.DynamicClientRegistration.Enabled {
initialAccessToken, err := c.OAuth2.DynamicClientRegistration.token()
if err != nil {
return err
}
serverConfig.DynamicClientRegistration = &server.DynamicClientRegistrationConfig{
InitialAccessToken: initialAccessToken,
}
}

if c.Expiry.AuthRequests != "" {
authRequests, err := time.ParseDuration(c.Expiry.AuthRequests)
Expand Down
6 changes: 6 additions & 0 deletions config.yaml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,12 @@ web:
# enforce: false
# # Supported code challenge methods. Defaults to ["S256", "plain"].
# codeChallengeMethodsSupported: ["S256", "plain"]
#
# # RFC 7591 dynamic client registration. Keep the initial access token secret;
# # unauthenticated registration is not supported.
# dynamicClientRegistration:
# enabled: true
# initialAccessTokenEnv: DEX_REGISTRATION_INITIAL_ACCESS_TOKEN

# Static clients registered in Dex by default.
#
Expand Down
6 changes: 6 additions & 0 deletions examples/config-dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,12 @@ oauth2:
# enforce: false
# # Supported code challenge methods. Defaults to ["S256", "plain"].
# codeChallengeMethodsSupported: ["S256", "plain"]
# # RFC 7591 dynamic client registration is disabled by default. When
# # enabled, the initial access token is required. Use initialAccessTokenEnv
# # to keep the token out of this file.
# dynamicClientRegistration:
# enabled: true
# initialAccessTokenEnv: DEX_REGISTRATION_INITIAL_ACCESS_TOKEN

# Multi-factor authentication configuration.
# Requires DEX_SESSIONS_ENABLED=true feature flag.
Expand Down
40 changes: 35 additions & 5 deletions server/authflow/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,17 @@ func isHostLocal(host string) bool {
return host == "localhost" || net.ParseIP(host).IsLoopback()
}

func authorizationGrantTypes(responseTypes []string) []string {
grantTypes := make([]string, 0, 2)
if slices.Contains(responseTypes, oauth2.ResponseTypeCode) {
grantTypes = append(grantTypes, oauth2.GrantTypeAuthorizationCode)
}
if slices.Contains(responseTypes, oauth2.ResponseTypeToken) || slices.Contains(responseTypes, oauth2.ResponseTypeIDToken) {
grantTypes = append(grantTypes, oauth2.GrantTypeImplicit)
}
return grantTypes
}

func validateConnectorID(connectors []storage.Connector, connectorID string) bool {
for _, c := range connectors {
if c.ID == connectorID {
Expand Down Expand Up @@ -222,6 +233,20 @@ func (h *Handler) parseAuthorizationRequest(r *http.Request) (*storage.AuthReque
newredirectedAuthErr := func(typ, format string, a ...interface{}) *redirectedAuthErr {
return &redirectedAuthErr{state, redirectURI, typ, fmt.Sprintf(format, a...)}
}
if len(responseTypes) == 0 {
return nil, "", newredirectedAuthErr(oauth2.InvalidRequest, "No response_type provided")
}
if !client.AllowsScopes(scopes) {
return nil, "", newredirectedAuthErr(oauth2.InvalidScope, "Client did not register all requested scopes.")
}
if !client.AllowsResponseType(responseTypes) {
return nil, "", newredirectedAuthErr(oauth2.UnauthorizedClient, "Client did not register the requested response type.")
}
for _, grantType := range authorizationGrantTypes(responseTypes) {
if !client.AllowsGrantType(grantType) {
return nil, "", newredirectedAuthErr(oauth2.UnauthorizedClient, "Client did not register the grant type required by the requested response type.")
}
}

if connectorID != "" {
connectors, err := h.Storage.ListConnectors(ctx)
Expand All @@ -247,7 +272,16 @@ func (h *Handler) parseAuthorizationRequest(r *http.Request) (*storage.AuthReque
return nil, "", newredirectedAuthErr(oauth2.InvalidRequest, "Unsupported PKCE challenge method (%q).", codeChallengeMethod)
}

// Enforce PKCE if configured.
// Public RFC 7591 clients cannot authenticate at the token endpoint, so bind
// every authorization code to an S256 verifier even when PKCE is optional for
// legacy clients.
dynamicPublicCodeFlow := client.DynamicallyRegistered && client.Public &&
slices.Contains(responseTypes, oauth2.ResponseTypeCode)
if dynamicPublicCodeFlow && (codeChallenge == "" || codeChallengeMethod != oauth2.PKCEMethodS256) {
return nil, "", newredirectedAuthErr(oauth2.InvalidRequest, "Public dynamically registered clients must use the S256 PKCE challenge method.")
}

// Enforce PKCE globally if configured.
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-12#section-4.1.1
if h.PKCE.Enforce && codeChallenge == "" {
return nil, "", newredirectedAuthErr(oauth2.InvalidRequest, "PKCE is required. The code_challenge parameter must be provided.")
Expand Down Expand Up @@ -312,10 +346,6 @@ func (h *Handler) parseAuthorizationRequest(r *http.Request) (*storage.AuthReque
}
}

if len(responseTypes) == 0 {
return nil, "", newredirectedAuthErr(oauth2.InvalidRequest, "No response_type provided")
}

if rt.token && !rt.code && !rt.idToken {
// "token" can't be provided on its own.
// https://openid.net/specs/openid-connect-core-1_0.html#Authentication
Expand Down
102 changes: 102 additions & 0 deletions server/authflow/request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,89 @@ func TestParseAuthorizationRequest(t *testing.T) {
"scope": "openid email profile",
},
},
{
name: "dynamic client rejects unregistered scope",
clients: []storage.Client{{
ID: "dynamic", RedirectURIs: []string{"https://example.com/callback"},
DynamicallyRegistered: true, ResponseTypes: []string{"code"}, AllowedScopes: []string{"openid", "profile"},
}},
supportedResponseTypes: []string{"code"},
queryParams: map[string]string{
"client_id": "dynamic", "redirect_uri": "https://example.com/callback",
"response_type": "code", "scope": "openid email",
},
expectedError: &redirectedAuthErr{Type: oauth2.InvalidScope},
},
{
name: "dynamic client rejects unregistered response type",
clients: []storage.Client{{
ID: "dynamic", RedirectURIs: []string{"https://example.com/callback"},
DynamicallyRegistered: true, ResponseTypes: []string{"code"}, AllowedScopes: []string{"openid"},
}},
supportedResponseTypes: []string{"code", "id_token"},
queryParams: map[string]string{
"client_id": "dynamic", "redirect_uri": "https://example.com/callback",
"response_type": "id_token", "scope": "openid", "nonce": "nonce",
},
expectedError: &redirectedAuthErr{Type: oauth2.UnauthorizedClient},
},
{
name: "dynamic client rejects unregistered authorization code grant",
clients: []storage.Client{{
ID: "dynamic", RedirectURIs: []string{"https://example.com/callback"},
DynamicallyRegistered: true, GrantTypes: []string{oauth2.GrantTypeImplicit},
ResponseTypes: []string{"code"}, AllowedScopes: []string{"openid"},
}},
supportedResponseTypes: []string{"code"},
queryParams: map[string]string{
"client_id": "dynamic", "redirect_uri": "https://example.com/callback",
"response_type": "code", "scope": "openid",
},
expectedError: &redirectedAuthErr{Type: oauth2.UnauthorizedClient},
},
{
name: "dynamic public authorization code client requires PKCE",
clients: []storage.Client{{
ID: "dynamic", RedirectURIs: []string{"com.example.app:/callback"}, Public: true,
DynamicallyRegistered: true, GrantTypes: []string{oauth2.GrantTypeAuthorizationCode},
ResponseTypes: []string{"code"}, AllowedScopes: []string{"openid"},
}},
supportedResponseTypes: []string{"code"},
queryParams: map[string]string{
"client_id": "dynamic", "redirect_uri": "com.example.app:/callback",
"response_type": "code", "scope": "openid",
},
expectedError: &redirectedAuthErr{Type: oauth2.InvalidRequest},
},
{
name: "dynamic public authorization code client rejects plain PKCE",
clients: []storage.Client{{
ID: "dynamic", RedirectURIs: []string{"com.example.app:/callback"}, Public: true,
DynamicallyRegistered: true, GrantTypes: []string{oauth2.GrantTypeAuthorizationCode},
ResponseTypes: []string{"code"}, AllowedScopes: []string{"openid"},
}},
supportedResponseTypes: []string{"code"},
queryParams: map[string]string{
"client_id": "dynamic", "redirect_uri": "com.example.app:/callback",
"response_type": "code", "scope": "openid", "code_challenge": "challenge",
"code_challenge_method": oauth2.PKCEMethodPlain,
},
expectedError: &redirectedAuthErr{Type: oauth2.InvalidRequest},
},
{
name: "dynamic public authorization code client accepts S256 PKCE",
clients: []storage.Client{{
ID: "dynamic", RedirectURIs: []string{"com.example.app:/callback"}, Public: true,
DynamicallyRegistered: true, GrantTypes: []string{oauth2.GrantTypeAuthorizationCode},
ResponseTypes: []string{"code"}, AllowedScopes: []string{"openid"},
}},
supportedResponseTypes: []string{"code"},
queryParams: map[string]string{
"client_id": "dynamic", "redirect_uri": "com.example.app:/callback",
"response_type": "code", "scope": "openid", "code_challenge": "challenge",
"code_challenge_method": oauth2.PKCEMethodS256,
},
},
{
name: "POST request",
clients: []storage.Client{
Expand Down Expand Up @@ -294,6 +377,22 @@ func TestParseAuthorizationRequest(t *testing.T) {
},
expectedError: &redirectedAuthErr{Type: oauth2.InvalidRequest},
},
{
name: "dynamic client with no response type",
clients: []storage.Client{{
ID: "dynamic", RedirectURIs: []string{"https://example.com/callback"},
DynamicallyRegistered: true, ResponseTypes: []string{"code"},
AllowedScopes: []string{"openid"},
}},
supportedResponseTypes: []string{"code"},
queryParams: map[string]string{
"client_id": "dynamic", "redirect_uri": "https://example.com/callback",
"scope": "openid",
},
expectedError: &redirectedAuthErr{
Type: oauth2.InvalidRequest, Description: "No response_type provided",
},
},
{
name: "PKCE enforced, no code_challenge provided",
clients: []storage.Client{
Expand Down Expand Up @@ -421,6 +520,9 @@ func TestParseAuthorizationRequest(t *testing.T) {
if e.Type != expectedErr.Type {
t.Errorf("%s: expected error type %v, got %v", tc.name, expectedErr.Type, e.Type)
}
if expectedErr.Description != "" && e.Description != expectedErr.Description {
t.Errorf("%s: expected error description %q, got %q", tc.name, expectedErr.Description, e.Description)
}
if e.RedirectURI != tc.queryParams["redirect_uri"] {
t.Errorf("%s: expected error to be returned in redirect to %v", tc.name, tc.queryParams["redirect_uri"])
}
Expand Down
15 changes: 15 additions & 0 deletions server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ type Config struct {
// PKCE configuration
PKCE authflow.PKCEConfig

// DynamicClientRegistration enables the RFC 7591 client registration
// endpoint. Nil keeps the endpoint disabled and out of discovery.
DynamicClientRegistration *DynamicClientRegistrationConfig

GCFrequency time.Duration // Defaults to 5 minutes

// If specified, the server will use this function for determining time.
Expand Down Expand Up @@ -106,6 +110,14 @@ type Config struct {
DefaultMFAChain []string
}

// DynamicClientRegistrationConfig configures protected RFC 7591 client
// registration. Dex deliberately requires an initial access token when the
// endpoint is enabled; an accidentally open endpoint would allow arbitrary
// callers to create persistent clients.
type DynamicClientRegistrationConfig struct {
InitialAccessToken string
}

// WebConfig holds the server's frontend templates and asset configuration.
type WebConfig struct {
// A file path to static web assets.
Expand Down Expand Up @@ -173,6 +185,9 @@ func normalizeConfig(c *Config) (resolvedConfig, error) {
if c.Storage == nil {
return resolvedConfig{}, errors.New("server: storage cannot be nil")
}
if c.DynamicClientRegistration != nil && c.DynamicClientRegistration.InitialAccessToken == "" {
return resolvedConfig{}, errors.New("server: dynamic client registration requires an initial access token")
}

issuerURL, err := url.Parse(c.Issuer)
if err != nil {
Expand Down
7 changes: 7 additions & 0 deletions server/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,13 @@ func TestNormalizeConfigRejects(t *testing.T) {
},
errMsg: `unsupported PKCE challenge method "S512"`,
},
{
name: "dynamic registration without an initial access token",
mutate: func(c *Config) {
c.DynamicClientRegistration = &DynamicClientRegistrationConfig{}
},
errMsg: "dynamic client registration requires an initial access token",
},
}

for _, tc := range tests {
Expand Down
Loading
Loading