diff --git a/README.md b/README.md index 57ac672cf..066bd3ca5 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,7 @@ Usage of oauth2_proxy: -pass-access-token=false: pass OAuth access_token to upstream via X-Forwarded-Access-Token header -pass-basic-auth=true: pass HTTP Basic Auth, X-Forwarded-User and X-Forwarded-Email information to upstream -pass-host-header=true: pass the request Host Header to upstream + -pass-roles-header=false: pass user's roles upstream via X-Forwarded-Roles header -profile-url="": Profile access endpoint -provider="google": OAuth provider -proxy-prefix="/oauth2": the url root path that this proxy should be nested under (e.g. //sign_in) diff --git a/contrib/oauth2_proxy.cfg.example b/contrib/oauth2_proxy.cfg.example index 4006850a4..49261347e 100644 --- a/contrib/oauth2_proxy.cfg.example +++ b/contrib/oauth2_proxy.cfg.example @@ -41,6 +41,9 @@ ## Pass OAuth Access token to upstream via "X-Forwarded-Access-Token" # pass_access_token = false +## Pass roles the user has via X-Forwarded-Roles +# pass_roles_header = false + ## Authenticated Email Addresses File (one email per line) # authenticated_emails_file = "" diff --git a/main.go b/main.go index ba3366876..6e2adc6d5 100644 --- a/main.go +++ b/main.go @@ -35,6 +35,7 @@ func main() { flagSet.String("basic-auth-password", "", "the password to set when passing the HTTP Basic Auth header") flagSet.Bool("pass-access-token", false, "pass OAuth access_token to upstream via X-Forwarded-Access-Token header") flagSet.Bool("pass-host-header", true, "pass the request Host Header to upstream") + flagSet.Bool("pass-roles-header", false, "pass user's teams upstream via X-Forwarded-Roles header") flagSet.Var(&skipAuthRegex, "skip-auth-regex", "bypass authentication for requests path's that match (may be given multiple times)") flagSet.Bool("skip-provider-button", false, "will skip sign-in-page to directly reach the next step: oauth/start") diff --git a/oauthproxy.go b/oauthproxy.go index f1a6920e7..2ab3b8941 100644 --- a/oauthproxy.go +++ b/oauthproxy.go @@ -30,6 +30,7 @@ var SignatureHeaders []string = []string{ "X-Forwarded-User", "X-Forwarded-Email", "X-Forwarded-Access-Token", + "X-Forwarded-Roles", "Cookie", "Gap-Auth", } @@ -62,6 +63,7 @@ type OAuthProxy struct { SkipProviderButton bool BasicAuthPassword string PassAccessToken bool + PassRolesHeader bool CookieCipher *cookie.Cipher skipAuthRegex []string compiledRegex []*regexp.Regexp @@ -196,6 +198,7 @@ func NewOAuthProxy(opts *Options, validator func(string) bool) *OAuthProxy { PassBasicAuth: opts.PassBasicAuth, BasicAuthPassword: opts.BasicAuthPassword, PassAccessToken: opts.PassAccessToken, + PassRolesHeader: opts.PassRolesHeader, SkipProviderButton: opts.SkipProviderButton, CookieCipher: cipher, templates: loadTemplates(opts.CustomTemplatesDir), @@ -536,6 +539,9 @@ func (p *OAuthProxy) Authenticate(rw http.ResponseWriter, req *http.Request) int } if session != nil && sessionAge > p.CookieRefresh && p.CookieRefresh != time.Duration(0) { log.Printf("%s refreshing %s old session cookie for %s (refresh after %s)", remoteAddr, sessionAge, session, p.CookieRefresh) + log.Printf("Refreshing role permissions for user") + rp := p.provider.(providers.RoleProvider) + rp.SetUserRoles(session.AccessToken) saveSession = true } @@ -605,6 +611,28 @@ func (p *OAuthProxy) Authenticate(rw http.ResponseWriter, req *http.Request) int if p.PassAccessToken && session.AccessToken != "" { req.Header["X-Forwarded-Access-Token"] = []string{session.AccessToken} } + + if p.PassRolesHeader { + rp := p.provider.(providers.RoleProvider) + roles := rp.GetUserRoles() + + // Upon restarting the proxy, if there is an existing cookie, we need to re-fetch roles from provider + // Project preference is to avoid cookie bloat, so we aren't storing roles in the cookie + // https://github.com/bitly/oauth2_proxy/issues/174#issuecomment-1578273584 + var i = 0 + if len(roles) < 1 && i < 1 { + i++ + rp.SetUserRoles(session.AccessToken) + refreshedRoles := rp.GetUserRoles() + req.Header["X-Forwarded-Roles"] = []string{refreshedRoles} + log.Printf("Refreshed user role data - %v", refreshedRoles) + + } else { + req.Header["X-Forwarded-Roles"] = []string{roles} + log.Printf("User role data - %v", roles) + } + } + if session.Email == "" { rw.Header().Set("GAP-Auth", session.User) } else { diff --git a/options.go b/options.go index 3b1366f27..086aa32c7 100644 --- a/options.go +++ b/options.go @@ -52,6 +52,7 @@ type Options struct { BasicAuthPassword string `flag:"basic-auth-password" cfg:"basic_auth_password"` PassAccessToken bool `flag:"pass-access-token" cfg:"pass_access_token"` PassHostHeader bool `flag:"pass-host-header" cfg:"pass_host_header"` + PassRolesHeader bool `flag:"pass-roles-header" cfg:"pass_roles_header"` SkipProviderButton bool `flag:"skip-provider-button" cfg:"skip_provider_button"` // These options allow for other providers besides Google, with @@ -96,6 +97,7 @@ func NewOptions() *Options { PassBasicAuth: true, PassAccessToken: false, PassHostHeader: true, + PassRolesHeader: false, SkipProviderButton: false, ApprovalPrompt: "force", RequestLogging: true, @@ -187,6 +189,13 @@ func (o *Options) Validate() error { o.CookieExpire.String())) } + // Confirm the provider type supports sending user roles + if o.PassRolesHeader { + if _, ok := o.provider.(providers.RoleProvider); !ok { + msgs = append(msgs, "Provider '"+o.provider.Data().ProviderName+"' does not support sending a roles header.") + } + } + if len(o.GoogleGroups) > 0 || o.GoogleAdminEmail != "" || o.GoogleServiceAccountJSON != "" { if len(o.GoogleGroups) < 1 { msgs = append(msgs, "missing setting: google-group") diff --git a/providers/github.go b/providers/github.go index 9101c6cf8..c5f4f366b 100644 --- a/providers/github.go +++ b/providers/github.go @@ -13,8 +13,15 @@ import ( type GitHubProvider struct { *ProviderData - Org string - Team string + Org string + Team string + userRoles []struct { + Name string `json:"name"` + Slug string `json:"slug"` + Org struct { + Login string `json:"login"` + } `json:"organization"` + } } func NewGitHubProvider(p *ProviderData) *GitHubProvider { @@ -46,6 +53,7 @@ func NewGitHubProvider(p *ProviderData) *GitHubProvider { } return &GitHubProvider{ProviderData: p} } + func (p *GitHubProvider) SetOrgTeam(org, team string) { p.Org = org p.Team = team @@ -73,6 +81,7 @@ func (p *GitHubProvider) hasOrg(accessToken string) (bool, error) { RawQuery: params.Encode(), } req, _ := http.NewRequest("GET", endpoint.String(), nil) + req.Header.Set("Accept", "application/vnd.github.v3+json") resp, err := http.DefaultClient.Do(req) if err != nil { @@ -105,17 +114,9 @@ func (p *GitHubProvider) hasOrg(accessToken string) (bool, error) { return false, nil } -func (p *GitHubProvider) hasOrgAndTeam(accessToken string) (bool, error) { - // https://developer.github.com/v3/orgs/teams/#list-user-teams - - var teams []struct { - Name string `json:"name"` - Slug string `json:"slug"` - Org struct { - Login string `json:"login"` - } `json:"organization"` - } +func (p *GitHubProvider) SetUserRoles(accessToken string) (bool, error) { + // https://developer.github.com/v3/orgs/teams/#list-user-teams params := url.Values{ "access_token": {accessToken}, "limit": {"100"}, @@ -143,14 +144,21 @@ func (p *GitHubProvider) hasOrgAndTeam(accessToken string) (bool, error) { return false, fmt.Errorf("got %d from %q %s", resp.StatusCode, endpoint, body) } - if err := json.Unmarshal(body, &teams); err != nil { + if err := json.Unmarshal(body, &p.userRoles); err != nil { return false, fmt.Errorf("%s unmarshaling %s", err, body) } + log.Printf("Returned roles - %v", p.userRoles) + + return true, nil +} + +func (p *GitHubProvider) hasOrgAndTeam(accessToken string) (bool, error) { + var hasOrg bool presentOrgs := make(map[string]bool) var presentTeams []string - for _, team := range teams { + for _, team := range p.userRoles { presentOrgs[team.Org.Login] = true if p.Org == team.Org.Login { hasOrg = true @@ -183,6 +191,10 @@ func (p *GitHubProvider) GetEmailAddress(s *SessionState) (string, error) { Primary bool `json:"primary"` } + if ok, err := p.SetUserRoles(s.AccessToken); err != nil || !ok { + return "", err + } + // if we require an Org or Team, check that first if p.Org != "" { if p.Team != "" { @@ -234,3 +246,25 @@ func (p *GitHubProvider) GetEmailAddress(s *SessionState) (string, error) { return "", nil } + +// Return a filtered list of all teams assigned to a user by the organization defined in the configuration +func (p *GitHubProvider) GetUserRoles() string { + + // Todo - could abstract this filtering and refactor hasOrgAndTeam() + presentOrgs := make(map[string]bool) + var presentRoles []string + for _, team := range p.userRoles { + presentOrgs[team.Org.Login] = true + if p.Org == team.Org.Login { + ts := strings.Split(p.Team, ",") + for _, t := range ts { + if t == team.Slug { + log.Printf("Found Github Organization:%q Team:%q (Name:%q)", team.Org.Login, team.Slug, team.Name) + } + } + presentRoles = append(presentRoles, team.Slug) + } + } + + return strings.Join(presentRoles, ",") +} diff --git a/providers/provider_default.go b/providers/provider_default.go index 82b73ec3d..637c54944 100644 --- a/providers/provider_default.go +++ b/providers/provider_default.go @@ -9,7 +9,6 @@ import ( "net/http" "net/url" "strings" - "github.com/bitly/oauth2_proxy/cookie" ) @@ -122,4 +121,4 @@ func (p *ProviderData) ValidateSessionState(s *SessionState) bool { // RefreshSessionIfNeeded func (p *ProviderData) RefreshSessionIfNeeded(s *SessionState) (bool, error) { return false, nil -} +} \ No newline at end of file diff --git a/providers/providers.go b/providers/providers.go index fb2e5fc51..59c36bf6f 100644 --- a/providers/providers.go +++ b/providers/providers.go @@ -4,6 +4,8 @@ import ( "github.com/bitly/oauth2_proxy/cookie" ) +// Provider is the primary interface for an authentication provider +// all provider type Provider interface { Data() *ProviderData GetEmailAddress(*SessionState) (string, error) @@ -16,6 +18,15 @@ type Provider interface { CookieForSession(*SessionState, *cookie.Cipher) (string, error) } +// RoleProvider is an optional interface that exposes a list of roles +// for a user. For Providers like GitHub this would be the teams the user +// is a member of. +type RoleProvider interface { + GetUserRoles() string + SetUserRoles(string) (bool, error) +} + +// New gives you an instance of the given provider func New(provider string, p *ProviderData) Provider { switch provider { case "myusa":