diff --git a/Documentation/connectors/gitea.md b/Documentation/connectors/gitea.md new file mode 100644 index 00000000..455ef4ab --- /dev/null +++ b/Documentation/connectors/gitea.md @@ -0,0 +1,29 @@ +# Authentication through Gitea + +## Overview + +One of the login options for dex uses the Gitea OAuth2 flow to identify the end user through their Gitea account. + +When a client redeems a refresh token through dex, dex will re-query Gitea to update user information in the ID Token. To do this, __dex stores a readonly Gitea access token in its backing datastore.__ Users that reject dex's access through Gitea will also revoke all dex clients which authenticated them through Gitea. + +## Configuration + +Register a new OAuth consumer with [Gitea](https://docs.gitea.io/en-us/oauth2-provider/) ensuring the callback URL is `(dex issuer)/callback`. For example if dex is listening at the non-root path `https://auth.example.com/dex` the callback would be `https://auth.example.com/dex/callback`. + +The following is an example of a configuration for `examples/config-dev.yaml`: + +```yaml +connectors: +- type: gitea + # Required field for connector id. + id: gitea + # Required field for connector name. + name: Gitea + config: + # Credentials can be string literals or pulled from the environment. + clientID: $GITEA_CLIENT_ID + clientSecret: $GITEA_CLIENT_SECRET + redirectURI: http://127.0.0.1:5556/dex/callback + # optional, default = https://gitea.com + baseURL: https://gitea.com +``` diff --git a/README.md b/README.md index bbfb4f29..a4df3203 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ Dex implements the following connectors: | [Bitbucket Cloud](Documentation/connectors/bitbucketcloud.md) | yes | yes | no | alpha | | | [OpenShift](Documentation/connectors/openshift.md) | no | yes | no | stable | | | [Atlassian Crowd](Documentation/connectors/atlassiancrowd.md) | yes | yes | yes *) | beta | preferred_username claim must be configured through config | +| [Gitea](Documentation/connectors/gitea.md) | yes | no | yes | alpha | | Stable, beta, and alpha are defined as: diff --git a/connector/gitea/gitea.go b/connector/gitea/gitea.go new file mode 100644 index 00000000..c7da2f1c --- /dev/null +++ b/connector/gitea/gitea.go @@ -0,0 +1,266 @@ +// Package gitea provides authentication strategies using Gitea. +package gitea + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "strconv" + "sync" + "time" + + "golang.org/x/oauth2" + + "github.com/dexidp/dex/connector" + "github.com/dexidp/dex/pkg/log" +) + +// Config holds configuration options for gitea logins. +type Config struct { + BaseURL string `json:"baseURL"` + ClientID string `json:"clientID"` + ClientSecret string `json:"clientSecret"` + RedirectURI string `json:"redirectURI"` + UseLoginAsID bool `json:"useLoginAsID"` +} + +type giteaUser struct { + ID int `json:"id"` + Name string `json:"full_name"` + Username string `json:"login"` + Email string `json:"email"` + IsAdmin bool `json:"is_admin"` +} + +// Open returns a strategy for logging in through GitLab. +func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) { + if c.BaseURL == "" { + c.BaseURL = "https://gitea.com" + } + return &giteaConnector{ + baseURL: c.BaseURL, + redirectURI: c.RedirectURI, + clientID: c.ClientID, + clientSecret: c.ClientSecret, + logger: logger, + useLoginAsID: c.UseLoginAsID, + }, nil +} + +type connectorData struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + Expiry time.Time `json:"expiry"` +} + +var ( + _ connector.CallbackConnector = (*giteaConnector)(nil) + _ connector.RefreshConnector = (*giteaConnector)(nil) +) + +type giteaConnector struct { + baseURL string + redirectURI string + clientID string + clientSecret string + logger log.Logger + httpClient *http.Client + // if set to true will use the user's handle rather than their numeric id as the ID + useLoginAsID bool +} + +func (c *giteaConnector) oauth2Config(scopes connector.Scopes) *oauth2.Config { + giteaEndpoint := oauth2.Endpoint{AuthURL: c.baseURL + "/login/oauth/authorize", TokenURL: c.baseURL + "/login/oauth/access_token"} + return &oauth2.Config{ + ClientID: c.clientID, + ClientSecret: c.clientSecret, + Endpoint: giteaEndpoint, + RedirectURL: c.redirectURI, + } +} + +func (c *giteaConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) { + if c.redirectURI != callbackURL { + return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", c.redirectURI, callbackURL) + } + return c.oauth2Config(scopes).AuthCodeURL(state), nil +} + +type oauth2Error struct { + error string + errorDescription string +} + +func (e *oauth2Error) Error() string { + if e.errorDescription == "" { + return e.error + } + return e.error + ": " + e.errorDescription +} + +func (c *giteaConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) { + q := r.URL.Query() + if errType := q.Get("error"); errType != "" { + return identity, &oauth2Error{errType, q.Get("error_description")} + } + + oauth2Config := c.oauth2Config(s) + + ctx := r.Context() + if c.httpClient != nil { + ctx = context.WithValue(r.Context(), oauth2.HTTPClient, c.httpClient) + } + + token, err := oauth2Config.Exchange(ctx, q.Get("code")) + if err != nil { + return identity, fmt.Errorf("gitea: failed to get token: %v", err) + } + + client := oauth2Config.Client(ctx, token) + + user, err := c.user(ctx, client) + if err != nil { + return identity, fmt.Errorf("gitea: get user: %v", err) + } + + username := user.Name + if username == "" { + username = user.Email + } + identity = connector.Identity{ + UserID: strconv.Itoa(user.ID), + Username: username, + PreferredUsername: user.Username, + Email: user.Email, + EmailVerified: true, + } + if c.useLoginAsID { + identity.UserID = user.Username + } + + if s.OfflineAccess { + data := connectorData{ + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + Expiry: token.Expiry, + } + connData, err := json.Marshal(data) + if err != nil { + return identity, fmt.Errorf("gitea: marshal connector data: %v", err) + } + identity.ConnectorData = connData + } + + return identity, nil +} + +// Refreshing tokens +// https://github.com/golang/oauth2/issues/84#issuecomment-332860871 +type tokenNotifyFunc func(*oauth2.Token) error + +// notifyRefreshTokenSource is essentially `oauth2.ReuseTokenSource` with `TokenNotifyFunc` added. +type notifyRefreshTokenSource struct { + new oauth2.TokenSource + mu sync.Mutex // guards t + t *oauth2.Token + f tokenNotifyFunc // called when token refreshed so new refresh token can be persisted +} + +// Token returns the current token if it's still valid, else will +// refresh the current token (using r.Context for HTTP client +// information) and return the new one. +func (s *notifyRefreshTokenSource) Token() (*oauth2.Token, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.t.Valid() { + return s.t, nil + } + t, err := s.new.Token() + if err != nil { + return nil, err + } + s.t = t + return t, s.f(t) +} + +func (c *giteaConnector) Refresh(ctx context.Context, s connector.Scopes, ident connector.Identity) (connector.Identity, error) { + if len(ident.ConnectorData) == 0 { + return ident, errors.New("gitea: no upstream access token found") + } + + var data connectorData + if err := json.Unmarshal(ident.ConnectorData, &data); err != nil { + return ident, fmt.Errorf("gitea: unmarshal access token: %v", err) + } + + tok := &oauth2.Token{ + AccessToken: data.AccessToken, + RefreshToken: data.RefreshToken, + Expiry: data.Expiry, + } + + client := oauth2.NewClient(ctx, ¬ifyRefreshTokenSource{ + new: c.oauth2Config(s).TokenSource(ctx, tok), + t: tok, + f: func(tok *oauth2.Token) error { + data := connectorData{ + AccessToken: tok.AccessToken, + RefreshToken: tok.RefreshToken, + Expiry: tok.Expiry, + } + connData, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("gitea: marshal connector data: %v", err) + } + ident.ConnectorData = connData + return nil + }, + }) + user, err := c.user(ctx, client) + if err != nil { + return ident, fmt.Errorf("gitea: get user: %v", err) + } + + username := user.Name + if username == "" { + username = user.Email + } + ident.Username = username + ident.PreferredUsername = user.Username + ident.Email = user.Email + + return ident, nil +} + +// user queries the Gitea API for profile information using the provided client. The HTTP +// client is expected to be constructed by the golang.org/x/oauth2 package, which inserts +// a bearer token as part of the request. +func (c *giteaConnector) user(ctx context.Context, client *http.Client) (giteaUser, error) { + var u giteaUser + req, err := http.NewRequest("GET", c.baseURL+"/api/v1/user", nil) + if err != nil { + return u, fmt.Errorf("gitea: new req: %v", err) + } + req = req.WithContext(ctx) + resp, err := client.Do(req) + if err != nil { + return u, fmt.Errorf("gitea: get URL %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return u, fmt.Errorf("gitea: read body: %v", err) + } + return u, fmt.Errorf("%s: %s", resp.Status, body) + } + + if err := json.NewDecoder(resp.Body).Decode(&u); err != nil { + return u, fmt.Errorf("failed to decode response: %v", err) + } + return u, nil +} diff --git a/connector/gitea/gitea_test.go b/connector/gitea/gitea_test.go new file mode 100644 index 00000000..a71d7995 --- /dev/null +++ b/connector/gitea/gitea_test.go @@ -0,0 +1,72 @@ +package gitea + +import ( + "crypto/tls" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "testing" + + "github.com/dexidp/dex/connector" +) + +// tests that the email is used as their username when they have no username set +func TestUsernameIncludedInFederatedIdentity(t *testing.T) { + s := newTestServer(map[string]interface{}{ + "/api/v1/user": giteaUser{Email: "some@email.com", ID: 12345678}, + "/login/oauth/access_token": map[string]interface{}{ + "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9", + "expires_in": "30", + }, + }) + defer s.Close() + + hostURL, err := url.Parse(s.URL) + expectNil(t, err) + + req, err := http.NewRequest("GET", hostURL.String(), nil) + expectNil(t, err) + + c := giteaConnector{baseURL: s.URL, httpClient: newClient()} + identity, err := c.HandleCallback(connector.Scopes{}, req) + + expectNil(t, err) + expectEquals(t, identity.Username, "some@email.com") + expectEquals(t, identity.UserID, "12345678") + + c = giteaConnector{baseURL: s.URL, httpClient: newClient()} + identity, err = c.HandleCallback(connector.Scopes{}, req) + + expectNil(t, err) + expectEquals(t, identity.Username, "some@email.com") + expectEquals(t, identity.UserID, "12345678") +} + +func newTestServer(responses map[string]interface{}) *httptest.Server { + return httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := responses[r.RequestURI] + w.Header().Add("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + })) +} + +func newClient() *http.Client { + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + return &http.Client{Transport: tr} +} + +func expectNil(t *testing.T, a interface{}) { + if a != nil { + t.Errorf("Expected %+v to equal nil", a) + } +} + +func expectEquals(t *testing.T, a interface{}, b interface{}) { + if !reflect.DeepEqual(a, b) { + t.Errorf("Expected %+v to equal %+v", a, b) + } +} diff --git a/server/server.go b/server/server.go index 09292b16..a0a075fb 100644 --- a/server/server.go +++ b/server/server.go @@ -24,6 +24,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/gitea" "github.com/dexidp/dex/connector/github" "github.com/dexidp/dex/connector/gitlab" "github.com/dexidp/dex/connector/google" @@ -468,6 +469,7 @@ var ConnectorsConfig = map[string]func() ConnectorConfig{ "mockCallback": func() ConnectorConfig { return new(mock.CallbackConfig) }, "mockPassword": func() ConnectorConfig { return new(mock.PasswordConfig) }, "ldap": func() ConnectorConfig { return new(ldap.Config) }, + "gitea": func() ConnectorConfig { return new(gitea.Config) }, "github": func() ConnectorConfig { return new(github.Config) }, "gitlab": func() ConnectorConfig { return new(gitlab.Config) }, "google": func() ConnectorConfig { return new(google.Config) },