diff --git a/connector/cas/cas.go b/connector/cas/cas.go new file mode 100644 index 0000000000..d1f8548694 --- /dev/null +++ b/connector/cas/cas.go @@ -0,0 +1,138 @@ +// Package cas provides authentication strategies using CAS. +package cas + +import ( + "fmt" + "log/slog" + "net/http" + "net/url" + + "github.com/dexidp/dex/connector" + "github.com/pkg/errors" + "gopkg.in/cas.v2" +) + +// Config holds configuration options for CAS logins. +type Config struct { + Portal string `json:"portal"` + Mapping map[string]string `json:"mapping"` +} + +// Open returns a strategy for logging in through CAS. +func (c *Config) Open(id string, logger *slog.Logger) (connector.Connector, error) { + casURL, err := url.Parse(c.Portal) + if err != nil { + return "", fmt.Errorf("failed to parse casURL %q: %v", c.Portal, err) + } + return &casConnector{ + client: http.DefaultClient, + portal: casURL, + mapping: c.Mapping, + logger: logger.With(slog.Group("connector", "type", "cas", "id", id)), + pathSuffix: "/" + id, + }, nil +} + +var ( + _ connector.CallbackConnector = (*casConnector)(nil) +) + +type casConnector struct { + client *http.Client + portal *url.URL + mapping map[string]string + logger *slog.Logger + pathSuffix string +} + +// LoginURL returns the URL to redirect the user to login with. +func (m *casConnector) LoginURL(s connector.Scopes, callbackURL, state string) (string, error) { + u, err := url.Parse(callbackURL) + if err != nil { + return "", fmt.Errorf("failed to parse callbackURL %q: %v", callbackURL, err) + } + u.Path += m.pathSuffix + // context = $callbackURL + $m.pathSuffix + v := u.Query() + v.Set("context", u.String()) // without query params + v.Set("state", state) + u.RawQuery = v.Encode() + + loginURL := *m.portal + loginURL.Path += "/login" + // service = $callbackURL + $m.pathSuffix ? state=$state & context=$callbackURL + $m.pathSuffix + q := loginURL.Query() + q.Set("service", u.String()) // service = ...?state=...&context=... + loginURL.RawQuery = q.Encode() + return loginURL.String(), nil +} + +// HandleCallback parses the request and returns the user's identity +func (m *casConnector) HandleCallback(s connector.Scopes, r *http.Request) (connector.Identity, error) { + + state := r.URL.Query().Get("state") + ticket := r.URL.Query().Get("ticket") + + // service=context = $callbackURL + $m.pathSuffix + serviceURL, err := url.Parse(r.URL.Query().Get("context")) + if err != nil { + return connector.Identity{}, fmt.Errorf("failed to parse serviceURL %q: %v", r.URL.Query().Get("ext"), err) + } + // service = $callbackURL + $m.pathSuffix ? state=$state & context=$callbackURL + $m.pathSuffix + q := serviceURL.Query() + q.Set("context", serviceURL.String()) + q.Set("state", state) + serviceURL.RawQuery = q.Encode() + + user, err := m.getCasUserByTicket(ticket, serviceURL) + if err != nil { + return connector.Identity{}, err + } + m.logger.Info("cas user", "user", user) + return user, nil +} + +func (m *casConnector) getCasUserByTicket(ticket string, serviceURL *url.URL) (id connector.Identity, err error) { + + var ( + resp *cas.AuthenticationResponse + ) + + // validate ticket + validator := cas.NewServiceTicketValidator(m.client, m.portal) + if resp, err = validator.ValidateTicket(serviceURL, ticket); err != nil { + err = errors.Wrapf(err, "failed to validate ticket via %q with ticket %q", serviceURL, ticket) + return + } + + // fill identity + id.UserID = resp.User + id.Groups = resp.MemberOf + if len(m.mapping) == 0 { + return + } + if username, ok := m.mapping["username"]; ok { + id.Username = resp.Attributes.Get(username) + if id.Username == "" && username == "userid" { + id.Username = resp.User + } + } + if preferredUsername, ok := m.mapping["preferred_username"]; ok { + id.PreferredUsername = resp.Attributes.Get(preferredUsername) + if id.PreferredUsername == "" && preferredUsername == "userid" { + id.PreferredUsername = resp.User + } + } + if email, ok := m.mapping["email"]; ok { + id.Email = resp.Attributes.Get(email) + if id.Email != "" { + id.EmailVerified = true + } + } + // override memberOf + if groups, ok := m.mapping["groups"]; ok { + id.Groups = resp.Attributes[groups] + } + return + +} diff --git a/connector/cas/cas_test.go b/connector/cas/cas_test.go new file mode 100644 index 0000000000..42dcbd7fc4 --- /dev/null +++ b/connector/cas/cas_test.go @@ -0,0 +1,194 @@ +package cas + +import ( + "fmt" + "log/slog" + "math/rand" + "net/http" + "net/url" + "os" + "reflect" + "testing" + "time" + + "github.com/dexidp/dex/connector" + "github.com/pkg/errors" + "gopkg.in/yaml.v3" +) + +type tcase struct { + xml string + mapping map[string]string + id connector.Identity + err string +} + +func TestOpen(t *testing.T) { + configSection := ` +portal: https://example.org/cas +mapping: + username: name + preferred_username: username + email: email + groups: affiliation +` + + var config Config + if err := yaml.Unmarshal([]byte(configSection), &config); err != nil { + t.Errorf("parse config: %v", err) + return + } + + conn, err := config.Open("cas", slog.Default()) + if err != nil { + t.Errorf("open connector: %v", err) + return + } + + casConnector, _ := conn.(*casConnector) + if casConnector.portal.String() != config.Portal { + t.Errorf("expected portal %q, got %q", config.Portal, casConnector.portal.String()) + return + } + if !reflect.DeepEqual(casConnector.mapping, config.Mapping) { + t.Errorf("expected mapping %v, got %v", config.Mapping, casConnector.mapping) + return + } +} + +func TestCAS(t *testing.T) { + + callback := "https://dex.example.org/dex/callback" + casURL, _ := url.Parse("https://example.org/cas") + scope := connector.Scopes{Groups: true} + + cases := []tcase{{ + xml: "testdata/cas_success.xml", + mapping: map[string]string{ + "username": "name", + "preferred_username": "username", + "email": "email", + }, + id: connector.Identity{ + UserID: "123456", + Username: "jdoe", + PreferredUsername: "jdoe", + Email: "jdoe@example.org", + EmailVerified: true, + Groups: []string{"A", "B"}, + ConnectorData: nil, + }, + err: "", + }, { + xml: "testdata/cas_success.xml", + mapping: map[string]string{ + "username": "name", + "preferred_username": "username", + "email": "email", + "groups": "affiliation", + }, + id: connector.Identity{ + UserID: "123456", + Username: "jdoe", + PreferredUsername: "jdoe", + Email: "jdoe@example.org", + EmailVerified: true, + Groups: []string{"staff", "faculty"}, + ConnectorData: nil, + }, + err: "", + }, { + xml: "testdata/cas_failure.xml", + mapping: map[string]string{}, + id: connector.Identity{}, + err: "INVALID_TICKET: Ticket ST-1856339-aA5Yuvrxzpv8Tau1cYQ7 not recognized", + }} + + seed := rand.NewSource(time.Now().UnixNano()) + for _, tc := range cases { + + ticket := fmt.Sprintf("ST-%d", seed.Int63()) + state := fmt.Sprintf("%d", seed.Int63()) + + conn := &casConnector{ + portal: casURL, + mapping: tc.mapping, + logger: slog.Default(), + pathSuffix: "/cas", + client: &http.Client{ + Transport: &mockTransport{ + ticket: ticket, + file: tc.xml, + }, + }, + } + + // login + login, err := conn.LoginURL(scope, callback, state) + if err != nil { + t.Errorf("get login url: %v", err) + return + } + loginURL, err := url.Parse(login) + if err != nil { + t.Errorf("parse login url: %v", err) + return + } + + // cas server + queryService := loginURL.Query().Get("service") + serviceURL, err := url.Parse(queryService) + if err != nil { + t.Errorf("parse service url: %v", err) + return + } + serviceQueryState := serviceURL.Query().Get("state") + if serviceQueryState != state { + t.Errorf("state: expected %#v, got %#v", state, serviceQueryState) + return + } + req, _ := http.NewRequest(http.MethodGet, queryService, nil) + q := req.URL.Query() + q.Set("ticket", ticket) + req.URL.RawQuery = q.Encode() + + // validate + id, err := conn.HandleCallback(scope, req) + if err != nil { + if c := errors.Cause(err); c != nil && tc.err != "" && c.Error() == tc.err { + continue + } + t.Errorf("handle callback: %v", err) + return + } + if !reflect.DeepEqual(id, tc.id) { + t.Errorf("identity: expected %#v, got %#v", tc.id, id) + return + } + } +} + +type mockTransport struct { + ticket string + file string +} + +func (f *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) { + file, err := os.Open(f.file) + if err != nil { + return nil, err + } + + if ticket := req.URL.Query().Get("ticket"); ticket != f.ticket { + return nil, fmt.Errorf("ticket: expected %#v, got %#v", f.ticket, ticket) + } + + return &http.Response{ + StatusCode: http.StatusOK, + Body: file, + Header: http.Header{ + "Content-Type": []string{"text/xml"}, + }, + Request: req, + }, nil +} diff --git a/connector/cas/testdata/cas_failure.xml b/connector/cas/testdata/cas_failure.xml new file mode 100644 index 0000000000..0e21ba8583 --- /dev/null +++ b/connector/cas/testdata/cas_failure.xml @@ -0,0 +1,5 @@ + + + Ticket ST-1856339-aA5Yuvrxzpv8Tau1cYQ7 not recognized + + \ No newline at end of file diff --git a/connector/cas/testdata/cas_success.xml b/connector/cas/testdata/cas_success.xml new file mode 100644 index 0000000000..560b5c20bc --- /dev/null +++ b/connector/cas/testdata/cas_success.xml @@ -0,0 +1,15 @@ + + + 123456 + + jdoe + jdoe + jdoe@example.org + staff + faculty + A + B + + PGTIOU-84678-8a9d... + + \ No newline at end of file diff --git a/go.mod b/go.mod index dfa9e39364..f8f70254b4 100644 --- a/go.mod +++ b/go.mod @@ -39,6 +39,8 @@ require ( google.golang.org/api v0.203.0 google.golang.org/grpc v1.67.1 google.golang.org/protobuf v1.35.1 + gopkg.in/cas.v2 v2.2.2 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -63,6 +65,7 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/inflect v0.19.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/glog v1.2.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-cmp v0.6.0 // indirect @@ -101,7 +104,6 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) replace github.com/dexidp/dex/api/v2 => ./api/v2 diff --git a/go.sum b/go.sum index e7a0ec0c64..a74b6bb6cf 100644 --- a/go.sum +++ b/go.sum @@ -90,6 +90,8 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/glog v1.2.2 h1:1+mZ9upx1Dh6FmUTFR1naJ77miKiXgALjWOZ3NVFPmY= +github.com/golang/glog v1.2.2/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -394,6 +396,8 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/cas.v2 v2.2.2 h1:teLr/JI7VDEQu6qkXKndYac9w5tfy57sWlV+eNYHH+o= +gopkg.in/cas.v2 v2.2.2/go.mod h1:mlmjh4qM/Jm3eSDD0QVr5GaaSW3nOonSUSWkLLvNYnI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/server/server.go b/server/server.go index 5c5faa3003..4a2e44bc01 100644 --- a/server/server.go +++ b/server/server.go @@ -32,6 +32,7 @@ import ( "github.com/dexidp/dex/connector/atlassiancrowd" "github.com/dexidp/dex/connector/authproxy" "github.com/dexidp/dex/connector/bitbucketcloud" + "github.com/dexidp/dex/connector/cas" "github.com/dexidp/dex/connector/gitea" "github.com/dexidp/dex/connector/github" "github.com/dexidp/dex/connector/gitlab" @@ -663,6 +664,7 @@ var ConnectorsConfig = map[string]func() ConnectorConfig{ "bitbucket-cloud": func() ConnectorConfig { return new(bitbucketcloud.Config) }, "openshift": func() ConnectorConfig { return new(openshift.Config) }, "atlassian-crowd": func() ConnectorConfig { return new(atlassiancrowd.Config) }, + "cas": func() ConnectorConfig { return new(cas.Config) }, // Keep around for backwards compatibility. "samlExperimental": func() ConnectorConfig { return new(saml.Config) }, }