diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index c565b5dbe..a07032de4 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -86,6 +86,7 @@ module.exports = { "identity-providers/", "identity-providers/azure", "identity-providers/cognito", + "identity-providers/gitlab", "identity-providers/google", "identity-providers/okta", "identity-providers/one-login" diff --git a/docs/docs/identity-providers/gitlab.md b/docs/docs/identity-providers/gitlab.md new file mode 100644 index 000000000..e19e0abd0 --- /dev/null +++ b/docs/docs/identity-providers/gitlab.md @@ -0,0 +1,41 @@ +--- +title: GitLab +lang: en-US +sidebarDepth: 0 +meta: + - name: keywords + content: gitlab oidc openid-connect identity-provider +--- + +# GitLab + +Log in to your GitLab account or create one [here](https://gitlab.com/users/sign_in) + +Go to the user settings which can be found in the user profile to [create an application](https://gitlab.com/profile/applications) where you will get your app credentials + +![create an application](./img/gitlab/gitlab-create-applications.png) + +On the **Applications** page, add a new application by setting the following parameters: + +Field | Description +------------ | -------------------------------------------- +Name | The name of your web app +Redirect URI | `https://${authenticate_service_url}/oauth2/callback` +Scopes | **Must** select **read_user** and **openid** + +Your `Client ID` and `Client Secret` will be displayed: + +![Gitlab OAuth Client ID and Secret](./img/gitlab/gitlab-credentials.png) + +Set `Client ID` and `Client Secret` in Pomerium's settings. Your [environmental variables] should look something like this. + +```bash +authenticate_service_url: https://authenticate.localhost.pomerium.io +idp_provider: "gitlab" +idp_client_id: "REDACTED" // gitlab application ID +idp_client_secret: "REDACTED" // gitlab application secret +``` + +When a user first uses pomerium to login, they will be presented with an authorization screen similar to the following depending on the scope parameters setup. + +![gitlab access authorization screen](./img/gitlab/gitlab-verify-access.png) \ No newline at end of file diff --git a/docs/docs/identity-providers/img/gitlab-credentials.png b/docs/docs/identity-providers/img/gitlab-credentials.png deleted file mode 100644 index b22a074da..000000000 Binary files a/docs/docs/identity-providers/img/gitlab-credentials.png and /dev/null differ diff --git a/docs/docs/identity-providers/img/gitlab-create-application.png b/docs/docs/identity-providers/img/gitlab/gitlab-create-application.png similarity index 100% rename from docs/docs/identity-providers/img/gitlab-create-application.png rename to docs/docs/identity-providers/img/gitlab/gitlab-create-application.png diff --git a/docs/docs/identity-providers/img/gitlab/gitlab-create-applications.png b/docs/docs/identity-providers/img/gitlab/gitlab-create-applications.png new file mode 100644 index 000000000..b22280cb8 Binary files /dev/null and b/docs/docs/identity-providers/img/gitlab/gitlab-create-applications.png differ diff --git a/docs/docs/identity-providers/img/gitlab/gitlab-credentials.png b/docs/docs/identity-providers/img/gitlab/gitlab-credentials.png new file mode 100644 index 000000000..cfad8f4a4 Binary files /dev/null and b/docs/docs/identity-providers/img/gitlab/gitlab-credentials.png differ diff --git a/docs/docs/identity-providers/img/gitlab-verify-access.png b/docs/docs/identity-providers/img/gitlab/gitlab-verify-access.png similarity index 100% rename from docs/docs/identity-providers/img/gitlab-verify-access.png rename to docs/docs/identity-providers/img/gitlab/gitlab-verify-access.png diff --git a/go.sum b/go.sum index 70947721b..c9199360c 100644 --- a/go.sum +++ b/go.sum @@ -409,6 +409,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa h1:F+8P+gmewFQYRk6JoLQLwjBCTu3mcIURZfNkVweuRKA= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a h1:GuSPYbZzB5/dcLNCwLQLsg3obCJtX9IJhpXkvY7kzk0= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421 h1:Wo7BWFiOk0QRFMLYMqJGFMd9CgUAcGx7V+qEg/h5IBI= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= diff --git a/internal/identity/gitlab.go b/internal/identity/gitlab.go new file mode 100644 index 000000000..1d0e5c8b0 --- /dev/null +++ b/internal/identity/gitlab.go @@ -0,0 +1,153 @@ +package identity // import "github.com/pomerium/pomerium/internal/identity" + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + + oidc "github.com/coreos/go-oidc" + "github.com/pomerium/pomerium/internal/httputil" + "github.com/pomerium/pomerium/internal/sessions" + "github.com/pomerium/pomerium/internal/version" + "golang.org/x/oauth2" +) + +const ( + defaultGitLabProviderURL = "https://gitlab.com" + revokeURL = "https://gitlab.com/oauth/revoke" + defaultGitLabGroupURL = "https://gitlab.com/api/v4/groups" +) + +// GitLabProvider is an implementation of the OAuth Provider +type GitLabProvider struct { + *Provider + RevokeURL string `json:"revocation_endpoint"` +} + +// NewGitLabProvider returns a new GitLabProvider. +// https://www.pomerium.io/docs/identity-providers/gitlab.html +func NewGitLabProvider(p *Provider) (*GitLabProvider, error) { + ctx := context.Background() + + if p.ProviderURL == "" { + p.ProviderURL = defaultGitLabProviderURL + } + + var err error + p.provider, err = oidc.NewProvider(ctx, p.ProviderURL) + if err != nil { + return nil, err + } + + if len(p.Scopes) == 0 { + p.Scopes = []string{oidc.ScopeOpenID, "api", "read_user", "profile", "email"} + } + + p.verifier = p.provider.Verifier(&oidc.Config{ClientID: p.ClientID}) + p.oauth = &oauth2.Config{ + ClientID: p.ClientID, + ClientSecret: p.ClientSecret, + Endpoint: p.provider.Endpoint(), + RedirectURL: p.RedirectURL.String(), + Scopes: p.Scopes, + } + gp := &GitLabProvider{ + Provider: p, + RevokeURL: revokeURL, + } + + if err := p.provider.Claims(&gp); err != nil { + return nil, err + } + + return gp, nil +} + +// Authenticate creates an identity session with gitlab from a authorization code, and makes +// a call to the userinfo endpoint to get the information of the user. +func (p GitLabProvider) Authenticate(ctx context.Context, code string) (*sessions.State, error) { + oauth2Token, err := p.oauth.Exchange(ctx, code) + if err != nil { + return nil, fmt.Errorf("internal/gitlab: token exchange failed: %w", err) + } + + idToken, err := p.IdentityFromToken(ctx, oauth2Token) + if err != nil { + return nil, err + } + + s, err := sessions.NewStateFromTokens(idToken, oauth2Token, p.RedirectURL.Hostname()) + if err != nil { + return nil, err + } + + var claims struct { + ID string `json:"sub"` + UserInfoURL string `json:"userinfo_endpoint"` + } + + if err := p.provider.Claims(&claims); err == nil && claims.UserInfoURL != "" { + userInfo, err := p.provider.UserInfo(ctx, oauth2.StaticTokenSource(oauth2Token)) + if err != nil { + return nil, fmt.Errorf("internal/gitlab: could not retrieve user info %w", err) + } + if err := userInfo.Claims(&s); err != nil { + return nil, err + } + } + + if p.UserGroupFn != nil { + s.Groups, err = p.UserGroupFn(ctx, s) + if err != nil { + return nil, fmt.Errorf("internal/gitlab: could not retrieve groups %w", err) + } + } + + return s, nil +} + +// UserGroups returns a slice of groups for the user. +// +// By default, this request returns 20 results at a time because the API results are paginated. +// https://docs.gitlab.com/ee/api/groups.html#list-groups +func (p *GitLabProvider) UserGroups(ctx context.Context, s *sessions.State) ([]string, error) { + if s == nil || s.AccessToken == nil { + return nil, errors.New("identity/gitlab: user session cannot be empty") + } + + var response []struct { + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + Description string `json:"description"` + Visibility string `json:"visibility"` + } + headers := map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.AccessToken.AccessToken)} + err := httputil.Client(ctx, http.MethodGet, defaultGitLabGroupURL, version.UserAgent(), headers, nil, &response) + if err != nil { + return nil, err + } + + var groups []string + for _, group := range response { + groups = append(groups, group.Name) + } + + return groups, nil +} + +// Revoke attempts to revoke session access via revocation endpoint +// https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#revoking-a-personal-access-token +func (p *GitLabProvider) Revoke(ctx context.Context, token *oauth2.Token) error { + params := url.Values{} + params.Add("access_token", token.AccessToken) + + err := httputil.Client(ctx, http.MethodPost, p.RevokeURL, version.UserAgent(), nil, params, nil) + if err != nil && err != httputil.ErrTokenRevoked { + return err + } + + return nil +} diff --git a/internal/identity/providers.go b/internal/identity/providers.go index ef17b6125..f9d7226d6 100644 --- a/internal/identity/providers.go +++ b/internal/identity/providers.go @@ -47,8 +47,8 @@ func New(providerName string, p *Provider) (a Authenticator, err error) { switch providerName { case AzureProviderName: a, err = NewAzureProvider(p) - // case GitlabProviderName: - // return nil, fmt.Errorf("internal/identity: %s currently not supported", providerName) + case GitlabProviderName: + a, err = NewGitLabProvider(p) case GoogleProviderName: a, err = NewGoogleProvider(p) case OIDCProviderName: