Browse Source

feat: add PKCE (Proof Key for Code Exchange) configuration to OAuth2 settings (#4638)

Signed-off-by: maksim.nabokikh <max.nabokih@gmail.com>
pull/4641/head
Maksim Nabokikh 4 days ago committed by GitHub
parent
commit
5bbfbbe168
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 11
      cmd/dex/config.go
  2. 14
      cmd/dex/serve.go
  3. 7
      config.yaml.dist
  4. 24
      examples/config-dev.yaml
  5. 2
      server/handlers.go
  6. 9
      server/oauth2.go
  7. 90
      server/oauth2_test.go
  8. 27
      server/server.go

11
cmd/dex/config.go

@ -99,6 +99,7 @@ func (c Config) Validate() error {
checkErrors = append(checkErrors, check.errMsg) checkErrors = append(checkErrors, check.errMsg)
} }
} }
if len(checkErrors) != 0 { if len(checkErrors) != 0 {
return fmt.Errorf("invalid Config:\n\t-\t%s", strings.Join(checkErrors, "\n\t-\t")) 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"` AlwaysShowLoginScreen bool `json:"alwaysShowLoginScreen"`
// This is the connector that can be used for password grant // This is the connector that can be used for password grant
PasswordConnector string `json:"passwordConnector"` 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. // Web is the config format for the HTTP server.

14
cmd/dex/serve.go

@ -362,11 +362,15 @@ func runServe(options serveOptions) error {
} }
serverConfig := server.Config{ serverConfig := server.Config{
AllowedGrantTypes: c.OAuth2.GrantTypes, AllowedGrantTypes: c.OAuth2.GrantTypes,
SupportedResponseTypes: c.OAuth2.ResponseTypes, SupportedResponseTypes: c.OAuth2.ResponseTypes,
SkipApprovalScreen: c.OAuth2.SkipApprovalScreen, SkipApprovalScreen: c.OAuth2.SkipApprovalScreen,
AlwaysShowLoginScreen: c.OAuth2.AlwaysShowLoginScreen, AlwaysShowLoginScreen: c.OAuth2.AlwaysShowLoginScreen,
PasswordConnector: c.OAuth2.PasswordConnector, PasswordConnector: c.OAuth2.PasswordConnector,
PKCE: server.PKCEConfig{
Enforce: c.OAuth2.PKCE.Enforce,
CodeChallengeMethodsSupported: c.OAuth2.PKCE.CodeChallengeMethodsSupported,
},
Headers: c.Web.Headers.ToHTTPHeader(), Headers: c.Web.Headers.ToHTTPHeader(),
AllowedOrigins: c.Web.AllowedOrigins, AllowedOrigins: c.Web.AllowedOrigins,
AllowedHeaders: c.Web.AllowedHeaders, AllowedHeaders: c.Web.AllowedHeaders,

7
config.yaml.dist

@ -109,6 +109,13 @@ web:
# #
# # Uncomment to use a specific connector for password grants # # Uncomment to use a specific connector for password grants
# passwordConnector: local # 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. # Static clients registered in Dex by default.
# #

24
examples/config-dev.yaml

@ -102,7 +102,7 @@ telemetry:
# Default values shown below # Default values shown below
# oauth2: # oauth2:
# grantTypes determines the allowed set of authorization flows. # # grantTypes determines the allowed set of authorization flows.
# grantTypes: # grantTypes:
# - "authorization_code" # - "authorization_code"
# - "client_credentials" # - "client_credentials"
@ -111,18 +111,24 @@ telemetry:
# - "password" # - "password"
# - "urn:ietf:params:oauth:grant-type:device_code" # - "urn:ietf:params:oauth:grant-type:device_code"
# - "urn:ietf:params:oauth:grant-type:token-exchange" # - "urn:ietf:params:oauth:grant-type:token-exchange"
# responseTypes determines the allowed response contents of a successful authorization flow. # # responseTypes determines the allowed response contents of a successful authorization flow.
# use ["code", "token", "id_token"] to enable implicit flow for web-only clients. # # use ["code", "token", "id_token"] to enable implicit flow for web-only clients.
# responseTypes: [ "code" ] # also allowed are "token" and "id_token" # responseTypes: [ "code" ] # also allowed are "token" and "id_token"
# By default, Dex will ask for approval to share data with application # # 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) # # (approval for sharing data from connected IdP to Dex is separate process on IdP)
# skipApprovalScreen: false # skipApprovalScreen: false
# If only one authentication method is enabled, the default behavior is to # # If only one authentication method is enabled, the default behavior is to
# go directly to it. For connected IdPs, this redirects the browser away # # go directly to it. For connected IdPs, this redirects the browser away
# from application to upstream provider such as the Google login page # # from application to upstream provider such as the Google login page
# alwaysShowLoginScreen: false # 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 # 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. # Instead of reading from an external storage, use this list of clients.
# #

2
server/handlers.go

@ -116,7 +116,7 @@ func (s *Server) constructDiscovery(ctx context.Context) discovery {
Introspect: s.absURL("/token/introspect"), Introspect: s.absURL("/token/introspect"),
Subjects: []string{"public"}, Subjects: []string{"public"},
IDTokenAlgs: []string{string(jose.RS256)}, IDTokenAlgs: []string{string(jose.RS256)},
CodeChallengeAlgs: []string{codeChallengeMethodS256, codeChallengeMethodPlain}, CodeChallengeAlgs: s.pkce.CodeChallengeMethodsSupported,
Scopes: []string{"openid", "email", "groups", "profile", "offline_access"}, Scopes: []string{"openid", "email", "groups", "profile", "offline_access"},
AuthMethods: []string{"client_secret_basic", "client_secret_post"}, AuthMethods: []string{"client_secret_basic", "client_secret_post"},
Claims: []string{ Claims: []string{

9
server/oauth2.go

@ -14,6 +14,7 @@ import (
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -486,11 +487,17 @@ func (s *Server) parseAuthorizationRequest(r *http.Request) (*storage.AuthReques
return nil, newRedirectedErr(errRequestNotSupported, "Server does not support request parameter.") 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) description := fmt.Sprintf("Unsupported PKCE challenge method (%q).", codeChallengeMethod)
return nil, newRedirectedErr(errInvalidRequest, description) 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 ( var (
unrecognized []string unrecognized []string
invalidScopes []string invalidScopes []string

90
server/oauth2_test.go

@ -53,6 +53,7 @@ func TestParseAuthorizationRequest(t *testing.T) {
name string name string
clients []storage.Client clients []storage.Client
supportedResponseTypes []string supportedResponseTypes []string
pkce PKCEConfig
usePOST bool usePOST bool
@ -319,6 +320,92 @@ func TestParseAuthorizationRequest(t *testing.T) {
}, },
expectedError: &redirectedAuthErr{Type: errInvalidRequest}, 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 { for _, tc := range tests {
@ -326,6 +413,9 @@ func TestParseAuthorizationRequest(t *testing.T) {
httpServer, server := newTestServerMultipleConnectors(t, func(c *Config) { httpServer, server := newTestServerMultipleConnectors(t, func(c *Config) {
c.SupportedResponseTypes = tc.supportedResponseTypes c.SupportedResponseTypes = tc.supportedResponseTypes
c.Storage = storage.WithStaticClients(c.Storage, tc.clients) c.Storage = storage.WithStaticClients(c.Storage, tc.clients)
if len(tc.pkce.CodeChallengeMethodsSupported) > 0 || tc.pkce.Enforce {
c.PKCE = tc.pkce
}
}) })
defer httpServer.Close() defer httpServer.Close()

27
server/server.go

@ -114,6 +114,9 @@ type Config struct {
// If set, the server will use this connector to handle password grants // If set, the server will use this connector to handle password grants
PasswordConnector string PasswordConnector string
// PKCE configuration
PKCE PKCEConfig
GCFrequency time.Duration // Defaults to 5 minutes GCFrequency time.Duration // Defaults to 5 minutes
// If specified, the server will use this function for determining time. // If specified, the server will use this function for determining time.
@ -166,6 +169,14 @@ type WebConfig struct {
Extra map[string]string 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 { func value(val, defaultValue time.Duration) time.Duration {
if val == 0 { if val == 0 {
return defaultValue return defaultValue
@ -201,6 +212,8 @@ type Server struct {
supportedGrantTypes []string supportedGrantTypes []string
pkce PKCEConfig
now func() time.Time now func() time.Time
idTokensValidFor time.Duration idTokensValidFor time.Duration
@ -236,6 +249,19 @@ func newServer(ctx context.Context, c Config) (*Server, error) {
c.AllowedHeaders = []string{"Authorization"} 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{ allSupportedGrants := map[string]bool{
grantTypeAuthorizationCode: true, grantTypeAuthorizationCode: true,
grantTypeRefreshToken: true, grantTypeRefreshToken: true,
@ -310,6 +336,7 @@ func newServer(ctx context.Context, c Config) (*Server, error) {
storage: newKeyCacher(c.Storage, now), storage: newKeyCacher(c.Storage, now),
supportedResponseTypes: supportedRes, supportedResponseTypes: supportedRes,
supportedGrantTypes: supportedGrants, supportedGrantTypes: supportedGrants,
pkce: c.PKCE,
idTokensValidFor: value(c.IDTokensValidFor, 24*time.Hour), idTokensValidFor: value(c.IDTokensValidFor, 24*time.Hour),
authRequestsValidFor: value(c.AuthRequestsValidFor, 24*time.Hour), authRequestsValidFor: value(c.AuthRequestsValidFor, 24*time.Hour),
deviceRequestsValidFor: value(c.DeviceRequestsValidFor, 5*time.Minute), deviceRequestsValidFor: value(c.DeviceRequestsValidFor, 5*time.Minute),

Loading…
Cancel
Save