connector: add GitLab connector
This commit is contained in:
parent
48fcf66a35
commit
e623ad4d35
5 changed files with 377 additions and 0 deletions
29
Documentation/gitlab-connector.md
Normal file
29
Documentation/gitlab-connector.md
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
# Authentication through Gitlab
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
GitLab is a web-based Git repository manager with wiki and issue tracking features, using an open source license, developed by GitLab Inc. One of the login options for dex uses the GitLab OAuth2 flow to identify the end user through their GitLab account. You can use this option with [gitlab.com](gitlab.com), GitLab community or enterprise edition.
|
||||||
|
|
||||||
|
When a client redeems a refresh token through dex, dex will re-query GitLab to update user information in the ID Token. To do this, __dex stores a readonly GitLab access token in its backing datastore.__ Users that reject dex's access through GitLab will also revoke all dex clients which authenticated them through GitLab.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Register a new application via `User Settings -> Applications` 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: gitlab
|
||||||
|
# Required field for connector id.
|
||||||
|
id: gitlab
|
||||||
|
# Required field for connector name.
|
||||||
|
name: GitLab
|
||||||
|
config:
|
||||||
|
# optional, default = https://www.gitlab.com
|
||||||
|
baseURL: https://www.gitlab.com
|
||||||
|
# Credentials can be string literals or pulled from the environment.
|
||||||
|
clientID: $GITLAB_APPLICATION_ID
|
||||||
|
clientSecret: $GITLAB_CLIENT_SECRET
|
||||||
|
redirectURI: http://127.0.0.1:5556/dex/callback
|
||||||
|
```
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"github.com/Sirupsen/logrus"
|
"github.com/Sirupsen/logrus"
|
||||||
"github.com/coreos/dex/connector"
|
"github.com/coreos/dex/connector"
|
||||||
"github.com/coreos/dex/connector/github"
|
"github.com/coreos/dex/connector/github"
|
||||||
|
"github.com/coreos/dex/connector/gitlab"
|
||||||
"github.com/coreos/dex/connector/ldap"
|
"github.com/coreos/dex/connector/ldap"
|
||||||
"github.com/coreos/dex/connector/mock"
|
"github.com/coreos/dex/connector/mock"
|
||||||
"github.com/coreos/dex/connector/oidc"
|
"github.com/coreos/dex/connector/oidc"
|
||||||
|
@ -182,6 +183,7 @@ var connectors = map[string]func() ConnectorConfig{
|
||||||
"mockPassword": func() ConnectorConfig { return new(mock.PasswordConfig) },
|
"mockPassword": func() ConnectorConfig { return new(mock.PasswordConfig) },
|
||||||
"ldap": func() ConnectorConfig { return new(ldap.Config) },
|
"ldap": func() ConnectorConfig { return new(ldap.Config) },
|
||||||
"github": func() ConnectorConfig { return new(github.Config) },
|
"github": func() ConnectorConfig { return new(github.Config) },
|
||||||
|
"gitlab": func() ConnectorConfig { return new(gitlab.Config) },
|
||||||
"oidc": func() ConnectorConfig { return new(oidc.Config) },
|
"oidc": func() ConnectorConfig { return new(oidc.Config) },
|
||||||
"samlExperimental": func() ConnectorConfig { return new(saml.Config) },
|
"samlExperimental": func() ConnectorConfig { return new(saml.Config) },
|
||||||
}
|
}
|
||||||
|
|
288
connector/gitlab/gitlab.go
Normal file
288
connector/gitlab/gitlab.go
Normal file
|
@ -0,0 +1,288 @@
|
||||||
|
// Package gitlab provides authentication strategies using Gitlab.
|
||||||
|
package gitlab
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/Sirupsen/logrus"
|
||||||
|
"github.com/coreos/dex/connector"
|
||||||
|
"golang.org/x/net/context"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
scopeEmail = "user:email"
|
||||||
|
scopeOrgs = "read:org"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds configuration options for gilab logins.
|
||||||
|
type Config struct {
|
||||||
|
BaseURL string `json:"baseURL"`
|
||||||
|
ClientID string `json:"clientID"`
|
||||||
|
ClientSecret string `json:"clientSecret"`
|
||||||
|
RedirectURI string `json:"redirectURI"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type gitlabUser struct {
|
||||||
|
ID int
|
||||||
|
Name string
|
||||||
|
Username string
|
||||||
|
State string
|
||||||
|
Email string
|
||||||
|
IsAdmin bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type gitlabGroup struct {
|
||||||
|
ID int
|
||||||
|
Name string
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open returns a strategy for logging in through GitLab.
|
||||||
|
func (c *Config) Open(logger logrus.FieldLogger) (connector.Connector, error) {
|
||||||
|
if c.BaseURL == "" {
|
||||||
|
c.BaseURL = "https://www.gitlab.com"
|
||||||
|
}
|
||||||
|
return &gitlabConnector{
|
||||||
|
baseURL: c.BaseURL,
|
||||||
|
redirectURI: c.RedirectURI,
|
||||||
|
clientID: c.ClientID,
|
||||||
|
clientSecret: c.ClientSecret,
|
||||||
|
logger: logger,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type connectorData struct {
|
||||||
|
// GitLab's OAuth2 tokens never expire. We don't need a refresh token.
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ connector.CallbackConnector = (*gitlabConnector)(nil)
|
||||||
|
_ connector.RefreshConnector = (*gitlabConnector)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
type gitlabConnector struct {
|
||||||
|
baseURL string
|
||||||
|
redirectURI string
|
||||||
|
org string
|
||||||
|
clientID string
|
||||||
|
clientSecret string
|
||||||
|
logger logrus.FieldLogger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *gitlabConnector) oauth2Config(scopes connector.Scopes) *oauth2.Config {
|
||||||
|
gitlabScopes := []string{"api"}
|
||||||
|
gitlabEndpoint := oauth2.Endpoint{AuthURL: c.baseURL + "/oauth/authorize", TokenURL: c.baseURL + "/oauth/token"}
|
||||||
|
return &oauth2.Config{
|
||||||
|
ClientID: c.clientID,
|
||||||
|
ClientSecret: c.clientSecret,
|
||||||
|
Endpoint: gitlabEndpoint,
|
||||||
|
Scopes: gitlabScopes,
|
||||||
|
RedirectURL: c.redirectURI,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *gitlabConnector) 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 *gitlabConnector) 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()
|
||||||
|
|
||||||
|
token, err := oauth2Config.Exchange(ctx, q.Get("code"))
|
||||||
|
if err != nil {
|
||||||
|
return identity, fmt.Errorf("gitlab: failed to get token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := oauth2Config.Client(ctx, token)
|
||||||
|
|
||||||
|
user, err := c.user(ctx, client)
|
||||||
|
if err != nil {
|
||||||
|
return identity, fmt.Errorf("gitlab: get user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
username := user.Name
|
||||||
|
if username == "" {
|
||||||
|
username = user.Email
|
||||||
|
}
|
||||||
|
identity = connector.Identity{
|
||||||
|
UserID: strconv.Itoa(user.ID),
|
||||||
|
Username: username,
|
||||||
|
Email: user.Email,
|
||||||
|
EmailVerified: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.Groups {
|
||||||
|
groups, err := c.groups(ctx, client)
|
||||||
|
if err != nil {
|
||||||
|
return identity, fmt.Errorf("gitlab: get groups: %v", err)
|
||||||
|
}
|
||||||
|
identity.Groups = groups
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.OfflineAccess {
|
||||||
|
data := connectorData{AccessToken: token.AccessToken}
|
||||||
|
connData, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return identity, fmt.Errorf("marshal connector data: %v", err)
|
||||||
|
}
|
||||||
|
identity.ConnectorData = connData
|
||||||
|
}
|
||||||
|
|
||||||
|
return identity, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *gitlabConnector) Refresh(ctx context.Context, s connector.Scopes, ident connector.Identity) (connector.Identity, error) {
|
||||||
|
if len(ident.ConnectorData) == 0 {
|
||||||
|
return ident, errors.New("no upstream access token found")
|
||||||
|
}
|
||||||
|
|
||||||
|
var data connectorData
|
||||||
|
if err := json.Unmarshal(ident.ConnectorData, &data); err != nil {
|
||||||
|
return ident, fmt.Errorf("gitlab: unmarshal access token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := c.oauth2Config(s).Client(ctx, &oauth2.Token{AccessToken: data.AccessToken})
|
||||||
|
user, err := c.user(ctx, client)
|
||||||
|
if err != nil {
|
||||||
|
return ident, fmt.Errorf("gitlab: get user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
username := user.Name
|
||||||
|
if username == "" {
|
||||||
|
username = user.Email
|
||||||
|
}
|
||||||
|
ident.Username = username
|
||||||
|
ident.Email = user.Email
|
||||||
|
|
||||||
|
if s.Groups {
|
||||||
|
groups, err := c.groups(ctx, client)
|
||||||
|
if err != nil {
|
||||||
|
return ident, fmt.Errorf("gitlab: get groups: %v", err)
|
||||||
|
}
|
||||||
|
ident.Groups = groups
|
||||||
|
}
|
||||||
|
return ident, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// user queries the GitLab 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 *gitlabConnector) user(ctx context.Context, client *http.Client) (gitlabUser, error) {
|
||||||
|
var u gitlabUser
|
||||||
|
req, err := http.NewRequest("GET", c.baseURL+"/api/v3/user", nil)
|
||||||
|
if err != nil {
|
||||||
|
return u, fmt.Errorf("gitlab: new req: %v", err)
|
||||||
|
}
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return u, fmt.Errorf("gitlab: 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("gitlab: 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// groups queries the GitLab API for group membership.
|
||||||
|
//
|
||||||
|
// The HTTP passed 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 *gitlabConnector) groups(ctx context.Context, client *http.Client) ([]string, error) {
|
||||||
|
|
||||||
|
apiURL := c.baseURL + "/api/v3/groups"
|
||||||
|
|
||||||
|
reNext := regexp.MustCompile("<(.*)>; rel=\"next\"")
|
||||||
|
reLast := regexp.MustCompile("<(.*)>; rel=\"last\"")
|
||||||
|
|
||||||
|
groups := []string{}
|
||||||
|
var gitlabGroups []gitlabGroup
|
||||||
|
for {
|
||||||
|
// 100 is the maximum number for per_page that allowed by gitlab
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gitlab: new req: %v", err)
|
||||||
|
}
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gitlab: get groups: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, err := ioutil.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gitlab: read body: %v", err)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("%s: %s", resp.Status, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&gitlabGroups); err != nil {
|
||||||
|
return nil, fmt.Errorf("gitlab: unmarshal groups: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, group := range gitlabGroups {
|
||||||
|
groups = append(groups, group.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
link := resp.Header.Get("Link")
|
||||||
|
|
||||||
|
if len(reLast.FindStringSubmatch(link)) > 1 {
|
||||||
|
lastPageURL := reLast.FindStringSubmatch(link)[1]
|
||||||
|
|
||||||
|
if apiURL == lastPageURL {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(reNext.FindStringSubmatch(link)) > 1 {
|
||||||
|
apiURL = reNext.FindStringSubmatch(link)[1]
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
return groups, nil
|
||||||
|
}
|
53
web/static/img/gitlab-icon.svg
Normal file
53
web/static/img/gitlab-icon.svg
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg width="500px" height="500px" viewBox="0 0 500 500" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
|
||||||
|
<!-- Generator: Sketch 3.2.2 (9983) - http://www.bohemiancoding.com/sketch -->
|
||||||
|
<title>logo-square</title>
|
||||||
|
<desc>Created with Sketch.</desc>
|
||||||
|
<defs></defs>
|
||||||
|
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
|
||||||
|
<g id="logo-square" sketch:type="MSArtboardGroup">
|
||||||
|
<g id="logo-no-bg" sketch:type="MSLayerGroup" transform="translate(2.000000, 19.000000)">
|
||||||
|
<g id="Page-1" sketch:type="MSShapeGroup">
|
||||||
|
<g id="gitlab_logo">
|
||||||
|
<g id="g10" transform="translate(248.000000, 228.833300) scale(1, -1) translate(-248.000000, -228.833300) translate(0.000000, 0.333300)">
|
||||||
|
<g id="g16">
|
||||||
|
<g id="g18-Clipped">
|
||||||
|
<g id="g18">
|
||||||
|
<g>
|
||||||
|
<g id="Group" transform="translate(0.666658, 0.666658)">
|
||||||
|
<g id="g44" transform="translate(0.532000, 0.774933)" fill="#FC6D26">
|
||||||
|
<path d="M491.999988,194.666662 L464.441322,279.481326 L409.82399,447.578655 C407.014656,456.226655 394.778657,456.226655 391.96799,447.578655 L337.349325,279.481326 L155.982663,279.481326 L101.362664,447.578655 C98.5533309,456.226655 86.3173312,456.226655 83.5066646,447.578655 L28.8893326,279.481326 L1.33199997,194.666662 C-1.18266664,186.930662 1.57199996,178.455996 8.1519998,173.674662 L246.665327,0.385333324 L485.179988,173.674662 C491.759988,178.455996 494.513321,186.930662 491.999988,194.666662" id="path46"></path>
|
||||||
|
</g>
|
||||||
|
<g id="g48" transform="translate(156.197863, 1.160267)" fill="#E24329">
|
||||||
|
<path d="M90.9999977,0 L90.9999977,0 L181.683995,279.095993 L0.31599997,279.095993 L90.9999977,0 L90.9999977,0 Z" id="path50"></path>
|
||||||
|
</g>
|
||||||
|
<g id="g56" transform="translate(28.531199, 1.160800)" fill="#FC6D26">
|
||||||
|
<path d="M218.666661,0 L127.982663,279.09466 L0.890666644,279.09466 L218.666661,0 L218.666661,0 Z" id="path58"></path>
|
||||||
|
</g>
|
||||||
|
<g id="g64" transform="translate(0.088533, 0.255867)" fill="#FCA326">
|
||||||
|
<path d="M29.3333326,279.999993 L29.3333326,279.999993 L1.77466662,195.185328 C-0.738666648,187.449329 2.01466662,178.974662 8.59599979,174.194662 L247.109327,0.905333311 L29.3333326,279.999993 L29.3333326,279.999993 Z" id="path66"></path>
|
||||||
|
</g>
|
||||||
|
<g id="g72" transform="translate(29.421866, 280.255593)" fill="#E24329">
|
||||||
|
<path d="M0,0 L127.091997,0 L72.4733315,168.097329 C69.6626649,176.746662 57.4266652,176.746662 54.617332,168.097329 L0,0 L0,0 Z" id="path74"></path>
|
||||||
|
</g>
|
||||||
|
<g id="g76" transform="translate(247.197860, 1.160800)" fill="#FC6D26">
|
||||||
|
<path d="M0,0 L90.6839977,279.09466 L217.775995,279.09466 L0,0 L0,0 Z" id="path78"></path>
|
||||||
|
</g>
|
||||||
|
<g id="g80" transform="translate(246.307061, 0.255867)" fill="#FCA326">
|
||||||
|
<path d="M218.666661,279.999993 L218.666661,279.999993 L246.225327,195.185328 C248.73866,187.449329 245.985327,178.974662 239.403994,174.194662 L0.890666644,0.905333311 L218.666661,279.999993 L218.666661,279.999993 Z" id="path82"></path>
|
||||||
|
</g>
|
||||||
|
<g id="g84" transform="translate(336.973725, 280.255593)" fill="#E24329">
|
||||||
|
<path d="M127.999997,0 L0.907999977,0 L55.5266653,168.097329 C58.3373319,176.746662 70.5733316,176.746662 73.3826648,168.097329 L127.999997,0 L127.999997,0 Z" id="path86"></path>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 4.9 KiB |
|
@ -62,6 +62,11 @@ body {
|
||||||
background-image: url(../static/img/github-icon.svg);
|
background-image: url(../static/img/github-icon.svg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dex-btn-icon--gitlab {
|
||||||
|
background-image: url(../static/img/gitlab-icon.svg);
|
||||||
|
background-size: contain;
|
||||||
|
}
|
||||||
|
|
||||||
.dex-btn-icon--bitbucket {
|
.dex-btn-icon--bitbucket {
|
||||||
background-color: #205081;
|
background-color: #205081;
|
||||||
background-image: url(../static/img/bitbucket-icon.svg);
|
background-image: url(../static/img/bitbucket-icon.svg);
|
||||||
|
|
Reference in a new issue