-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat!: support PKCE in the oauth2 client's auth/exchange flow #21215
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
08f52e9
40283ea
5eb6e9f
53fc864
ead92c8
0be0bb2
303ea30
7af6c3a
7f11ab7
65b08c8
c6b3b24
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,6 +24,7 @@ import ( | |
| "github.com/coder/coder/v2/coderd/oauth2provider" | ||
| "github.com/coder/coder/v2/coderd/pproflabel" | ||
| "github.com/coder/coder/v2/coderd/prebuilds" | ||
| "github.com/coder/coder/v2/coderd/promoauth" | ||
| "github.com/coder/coder/v2/coderd/usage" | ||
| "github.com/coder/coder/v2/coderd/wsbuilder" | ||
|
|
||
|
|
@@ -940,7 +941,7 @@ func New(options *Options) *API { | |
| r.Route(fmt.Sprintf("/%s/callback", externalAuthConfig.ID), func(r chi.Router) { | ||
| r.Use( | ||
| 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)) | ||
| }) | ||
|
|
@@ -1289,14 +1290,15 @@ func New(options *Options) *API { | |
| r.Get("/github/device", api.userOAuth2GithubDevice) | ||
| r.Route("/github", func(r chi.Router) { | ||
| r.Use( | ||
| httpmw.ExtractOAuth2(options.GithubOAuth2Config, options.HTTPClient, options.DeploymentValues.HTTPCookies, nil), | ||
| // Github supports PKCE S256 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need to call this out, link to docs? |
||
| httpmw.ExtractOAuth2(options.GithubOAuth2Config, options.HTTPClient, options.DeploymentValues.HTTPCookies, nil, []promoauth.Oauth2PKCEChallengeMethod{promoauth.PKCEChallengeMethodSha256}), | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: |
||
| ) | ||
| r.Get("/callback", api.userOAuth2Github) | ||
| }) | ||
| }) | ||
| r.Route("/oidc/callback", func(r chi.Router) { | ||
| 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) | ||
| }) | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -169,6 +169,7 @@ type FakeIDP struct { | |||||
| // clientID to be used by coderd | ||||||
| clientID string | ||||||
| clientSecret string | ||||||
| pkce bool // TODO: Implement for refresh token flow as well | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: |
||||||
| // externalProviderID is optional to match the provider in coderd for | ||||||
| // redirectURLs. | ||||||
| externalProviderID string | ||||||
|
|
@@ -181,6 +182,8 @@ type FakeIDP struct { | |||||
| // These maps are used to control the state of the IDP. | ||||||
| // That is the various access tokens, refresh tokens, states, etc. | ||||||
| codeToStateMap *syncmap.Map[string, string] | ||||||
| // Code -> PKCE Challenge | ||||||
| codeToChallengeMap *syncmap.Map[string, string] | ||||||
| // Token -> Email | ||||||
| accessTokens *syncmap.Map[string, token] | ||||||
| // Refresh Token -> Email | ||||||
|
|
@@ -239,6 +242,12 @@ func (s statusHookError) Error() string { | |||||
|
|
||||||
| 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) { | ||||||
| return func(f *FakeIDP) { | ||||||
| f.hookValidRedirectURL = hook | ||||||
|
|
@@ -450,6 +459,7 @@ func NewFakeIDP(t testing.TB, opts ...FakeIDPOpt) *FakeIDP { | |||||
| clientSecret: uuid.NewString(), | ||||||
| logger: slog.Make(), | ||||||
| codeToStateMap: syncmap.New[string, string](), | ||||||
| codeToChallengeMap: syncmap.New[string, string](), | ||||||
| accessTokens: syncmap.New[string, token](), | ||||||
| refreshTokens: syncmap.New[string, string](), | ||||||
| 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) { | ||||||
| state := uuid.NewString() | ||||||
| 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". | ||||||
|
|
@@ -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") | ||||||
| } | ||||||
|
|
||||||
| opts := []oauth2.AuthCodeOption{} | ||||||
| if f.pkce { | ||||||
| verifier := oauth2.GenerateVerifier() | ||||||
| opts = append(opts, oauth2.S256ChallengeOption(oauth2.S256ChallengeFromVerifier(verifier))) | ||||||
| } | ||||||
|
|
||||||
| f.stateToIDTokenClaims.Store(state, idTokenClaims) | ||||||
|
|
||||||
| cli := f.HTTPClient(nil) | ||||||
| u := f.locked.Config().AuthCodeURL(state) | ||||||
| u := f.locked.Config().AuthCodeURL(state, opts...) | ||||||
| req, err := http.NewRequest("GET", u, nil) | ||||||
| require.NoError(t, err) | ||||||
|
|
||||||
|
|
@@ -790,9 +814,10 @@ type ProviderJSON struct { | |||||
|
|
||||||
| // newCode enforces the code exchanged is actually a valid code | ||||||
| // created by the IDP. | ||||||
| func (f *FakeIDP) newCode(state string) string { | ||||||
| func (f *FakeIDP) newCode(state string, challenge string) string { | ||||||
| code := uuid.NewString() | ||||||
| f.codeToStateMap.Store(code, state) | ||||||
| f.codeToChallengeMap.Store(code, challenge) | ||||||
| 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) { | ||||||
| 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") | ||||||
| if !assert.Equal(t, f.clientID, clientID, "unexpected 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.Set("state", state) | ||||||
| q.Set("code", f.newCode(state)) | ||||||
| q.Set("code", f.newCode(state, challenge)) | ||||||
| ru.RawQuery = q.Encode() | ||||||
|
|
||||||
| http.Redirect(rw, r, ru.String(), http.StatusTemporaryRedirect) | ||||||
|
|
@@ -1009,6 +1050,21 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler { | |||||
| http.Error(rw, "invalid code", http.StatusBadRequest) | ||||||
| return | ||||||
| } | ||||||
|
|
||||||
| if f.pkce { | ||||||
| challenge, ok := f.codeToChallengeMap.Load(code) | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should also clean up this map after use. |
||||||
| if !ok { | ||||||
| httpError(rw, http.StatusBadRequest, xerrors.New("code challenge not found for code")) | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
This read a bit clearer to me, but if the original is more correct, keep it. Should we mention pkce here to give a hint |
||||||
| return | ||||||
| } | ||||||
| codeVerifier := values.Get("code_verifier") | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Empty handling? |
||||||
| expecter := oauth2.S256ChallengeFromVerifier(codeVerifier) | ||||||
| if challenge != expecter { | ||||||
| httpError(rw, http.StatusBadRequest, xerrors.New("invalid code verifier")) | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
? |
||||||
| return | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| // Always invalidate the code after it is used. | ||||||
| f.codeToStateMap.Delete(code) | ||||||
|
|
||||||
|
|
||||||
Uh oh!
There was an error while loading. Please reload this page.