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 11:41:47 -06:00
committed by GitHub
parent 3194bcfc9e
commit 8fefd91e4a
26 changed files with 473 additions and 169 deletions
+11
View File
@@ -186,6 +186,14 @@ func createOIDCConfig(ctx context.Context, logger slog.Logger, vals *codersdk.De
secondaryClaimsSrc = coderd.MergedClaimsSourceAccessToken secondaryClaimsSrc = coderd.MergedClaimsSourceAccessToken
} }
var pkceSupport struct {
CodeChallengeMethodsSupported []promoauth.Oauth2PKCEChallengeMethod `json:"code_challenge_methods_supported"`
}
err = oidcProvider.Claims(&pkceSupport)
if err != nil {
return nil, xerrors.Errorf("pkce detect in claims: %w", err)
}
return &coderd.OIDCConfig{ return &coderd.OIDCConfig{
OAuth2Config: useCfg, OAuth2Config: useCfg,
Provider: oidcProvider, Provider: oidcProvider,
@@ -206,6 +214,7 @@ func createOIDCConfig(ctx context.Context, logger slog.Logger, vals *codersdk.De
SignupsDisabledText: vals.OIDC.SignupsDisabledText.String(), SignupsDisabledText: vals.OIDC.SignupsDisabledText.String(),
IconURL: vals.OIDC.IconURL.String(), IconURL: vals.OIDC.IconURL.String(),
IgnoreEmailVerified: vals.OIDC.IgnoreEmailVerified.Value(), IgnoreEmailVerified: vals.OIDC.IgnoreEmailVerified.Value(),
PKCEMethods: pkceSupport.CodeChallengeMethodsSupported,
}, nil }, nil
} }
@@ -2761,6 +2770,8 @@ func parseExternalAuthProvidersFromEnv(prefix string, environ []string) ([]coder
provider.MCPToolAllowRegex = v.Value provider.MCPToolAllowRegex = v.Value
case "MCP_TOOL_DENY_REGEX": case "MCP_TOOL_DENY_REGEX":
provider.MCPToolDenyRegex = v.Value provider.MCPToolDenyRegex = v.Value
case "PKCE_METHODS":
provider.CodeChallengeMethodsSupported = strings.Split(v.Value, " ")
} }
providers[providerNum] = provider providers[providerNum] = provider
} }
+7
View File
@@ -14640,6 +14640,13 @@ const docTemplate = `{
"client_id": { "client_id": {
"type": "string" "type": "string"
}, },
"code_challenge_methods_supported": {
"description": "CodeChallengeMethodsSupported lists the PKCE code challenge methods\nThe only one supported by Coder is \"S256\".",
"type": "array",
"items": {
"type": "string"
}
},
"device_code_url": { "device_code_url": {
"type": "string" "type": "string"
}, },
+7
View File
@@ -13217,6 +13217,13 @@
"client_id": { "client_id": {
"type": "string" "type": "string"
}, },
"code_challenge_methods_supported": {
"description": "CodeChallengeMethodsSupported lists the PKCE code challenge methods\nThe only one supported by Coder is \"S256\".",
"type": "array",
"items": {
"type": "string"
}
},
"device_code_url": { "device_code_url": {
"type": "string" "type": "string"
}, },
+4 -3
View File
@@ -941,7 +941,7 @@ func New(options *Options) *API {
r.Route(fmt.Sprintf("/%s/callback", externalAuthConfig.ID), func(r chi.Router) { r.Route(fmt.Sprintf("/%s/callback", externalAuthConfig.ID), func(r chi.Router) {
r.Use( r.Use(
apiKeyMiddlewareRedirect, apiKeyMiddlewareRedirect,
httpmw.ExtractOAuth2(externalAuthConfig, options.HTTPClient, options.DeploymentValues.HTTPCookies, nil), httpmw.ExtractOAuth2(externalAuthConfig, options.HTTPClient, options.DeploymentValues.HTTPCookies, nil, externalAuthConfig.CodeChallengeMethodsSupported),
) )
r.Get("/", api.externalAuthCallback(externalAuthConfig)) r.Get("/", api.externalAuthCallback(externalAuthConfig))
}) })
@@ -1290,14 +1290,15 @@ func New(options *Options) *API {
r.Get("/github/device", api.userOAuth2GithubDevice) r.Get("/github/device", api.userOAuth2GithubDevice)
r.Route("/github", func(r chi.Router) { r.Route("/github", func(r chi.Router) {
r.Use( r.Use(
httpmw.ExtractOAuth2(options.GithubOAuth2Config, options.HTTPClient, options.DeploymentValues.HTTPCookies, nil), // Github supports PKCE S256
httpmw.ExtractOAuth2(options.GithubOAuth2Config, options.HTTPClient, options.DeploymentValues.HTTPCookies, nil, options.GithubOAuth2Config.PKCESupported()),
) )
r.Get("/callback", api.userOAuth2Github) r.Get("/callback", api.userOAuth2Github)
}) })
}) })
r.Route("/oidc/callback", func(r chi.Router) { r.Route("/oidc/callback", func(r chi.Router) {
r.Use( r.Use(
httpmw.ExtractOAuth2(options.OIDCConfig, options.HTTPClient, options.DeploymentValues.HTTPCookies, oidcAuthURLParams), httpmw.ExtractOAuth2(options.OIDCConfig, options.HTTPClient, options.DeploymentValues.HTTPCookies, oidcAuthURLParams, options.OIDCConfig.PKCESupported()),
) )
r.Get("/", api.userOIDC) r.Get("/", api.userOIDC)
}) })
+66 -5
View File
@@ -169,6 +169,7 @@ type FakeIDP struct {
// clientID to be used by coderd // clientID to be used by coderd
clientID string clientID string
clientSecret string clientSecret string
pkce bool // TODO(Emyrk): Implement for refresh token flow as well
// externalProviderID is optional to match the provider in coderd for // externalProviderID is optional to match the provider in coderd for
// redirectURLs. // redirectURLs.
externalProviderID string externalProviderID string
@@ -181,6 +182,8 @@ type FakeIDP struct {
// These maps are used to control the state of the IDP. // These maps are used to control the state of the IDP.
// That is the various access tokens, refresh tokens, states, etc. // That is the various access tokens, refresh tokens, states, etc.
codeToStateMap *syncmap.Map[string, string] codeToStateMap *syncmap.Map[string, string]
// Code -> PKCE Challenge
codeToChallengeMap *syncmap.Map[string, string]
// Token -> Email // Token -> Email
accessTokens *syncmap.Map[string, token] accessTokens *syncmap.Map[string, token]
// Refresh Token -> Email // Refresh Token -> Email
@@ -239,6 +242,12 @@ func (s statusHookError) Error() string {
type FakeIDPOpt func(idp *FakeIDP) type FakeIDPOpt func(idp *FakeIDP)
func WithPKCE() func(*FakeIDP) {
return func(f *FakeIDP) {
f.pkce = true
}
}
func WithAuthorizedRedirectURL(hook func(redirectURL string) error) func(*FakeIDP) { func WithAuthorizedRedirectURL(hook func(redirectURL string) error) func(*FakeIDP) {
return func(f *FakeIDP) { return func(f *FakeIDP) {
f.hookValidRedirectURL = hook f.hookValidRedirectURL = hook
@@ -450,6 +459,7 @@ func NewFakeIDP(t testing.TB, opts ...FakeIDPOpt) *FakeIDP {
clientSecret: uuid.NewString(), clientSecret: uuid.NewString(),
logger: slog.Make(), logger: slog.Make(),
codeToStateMap: syncmap.New[string, string](), codeToStateMap: syncmap.New[string, string](),
codeToChallengeMap: syncmap.New[string, string](),
accessTokens: syncmap.New[string, token](), accessTokens: syncmap.New[string, token](),
refreshTokens: syncmap.New[string, string](), refreshTokens: syncmap.New[string, string](),
refreshTokensUsed: syncmap.New[string, bool](), refreshTokensUsed: syncmap.New[string, bool](),
@@ -557,8 +567,16 @@ func (f *FakeIDP) realServer(t testing.TB) *httptest.Server {
func (f *FakeIDP) GenerateAuthenticatedToken(claims jwt.MapClaims) (*oauth2.Token, error) { func (f *FakeIDP) GenerateAuthenticatedToken(claims jwt.MapClaims) (*oauth2.Token, error) {
state := uuid.NewString() state := uuid.NewString()
f.stateToIDTokenClaims.Store(state, claims) f.stateToIDTokenClaims.Store(state, claims)
code := f.newCode(state)
return f.locked.Config().Exchange(oidc.ClientContext(context.Background(), f.HTTPClient(nil)), code) exchangeOpts := []oauth2.AuthCodeOption{}
verifier := ""
if f.pkce {
verifier = oauth2.GenerateVerifier()
exchangeOpts = append(exchangeOpts, oauth2.VerifierOption(verifier))
}
code := f.newCode(state, oauth2.S256ChallengeFromVerifier(verifier))
return f.locked.Config().Exchange(oidc.ClientContext(context.Background(), f.HTTPClient(nil)), code, exchangeOpts...)
} }
// Login does the full OIDC flow starting at the "LoginButton". // Login does the full OIDC flow starting at the "LoginButton".
@@ -756,10 +774,16 @@ func (f *FakeIDP) OIDCCallback(t testing.TB, state string, idTokenClaims jwt.Map
panic("cannot use OIDCCallback with WithServing. This is only for the in memory usage") panic("cannot use OIDCCallback with WithServing. This is only for the in memory usage")
} }
opts := []oauth2.AuthCodeOption{}
if f.pkce {
verifier := oauth2.GenerateVerifier()
opts = append(opts, oauth2.S256ChallengeOption(oauth2.S256ChallengeFromVerifier(verifier)))
}
f.stateToIDTokenClaims.Store(state, idTokenClaims) f.stateToIDTokenClaims.Store(state, idTokenClaims)
cli := f.HTTPClient(nil) cli := f.HTTPClient(nil)
u := f.locked.Config().AuthCodeURL(state) u := f.locked.Config().AuthCodeURL(state, opts...)
req, err := http.NewRequest("GET", u, nil) req, err := http.NewRequest("GET", u, nil)
require.NoError(t, err) require.NoError(t, err)
@@ -790,9 +814,10 @@ type ProviderJSON struct {
// newCode enforces the code exchanged is actually a valid code // newCode enforces the code exchanged is actually a valid code
// created by the IDP. // created by the IDP.
func (f *FakeIDP) newCode(state string) string { func (f *FakeIDP) newCode(state string, challenge string) string {
code := uuid.NewString() code := uuid.NewString()
f.codeToStateMap.Store(code, state) f.codeToStateMap.Store(code, state)
f.codeToChallengeMap.Store(code, challenge)
return code return code
} }
@@ -918,6 +943,22 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler {
mux.Handle(authorizePath, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { mux.Handle(authorizePath, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
f.logger.Info(r.Context(), "http call authorize", slogRequestFields(r)...) f.logger.Info(r.Context(), "http call authorize", slogRequestFields(r)...)
challenge := ""
if f.pkce {
method := r.URL.Query().Get("code_challenge_method")
challenge = r.URL.Query().Get("code_challenge")
if method == "" {
httpError(rw, http.StatusBadRequest, xerrors.New("missing code_challenge_method"))
return
}
if challenge == "" {
httpError(rw, http.StatusBadRequest, xerrors.New("missing code_challenge"))
return
}
}
clientID := r.URL.Query().Get("client_id") clientID := r.URL.Query().Get("client_id")
if !assert.Equal(t, f.clientID, clientID, "unexpected client_id") { if !assert.Equal(t, f.clientID, clientID, "unexpected client_id") {
httpError(rw, http.StatusBadRequest, xerrors.New("invalid client_id")) httpError(rw, http.StatusBadRequest, xerrors.New("invalid client_id"))
@@ -959,7 +1000,7 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler {
q := ru.Query() q := ru.Query()
q.Set("state", state) q.Set("state", state)
q.Set("code", f.newCode(state)) q.Set("code", f.newCode(state, challenge))
ru.RawQuery = q.Encode() ru.RawQuery = q.Encode()
http.Redirect(rw, r, ru.String(), http.StatusTemporaryRedirect) http.Redirect(rw, r, ru.String(), http.StatusTemporaryRedirect)
@@ -1009,8 +1050,28 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler {
http.Error(rw, "invalid code", http.StatusBadRequest) http.Error(rw, "invalid code", http.StatusBadRequest)
return return
} }
if f.pkce {
challenge, ok := f.codeToChallengeMap.Load(code)
if !ok {
httpError(rw, http.StatusBadRequest, xerrors.New("pkce: challenge not found for code"))
return
}
codeVerifier := values.Get("code_verifier")
if codeVerifier == "" {
httpError(rw, http.StatusBadRequest, xerrors.New("pkce: missing code_verifier"))
return
}
expecter := oauth2.S256ChallengeFromVerifier(codeVerifier)
if challenge != expecter {
httpError(rw, http.StatusBadRequest, xerrors.New("pkce: invalid code verifier"))
return
}
}
// Always invalidate the code after it is used. // Always invalidate the code after it is used.
f.codeToStateMap.Delete(code) f.codeToStateMap.Delete(code)
f.codeToChallengeMap.Delete(code)
idTokenClaims, ok := f.getClaims(f.stateToIDTokenClaims, stateStr) idTokenClaims, ok := f.getClaims(f.stateToIDTokenClaims, stateStr)
if !ok { if !ok {
+2
View File
@@ -16,6 +16,7 @@ import (
"github.com/coder/coder/v2/coderd/externalauth" "github.com/coder/coder/v2/coderd/externalauth"
"github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk"
) )
@@ -425,6 +426,7 @@ func ExternalAuthConfig(cfg *externalauth.Config) codersdk.ExternalAuthLinkProvi
AllowRefresh: !cfg.NoRefresh, AllowRefresh: !cfg.NoRefresh,
AllowValidate: cfg.ValidateURL != "", AllowValidate: cfg.ValidateURL != "",
SupportsRevocation: cfg.RevokeURL != "", SupportsRevocation: cfg.RevokeURL != "",
CodeChallengeMethodsSupported: slice.ToStrings(cfg.CodeChallengeMethodsSupported),
} }
} }
+26 -2
View File
@@ -25,6 +25,7 @@ import (
"github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/promoauth" "github.com/coder/coder/v2/coderd/promoauth"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk"
"github.com/coder/retry" "github.com/coder/retry"
) )
@@ -103,6 +104,7 @@ type Config struct {
// In the case of conflicts, items evaluated by this list override [MCPToolAllowRegex]. // In the case of conflicts, items evaluated by this list override [MCPToolAllowRegex].
// This field can be nil if unspecified in the config. // 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. // GenerateTokenExtra generates the extra token data to store in the database.
@@ -741,6 +743,7 @@ func ConvertConfig(instrument *promoauth.Factory, entries []codersdk.ExternalAut
MCPURL: entry.MCPURL, MCPURL: entry.MCPURL,
MCPToolAllowRegex: mcpToolAllow, MCPToolAllowRegex: mcpToolAllow,
MCPToolDenyRegex: mcpToolDeny, MCPToolDenyRegex: mcpToolDeny,
CodeChallengeMethodsSupported: slice.StringEnums[promoauth.Oauth2PKCEChallengeMethod](entry.CodeChallengeMethodsSupported),
} }
if entry.DeviceFlow { if entry.DeviceFlow {
@@ -800,8 +803,7 @@ func applyDefaultsToConfig(config *codersdk.ExternalAuthConfig) {
copyDefaultSettings(config, azureDevopsEntraDefaults(config)) copyDefaultSettings(config, azureDevopsEntraDefaults(config))
return return
default: default:
// No defaults for this type. We still want to run this apply with // Global defaults are specified at the end of the `copyDefaultSettings` function.
// an empty set of defaults.
copyDefaultSettings(config, codersdk.ExternalAuthConfig{}) copyDefaultSettings(config, codersdk.ExternalAuthConfig{})
return return
} }
@@ -844,6 +846,9 @@ func copyDefaultSettings(config *codersdk.ExternalAuthConfig, defaults codersdk.
if len(config.ExtraTokenKeys) == 0 { if len(config.ExtraTokenKeys) == 0 {
config.ExtraTokenKeys = defaults.ExtraTokenKeys config.ExtraTokenKeys = defaults.ExtraTokenKeys
} }
if config.CodeChallengeMethodsSupported == nil {
config.CodeChallengeMethodsSupported = defaults.CodeChallengeMethodsSupported
}
// Apply defaults if it's still empty... // Apply defaults if it's still empty...
if config.ID == "" { if config.ID == "" {
@@ -856,6 +861,9 @@ func copyDefaultSettings(config *codersdk.ExternalAuthConfig, defaults codersdk.
// This is a key emoji. // This is a key emoji.
config.DisplayIcon = "/emojis/1f511.png" config.DisplayIcon = "/emojis/1f511.png"
} }
if config.CodeChallengeMethodsSupported == nil {
config.CodeChallengeMethodsSupported = []string{string(promoauth.PKCEChallengeMethodSha256)}
}
} }
// gitHubDefaults returns default config values for GitHub. // gitHubDefaults returns default config values for GitHub.
@@ -872,6 +880,7 @@ func gitHubDefaults(config *codersdk.ExternalAuthConfig) codersdk.ExternalAuthCo
Scopes: []string{"repo", "workflow"}, Scopes: []string{"repo", "workflow"},
DeviceCodeURL: "https://github.com/login/device/code", DeviceCodeURL: "https://github.com/login/device/code",
AppInstallationsURL: "https://api.github.com/user/installations", AppInstallationsURL: "https://api.github.com/user/installations",
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodSha256)},
} }
if config.RevokeURL == "" && config.ClientID != "" { if config.RevokeURL == "" && config.ClientID != "" {
@@ -886,6 +895,8 @@ func bitbucketServerDefaults(config *codersdk.ExternalAuthConfig) codersdk.Exter
DisplayName: "Bitbucket Server", DisplayName: "Bitbucket Server",
Scopes: []string{"PUBLIC_REPOS", "REPO_READ", "REPO_WRITE"}, Scopes: []string{"PUBLIC_REPOS", "REPO_READ", "REPO_WRITE"},
DisplayIcon: "/icon/bitbucket.svg", 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. // 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, // We will grab this from the Auth URL. This choice is a bit arbitrary,
@@ -931,6 +942,7 @@ func gitlabDefaults(config *codersdk.ExternalAuthConfig) codersdk.ExternalAuthCo
DisplayIcon: "/icon/gitlab.svg", DisplayIcon: "/icon/gitlab.svg",
Regex: `^(https?://)?gitlab\.com(/.*)?$`, Regex: `^(https?://)?gitlab\.com(/.*)?$`,
Scopes: []string{"write_repository"}, Scopes: []string{"write_repository"},
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodSha256)},
} }
if config.AuthURL == "" || config.AuthURL == cloud.AuthURL { if config.AuthURL == "" || config.AuthURL == cloud.AuthURL {
@@ -954,6 +966,7 @@ func gitlabDefaults(config *codersdk.ExternalAuthConfig) codersdk.ExternalAuthCo
ValidateURL: au.ResolveReference(&url.URL{Path: "/oauth/token/info"}).String(), ValidateURL: au.ResolveReference(&url.URL{Path: "/oauth/token/info"}).String(),
RevokeURL: au.ResolveReference(&url.URL{Path: "/oauth/revoke"}).String(), RevokeURL: au.ResolveReference(&url.URL{Path: "/oauth/revoke"}).String(),
Regex: fmt.Sprintf(`^(https?://)?%s(/.*)?$`, strings.ReplaceAll(au.Host, ".", `\.`)), 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", DisplayName: "JFrog Artifactory",
Scopes: []string{"applied-permissions/user"}, Scopes: []string{"applied-permissions/user"},
DisplayIcon: "/icon/jfrog.svg", 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. // 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 // We will grab this from the Auth URL. This choice is not arbitrary. It is a
@@ -1000,6 +1015,7 @@ func giteaDefaults(config *codersdk.ExternalAuthConfig) codersdk.ExternalAuthCon
DisplayName: "Gitea", DisplayName: "Gitea",
Scopes: []string{"read:repository", " write:repository", "read:user"}, Scopes: []string{"read:repository", " write:repository", "read:user"},
DisplayIcon: "/icon/gitea.svg", DisplayIcon: "/icon/gitea.svg",
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodSha256)},
} }
// Gitea's servers will have some base url, e.g: https://gitea.coder.com. // 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 // 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)", DisplayName: "Azure DevOps (Entra)",
DisplayIcon: "/icon/azure-devops.svg", DisplayIcon: "/icon/azure-devops.svg",
Regex: `^(https?://)?dev\.azure\.com(/.*)?$`, 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. // The tenant ID is required for urls and is in the auth url.
if config.AuthURL == "" { if config.AuthURL == "" {
@@ -1069,6 +1087,8 @@ var staticDefaults = map[codersdk.EnhancedExternalAuthProvider]codersdk.External
DisplayIcon: "/icon/azure-devops.svg", DisplayIcon: "/icon/azure-devops.svg",
Regex: `^(https?://)?dev\.azure\.com(/.*)?$`, Regex: `^(https?://)?dev\.azure\.com(/.*)?$`,
Scopes: []string{"vso.code_write"}, Scopes: []string{"vso.code_write"},
// TODO: Investigate if 'S256' is accepted and PKCE is supported
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodNone)},
}, },
codersdk.EnhancedExternalAuthProviderBitBucketCloud: { codersdk.EnhancedExternalAuthProviderBitBucketCloud: {
AuthURL: "https://bitbucket.org/site/oauth2/authorize", AuthURL: "https://bitbucket.org/site/oauth2/authorize",
@@ -1078,6 +1098,8 @@ var staticDefaults = map[codersdk.EnhancedExternalAuthProvider]codersdk.External
DisplayIcon: "/icon/bitbucket.svg", DisplayIcon: "/icon/bitbucket.svg",
Regex: `^(https?://)?bitbucket\.org(/.*)?$`, Regex: `^(https?://)?bitbucket\.org(/.*)?$`,
Scopes: []string{"account", "repository:write"}, Scopes: []string{"account", "repository:write"},
// TODO: Investigate if 'S256' is accepted and PKCE is supported
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodNone)},
}, },
codersdk.EnhancedExternalAuthProviderSlack: { codersdk.EnhancedExternalAuthProviderSlack: {
AuthURL: "https://slack.com/oauth/v2/authorize", AuthURL: "https://slack.com/oauth/v2/authorize",
@@ -1087,6 +1109,8 @@ var staticDefaults = map[codersdk.EnhancedExternalAuthProvider]codersdk.External
DisplayIcon: "/icon/slack.svg", DisplayIcon: "/icon/slack.svg",
// See: https://api.slack.com/authentication/oauth-v2#exchanging // See: https://api.slack.com/authentication/oauth-v2#exchanging
ExtraTokenKeys: []string{"authed_user"}, 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/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/promoauth"
"github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk"
) )
@@ -13,7 +14,8 @@ func TestGitlabDefaults(t *testing.T) {
// The default cloud setup. Copying this here as hard coded // The default cloud setup. Copying this here as hard coded
// values. // values.
cloud := codersdk.ExternalAuthConfig{ cloud := func() codersdk.ExternalAuthConfig {
return codersdk.ExternalAuthConfig{
Type: string(codersdk.EnhancedExternalAuthProviderGitLab), Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
ID: string(codersdk.EnhancedExternalAuthProviderGitLab), ID: string(codersdk.EnhancedExternalAuthProviderGitLab),
AuthURL: "https://gitlab.com/oauth/authorize", AuthURL: "https://gitlab.com/oauth/authorize",
@@ -24,6 +26,8 @@ func TestGitlabDefaults(t *testing.T) {
DisplayIcon: "/icon/gitlab.svg", DisplayIcon: "/icon/gitlab.svg",
Regex: `^(https?://)?gitlab\.com(/.*)?$`, Regex: `^(https?://)?gitlab\.com(/.*)?$`,
Scopes: []string{"write_repository"}, Scopes: []string{"write_repository"},
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodSha256)},
}
} }
tests := []struct { tests := []struct {
@@ -38,7 +42,7 @@ func TestGitlabDefaults(t *testing.T) {
input: codersdk.ExternalAuthConfig{ input: codersdk.ExternalAuthConfig{
Type: string(codersdk.EnhancedExternalAuthProviderGitLab), Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
}, },
expected: cloud, expected: cloud(),
}, },
{ {
// If someone was to manually configure the gitlab cli. // If someone was to manually configure the gitlab cli.
@@ -47,7 +51,7 @@ func TestGitlabDefaults(t *testing.T) {
Type: string(codersdk.EnhancedExternalAuthProviderGitLab), Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
AuthURL: "https://gitlab.com/oauth/authorize", AuthURL: "https://gitlab.com/oauth/authorize",
}, },
expected: cloud, expected: cloud(),
}, },
{ {
// Changing some of the defaults of the cloud option // Changing some of the defaults of the cloud option
@@ -60,7 +64,7 @@ func TestGitlabDefaults(t *testing.T) {
DisplayName: "custom", DisplayName: "custom",
Regex: ".*", Regex: ".*",
}, },
expected: cloud, expected: cloud(),
mutateExpected: func(config *codersdk.ExternalAuthConfig) { mutateExpected: func(config *codersdk.ExternalAuthConfig) {
config.AuthURL = "https://gitlab.com/oauth/authorize?foo=bar" config.AuthURL = "https://gitlab.com/oauth/authorize?foo=bar"
config.DisplayName = "custom" config.DisplayName = "custom"
@@ -75,7 +79,7 @@ func TestGitlabDefaults(t *testing.T) {
Type: string(codersdk.EnhancedExternalAuthProviderGitLab), Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
AuthURL: "https://gitlab.company.org/oauth/authorize?foo=bar", AuthURL: "https://gitlab.company.org/oauth/authorize?foo=bar",
}, },
expected: cloud, expected: cloud(),
mutateExpected: func(config *codersdk.ExternalAuthConfig) { mutateExpected: func(config *codersdk.ExternalAuthConfig) {
config.AuthURL = "https://gitlab.company.org/oauth/authorize?foo=bar" config.AuthURL = "https://gitlab.company.org/oauth/authorize?foo=bar"
config.ValidateURL = "https://gitlab.company.org/oauth/token/info" config.ValidateURL = "https://gitlab.company.org/oauth/token/info"
@@ -94,14 +98,16 @@ func TestGitlabDefaults(t *testing.T) {
TokenURL: "https://token.com/token", TokenURL: "https://token.com/token",
RevokeURL: "https://token.com/revoke", RevokeURL: "https://token.com/revoke",
Regex: "random", Regex: "random",
CodeChallengeMethodsSupported: []string{"random"},
}, },
expected: cloud, expected: cloud(),
mutateExpected: func(config *codersdk.ExternalAuthConfig) { mutateExpected: func(config *codersdk.ExternalAuthConfig) {
config.AuthURL = "https://auth.com/auth" config.AuthURL = "https://auth.com/auth"
config.ValidateURL = "https://validate.com/validate" config.ValidateURL = "https://validate.com/validate"
config.TokenURL = "https://token.com/token" config.TokenURL = "https://token.com/token"
config.RevokeURL = "https://token.com/revoke" config.RevokeURL = "https://token.com/revoke"
config.Regex = `random` config.Regex = `random`
config.CodeChallengeMethodsSupported = []string{"random"}
}, },
}, },
} }
@@ -138,6 +144,7 @@ func Test_bitbucketServerConfigDefaults(t *testing.T) {
DisplayName: "Bitbucket Server", DisplayName: "Bitbucket Server",
Scopes: []string{"PUBLIC_REPOS", "REPO_READ", "REPO_WRITE"}, Scopes: []string{"PUBLIC_REPOS", "REPO_READ", "REPO_WRITE"},
DisplayIcon: "/icon/bitbucket.svg", DisplayIcon: "/icon/bitbucket.svg",
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodNone)},
}, },
}, },
{ {
@@ -157,6 +164,7 @@ func Test_bitbucketServerConfigDefaults(t *testing.T) {
Regex: `^(https?://)?bitbucket\.example\.com(/.*)?$`, Regex: `^(https?://)?bitbucket\.example\.com(/.*)?$`,
DisplayName: "Bitbucket Server", DisplayName: "Bitbucket Server",
DisplayIcon: "/icon/bitbucket.svg", DisplayIcon: "/icon/bitbucket.svg",
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodNone)},
}, },
}, },
{ {
@@ -176,6 +184,7 @@ func Test_bitbucketServerConfigDefaults(t *testing.T) {
DisplayIcon: "/icon/bitbucket.svg", DisplayIcon: "/icon/bitbucket.svg",
Regex: `^(https?://)?bitbucket\.org(/.*)?$`, Regex: `^(https?://)?bitbucket\.org(/.*)?$`,
Scopes: []string{"account", "repository:write"}, 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)
})
}
}
+3 -1
View File
@@ -729,6 +729,7 @@ func TestConstantQueryParams(t *testing.T) {
authURL.RawQuery = url.Values{constantQueryParamKey: []string{constantQueryParamValue}}.Encode() authURL.RawQuery = url.Values{constantQueryParamKey: []string{constantQueryParamValue}}.Encode()
cfg.OAuth2Config.(*oauth2.Config).Endpoint.AuthURL = authURL.String() cfg.OAuth2Config.(*oauth2.Config).Endpoint.AuthURL = authURL.String()
require.Contains(t, cfg.OAuth2Config.(*oauth2.Config).Endpoint.AuthURL, constantQueryParam) 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" const providerID = "test-idp"
fake := oidctest.NewFakeIDP(t, fake := oidctest.NewFakeIDP(t,
append([]oidctest.FakeIDPOpt{}, settings.FakeIDPOpts...)..., append([]oidctest.FakeIDPOpt{oidctest.WithPKCE()}, settings.FakeIDPOpts...)...,
) )
f := promoauth.NewFactory(prometheus.NewRegistry()) f := promoauth.NewFactory(prometheus.NewRegistry())
@@ -806,6 +807,7 @@ func setupOauth2Test(t *testing.T, settings testConfig) (*oidctest.FakeIDP, *ext
ValidateURL: fake.WellknownConfig().UserInfoURL, ValidateURL: fake.WellknownConfig().UserInfoURL,
RevokeURL: fake.WellknownConfig().RevokeURL, RevokeURL: fake.WellknownConfig().RevokeURL,
RevokeTimeout: 1 * time.Second, RevokeTimeout: 1 * time.Second,
CodeChallengeMethodsSupported: []promoauth.Oauth2PKCEChallengeMethod{promoauth.PKCEChallengeMethodSha256},
} }
settings.ExternalAuthOpt(config) settings.ExternalAuthOpt(config)
+19
View File
@@ -26,6 +26,7 @@ import (
"github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/externalauth" "github.com/coder/coder/v2/coderd/externalauth"
"github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/promoauth"
"github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/provisioner/echo"
@@ -34,6 +35,24 @@ import (
func TestExternalAuthByID(t *testing.T) { func TestExternalAuthByID(t *testing.T) {
t.Parallel() t.Parallel()
t.Run("PKCEMissing", func(t *testing.T) {
t.Parallel()
const providerID = "fake-github"
fake := oidctest.NewFakeIDP(t, oidctest.WithServing())
client := coderdtest.New(t, &coderdtest.Options{
ExternalAuthConfigs: []*externalauth.Config{
fake.ExternalAuthConfig(t, providerID, nil, func(cfg *externalauth.Config) {
cfg.Type = codersdk.EnhancedExternalAuthProviderGitHub.String()
cfg.CodeChallengeMethodsSupported = []promoauth.Oauth2PKCEChallengeMethod{}
}),
},
})
coderdtest.CreateFirstUser(t, client)
auth, err := client.ExternalAuthByID(context.Background(), providerID)
require.NoError(t, err)
require.False(t, auth.Authenticated)
})
t.Run("Unauthenticated", func(t *testing.T) { t.Run("Unauthenticated", func(t *testing.T) {
t.Parallel() t.Parallel()
const providerID = "fake-github" const providerID = "fake-github"
+35 -3
View File
@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"reflect" "reflect"
"slices"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/google/uuid" "github.com/google/uuid"
@@ -40,13 +41,19 @@ func OAuth2(r *http.Request) OAuth2State {
// a "code" URL parameter will be redirected. // a "code" URL parameter will be redirected.
// AuthURLOpts are passed to the AuthCodeURL function. If this is nil, // AuthURLOpts are passed to the AuthCodeURL function. If this is nil,
// the default option oauth2.AccessTypeOffline will be used. // the default option oauth2.AccessTypeOffline will be used.
func ExtractOAuth2(config promoauth.OAuth2Config, client *http.Client, cookieCfg codersdk.HTTPCookieConfig, authURLOpts map[string]string) func(http.Handler) http.Handler { //
// pkceMethods should be a list like ['S256', 'plain'] indicating
// which PKCE methods are supported by the OAuth2 provider. If empty,
// PKCE will not be used.
func ExtractOAuth2(config promoauth.OAuth2Config, client *http.Client, cookieCfg codersdk.HTTPCookieConfig, authURLOpts map[string]string, pkceMethods []promoauth.Oauth2PKCEChallengeMethod) func(http.Handler) http.Handler {
opts := make([]oauth2.AuthCodeOption, 0, len(authURLOpts)+1) opts := make([]oauth2.AuthCodeOption, 0, len(authURLOpts)+1)
opts = append(opts, oauth2.AccessTypeOffline) opts = append(opts, oauth2.AccessTypeOffline)
for k, v := range authURLOpts { for k, v := range authURLOpts {
opts = append(opts, oauth2.SetAuthURLParam(k, v)) opts = append(opts, oauth2.SetAuthURLParam(k, v))
} }
// Only S256 PKCE is currently supported.
sha256PKCESupported := slices.Contains(pkceMethods, promoauth.PKCEChallengeMethodSha256)
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
@@ -133,7 +140,20 @@ func ExtractOAuth2(config promoauth.OAuth2Config, client *http.Client, cookieCfg
HttpOnly: true, HttpOnly: true,
})) }))
http.Redirect(rw, r, config.AuthCodeURL(state, opts...), http.StatusTemporaryRedirect) authOpts := slices.Clone(opts)
if sha256PKCESupported {
verifier := oauth2.GenerateVerifier()
authOpts = append(authOpts, oauth2.S256ChallengeOption(verifier))
http.SetCookie(rw, cookieCfg.Apply(&http.Cookie{
Name: codersdk.OAuth2PKCEVerifier,
Value: verifier,
Path: "/",
HttpOnly: true,
}))
}
http.Redirect(rw, r, config.AuthCodeURL(state, authOpts...), http.StatusTemporaryRedirect)
return return
} }
@@ -163,7 +183,19 @@ func ExtractOAuth2(config promoauth.OAuth2Config, client *http.Client, cookieCfg
redirect = stateRedirect.Value redirect = stateRedirect.Value
} }
oauthToken, err := config.Exchange(ctx, code) exchangeOpts := make([]oauth2.AuthCodeOption, 0)
if sha256PKCESupported {
pkceVerifier, err := r.Cookie(codersdk.OAuth2PKCEVerifier)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "PKCE challenge must be provided.",
})
return
}
exchangeOpts = append(exchangeOpts, oauth2.VerifierOption(pkceVerifier.Value))
}
oauthToken, err := config.Exchange(ctx, code, exchangeOpts...)
if err != nil { if err != nil {
errorCode := http.StatusInternalServerError errorCode := http.StatusInternalServerError
detail := err.Error() detail := err.Error()
+9 -9
View File
@@ -50,7 +50,7 @@ func TestOAuth2(t *testing.T) {
t.Parallel() t.Parallel()
req := httptest.NewRequest("GET", "/", nil) req := httptest.NewRequest("GET", "/", nil)
res := httptest.NewRecorder() res := httptest.NewRecorder()
httpmw.ExtractOAuth2(nil, nil, codersdk.HTTPCookieConfig{}, nil)(nil).ServeHTTP(res, req) httpmw.ExtractOAuth2(nil, nil, codersdk.HTTPCookieConfig{}, nil, nil)(nil).ServeHTTP(res, req)
require.Equal(t, http.StatusBadRequest, res.Result().StatusCode) require.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
}) })
t.Run("RedirectWithoutCode", func(t *testing.T) { t.Run("RedirectWithoutCode", func(t *testing.T) {
@@ -58,7 +58,7 @@ func TestOAuth2(t *testing.T) {
req := httptest.NewRequest("GET", "/?redirect="+url.QueryEscape("/dashboard"), nil) req := httptest.NewRequest("GET", "/?redirect="+url.QueryEscape("/dashboard"), nil)
res := httptest.NewRecorder() res := httptest.NewRecorder()
tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline) tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline)
httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil)(nil).ServeHTTP(res, req) httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil)(nil).ServeHTTP(res, req)
location := res.Header().Get("Location") location := res.Header().Get("Location")
if !assert.NotEmpty(t, location) { if !assert.NotEmpty(t, location) {
return return
@@ -82,7 +82,7 @@ func TestOAuth2(t *testing.T) {
req := httptest.NewRequest("GET", "/?redirect="+url.QueryEscape(uri.String()), nil) req := httptest.NewRequest("GET", "/?redirect="+url.QueryEscape(uri.String()), nil)
res := httptest.NewRecorder() res := httptest.NewRecorder()
tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline) tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline)
httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil)(nil).ServeHTTP(res, req) httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil)(nil).ServeHTTP(res, req)
location := res.Header().Get("Location") location := res.Header().Get("Location")
if !assert.NotEmpty(t, location) { if !assert.NotEmpty(t, location) {
return return
@@ -97,7 +97,7 @@ func TestOAuth2(t *testing.T) {
req := httptest.NewRequest("GET", "/?code=something", nil) req := httptest.NewRequest("GET", "/?code=something", nil)
res := httptest.NewRecorder() res := httptest.NewRecorder()
tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline) tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline)
httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil)(nil).ServeHTTP(res, req) httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil)(nil).ServeHTTP(res, req)
require.Equal(t, http.StatusBadRequest, res.Result().StatusCode) require.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
}) })
t.Run("NoStateCookie", func(t *testing.T) { t.Run("NoStateCookie", func(t *testing.T) {
@@ -105,7 +105,7 @@ func TestOAuth2(t *testing.T) {
req := httptest.NewRequest("GET", "/?code=something&state=test", nil) req := httptest.NewRequest("GET", "/?code=something&state=test", nil)
res := httptest.NewRecorder() res := httptest.NewRecorder()
tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline) tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline)
httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil)(nil).ServeHTTP(res, req) httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil)(nil).ServeHTTP(res, req)
require.Equal(t, http.StatusUnauthorized, res.Result().StatusCode) require.Equal(t, http.StatusUnauthorized, res.Result().StatusCode)
}) })
t.Run("MismatchedState", func(t *testing.T) { t.Run("MismatchedState", func(t *testing.T) {
@@ -117,7 +117,7 @@ func TestOAuth2(t *testing.T) {
}) })
res := httptest.NewRecorder() res := httptest.NewRecorder()
tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline) tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline)
httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil)(nil).ServeHTTP(res, req) httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil)(nil).ServeHTTP(res, req)
require.Equal(t, http.StatusUnauthorized, res.Result().StatusCode) require.Equal(t, http.StatusUnauthorized, res.Result().StatusCode)
}) })
t.Run("ExchangeCodeAndState", func(t *testing.T) { t.Run("ExchangeCodeAndState", func(t *testing.T) {
@@ -133,7 +133,7 @@ func TestOAuth2(t *testing.T) {
}) })
res := httptest.NewRecorder() res := httptest.NewRecorder()
tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline) tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline)
httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
state := httpmw.OAuth2(r) state := httpmw.OAuth2(r)
require.Equal(t, "/dashboard", state.Redirect) require.Equal(t, "/dashboard", state.Redirect)
})).ServeHTTP(res, req) })).ServeHTTP(res, req)
@@ -144,7 +144,7 @@ func TestOAuth2(t *testing.T) {
res := httptest.NewRecorder() res := httptest.NewRecorder()
tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("foo", "bar")) tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("foo", "bar"))
authOpts := map[string]string{"foo": "bar"} authOpts := map[string]string{"foo": "bar"}
httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, authOpts)(nil).ServeHTTP(res, req) httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, authOpts, nil)(nil).ServeHTTP(res, req)
location := res.Header().Get("Location") location := res.Header().Get("Location")
// Ideally we would also assert that the location contains the query params // Ideally we would also assert that the location contains the query params
// we set in the auth URL but this would essentially be testing the oauth2 package. // we set in the auth URL but this would essentially be testing the oauth2 package.
@@ -160,7 +160,7 @@ func TestOAuth2(t *testing.T) {
httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{ httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{
Secure: true, Secure: true,
SameSite: "none", SameSite: "none",
}, nil)(nil).ServeHTTP(res, req) }, nil, nil)(nil).ServeHTTP(res, req)
found := false found := false
for _, cookie := range res.Result().Cookies() { for _, cookie := range res.Result().Cookies() {
+7
View File
@@ -11,6 +11,13 @@ import (
"golang.org/x/oauth2" "golang.org/x/oauth2"
) )
type Oauth2PKCEChallengeMethod string
const (
PKCEChallengeMethodSha256 Oauth2PKCEChallengeMethod = "S256"
PKCEChallengeMethodNone Oauth2PKCEChallengeMethod = ""
)
type Oauth2Source string type Oauth2Source string
const ( const (
+13
View File
@@ -770,6 +770,10 @@ type GithubOAuth2Config struct {
DefaultProviderConfigured bool DefaultProviderConfigured bool
} }
func (*GithubOAuth2Config) PKCESupported() []promoauth.Oauth2PKCEChallengeMethod {
return []promoauth.Oauth2PKCEChallengeMethod{promoauth.PKCEChallengeMethodSha256}
}
func (c *GithubOAuth2Config) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { func (c *GithubOAuth2Config) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
if !c.DeviceFlowEnabled { if !c.DeviceFlowEnabled {
return c.OAuth2Config.Exchange(ctx, code, opts...) return c.OAuth2Config.Exchange(ctx, code, opts...)
@@ -1172,6 +1176,15 @@ type OIDCConfig struct {
IconURL string IconURL string
// SignupsDisabledText is the text do display on the static error page. // SignupsDisabledText is the text do display on the static error page.
SignupsDisabledText string SignupsDisabledText string
PKCEMethods []promoauth.Oauth2PKCEChallengeMethod
}
// PKCESupported is to prevent nil pointer dereference.
func (o *OIDCConfig) PKCESupported() []promoauth.Oauth2PKCEChallengeMethod {
if o == nil {
return nil
}
return o.PKCEMethods
} }
// @Summary OpenID Connect Callback // @Summary OpenID Connect Callback
+8
View File
@@ -1017,6 +1017,10 @@ func TestUserOAuth2Github(t *testing.T) {
Name: "oauth_state", Name: "oauth_state",
Value: "somestate", Value: "somestate",
}) })
req.AddCookie(&http.Cookie{
Name: codersdk.OAuth2PKCEVerifier,
Value: oauth2.GenerateVerifier(),
})
require.NoError(t, err) require.NoError(t, err)
res, err = client.HTTPClient.Do(req) res, err = client.HTTPClient.Do(req)
require.NoError(t, err) require.NoError(t, err)
@@ -2460,6 +2464,10 @@ func oauth2Callback(t *testing.T, client *codersdk.Client, opts ...func(*http.Re
Name: codersdk.OAuth2StateCookie, Name: codersdk.OAuth2StateCookie,
Value: state, Value: state,
}) })
req.AddCookie(&http.Cookie{
Name: codersdk.OAuth2PKCEVerifier,
Value: oauth2.GenerateVerifier(),
})
res, err := client.HTTPClient.Do(req) res, err := client.HTTPClient.Do(req)
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { t.Cleanup(func() {
+4
View File
@@ -38,6 +38,10 @@ const (
SessionTokenHeader = "Coder-Session-Token" SessionTokenHeader = "Coder-Session-Token"
// OAuth2StateCookie is the name of the cookie that stores the oauth2 state. // OAuth2StateCookie is the name of the cookie that stores the oauth2 state.
OAuth2StateCookie = "oauth_state" OAuth2StateCookie = "oauth_state"
// OAuth2PKCEVerifier is the name of the cookie that stores the oauth2 PKCE
// verifier. This is the raw verifier that when hashed, will match the challenge
// sent in the initial oauth2 request.
OAuth2PKCEVerifier = "oauth_pkce_verifier"
// OAuth2RedirectCookie is the name of the cookie that stores the oauth2 redirect. // OAuth2RedirectCookie is the name of the cookie that stores the oauth2 redirect.
OAuth2RedirectCookie = "oauth_redirect" OAuth2RedirectCookie = "oauth_redirect"
+4 -2
View File
@@ -772,6 +772,9 @@ type ExternalAuthConfig struct {
DisplayName string `json:"display_name" yaml:"display_name"` DisplayName string `json:"display_name" yaml:"display_name"`
// DisplayIcon is a URL to an icon to display in the UI. // DisplayIcon is a URL to an icon to display in the UI.
DisplayIcon string `json:"display_icon" yaml:"display_icon"` DisplayIcon string `json:"display_icon" yaml:"display_icon"`
// CodeChallengeMethodsSupported lists the PKCE code challenge methods
// The only one supported by Coder is "S256".
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported" yaml:"code_challenge_methods_supported"`
} }
type ProvisionerConfig struct { type ProvisionerConfig struct {
@@ -1681,8 +1684,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
Env: "CODER_BLOCK_DIRECT", Env: "CODER_BLOCK_DIRECT",
Value: &c.DERP.Config.BlockDirect, Value: &c.DERP.Config.BlockDirect,
Group: &deploymentGroupNetworkingDERP, Group: &deploymentGroupNetworkingDERP,
YAML: "blockDirect", Annotations: serpent.Annotations{}. YAML: "blockDirect", Annotations: serpent.Annotations{}.Mark(annotationExternalProxies, "true"),
Mark(annotationExternalProxies, "true"),
}, },
{ {
Name: "DERP Force WebSockets", Name: "DERP Force WebSockets",
+1
View File
@@ -417,6 +417,7 @@ func TestExternalAuthYAMLConfig(t *testing.T) {
MCPURL: "https://api.githubcopilot.com/mcp/", MCPURL: "https://api.githubcopilot.com/mcp/",
MCPToolAllowRegex: ".*", MCPToolAllowRegex: ".*",
MCPToolDenyRegex: "create_gist", MCPToolDenyRegex: "create_gist",
CodeChallengeMethodsSupported: []string{"S256"},
} }
// Input the github section twice for testing a slice of configs. // Input the github section twice for testing a slice of configs.
+1
View File
@@ -102,6 +102,7 @@ type ExternalAuthLinkProvider struct {
AllowRefresh bool `json:"allow_refresh"` AllowRefresh bool `json:"allow_refresh"`
AllowValidate bool `json:"allow_validate"` AllowValidate bool `json:"allow_validate"`
SupportsRevocation bool `json:"supports_revocation"` SupportsRevocation bool `json:"supports_revocation"`
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
} }
type ExternalAuthAppInstallation struct { type ExternalAuthAppInstallation struct {
+2
View File
@@ -24,3 +24,5 @@ externalAuthProviders:
regex: ^https://example.com/.*$ regex: ^https://example.com/.*$
display_name: GitHub display_name: GitHub
display_icon: /static/icons/github.svg display_icon: /static/icons/github.svg
code_challenge_methods_supported:
- S256
+17
View File
@@ -133,6 +133,23 @@ You must add the SSH key to your Git provider.
- [GitHub](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/adding-a-new-ssh-key-to-your-github-account#adding-a-new-ssh-key-to-your-account) - [GitHub](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/adding-a-new-ssh-key-to-your-github-account#adding-a-new-ssh-key-to-your-account)
- [GitLab](https://docs.gitlab.com/user/ssh/#add-an-ssh-key-to-your-gitlab-account) - [GitLab](https://docs.gitlab.com/user/ssh/#add-an-ssh-key-to-your-gitlab-account)
## PKCE Support
[PKCE (Proof Key for Code Exchange)](https://datatracker.ietf.org/doc/html/rfc7636) is an OAuth 2.0
security extension that prevents authorization code interception attacks. Coder supports PKCE when
acting as an OAuth client to external identity providers.
Coder will usually assume PKCE support is available with "S256" as the code challenge method. Manual
configuration is available to override any default behavior.
```env
# Enable PKCE with S256 (recommended when supported)
CODER_EXTERNAL_AUTH_0_PKCE_METHODS="S256"
# Disable PKCE entirely
CODER_EXTERNAL_AUTH_0_PKCE_METHODS="none"
```
## Git-provider specific env variables ## Git-provider specific env variables
### Azure DevOps ### Azure DevOps
+3
View File
@@ -262,6 +262,9 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
"app_installations_url": "string", "app_installations_url": "string",
"auth_url": "string", "auth_url": "string",
"client_id": "string", "client_id": "string",
"code_challenge_methods_supported": [
"string"
],
"device_code_url": "string", "device_code_url": "string",
"device_flow": true, "device_flow": true,
"display_icon": "string", "display_icon": "string",
+14 -1
View File
@@ -2952,6 +2952,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"app_installations_url": "string", "app_installations_url": "string",
"auth_url": "string", "auth_url": "string",
"client_id": "string", "client_id": "string",
"code_challenge_methods_supported": [
"string"
],
"device_code_url": "string", "device_code_url": "string",
"device_flow": true, "device_flow": true,
"display_icon": "string", "display_icon": "string",
@@ -3480,6 +3483,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"app_installations_url": "string", "app_installations_url": "string",
"auth_url": "string", "auth_url": "string",
"client_id": "string", "client_id": "string",
"code_challenge_methods_supported": [
"string"
],
"device_code_url": "string", "device_code_url": "string",
"device_flow": true, "device_flow": true,
"display_icon": "string", "display_icon": "string",
@@ -4219,6 +4225,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"app_installations_url": "string", "app_installations_url": "string",
"auth_url": "string", "auth_url": "string",
"client_id": "string", "client_id": "string",
"code_challenge_methods_supported": [
"string"
],
"device_code_url": "string", "device_code_url": "string",
"device_flow": true, "device_flow": true,
"display_icon": "string", "display_icon": "string",
@@ -4242,11 +4251,12 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
### Properties ### Properties
| Name | Type | Required | Restrictions | Description | | Name | Type | Required | Restrictions | Description |
|-------------------------|---------|----------|--------------|-----------------------------------------------------------------------------------------| |------------------------------------|-----------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------|
| `app_install_url` | string | false | | | | `app_install_url` | string | false | | |
| `app_installations_url` | string | false | | | | `app_installations_url` | string | false | | |
| `auth_url` | string | false | | | | `auth_url` | string | false | | |
| `client_id` | string | false | | | | `client_id` | string | false | | |
| `code_challenge_methods_supported` | array of string | false | | Code challenge methods supported lists the PKCE code challenge methods The only one supported by Coder is "S256". |
| `device_code_url` | string | false | | | | `device_code_url` | string | false | | |
| `device_flow` | boolean | false | | | | `device_flow` | boolean | false | | |
| `display_icon` | string | false | | Display icon is a URL to an icon to display in the UI. | | `display_icon` | string | false | | Display icon is a URL to an icon to display in the UI. |
@@ -14031,6 +14041,9 @@ None
"app_installations_url": "string", "app_installations_url": "string",
"auth_url": "string", "auth_url": "string",
"client_id": "string", "client_id": "string",
"code_challenge_methods_supported": [
"string"
],
"device_code_url": "string", "device_code_url": "string",
"device_flow": true, "device_flow": true,
"display_icon": "string", "display_icon": "string",
+14
View File
@@ -2004,6 +2004,11 @@ export interface ExternalAuthConfig {
* DisplayIcon is a URL to an icon to display in the UI. * DisplayIcon is a URL to an icon to display in the UI.
*/ */
readonly display_icon: string; readonly display_icon: string;
/**
* CodeChallengeMethodsSupported lists the PKCE code challenge methods
* The only one supported by Coder is "S256".
*/
readonly code_challenge_methods_supported: readonly string[];
} }
// From codersdk/externalauth.go // From codersdk/externalauth.go
@@ -2053,6 +2058,7 @@ export interface ExternalAuthLinkProvider {
readonly allow_refresh: boolean; readonly allow_refresh: boolean;
readonly allow_validate: boolean; readonly allow_validate: boolean;
readonly supports_revocation: boolean; readonly supports_revocation: boolean;
readonly code_challenge_methods_supported: readonly string[];
} }
// From codersdk/externalauth.go // From codersdk/externalauth.go
@@ -3050,6 +3056,14 @@ export interface OAuth2GithubConfig {
readonly enterprise_base_url: string; readonly enterprise_base_url: string;
} }
// From codersdk/client.go
/**
* OAuth2PKCEVerifier is the name of the cookie that stores the oauth2 PKCE
* verifier. This is the raw verifier that when hashed, will match the challenge
* sent in the initial oauth2 request.
*/
export const OAuth2PKCEVerifier = "oauth_pkce_verifier";
// From codersdk/oauth2.go // From codersdk/oauth2.go
/** /**
* OAuth2ProtectedResourceMetadata represents RFC 9728 OAuth 2.0 Protected Resource Metadata * OAuth2ProtectedResourceMetadata represents RFC 9728 OAuth 2.0 Protected Resource Metadata
@@ -27,6 +27,7 @@ const meta: Meta<typeof ExternalAuthSettingsPageView> = {
mcp_url: "", mcp_url: "",
mcp_tool_allow_regex: "", mcp_tool_allow_regex: "",
mcp_tool_deny_regex: "", mcp_tool_deny_regex: "",
code_challenge_methods_supported: ["S256"],
}, },
], ],
}, },
+1
View File
@@ -4503,6 +4503,7 @@ export const MockGithubExternalProvider: TypesGen.ExternalAuthLinkProvider = {
allow_refresh: true, allow_refresh: true,
allow_validate: true, allow_validate: true,
supports_revocation: false, supports_revocation: false,
code_challenge_methods_supported: ["S256"],
}; };
export const MockGithubAuthLink: TypesGen.ExternalAuthLink = { export const MockGithubAuthLink: TypesGen.ExternalAuthLink = {