git.iko.soy/typeable/oauth2-proxy
on ea261ca help sign in
292 lines 8.2 kB history raw
1package providers
3import (
4 "context"
5 "fmt"
6 "net/url"
7 "strconv"
8 "strings"
10 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
11 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
12 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
15const (
16 gitlabProviderName = "GitLab"
17 gitlabDefaultScope = "openid email"
18 gitlabProjectPrefix = "project:"
21// GitLabProvider represents a GitLab based Identity Provider
22type GitLabProvider struct {
23 *OIDCProvider
25 allowedProjects []*gitlabProject
26 // Expose this for unit testing
27 oidcRefreshFunc func(context.Context, *sessions.SessionState) (bool, error)
30var _ Provider = (*GitLabProvider)(nil)
32// NewGitLabProvider initiates a new GitLabProvider
33func NewGitLabProvider(p *ProviderData) *GitLabProvider {
34 p.ProviderName = gitlabProviderName
35 if p.Scope == "" {
36 p.Scope = gitlabDefaultScope
37 }
39 oidcProvider := &OIDCProvider{
40 ProviderData: p,
41 SkipNonce: false,
42 }
44 return &GitLabProvider{
45 OIDCProvider: oidcProvider,
46 oidcRefreshFunc: oidcProvider.RefreshSession,
47 }
50// SetAllowedProjects adds Gitlab projects to the AllowedGroups list
51// and tracks them to do a project API lookup during `EnrichSession`.
52func (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
67// gitlabProject represents a Gitlab project constraint entity
68type gitlabProject struct {
69 Name string
70 AccessLevel int
73// newGitlabProject Creates a new GitlabProject struct from project string
74// formatted as `namespace/project=accesslevel`
75// if no accesslevel provided, use the default one
76func newGitlabProject(project string) (*gitlabProject, error) {
77 const defaultAccessLevel = 20
78 // see https://docs.gitlab.com/ee/api/members.html#valid-access-levels
79 validAccessLevel := [4]int{10, 20, 30, 40}
81 parts := strings.SplitN(project, "=", 2)
82 if len(parts) == 2 {
83 lvl, err := strconv.Atoi(parts[1])
84 if err != nil {
85 return nil, err
86 }
87 for _, valid := range validAccessLevel {
88 if lvl == valid {
89 return &gitlabProject{
90 Name: parts[0],
91 AccessLevel: lvl,
92 }, nil
93 }
94 }
95 return nil, fmt.Errorf("invalid gitlab project access level specified (%s)", parts[0])
96 }
98 return &gitlabProject{
99 Name: project,
100 AccessLevel: defaultAccessLevel,
101 }, nil
104// setProjectScope ensures read_api is added to scope when filtering on projects
105func (p *GitLabProvider) setProjectScope() {
106 for _, val := range strings.Split(p.Scope, " ") {
107 if val == "read_api" {
108 return
109 }
110 }
111 p.Scope += " read_api"
114// EnrichSession enriches the session with the response from the userinfo API
115// endpoint & projects API endpoint for allowed projects.
116func (p *GitLabProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error {
117 // Retrieve user info
118 userinfo, err := p.getUserinfo(ctx, s)
119 if err != nil {
120 return fmt.Errorf("failed to retrieve user info: %v", err)
121 }
123 // Check if email is verified
124 if !p.AllowUnverifiedEmail && !userinfo.EmailVerified {
125 return fmt.Errorf("user email is not verified")
126 }
128 if userinfo.Nickname != "" {
129 s.User = userinfo.Nickname
130 }
131 if userinfo.Email != "" {
132 s.Email = userinfo.Email
133 }
134 if len(userinfo.Groups) > 0 {
135 s.Groups = userinfo.Groups
136 }
138 // Add projects as `project:blah` to s.Groups
139 p.addProjectsToSession(ctx, s)
141 return nil
144type gitlabUserinfo struct {
145 Nickname string `json:"nickname"`
146 Email string `json:"email"`
147 EmailVerified bool `json:"email_verified"`
148 Groups []string `json:"groups"`
151func (p *GitLabProvider) getUserinfo(ctx context.Context, s *sessions.SessionState) (*gitlabUserinfo, error) {
152 // Retrieve user info JSON
153 // https://docs.gitlab.com/ee/integration/openid_connect_provider.html#shared-information
155 // Build user info url from login url of GitLab instance
156 userinfoURL := *p.LoginURL
157 userinfoURL.Path = "/oauth/userinfo"
159 var userinfo gitlabUserinfo
160 err := requests.New(userinfoURL.String()).
161 WithContext(ctx).
162 SetHeader("Authorization", "Bearer "+s.AccessToken).
163 Do().
164 UnmarshalInto(&userinfo)
165 if err != nil {
166 return nil, fmt.Errorf("error getting user info: %v", err)
167 }
169 return &userinfo, nil
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.
175func (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 }
184 if projectInfo.Archived {
185 logger.Errorf("Warning: project %s is archived", project.Name)
186 continue
187 }
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 }
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 }
210 s.Groups = append(s.Groups, formatProject(project))
211 }
214type gitlabPermissionAccess struct {
215 AccessLevel int `json:"access_level"`
218type gitlabProjectPermission struct {
219 ProjectAccess *gitlabPermissionAccess `json:"project_access"`
220 GroupAccess *gitlabPermissionAccess `json:"group_access"`
223type gitlabProjectInfo struct {
224 Name string `json:"name"`
225 Archived bool `json:"archived"`
226 PathWithNamespace string `json:"path_with_namespace"`
227 Permissions gitlabProjectPermission `json:"permissions"`
230func (p *GitLabProvider) getProjectInfo(ctx context.Context, s *sessions.SessionState, project string) (*gitlabProjectInfo, error) {
231 var projectInfo gitlabProjectInfo
233 endpointURL := &url.URL{
234 Scheme: p.LoginURL.Scheme,
235 Host: p.LoginURL.Host,
236 Path: "/api/v4/projects/",
237 }
239 err := requests.New(fmt.Sprintf("%s%s", endpointURL.String(), url.QueryEscape(project))).
240 WithContext(ctx).
241 SetHeader("Authorization", "Bearer "+s.AccessToken).
242 Do().
243 UnmarshalInto(&projectInfo)
244 if err != nil {
245 return nil, fmt.Errorf("failed to get project info: %v", err)
246 }
248 return &projectInfo, nil
251func formatProject(project *gitlabProject) string {
252 return gitlabProjectPrefix + project.Name
255// RefreshSession refreshes the session with the OIDCProvider implementation
256// but preserves the custom GitLab projects added in the `EnrichSession` stage.
257func (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)
267 }
268 return refreshed, err
271func 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)
276 }
277 }
278 return projects
281func deduplicateGroups(groups []string) []string {
282 groupSet := make(map[string]struct{})
283 for _, group := range groups {
284 groupSet[group] = struct{}{}
285 }
287 uniqueGroups := make([]string, 0, len(groupSet))
288 for group := range groupSet {
289 uniqueGroups = append(uniqueGroups, group)
290 }
291 return uniqueGroups