feat!: support PKCE in the oauth2 client's auth/exchange flow (#21215)

**Breaking Change:** Existing oauth apps might now use PKCE. If an
unknown IdP type was being used, and it does not support PKCE, it will
break.

To fix, set the PKCE methods on the external auth to `none`
```
export CODER_EXTERNAL_AUTH_1_PKCE_METHODS=none
```
This commit is contained in:
Steven Masley
2025-12-15 17:41:47 +00:00
committed by GitHub
parent 3194bcfc9e
commit 8fefd91e4a
26 changed files with 473 additions and 169 deletions
+67 -43
View File
@@ -25,6 +25,7 @@ import (
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/promoauth"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/retry"
)
@@ -102,7 +103,8 @@ type Config struct {
// injected into Coder AI Bridge upstream requests.
// In the case of conflicts, items evaluated by this list override [MCPToolAllowRegex].
// This field can be nil if unspecified in the config.
MCPToolDenyRegex *regexp.Regexp
MCPToolDenyRegex *regexp.Regexp
CodeChallengeMethodsSupported []promoauth.Oauth2PKCEChallengeMethod
}
// GenerateTokenExtra generates the extra token data to store in the database.
@@ -723,24 +725,25 @@ func ConvertConfig(instrument *promoauth.Factory, entries []codersdk.ExternalAut
}
cfg := &Config{
InstrumentedOAuth2Config: instrumented,
ID: entry.ID,
ClientID: entry.ClientID,
ClientSecret: entry.ClientSecret,
Regex: regex,
Type: entry.Type,
NoRefresh: entry.NoRefresh,
ValidateURL: entry.ValidateURL,
RevokeURL: entry.RevokeURL,
RevokeTimeout: tokenRevocationTimeout,
AppInstallationsURL: entry.AppInstallationsURL,
AppInstallURL: entry.AppInstallURL,
DisplayName: entry.DisplayName,
DisplayIcon: entry.DisplayIcon,
ExtraTokenKeys: entry.ExtraTokenKeys,
MCPURL: entry.MCPURL,
MCPToolAllowRegex: mcpToolAllow,
MCPToolDenyRegex: mcpToolDeny,
InstrumentedOAuth2Config: instrumented,
ID: entry.ID,
ClientID: entry.ClientID,
ClientSecret: entry.ClientSecret,
Regex: regex,
Type: entry.Type,
NoRefresh: entry.NoRefresh,
ValidateURL: entry.ValidateURL,
RevokeURL: entry.RevokeURL,
RevokeTimeout: tokenRevocationTimeout,
AppInstallationsURL: entry.AppInstallationsURL,
AppInstallURL: entry.AppInstallURL,
DisplayName: entry.DisplayName,
DisplayIcon: entry.DisplayIcon,
ExtraTokenKeys: entry.ExtraTokenKeys,
MCPURL: entry.MCPURL,
MCPToolAllowRegex: mcpToolAllow,
MCPToolDenyRegex: mcpToolDeny,
CodeChallengeMethodsSupported: slice.StringEnums[promoauth.Oauth2PKCEChallengeMethod](entry.CodeChallengeMethodsSupported),
}
if entry.DeviceFlow {
@@ -800,8 +803,7 @@ func applyDefaultsToConfig(config *codersdk.ExternalAuthConfig) {
copyDefaultSettings(config, azureDevopsEntraDefaults(config))
return
default:
// No defaults for this type. We still want to run this apply with
// an empty set of defaults.
// Global defaults are specified at the end of the `copyDefaultSettings` function.
copyDefaultSettings(config, codersdk.ExternalAuthConfig{})
return
}
@@ -844,6 +846,9 @@ func copyDefaultSettings(config *codersdk.ExternalAuthConfig, defaults codersdk.
if len(config.ExtraTokenKeys) == 0 {
config.ExtraTokenKeys = defaults.ExtraTokenKeys
}
if config.CodeChallengeMethodsSupported == nil {
config.CodeChallengeMethodsSupported = defaults.CodeChallengeMethodsSupported
}
// Apply defaults if it's still empty...
if config.ID == "" {
@@ -856,6 +861,9 @@ func copyDefaultSettings(config *codersdk.ExternalAuthConfig, defaults codersdk.
// This is a key emoji.
config.DisplayIcon = "/emojis/1f511.png"
}
if config.CodeChallengeMethodsSupported == nil {
config.CodeChallengeMethodsSupported = []string{string(promoauth.PKCEChallengeMethodSha256)}
}
}
// gitHubDefaults returns default config values for GitHub.
@@ -869,9 +877,10 @@ func gitHubDefaults(config *codersdk.ExternalAuthConfig) codersdk.ExternalAuthCo
DisplayIcon: "/icon/github.svg",
Regex: `^(https?://)?github\.com(/.*)?$`,
// "workflow" is required for managing GitHub Actions in a repository.
Scopes: []string{"repo", "workflow"},
DeviceCodeURL: "https://github.com/login/device/code",
AppInstallationsURL: "https://api.github.com/user/installations",
Scopes: []string{"repo", "workflow"},
DeviceCodeURL: "https://github.com/login/device/code",
AppInstallationsURL: "https://api.github.com/user/installations",
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodSha256)},
}
if config.RevokeURL == "" && config.ClientID != "" {
@@ -886,6 +895,8 @@ func bitbucketServerDefaults(config *codersdk.ExternalAuthConfig) codersdk.Exter
DisplayName: "Bitbucket Server",
Scopes: []string{"PUBLIC_REPOS", "REPO_READ", "REPO_WRITE"},
DisplayIcon: "/icon/bitbucket.svg",
// TODO: Investigate if 'S256' is accepted and PKCE is supported
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodNone)},
}
// Bitbucket servers will have some base url, e.g. https://bitbucket.coder.com.
// We will grab this from the Auth URL. This choice is a bit arbitrary,
@@ -923,14 +934,15 @@ func bitbucketServerDefaults(config *codersdk.ExternalAuthConfig) codersdk.Exter
// Any user specific fields will override this if provided.
func gitlabDefaults(config *codersdk.ExternalAuthConfig) codersdk.ExternalAuthConfig {
cloud := codersdk.ExternalAuthConfig{
AuthURL: "https://gitlab.com/oauth/authorize",
TokenURL: "https://gitlab.com/oauth/token",
ValidateURL: "https://gitlab.com/oauth/token/info",
RevokeURL: "https://gitlab.com/oauth/revoke",
DisplayName: "GitLab",
DisplayIcon: "/icon/gitlab.svg",
Regex: `^(https?://)?gitlab\.com(/.*)?$`,
Scopes: []string{"write_repository"},
AuthURL: "https://gitlab.com/oauth/authorize",
TokenURL: "https://gitlab.com/oauth/token",
ValidateURL: "https://gitlab.com/oauth/token/info",
RevokeURL: "https://gitlab.com/oauth/revoke",
DisplayName: "GitLab",
DisplayIcon: "/icon/gitlab.svg",
Regex: `^(https?://)?gitlab\.com(/.*)?$`,
Scopes: []string{"write_repository"},
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodSha256)},
}
if config.AuthURL == "" || config.AuthURL == cloud.AuthURL {
@@ -946,14 +958,15 @@ func gitlabDefaults(config *codersdk.ExternalAuthConfig) codersdk.ExternalAuthCo
// At this point, assume it is self-hosted and use the AuthURL
return codersdk.ExternalAuthConfig{
DisplayName: cloud.DisplayName,
Scopes: cloud.Scopes,
DisplayIcon: cloud.DisplayIcon,
AuthURL: au.ResolveReference(&url.URL{Path: "/oauth/authorize"}).String(),
TokenURL: au.ResolveReference(&url.URL{Path: "/oauth/token"}).String(),
ValidateURL: au.ResolveReference(&url.URL{Path: "/oauth/token/info"}).String(),
RevokeURL: au.ResolveReference(&url.URL{Path: "/oauth/revoke"}).String(),
Regex: fmt.Sprintf(`^(https?://)?%s(/.*)?$`, strings.ReplaceAll(au.Host, ".", `\.`)),
DisplayName: cloud.DisplayName,
Scopes: cloud.Scopes,
DisplayIcon: cloud.DisplayIcon,
AuthURL: au.ResolveReference(&url.URL{Path: "/oauth/authorize"}).String(),
TokenURL: au.ResolveReference(&url.URL{Path: "/oauth/token"}).String(),
ValidateURL: au.ResolveReference(&url.URL{Path: "/oauth/token/info"}).String(),
RevokeURL: au.ResolveReference(&url.URL{Path: "/oauth/revoke"}).String(),
Regex: fmt.Sprintf(`^(https?://)?%s(/.*)?$`, strings.ReplaceAll(au.Host, ".", `\.`)),
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodSha256)},
}
}
@@ -962,6 +975,8 @@ func jfrogArtifactoryDefaults(config *codersdk.ExternalAuthConfig) codersdk.Exte
DisplayName: "JFrog Artifactory",
Scopes: []string{"applied-permissions/user"},
DisplayIcon: "/icon/jfrog.svg",
// TODO: Investigate if 'S256' is accepted and PKCE is supported
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodNone)},
}
// Artifactory servers will have some base url, e.g. https://jfrog.coder.com.
// We will grab this from the Auth URL. This choice is not arbitrary. It is a
@@ -997,9 +1012,10 @@ func jfrogArtifactoryDefaults(config *codersdk.ExternalAuthConfig) codersdk.Exte
func giteaDefaults(config *codersdk.ExternalAuthConfig) codersdk.ExternalAuthConfig {
defaults := codersdk.ExternalAuthConfig{
DisplayName: "Gitea",
Scopes: []string{"read:repository", " write:repository", "read:user"},
DisplayIcon: "/icon/gitea.svg",
DisplayName: "Gitea",
Scopes: []string{"read:repository", " write:repository", "read:user"},
DisplayIcon: "/icon/gitea.svg",
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodSha256)},
}
// Gitea's servers will have some base url, e.g: https://gitea.coder.com.
// If an auth url is not set, we will assume they are using the default
@@ -1031,6 +1047,8 @@ func azureDevopsEntraDefaults(config *codersdk.ExternalAuthConfig) codersdk.Exte
DisplayName: "Azure DevOps (Entra)",
DisplayIcon: "/icon/azure-devops.svg",
Regex: `^(https?://)?dev\.azure\.com(/.*)?$`,
// TODO: Investigate if 'S256' is accepted and PKCE is supported
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodNone)},
}
// The tenant ID is required for urls and is in the auth url.
if config.AuthURL == "" {
@@ -1069,6 +1087,8 @@ var staticDefaults = map[codersdk.EnhancedExternalAuthProvider]codersdk.External
DisplayIcon: "/icon/azure-devops.svg",
Regex: `^(https?://)?dev\.azure\.com(/.*)?$`,
Scopes: []string{"vso.code_write"},
// TODO: Investigate if 'S256' is accepted and PKCE is supported
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodNone)},
},
codersdk.EnhancedExternalAuthProviderBitBucketCloud: {
AuthURL: "https://bitbucket.org/site/oauth2/authorize",
@@ -1078,6 +1098,8 @@ var staticDefaults = map[codersdk.EnhancedExternalAuthProvider]codersdk.External
DisplayIcon: "/icon/bitbucket.svg",
Regex: `^(https?://)?bitbucket\.org(/.*)?$`,
Scopes: []string{"account", "repository:write"},
// TODO: Investigate if 'S256' is accepted and PKCE is supported
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodNone)},
},
codersdk.EnhancedExternalAuthProviderSlack: {
AuthURL: "https://slack.com/oauth/v2/authorize",
@@ -1087,6 +1109,8 @@ var staticDefaults = map[codersdk.EnhancedExternalAuthProvider]codersdk.External
DisplayIcon: "/icon/slack.svg",
// See: https://api.slack.com/authentication/oauth-v2#exchanging
ExtraTokenKeys: []string{"authed_user"},
// TODO: Investigate if 'S256' is accepted and PKCE is supported
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodNone)},
},
}
@@ -5,6 +5,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/promoauth"
"github.com/coder/coder/v2/codersdk"
)
@@ -13,17 +14,20 @@ func TestGitlabDefaults(t *testing.T) {
// The default cloud setup. Copying this here as hard coded
// values.
cloud := codersdk.ExternalAuthConfig{
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
ID: string(codersdk.EnhancedExternalAuthProviderGitLab),
AuthURL: "https://gitlab.com/oauth/authorize",
TokenURL: "https://gitlab.com/oauth/token",
ValidateURL: "https://gitlab.com/oauth/token/info",
RevokeURL: "https://gitlab.com/oauth/revoke",
DisplayName: "GitLab",
DisplayIcon: "/icon/gitlab.svg",
Regex: `^(https?://)?gitlab\.com(/.*)?$`,
Scopes: []string{"write_repository"},
cloud := func() codersdk.ExternalAuthConfig {
return codersdk.ExternalAuthConfig{
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
ID: string(codersdk.EnhancedExternalAuthProviderGitLab),
AuthURL: "https://gitlab.com/oauth/authorize",
TokenURL: "https://gitlab.com/oauth/token",
ValidateURL: "https://gitlab.com/oauth/token/info",
RevokeURL: "https://gitlab.com/oauth/revoke",
DisplayName: "GitLab",
DisplayIcon: "/icon/gitlab.svg",
Regex: `^(https?://)?gitlab\.com(/.*)?$`,
Scopes: []string{"write_repository"},
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodSha256)},
}
}
tests := []struct {
@@ -38,7 +42,7 @@ func TestGitlabDefaults(t *testing.T) {
input: codersdk.ExternalAuthConfig{
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
},
expected: cloud,
expected: cloud(),
},
{
// If someone was to manually configure the gitlab cli.
@@ -47,7 +51,7 @@ func TestGitlabDefaults(t *testing.T) {
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
AuthURL: "https://gitlab.com/oauth/authorize",
},
expected: cloud,
expected: cloud(),
},
{
// Changing some of the defaults of the cloud option
@@ -60,7 +64,7 @@ func TestGitlabDefaults(t *testing.T) {
DisplayName: "custom",
Regex: ".*",
},
expected: cloud,
expected: cloud(),
mutateExpected: func(config *codersdk.ExternalAuthConfig) {
config.AuthURL = "https://gitlab.com/oauth/authorize?foo=bar"
config.DisplayName = "custom"
@@ -75,7 +79,7 @@ func TestGitlabDefaults(t *testing.T) {
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
AuthURL: "https://gitlab.company.org/oauth/authorize?foo=bar",
},
expected: cloud,
expected: cloud(),
mutateExpected: func(config *codersdk.ExternalAuthConfig) {
config.AuthURL = "https://gitlab.company.org/oauth/authorize?foo=bar"
config.ValidateURL = "https://gitlab.company.org/oauth/token/info"
@@ -88,20 +92,22 @@ func TestGitlabDefaults(t *testing.T) {
// Strange values
name: "RandomValues",
input: codersdk.ExternalAuthConfig{
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
AuthURL: "https://auth.com/auth",
ValidateURL: "https://validate.com/validate",
TokenURL: "https://token.com/token",
RevokeURL: "https://token.com/revoke",
Regex: "random",
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
AuthURL: "https://auth.com/auth",
ValidateURL: "https://validate.com/validate",
TokenURL: "https://token.com/token",
RevokeURL: "https://token.com/revoke",
Regex: "random",
CodeChallengeMethodsSupported: []string{"random"},
},
expected: cloud,
expected: cloud(),
mutateExpected: func(config *codersdk.ExternalAuthConfig) {
config.AuthURL = "https://auth.com/auth"
config.ValidateURL = "https://validate.com/validate"
config.TokenURL = "https://token.com/token"
config.RevokeURL = "https://token.com/revoke"
config.Regex = `random`
config.CodeChallengeMethodsSupported = []string{"random"}
},
},
}
@@ -133,11 +139,12 @@ func Test_bitbucketServerConfigDefaults(t *testing.T) {
Type: bbType,
},
expected: codersdk.ExternalAuthConfig{
Type: bbType,
ID: bbType,
DisplayName: "Bitbucket Server",
Scopes: []string{"PUBLIC_REPOS", "REPO_READ", "REPO_WRITE"},
DisplayIcon: "/icon/bitbucket.svg",
Type: bbType,
ID: bbType,
DisplayName: "Bitbucket Server",
Scopes: []string{"PUBLIC_REPOS", "REPO_READ", "REPO_WRITE"},
DisplayIcon: "/icon/bitbucket.svg",
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodNone)},
},
},
{
@@ -148,15 +155,16 @@ func Test_bitbucketServerConfigDefaults(t *testing.T) {
AuthURL: "https://bitbucket.example.com/login/oauth/authorize",
},
expected: codersdk.ExternalAuthConfig{
Type: bbType,
ID: bbType,
AuthURL: "https://bitbucket.example.com/login/oauth/authorize",
TokenURL: "https://bitbucket.example.com/rest/oauth2/latest/token",
ValidateURL: "https://bitbucket.example.com/rest/api/latest/inbox/pull-requests/count",
Scopes: []string{"PUBLIC_REPOS", "REPO_READ", "REPO_WRITE"},
Regex: `^(https?://)?bitbucket\.example\.com(/.*)?$`,
DisplayName: "Bitbucket Server",
DisplayIcon: "/icon/bitbucket.svg",
Type: bbType,
ID: bbType,
AuthURL: "https://bitbucket.example.com/login/oauth/authorize",
TokenURL: "https://bitbucket.example.com/rest/oauth2/latest/token",
ValidateURL: "https://bitbucket.example.com/rest/api/latest/inbox/pull-requests/count",
Scopes: []string{"PUBLIC_REPOS", "REPO_READ", "REPO_WRITE"},
Regex: `^(https?://)?bitbucket\.example\.com(/.*)?$`,
DisplayName: "Bitbucket Server",
DisplayIcon: "/icon/bitbucket.svg",
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodNone)},
},
},
{
@@ -167,15 +175,16 @@ func Test_bitbucketServerConfigDefaults(t *testing.T) {
Type: "bitbucket",
},
expected: codersdk.ExternalAuthConfig{
Type: string(codersdk.EnhancedExternalAuthProviderBitBucketCloud),
ID: "bitbucket", // Legacy ID remains unchanged
AuthURL: "https://bitbucket.org/site/oauth2/authorize",
TokenURL: "https://bitbucket.org/site/oauth2/access_token",
ValidateURL: "https://api.bitbucket.org/2.0/user",
DisplayName: "BitBucket",
DisplayIcon: "/icon/bitbucket.svg",
Regex: `^(https?://)?bitbucket\.org(/.*)?$`,
Scopes: []string{"account", "repository:write"},
Type: string(codersdk.EnhancedExternalAuthProviderBitBucketCloud),
ID: "bitbucket", // Legacy ID remains unchanged
AuthURL: "https://bitbucket.org/site/oauth2/authorize",
TokenURL: "https://bitbucket.org/site/oauth2/access_token",
ValidateURL: "https://api.bitbucket.org/2.0/user",
DisplayName: "BitBucket",
DisplayIcon: "/icon/bitbucket.svg",
Regex: `^(https?://)?bitbucket\.org(/.*)?$`,
Scopes: []string{"account", "repository:write"},
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodNone)},
},
},
}
@@ -187,3 +196,45 @@ func Test_bitbucketServerConfigDefaults(t *testing.T) {
})
}
}
func TestUntyped(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input codersdk.ExternalAuthConfig
expected codersdk.ExternalAuthConfig
}{
{
// Unknown Type uses S256 by default.
name: "RandomValues",
input: codersdk.ExternalAuthConfig{
Type: "unknown",
AuthURL: "https://auth.com/auth",
ValidateURL: "https://validate.com/validate",
TokenURL: "https://token.com/token",
RevokeURL: "https://token.com/revoke",
Regex: "random",
},
expected: codersdk.ExternalAuthConfig{
ID: "unknown",
Type: "unknown",
DisplayName: "unknown",
DisplayIcon: "/emojis/1f511.png",
AuthURL: "https://auth.com/auth",
ValidateURL: "https://validate.com/validate",
TokenURL: "https://token.com/token",
RevokeURL: "https://token.com/revoke",
Regex: `random`,
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodSha256)},
},
},
}
for _, c := range tests {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
applyDefaultsToConfig(&c.input)
require.Equal(t, c.input, c.expected)
})
}
}
+9 -7
View File
@@ -729,6 +729,7 @@ func TestConstantQueryParams(t *testing.T) {
authURL.RawQuery = url.Values{constantQueryParamKey: []string{constantQueryParamValue}}.Encode()
cfg.OAuth2Config.(*oauth2.Config).Endpoint.AuthURL = authURL.String()
require.Contains(t, cfg.OAuth2Config.(*oauth2.Config).Endpoint.AuthURL, constantQueryParam)
cfg.PKCEMethods = []promoauth.Oauth2PKCEChallengeMethod{promoauth.PKCEChallengeMethodSha256}
},
},
})
@@ -792,7 +793,7 @@ func setupOauth2Test(t *testing.T, settings testConfig) (*oidctest.FakeIDP, *ext
const providerID = "test-idp"
fake := oidctest.NewFakeIDP(t,
append([]oidctest.FakeIDPOpt{}, settings.FakeIDPOpts...)...,
append([]oidctest.FakeIDPOpt{oidctest.WithPKCE()}, settings.FakeIDPOpts...)...,
)
f := promoauth.NewFactory(prometheus.NewRegistry())
@@ -800,12 +801,13 @@ func setupOauth2Test(t *testing.T, settings testConfig) (*oidctest.FakeIDP, *ext
config := &externalauth.Config{
InstrumentedOAuth2Config: f.New("test-oauth2",
fake.OIDCConfig(t, nil, settings.CoderOIDCConfigOpts...)),
ID: providerID,
ClientID: cid,
ClientSecret: cs,
ValidateURL: fake.WellknownConfig().UserInfoURL,
RevokeURL: fake.WellknownConfig().RevokeURL,
RevokeTimeout: 1 * time.Second,
ID: providerID,
ClientID: cid,
ClientSecret: cs,
ValidateURL: fake.WellknownConfig().UserInfoURL,
RevokeURL: fake.WellknownConfig().RevokeURL,
RevokeTimeout: 1 * time.Second,
CodeChallengeMethodsSupported: []promoauth.Oauth2PKCEChallengeMethod{promoauth.PKCEChallengeMethodSha256},
}
settings.ExternalAuthOpt(config)