-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* Add Gitea connector * Add details to readme * resolve lint issue
- Loading branch information
1 parent
709d416
commit 0a9f565
Showing
5 changed files
with
370 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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: "[email protected]", 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, "[email protected]") | ||
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, "[email protected]") | ||
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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters