mirror of
https://github.com/oauth2-proxy/oauth2-proxy.git
synced 2024-12-12 11:15:02 +02:00
fd2807c091
* Fix uninitialized user claim Some providers doesn't initialize data with setProviderDefaults function (keycloak-oidc for example), therefore UserClaim is never initialized with the default value and stay as an empty string. This result in an empty user. * Add CHANGELOG.md entry for #1873 * Call setProviderDefaults where missing Co-authored-by: Joel Speed <Joel.speed@hotmail.co.uk>
304 lines
8.3 KiB
Go
304 lines
8.3 KiB
Go
package providers
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
|
|
"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"
|
|
)
|
|
|
|
const (
|
|
gitlabProviderName = "GitLab"
|
|
gitlabDefaultScope = "openid email"
|
|
gitlabProjectPrefix = "project:"
|
|
)
|
|
|
|
// GitLabProvider represents a GitLab based Identity Provider
|
|
type GitLabProvider struct {
|
|
*OIDCProvider
|
|
|
|
allowedProjects []*gitlabProject
|
|
// Expose this for unit testing
|
|
oidcRefreshFunc func(context.Context, *sessions.SessionState) (bool, error)
|
|
}
|
|
|
|
var _ Provider = (*GitLabProvider)(nil)
|
|
|
|
// NewGitLabProvider initiates a new GitLabProvider
|
|
func NewGitLabProvider(p *ProviderData, opts options.GitLabOptions) (*GitLabProvider, error) {
|
|
p.setProviderDefaults(providerDefaults{
|
|
name: gitlabProviderName,
|
|
})
|
|
|
|
if p.Scope == "" {
|
|
p.Scope = gitlabDefaultScope
|
|
}
|
|
|
|
oidcProvider := &OIDCProvider{
|
|
ProviderData: p,
|
|
SkipNonce: false,
|
|
}
|
|
|
|
provider := &GitLabProvider{
|
|
OIDCProvider: oidcProvider,
|
|
oidcRefreshFunc: oidcProvider.RefreshSession,
|
|
}
|
|
provider.setAllowedGroups(opts.Group)
|
|
|
|
if err := provider.setAllowedProjects(opts.Projects); err != nil {
|
|
return nil, fmt.Errorf("could not configure allowed projects: %v", err)
|
|
}
|
|
|
|
return provider, nil
|
|
}
|
|
|
|
// 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
|
|
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) {
|
|
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])
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
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 retrieve user info: %v", err)
|
|
}
|
|
|
|
// Check if email is verified
|
|
if !p.AllowUnverifiedEmail && !userinfo.EmailVerified {
|
|
return fmt.Errorf("user email is not verified")
|
|
}
|
|
|
|
if userinfo.Nickname != "" {
|
|
s.User = userinfo.Nickname
|
|
}
|
|
if userinfo.Email != "" {
|
|
s.Email = userinfo.Email
|
|
}
|
|
if len(userinfo.Groups) > 0 {
|
|
s.Groups = userinfo.Groups
|
|
}
|
|
|
|
// Add projects as `project:blah` to s.Groups
|
|
p.addProjectsToSession(ctx, s)
|
|
|
|
return nil
|
|
}
|
|
|
|
type gitlabUserinfo struct {
|
|
Nickname 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) {
|
|
// 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"
|
|
|
|
var userinfo gitlabUserinfo
|
|
err := requests.New(userinfoURL.String()).
|
|
WithContext(ctx).
|
|
SetHeader("Authorization", "Bearer "+s.AccessToken).
|
|
Do().
|
|
UnmarshalInto(&userinfo)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error getting user info: %v", err)
|
|
}
|
|
|
|
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 {
|
|
AccessLevel int `json:"access_level"`
|
|
}
|
|
|
|
type gitlabProjectPermission struct {
|
|
ProjectAccess *gitlabPermissionAccess `json:"project_access"`
|
|
GroupAccess *gitlabPermissionAccess `json:"group_access"`
|
|
}
|
|
|
|
type gitlabProjectInfo struct {
|
|
Name string `json:"name"`
|
|
Archived bool `json:"archived"`
|
|
PathWithNamespace string `json:"path_with_namespace"`
|
|
Permissions gitlabProjectPermission `json:"permissions"`
|
|
}
|
|
|
|
func (p *GitLabProvider) getProjectInfo(ctx context.Context, s *sessions.SessionState, project string) (*gitlabProjectInfo, error) {
|
|
var projectInfo gitlabProjectInfo
|
|
|
|
endpointURL := &url.URL{
|
|
Scheme: p.LoginURL.Scheme,
|
|
Host: p.LoginURL.Host,
|
|
Path: "/api/v4/projects/",
|
|
}
|
|
|
|
err := requests.New(fmt.Sprintf("%s%s", endpointURL.String(), url.QueryEscape(project))).
|
|
WithContext(ctx).
|
|
SetHeader("Authorization", "Bearer "+s.AccessToken).
|
|
Do().
|
|
UnmarshalInto(&projectInfo)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get project info: %v", err)
|
|
}
|
|
|
|
return &projectInfo, nil
|
|
}
|
|
|
|
func formatProject(project *gitlabProject) string {
|
|
return gitlabProjectPrefix + project.Name
|
|
}
|
|
|
|
// RefreshSession refreshes the session with the OIDCProvider implementation
|
|
// but preserves the custom GitLab projects added in the `EnrichSession` stage.
|
|
func (p *GitLabProvider) RefreshSession(ctx context.Context, s *sessions.SessionState) (bool, error) {
|
|
nickname := s.User
|
|
projects := getSessionProjects(s)
|
|
// This will overwrite s.Groups with the new IDToken's `groups` claims
|
|
// and s.User with the `sub` claim.
|
|
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
|
|
}
|
|
|
|
func deduplicateGroups(groups []string) []string {
|
|
groupSet := make(map[string]struct{})
|
|
for _, group := range groups {
|
|
groupSet[group] = struct{}{}
|
|
}
|
|
|
|
uniqueGroups := make([]string, 0, len(groupSet))
|
|
for group := range groupSet {
|
|
uniqueGroups = append(uniqueGroups, group)
|
|
}
|
|
return uniqueGroups
|
|
}
|