Merge branch 'master' into feature/add_oidc_userinfo_support

This commit is contained in:
Ryan Luckie 2019-08-12 17:32:29 -05:00 committed by GitHub
commit c457eeb711
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 544 additions and 218 deletions

6
.github/CODEOWNERS vendored
View File

@ -1,6 +1,6 @@
# Default owner should be a Pusher cloud-team member unless overridden by later
# rules in this file
* @pusher/cloud-team
# Default owner should be a Pusher cloud-team member or another maintainer
# unless overridden by later rules in this file
* @pusher/cloud-team @syscll @steakunderscore
# login.gov provider
# Note: If @timothy-spencer terms out of his appointment, your best bet

View File

@ -3,10 +3,8 @@ go:
- 1.12.x
install:
# Fetch dependencies
- wget -O dep https://github.com/golang/dep/releases/download/v0.5.0/dep-linux-amd64
- chmod +x dep
- mv dep $GOPATH/bin/dep
- curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh -s -- -b $GOPATH/bin v1.17.1
- GO111MODULE=on go mod download
script:
- ./configure && make test
sudo: false

View File

@ -2,6 +2,11 @@
## Breaking Changes
- [#231](https://github.com/pusher/oauth2_proxy/pull/231) Rework GitLab provider (@Overv)
- This PR changes the configuration options for the GitLab provider to use
a self-hosted instance. You now need to specify a `-oidc-issuer-url` rather than
explicit `-login-url`, `-redeem-url` and `-validate-url` parameters.
- [#186](https://github.com/pusher/oauth2_proxy/pull/186) Make config consistent
- This PR changes configuration options so that all flags have a config counterpart
of the same name but with underscores (`_`) in place of hyphens (`-`).
@ -31,18 +36,21 @@
## Changes since v3.2.0
- [#178](https://github.com/pusher/outh2_proxy/pull/178) Add Silence Ping Logging and Exclude Logging Paths flags (@kskewes)
- [#209](https://github.com/pusher/outh2_proxy/pull/209) Improve docker build caching of layers (@dekimsey)
- [#234](https://github.com/pusher/oauth2_proxy/pull/234) Added option `-ssl-upstream-insecure-skip-validation` to skip validation of upstream SSL certificates (@jansinger)
- [#224](https://github.com/pusher/oauth2_proxy/pull/224) Check Google group membership using hasMember to support nested groups and external users (@jpalpant)
- [#231](https://github.com/pusher/oauth2_proxy/pull/231) Add optional group membership and email domain checks to the GitLab provider (@Overv)
- [#178](https://github.com/pusher/oauth2_proxy/pull/178) Add Silence Ping Logging and Exclude Logging Paths flags (@kskewes)
- [#209](https://github.com/pusher/oauth2_proxy/pull/209) Improve docker build caching of layers (@dekimsey)
- [#186](https://github.com/pusher/oauth2_proxy/pull/186) Make config consistent (@JoelSpeed)
- [#187](https://github.com/pusher/oauth2_proxy/pull/187) Move root packages to pkg folder (@JoelSpeed)
- [#65](https://github.com/pusher/oauth2_proxy/pull/65) Improvements to authenticate requests with a JWT bearer token in the `Authorization` header via
the `-skip-jwt-bearer-token` options.
- Additional verifiers can be configured via the `-extra-jwt-issuers` flag if the JWT issuers is either an OpenID provider or has a JWKS URL
(e.g. `https://example.com/.well-known/jwks.json`).
- [#180](https://github.com/pusher/outh2_proxy/pull/180) Minor refactor of core proxying path (@aeijdenberg).
- [#175](https://github.com/pusher/outh2_proxy/pull/175) Bump go-oidc to v2.0.0 (@aeijdenberg).
- [#180](https://github.com/pusher/oauth2_proxy/pull/180) Minor refactor of core proxying path (@aeijdenberg).
- [#175](https://github.com/pusher/oauth2_proxy/pull/175) Bump go-oidc to v2.0.0 (@aeijdenberg).
- Includes fix for potential signature checking issue when OIDC discovery is skipped.
- [#155](https://github.com/pusher/outh2_proxy/pull/155) Add RedisSessionStore implementation (@brianv0, @JoelSpeed)
- [#155](https://github.com/pusher/oauth2_proxy/pull/155) Add RedisSessionStore implementation (@brianv0, @JoelSpeed)
- Implement flags to configure the redis session store
- `-session-store-type=redis` Sets the store type to redis
- `-redis-connection-url` Sets the Redis connection URL
@ -52,10 +60,10 @@
- Introduces the concept of a session ticket. Tickets are composed of the cookie name, a session ID, and a secret.
- Redis Sessions are stored encrypted with a per-session secret
- Added tests for server based session stores
- [#168](https://github.com/pusher/outh2_proxy/pull/168) Drop Go 1.11 support in Travis (@JoelSpeed)
- [#169](https://github.com/pusher/outh2_proxy/pull/169) Update Alpine to 3.9 (@kskewes)
- [#148](https://github.com/pusher/outh2_proxy/pull/148) Implement SessionStore interface within proxy (@JoelSpeed)
- [#147](https://github.com/pusher/outh2_proxy/pull/147) Add SessionStore interfaces and initial implementation (@JoelSpeed)
- [#168](https://github.com/pusher/oauth2_proxy/pull/168) Drop Go 1.11 support in Travis (@JoelSpeed)
- [#169](https://github.com/pusher/oauth2_proxy/pull/169) Update Alpine to 3.9 (@kskewes)
- [#148](https://github.com/pusher/oauth2_proxy/pull/148) Implement SessionStore interface within proxy (@JoelSpeed)
- [#147](https://github.com/pusher/oauth2_proxy/pull/147) Add SessionStore interfaces and initial implementation (@JoelSpeed)
- Allows for multiple different session storage implementations including client and server side
- Adds tests suite for interface to ensure consistency across implementations
- Refactor some configuration options (around cookies) into packages
@ -83,8 +91,8 @@
- [#185](https://github.com/pusher/oauth2_proxy/pull/185) Fix an unsupported protocol scheme error during token validation when using the Azure provider (@jonas)
- [#141](https://github.com/pusher/oauth2_proxy/pull/141) Check google group membership based on email address (@bchess)
- Google Group membership is additionally checked via email address, allowing users outside a GSuite domain to be authorized.
- [#195](https://github.com/pusher/outh2_proxy/pull/195) Add `-banner` flag for overriding the banner line that is displayed (@steakunderscore)
- [#198](https://github.com/pusher/outh2_proxy/pull/198) Switch from gometalinter to golangci-lint (@steakunderscore)
- [#195](https://github.com/pusher/oauth2_proxy/pull/195) Add `-banner` flag for overriding the banner line that is displayed (@steakunderscore)
- [#198](https://github.com/pusher/oauth2_proxy/pull/198) Switch from gometalinter to golangci-lint (@steakunderscore)
- [#159](https://github.com/pusher/oauth2_proxy/pull/159) Add option to skip the OIDC provider verified email check: `--insecure-oidc-allow-unverified-email`
- [#210](https://github.com/pusher/oauth2_proxy/pull/210) Update base image from Alpine 3.9 to 3.10 (@steakunderscore)
- [#211](https://github.com/pusher/oauth2_proxy/pull/211) Switch from dep to go modules (@steakunderscore)

3
MAINTAINERS Normal file
View File

@ -0,0 +1,3 @@
Joel Speed <joel.speed@hotmail.co.uk> (@JoelSpeed)
Dan Bond (@syscll)
Henry Jenkins <henry@henryjenkins.name> (@steakunderscore)

View File

@ -38,6 +38,10 @@ Read the docs on our [Docs site](https://pusher.github.io/oauth2_proxy).
![OAuth2 Proxy Architecture](https://cloud.githubusercontent.com/assets/45028/8027702/bd040b7a-0d6a-11e5-85b9-f8d953d04f39.png)
## Getting Involved
If you would like to reach out to the maintainers, come talk to us in the `#oauth2_proxy` channel in the [Gophers slack](http://gophers.slack.com/).
## Contributing
Please see our [Contributing](CONTRIBUTING.md) guidelines.

View File

@ -103,13 +103,15 @@ If you are using GitHub enterprise, make sure you set the following to the appro
### GitLab Auth Provider
Whether you are using GitLab.com or self-hosting GitLab, follow [these steps to add an application](http://doc.gitlab.com/ce/integration/oauth_provider.html)
Whether you are using GitLab.com or self-hosting GitLab, follow [these steps to add an application](http://doc.gitlab.com/ce/integration/oauth_provider.html). Make sure to enable at least the `openid`, `profile` and `email` scopes.
Restricting by group membership is possible with the following option:
-gitlab-group="": restrict logins to members of any of these groups (slug), separated by a comma
If you are using self-hosted GitLab, make sure you set the following to the appropriate URL:
-login-url="<your gitlab url>/oauth/authorize"
-redeem-url="<your gitlab url>/oauth/token"
-validate-url="<your gitlab url>/api/v4/user"
-oidc-issuer-url="<your gitlab url>"
### LinkedIn Auth Provider
@ -144,6 +146,56 @@ OpenID Connect is a spec for OAUTH 2.0 + identity that is implemented by many ma
-cookie-secure=false
-email-domain example.com
The OpenID Connect Provider (OIDC) can also be used to connect to other Identity Providers such as Okta. To configure the OIDC provider for Okta, perform
the following steps:
#### Configuring the OIDC Provider with Okta
1. Log in to Okta using an administrative account. It is suggested you try this in preview first, `example.oktapreview.com`
2. (OPTIONAL) If you want to configure authorization scopes and claims to be passed on to multiple applications,
you may wish to configure an authorization server for each application. Otherwise, the provided `default` will work.
* Navigate to **Security** then select **API**
* Click **Add Authorization Server**, if this option is not available you may require an additional license for a custom authorization server.
* Fill out the **Name** with something to describe the application you are protecting. e.g. 'Example App'.
* For **Audience**, pick the URL of the application you wish to protect: https://example.corp.com
* Fill out a **Description**
* Add any **Access Policies** you wish to configure to limit application access.
* The default settings will work for other options.
[See Okta documentation for more information on Authorization Servers](https://developer.okta.com/docs/guides/customize-authz-server/overview/)
3. Navigate to **Applications** then select **Add Application**.
* Select **Web** for the **Platform** setting.
* Select **OpenID Connect** and click **Create**
* Pick an **Application Name** such as `Example App`.
* Set the **Login redirect URI** to `https://example.corp.com`.
* Under **General** set the **Allowed grant types** to `Authorization Code` and `Refresh Token`.
* Leave the rest as default, taking note of the `Client ID` and `Client Secret`.
* Under **Assignments** select the users or groups you wish to access your application.
4. Create a configuration file like the following:
```
provider = "oidc"
redirect_url = "https://example.corp.com"
oidc_issuer_url = "https://corp.okta.com/oauth2/abCd1234"
upstreams = [
"https://example.corp.com"
]
email_domains = [
"corp.com"
]
client_id = "XXXXX"
client_secret = "YYYYY"
pass_access_token = true
cookie_secret = "ZZZZZ"
skip_provider_button = true
```
The `oidc_issuer_url` is based on URL from your **Authorization Server**'s **Issuer** field in step 2, or simply https://corp.okta.com
The `client_id` and `client_secret` are configured in the application settings.
Generate a unique `client_secret` to encrypt the cookie.
Then you can start the oauth2_proxy with `./oauth2_proxy -config /etc/example.cfg`
### login.gov Provider
login.gov is an OIDC provider for the US Government.

View File

@ -14,7 +14,7 @@ To generate a strong cookie secret use `python -c 'import os,base64; print base6
### Config File
An example [oauth2_proxy.cfg](contrib/oauth2_proxy.cfg.example) config file is in the contrib directory. It can be used by specifying `-config=/etc/oauth2_proxy.cfg`
An example [oauth2_proxy.cfg](https://github.com/pusher/oauth2_proxy/blob/master/contrib/oauth2_proxy.cfg.example) config file is in the contrib directory. It can be used by specifying `-config=/etc/oauth2_proxy.cfg`
### Command Line Options
@ -49,6 +49,7 @@ Usage of oauth2_proxy:
-gcp-healthchecks: will enable /liveness_check, /readiness_check, and / (with the proper user-agent) endpoints that will make it work well with GCP App Engine and GKE Ingresses (default false)
-github-org string: restrict logins to members of this organisation
-github-team string: restrict logins to members of any of these teams (slug), separated by a comma
-gitlab-group string: restrict logins to members of any of these groups (slug), separated by a comma
-google-admin-email string: the google admin to impersonate for api calls
-google-group value: restrict logins to members of this google group (may be given multiple times).
-google-service-account-json string: the path to the service account json credentials
@ -98,7 +99,8 @@ Usage of oauth2_proxy:
-skip-jwt-bearer-tokens: will skip requests that have verified JWT bearer tokens
-skip-oidc-discovery: bypass OIDC endpoint discovery. login-url, redeem-url and oidc-jwks-url must be configured in this case
-skip-provider-button: will skip sign-in-page to directly reach the next step: oauth/start
-ssl-insecure-skip-verify: skip validation of certificates presented when using HTTPS
-ssl-insecure-skip-verify: skip validation of certificates presented when using HTTPS providers
-ssl-upstream-insecure-skip-verify: skip validation of certificates presented when using HTTPS upstreams
-standard-logging: Log standard runtime information (default true)
-standard-logging-format string: Template for standard log lines (see "Logging Configuration" paragraph below)
-tls-cert-file string: path to certificate file

View File

@ -47,7 +47,8 @@ func main() {
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")
flagSet.Bool("skip-auth-preflight", false, "will skip authentication for OPTIONS requests")
flagSet.Bool("ssl-insecure-skip-verify", false, "skip validation of certificates presented when using HTTPS")
flagSet.Bool("ssl-insecure-skip-verify", false, "skip validation of certificates presented when using HTTPS providers")
flagSet.Bool("ssl-upstream-insecure-skip-verify", false, "skip validation of certificates presented when using HTTPS upstreams")
flagSet.Duration("flush-interval", time.Duration(1)*time.Second, "period between response flushing when streaming responses")
flagSet.Bool("skip-jwt-bearer-tokens", false, "will skip requests that have verified JWT bearer tokens (default false)")
flagSet.Var(&jwtIssuers, "extra-jwt-issuers", "if skip-jwt-bearer-tokens is set, a list of extra JWT issuer=audience pairs (where the issuer URL has a .well-known/openid-configuration or a .well-known/jwks.json)")
@ -57,6 +58,7 @@ func main() {
flagSet.String("azure-tenant", "common", "go to a tenant-specific or common (tenant-independent) endpoint.")
flagSet.String("github-org", "", "restrict logins to members of this organisation")
flagSet.String("github-team", "", "restrict logins to members of this team")
flagSet.String("gitlab-group", "", "restrict logins to members of this group")
flagSet.Var(&googleGroups, "google-group", "restrict logins to members of this google group (may be given multiple times).")
flagSet.String("google-admin-email", "", "the google admin to impersonate for api calls")
flagSet.String("google-service-account-json", "", "the path to the service account json credentials")

View File

@ -2,6 +2,7 @@ package main
import (
"context"
"crypto/tls"
b64 "encoding/base64"
"errors"
"fmt"
@ -128,9 +129,14 @@ func (u *UpstreamProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// NewReverseProxy creates a new reverse proxy for proxying requests to upstream
// servers
func NewReverseProxy(target *url.URL, flushInterval time.Duration) (proxy *httputil.ReverseProxy) {
func NewReverseProxy(target *url.URL, opts *Options) (proxy *httputil.ReverseProxy) {
proxy = httputil.NewSingleHostReverseProxy(target)
proxy.FlushInterval = flushInterval
proxy.FlushInterval = opts.FlushInterval
if opts.SSLUpstreamInsecureSkipVerify {
proxy.Transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
}
return proxy
}
@ -163,7 +169,7 @@ func NewFileServer(path string, filesystemPath string) (proxy http.Handler) {
// NewWebSocketOrRestReverseProxy creates a reverse proxy for REST or websocket based on url
func NewWebSocketOrRestReverseProxy(u *url.URL, opts *Options, auth hmacauth.HmacAuth) http.Handler {
u.Path = ""
proxy := NewReverseProxy(u, opts.FlushInterval)
proxy := NewReverseProxy(u, opts)
if !opts.PassHostHeader {
setProxyUpstreamHostHeader(proxy, u)
} else {

View File

@ -122,7 +122,7 @@ func TestNewReverseProxy(t *testing.T) {
backendHost := net.JoinHostPort(backendHostname, backendPort)
proxyURL, _ := url.Parse(backendURL.Scheme + "://" + backendHost + "/")
proxyHandler := NewReverseProxy(proxyURL, time.Second)
proxyHandler := NewReverseProxy(proxyURL, &Options{FlushInterval: time.Second})
setProxyUpstreamHostHeader(proxyHandler, proxyURL)
frontend := httptest.NewServer(proxyHandler)
defer frontend.Close()
@ -144,7 +144,7 @@ func TestEncodedSlashes(t *testing.T) {
defer backend.Close()
b, _ := url.Parse(backend.URL)
proxyHandler := NewReverseProxy(b, time.Second)
proxyHandler := NewReverseProxy(b, &Options{FlushInterval: time.Second})
setProxyDirector(proxyHandler)
frontend := httptest.NewServer(proxyHandler)
defer frontend.Close()

View File

@ -46,6 +46,7 @@ type Options struct {
WhitelistDomains []string `flag:"whitelist-domain" cfg:"whitelist_domains" env:"OAUTH2_PROXY_WHITELIST_DOMAINS"`
GitHubOrg string `flag:"github-org" cfg:"github_org" env:"OAUTH2_PROXY_GITHUB_ORG"`
GitHubTeam string `flag:"github-team" cfg:"github_team" env:"OAUTH2_PROXY_GITHUB_TEAM"`
GitLabGroup string `flag:"gitlab-group" cfg:"gitlab_group" env:"OAUTH2_PROXY_GITLAB_GROUP"`
GoogleGroups []string `flag:"google-group" cfg:"google_group" env:"OAUTH2_PROXY_GOOGLE_GROUPS"`
GoogleAdminEmail string `flag:"google-admin-email" cfg:"google_admin_email" env:"OAUTH2_PROXY_GOOGLE_ADMIN_EMAIL"`
GoogleServiceAccountJSON string `flag:"google-service-account-json" cfg:"google_service_account_json" env:"OAUTH2_PROXY_GOOGLE_SERVICE_ACCOUNT_JSON"`
@ -72,6 +73,7 @@ type Options struct {
SkipProviderButton bool `flag:"skip-provider-button" cfg:"skip_provider_button" env:"OAUTH2_PROXY_SKIP_PROVIDER_BUTTON"`
PassUserHeaders bool `flag:"pass-user-headers" cfg:"pass_user_headers" env:"OAUTH2_PROXY_PASS_USER_HEADERS"`
SSLInsecureSkipVerify bool `flag:"ssl-insecure-skip-verify" cfg:"ssl_insecure_skip_verify" env:"OAUTH2_PROXY_SSL_INSECURE_SKIP_VERIFY"`
SSLUpstreamInsecureSkipVerify bool `flag:"ssl-upstream-insecure-skip-verify" cfg:"ssl_upstream_insecure_skip_verify" env:"OAUTH2_PROXY_SSL_UPSTREAM_INSECURE_SKIP_VERIFY"`
SetXAuthRequest bool `flag:"set-xauthrequest" cfg:"set_xauthrequest" env:"OAUTH2_PROXY_SET_XAUTHREQUEST"`
SetAuthorization bool `flag:"set-authorization-header" cfg:"set_authorization_header" env:"OAUTH2_PROXY_SET_AUTHORIZATION_HEADER"`
PassAuthorization bool `flag:"pass-authorization-header" cfg:"pass_authorization_header" env:"OAUTH2_PROXY_PASS_AUTHORIZATION_HEADER"`
@ -410,6 +412,29 @@ func parseProviderInfo(o *Options, msgs []string) []string {
} else {
p.Verifier = o.oidcVerifier
}
case *providers.GitLabProvider:
p.AllowUnverifiedEmail = o.InsecureOIDCAllowUnverifiedEmail
p.Group = o.GitLabGroup
p.EmailDomains = o.EmailDomains
if o.oidcVerifier != nil {
p.Verifier = o.oidcVerifier
} else {
// Initialize with default verifier for gitlab.com
ctx := context.Background()
provider, err := oidc.NewProvider(ctx, "https://gitlab.com")
if err != nil {
msgs = append(msgs, "failed to initialize oidc provider for gitlab.com")
} else {
p.Verifier = provider.Verifier(&oidc.Config{
ClientID: o.ClientID,
})
p.LoginURL, msgs = parseURL(provider.Endpoint().AuthURL, "login", msgs)
p.RedeemURL, msgs = parseURL(provider.Endpoint().TokenURL, "redeem", msgs)
}
}
case *providers.LoginGovProvider:
p.AcrValues = o.AcrValues
p.PubJWKURL, msgs = parseURL(o.PubJWKURL, "pubjwk", msgs)

View File

@ -1,62 +1,258 @@
package providers
import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"strings"
"time"
oidc "github.com/coreos/go-oidc"
"github.com/pusher/oauth2_proxy/pkg/apis/sessions"
"github.com/pusher/oauth2_proxy/pkg/logger"
"github.com/pusher/oauth2_proxy/pkg/requests"
"golang.org/x/oauth2"
)
// GitLabProvider represents an GitLab based Identity Provider
// GitLabProvider represents a GitLab based Identity Provider
type GitLabProvider struct {
*ProviderData
Group string
EmailDomains []string
Verifier *oidc.IDTokenVerifier
AllowUnverifiedEmail bool
}
// NewGitLabProvider initiates a new GitLabProvider
func NewGitLabProvider(p *ProviderData) *GitLabProvider {
p.ProviderName = "GitLab"
if p.LoginURL == nil || p.LoginURL.String() == "" {
p.LoginURL = &url.URL{
Scheme: "https",
Host: "gitlab.com",
Path: "/oauth/authorize",
}
}
if p.RedeemURL == nil || p.RedeemURL.String() == "" {
p.RedeemURL = &url.URL{
Scheme: "https",
Host: "gitlab.com",
Path: "/oauth/token",
}
}
if p.ValidateURL == nil || p.ValidateURL.String() == "" {
p.ValidateURL = &url.URL{
Scheme: "https",
Host: "gitlab.com",
Path: "/api/v4/user",
}
}
if p.Scope == "" {
p.Scope = "read_user"
p.Scope = "openid email"
}
return &GitLabProvider{ProviderData: p}
}
// Redeem exchanges the OAuth2 authentication token for an ID token
func (p *GitLabProvider) Redeem(redirectURL, code string) (s *sessions.SessionState, err error) {
ctx := context.Background()
c := oauth2.Config{
ClientID: p.ClientID,
ClientSecret: p.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.createSessionState(ctx, token)
if err != nil {
return nil, fmt.Errorf("unable to update session: %v", err)
}
return
}
// RefreshSessionIfNeeded checks if the session has expired and uses the
// RefreshToken to fetch a new ID token if required
func (p *GitLabProvider) RefreshSessionIfNeeded(s *sessions.SessionState) (bool, error) {
if s == nil || s.ExpiresOn.After(time.Now()) || s.RefreshToken == "" {
return false, nil
}
origExpiration := s.ExpiresOn
err := p.redeemRefreshToken(s)
if err != nil {
return false, fmt.Errorf("unable to redeem refresh token: %v", err)
}
fmt.Printf("refreshed id token %s (expired on %s)\n", s, origExpiration)
return true, nil
}
func (p *GitLabProvider) redeemRefreshToken(s *sessions.SessionState) (err error) {
c := oauth2.Config{
ClientID: p.ClientID,
ClientSecret: p.ClientSecret,
Endpoint: oauth2.Endpoint{
TokenURL: p.RedeemURL.String(),
},
}
ctx := context.Background()
t := &oauth2.Token{
RefreshToken: s.RefreshToken,
Expiry: time.Now().Add(-time.Hour),
}
token, err := c.TokenSource(ctx, t).Token()
if err != nil {
return fmt.Errorf("failed to get token: %v", err)
}
newSession, err := p.createSessionState(ctx, token)
if err != nil {
return fmt.Errorf("unable to update session: %v", err)
}
s.AccessToken = newSession.AccessToken
s.IDToken = newSession.IDToken
s.RefreshToken = newSession.RefreshToken
s.CreatedAt = newSession.CreatedAt
s.ExpiresOn = newSession.ExpiresOn
s.Email = newSession.Email
return
}
type gitlabUserInfo struct {
Username string `json:"nickname"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Groups []string `json:"groups"`
}
func (p *GitLabProvider) getUserInfo(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"
req, err := http.NewRequest("GET", userInfoURL.String(), nil)
if err != nil {
return nil, fmt.Errorf("failed to create user info request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+s.AccessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to perform user info request: %v", err)
}
var body []byte
body, err = ioutil.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return nil, fmt.Errorf("failed to read user info response: %v", err)
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("got %d during user info request: %s", resp.StatusCode, body)
}
var userInfo gitlabUserInfo
err = json.Unmarshal(body, &userInfo)
if err != nil {
return nil, fmt.Errorf("failed to parse user info: %v", err)
}
return &userInfo, nil
}
func (p *GitLabProvider) verifyGroupMembership(userInfo *gitlabUserInfo) error {
if p.Group == "" {
return nil
}
// Collect user group memberships
membershipSet := make(map[string]bool)
for _, group := range userInfo.Groups {
membershipSet[group] = true
}
// Find a valid group that they are a member of
validGroups := strings.Split(p.Group, " ")
for _, validGroup := range validGroups {
if _, ok := membershipSet[validGroup]; ok {
return nil
}
}
return fmt.Errorf("user is not a member of '%s'", p.Group)
}
func (p *GitLabProvider) verifyEmailDomain(userInfo *gitlabUserInfo) error {
if len(p.EmailDomains) == 0 || p.EmailDomains[0] == "*" {
return nil
}
for _, domain := range p.EmailDomains {
if strings.HasSuffix(userInfo.Email, domain) {
return nil
}
}
return fmt.Errorf("user email is not one of the valid domains '%v'", p.EmailDomains)
}
func (p *GitLabProvider) createSessionState(ctx context.Context, token *oauth2.Token) (*sessions.SessionState, error) {
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return nil, fmt.Errorf("token response did not contain an id_token")
}
// Parse and verify ID Token payload.
idToken, err := p.Verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, fmt.Errorf("could not verify id_token: %v", err)
}
return &sessions.SessionState{
AccessToken: token.AccessToken,
IDToken: rawIDToken,
RefreshToken: token.RefreshToken,
CreatedAt: time.Now(),
ExpiresOn: idToken.Expiry,
}, nil
}
// ValidateSessionState checks that the session's IDToken is still valid
func (p *GitLabProvider) ValidateSessionState(s *sessions.SessionState) bool {
ctx := context.Background()
_, err := p.Verifier.Verify(ctx, s.IDToken)
if err != nil {
return false
}
return true
}
// GetEmailAddress returns the Account email address
func (p *GitLabProvider) GetEmailAddress(s *sessions.SessionState) (string, error) {
// Retrieve user info
userInfo, err := p.getUserInfo(s)
if err != nil {
return "", fmt.Errorf("failed to retrieve user info: %v", err)
}
req, err := http.NewRequest("GET",
p.ValidateURL.String()+"?access_token="+s.AccessToken, nil)
// Check if email is verified
if !p.AllowUnverifiedEmail && !userInfo.EmailVerified {
return "", fmt.Errorf("user email is not verified")
}
// Check if email has valid domain
err = p.verifyEmailDomain(userInfo)
if err != nil {
logger.Printf("failed building request %s", err)
return "", err
return "", fmt.Errorf("email domain check failed: %v", err)
}
json, err := requests.Request(req)
// Check group membership
err = p.verifyGroupMembership(userInfo)
if err != nil {
logger.Printf("failed making request %s", err)
return "", err
return "", fmt.Errorf("group membership check failed: %v", err)
}
return json.Get("email").String()
return userInfo.Email, nil
}
// GetUserName returns the Account user name
func (p *GitLabProvider) GetUserName(s *sessions.SessionState) (string, error) {
userInfo, err := p.getUserInfo(s)
if err != nil {
return "", fmt.Errorf("failed to retrieve user info: %v", err)
}
return userInfo.Username, nil
}

View File

@ -25,104 +25,142 @@ func testGitLabProvider(hostname string) *GitLabProvider {
updateURL(p.Data().ProfileURL, hostname)
updateURL(p.Data().ValidateURL, hostname)
}
return p
}
func testGitLabBackend(payload string) *httptest.Server {
path := "/api/v4/user"
query := "access_token=imaginary_access_token"
func testGitLabBackend() *httptest.Server {
userInfo := `
{
"nickname": "FooBar",
"email": "foo@bar.com",
"email_verified": false,
"groups": ["foo", "bar"]
}
`
authHeader := "Bearer gitlab_access_token"
return httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != path || r.URL.RawQuery != query {
w.WriteHeader(404)
} else {
if r.URL.Path == "/oauth/userinfo" {
if r.Header["Authorization"][0] == authHeader {
w.WriteHeader(200)
w.Write([]byte(payload))
w.Write([]byte(userInfo))
} else {
w.WriteHeader(401)
}
} else {
w.WriteHeader(404)
}
}))
}
func TestGitLabProviderDefaults(t *testing.T) {
p := testGitLabProvider("")
assert.NotEqual(t, nil, p)
assert.Equal(t, "GitLab", p.Data().ProviderName)
assert.Equal(t, "https://gitlab.com/oauth/authorize",
p.Data().LoginURL.String())
assert.Equal(t, "https://gitlab.com/oauth/token",
p.Data().RedeemURL.String())
assert.Equal(t, "https://gitlab.com/api/v4/user",
p.Data().ValidateURL.String())
assert.Equal(t, "read_user", p.Data().Scope)
}
func TestGitLabProviderOverrides(t *testing.T) {
p := NewGitLabProvider(
&ProviderData{
LoginURL: &url.URL{
Scheme: "https",
Host: "example.com",
Path: "/oauth/auth"},
RedeemURL: &url.URL{
Scheme: "https",
Host: "example.com",
Path: "/oauth/token"},
ValidateURL: &url.URL{
Scheme: "https",
Host: "example.com",
Path: "/api/v4/user"},
Scope: "profile"})
assert.NotEqual(t, nil, p)
assert.Equal(t, "GitLab", p.Data().ProviderName)
assert.Equal(t, "https://example.com/oauth/auth",
p.Data().LoginURL.String())
assert.Equal(t, "https://example.com/oauth/token",
p.Data().RedeemURL.String())
assert.Equal(t, "https://example.com/api/v4/user",
p.Data().ValidateURL.String())
assert.Equal(t, "profile", p.Data().Scope)
}
func TestGitLabProviderGetEmailAddress(t *testing.T) {
b := testGitLabBackend("{\"email\": \"michael.bland@gsa.gov\"}")
func TestGitLabProviderBadToken(t *testing.T) {
b := testGitLabBackend()
defer b.Close()
bURL, _ := url.Parse(b.URL)
p := testGitLabProvider(bURL.Host)
session := &sessions.SessionState{AccessToken: "imaginary_access_token"}
session := &sessions.SessionState{AccessToken: "unexpected_gitlab_access_token"}
_, err := p.GetEmailAddress(session)
assert.NotEqual(t, nil, err)
}
func TestGitLabProviderUnverifiedEmailDenied(t *testing.T) {
b := testGitLabBackend()
defer b.Close()
bURL, _ := url.Parse(b.URL)
p := testGitLabProvider(bURL.Host)
session := &sessions.SessionState{AccessToken: "gitlab_access_token"}
_, err := p.GetEmailAddress(session)
assert.NotEqual(t, nil, err)
}
func TestGitLabProviderUnverifiedEmailAllowed(t *testing.T) {
b := testGitLabBackend()
defer b.Close()
bURL, _ := url.Parse(b.URL)
p := testGitLabProvider(bURL.Host)
p.AllowUnverifiedEmail = true
session := &sessions.SessionState{AccessToken: "gitlab_access_token"}
email, err := p.GetEmailAddress(session)
assert.Equal(t, nil, err)
assert.Equal(t, "michael.bland@gsa.gov", email)
assert.Equal(t, "foo@bar.com", email)
}
// Note that trying to trigger the "failed building request" case is not
// practical, since the only way it can fail is if the URL fails to parse.
func TestGitLabProviderGetEmailAddressFailedRequest(t *testing.T) {
b := testGitLabBackend("unused payload")
func TestGitLabProviderUsername(t *testing.T) {
b := testGitLabBackend()
defer b.Close()
bURL, _ := url.Parse(b.URL)
p := testGitLabProvider(bURL.Host)
p.AllowUnverifiedEmail = true
// We'll trigger a request failure by using an unexpected access
// token. Alternatively, we could allow the parsing of the payload as
// JSON to fail.
session := &sessions.SessionState{AccessToken: "unexpected_access_token"}
email, err := p.GetEmailAddress(session)
assert.NotEqual(t, nil, err)
assert.Equal(t, "", email)
session := &sessions.SessionState{AccessToken: "gitlab_access_token"}
username, err := p.GetUserName(session)
assert.Equal(t, nil, err)
assert.Equal(t, "FooBar", username)
}
func TestGitLabProviderGetEmailAddressEmailNotPresentInPayload(t *testing.T) {
b := testGitLabBackend("{\"foo\": \"bar\"}")
func TestGitLabProviderGroupMembershipValid(t *testing.T) {
b := testGitLabBackend()
defer b.Close()
bURL, _ := url.Parse(b.URL)
p := testGitLabProvider(bURL.Host)
p.AllowUnverifiedEmail = true
p.Group = "foo"
session := &sessions.SessionState{AccessToken: "imaginary_access_token"}
session := &sessions.SessionState{AccessToken: "gitlab_access_token"}
email, err := p.GetEmailAddress(session)
assert.NotEqual(t, nil, err)
assert.Equal(t, "", email)
assert.Equal(t, nil, err)
assert.Equal(t, "foo@bar.com", email)
}
func TestGitLabProviderGroupMembershipMissing(t *testing.T) {
b := testGitLabBackend()
defer b.Close()
bURL, _ := url.Parse(b.URL)
p := testGitLabProvider(bURL.Host)
p.AllowUnverifiedEmail = true
p.Group = "baz"
session := &sessions.SessionState{AccessToken: "gitlab_access_token"}
_, err := p.GetEmailAddress(session)
assert.NotEqual(t, nil, err)
}
func TestGitLabProviderEmailDomainValid(t *testing.T) {
b := testGitLabBackend()
defer b.Close()
bURL, _ := url.Parse(b.URL)
p := testGitLabProvider(bURL.Host)
p.AllowUnverifiedEmail = true
p.EmailDomains = []string{"bar.com"}
session := &sessions.SessionState{AccessToken: "gitlab_access_token"}
email, err := p.GetEmailAddress(session)
assert.Equal(t, nil, err)
assert.Equal(t, "foo@bar.com", email)
}
func TestGitLabProviderEmailDomainInvalid(t *testing.T) {
b := testGitLabBackend()
defer b.Close()
bURL, _ := url.Parse(b.URL)
p := testGitLabProvider(bURL.Host)
p.AllowUnverifiedEmail = true
p.EmailDomains = []string{"baz.com"}
session := &sessions.SessionState{AccessToken: "gitlab_access_token"}
_, err := p.GetEmailAddress(session)
assert.NotEqual(t, nil, err)
}

View File

@ -189,71 +189,42 @@ func getAdminService(adminEmail string, credentialsReader io.Reader) *admin.Serv
}
func userInGroup(service *admin.Service, groups []string, email string) bool {
user, err := fetchUser(service, email)
if err != nil {
logger.Printf("Warning: unable to fetch user: %v", err)
user = nil
}
for _, group := range groups {
members, err := fetchGroupMembers(service, group)
if err != nil {
if err, ok := err.(*googleapi.Error); ok && err.Code == 404 {
logger.Printf("error fetching members for group %s: group does not exist", group)
} else {
logger.Printf("error fetching group members: %v", err)
return false
}
}
for _, member := range members {
if member.Email == email {
return true
}
if user == nil {
continue
}
switch member.Type {
case "CUSTOMER":
if member.Id == user.CustomerId {
return true
}
case "USER":
if member.Id == user.Id {
return true
}
}
}
}
return false
}
func fetchUser(service *admin.Service, email string) (*admin.User, error) {
user, err := service.Users.Get(email).Do()
return user, err
}
func fetchGroupMembers(service *admin.Service, group string) ([]*admin.Member, error) {
members := []*admin.Member{}
pageToken := ""
for {
req := service.Members.List(group)
if pageToken != "" {
req.PageToken(pageToken)
}
// Use the HasMember API to checking for the user's presence in each group or nested subgroups
req := service.Members.HasMember(group, email)
r, err := req.Do()
if err != nil {
return nil, err
err, ok := err.(*googleapi.Error)
if ok && err.Code == 404 {
logger.Printf("error checking membership in group %s: group does not exist", group)
} else if ok && err.Code == 400 {
// It is possible for Members.HasMember to return false even if the email is a group member.
// One case that can cause this is if the user email is from a different domain than the group,
// e.g. "member@otherdomain.com" in the group "group@mydomain.com" will result in a 400 error
// from the HasMember API. In that case, attempt to query the member object directly from the group.
req := service.Members.Get(group, email)
r, err := req.Do()
if err != nil {
logger.Printf("error using get API to check member %s of google group %s: user not in the group", email, group)
continue
}
for _, member := range r.Members {
members = append(members, member)
// If the non-domain user is found within the group, still verify that they are "ACTIVE".
// Do not count the user as belonging to a group if they have another status ("ARCHIVED", "SUSPENDED", or "UNKNOWN").
if r.Status == "ACTIVE" {
return true
}
if r.NextPageToken == "" {
break
} else {
logger.Printf("error checking group membership: %v", err)
}
pageToken = r.NextPageToken
continue
}
return members, nil
if r.IsMember {
return true
}
}
return false
}
// ValidateGroup validates that the provided email exists in the configured Google

View File

@ -1,6 +1,7 @@
package providers
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
@ -12,6 +13,7 @@ import (
"github.com/stretchr/testify/assert"
admin "google.golang.org/api/admin/directory/v1"
option "google.golang.org/api/option"
)
func newRedeemServer(body []byte) (*url.URL, *httptest.Server) {
@ -185,34 +187,53 @@ func TestGoogleProviderGetEmailAddressEmailMissing(t *testing.T) {
func TestGoogleProviderUserInGroup(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/users/member-by-email@example.com" {
fmt.Fprintln(w, "{}")
} else if r.URL.Path == "/users/non-member-by-email@example.com" {
fmt.Fprintln(w, "{}")
} else if r.URL.Path == "/users/member-by-id@example.com" {
fmt.Fprintln(w, "{\"id\": \"member-id\"}")
} else if r.URL.Path == "/users/non-member-by-id@example.com" {
fmt.Fprintln(w, "{\"id\": \"non-member-id\"}")
} else if r.URL.Path == "/groups/group@example.com/members" {
fmt.Fprintln(w, "{\"members\": [{\"email\": \"member-by-email@example.com\"}, {\"id\": \"member-id\", \"type\": \"USER\"}]}")
if r.URL.Path == "/groups/group@example.com/hasMember/member-in-domain@example.com" {
fmt.Fprintln(w, `{"isMember": true}`)
} else if r.URL.Path == "/groups/group@example.com/hasMember/non-member-in-domain@example.com" {
fmt.Fprintln(w, `{"isMember": false}`)
} else if r.URL.Path == "/groups/group@example.com/hasMember/member-out-of-domain@otherexample.com" {
http.Error(
w,
`{"error": {"errors": [{"domain": "global","reason": "invalid","message": "Invalid Input: memberKey"}],"code": 400,"message": "Invalid Input: memberKey"}}`,
http.StatusBadRequest,
)
} else if r.URL.Path == "/groups/group@example.com/hasMember/non-member-out-of-domain@otherexample.com" {
http.Error(
w,
`{"error": {"errors": [{"domain": "global","reason": "invalid","message": "Invalid Input: memberKey"}],"code": 400,"message": "Invalid Input: memberKey"}}`,
http.StatusBadRequest,
)
} else if r.URL.Path == "/groups/group@example.com/members/non-member-out-of-domain@otherexample.com" {
// note that the client currently doesn't care what this response text or code is - any error here results in failure to match the group
http.Error(
w,
`{"error": {"errors": [{"domain": "global","reason": "notFound","message": "Resource Not Found: memberKey"}],"code": 404,"message": "Resource Not Found: memberKey"}}`,
http.StatusNotFound,
)
} else if r.URL.Path == "/groups/group@example.com/members/member-out-of-domain@otherexample.com" {
fmt.Fprintln(w,
`{"kind": "admin#directory#member","etag":"12345","id":"1234567890","email": "member-out-of-domain@otherexample.com","role": "MEMBER","type": "USER","status": "ACTIVE","delivery_settings": "ALL_MAIL"}}`,
)
}
}))
defer ts.Close()
client := ts.Client()
service, err := admin.New(client)
ctx := context.Background()
service, err := admin.NewService(ctx, option.WithHTTPClient(client))
service.BasePath = ts.URL
assert.Equal(t, nil, err)
result := userInGroup(service, []string{"group@example.com"}, "member-by-email@example.com")
result := userInGroup(service, []string{"group@example.com"}, "member-in-domain@example.com")
assert.True(t, result)
result = userInGroup(service, []string{"group@example.com"}, "member-by-id@example.com")
result = userInGroup(service, []string{"group@example.com"}, "member-out-of-domain@otherexample.com")
assert.True(t, result)
result = userInGroup(service, []string{"group@example.com"}, "non-member-by-id@example.com")
result = userInGroup(service, []string{"group@example.com"}, "non-member-in-domain@example.com")
assert.False(t, result)
result = userInGroup(service, []string{"group@example.com"}, "non-member-by-email@example.com")
result = userInGroup(service, []string{"group@example.com"}, "non-member-out-of-domain@otherexample.com")
assert.False(t, result)
}