@@ -6,194 +6,209 @@ import (
66 "net/url"
77 "strconv"
88 "strings"
9− "time"
109
1110 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
1211 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
1312 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
14− "golang.org/x/oauth2"
13+)
14+
15+const (
16+ gitlabProviderName = "GitLab"
17+ gitlabDefaultScope = "openid email"
18+ gitlabProjectPrefix = "project:"
1519)
1620
1721// GitLabProvider represents a GitLab based Identity Provider
1822type GitLabProvider struct {
19− *ProviderData
23+ *OIDCProvider
2024
21− Groups []string
22− Projects []*GitlabProject
25+ allowedProjects []*gitlabProject
26+ // Expose this for unit testing
27+ oidcRefreshFunc func(context.Context, *sessions.SessionState) (bool, error)
2328}
2429
25−// GitlabProject represents a Gitlab project constraint entity
26−type GitlabProject struct {
30+var _ Provider = (*GitLabProvider)(nil)
31+
32+// NewGitLabProvider initiates a new GitLabProvider
33+func NewGitLabProvider(p *ProviderData) *GitLabProvider {
34+ p.ProviderName = gitlabProviderName
35+ if p.Scope == "" {
36+ p.Scope = gitlabDefaultScope
37+ }
38+
39+ oidcProvider := &OIDCProvider{
40+ ProviderData: p,
41+ SkipNonce: false,
42+ }
43+
44+ return &GitLabProvider{
45+ OIDCProvider: oidcProvider,
46+ oidcRefreshFunc: oidcProvider.RefreshSession,
47+ }
48+}
49+
50+// SetAllowedProjects adds Gitlab projects to the AllowedGroups list
51+// and tracks them to do a project API lookup during `EnrichSession`.
52+func (p *GitLabProvider) SetAllowedProjects(projects []string) error {
53+ for _, project := range projects {
54+ gp, err := newGitlabProject(project)
55+ if err != nil {
56+ return err
57+ }
58+ p.allowedProjects = append(p.allowedProjects, gp)
59+ p.AllowedGroups[formatProject(gp)] = struct{}{}
60+ }
61+ if len(p.allowedProjects) > 0 {
62+ p.setProjectScope()
63+ }
64+ return nil
65+}
66+
67+// gitlabProject represents a Gitlab project constraint entity
68+type gitlabProject struct {
2769 Name string
2870 AccessLevel int
2971}
3072
31−// newGitlabProject Creates a new GitlabProject struct from project string formatted as namespace/project=accesslevel
73+// newGitlabProject Creates a new GitlabProject struct from project string
74+// formatted as `namespace/project=accesslevel`
3275// if no accesslevel provided, use the default one
33−func newGitlabproject(project string) (*GitlabProject, error) {
34− // default access level is 20
35− defaultAccessLevel := 20
76+func newGitlabProject(project string) (*gitlabProject, error) {
77+ const defaultAccessLevel = 20
3678 // see https://docs.gitlab.com/ee/api/members.html#valid-access-levels
3779 validAccessLevel := [4]int{10, 20, 30, 40}
3880
3981 parts := strings.SplitN(project, "=", 2)
40−
4182 if len(parts) == 2 {
4283 lvl, err := strconv.Atoi(parts[1])
4384 if err != nil {
4485 return nil, err
4586 }
46−
4787 for _, valid := range validAccessLevel {
4888 if lvl == valid {
49− return &GitlabProject{
50− Name: parts[0],
51− AccessLevel: lvl},
52− err
89+ return &gitlabProject{
90+ Name: parts[0],
91+ AccessLevel: lvl,
92+ }, nil
5393 }
5494 }
55−
5695 return nil, fmt.Errorf("invalid gitlab project access level specified (%s)", parts[0])
57−
58− }
59−
60− return &GitlabProject{
61− Name: project,
62− AccessLevel: defaultAccessLevel},
63− nil
64−
65−}
66−
67−var _ Provider = (*GitLabProvider)(nil)
68−
69−const (
70− gitlabProviderName = "GitLab"
71− gitlabDefaultScope = "openid email"
72−)
73−
74−// NewGitLabProvider initiates a new GitLabProvider
75−func NewGitLabProvider(p *ProviderData) *GitLabProvider {
76− p.ProviderName = gitlabProviderName
77−
78− if p.Scope == "" {
79− p.Scope = gitlabDefaultScope
8096 }
8197
82− return &GitLabProvider{ProviderData: p}
83−}
84−
85−// Redeem exchanges the OAuth2 authentication token for an ID token
86−func (p *GitLabProvider) Redeem(ctx context.Context, redirectURL, code string) (s *sessions.SessionState, err error) {
87− clientSecret, err := p.GetClientSecret()
88− if err != nil {
89− return
90− }
91−
92− c := oauth2.Config{
93− ClientID: p.ClientID,
94− ClientSecret: clientSecret,
95− Endpoint: oauth2.Endpoint{
96− TokenURL: p.RedeemURL.String(),
97− },
98− RedirectURL: redirectURL,
99− }
100− token, err := c.Exchange(ctx, code)
101− if err != nil {
102− return nil, fmt.Errorf("token exchange: %v", err)
103− }
104− s, err = p.createSession(ctx, token)
105− if err != nil {
106− return nil, fmt.Errorf("unable to update session: %v", err)
107− }
108− return
98+ return &gitlabProject{
99+ Name: project,
100+ AccessLevel: defaultAccessLevel,
101+ }, nil
109102}
110103
111−// SetProjectScope ensure read_api is added to scope when filtering on projects
112−func (p *GitLabProvider) SetProjectScope() {
113− if len(p.Projects) > 0 {
114− for _, val := range strings.Split(p.Scope, " ") {
115− if val == "read_api" {
116− return
117− }
118−
104+// setProjectScope ensures read_api is added to scope when filtering on projects
105+func (p *GitLabProvider) setProjectScope() {
106+ for _, val := range strings.Split(p.Scope, " ") {
107+ if val == "read_api" {
108+ return
119109 }
120− p.Scope += " read_api"
121110 }
111+ p.Scope += " read_api"
122112}
123113
124−// RefreshSession uses the RefreshToken to fetch new Access and ID Tokens
125−func (p *GitLabProvider) RefreshSession(ctx context.Context, s *sessions.SessionState) (bool, error) {
126− if s == nil || s.RefreshToken == "" {
127− return false, nil
128− }
129−
130− origExpiration := s.ExpiresOn
131−
132− err := p.redeemRefreshToken(ctx, s)
114+// EnrichSession enriches the session with the response from the userinfo API
115+// endpoint & projects API endpoint for allowed projects.
116+func (p *GitLabProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error {
117+ // Retrieve user info
118+ userinfo, err := p.getUserinfo(ctx, s)
133119 if err != nil {
134− return false, fmt.Errorf("unable to redeem refresh token: %v", err)
120+ return fmt.Errorf("failed to retrieve user info: %v", err)
135121 }
136122
137− logger.Printf("refreshed id token %s (expired on %s)\n", s, origExpiration)
138− return true, nil
139−}
140−
141−func (p *GitLabProvider) redeemRefreshToken(ctx context.Context, s *sessions.SessionState) error {
142− clientSecret, err := p.GetClientSecret()
143− if err != nil {
144− return err
123+ // Check if email is verified
124+ if !p.AllowUnverifiedEmail && !userinfo.EmailVerified {
125+ return fmt.Errorf("user email is not verified")
145126 }
146127
147− c := oauth2.Config{
148− ClientID: p.ClientID,
149− ClientSecret: clientSecret,
150− Endpoint: oauth2.Endpoint{
151− TokenURL: p.RedeemURL.String(),
152− },
128+ if userinfo.Nickname != "" {
129+ s.User = userinfo.Nickname
153130 }
154− t := &oauth2.Token{
155− RefreshToken: s.RefreshToken,
156− Expiry: time.Now().Add(-time.Hour),
131+ if userinfo.Email != "" {
132+ s.Email = userinfo.Email
157133 }
158− token, err := c.TokenSource(ctx, t).Token()
159− if err != nil {
160− return fmt.Errorf("failed to get token: %v", err)
161− }
162− newSession, err := p.createSession(ctx, token)
163− if err != nil {
164− return fmt.Errorf("unable to update session: %v", err)
134+ if len(userinfo.Groups) > 0 {
135+ s.Groups = userinfo.Groups
165136 }
166− *s = *newSession
137+
138+ // Add projects as `project:blah` to s.Groups
139+ p.addProjectsToSession(ctx, s)
167140
168141 return nil
169142}
170143
171−type gitlabUserInfo struct {
172− Username string `json:"nickname"`
144+type gitlabUserinfo struct {
145+ Nickname string `json:"nickname"`
173146 Email string `json:"email"`
174147 EmailVerified bool `json:"email_verified"`
175148 Groups []string `json:"groups"`
176149}
177150
178−func (p *GitLabProvider) getUserInfo(ctx context.Context, s *sessions.SessionState) (*gitlabUserInfo, error) {
151+func (p *GitLabProvider) getUserinfo(ctx context.Context, s *sessions.SessionState) (*gitlabUserinfo, error) {
179152 // Retrieve user info JSON
180153 // https://docs.gitlab.com/ee/integration/openid_connect_provider.html#shared-information
181154
182155 // Build user info url from login url of GitLab instance
183− userInfoURL := *p.LoginURL
184− userInfoURL.Path = "/oauth/userinfo"
156+ userinfoURL := *p.LoginURL
157+ userinfoURL.Path = "/oauth/userinfo"
185158
186− var userInfo gitlabUserInfo
187− err := requests.New(userInfoURL.String()).
159+ var userinfo gitlabUserinfo
160+ err := requests.New(userinfoURL.String()).
188161 WithContext(ctx).
189162 SetHeader("Authorization", "Bearer "+s.AccessToken).
190163 Do().
191− UnmarshalInto(&userInfo)
164+ UnmarshalInto(&userinfo)
192165 if err != nil {
193166 return nil, fmt.Errorf("error getting user info: %v", err)
194167 }
195168
196− return &userInfo, nil
169+ return &userinfo, nil
170+}
171+
172+// addProjectsToSession adds projects matching user access requirements into
173+// the session state groups list.
174+// This method prefixes projects names with `project:` to specify group kind.
175+func (p *GitLabProvider) addProjectsToSession(ctx context.Context, s *sessions.SessionState) {
176+ // Iterate over projects, check if oauth2-proxy can get project information on behalf of the user
177+ for _, project := range p.allowedProjects {
178+ projectInfo, err := p.getProjectInfo(ctx, s, project.Name)
179+ if err != nil {
180+ logger.Errorf("Warning: project info request failed: %v", err)
181+ continue
182+ }
183+
184+ if projectInfo.Archived {
185+ logger.Errorf("Warning: project %s is archived", project.Name)
186+ continue
187+ }
188+
189+ perms := projectInfo.Permissions.ProjectAccess
190+ if perms == nil {
191+ // use group project access as fallback
192+ perms = projectInfo.Permissions.GroupAccess
193+ // group project access is not set for this user then we give up
194+ if perms == nil {
195+ logger.Errorf("Warning: user %q has no project level access to %s",
196+ s.Email, project.Name)
197+ continue
198+ }
199+ }
200+
201+ if perms.AccessLevel < project.AccessLevel {
202+ logger.Errorf(
203+ "Warning: user %q does not have the minimum required access level for project %q",
204+ s.Email,
205+ project.Name,
206+ )
207+ continue
208+ }
209+
210+ s.Groups = append(s.Groups, formatProject(project))
211+ }
197212}
198213
199214type gitlabPermissionAccess struct {
@@ -226,7 +241,6 @@ func (p *GitLabProvider) getProjectInfo(ctx context.Context, s *sessions.Session
226241 SetHeader("Authorization", "Bearer "+s.AccessToken).
227242 Do().
228243 UnmarshalInto(&projectInfo)
229−
230244 if err != nil {
231245 return nil, fmt.Errorf("failed to get project info: %v", err)
232246 }
@@ -234,116 +248,45 @@ func (p *GitLabProvider) getProjectInfo(ctx context.Context, s *sessions.Session
234248 return &projectInfo, nil
235249}
236250
237−// AddProjects adds Gitlab projects from options to GitlabProvider struct
238−func (p *GitLabProvider) AddProjects(projects []string) error {
239− for _, project := range projects {
240− gp, err := newGitlabproject(project)
241− if err != nil {
242− return err
243− }
244−
245− p.Projects = append(p.Projects, gp)
246− }
247−
248− return nil
251+func formatProject(project *gitlabProject) string {
252+ return gitlabProjectPrefix + project.Name
249253}
250254
251−func (p *GitLabProvider) createSession(ctx context.Context, token *oauth2.Token) (*sessions.SessionState, error) {
252− idToken, err := p.verifyIDToken(ctx, token)
253− if err != nil {
254− switch err {
255− case ErrMissingIDToken:
256− return nil, fmt.Errorf("token response did not contain an id_token")
257− default:
258− return nil, fmt.Errorf("could not verify id_token: %v", err)
259− }
260− }
261−
262− ss := &sessions.SessionState{
263− AccessToken: token.AccessToken,
264− IDToken: getIDToken(token),
265− RefreshToken: token.RefreshToken,
266− }
267−
268− ss.CreatedAtNow()
269− ss.SetExpiresOn(idToken.Expiry)
270−
271− return ss, nil
272−}
273−
274−// ValidateSession checks that the session's IDToken is still valid
275−func (p *GitLabProvider) ValidateSession(ctx context.Context, s *sessions.SessionState) bool {
276− _, err := p.Verifier.Verify(ctx, s.IDToken)
277− return err == nil
278−}
279−
280−// EnrichSession adds values and data from the Gitlab endpoint to current session
281−func (p *GitLabProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error {
282− // Retrieve user info
283− userInfo, err := p.getUserInfo(ctx, s)
284− if err != nil {
285− return fmt.Errorf("failed to retrieve user info: %v", err)
286− }
287−
288− // Check if email is verified
289− if !p.AllowUnverifiedEmail && !userInfo.EmailVerified {
290− return fmt.Errorf("user email is not verified")
291− }
292−
293− s.User = userInfo.Username
294− s.Email = userInfo.Email
295− for _, group := range userInfo.Groups {
296− s.Groups = append(s.Groups, fmt.Sprintf("group:%s", group))
255+// RefreshSession refreshes the session with the OIDCProvider implementation
256+// but preserves the custom GitLab projects added in the `EnrichSession` stage.
257+func (p *GitLabProvider) RefreshSession(ctx context.Context, s *sessions.SessionState) (bool, error) {
258+ nickname := s.User
259+ projects := getSessionProjects(s)
260+ // This will overwrite s.Groups with the new IDToken's `groups` claims
261+ // and s.User with the `sub` claim.
262+ refreshed, err := p.oidcRefreshFunc(ctx, s)
263+ if refreshed && err == nil {
264+ s.User = nickname
265+ s.Groups = append(s.Groups, projects...)
266+ s.Groups = deduplicateGroups(s.Groups)
297267 }
298−
299− p.addProjectsToSession(ctx, s)
300−
301− return nil
268+ return refreshed, err
302269}
303270
304−// addProjectsToSession adds projects matching user access requirements into the session state groups list
305−// This method prefix projects names with `project` to specify group kind
306−func (p *GitLabProvider) addProjectsToSession(ctx context.Context, s *sessions.SessionState) {
307− // Iterate over projects, check if oauth2-proxy can get project information on behalf of the user
308− for _, project := range p.Projects {
309− projectInfo, err := p.getProjectInfo(ctx, s, project.Name)
310−
311− if err != nil {
312− logger.Errorf("Warning: project info request failed: %v", err)
313− continue
314− }
315−
316− if !projectInfo.Archived {
317− perms := projectInfo.Permissions.ProjectAccess
318− if perms == nil {
319− // use group project access as fallback
320− perms = projectInfo.Permissions.GroupAccess
321− // group project access is not set for this user then we give up
322− if perms == nil {
323− logger.Errorf("Warning: user %q has no project level access to %s", s.Email, project.Name)
324− continue
325− }
326− }
327−
328− if perms != nil && perms.AccessLevel >= project.AccessLevel {
329− s.Groups = append(s.Groups, fmt.Sprintf("project:%s", project.Name))
330− } else {
331− logger.Errorf("Warning: user %q does not have the minimum required access level for project %q", s.Email, project.Name)
332− }
333− continue
271+func getSessionProjects(s *sessions.SessionState) []string {
272+ var projects []string
273+ for _, group := range s.Groups {
274+ if strings.HasPrefix(group, gitlabProjectPrefix) {
275+ projects = append(projects, group)
334276 }
335−
336− logger.Errorf("Warning: project %s is archived", project.Name)
337277 }
278+ return projects
338279}
339280
340−// PrefixAllowedGroups returns a list of allowed groups, prefixed by their `kind` value
341−func (p *GitLabProvider) PrefixAllowedGroups() (groups []string) {
342− for _, val := range p.Groups {
343− groups = append(groups, fmt.Sprintf("group:%s", val))
281+func deduplicateGroups(groups []string) []string {
282+ groupSet := make(map[string]struct{})
283+ for _, group := range groups {
284+ groupSet[group] = struct{}{}
344285 }
345− for _, val := range p.Projects {
346− groups = append(groups, fmt.Sprintf("project:%s", val.Name))
286+
287+ uniqueGroups := make([]string, 0, len(groupSet))
288+ for group := range groupSet {
289+ uniqueGroups = append(uniqueGroups, group)
347290 }
348− return groups
291+ return uniqueGroups
349292}