diff --git a/cmd/dex/config.go b/cmd/dex/config.go index a05c3efd..913d4dfe 100644 --- a/cmd/dex/config.go +++ b/cmd/dex/config.go @@ -99,6 +99,7 @@ func (c Config) Validate() error { checkErrors = append(checkErrors, check.errMsg) } } + if len(checkErrors) != 0 { return fmt.Errorf("invalid Config:\n\t-\t%s", strings.Join(checkErrors, "\n\t-\t")) } @@ -171,6 +172,16 @@ type OAuth2 struct { AlwaysShowLoginScreen bool `json:"alwaysShowLoginScreen"` // This is the connector that can be used for password grant PasswordConnector string `json:"passwordConnector"` + // PKCE configuration + PKCE PKCE `json:"pkce"` +} + +// PKCE holds the PKCE (Proof Key for Code Exchange) configuration. +type PKCE struct { + // If true, PKCE is required for all authorization code flows. + Enforce bool `json:"enforce"` + // Supported code challenge methods. Defaults to ["S256", "plain"]. + CodeChallengeMethodsSupported []string `json:"codeChallengeMethodsSupported"` } // Web is the config format for the HTTP server. diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index cb675869..cd9d3839 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -362,11 +362,15 @@ func runServe(options serveOptions) error { } serverConfig := server.Config{ - AllowedGrantTypes: c.OAuth2.GrantTypes, - SupportedResponseTypes: c.OAuth2.ResponseTypes, - SkipApprovalScreen: c.OAuth2.SkipApprovalScreen, - AlwaysShowLoginScreen: c.OAuth2.AlwaysShowLoginScreen, - PasswordConnector: c.OAuth2.PasswordConnector, + AllowedGrantTypes: c.OAuth2.GrantTypes, + SupportedResponseTypes: c.OAuth2.ResponseTypes, + SkipApprovalScreen: c.OAuth2.SkipApprovalScreen, + AlwaysShowLoginScreen: c.OAuth2.AlwaysShowLoginScreen, + PasswordConnector: c.OAuth2.PasswordConnector, + PKCE: server.PKCEConfig{ + Enforce: c.OAuth2.PKCE.Enforce, + CodeChallengeMethodsSupported: c.OAuth2.PKCE.CodeChallengeMethodsSupported, + }, Headers: c.Web.Headers.ToHTTPHeader(), AllowedOrigins: c.Web.AllowedOrigins, AllowedHeaders: c.Web.AllowedHeaders, diff --git a/config.yaml.dist b/config.yaml.dist index 3f888e08..5d2c37ea 100644 --- a/config.yaml.dist +++ b/config.yaml.dist @@ -109,6 +109,13 @@ web: # # # Uncomment to use a specific connector for password grants # passwordConnector: local +# +# # PKCE (Proof Key for Code Exchange) configuration +# pkce: +# # If true, PKCE is required for all authorization code flows (OAuth 2.1). +# enforce: false +# # Supported code challenge methods. Defaults to ["S256", "plain"]. +# codeChallengeMethodsSupported: ["S256", "plain"] # Static clients registered in Dex by default. # diff --git a/examples/config-dev.yaml b/examples/config-dev.yaml index eb48e581..0e8bb575 100644 --- a/examples/config-dev.yaml +++ b/examples/config-dev.yaml @@ -102,7 +102,7 @@ telemetry: # Default values shown below # oauth2: - # grantTypes determines the allowed set of authorization flows. +# # grantTypes determines the allowed set of authorization flows. # grantTypes: # - "authorization_code" # - "client_credentials" @@ -111,18 +111,24 @@ telemetry: # - "password" # - "urn:ietf:params:oauth:grant-type:device_code" # - "urn:ietf:params:oauth:grant-type:token-exchange" - # responseTypes determines the allowed response contents of a successful authorization flow. - # use ["code", "token", "id_token"] to enable implicit flow for web-only clients. +# # responseTypes determines the allowed response contents of a successful authorization flow. +# # use ["code", "token", "id_token"] to enable implicit flow for web-only clients. # responseTypes: [ "code" ] # also allowed are "token" and "id_token" - # By default, Dex will ask for approval to share data with application - # (approval for sharing data from connected IdP to Dex is separate process on IdP) +# # By default, Dex will ask for approval to share data with application +# # (approval for sharing data from connected IdP to Dex is separate process on IdP) # skipApprovalScreen: false - # If only one authentication method is enabled, the default behavior is to - # go directly to it. For connected IdPs, this redirects the browser away - # from application to upstream provider such as the Google login page +# # If only one authentication method is enabled, the default behavior is to +# # go directly to it. For connected IdPs, this redirects the browser away +# # from application to upstream provider such as the Google login page # alwaysShowLoginScreen: false - # Uncomment the passwordConnector to use a specific connector for password grants +# # Uncomment the passwordConnector to use a specific connector for password grants # passwordConnector: local +# # PKCE (Proof Key for Code Exchange) configuration +# pkce: +# # If true, PKCE is required for all authorization code flows (OAuth 2.1). +# enforce: false +# # Supported code challenge methods. Defaults to ["S256", "plain"]. +# codeChallengeMethodsSupported: ["S256", "plain"] # Instead of reading from an external storage, use this list of clients. # diff --git a/server/handlers.go b/server/handlers.go index a7a55aa2..e60715d9 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -116,7 +116,7 @@ func (s *Server) constructDiscovery(ctx context.Context) discovery { Introspect: s.absURL("/token/introspect"), Subjects: []string{"public"}, IDTokenAlgs: []string{string(jose.RS256)}, - CodeChallengeAlgs: []string{codeChallengeMethodS256, codeChallengeMethodPlain}, + CodeChallengeAlgs: s.pkce.CodeChallengeMethodsSupported, Scopes: []string{"openid", "email", "groups", "profile", "offline_access"}, AuthMethods: []string{"client_secret_basic", "client_secret_post"}, Claims: []string{ diff --git a/server/oauth2.go b/server/oauth2.go index 2300e934..9f12d1d0 100644 --- a/server/oauth2.go +++ b/server/oauth2.go @@ -14,6 +14,7 @@ import ( "net" "net/http" "net/url" + "slices" "strconv" "strings" "time" @@ -486,11 +487,17 @@ func (s *Server) parseAuthorizationRequest(r *http.Request) (*storage.AuthReques return nil, newRedirectedErr(errRequestNotSupported, "Server does not support request parameter.") } - if codeChallengeMethod != codeChallengeMethodS256 && codeChallengeMethod != codeChallengeMethodPlain { + if codeChallenge != "" && !slices.Contains(s.pkce.CodeChallengeMethodsSupported, codeChallengeMethod) { description := fmt.Sprintf("Unsupported PKCE challenge method (%q).", codeChallengeMethod) return nil, newRedirectedErr(errInvalidRequest, description) } + // Enforce PKCE if configured. + // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-12#section-4.1.1 + if s.pkce.Enforce && codeChallenge == "" { + return nil, newRedirectedErr(errInvalidRequest, "PKCE is required. The code_challenge parameter must be provided.") + } + var ( unrecognized []string invalidScopes []string diff --git a/server/oauth2_test.go b/server/oauth2_test.go index ea930cb3..6e1528ce 100644 --- a/server/oauth2_test.go +++ b/server/oauth2_test.go @@ -53,6 +53,7 @@ func TestParseAuthorizationRequest(t *testing.T) { name string clients []storage.Client supportedResponseTypes []string + pkce PKCEConfig usePOST bool @@ -319,6 +320,92 @@ func TestParseAuthorizationRequest(t *testing.T) { }, expectedError: &redirectedAuthErr{Type: errInvalidRequest}, }, + { + name: "PKCE enforced, no code_challenge provided", + clients: []storage.Client{ + { + ID: "bar", + RedirectURIs: []string{"https://example.com/bar"}, + }, + }, + supportedResponseTypes: []string{"code"}, + pkce: PKCEConfig{ + Enforce: true, + CodeChallengeMethodsSupported: []string{"S256", "plain"}, + }, + queryParams: map[string]string{ + "client_id": "bar", + "redirect_uri": "https://example.com/bar", + "response_type": "code", + "scope": "openid email profile", + }, + expectedError: &redirectedAuthErr{Type: errInvalidRequest}, + }, + { + name: "PKCE enforced, code_challenge provided", + clients: []storage.Client{ + { + ID: "bar", + RedirectURIs: []string{"https://example.com/bar"}, + }, + }, + supportedResponseTypes: []string{"code"}, + pkce: PKCEConfig{ + Enforce: true, + CodeChallengeMethodsSupported: []string{"S256", "plain"}, + }, + queryParams: map[string]string{ + "client_id": "bar", + "redirect_uri": "https://example.com/bar", + "response_type": "code", + "code_challenge": "123", + "code_challenge_method": "S256", + "scope": "openid email profile", + }, + }, + { + name: "PKCE only S256 allowed, plain rejected", + clients: []storage.Client{ + { + ID: "bar", + RedirectURIs: []string{"https://example.com/bar"}, + }, + }, + supportedResponseTypes: []string{"code"}, + pkce: PKCEConfig{ + CodeChallengeMethodsSupported: []string{"S256"}, + }, + queryParams: map[string]string{ + "client_id": "bar", + "redirect_uri": "https://example.com/bar", + "response_type": "code", + "code_challenge": "123", + "code_challenge_method": "plain", + "scope": "openid email profile", + }, + expectedError: &redirectedAuthErr{Type: errInvalidRequest}, + }, + { + name: "PKCE only S256 allowed, S256 accepted", + clients: []storage.Client{ + { + ID: "bar", + RedirectURIs: []string{"https://example.com/bar"}, + }, + }, + supportedResponseTypes: []string{"code"}, + pkce: PKCEConfig{ + CodeChallengeMethodsSupported: []string{"S256"}, + }, + queryParams: map[string]string{ + "client_id": "bar", + "redirect_uri": "https://example.com/bar", + "response_type": "code", + "code_challenge": "123", + "code_challenge_method": "S256", + "scope": "openid email profile", + }, + }, } for _, tc := range tests { @@ -326,6 +413,9 @@ func TestParseAuthorizationRequest(t *testing.T) { httpServer, server := newTestServerMultipleConnectors(t, func(c *Config) { c.SupportedResponseTypes = tc.supportedResponseTypes c.Storage = storage.WithStaticClients(c.Storage, tc.clients) + if len(tc.pkce.CodeChallengeMethodsSupported) > 0 || tc.pkce.Enforce { + c.PKCE = tc.pkce + } }) defer httpServer.Close() diff --git a/server/server.go b/server/server.go index c4f16536..e6945c72 100644 --- a/server/server.go +++ b/server/server.go @@ -114,6 +114,9 @@ type Config struct { // If set, the server will use this connector to handle password grants PasswordConnector string + // PKCE configuration + PKCE PKCEConfig + GCFrequency time.Duration // Defaults to 5 minutes // If specified, the server will use this function for determining time. @@ -166,6 +169,14 @@ type WebConfig struct { Extra map[string]string } +// PKCEConfig holds PKCE (Proof Key for Code Exchange) settings. +type PKCEConfig struct { + // If true, PKCE is required for all authorization code flows. + Enforce bool + // Supported code challenge methods. Defaults to ["S256", "plain"]. + CodeChallengeMethodsSupported []string +} + func value(val, defaultValue time.Duration) time.Duration { if val == 0 { return defaultValue @@ -201,6 +212,8 @@ type Server struct { supportedGrantTypes []string + pkce PKCEConfig + now func() time.Time idTokensValidFor time.Duration @@ -236,6 +249,19 @@ func newServer(ctx context.Context, c Config) (*Server, error) { c.AllowedHeaders = []string{"Authorization"} } + supportedChallengeMethods := map[string]bool{ + codeChallengeMethodS256: true, + codeChallengeMethodPlain: true, + } + if len(c.PKCE.CodeChallengeMethodsSupported) == 0 { + c.PKCE.CodeChallengeMethodsSupported = []string{codeChallengeMethodS256, codeChallengeMethodPlain} + } + for _, m := range c.PKCE.CodeChallengeMethodsSupported { + if !supportedChallengeMethods[m] { + return nil, fmt.Errorf("unsupported PKCE challenge method %q", m) + } + } + allSupportedGrants := map[string]bool{ grantTypeAuthorizationCode: true, grantTypeRefreshToken: true, @@ -310,6 +336,7 @@ func newServer(ctx context.Context, c Config) (*Server, error) { 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),