1
0
mirror of https://github.com/oauth2-proxy/oauth2-proxy.git synced 2025-04-25 12:24:41 +02:00

Merge pull request from oauth2-proxy/gitlab-oidc

Make GitLab Provider based on OIDC Provider
This commit is contained in:
Nick Meves 2021-09-25 17:11:43 -07:00 committed by GitHub
commit f6b2848e9a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 336 additions and 289 deletions

@ -13,9 +13,13 @@
## Breaking Changes ## Breaking Changes
- [#1239](https://github.com/oauth2-proxy/oauth2-proxy/pull/1239) GitLab groups sent in the `X-Forwarded-Groups` header
to the upstream server will no longer be prefixed with `group:`
## Changes since v7.1.3 ## Changes since v7.1.3
- [#1337](https://github.com/oauth2-proxy/oauth2-proxy/pull/1337) Changing user field type to text when using htpasswd (@pburgisser) - [#1337](https://github.com/oauth2-proxy/oauth2-proxy/pull/1337) Changing user field type to text when using htpasswd (@pburgisser)
- [#1239](https://github.com/oauth2-proxy/oauth2-proxy/pull/1239) Base GitLab provider implementation on OIDCProvider (@NickMeves)
- [#1276](https://github.com/oauth2-proxy/oauth2-proxy/pull/1276) Update crypto and switched to new github.com/golang-jwt/jwt (@JVecsei) - [#1276](https://github.com/oauth2-proxy/oauth2-proxy/pull/1276) Update crypto and switched to new github.com/golang-jwt/jwt (@JVecsei)
- [#1264](https://github.com/oauth2-proxy/oauth2-proxy/pull/1264) Update go-oidc to v3 (@NickMeves) - [#1264](https://github.com/oauth2-proxy/oauth2-proxy/pull/1264) Update go-oidc to v3 (@NickMeves)
- [#1233](https://github.com/oauth2-proxy/oauth2-proxy/pull/1233) Extend email-domain validation with sub-domain capability (@morarucostel) - [#1233](https://github.com/oauth2-proxy/oauth2-proxy/pull/1233) Extend email-domain validation with sub-domain capability (@morarucostel)

@ -276,13 +276,11 @@ func parseProviderInfo(o *options.Options, msgs []string) []string {
msgs = append(msgs, "oidc provider requires an oidc issuer URL") msgs = append(msgs, "oidc provider requires an oidc issuer URL")
} }
case *providers.GitLabProvider: case *providers.GitLabProvider:
p.Groups = o.Providers[0].GitLabConfig.Group p.SetAllowedGroups(o.Providers[0].GitLabConfig.Group)
err := p.AddProjects(o.Providers[0].GitLabConfig.Projects) err := p.SetAllowedProjects(o.Providers[0].GitLabConfig.Projects)
if err != nil { if err != nil {
msgs = append(msgs, "failed to setup gitlab project access level") msgs = append(msgs, "failed to setup gitlab project access level")
} }
p.SetAllowedGroups(p.PrefixAllowedGroups())
p.SetProjectScope()
if p.Verifier == nil { if p.Verifier == nil {
// Initialize with default verifier for gitlab.com // Initialize with default verifier for gitlab.com

@ -6,194 +6,209 @@ import (
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" "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/logger"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
"golang.org/x/oauth2" )
const (
gitlabProviderName = "GitLab"
gitlabDefaultScope = "openid email"
gitlabProjectPrefix = "project:"
) )
// GitLabProvider represents a GitLab based Identity Provider // GitLabProvider represents a GitLab based Identity Provider
type GitLabProvider struct { type GitLabProvider struct {
*ProviderData *OIDCProvider
Groups []string allowedProjects []*gitlabProject
Projects []*GitlabProject // Expose this for unit testing
oidcRefreshFunc func(context.Context, *sessions.SessionState) (bool, error)
} }
// GitlabProject represents a Gitlab project constraint entity var _ Provider = (*GitLabProvider)(nil)
type GitlabProject struct {
// NewGitLabProvider initiates a new GitLabProvider
func NewGitLabProvider(p *ProviderData) *GitLabProvider {
p.ProviderName = gitlabProviderName
if p.Scope == "" {
p.Scope = gitlabDefaultScope
}
oidcProvider := &OIDCProvider{
ProviderData: p,
SkipNonce: false,
}
return &GitLabProvider{
OIDCProvider: oidcProvider,
oidcRefreshFunc: oidcProvider.RefreshSession,
}
}
// 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.allowedProjects = append(p.allowedProjects, gp)
p.AllowedGroups[formatProject(gp)] = struct{}{}
}
if len(p.allowedProjects) > 0 {
p.setProjectScope()
}
return nil
}
// gitlabProject represents a Gitlab project constraint entity
type gitlabProject struct {
Name string Name string
AccessLevel int AccessLevel int
} }
// newGitlabProject Creates a new GitlabProject struct from project string formatted as namespace/project=accesslevel // newGitlabProject Creates a new GitlabProject struct from project string
// formatted as `namespace/project=accesslevel`
// if no accesslevel provided, use the default one // if no accesslevel provided, use the default one
func newGitlabproject(project string) (*GitlabProject, error) { func newGitlabProject(project string) (*gitlabProject, error) {
// default access level is 20 const defaultAccessLevel = 20
defaultAccessLevel := 20
// see https://docs.gitlab.com/ee/api/members.html#valid-access-levels // see https://docs.gitlab.com/ee/api/members.html#valid-access-levels
validAccessLevel := [4]int{10, 20, 30, 40} validAccessLevel := [4]int{10, 20, 30, 40}
parts := strings.SplitN(project, "=", 2) parts := strings.SplitN(project, "=", 2)
if len(parts) == 2 { if len(parts) == 2 {
lvl, err := strconv.Atoi(parts[1]) lvl, err := strconv.Atoi(parts[1])
if err != nil { if err != nil {
return nil, err return nil, err
} }
for _, valid := range validAccessLevel { for _, valid := range validAccessLevel {
if lvl == valid { if lvl == valid {
return &GitlabProject{ return &gitlabProject{
Name: parts[0], Name: parts[0],
AccessLevel: lvl}, AccessLevel: lvl,
err }, nil
} }
} }
return nil, fmt.Errorf("invalid gitlab project access level specified (%s)", parts[0]) return nil, fmt.Errorf("invalid gitlab project access level specified (%s)", parts[0])
} }
return &GitlabProject{ return &gitlabProject{
Name: project, Name: project,
AccessLevel: defaultAccessLevel}, AccessLevel: defaultAccessLevel,
nil }, nil
} }
var _ Provider = (*GitLabProvider)(nil) // setProjectScope ensures read_api is added to scope when filtering on projects
func (p *GitLabProvider) setProjectScope() {
const ( for _, val := range strings.Split(p.Scope, " ") {
gitlabProviderName = "GitLab" if val == "read_api" {
gitlabDefaultScope = "openid email" return
)
// 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(),
},
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
}
} }
p.Scope += " read_api"
} }
p.Scope += " read_api"
} }
// RefreshSession uses the RefreshToken to fetch new Access and ID Tokens // EnrichSession enriches the session with the response from the userinfo API
func (p *GitLabProvider) RefreshSession(ctx context.Context, s *sessions.SessionState) (bool, error) { // endpoint & projects API endpoint for allowed projects.
if s == nil || s.RefreshToken == "" { func (p *GitLabProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error {
return false, nil // Retrieve user info
} userinfo, err := p.getUserinfo(ctx, s)
origExpiration := s.ExpiresOn
err := p.redeemRefreshToken(ctx, s)
if err != nil { if err != nil {
return false, fmt.Errorf("unable to redeem refresh token: %v", err) return fmt.Errorf("failed to retrieve user info: %v", err)
} }
logger.Printf("refreshed id token %s (expired on %s)\n", s, origExpiration) // Check if email is verified
return true, nil if !p.AllowUnverifiedEmail && !userinfo.EmailVerified {
} return fmt.Errorf("user email is not verified")
func (p *GitLabProvider) redeemRefreshToken(ctx context.Context, s *sessions.SessionState) error {
clientSecret, err := p.GetClientSecret()
if err != nil {
return err
} }
c := oauth2.Config{ if userinfo.Nickname != "" {
ClientID: p.ClientID, s.User = userinfo.Nickname
ClientSecret: clientSecret,
Endpoint: oauth2.Endpoint{
TokenURL: p.RedeemURL.String(),
},
} }
t := &oauth2.Token{ if userinfo.Email != "" {
RefreshToken: s.RefreshToken, s.Email = userinfo.Email
Expiry: time.Now().Add(-time.Hour),
} }
token, err := c.TokenSource(ctx, t).Token() if len(userinfo.Groups) > 0 {
if err != nil { s.Groups = userinfo.Groups
return fmt.Errorf("failed to get token: %v", err)
} }
newSession, err := p.createSession(ctx, token)
if err != nil { // Add projects as `project:blah` to s.Groups
return fmt.Errorf("unable to update session: %v", err) p.addProjectsToSession(ctx, s)
}
*s = *newSession
return nil return nil
} }
type gitlabUserInfo struct { type gitlabUserinfo struct {
Username string `json:"nickname"` Nickname string `json:"nickname"`
Email string `json:"email"` Email string `json:"email"`
EmailVerified bool `json:"email_verified"` EmailVerified bool `json:"email_verified"`
Groups []string `json:"groups"` 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 // Retrieve user info JSON
// https://docs.gitlab.com/ee/integration/openid_connect_provider.html#shared-information // https://docs.gitlab.com/ee/integration/openid_connect_provider.html#shared-information
// Build user info url from login url of GitLab instance // Build user info url from login url of GitLab instance
userInfoURL := *p.LoginURL userinfoURL := *p.LoginURL
userInfoURL.Path = "/oauth/userinfo" userinfoURL.Path = "/oauth/userinfo"
var userInfo gitlabUserInfo var userinfo gitlabUserinfo
err := requests.New(userInfoURL.String()). err := requests.New(userinfoURL.String()).
WithContext(ctx). WithContext(ctx).
SetHeader("Authorization", "Bearer "+s.AccessToken). SetHeader("Authorization", "Bearer "+s.AccessToken).
Do(). Do().
UnmarshalInto(&userInfo) UnmarshalInto(&userinfo)
if err != nil { if err != nil {
return nil, fmt.Errorf("error getting user info: %v", err) 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 { type gitlabPermissionAccess struct {
@ -226,7 +241,6 @@ func (p *GitLabProvider) getProjectInfo(ctx context.Context, s *sessions.Session
SetHeader("Authorization", "Bearer "+s.AccessToken). SetHeader("Authorization", "Bearer "+s.AccessToken).
Do(). Do().
UnmarshalInto(&projectInfo) UnmarshalInto(&projectInfo)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get project info: %v", err) return nil, fmt.Errorf("failed to get project info: %v", err)
} }
@ -234,116 +248,45 @@ func (p *GitLabProvider) getProjectInfo(ctx context.Context, s *sessions.Session
return &projectInfo, nil return &projectInfo, nil
} }
// AddProjects adds Gitlab projects from options to GitlabProvider struct func formatProject(project *gitlabProject) string {
func (p *GitLabProvider) AddProjects(projects []string) error { return gitlabProjectPrefix + project.Name
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) { // RefreshSession refreshes the session with the OIDCProvider implementation
idToken, err := p.verifyIDToken(ctx, token) // but preserves the custom GitLab projects added in the `EnrichSession` stage.
if err != nil { func (p *GitLabProvider) RefreshSession(ctx context.Context, s *sessions.SessionState) (bool, error) {
switch err { nickname := s.User
case ErrMissingIDToken: projects := getSessionProjects(s)
return nil, fmt.Errorf("token response did not contain an id_token") // This will overwrite s.Groups with the new IDToken's `groups` claims
default: // and s.User with the `sub` claim.
return nil, fmt.Errorf("could not verify id_token: %v", err) refreshed, err := p.oidcRefreshFunc(ctx, s)
if refreshed && err == nil {
s.User = nickname
s.Groups = append(s.Groups, projects...)
s.Groups = deduplicateGroups(s.Groups)
}
return refreshed, err
}
func getSessionProjects(s *sessions.SessionState) []string {
var projects []string
for _, group := range s.Groups {
if strings.HasPrefix(group, gitlabProjectPrefix) {
projects = append(projects, group)
} }
} }
return projects
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 deduplicateGroups(groups []string) []string {
func (p *GitLabProvider) ValidateSession(ctx context.Context, s *sessions.SessionState) bool { groupSet := make(map[string]struct{})
_, err := p.Verifier.Verify(ctx, s.IDToken) for _, group := range groups {
return err == nil groupSet[group] = struct{}{}
} }
// EnrichSession adds values and data from the Gitlab endpoint to current session uniqueGroups := make([]string, 0, len(groupSet))
func (p *GitLabProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error { for group := range groupSet {
// Retrieve user info uniqueGroups = append(uniqueGroups, group)
userInfo, err := p.getUserInfo(ctx, s) }
if err != nil { return uniqueGroups
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
} }

@ -188,7 +188,7 @@ var _ = Describe("Gitlab Provider Tests", func() {
err := p.EnrichSession(context.Background(), session) err := p.EnrichSession(context.Background(), session)
if in.expectedError != nil { if in.expectedError != nil {
Expect(err).To(MatchError(err)) Expect(err).To(MatchError(in.expectedError))
} else { } else {
Expect(err).To(BeNil()) Expect(err).To(BeNil())
Expect(session.Email).To(Equal(in.expectedValue)) Expect(session.Email).To(Equal(in.expectedValue))
@ -208,98 +208,165 @@ var _ = Describe("Gitlab Provider Tests", func() {
Context("when filtering on gitlab entities (groups and projects)", func() { Context("when filtering on gitlab entities (groups and projects)", func() {
type entitiesTableInput struct { type entitiesTableInput struct {
expectedValue []string allowedProjects []string
projects []string allowedGroups []string
groups []string scope string
expectedAuthz bool
expectedError error
expectedGroups []string
expectedScope string
} }
DescribeTable("should return expected results", DescribeTable("should return expected results",
func(in entitiesTableInput) { func(in entitiesTableInput) {
p.AllowUnverifiedEmail = true p.AllowUnverifiedEmail = true
if in.scope != "" {
p.Scope = in.scope
}
session := &sessions.SessionState{AccessToken: "gitlab_access_token"} session := &sessions.SessionState{AccessToken: "gitlab_access_token"}
err := p.AddProjects(in.projects) p.SetAllowedGroups(in.allowedGroups)
Expect(err).To(BeNil())
p.SetProjectScope()
if len(in.groups) > 0 { err := p.SetAllowedProjects(in.allowedProjects)
p.Groups = in.groups if in.expectedError == 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) err = p.EnrichSession(context.Background(), session)
Expect(err).To(BeNil()) 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{ Entry("project membership valid on group project", entitiesTableInput{
expectedValue: []string{"group:foo", "group:bar", "project:my_group/my_project"}, allowedProjects: []string{"my_group/my_project"},
projects: []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{ Entry("project membership invalid on group project, insufficient access level level", entitiesTableInput{
expectedValue: []string{"group:foo", "group:bar"}, allowedProjects: []string{"my_group/my_project=40"},
projects: []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{ Entry("project membership invalid on group project, no access at all", entitiesTableInput{
expectedValue: []string{"group:foo", "group:bar"}, allowedProjects: []string{"no_access_group/no_access_project=30"},
projects: []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{ Entry("project membership valid on personnal project", entitiesTableInput{
expectedValue: []string{"group:foo", "group:bar", "project:my_profile/my_personal_project"}, allowedProjects: []string{"my_profile/my_personal_project"},
projects: []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{ Entry("project membership invalid on personnal project, insufficient access level", entitiesTableInput{
expectedValue: []string{"group:foo", "group:bar"}, allowedProjects: []string{"my_profile/my_personal_project=40"},
projects: []string{"my_profile/my_personal_project=40"}, expectedAuthz: false,
expectedGroups: []string{"foo", "bar"},
expectedScope: "openid email read_api",
}), }),
Entry("project membership invalid", entitiesTableInput{ Entry("project membership invalid", entitiesTableInput{
expectedValue: []string{"group:foo", "group:bar"}, allowedProjects: []string{"my_group/my_bad_project"},
projects: []string{"my_group/my_bad_project"}, expectedAuthz: false,
expectedGroups: []string{"foo", "bar"},
expectedScope: "openid email read_api",
}), }),
Entry("group membership valid", entitiesTableInput{ Entry("group membership valid", entitiesTableInput{
expectedValue: []string{"group:foo", "group:bar"}, allowedGroups: []string{"foo"},
groups: []string{"foo"}, expectedGroups: []string{"foo", "bar"},
expectedAuthz: true,
expectedScope: "openid email",
}), }),
Entry("groups and projects", entitiesTableInput{ Entry("groups and projects", entitiesTableInput{
expectedValue: []string{"group:foo", "group:bar", "project:my_group/my_project", "project:my_profile/my_personal_project"}, allowedGroups: []string{"foo", "baz"},
groups: []string{"foo", "baz"}, allowedProjects: []string{"my_group/my_project", "my_profile/my_personal_project"},
projects: []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{ Entry("archived projects", entitiesTableInput{
expectedValue: []string{"group:foo", "group:bar"}, allowedProjects: []string{"my_group/my_archived_project"},
groups: []string{}, expectedAuthz: false,
projects: []string{"my_group/my_archived_project"}, expectedGroups: []string{"foo", "bar"},
expectedScope: "openid email read_api",
}),
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",
}), }),
) )
}) })
Context("when generating group list from multiple kind", func() { Context("when refreshing", func() {
type entitiesTableInput struct { It("keeps the existing nickname after refreshing", func() {
projects []string session := &sessions.SessionState{
groups []string User: "nickname",
} }
p.oidcRefreshFunc = func(_ context.Context, s *sessions.SessionState) (bool, error) {
s.User = "subject"
return true, nil
}
refreshed, err := p.RefreshSession(context.Background(), session)
Expect(refreshed).To(BeTrue())
Expect(err).ToNot(HaveOccurred())
Expect(session.User).To(Equal("nickname"))
})
It("keeps existing projects after refreshing groups", func() {
session := &sessions.SessionState{}
session.Groups = []string{"foo", "bar", "project:thing", "project:sample"}
DescribeTable("should prefix entities with group kind", func(in entitiesTableInput) { p.oidcRefreshFunc = func(_ context.Context, s *sessions.SessionState) (bool, error) {
p.Groups = in.groups s.Groups = []string{"baz"}
err := p.AddProjects(in.projects) return true, nil
Expect(err).To(BeNil()) }
all := p.PrefixAllowedGroups() refreshed, err := p.RefreshSession(context.Background(), session)
Expect(refreshed).To(BeTrue())
Expect(err).ToNot(HaveOccurred())
Expect(len(session.Groups)).To(Equal(3))
Expect(session.Groups).
To(ContainElements([]string{"baz", "project:thing", "project:sample"}))
})
It("leaves existing groups when not refreshed", func() {
session := &sessions.SessionState{}
session.Groups = []string{"foo", "bar", "project:thing", "project:sample"}
Expect(len(all)).To(Equal(len(in.projects) + len(in.groups))) p.oidcRefreshFunc = func(_ context.Context, s *sessions.SessionState) (bool, error) {
}, return false, nil
Entry("simple test case", entitiesTableInput{ }
projects: []string{"my_group/my_project", "my_group/my_other_project"},
groups: []string{"mygroup", "myothergroup"}, refreshed, err := p.RefreshSession(context.Background(), session)
}), Expect(refreshed).To(BeFalse())
Entry("projects only", entitiesTableInput{ Expect(err).ToNot(HaveOccurred())
projects: []string{"my_group/my_project", "my_group/my_other_project"}, Expect(len(session.Groups)).To(Equal(4))
groups: []string{}, Expect(session.Groups).
}), To(ContainElements([]string{"foo", "bar", "project:thing", "project:sample"}))
Entry("groups only", entitiesTableInput{ })
projects: []string{}, It("leaves existing groups when OIDC refresh errors", func() {
groups: []string{"mygroup", "myothergroup"}, session := &sessions.SessionState{}
}), session.Groups = []string{"foo", "bar", "project:thing", "project:sample"}
)
p.oidcRefreshFunc = func(_ context.Context, s *sessions.SessionState) (bool, error) {
return false, errors.New("failure")
}
refreshed, err := p.RefreshSession(context.Background(), session)
Expect(refreshed).To(BeFalse())
Expect(err).To(HaveOccurred())
Expect(len(session.Groups)).To(Equal(4))
Expect(session.Groups).
To(ContainElements([]string{"foo", "bar", "project:thing", "project:sample"}))
})
}) })
}) })

@ -41,6 +41,7 @@ type ProviderData struct {
// Common OIDC options for any OIDC-based providers to consume // Common OIDC options for any OIDC-based providers to consume
AllowUnverifiedEmail bool AllowUnverifiedEmail bool
UserClaim string
EmailClaim string EmailClaim string
GroupsClaim string GroupsClaim string
Verifier *oidc.IDTokenVerifier Verifier *oidc.IDTokenVerifier
@ -156,6 +157,17 @@ func (p *ProviderData) buildSessionFromClaims(idToken *oidc.IDToken) (*sessions.
ss.Email = claims.Email ss.Email = claims.Email
ss.Groups = claims.Groups ss.Groups = claims.Groups
// Allow specialized providers that embed OIDCProvider to control the User
// claim. Not exposed as a configuration flag to generic OIDC provider
// users (yet).
if p.UserClaim != "" {
user, ok := claims.raw[p.UserClaim].(string)
if !ok {
return nil, fmt.Errorf("unable to extract custom UserClaim (%s)", p.UserClaim)
}
ss.User = user
}
// TODO (@NickMeves) Deprecate for dynamic claim to session mapping // TODO (@NickMeves) Deprecate for dynamic claim to session mapping
if pref, ok := claims.raw["preferred_username"].(string); ok { if pref, ok := claims.raw["preferred_username"].(string); ok {
ss.PreferredUsername = pref ss.PreferredUsername = pref

@ -211,6 +211,7 @@ func TestProviderData_buildSessionFromClaims(t *testing.T) {
testCases := map[string]struct { testCases := map[string]struct {
IDToken idTokenClaims IDToken idTokenClaims
AllowUnverified bool AllowUnverified bool
UserClaim string
EmailClaim string EmailClaim string
GroupsClaim string GroupsClaim string
ExpectedError error ExpectedError error
@ -259,6 +260,27 @@ func TestProviderData_buildSessionFromClaims(t *testing.T) {
PreferredUsername: "Complex Claim", PreferredUsername: "Complex Claim",
}, },
}, },
"User Claim Switched": {
IDToken: defaultIDToken,
AllowUnverified: true,
UserClaim: "phone_number",
EmailClaim: "email",
GroupsClaim: "groups",
ExpectedSession: &sessions.SessionState{
User: "+4798765432",
Email: "janed@me.com",
Groups: []string{"test:a", "test:b"},
PreferredUsername: "Jane Dobbs",
},
},
"User Claim Invalid": {
IDToken: defaultIDToken,
AllowUnverified: true,
UserClaim: "groups",
EmailClaim: "email",
GroupsClaim: "groups",
ExpectedError: errors.New("unable to extract custom UserClaim (groups)"),
},
"Email Claim Switched": { "Email Claim Switched": {
IDToken: unverifiedIDToken, IDToken: unverifiedIDToken,
AllowUnverified: true, AllowUnverified: true,
@ -332,6 +354,7 @@ func TestProviderData_buildSessionFromClaims(t *testing.T) {
), ),
} }
provider.AllowUnverifiedEmail = tc.AllowUnverified provider.AllowUnverifiedEmail = tc.AllowUnverified
provider.UserClaim = tc.UserClaim
provider.EmailClaim = tc.EmailClaim provider.EmailClaim = tc.EmailClaim
provider.GroupsClaim = tc.GroupsClaim provider.GroupsClaim = tc.GroupsClaim