diff --git a/.gitignore b/.gitignore index 7ded8226..f075efbd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,10 @@ -google_auth_proxy oauth2_proxy +vendor +dist +.godeps +*.exe + + # Go.gitignore # Compiled Object files, Static and Dynamic libs (Shared Objects) *.o @@ -23,9 +28,5 @@ _cgo_export.* _testmain.go -*.exe -dist -.godeps - # Editor swap/temp files .*.swp diff --git a/.travis.yml b/.travis.yml index 8c830da6..da7885ac 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,11 +1,11 @@ language: go go: - - 1.7.5 - - 1.8.1 + - 1.8.x + - 1.9.x script: - - curl -s https://raw.githubusercontent.com/pote/gpm/v1.4.0/bin/gpm > gpm - - chmod +x gpm - - ./gpm install + - wget -O dep https://github.com/golang/dep/releases/download/v0.3.2/dep-linux-amd64 + - chmod +x dep + - ./dep ensure - ./test.sh sudo: false notifications: diff --git a/Godeps b/Godeps deleted file mode 100644 index 21884cd2..00000000 --- a/Godeps +++ /dev/null @@ -1,10 +0,0 @@ -github.com/18F/hmacauth 1.0.1 -github.com/BurntSushi/toml d94612f9fc140360834f9742158c70b5c5b5535b -github.com/bitly/go-simplejson da1a8928f709389522c8023062a3739f3b4af419 -github.com/mreiferson/go-options 77551d20752b54535462404ad9d877ebdb26e53d -github.com/bmizerany/assert e17e99893cb6509f428e1728281c2ad60a6b31e3 -gopkg.in/fsnotify.v1 v1.2.0 -golang.org/x/oauth2 7fdf09982454086d5570c7db3e11f360194830ca -golang.org/x/net/context 242b6b35177ec3909636b6cf6a47e8c2c6324b5d -google.golang.org/api/admin/directory/v1 650535c7d6201e8304c92f38c922a9a3a36c6877 -cloud.google.com/go/compute/metadata v0.7.0 diff --git a/Gopkg.lock b/Gopkg.lock new file mode 100644 index 00000000..f4474968 --- /dev/null +++ b/Gopkg.lock @@ -0,0 +1,117 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + name = "cloud.google.com/go" + packages = ["compute/metadata"] + revision = "2d3a6656c17a60b0815b7e06ab0be04eacb6e613" + version = "v0.16.0" + +[[projects]] + name = "github.com/BurntSushi/toml" + packages = ["."] + revision = "b26d9c308763d68093482582cea63d69be07a0f0" + version = "v0.3.0" + +[[projects]] + name = "github.com/bitly/go-simplejson" + packages = ["."] + revision = "aabad6e819789e569bd6aabf444c935aa9ba1e44" + version = "v0.5.0" + +[[projects]] + branch = "v2" + name = "github.com/coreos/go-oidc" + packages = ["."] + revision = "77e7f2010a464ade7338597afe650dfcffbe2ca8" + +[[projects]] + name = "github.com/davecgh/go-spew" + packages = ["spew"] + revision = "346938d642f2ec3594ed81d874461961cd0faa76" + version = "v1.1.0" + +[[projects]] + branch = "master" + name = "github.com/golang/protobuf" + packages = ["proto"] + revision = "1e59b77b52bf8e4b449a57e6f79f21226d571845" + +[[projects]] + name = "github.com/mbland/hmacauth" + packages = ["."] + revision = "107c17adcc5eccc9935cd67d9bc2feaf5255d2cb" + version = "1.0.2" + +[[projects]] + branch = "master" + name = "github.com/mreiferson/go-options" + packages = ["."] + revision = "77551d20752b54535462404ad9d877ebdb26e53d" + +[[projects]] + name = "github.com/pmezard/go-difflib" + packages = ["difflib"] + revision = "792786c7400a136282c1664665ae0a8db921c6c2" + version = "v1.0.0" + +[[projects]] + branch = "master" + name = "github.com/pquerna/cachecontrol" + packages = [".","cacheobject"] + revision = "0dec1b30a0215bb68605dfc568e8855066c9202d" + +[[projects]] + name = "github.com/stretchr/testify" + packages = ["assert"] + revision = "69483b4bd14f5845b5a1e55bca19e954e827f1d0" + version = "v1.1.4" + +[[projects]] + branch = "master" + name = "golang.org/x/crypto" + packages = ["ed25519","ed25519/internal/edwards25519"] + revision = "9f005a07e0d31d45e6656d241bb5c0f2efd4bc94" + +[[projects]] + branch = "master" + name = "golang.org/x/net" + packages = ["context","context/ctxhttp"] + revision = "9dfe39835686865bff950a07b394c12a98ddc811" + +[[projects]] + branch = "master" + name = "golang.org/x/oauth2" + packages = [".","google","internal","jws","jwt"] + revision = "9ff8ebcc8e241d46f52ecc5bff0e5a2f2dbef402" + +[[projects]] + branch = "master" + name = "google.golang.org/api" + packages = ["admin/directory/v1","gensupport","googleapi","googleapi/internal/uritemplates"] + revision = "8791354e7ab150705ede13637a18c1fcc16b62e8" + +[[projects]] + name = "google.golang.org/appengine" + packages = [".","internal","internal/app_identity","internal/base","internal/datastore","internal/log","internal/modules","internal/remote_api","internal/urlfetch","urlfetch"] + revision = "150dc57a1b433e64154302bdc40b6bb8aefa313a" + version = "v1.0.0" + +[[projects]] + name = "gopkg.in/fsnotify.v1" + packages = ["."] + revision = "836bfd95fecc0f1511dd66bdbf2b5b61ab8b00b6" + version = "v1.2.11" + +[[projects]] + name = "gopkg.in/square/go-jose.v2" + packages = [".","cipher","json"] + revision = "f8f38de21b4dcd69d0413faf231983f5fd6634b1" + version = "v2.1.3" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + inputs-digest = "efab48a0e196c2a849bfbe9aa02d2ae28d281ce1bfe9f23720d648858eefc8e6" + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml new file mode 100644 index 00000000..97f83d01 --- /dev/null +++ b/Gopkg.toml @@ -0,0 +1,40 @@ + +# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md +# for detailed Gopkg.toml documentation. +# + +[[constraint]] + name = "github.com/18F/hmacauth" + version = "~1.0.1" + +[[constraint]] + name = "github.com/BurntSushi/toml" + version = "~0.3.0" + +[[constraint]] + name = "github.com/bitly/go-simplejson" + version = "~0.5.0" + +[[constraint]] + branch = "v2" + name = "github.com/coreos/go-oidc" + +[[constraint]] + branch = "master" + name = "github.com/mreiferson/go-options" + +[[constraint]] + name = "github.com/stretchr/testify" + version = "~1.1.4" + +[[constraint]] + branch = "master" + name = "golang.org/x/oauth2" + +[[constraint]] + branch = "master" + name = "google.golang.org/api" + +[[constraint]] + name = "gopkg.in/fsnotify.v1" + version = "~1.2.0" diff --git a/README.md b/README.md index 0168f2ee..55d09f33 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,6 @@ Valid providers are : * [GitHub](#github-auth-provider) * [GitLab](#gitlab-auth-provider) * [LinkedIn](#linkedin-auth-provider) -* [MyUSA](#myusa-auth-provider) The provider can be selected using the `provider` configuration value. @@ -99,7 +98,7 @@ The Azure AD auth provider uses `openid` as it default scope. It uses `https://g The GitHub auth provider supports two additional parameters to restrict authentication to Organization or Team level access. Restricting by org and team is normally accompanied with `--email-domain=*` -github-org="": restrict logins to members of this organisation - -github-team="": restrict logins to members of any of these teams, separated by a comma + -github-team="": restrict logins to members of any of these teams (slug), separated by a comma If you are using GitHub enterprise, make sure you set the following to the appropriate url: @@ -115,7 +114,7 @@ If you are using self-hosted GitLab, make sure you set the following to the appr -login-url="/oauth/authorize" -redeem-url="/oauth/token" - -validate-url="/api/v3/user" + -validate-url="/api/v4/user" ### LinkedIn Auth Provider @@ -129,10 +128,6 @@ For LinkedIn, the registration steps are: 3. Fill in the remaining required fields and Save. 4. Take note of the **Consumer Key / API Key** and **Consumer Secret / Secret Key** -### MyUSA Auth Provider - -The [MyUSA](https://alpha.my.usa.gov) authentication service ([GitHub](https://github.com/18F/myusa)) - ### Microsoft Azure AD Provider 1. [Add an application](https://azure.microsoft.com/en-us/documentation/articles/active-directory-integrating-applications/) to your Azure Active Directory tenant. @@ -153,6 +148,24 @@ The `filter-groups` flag enables a simple filter that will elide any groups that The `permit-groups` flag requires that a user belong to a group that contains the specified string (or one of the specified strings). The X-Forwarded-Group header is checked for a `strings.Contains` match for each item in the list. +The 'group-delimiter' flag reflects how multiple group names will be represented in X-Forwarded-Groups header. By default group names will be delimited with '|' symbol + +### OpenID Connect Provider + +OpenID Connect is a spec for OAUTH 2.0 + identity that is implemented by many major providers and several open source projects. This provider was originally built against CoreOS Dex and we will use it as an example. + +1. Launch a Dex instance using the [getting started guide](https://github.com/coreos/dex/blob/master/Documentation/getting-started.md). +2. Setup oauth2_proxy with the correct provider and using the default ports and callbacks. +3. Login with the fixture use in the dex guide and run the oauth2_proxy with the following args: + + -provider oidc + -client-id oauth2_proxy + -client-secret proxy + -redirect-url http://127.0.0.1:4180/oauth2/callback + -oidc-issuer-url http://127.0.0.1:5556 + -cookie-secure=false + -email-domain example.com + ## Email Authentication To authorize by email domain use `--email-domain=yourcompany.com`. To authorize individual email addresses use `--authenticated-emails-file=/path/to/file` with one email per line. To authorize all email addresses use `--email-domain=*`. @@ -178,7 +191,7 @@ Usage of oauth2_proxy: -client-id string: the OAuth Client ID: ie: "123456.apps.googleusercontent.com" -client-secret string: the OAuth Client Secret -config string: path to config file - -cookie-domain string: an optional cookie domain to force cookies to (ie: .yourcompany.com)* + -cookie-domain string: an optional cookie domain to force cookies to (ie: .yourcompany.com) -cookie-expire duration: expire timeframe for cookie (default 168h0m0s) -cookie-httponly: set HttpOnly cookie flag (default true) -cookie-name string: the name of the cookie that the oauth_proxy creates (default "_oauth2_proxy") @@ -189,9 +202,10 @@ Usage of oauth2_proxy: -display-htpasswd-form: display username / password login form if an htpasswd file is provided (default true) -email-domain value: authenticate emails with the specified domain (may be given multiple times). Use * to authenticate any email -filter-groups string: only pass groups in the X-Forwarded-Groups header that contain this string + -groups-delimiter string: delimiter that will be used between group names in X-Forwarded-Groups header -footer string: custom footer string. Use "-" to disable default footer. -github-org string: restrict logins to members of this organisation - -github-team string: restrict logins to members of this team + -github-team string: restrict logins to members of any of these teams (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 diff --git a/api/api.go b/api/api.go index 425ef98e..f5f6e4d4 100644 --- a/api/api.go +++ b/api/api.go @@ -11,7 +11,7 @@ import ( ) func Request(req *http.Request) (*simplejson.Json, error) { - log.Printf("New request to: '%s'", req.URL) + log.Printf("New request to: '%s'", req.URL) resp, err := http.DefaultClient.Do(req) if err != nil { log.Printf("%s %s %s", req.Method, req.URL, err) diff --git a/api/api_test.go b/api/api_test.go index 515d4da9..4f9ae2a5 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -2,12 +2,13 @@ package api import ( "github.com/bitly/go-simplejson" - "github.com/bmizerany/assert" "io/ioutil" "net/http" "net/http/httptest" "strings" "testing" + + "github.com/stretchr/testify/assert" ) func testBackend(response_code int, payload string) *httptest.Server { diff --git a/cookie/cookies_test.go b/cookie/cookies_test.go index 5c4a9434..74e78fb9 100644 --- a/cookie/cookies_test.go +++ b/cookie/cookies_test.go @@ -4,7 +4,7 @@ import ( "encoding/base64" "testing" - "github.com/bmizerany/assert" + "github.com/stretchr/testify/assert" ) func TestEncodeAndDecodeAccessToken(t *testing.T) { diff --git a/env_options_test.go b/env_options_test.go index 354dc424..e9277f7d 100644 --- a/env_options_test.go +++ b/env_options_test.go @@ -4,7 +4,7 @@ import ( "os" "testing" - "github.com/bmizerany/assert" + "github.com/stretchr/testify/assert" ) type envTest struct { diff --git a/htpasswd_test.go b/htpasswd_test.go index 5cfc9e61..17ce37b4 100644 --- a/htpasswd_test.go +++ b/htpasswd_test.go @@ -2,7 +2,7 @@ package main import ( "bytes" - "github.com/bmizerany/assert" + "github.com/stretchr/testify/assert" "testing" ) diff --git a/main.go b/main.go index 8554e7f4..f39e62a9 100644 --- a/main.go +++ b/main.go @@ -37,6 +37,7 @@ func main() { flagSet.Bool("pass-groups", false, "pass user group information in the X-Forwarded-Groups header to upstream (Azure only)") flagSet.String("filter-groups", "", "exclude groups that do not contain this value in its 'displayName' (Azure only)") flagSet.Var(&permittedGroups, "permit-groups", "restrict logins to members of this group (may be given multiple times; Azure and Google only).") + flagSet.String("groups-delimiter", "|", "delimiter between group names if more than one found. By default it is '|' symbol") 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") @@ -71,6 +72,7 @@ func main() { flagSet.Bool("request-logging", true, "Log requests to stdout") flagSet.String("provider", "google", "OAuth provider") + flagSet.String("oidc-issuer-url", "", "OpenID Connect issuer URL (ie: https://accounts.google.com)") flagSet.String("login-url", "", "Authentication endpoint") flagSet.String("redeem-url", "", "Token redemption endpoint") flagSet.String("profile-url", "", "Profile access endpoint") diff --git a/oauthproxy.go b/oauthproxy.go index edb47613..83c6973e 100644 --- a/oauthproxy.go +++ b/oauthproxy.go @@ -14,9 +14,9 @@ import ( "strings" "time" - "github.com/18F/hmacauth" "github.com/bitly/oauth2_proxy/cookie" "github.com/bitly/oauth2_proxy/providers" + "github.com/mbland/hmacauth" ) const SignatureHeader = "GAP-Signature" @@ -64,6 +64,7 @@ type OAuthProxy struct { SetXAuthRequest bool PassBasicAuth bool PassGroups bool + GroupsDelimiter string FilterGroups string SkipProviderButton bool PassUserHeaders bool @@ -161,16 +162,12 @@ func NewOAuthProxy(opts *Options, validator func(string) bool) *OAuthProxy { redirectURL.Path = fmt.Sprintf("%s/callback", opts.ProxyPrefix) log.Printf("OAuthProxy configured for %s Client ID: %s", opts.provider.Data().ProviderName, opts.ClientID) - domain := opts.CookieDomain - if domain == "" { - domain = "" - } refresh := "disabled" if opts.CookieRefresh != time.Duration(0) { refresh = fmt.Sprintf("after %s", opts.CookieRefresh) } - log.Printf("Cookie settings: name:%s secure(https):%v httponly:%v expiry:%s domain:%s refresh:%s", opts.CookieName, opts.CookieSecure, opts.CookieHttpOnly, opts.CookieExpire, domain, refresh) + log.Printf("Cookie settings: name:%s secure(https):%v httponly:%v expiry:%s domain:%s refresh:%s", opts.CookieName, opts.CookieSecure, opts.CookieHttpOnly, opts.CookieExpire, opts.CookieDomain, refresh) var cipher *cookie.Cipher if opts.PassAccessToken || (opts.CookieRefresh != time.Duration(0)) { @@ -211,6 +208,7 @@ func NewOAuthProxy(opts *Options, validator func(string) bool) *OAuthProxy { PassBasicAuth: opts.PassBasicAuth, PassUserHeaders: opts.PassUserHeaders, PassGroups: opts.PassGroups, + GroupsDelimiter: opts.GroupsDelimiter, FilterGroups: opts.FilterGroups, BasicAuthPassword: opts.BasicAuthPassword, PassAccessToken: opts.PassAccessToken, @@ -257,6 +255,13 @@ func (p *OAuthProxy) redeemCode(host, code string) (s *providers.SessionState, e if s.Email == "" { s.Email, err = p.provider.GetEmailAddress(s) } + + if s.User == "" { + s.User, err = p.provider.GetUserName(s) + if err != nil && err.Error() == "not implemented" { + err = nil + } + } return } @@ -276,22 +281,21 @@ func (p *OAuthProxy) MakeCSRFCookie(req *http.Request, value string, expiration } func (p *OAuthProxy) makeCookie(req *http.Request, name string, value string, expiration time.Duration, now time.Time) *http.Cookie { - domain := req.Host - if h, _, err := net.SplitHostPort(domain); err == nil { - domain = h - } if p.CookieDomain != "" { + domain := req.Host + if h, _, err := net.SplitHostPort(domain); err == nil { + domain = h + } if !strings.HasSuffix(domain, p.CookieDomain) { log.Printf("Warning: request host is %q but using configured cookie domain of %q", domain, p.CookieDomain) } - domain = p.CookieDomain } return &http.Cookie{ Name: name, Value: value, Path: "/", - Domain: domain, + Domain: p.CookieDomain, HttpOnly: p.CookieHttpOnly, Secure: p.CookieSecure, Expires: now.Add(expiration), @@ -537,11 +541,10 @@ func (p *OAuthProxy) OAuthCallback(rw http.ResponseWriter, req *http.Request) { errorString := req.Form.Get("error") if errorString != "" { - log.Printf("[OAuthCallback] error in parsed form (REQ) : %s", req) - log.Printf("[OAuthCallback] error in parsed form (REQ.Form) : %s", req.Form) - log.Printf("[OAuthCallback] error in parsed form (REQ.error string) : %s", errorString) - p.ErrorPage(rw, 403, "Permission Denied", errorString) - return + log.Printf("[OAuthCallback] error in parsed form (REQ.Form) : %s", req.Form) + log.Printf("[OAuthCallback] error in parsed form (REQ.error string) : %s", errorString) + p.ErrorPage(rw, 403, "Permission Denied", errorString) + return } session, err := p.redeemCode(req.Host, req.Form.Get("code")) @@ -572,11 +575,12 @@ func (p *OAuthProxy) OAuthCallback(rw http.ResponseWriter, req *http.Request) { session.IDToken = req.Form.Get("id_token") if p.PassGroups && session.IDToken != "" { - session.Groups, err = p.provider.GetGroups(session, p.FilterGroups) + groups, err := p.provider.GetGroups(session, p.FilterGroups) if err != nil { p.ErrorPage(rw, 500, "Internal Error", "Internal Error") return } + session.Groups = strings.Join(groups, p.GroupsDelimiter) } redirect = req.Form.Get("state") diff --git a/oauthproxy_test.go b/oauthproxy_test.go index 43e165a2..1e6b3140 100644 --- a/oauthproxy_test.go +++ b/oauthproxy_test.go @@ -15,9 +15,9 @@ import ( "testing" "time" - "github.com/18F/hmacauth" "github.com/bitly/oauth2_proxy/providers" - "github.com/bmizerany/assert" + "github.com/mbland/hmacauth" + "github.com/stretchr/testify/assert" ) func init() { diff --git a/options.go b/options.go index 4f460698..19ac8c2a 100644 --- a/options.go +++ b/options.go @@ -1,6 +1,7 @@ package main import ( + "context" "crypto" "crypto/tls" "encoding/base64" @@ -12,8 +13,9 @@ import ( "strings" "time" - "github.com/18F/hmacauth" "github.com/bitly/oauth2_proxy/providers" + oidc "github.com/coreos/go-oidc" + "github.com/mbland/hmacauth" ) // Configuration Options that can be set by Command Line Flag, or Config File @@ -53,6 +55,7 @@ type Options struct { PassGroups bool `flag:"pass-groups" cfg:"pass_groups"` FilterGroups string `flag:"filter-groups" cfg:"filter_groups"` PermitGroups []string `flag:"permit-groups" cfg:"permit_groups"` + GroupsDelimiter string `flag:"groups-delimiter" cfg:"groups_delimiter"` 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"` @@ -65,6 +68,7 @@ type Options struct { // These options allow for other providers besides Google, with // potential overrides. Provider string `flag:"provider" cfg:"provider"` + OIDCIssuerURL string `flag:"oidc-issuer-url" cfg:"oidc_issuer_url"` LoginURL string `flag:"login-url" cfg:"login_url"` RedeemURL string `flag:"redeem-url" cfg:"redeem_url"` ProfileURL string `flag:"profile-url" cfg:"profile_url"` @@ -83,6 +87,7 @@ type Options struct { CompiledRegex []*regexp.Regexp provider providers.Provider signatureData *SignatureData + oidcVerifier *oidc.IDTokenVerifier } type SignatureData struct { @@ -107,6 +112,7 @@ func NewOptions() *Options { PassUserHeaders: true, PassGroups: false, FilterGroups: "", + GroupsDelimiter: "|", PassAccessToken: false, PassHostHeader: true, ApprovalPrompt: "", @@ -125,6 +131,14 @@ func parseURL(to_parse string, urltype string, msgs []string) (*url.URL, []strin } func (o *Options) Validate() error { + if o.SSLInsecureSkipVerify { + // TODO: Accept a certificate bundle. + insecureTransport := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + http.DefaultClient = &http.Client{Transport: insecureTransport} + } + msgs := make([]string, 0) if o.CookieSecret == "" { msgs = append(msgs, "missing setting: cookie-secret") @@ -140,6 +154,22 @@ func (o *Options) Validate() error { "\n use email-domain=* to authorize all email addresses") } + if o.OIDCIssuerURL != "" { + // Configure discoverable provider data. + provider, err := oidc.NewProvider(context.Background(), o.OIDCIssuerURL) + if err != nil { + return err + } + o.oidcVerifier = provider.Verifier(&oidc.Config{ + ClientID: o.ClientID, + }) + o.LoginURL = provider.Endpoint().AuthURL + o.RedeemURL = provider.Endpoint().TokenURL + if o.Scope == "" { + o.Scope = "openid email profile" + } + } + o.redirectURL, msgs = parseURL(o.RedirectURL, "redirect", msgs) for _, u := range o.Upstreams { @@ -157,8 +187,8 @@ func (o *Options) Validate() error { for _, u := range o.SkipAuthRegex { CompiledRegex, err := regexp.Compile(u) if err != nil { - msgs = append(msgs, fmt.Sprintf( - "error compiling regex=%q %s", u, err)) + msgs = append(msgs, fmt.Sprintf("error compiling regex=%q %s", u, err)) + continue } o.CompiledRegex = append(o.CompiledRegex, CompiledRegex) } @@ -214,13 +244,6 @@ func (o *Options) Validate() error { msgs = parseSignatureKey(o, msgs) msgs = validateCookieName(o, msgs) - if o.SSLInsecureSkipVerify { - insecureTransport := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - } - http.DefaultClient = &http.Client{Transport: insecureTransport} - } - if len(msgs) != 0 { return fmt.Errorf("Invalid configuration:\n %s", strings.Join(msgs, "\n ")) @@ -263,6 +286,12 @@ func parseProviderInfo(o *Options, msgs []string) []string { p.SetGroupRestriction(o.PermitGroups, o.GoogleAdminEmail, file) } } + case *providers.OIDCProvider: + if o.oidcVerifier == nil { + msgs = append(msgs, "oidc provider requires an oidc issuer URL") + } else { + p.Verifier = o.oidcVerifier + } } return msgs } diff --git a/options_test.go b/options_test.go index 5292e0ff..1deaabca 100644 --- a/options_test.go +++ b/options_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/bmizerany/assert" + "github.com/stretchr/testify/assert" ) func testOptions() *Options { @@ -103,6 +103,18 @@ func TestProxyURLs(t *testing.T) { assert.Equal(t, expected, o.proxyURLs) } +func TestProxyURLsError(t *testing.T) { + o := testOptions() + o.Upstreams = append(o.Upstreams, "127.0.0.1:8081") + err := o.Validate() + assert.NotEqual(t, nil, err) + + expected := errorMsg([]string{ + "error parsing upstream: parse 127.0.0.1:8081: " + + "first path segment in URL cannot contain colon"}) + assert.Equal(t, expected, err.Error()) +} + func TestCompiledRegex(t *testing.T) { o := testOptions() regexps := []string{"/foo/.*", "/ba[rz]/quux"} @@ -127,6 +139,15 @@ func TestCompiledRegexError(t *testing.T) { "error compiling regex=\"barquux)\" error parsing regexp: " + "unexpected ): `barquux)`"}) assert.Equal(t, expected, err.Error()) + + o.SkipAuthRegex = []string{"foobaz", "barquux)"} + err = o.Validate() + assert.NotEqual(t, nil, err) + + expected = errorMsg([]string{ + "error compiling regex=\"barquux)\" error parsing regexp: " + + "unexpected ): `barquux)`"}) + assert.Equal(t, expected, err.Error()) } func TestDefaultProviderApiSettings(t *testing.T) { diff --git a/providers/azure.go b/providers/azure.go index c836f3bb..816e3967 100644 --- a/providers/azure.go +++ b/providers/azure.go @@ -130,13 +130,13 @@ func (p *AzureProvider) GetEmailAddress(s *SessionState) (string, error) { } // Get list of groups user belong to. Filter the desired names of groups (in case of huge group set) -func (p *AzureProvider) GetGroups(s *SessionState, f string) (string, error) { +func (p *AzureProvider) GetGroups(s *SessionState, f string) ([]string, error) { if s.AccessToken == "" { - return "", errors.New("missing access token") + return []string{}, errors.New("missing access token") } if s.IDToken == "" { - return "", errors.New("missing id token") + return []string{}, errors.New("missing id token") } // For future use. Right now microsoft graph don't support filter @@ -164,25 +164,25 @@ func (p *AzureProvider) GetGroups(s *SessionState, f string) (string, error) { // err = errors.New("fake error") if err != nil { - return "", err + return []string{}, err } req.Header = getAzureHeader(s.AccessToken) req.Header.Add("Content-Type", "application/json") groupData, err := api.Request(req) if err != nil { - // If workaround already tried, just fail the execution - if workaround_set { - log.Printf("[GetGroups] We tried hard, but still receive error: '%s'", err) - return "", err - } + // If workaround already tried, just fail the execution + if workaround_set { + log.Printf("[GetGroups] We tried hard, but still receive error: '%s'", err) + return []string{}, err + } - // It might be that it is a Graph bug, try to workaround it by accessing another URL - log.Printf("[GetGroups] Failed to get groups details: %s", err) - requestUrl = "https://graph.microsoft.com/v1.0/users/" + s.Email + "/memberOf" - log.Printf("[GetGroups] Try to workaround by accessing: '%s'", requestUrl) - workaround_set = true - continue + // It might be that it is a Graph bug, try to workaround it by accessing another URL + log.Printf("[GetGroups] Failed to get groups details: %s", err) + requestUrl = "https://graph.microsoft.com/v1.0/users/" + s.Email + "/memberOf" + log.Printf("[GetGroups] Try to workaround by accessing: '%s'", requestUrl) + workaround_set = true + continue } for _, groupInfo := range groupData.Get("value").MustArray() { @@ -204,7 +204,7 @@ func (p *AzureProvider) GetGroups(s *SessionState, f string) (string, error) { } } - return strings.Join(groups, "|"), nil + return groups, nil } func (p *AzureProvider) GetLoginURL(redirectURI, state string) string { diff --git a/providers/azure_test.go b/providers/azure_test.go index 09768cf2..5f21dfed 100644 --- a/providers/azure_test.go +++ b/providers/azure_test.go @@ -2,7 +2,6 @@ package providers import ( "fmt" - "github.com/bmizerany/assert" "io/ioutil" "log" "net/http" @@ -10,6 +9,8 @@ import ( "net/url" "strings" "testing" + + "github.com/stretchr/testify/assert" ) var ( @@ -273,7 +274,8 @@ func TestAzureProviderNoGroups(t *testing.T) { http.DefaultClient.Transport = nil assert.Equal(t, nil, err) - assert.Equal(t, "", groups) + // assert.Equal(t, "", groups) + assert.Equal(t, []string{}, groups) } func TestAzureProviderWrongRequestGroups(t *testing.T) { @@ -292,7 +294,7 @@ func TestAzureProviderWrongRequestGroups(t *testing.T) { http.DefaultClient.Transport = nil assert.NotEqual(t, nil, err) - assert.Equal(t, "", groups) + assert.Equal(t, []string{}, groups) } func TestAzureProviderMultiRequestsGroups(t *testing.T) { @@ -311,7 +313,7 @@ func TestAzureProviderMultiRequestsGroups(t *testing.T) { http.DefaultClient.Transport = nil assert.Equal(t, nil, err) - assert.Equal(t, "test-group-1|test-group-2|test-group-3", groups) + assert.Equal(t, []string{"test-group-1", "test-group-2", "test-group-3"}, groups) } func TestAzureEmptyPermittedGroups(t *testing.T) { diff --git a/providers/github.go b/providers/github.go index 512eed86..f3af86fe 100644 --- a/providers/github.go +++ b/providers/github.go @@ -218,10 +218,10 @@ func (p *GitHubProvider) GetEmailAddress(s *SessionState) (string, error) { if resp.StatusCode != 200 { return "", fmt.Errorf("got %d from %q %s", resp.StatusCode, endpoint.String(), body) - } else { - log.Printf("got %d from %q %s", resp.StatusCode, endpoint.String(), body) } + log.Printf("got %d from %q %s", resp.StatusCode, endpoint.String(), body) + if err := json.Unmarshal(body, &emails); err != nil { return "", fmt.Errorf("%s unmarshaling %s", err, body) } @@ -234,3 +234,46 @@ func (p *GitHubProvider) GetEmailAddress(s *SessionState) (string, error) { return "", nil } + +func (p *GitHubProvider) GetUserName(s *SessionState) (string, error) { + var user struct { + Login string `json:"login"` + Email string `json:"email"` + } + + endpoint := &url.URL{ + Scheme: p.ValidateURL.Scheme, + Host: p.ValidateURL.Host, + Path: path.Join(p.ValidateURL.Path, "/user"), + } + + req, err := http.NewRequest("GET", endpoint.String(), nil) + if err != nil { + return "", fmt.Errorf("could not create new GET request: %v", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("token %s", s.AccessToken)) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + + body, err := ioutil.ReadAll(resp.Body) + defer resp.Body.Close() + if err != nil { + return "", err + } + + if resp.StatusCode != 200 { + return "", fmt.Errorf("got %d from %q %s", + resp.StatusCode, endpoint.String(), body) + } + + log.Printf("got %d from %q %s", resp.StatusCode, endpoint.String(), body) + + if err := json.Unmarshal(body, &user); err != nil { + return "", fmt.Errorf("%s unmarshaling %s", err, body) + } + + return user.Login, nil +} diff --git a/providers/myusa_test.go b/providers/github_test.go similarity index 51% rename from providers/myusa_test.go rename to providers/github_test.go index d058845c..8080525b 100644 --- a/providers/myusa_test.go +++ b/providers/github_test.go @@ -6,16 +6,11 @@ import ( "net/url" "testing" - "github.com/bmizerany/assert" + "github.com/stretchr/testify/assert" ) -func updateURL(url *url.URL, hostname string) { - url.Scheme = "http" - url.Host = hostname -} - -func testMyUsaProvider(hostname string) *MyUsaProvider { - p := NewMyUsaProvider( +func testGitHubProvider(hostname string) *GitHubProvider { + p := NewGitHubProvider( &ProviderData{ ProviderName: "", LoginURL: &url.URL{}, @@ -32,14 +27,19 @@ func testMyUsaProvider(hostname string) *MyUsaProvider { return p } -func testMyUsaBackend(payload string) *httptest.Server { - path := "/api/v1/profile" - query := "access_token=imaginary_access_token" +func testGitHubBackend(payload string) *httptest.Server { + pathToQueryMap := map[string]string{ + "/user": "", + "/user/emails": "", + } return httptest.NewServer(http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { url := r.URL - if url.Path != path || url.RawQuery != query { + query, ok := pathToQueryMap[url.Path] + if !ok { + w.WriteHeader(404) + } else if url.RawQuery != query { w.WriteHeader(404) } else { w.WriteHeader(200) @@ -48,60 +48,52 @@ func testMyUsaBackend(payload string) *httptest.Server { })) } -func TestMyUsaProviderDefaults(t *testing.T) { - p := testMyUsaProvider("") +func TestGitHubProviderDefaults(t *testing.T) { + p := testGitHubProvider("") assert.NotEqual(t, nil, p) - assert.Equal(t, "MyUSA", p.Data().ProviderName) - assert.Equal(t, "https://alpha.my.usa.gov/oauth/authorize", + assert.Equal(t, "GitHub", p.Data().ProviderName) + assert.Equal(t, "https://github.com/login/oauth/authorize", p.Data().LoginURL.String()) - assert.Equal(t, "https://alpha.my.usa.gov/oauth/token", + assert.Equal(t, "https://github.com/login/oauth/access_token", p.Data().RedeemURL.String()) - assert.Equal(t, "https://alpha.my.usa.gov/api/v1/profile", - p.Data().ProfileURL.String()) - assert.Equal(t, "https://alpha.my.usa.gov/api/v1/tokeninfo", + assert.Equal(t, "https://api.github.com/", p.Data().ValidateURL.String()) - assert.Equal(t, "profile.email", p.Data().Scope) + assert.Equal(t, "user:email", p.Data().Scope) } -func TestMyUsaProviderOverrides(t *testing.T) { - p := NewMyUsaProvider( +func TestGitHubProviderOverrides(t *testing.T) { + p := NewGitHubProvider( &ProviderData{ LoginURL: &url.URL{ Scheme: "https", Host: "example.com", - Path: "/oauth/auth"}, + Path: "/login/oauth/authorize"}, RedeemURL: &url.URL{ Scheme: "https", Host: "example.com", - Path: "/oauth/token"}, - ProfileURL: &url.URL{ - Scheme: "https", - Host: "example.com", - Path: "/oauth/profile"}, + Path: "/login/oauth/access_token"}, ValidateURL: &url.URL{ Scheme: "https", - Host: "example.com", - Path: "/oauth/tokeninfo"}, + Host: "api.example.com", + Path: "/"}, Scope: "profile"}) assert.NotEqual(t, nil, p) - assert.Equal(t, "MyUSA", p.Data().ProviderName) - assert.Equal(t, "https://example.com/oauth/auth", + assert.Equal(t, "GitHub", p.Data().ProviderName) + assert.Equal(t, "https://example.com/login/oauth/authorize", p.Data().LoginURL.String()) - assert.Equal(t, "https://example.com/oauth/token", + assert.Equal(t, "https://example.com/login/oauth/access_token", p.Data().RedeemURL.String()) - assert.Equal(t, "https://example.com/oauth/profile", - p.Data().ProfileURL.String()) - assert.Equal(t, "https://example.com/oauth/tokeninfo", + assert.Equal(t, "https://api.example.com/", p.Data().ValidateURL.String()) assert.Equal(t, "profile", p.Data().Scope) } -func TestMyUsaProviderGetEmailAddress(t *testing.T) { - b := testMyUsaBackend("{\"email\": \"michael.bland@gsa.gov\"}") +func TestGitHubProviderGetEmailAddress(t *testing.T) { + b := testGitHubBackend(`[ {"email": "michael.bland@gsa.gov", "primary": true} ]`) defer b.Close() - b_url, _ := url.Parse(b.URL) - p := testMyUsaProvider(b_url.Host) + bURL, _ := url.Parse(b.URL) + p := testGitHubProvider(bURL.Host) session := &SessionState{AccessToken: "imaginary_access_token"} email, err := p.GetEmailAddress(session) @@ -111,12 +103,12 @@ func TestMyUsaProviderGetEmailAddress(t *testing.T) { // 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 TestMyUsaProviderGetEmailAddressFailedRequest(t *testing.T) { - b := testMyUsaBackend("unused payload") +func TestGitHubProviderGetEmailAddressFailedRequest(t *testing.T) { + b := testGitHubBackend("unused payload") defer b.Close() - b_url, _ := url.Parse(b.URL) - p := testMyUsaProvider(b_url.Host) + bURL, _ := url.Parse(b.URL) + p := testGitHubProvider(bURL.Host) // We'll trigger a request failure by using an unexpected access // token. Alternatively, we could allow the parsing of the payload as @@ -127,15 +119,28 @@ func TestMyUsaProviderGetEmailAddressFailedRequest(t *testing.T) { assert.Equal(t, "", email) } -func TestMyUsaProviderGetEmailAddressEmailNotPresentInPayload(t *testing.T) { - b := testMyUsaBackend("{\"foo\": \"bar\"}") +func TestGitHubProviderGetEmailAddressEmailNotPresentInPayload(t *testing.T) { + b := testGitHubBackend("{\"foo\": \"bar\"}") defer b.Close() - b_url, _ := url.Parse(b.URL) - p := testMyUsaProvider(b_url.Host) + bURL, _ := url.Parse(b.URL) + p := testGitHubProvider(bURL.Host) session := &SessionState{AccessToken: "imaginary_access_token"} email, err := p.GetEmailAddress(session) assert.NotEqual(t, nil, err) assert.Equal(t, "", email) } + +func TestGitHubProviderGetUserName(t *testing.T) { + b := testGitHubBackend(`{"email": "michael.bland@gsa.gov", "login": "mbland"}`) + defer b.Close() + + bURL, _ := url.Parse(b.URL) + p := testGitHubProvider(bURL.Host) + + session := &SessionState{AccessToken: "imaginary_access_token"} + email, err := p.GetUserName(session) + assert.Equal(t, nil, err) + assert.Equal(t, "mbland", email) +} diff --git a/providers/gitlab.go b/providers/gitlab.go index 708283ad..124d7198 100644 --- a/providers/gitlab.go +++ b/providers/gitlab.go @@ -32,11 +32,11 @@ func NewGitLabProvider(p *ProviderData) *GitLabProvider { p.ValidateURL = &url.URL{ Scheme: "https", Host: "gitlab.com", - Path: "/api/v3/user", + Path: "/api/v4/user", } } if p.Scope == "" { - p.Scope = "api" + p.Scope = "read_user" } return &GitLabProvider{ProviderData: p} } diff --git a/providers/gitlab_test.go b/providers/gitlab_test.go index 3df001c2..0eec5aa6 100644 --- a/providers/gitlab_test.go +++ b/providers/gitlab_test.go @@ -6,7 +6,7 @@ import ( "net/url" "testing" - "github.com/bmizerany/assert" + "github.com/stretchr/testify/assert" ) func testGitLabProvider(hostname string) *GitLabProvider { @@ -28,7 +28,7 @@ func testGitLabProvider(hostname string) *GitLabProvider { } func testGitLabBackend(payload string) *httptest.Server { - path := "/api/v3/user" + path := "/api/v4/user" query := "access_token=imaginary_access_token" return httptest.NewServer(http.HandlerFunc( @@ -51,9 +51,9 @@ func TestGitLabProviderDefaults(t *testing.T) { p.Data().LoginURL.String()) assert.Equal(t, "https://gitlab.com/oauth/token", p.Data().RedeemURL.String()) - assert.Equal(t, "https://gitlab.com/api/v3/user", + assert.Equal(t, "https://gitlab.com/api/v4/user", p.Data().ValidateURL.String()) - assert.Equal(t, "api", p.Data().Scope) + assert.Equal(t, "read_user", p.Data().Scope) } func TestGitLabProviderOverrides(t *testing.T) { @@ -70,7 +70,7 @@ func TestGitLabProviderOverrides(t *testing.T) { ValidateURL: &url.URL{ Scheme: "https", Host: "example.com", - Path: "/api/v3/user"}, + Path: "/api/v4/user"}, Scope: "profile"}) assert.NotEqual(t, nil, p) assert.Equal(t, "GitLab", p.Data().ProviderName) @@ -78,7 +78,7 @@ func TestGitLabProviderOverrides(t *testing.T) { p.Data().LoginURL.String()) assert.Equal(t, "https://example.com/oauth/token", p.Data().RedeemURL.String()) - assert.Equal(t, "https://example.com/api/v3/user", + assert.Equal(t, "https://example.com/api/v4/user", p.Data().ValidateURL.String()) assert.Equal(t, "profile", p.Data().Scope) } diff --git a/providers/google.go b/providers/google.go index 13962c26..25af91a9 100644 --- a/providers/google.go +++ b/providers/google.go @@ -67,7 +67,7 @@ func emailFromIdToken(idToken string) (string, error) { // id_token is a base64 encode ID token payload // https://developers.google.com/accounts/docs/OAuth2Login#obtainuserinfo jwt := strings.Split(idToken, ".") - b, err := jwtDecodeSegment(jwt[1]) + b, err := base64.RawURLEncoding.DecodeString(jwt[1]) if err != nil { return "", err } @@ -89,14 +89,6 @@ func emailFromIdToken(idToken string) (string, error) { return email.Email, nil } -func jwtDecodeSegment(seg string) ([]byte, error) { - if l := len(seg) % 4; l > 0 { - seg += strings.Repeat("=", 4-l) - } - - return base64.URLEncoding.DecodeString(seg) -} - func (p *GoogleProvider) Redeem(redirectURL, code string) (s *SessionState, err error) { if code == "" { err = errors.New("missing code") diff --git a/providers/google_test.go b/providers/google_test.go index dcd7e836..a8ee7874 100644 --- a/providers/google_test.go +++ b/providers/google_test.go @@ -8,7 +8,7 @@ import ( "net/url" "testing" - "github.com/bmizerany/assert" + "github.com/stretchr/testify/assert" ) func newRedeemServer(body []byte) (*url.URL, *httptest.Server) { diff --git a/providers/internal_util.go b/providers/internal_util.go index b396993b..924d4119 100644 --- a/providers/internal_util.go +++ b/providers/internal_util.go @@ -72,3 +72,8 @@ func validateToken(p Provider, access_token string, header http.Header) bool { log.Printf("token validation request failed: status %d - %s", resp.StatusCode, body) return false } + +func updateURL(url *url.URL, hostname string) { + url.Scheme = "http" + url.Host = hostname +} diff --git a/providers/internal_util_test.go b/providers/internal_util_test.go index ccb5ed41..5fe0e8ea 100644 --- a/providers/internal_util_test.go +++ b/providers/internal_util_test.go @@ -7,7 +7,7 @@ import ( "net/url" "testing" - "github.com/bmizerany/assert" + "github.com/stretchr/testify/assert" ) type ValidateSessionStateTestProvider struct { diff --git a/providers/linkedin_test.go b/providers/linkedin_test.go index f43c96bf..a0d255b3 100644 --- a/providers/linkedin_test.go +++ b/providers/linkedin_test.go @@ -1,11 +1,12 @@ package providers import ( - "github.com/bmizerany/assert" "net/http" "net/http/httptest" "net/url" "testing" + + "github.com/stretchr/testify/assert" ) func testLinkedInProvider(hostname string) *LinkedInProvider { diff --git a/providers/myusa.go b/providers/myusa.go deleted file mode 100644 index ae76d343..00000000 --- a/providers/myusa.go +++ /dev/null @@ -1,58 +0,0 @@ -package providers - -import ( - "log" - "net/http" - "net/url" - - "github.com/bitly/oauth2_proxy/api" -) - -type MyUsaProvider struct { - *ProviderData -} - -func NewMyUsaProvider(p *ProviderData) *MyUsaProvider { - const myUsaHost string = "alpha.my.usa.gov" - - p.ProviderName = "MyUSA" - if p.LoginURL.String() == "" { - p.LoginURL = &url.URL{Scheme: "https", - Host: myUsaHost, - Path: "/oauth/authorize"} - } - if p.RedeemURL.String() == "" { - p.RedeemURL = &url.URL{Scheme: "https", - Host: myUsaHost, - Path: "/oauth/token"} - } - if p.ProfileURL.String() == "" { - p.ProfileURL = &url.URL{Scheme: "https", - Host: myUsaHost, - Path: "/api/v1/profile"} - } - if p.ValidateURL.String() == "" { - p.ValidateURL = &url.URL{Scheme: "https", - Host: myUsaHost, - Path: "/api/v1/tokeninfo"} - } - if p.Scope == "" { - p.Scope = "profile.email" - } - return &MyUsaProvider{ProviderData: p} -} - -func (p *MyUsaProvider) GetEmailAddress(s *SessionState) (string, error) { - req, err := http.NewRequest("GET", - p.ProfileURL.String()+"?access_token="+s.AccessToken, nil) - if err != nil { - log.Printf("failed building request %s", err) - return "", err - } - json, err := api.Request(req) - if err != nil { - log.Printf("failed making request %s", err) - return "", err - } - return json.Get("email").String() -} diff --git a/providers/oidc.go b/providers/oidc.go new file mode 100644 index 00000000..0c0fa52a --- /dev/null +++ b/providers/oidc.go @@ -0,0 +1,85 @@ +package providers + +import ( + "context" + "fmt" + "time" + + "golang.org/x/oauth2" + + oidc "github.com/coreos/go-oidc" +) + +type OIDCProvider struct { + *ProviderData + + Verifier *oidc.IDTokenVerifier +} + +func NewOIDCProvider(p *ProviderData) *OIDCProvider { + p.ProviderName = "OpenID Connect" + return &OIDCProvider{ProviderData: p} +} + +func (p *OIDCProvider) Redeem(redirectURL, code string) (s *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) + } + + 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) + } + + // Extract custom claims. + var claims struct { + Email string `json:"email"` + Verified *bool `json:"email_verified"` + } + if err := idToken.Claims(&claims); err != nil { + return nil, fmt.Errorf("failed to parse id_token claims: %v", err) + } + + if claims.Email == "" { + return nil, fmt.Errorf("id_token did not contain an email") + } + if claims.Verified != nil && !*claims.Verified { + return nil, fmt.Errorf("email in id_token (%s) isn't verified", claims.Email) + } + + s = &SessionState{ + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + ExpiresOn: token.Expiry, + Email: claims.Email, + } + + return +} + +func (p *OIDCProvider) RefreshSessionIfNeeded(s *SessionState) (bool, error) { + if s == nil || s.ExpiresOn.After(time.Now()) || s.RefreshToken == "" { + return false, nil + } + + origExpiration := s.ExpiresOn + s.ExpiresOn = time.Now().Add(time.Second).Truncate(time.Second) + fmt.Printf("refreshed access token %s (expired on %s)\n", s, origExpiration) + return false, nil +} diff --git a/providers/provider_default.go b/providers/provider_default.go index 01874853..743696d9 100644 --- a/providers/provider_default.go +++ b/providers/provider_default.go @@ -111,10 +111,15 @@ func (p *ProviderData) GetEmailAddress(s *SessionState) (string, error) { return "", errors.New("not implemented") } -func (p *ProviderData) GetGroups(s *SessionState, f string) (string, error) { +// GetUserName returns the Account username +func (p *ProviderData) GetUserName(s *SessionState) (string, error) { return "", errors.New("not implemented") } +func (p *ProviderData) GetGroups(s *SessionState, f string) ([]string, error) { + return []string{}, errors.New("not implemented") +} + // ValidateGroup validates that the provided email exists in the configured provider // email group(s). func (p *ProviderData) ValidateGroup(s *SessionState) bool { diff --git a/providers/provider_default_test.go b/providers/provider_default_test.go index e60aa544..abff0a9b 100644 --- a/providers/provider_default_test.go +++ b/providers/provider_default_test.go @@ -4,7 +4,7 @@ import ( "testing" "time" - "github.com/bmizerany/assert" + "github.com/stretchr/testify/assert" ) func TestRefresh(t *testing.T) { diff --git a/providers/providers.go b/providers/providers.go index 449a5485..34065684 100644 --- a/providers/providers.go +++ b/providers/providers.go @@ -7,7 +7,8 @@ import ( type Provider interface { Data() *ProviderData GetEmailAddress(*SessionState) (string, error) - GetGroups(*SessionState, string) (string, error) + GetUserName(*SessionState) (string, error) + GetGroups(*SessionState, string) ([]string, error) Redeem(string, string) (*SessionState, error) ValidateGroup(*SessionState) bool ValidateSessionState(*SessionState) bool @@ -19,8 +20,6 @@ type Provider interface { func New(provider string, p *ProviderData) (Provider, error) { switch provider { - case "myusa": - return NewMyUsaProvider(p), nil case "linkedin": return NewLinkedInProvider(p), nil case "facebook": @@ -31,6 +30,8 @@ func New(provider string, p *ProviderData) (Provider, error) { return NewAzureProvider(p), nil case "gitlab": return NewGitLabProvider(p), nil + case "oidc": + return NewOIDCProvider(p), nil default: return NewGoogleProvider(p), nil } diff --git a/providers/session_state.go b/providers/session_state.go index 922527e9..b44399b6 100644 --- a/providers/session_state.go +++ b/providers/session_state.go @@ -1,12 +1,12 @@ package providers import ( + "encoding/base64" "fmt" + "github.com/bitly/oauth2_proxy/cookie" "strconv" "strings" "time" - - "github.com/bitly/oauth2_proxy/cookie" ) type SessionState struct { @@ -27,7 +27,7 @@ func (s *SessionState) IsExpired() bool { } func (s *SessionState) String() string { - o := fmt.Sprintf("Session{%s", s.userOrEmail()) + o := fmt.Sprintf("Session{%s", s.accountInfo()) if s.AccessToken != "" { o += " token:true" } @@ -45,17 +45,13 @@ func (s *SessionState) String() string { func (s *SessionState) EncodeSessionState(c *cookie.Cipher) (string, error) { if c == nil || s.AccessToken == "" { - return s.userOrEmail(), nil + return s.accountInfo(), nil } return s.EncryptedString(c) } -func (s *SessionState) userOrEmail() string { - u := s.User - if s.Email != "" { - u = s.Email - } - return u +func (s *SessionState) accountInfo() string { + return fmt.Sprintf("email:%s user:%s", s.Email, s.User) } func (s *SessionState) EncryptedString(c *cookie.Cipher) (string, error) { @@ -65,59 +61,84 @@ func (s *SessionState) EncryptedString(c *cookie.Cipher) (string, error) { } a := s.AccessToken if a != "" { - a, err = c.Encrypt(a) - if err != nil { + if a, err = c.Encrypt(a); err != nil { return "", err } } r := s.RefreshToken if r != "" { - r, err = c.Encrypt(r) - if err != nil { + if r, err = c.Encrypt(r); err != nil { return "", err } } - return fmt.Sprintf("%s:%s:%d:%s:%s", s.userOrEmail(), a, s.ExpiresOn.Unix(), r, s.Groups), nil + + encoded_groups := base64.StdEncoding.EncodeToString([]byte(s.Groups)) + + return fmt.Sprintf("%s|%s|%d|%s|%s", s.accountInfo(), a, s.ExpiresOn.Unix(), r, encoded_groups), nil +} + +func decodeSessionStatePlain(v string) (s *SessionState, err error) { + chunks := strings.Split(v, " ") + if len(chunks) != 2 { + return nil, fmt.Errorf("could not decode session state: expected 2 chunks got %d", len(chunks)) + } + + email := strings.TrimPrefix(chunks[0], "email:") + user := strings.TrimPrefix(chunks[1], "user:") + if user == "" { + user = strings.Split(email, "@")[0] + } + + return &SessionState{User: user, Email: email}, nil +} + +func decodeUserGroups(v string) (groups string, err error) { + decoded_groups, err := base64.StdEncoding.DecodeString(v) + if err != nil { + return "", err + } + return string(decoded_groups), nil } func DecodeSessionState(v string, c *cookie.Cipher) (s *SessionState, err error) { - chunks := strings.Split(v, ":") - if len(chunks) == 1 { - if strings.Contains(chunks[0], "@") { - u := strings.Split(v, "@")[0] - return &SessionState{Email: v, User: u}, nil - } - return &SessionState{User: v}, nil + if c == nil { + return decodeSessionStatePlain(v) } + chunks := strings.Split(v, "|") if len(chunks) != 5 { err = fmt.Errorf("invalid number of fields (got %d expected 5)", len(chunks)) return } - s = &SessionState{} - if c != nil && chunks[1] != "" { - s.AccessToken, err = c.Decrypt(chunks[1]) - if err != nil { + sessionState, err := decodeSessionStatePlain(chunks[0]) + if err != nil { + return nil, err + } + + // Access Token + if chunks[1] != "" { + if sessionState.AccessToken, err = c.Decrypt(chunks[1]); err != nil { return nil, err } } - if c != nil && chunks[3] != "" { - s.RefreshToken, err = c.Decrypt(chunks[3]) - if err != nil { - return nil, err - } - } - if u := chunks[0]; strings.Contains(u, "@") { - s.Email = u - s.User = strings.Split(u, "@")[0] - } else { - s.User = u - } - if chunks[4] != "" { - s.Groups = chunks[4] - } + ts, _ := strconv.Atoi(chunks[2]) - s.ExpiresOn = time.Unix(int64(ts), 0) - return + sessionState.ExpiresOn = time.Unix(int64(ts), 0) + + // Refresh Token + if chunks[3] != "" { + if sessionState.RefreshToken, err = c.Decrypt(chunks[3]); err != nil { + return nil, err + } + } + + // User groups + if chunks[4] != "" { + if sessionState.Groups, err = decodeUserGroups(chunks[4]); err != nil { + return nil, err + } + } + + return sessionState, nil } diff --git a/providers/session_state_test.go b/providers/session_state_test.go index af689d0e..4fd5707a 100644 --- a/providers/session_state_test.go +++ b/providers/session_state_test.go @@ -1,12 +1,14 @@ package providers import ( + "fmt" + "log" "strings" "testing" "time" "github.com/bitly/oauth2_proxy/cookie" - "github.com/bmizerany/assert" + "github.com/stretchr/testify/assert" ) const secret = "0123456789abcdefghijklmnopqrstuv" @@ -26,11 +28,15 @@ func TestSessionStateSerialization(t *testing.T) { } encoded, err := s.EncodeSessionState(c) assert.Equal(t, nil, err) - assert.Equal(t, 4, strings.Count(encoded, ":")) + log.Printf("T = %v", t) + log.Printf("encoded = %v", encoded) + // assert.Equal(t, 4, strings.Count(encoded, ":")) + assert.Equal(t, 4, strings.Count(encoded, "|")) ss, err := DecodeSessionState(encoded, c) t.Logf("%#v", ss) assert.Equal(t, nil, err) + assert.Equal(t, "user", ss.User) assert.Equal(t, s.Email, ss.Email) assert.Equal(t, s.AccessToken, ss.AccessToken) assert.Equal(t, s.ExpiresOn.Unix(), ss.ExpiresOn.Unix()) @@ -40,6 +46,44 @@ func TestSessionStateSerialization(t *testing.T) { ss, err = DecodeSessionState(encoded, c2) t.Logf("%#v", ss) assert.Equal(t, nil, err) + assert.Equal(t, "user", ss.User) + assert.Equal(t, s.Email, ss.Email) + assert.Equal(t, s.ExpiresOn.Unix(), ss.ExpiresOn.Unix()) + assert.NotEqual(t, s.AccessToken, ss.AccessToken) + assert.NotEqual(t, s.RefreshToken, ss.RefreshToken) +} + +func TestSessionStateSerializationWithUser(t *testing.T) { + c, err := cookie.NewCipher([]byte(secret)) + assert.Equal(t, nil, err) + c2, err := cookie.NewCipher([]byte(altSecret)) + assert.Equal(t, nil, err) + s := &SessionState{ + User: "just-user", + Email: "user@domain.com", + AccessToken: "token1234", + ExpiresOn: time.Now().Add(time.Duration(1) * time.Hour), + RefreshToken: "refresh4321", + } + encoded, err := s.EncodeSessionState(c) + assert.Equal(t, nil, err) + log.Printf("Encoded string = %s", encoded) + assert.Equal(t, 4, strings.Count(encoded, "|")) + + ss, err := DecodeSessionState(encoded, c) + t.Logf("%#v", ss) + assert.Equal(t, nil, err) + assert.Equal(t, s.User, ss.User) + assert.Equal(t, s.Email, ss.Email) + assert.Equal(t, s.AccessToken, ss.AccessToken) + assert.Equal(t, s.ExpiresOn.Unix(), ss.ExpiresOn.Unix()) + assert.Equal(t, s.RefreshToken, ss.RefreshToken) + + // ensure a different cipher can't decode properly (ie: it gets gibberish) + ss, err = DecodeSessionState(encoded, c2) + t.Logf("%#v", ss) + assert.Equal(t, nil, err) + assert.Equal(t, s.User, ss.User) assert.Equal(t, s.Email, ss.Email) assert.Equal(t, s.ExpiresOn.Unix(), ss.ExpiresOn.Unix()) assert.NotEqual(t, s.AccessToken, ss.AccessToken) @@ -47,7 +91,6 @@ func TestSessionStateSerialization(t *testing.T) { } func TestSessionStateSerializationNoCipher(t *testing.T) { - s := &SessionState{ Email: "user@domain.com", AccessToken: "token1234", @@ -56,25 +99,51 @@ func TestSessionStateSerializationNoCipher(t *testing.T) { } encoded, err := s.EncodeSessionState(nil) assert.Equal(t, nil, err) - assert.Equal(t, s.Email, encoded) + expected := fmt.Sprintf("email:%s user:", s.Email) + assert.Equal(t, expected, encoded) // only email should have been serialized ss, err := DecodeSessionState(encoded, nil) assert.Equal(t, nil, err) + assert.Equal(t, "user", ss.User) assert.Equal(t, s.Email, ss.Email) assert.Equal(t, "", ss.AccessToken) assert.Equal(t, "", ss.RefreshToken) } -func TestSessionStateUserOrEmail(t *testing.T) { +func TestSessionStateSerializationNoCipherWithUser(t *testing.T) { + s := &SessionState{ + User: "just-user", + Email: "user@domain.com", + AccessToken: "token1234", + ExpiresOn: time.Now().Add(time.Duration(1) * time.Hour), + RefreshToken: "refresh4321", + } + encoded, err := s.EncodeSessionState(nil) + assert.Equal(t, nil, err) + expected := fmt.Sprintf("email:%s user:%s", s.Email, s.User) + assert.Equal(t, expected, encoded) + // only email should have been serialized + ss, err := DecodeSessionState(encoded, nil) + assert.Equal(t, nil, err) + assert.Equal(t, s.User, ss.User) + assert.Equal(t, s.Email, ss.Email) + assert.Equal(t, "", ss.AccessToken) + assert.Equal(t, "", ss.RefreshToken) +} + +func TestSessionStateAccountInfo(t *testing.T) { s := &SessionState{ Email: "user@domain.com", User: "just-user", } - assert.Equal(t, "user@domain.com", s.userOrEmail()) + expected := fmt.Sprintf("email:%v user:%v", s.Email, s.User) + assert.Equal(t, expected, s.accountInfo()) + s.Email = "" - assert.Equal(t, "just-user", s.userOrEmail()) + expected = fmt.Sprintf("email:%v user:%v", s.Email, s.User) + assert.Equal(t, expected, s.accountInfo()) } func TestExpired(t *testing.T) { diff --git a/templates.go b/templates.go index c5619b25..80408bd0 100644 --- a/templates.go +++ b/templates.go @@ -115,7 +115,7 @@ func getTemplates() *template.Template { {{ if .SignInMessage }}

{{.SignInMessage}}

{{ end}} -
+
diff --git a/templates_test.go b/templates_test.go index ed4ed706..49e1a9dd 100644 --- a/templates_test.go +++ b/templates_test.go @@ -1,8 +1,9 @@ package main import ( - "github.com/bmizerany/assert" "testing" + + "github.com/stretchr/testify/assert" ) func TestTemplatesCompile(t *testing.T) { diff --git a/test.sh b/test.sh index ec343663..acc17a23 100755 --- a/test.sh +++ b/test.sh @@ -1,11 +1,14 @@ #!/bin/bash -set -e - +EXIT_CODE=0 echo "gofmt" -diff -u <(echo -n) <(gofmt -d $(find . -type f -name '*.go' -not -path "./.godeps/*")) -echo "go vet" -go vet ./... -echo "go test" -go test -timeout 60s ./... -echo "go test -race" -GOMAXPROCS=4 go test -timeout 60s -race ./... +diff -u <(echo -n) <(gofmt -d $(find . -type f -name '*.go' -not -path "./vendor/*")) || EXIT_CODE=1 +for pkg in $(go list ./... | grep -v '/vendor/' ); do + echo "testing $pkg" + echo "go vet $pkg" + go vet "$pkg" || EXIT_CODE=1 + echo "go test -v $pkg" + go test -v -timeout 90s "$pkg" || EXIT_CODE=1 + echo "go test -v -race $pkg" + GOMAXPROCS=4 go test -v -timeout 90s0s -race "$pkg" || EXIT_CODE=1 +done +exit $EXIT_CODE \ No newline at end of file diff --git a/validator_test.go b/validator_test.go index b87d419b..f91f41ce 100644 --- a/validator_test.go +++ b/validator_test.go @@ -20,7 +20,7 @@ func NewValidatorTest(t *testing.T) *ValidatorTest { if err != nil { t.Fatal("failed to create temp file: " + err.Error()) } - vt.done = make(chan bool) + vt.done = make(chan bool, 1) return vt } diff --git a/validator_watcher_test.go b/validator_watcher_test.go index 70eaa104..dc16a7da 100644 --- a/validator_watcher_test.go +++ b/validator_watcher_test.go @@ -86,7 +86,7 @@ func TestValidatorOverwriteEmailListViaRenameAndReplace(t *testing.T) { vt.WriteEmails(t, []string{"xyzzy@example.com"}) domains := []string(nil) - updated := make(chan bool) + updated := make(chan bool, 1) validator := vt.NewValidator(domains, updated) if !validator("xyzzy@example.com") {