From 3092941c57d845e2befe6fb2860d0e7de74455d3 Mon Sep 17 00:00:00 2001 From: Nick Meves Date: Sun, 13 Jun 2021 12:41:57 -0700 Subject: [PATCH] Use OIDC as base of Gitlab provider --- providers/gitlab.go | 381 ++++++++++++++------------------------- providers/gitlab_test.go | 127 ++++++------- 2 files changed, 202 insertions(+), 306 deletions(-) diff --git a/providers/gitlab.go b/providers/gitlab.go index a2b11df7..84ceda8b 100644 --- a/providers/gitlab.go +++ b/providers/gitlab.go @@ -6,194 +6,196 @@ import ( "net/url" "strconv" "strings" - "time" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests" - "golang.org/x/oauth2" ) -// GitLabProvider represents a GitLab based Identity Provider -type GitLabProvider struct { - *ProviderData - - Groups []string - Projects []*GitlabProject -} - -// GitlabProject represents a Gitlab project constraint entity -type GitlabProject struct { - Name string - AccessLevel int -} - -// newGitlabProject Creates a new GitlabProject struct from project string formatted as namespace/project=accesslevel -// if no accesslevel provided, use the default one -func newGitlabproject(project string) (*GitlabProject, error) { - // default access level is 20 - defaultAccessLevel := 20 - // see https://docs.gitlab.com/ee/api/members.html#valid-access-levels - validAccessLevel := [4]int{10, 20, 30, 40} - - parts := strings.SplitN(project, "=", 2) - - if len(parts) == 2 { - lvl, err := strconv.Atoi(parts[1]) - if err != nil { - return nil, err - } - - for _, valid := range validAccessLevel { - if lvl == valid { - return &GitlabProject{ - Name: parts[0], - AccessLevel: lvl}, - err - } - } - - return nil, fmt.Errorf("invalid gitlab project access level specified (%s)", parts[0]) - - } - - return &GitlabProject{ - Name: project, - AccessLevel: defaultAccessLevel}, - nil - -} - -var _ Provider = (*GitLabProvider)(nil) - const ( gitlabProviderName = "GitLab" gitlabDefaultScope = "openid email" ) +// GitLabProvider represents a GitLab based Identity Provider +type GitLabProvider struct { + *OIDCProvider + + allowedProjects []*gitlabProject +} + +var _ Provider = (*GitLabProvider)(nil) + // NewGitLabProvider initiates a new GitLabProvider func NewGitLabProvider(p *ProviderData) *GitLabProvider { p.ProviderName = gitlabProviderName - if p.Scope == "" { p.Scope = gitlabDefaultScope } - return &GitLabProvider{ProviderData: p} -} - -// Redeem exchanges the OAuth2 authentication token for an ID token -func (p *GitLabProvider) Redeem(ctx context.Context, redirectURL, code string) (s *sessions.SessionState, err error) { - clientSecret, err := p.GetClientSecret() - if err != nil { - return - } - - c := oauth2.Config{ - ClientID: p.ClientID, - ClientSecret: clientSecret, - Endpoint: oauth2.Endpoint{ - TokenURL: p.RedeemURL.String(), + return &GitLabProvider{ + OIDCProvider: &OIDCProvider{ + ProviderData: p, + SkipNonce: false, }, - RedirectURL: redirectURL, } - token, err := c.Exchange(ctx, code) - if err != nil { - return nil, fmt.Errorf("token exchange: %v", err) - } - s, err = p.createSession(ctx, token) - if err != nil { - return nil, fmt.Errorf("unable to update session: %v", err) - } - return } -// SetProjectScope ensure read_api is added to scope when filtering on projects -func (p *GitLabProvider) SetProjectScope() { - if len(p.Projects) > 0 { - for _, val := range strings.Split(p.Scope, " ") { - if val == "read_api" { - return - } - +// SetAllowedProjects adds Gitlab projects to the AllowedGroups list +// and tracks them to do a project API lookup during `EnrichSession`. +func (p *GitLabProvider) SetAllowedProjects(projects []string) error { + for _, project := range projects { + gp, err := newGitlabProject(project) + if err != nil { + return err } - p.Scope += " read_api" + p.allowedProjects = append(p.allowedProjects, gp) + p.AllowedGroups[formatProject(gp)] = struct{}{} } + if len(p.allowedProjects) > 0 { + p.setProjectScope() + } + return nil } -// RefreshSession uses the RefreshToken to fetch new Access and ID Tokens -func (p *GitLabProvider) RefreshSession(ctx context.Context, s *sessions.SessionState) (bool, error) { - if s == nil || s.RefreshToken == "" { - return false, nil - } - - origExpiration := s.ExpiresOn - - err := p.redeemRefreshToken(ctx, s) - if err != nil { - return false, fmt.Errorf("unable to redeem refresh token: %v", err) - } - - logger.Printf("refreshed id token %s (expired on %s)\n", s, origExpiration) - return true, nil +// gitlabProject represents a Gitlab project constraint entity +type gitlabProject struct { + Name string + AccessLevel int } -func (p *GitLabProvider) redeemRefreshToken(ctx context.Context, s *sessions.SessionState) error { - clientSecret, err := p.GetClientSecret() - if err != nil { - return err +// newGitlabProject Creates a new GitlabProject struct from project string +// formatted as `namespace/project=accesslevel` +// if no accesslevel provided, use the default one +func newGitlabProject(project string) (*gitlabProject, error) { + const defaultAccessLevel = 20 + // see https://docs.gitlab.com/ee/api/members.html#valid-access-levels + validAccessLevel := [4]int{10, 20, 30, 40} + + parts := strings.SplitN(project, "=", 2) + if len(parts) == 2 { + lvl, err := strconv.Atoi(parts[1]) + if err != nil { + return nil, err + } + for _, valid := range validAccessLevel { + if lvl == valid { + return &gitlabProject{ + Name: parts[0], + AccessLevel: lvl, + }, nil + } + } + return nil, fmt.Errorf("invalid gitlab project access level specified (%s)", parts[0]) } - c := oauth2.Config{ - ClientID: p.ClientID, - ClientSecret: clientSecret, - Endpoint: oauth2.Endpoint{ - TokenURL: p.RedeemURL.String(), - }, + return &gitlabProject{ + Name: project, + AccessLevel: defaultAccessLevel, + }, nil +} + +// setProjectScope ensures read_api is added to scope when filtering on projects +func (p *GitLabProvider) setProjectScope() { + for _, val := range strings.Split(p.Scope, " ") { + if val == "read_api" { + return + } } - t := &oauth2.Token{ - RefreshToken: s.RefreshToken, - Expiry: time.Now().Add(-time.Hour), - } - token, err := c.TokenSource(ctx, t).Token() + p.Scope += " read_api" +} + +// EnrichSession enriches the session with the response from the userinfo API +// endpoint & projects API endpoint for allowed projects. +func (p *GitLabProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error { + // Retrieve user info + userinfo, err := p.getUserinfo(ctx, s) if err != nil { - return fmt.Errorf("failed to get token: %v", err) + return fmt.Errorf("failed to retrieve user info: %v", err) } - newSession, err := p.createSession(ctx, token) - if err != nil { - return fmt.Errorf("unable to update session: %v", err) + + // Check if email is verified + if !p.AllowUnverifiedEmail && !userinfo.EmailVerified { + return fmt.Errorf("user email is not verified") } - *s = *newSession + + s.User = userinfo.Username + s.Email = userinfo.Email + s.Groups = append(s.Groups, userinfo.Groups...) + + p.addProjectsToSession(ctx, s) return nil } -type gitlabUserInfo struct { +type gitlabUserinfo struct { Username string `json:"nickname"` Email string `json:"email"` EmailVerified bool `json:"email_verified"` Groups []string `json:"groups"` } -func (p *GitLabProvider) getUserInfo(ctx context.Context, s *sessions.SessionState) (*gitlabUserInfo, error) { +func (p *GitLabProvider) getUserinfo(ctx context.Context, s *sessions.SessionState) (*gitlabUserinfo, error) { // Retrieve user info JSON // https://docs.gitlab.com/ee/integration/openid_connect_provider.html#shared-information // Build user info url from login url of GitLab instance - userInfoURL := *p.LoginURL - userInfoURL.Path = "/oauth/userinfo" + userinfoURL := *p.LoginURL + userinfoURL.Path = "/oauth/userinfo" - var userInfo gitlabUserInfo - err := requests.New(userInfoURL.String()). + var userinfo gitlabUserinfo + err := requests.New(userinfoURL.String()). WithContext(ctx). SetHeader("Authorization", "Bearer "+s.AccessToken). Do(). - UnmarshalInto(&userInfo) + UnmarshalInto(&userinfo) if err != nil { return nil, fmt.Errorf("error getting user info: %v", err) } - return &userInfo, nil + return &userinfo, nil +} + +// addProjectsToSession adds projects matching user access requirements into +// the session state groups list. +// This method prefixes projects names with `project:` to specify group kind. +func (p *GitLabProvider) addProjectsToSession(ctx context.Context, s *sessions.SessionState) { + // Iterate over projects, check if oauth2-proxy can get project information on behalf of the user + for _, project := range p.allowedProjects { + projectInfo, err := p.getProjectInfo(ctx, s, project.Name) + if err != nil { + logger.Errorf("Warning: project info request failed: %v", err) + continue + } + + if projectInfo.Archived { + logger.Errorf("Warning: project %s is archived", project.Name) + continue + } + + perms := projectInfo.Permissions.ProjectAccess + if perms == nil { + // use group project access as fallback + perms = projectInfo.Permissions.GroupAccess + // group project access is not set for this user then we give up + if perms == nil { + logger.Errorf("Warning: user %q has no project level access to %s", + s.Email, project.Name) + continue + } + } + + if perms.AccessLevel < project.AccessLevel { + logger.Errorf( + "Warning: user %q does not have the minimum required access level for project %q", + s.Email, + project.Name, + ) + continue + } + + s.Groups = append(s.Groups, formatProject(project)) + } } type gitlabPermissionAccess struct { @@ -226,7 +228,6 @@ func (p *GitLabProvider) getProjectInfo(ctx context.Context, s *sessions.Session SetHeader("Authorization", "Bearer "+s.AccessToken). Do(). UnmarshalInto(&projectInfo) - if err != nil { return nil, fmt.Errorf("failed to get project info: %v", err) } @@ -234,116 +235,6 @@ func (p *GitLabProvider) getProjectInfo(ctx context.Context, s *sessions.Session return &projectInfo, nil } -// AddProjects adds Gitlab projects from options to GitlabProvider struct -func (p *GitLabProvider) AddProjects(projects []string) error { - for _, project := range projects { - gp, err := newGitlabproject(project) - if err != nil { - return err - } - - p.Projects = append(p.Projects, gp) - } - - return nil -} - -func (p *GitLabProvider) createSession(ctx context.Context, token *oauth2.Token) (*sessions.SessionState, error) { - idToken, err := p.verifyIDToken(ctx, token) - if err != nil { - switch err { - case ErrMissingIDToken: - return nil, fmt.Errorf("token response did not contain an id_token") - default: - return nil, fmt.Errorf("could not verify id_token: %v", err) - } - } - - ss := &sessions.SessionState{ - AccessToken: token.AccessToken, - IDToken: getIDToken(token), - RefreshToken: token.RefreshToken, - } - - ss.CreatedAtNow() - ss.SetExpiresOn(idToken.Expiry) - - return ss, nil -} - -// ValidateSession checks that the session's IDToken is still valid -func (p *GitLabProvider) ValidateSession(ctx context.Context, s *sessions.SessionState) bool { - _, err := p.Verifier.Verify(ctx, s.IDToken) - return err == nil -} - -// EnrichSession adds values and data from the Gitlab endpoint to current session -func (p *GitLabProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error { - // Retrieve user info - userInfo, err := p.getUserInfo(ctx, s) - if err != nil { - return fmt.Errorf("failed to retrieve user info: %v", err) - } - - // Check if email is verified - if !p.AllowUnverifiedEmail && !userInfo.EmailVerified { - return fmt.Errorf("user email is not verified") - } - - s.User = userInfo.Username - s.Email = userInfo.Email - for _, group := range userInfo.Groups { - s.Groups = append(s.Groups, fmt.Sprintf("group:%s", group)) - } - - p.addProjectsToSession(ctx, s) - - return nil -} - -// addProjectsToSession adds projects matching user access requirements into the session state groups list -// This method prefix projects names with `project` to specify group kind -func (p *GitLabProvider) addProjectsToSession(ctx context.Context, s *sessions.SessionState) { - // Iterate over projects, check if oauth2-proxy can get project information on behalf of the user - for _, project := range p.Projects { - projectInfo, err := p.getProjectInfo(ctx, s, project.Name) - - if err != nil { - logger.Errorf("Warning: project info request failed: %v", err) - continue - } - - if !projectInfo.Archived { - perms := projectInfo.Permissions.ProjectAccess - if perms == nil { - // use group project access as fallback - perms = projectInfo.Permissions.GroupAccess - // group project access is not set for this user then we give up - if perms == nil { - logger.Errorf("Warning: user %q has no project level access to %s", s.Email, project.Name) - continue - } - } - - if perms != nil && perms.AccessLevel >= project.AccessLevel { - s.Groups = append(s.Groups, fmt.Sprintf("project:%s", project.Name)) - } else { - logger.Errorf("Warning: user %q does not have the minimum required access level for project %q", s.Email, project.Name) - } - continue - } - - logger.Errorf("Warning: project %s is archived", project.Name) - } -} - -// PrefixAllowedGroups returns a list of allowed groups, prefixed by their `kind` value -func (p *GitLabProvider) PrefixAllowedGroups() (groups []string) { - for _, val := range p.Groups { - groups = append(groups, fmt.Sprintf("group:%s", val)) - } - for _, val := range p.Projects { - groups = append(groups, fmt.Sprintf("project:%s", val.Name)) - } - return groups +func formatProject(project *gitlabProject) string { + return fmt.Sprintf("project:%s", project.Name) } diff --git a/providers/gitlab_test.go b/providers/gitlab_test.go index 9c3fb00e..3a41cb7b 100644 --- a/providers/gitlab_test.go +++ b/providers/gitlab_test.go @@ -188,7 +188,7 @@ var _ = Describe("Gitlab Provider Tests", func() { err := p.EnrichSession(context.Background(), session) if in.expectedError != nil { - Expect(err).To(MatchError(err)) + Expect(err).To(MatchError(in.expectedError)) } else { Expect(err).To(BeNil()) Expect(session.Email).To(Equal(in.expectedValue)) @@ -208,97 +208,102 @@ var _ = Describe("Gitlab Provider Tests", func() { Context("when filtering on gitlab entities (groups and projects)", func() { type entitiesTableInput struct { - expectedValue []string - projects []string - groups []string + allowedProjects []string + allowedGroups []string + scope string + expectedAuthz bool + expectedError error + expectedGroups []string + expectedScope string } DescribeTable("should return expected results", func(in entitiesTableInput) { p.AllowUnverifiedEmail = true + if in.scope != "" { + p.Scope = in.scope + } session := &sessions.SessionState{AccessToken: "gitlab_access_token"} - err := p.AddProjects(in.projects) - Expect(err).To(BeNil()) - p.SetProjectScope() + p.SetAllowedGroups(in.allowedGroups) - if len(in.groups) > 0 { - p.Groups = in.groups + err := p.SetAllowedProjects(in.allowedProjects) + if err == nil { + Expect(err).To(BeNil()) + } else { + Expect(err).To(MatchError(in.expectedError)) + return } + Expect(p.Scope).To(Equal(in.expectedScope)) err = p.EnrichSession(context.Background(), session) - Expect(err).To(BeNil()) - Expect(session.Groups).To(Equal(in.expectedValue)) + Expect(session.Groups).To(Equal(in.expectedGroups)) + + authorized, err := p.Authorize(context.Background(), session) + Expect(err).To(BeNil()) + Expect(authorized).To(Equal(in.expectedAuthz)) }, Entry("project membership valid on group project", entitiesTableInput{ - expectedValue: []string{"group:foo", "group:bar", "project:my_group/my_project"}, - projects: []string{"my_group/my_project"}, + allowedProjects: []string{"my_group/my_project"}, + expectedAuthz: true, + expectedGroups: []string{"foo", "bar", "project:my_group/my_project"}, + expectedScope: "openid email read_api", }), Entry("project membership invalid on group project, insufficient access level level", entitiesTableInput{ - expectedValue: []string{"group:foo", "group:bar"}, - projects: []string{"my_group/my_project=40"}, + allowedProjects: []string{"my_group/my_project=40"}, + expectedAuthz: false, + expectedGroups: []string{"foo", "bar"}, + expectedScope: "openid email read_api", }), Entry("project membership invalid on group project, no access at all", entitiesTableInput{ - expectedValue: []string{"group:foo", "group:bar"}, - projects: []string{"no_access_group/no_access_project=30"}, + allowedProjects: []string{"no_access_group/no_access_project=30"}, + expectedAuthz: false, + expectedGroups: []string{"foo", "bar"}, + expectedScope: "openid email read_api", }), Entry("project membership valid on personnal project", entitiesTableInput{ - expectedValue: []string{"group:foo", "group:bar", "project:my_profile/my_personal_project"}, - projects: []string{"my_profile/my_personal_project"}, + allowedProjects: []string{"my_profile/my_personal_project"}, + scope: "openid email read_api profile", + expectedAuthz: true, + expectedGroups: []string{"foo", "bar", "project:my_profile/my_personal_project"}, + expectedScope: "openid email read_api profile", }), Entry("project membership invalid on personnal project, insufficient access level", entitiesTableInput{ - expectedValue: []string{"group:foo", "group:bar"}, - projects: []string{"my_profile/my_personal_project=40"}, + allowedProjects: []string{"my_profile/my_personal_project=40"}, + expectedAuthz: false, + expectedGroups: []string{"foo", "bar"}, + expectedScope: "openid email read_api", }), Entry("project membership invalid", entitiesTableInput{ - expectedValue: []string{"group:foo", "group:bar"}, - projects: []string{"my_group/my_bad_project"}, + allowedProjects: []string{"my_group/my_bad_project"}, + expectedAuthz: false, + expectedGroups: []string{"foo", "bar"}, + expectedScope: "openid email read_api", }), Entry("group membership valid", entitiesTableInput{ - expectedValue: []string{"group:foo", "group:bar"}, - groups: []string{"foo"}, + allowedGroups: []string{"foo"}, + expectedGroups: []string{"foo", "bar"}, + expectedAuthz: true, + expectedScope: "openid email", }), Entry("groups and projects", entitiesTableInput{ - expectedValue: []string{"group:foo", "group:bar", "project:my_group/my_project", "project:my_profile/my_personal_project"}, - groups: []string{"foo", "baz"}, - projects: []string{"my_group/my_project", "my_profile/my_personal_project"}, + allowedGroups: []string{"foo", "baz"}, + allowedProjects: []string{"my_group/my_project", "my_profile/my_personal_project"}, + expectedAuthz: true, + expectedGroups: []string{"foo", "bar", "project:my_group/my_project", "project:my_profile/my_personal_project"}, + expectedScope: "openid email read_api", }), Entry("archived projects", entitiesTableInput{ - expectedValue: []string{"group:foo", "group:bar"}, - groups: []string{}, - projects: []string{"my_group/my_archived_project"}, + allowedProjects: []string{"my_group/my_archived_project"}, + expectedAuthz: false, + expectedGroups: []string{"foo", "bar"}, + expectedScope: "openid email read_api", }), - ) - - }) - - Context("when generating group list from multiple kind", func() { - type entitiesTableInput struct { - projects []string - groups []string - } - - DescribeTable("should prefix entities with group kind", func(in entitiesTableInput) { - p.Groups = in.groups - err := p.AddProjects(in.projects) - Expect(err).To(BeNil()) - - all := p.PrefixAllowedGroups() - - Expect(len(all)).To(Equal(len(in.projects) + len(in.groups))) - }, - Entry("simple test case", entitiesTableInput{ - projects: []string{"my_group/my_project", "my_group/my_other_project"}, - groups: []string{"mygroup", "myothergroup"}, - }), - Entry("projects only", entitiesTableInput{ - projects: []string{"my_group/my_project", "my_group/my_other_project"}, - groups: []string{}, - }), - Entry("groups only", entitiesTableInput{ - projects: []string{}, - groups: []string{"mygroup", "myothergroup"}, + Entry("invalid project format", entitiesTableInput{ + allowedProjects: []string{"my_group/my_invalid_project=123"}, + expectedError: errors.New("invalid gitlab project access level specified (my_group/my_invalid_project)"), + expectedScope: "openid email read_api", }), ) })