diff --git a/coderd/coderdtest/oidctest/idp.go b/coderd/coderdtest/oidctest/idp.go index 807257ff18..86be0b6621 100644 --- a/coderd/coderdtest/oidctest/idp.go +++ b/coderd/coderdtest/oidctest/idp.go @@ -19,8 +19,6 @@ import ( "testing" "time" - "github.com/coder/coder/v2/coderd/util/syncmap" - "github.com/coreos/go-oidc/v3/oidc" "github.com/go-chi/chi/v5" "github.com/go-jose/go-jose/v3" @@ -34,22 +32,32 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/v2/coderd" + "github.com/coder/coder/v2/coderd/externalauth" + "github.com/coder/coder/v2/coderd/util/syncmap" "github.com/coder/coder/v2/codersdk" ) // FakeIDP is a functional OIDC provider. // It only supports 1 OIDC client. type FakeIDP struct { - issuer string - key *rsa.PrivateKey - provider ProviderJSON - handler http.Handler - cfg *oauth2.Config + issuer string + issuerURL *url.URL + key *rsa.PrivateKey + provider ProviderJSON + handler http.Handler + cfg *oauth2.Config // clientID to be used by coderd clientID string clientSecret string - logger slog.Logger + // externalProviderID is optional to match the provider in coderd for + // redirectURLs. + externalProviderID string + logger slog.Logger + // externalAuthValidate will be called when the user tries to validate their + // external auth. The fake IDP will reject any invalid tokens, so this just + // controls the response payload after a successfully authed token. + externalAuthValidate func(email string, rw http.ResponseWriter, r *http.Request) // These maps are used to control the state of the IDP. // That is the various access tokens, refresh tokens, states, etc. @@ -222,6 +230,7 @@ func (f *FakeIDP) updateIssuerURL(t testing.TB, issuer string) { require.NoError(t, err, "invalid issuer URL") f.issuer = issuer + f.issuerURL = u // ProviderJSON is the JSON representation of the OpenID Connect provider // These are all the urls that the IDP will respond to. f.provider = ProviderJSON{ @@ -347,6 +356,47 @@ func (f *FakeIDP) LoginWithClient(t testing.TB, client *codersdk.Client, idToken return user, res } +// ExternalLogin does the oauth2 flow for external auth providers. This requires +// an authenticated coder client. +func (f *FakeIDP) ExternalLogin(t testing.TB, client *codersdk.Client, opts ...func(r *http.Request)) { + coderOauthURL, err := client.URL.Parse(fmt.Sprintf("/external-auth/%s/callback", f.externalProviderID)) + require.NoError(t, err) + f.SetRedirect(t, coderOauthURL.String()) + + cli := f.HTTPClient(client.HTTPClient) + cli.CheckRedirect = func(req *http.Request, via []*http.Request) error { + // Store the idTokenClaims to the specific state request. This ties + // the claims 1:1 with a given authentication flow. + state := req.URL.Query().Get("state") + f.stateToIDTokenClaims.Store(state, jwt.MapClaims{}) + return nil + } + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + req, err := http.NewRequestWithContext(ctx, "GET", coderOauthURL.String(), nil) + require.NoError(t, err) + // External auth flow requires the user be authenticated. + headerName := client.SessionTokenHeader + if headerName == "" { + headerName = codersdk.SessionTokenHeader + } + req.Header.Set(headerName, client.SessionToken()) + if cli.Jar == nil { + cli.Jar, err = cookiejar.New(nil) + require.NoError(t, err, "failed to create cookie jar") + } + + for _, opt := range opts { + opt(req) + } + + res, err := cli.Do(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, res.StatusCode, "client failed to login") + _ = res.Body.Close() +} + // OIDCCallback will emulate the IDP redirecting back to the Coder callback. // This is helpful if no Coderd exists because the IDP needs to redirect to // something. @@ -640,7 +690,7 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler { _ = json.NewEncoder(rw).Encode(token) })) - mux.Handle(userInfoPath, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + validateMW := func(rw http.ResponseWriter, r *http.Request) (email string, ok bool) { token, err := f.authenticateBearerTokenRequest(t, r) f.logger.Info(r.Context(), "http call idp user info", slog.Error(err), @@ -648,15 +698,23 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler { ) if err != nil { http.Error(rw, fmt.Sprintf("invalid user info request: %s", err.Error()), http.StatusBadRequest) - return + return "", false } - email, ok := f.accessTokens.Load(token) + email, ok = f.accessTokens.Load(token) if !ok { t.Errorf("access token user for user_info has no email to indicate which user") http.Error(rw, "invalid access token, missing user info", http.StatusBadRequest) + return "", false + } + return email, true + } + mux.Handle(userInfoPath, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + email, ok := validateMW(rw, r) + if !ok { return } + claims, err := f.hookUserInfo(email) if err != nil { http.Error(rw, fmt.Sprintf("user info hook returned error: %s", err.Error()), httpErrorCode(http.StatusBadRequest, err)) @@ -665,6 +723,24 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler { _ = json.NewEncoder(rw).Encode(claims) })) + // There is almost no difference between this and /userinfo. + // The main tweak is that this route is "mounted" vs "handle" because "/userinfo" + // should be strict, and this one needs to handle sub routes. + mux.Mount("/external-auth-validate/", http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + email, ok := validateMW(rw, r) + if !ok { + return + } + + if f.externalAuthValidate == nil { + t.Errorf("missing external auth validate handler") + http.Error(rw, "missing external auth validate handler", http.StatusBadRequest) + return + } + + f.externalAuthValidate(email, rw, r) + })) + mux.Handle(keysPath, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { f.logger.Info(r.Context(), "http call idp /keys") set := jose.JSONWebKeySet{ @@ -767,6 +843,80 @@ func (f *FakeIDP) SetCoderdCallbackHandler(handler http.HandlerFunc) { }) } +// ExternalAuthConfigOptions exists to provide additional functionality ontop +// of the standard "validate" url. Some providers like github we actually parse +// the response from the validate URL to gain additional information. +type ExternalAuthConfigOptions struct { + // ValidatePayload is the payload that is used when the user calls the + // equivalent of "userinfo" for oauth2. This is not standardized, so is + // different for each provider type. + ValidatePayload func(email string) interface{} + + // routes is more advanced usage. This allows the caller to + // completely customize the response. It captures all routes under the /external-auth-validate/* + // so the caller can do whatever they want and even add routes. + routes map[string]func(email string, rw http.ResponseWriter, r *http.Request) +} + +func (o *ExternalAuthConfigOptions) AddRoute(route string, handle func(email string, rw http.ResponseWriter, r *http.Request)) *ExternalAuthConfigOptions { + if route == "/" || route == "" || route == "/user" { + panic("cannot override the /user route. Use ValidatePayload instead") + } + if !strings.HasPrefix(route, "/") { + route = "/" + route + } + if o.routes == nil { + o.routes = make(map[string]func(email string, rw http.ResponseWriter, r *http.Request)) + } + o.routes[route] = handle + return o +} + +// ExternalAuthConfig is the config for external auth providers. +func (f *FakeIDP) ExternalAuthConfig(t testing.TB, id string, custom *ExternalAuthConfigOptions, opts ...func(cfg *externalauth.Config)) *externalauth.Config { + if custom == nil { + custom = &ExternalAuthConfigOptions{} + } + f.externalProviderID = id + f.externalAuthValidate = func(email string, rw http.ResponseWriter, r *http.Request) { + newPath := strings.TrimPrefix(r.URL.Path, fmt.Sprintf("/external-auth-validate/%s", id)) + switch newPath { + // /user is ALWAYS supported under the `/` path too. + case "/user", "/", "": + var payload interface{} = "OK" + if custom.ValidatePayload != nil { + payload = custom.ValidatePayload(email) + } + _ = json.NewEncoder(rw).Encode(payload) + default: + if custom.routes == nil { + custom.routes = make(map[string]func(email string, rw http.ResponseWriter, r *http.Request)) + } + handle, ok := custom.routes[newPath] + if !ok { + t.Errorf("missing route handler for %s", newPath) + http.Error(rw, fmt.Sprintf("missing route handler for %s", newPath), http.StatusBadRequest) + return + } + handle(email, rw, r) + } + } + cfg := &externalauth.Config{ + OAuth2Config: f.OIDCConfig(t, nil), + ID: id, + // No defaults for these fields by omitting the type + Type: "", + DisplayIcon: f.WellknownConfig().UserInfoURL, + // Omit the /user for the validate so we can easily append to it when modifying + // the cfg for advanced tests. + ValidateURL: f.issuerURL.ResolveReference(&url.URL{Path: fmt.Sprintf("/external-auth-validate/%s", id)}).String(), + } + for _, opt := range opts { + opt(cfg) + } + return cfg +} + // OIDCConfig returns the OIDC config to use for Coderd. func (f *FakeIDP) OIDCConfig(t testing.TB, scopes []string, opts ...func(cfg *coderd.OIDCConfig)) *coderd.OIDCConfig { t.Helper() diff --git a/coderd/externalauth_test.go b/coderd/externalauth_test.go index 9ba18b2c0f..41ef1ac11d 100644 --- a/coderd/externalauth_test.go +++ b/coderd/externalauth_test.go @@ -17,6 +17,7 @@ import ( "golang.org/x/oauth2" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/coderdtest/oidctest" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/externalauth" "github.com/coder/coder/v2/coderd/httpapi" @@ -30,15 +31,18 @@ func TestExternalAuthByID(t *testing.T) { t.Parallel() t.Run("Unauthenticated", 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{{ - ID: "test", - OAuth2Config: &testutil.OAuth2Config{}, - Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), - }}, + ExternalAuthConfigs: []*externalauth.Config{ + fake.ExternalAuthConfig(t, providerID, nil, func(cfg *externalauth.Config) { + cfg.Type = codersdk.EnhancedExternalAuthProviderGitHub.String() + }), + }, }) coderdtest.CreateFirstUser(t, client) - auth, err := client.ExternalAuthByID(context.Background(), "test") + auth, err := client.ExternalAuthByID(context.Background(), providerID) require.NoError(t, err) require.False(t, auth.Authenticated) }) @@ -46,42 +50,49 @@ func TestExternalAuthByID(t *testing.T) { // Ensures that a provider that can't obtain a user can // still return that the provider is authenticated. t.Parallel() + const providerID = "fake-azure" + fake := oidctest.NewFakeIDP(t, oidctest.WithServing()) + client := coderdtest.New(t, &coderdtest.Options{ - ExternalAuthConfigs: []*externalauth.Config{{ - ID: "test", - OAuth2Config: &testutil.OAuth2Config{}, + ExternalAuthConfigs: []*externalauth.Config{ // AzureDevops doesn't have a user endpoint! - Type: codersdk.EnhancedExternalAuthProviderAzureDevops.String(), - }}, + fake.ExternalAuthConfig(t, providerID, nil, func(cfg *externalauth.Config) { + cfg.Type = codersdk.EnhancedExternalAuthProviderAzureDevops.String() + }), + }, }) + coderdtest.CreateFirstUser(t, client) - resp := coderdtest.RequestExternalAuthCallback(t, "test", client) - _ = resp.Body.Close() - auth, err := client.ExternalAuthByID(context.Background(), "test") + fake.ExternalLogin(t, client) + + auth, err := client.ExternalAuthByID(context.Background(), providerID) require.NoError(t, err) require.True(t, auth.Authenticated) }) t.Run("AuthenticatedWithUser", func(t *testing.T) { t.Parallel() - validateSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - httpapi.Write(r.Context(), w, http.StatusOK, github.User{ - Login: github.String("kyle"), - AvatarURL: github.String("https://avatars.githubusercontent.com/u/12345678?v=4"), - }) - })) - defer validateSrv.Close() + const providerID = "fake-github" + fake := oidctest.NewFakeIDP(t, oidctest.WithServing()) client := coderdtest.New(t, &coderdtest.Options{ - ExternalAuthConfigs: []*externalauth.Config{{ - ID: "test", - ValidateURL: validateSrv.URL, - OAuth2Config: &testutil.OAuth2Config{}, - Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), - }}, + ExternalAuthConfigs: []*externalauth.Config{ + fake.ExternalAuthConfig(t, providerID, &oidctest.ExternalAuthConfigOptions{ + ValidatePayload: func(_ string) interface{} { + return github.User{ + Login: github.String("kyle"), + AvatarURL: github.String("https://avatars.githubusercontent.com/u/12345678?v=4"), + } + }, + }, func(cfg *externalauth.Config) { + cfg.Type = codersdk.EnhancedExternalAuthProviderGitHub.String() + }), + }, }) + coderdtest.CreateFirstUser(t, client) - resp := coderdtest.RequestExternalAuthCallback(t, "test", client) - _ = resp.Body.Close() - auth, err := client.ExternalAuthByID(context.Background(), "test") + // Login to external auth provider + fake.ExternalLogin(t, client) + + auth, err := client.ExternalAuthByID(context.Background(), providerID) require.NoError(t, err) require.True(t, auth.Authenticated) require.NotNil(t, auth.User) @@ -89,40 +100,42 @@ func TestExternalAuthByID(t *testing.T) { }) t.Run("AuthenticatedWithInstalls", func(t *testing.T) { t.Parallel() - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/user": - httpapi.Write(r.Context(), w, http.StatusOK, github.User{ + const providerID = "fake-github" + fake := oidctest.NewFakeIDP(t, oidctest.WithServing()) + + // routes includes a route for /install that returns a list of installations + routes := (&oidctest.ExternalAuthConfigOptions{ + ValidatePayload: func(_ string) interface{} { + return github.User{ Login: github.String("kyle"), AvatarURL: github.String("https://avatars.githubusercontent.com/u/12345678?v=4"), - }) - case "/installs": - httpapi.Write(r.Context(), w, http.StatusOK, struct { - Installations []github.Installation `json:"installations"` - }{ - Installations: []github.Installation{{ - ID: github.Int64(12345678), - Account: &github.User{ - Login: github.String("coder"), - }, - }}, - }) - } - })) - defer srv.Close() - client := coderdtest.New(t, &coderdtest.Options{ - ExternalAuthConfigs: []*externalauth.Config{{ - ID: "test", - ValidateURL: srv.URL + "/user", - AppInstallationsURL: srv.URL + "/installs", - OAuth2Config: &testutil.OAuth2Config{}, - Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), - }}, + } + }, + }).AddRoute("/installs", func(_ string, rw http.ResponseWriter, r *http.Request) { + httpapi.Write(r.Context(), rw, http.StatusOK, struct { + Installations []github.Installation `json:"installations"` + }{ + Installations: []github.Installation{{ + ID: github.Int64(12345678), + Account: &github.User{ + Login: github.String("coder"), + }, + }}, + }) }) + client := coderdtest.New(t, &coderdtest.Options{ + ExternalAuthConfigs: []*externalauth.Config{ + fake.ExternalAuthConfig(t, providerID, routes, func(cfg *externalauth.Config) { + cfg.AppInstallationsURL = cfg.ValidateURL + "/installs" + cfg.Type = codersdk.EnhancedExternalAuthProviderGitHub.String() + }), + }, + }) + coderdtest.CreateFirstUser(t, client) - resp := coderdtest.RequestExternalAuthCallback(t, "test", client) - _ = resp.Body.Close() - auth, err := client.ExternalAuthByID(context.Background(), "test") + fake.ExternalLogin(t, client) + + auth, err := client.ExternalAuthByID(context.Background(), providerID) require.NoError(t, err) require.True(t, auth.Authenticated) require.NotNil(t, auth.User)