Merge pull request #65 from lsst/jwt_bearer_passthrough
JWT bearer passthrough
This commit is contained in:
		
						commit
						317f09f41e
					
				|  | @ -14,6 +14,10 @@ | |||
| 
 | ||||
| ## Changes since v3.2.0 | ||||
| 
 | ||||
| - [#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). | ||||
|   - Includes fix for potential signature checking issue when OIDC discovery is skipped. | ||||
|  | @ -56,7 +60,6 @@ | |||
| - [#111](https://github.com/pusher/oauth2_proxy/pull/111) Add option for telling where to find a login.gov JWT key file (@timothy-spencer) | ||||
| - [#170](https://github.com/pusher/oauth2_proxy/pull/170) Restore binary tarball contents to be compatible with bitlys original tarballs (@zeha) | ||||
| - [#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. | ||||
| 
 | ||||
|  |  | |||
|  | @ -41,6 +41,7 @@ Usage of oauth2_proxy: | |||
|   -custom-templates-dir string: path to custom html templates | ||||
|   -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 | ||||
|   -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) | ||||
|   -flush-interval: period between flushing response buffers when streaming responses (default "1s") | ||||
|   -footer string: custom footer string. Use "-" to disable default footer. | ||||
|   -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) | ||||
|  | @ -89,6 +90,7 @@ Usage of oauth2_proxy: | |||
|   -signature-key string: GAP-Signature request signature key (algorithm:secretkey) | ||||
|   -skip-auth-preflight: will skip authentication for OPTIONS requests | ||||
|   -skip-auth-regex value: bypass authentication for requests path's that match (may be given multiple times) | ||||
|   -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 | ||||
|  |  | |||
							
								
								
									
										3
									
								
								main.go
								
								
								
								
							
							
						
						
									
										3
									
								
								main.go
								
								
								
								
							|  | @ -23,6 +23,7 @@ func main() { | |||
| 	whitelistDomains := StringArray{} | ||||
| 	upstreams := StringArray{} | ||||
| 	skipAuthRegex := StringArray{} | ||||
| 	jwtIssuers := StringArray{} | ||||
| 	googleGroups := StringArray{} | ||||
| 	redisSentinelConnectionURLs := StringArray{} | ||||
| 
 | ||||
|  | @ -48,6 +49,8 @@ func main() { | |||
| 	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.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)") | ||||
| 
 | ||||
| 	flagSet.Var(&emailDomains, "email-domain", "authenticate emails with the specified domain (may be given multiple times). Use * to authenticate any email") | ||||
| 	flagSet.Var(&whitelistDomains, "whitelist-domain", "allowed domains for redirection after authentication. Prefix domain with a . to allow subdomains (eg .example.com)") | ||||
|  |  | |||
							
								
								
									
										201
									
								
								oauthproxy.go
								
								
								
								
							
							
						
						
									
										201
									
								
								oauthproxy.go
								
								
								
								
							|  | @ -1,6 +1,7 @@ | |||
| package main | ||||
| 
 | ||||
| import ( | ||||
| 	"context" | ||||
| 	b64 "encoding/base64" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
|  | @ -13,6 +14,7 @@ import ( | |||
| 	"strings" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"github.com/coreos/go-oidc" | ||||
| 	"github.com/mbland/hmacauth" | ||||
| 	"github.com/pusher/oauth2_proxy/cookie" | ||||
| 	"github.com/pusher/oauth2_proxy/logger" | ||||
|  | @ -92,6 +94,8 @@ type OAuthProxy struct { | |||
| 	PassAuthorization   bool | ||||
| 	skipAuthRegex       []string | ||||
| 	skipAuthPreflight   bool | ||||
| 	skipJwtBearerTokens bool | ||||
| 	jwtBearerVerifiers  []*oidc.IDTokenVerifier | ||||
| 	compiledRegex       []*regexp.Regexp | ||||
| 	templates           *template.Template | ||||
| 	Footer              string | ||||
|  | @ -206,6 +210,12 @@ func NewOAuthProxy(opts *Options, validator func(string) bool) *OAuthProxy { | |||
| 		logger.Printf("compiled skip-auth-regex => %q", u) | ||||
| 	} | ||||
| 
 | ||||
| 	if opts.SkipJwtBearerTokens { | ||||
| 		logger.Printf("Skipping JWT tokens from configured OIDC issuer: %q", opts.OIDCIssuerURL) | ||||
| 		for _, issuer := range opts.ExtraJwtIssuers { | ||||
| 			logger.Printf("Skipping JWT tokens from extra JWT issuer: %q", issuer) | ||||
| 		} | ||||
| 	} | ||||
| 	redirectURL := opts.redirectURL | ||||
| 	if redirectURL.Path == "" { | ||||
| 		redirectURL.Path = fmt.Sprintf("%s/callback", opts.ProxyPrefix) | ||||
|  | @ -239,25 +249,27 @@ func NewOAuthProxy(opts *Options, validator func(string) bool) *OAuthProxy { | |||
| 		OAuthCallbackPath: fmt.Sprintf("%s/callback", opts.ProxyPrefix), | ||||
| 		AuthOnlyPath:      fmt.Sprintf("%s/auth", opts.ProxyPrefix), | ||||
| 
 | ||||
| 		ProxyPrefix:        opts.ProxyPrefix, | ||||
| 		provider:           opts.provider, | ||||
| 		sessionStore:       opts.sessionStore, | ||||
| 		serveMux:           serveMux, | ||||
| 		redirectURL:        redirectURL, | ||||
| 		whitelistDomains:   opts.WhitelistDomains, | ||||
| 		skipAuthRegex:      opts.SkipAuthRegex, | ||||
| 		skipAuthPreflight:  opts.SkipAuthPreflight, | ||||
| 		compiledRegex:      opts.CompiledRegex, | ||||
| 		SetXAuthRequest:    opts.SetXAuthRequest, | ||||
| 		PassBasicAuth:      opts.PassBasicAuth, | ||||
| 		PassUserHeaders:    opts.PassUserHeaders, | ||||
| 		BasicAuthPassword:  opts.BasicAuthPassword, | ||||
| 		PassAccessToken:    opts.PassAccessToken, | ||||
| 		SetAuthorization:   opts.SetAuthorization, | ||||
| 		PassAuthorization:  opts.PassAuthorization, | ||||
| 		SkipProviderButton: opts.SkipProviderButton, | ||||
| 		templates:          loadTemplates(opts.CustomTemplatesDir), | ||||
| 		Footer:             opts.Footer, | ||||
| 		ProxyPrefix:         opts.ProxyPrefix, | ||||
| 		provider:            opts.provider, | ||||
| 		sessionStore:        opts.sessionStore, | ||||
| 		serveMux:            serveMux, | ||||
| 		redirectURL:         redirectURL, | ||||
| 		whitelistDomains:    opts.WhitelistDomains, | ||||
| 		skipAuthRegex:       opts.SkipAuthRegex, | ||||
| 		skipAuthPreflight:   opts.SkipAuthPreflight, | ||||
| 		skipJwtBearerTokens: opts.SkipJwtBearerTokens, | ||||
| 		jwtBearerVerifiers:  opts.jwtBearerVerifiers, | ||||
| 		compiledRegex:       opts.CompiledRegex, | ||||
| 		SetXAuthRequest:     opts.SetXAuthRequest, | ||||
| 		PassBasicAuth:       opts.PassBasicAuth, | ||||
| 		PassUserHeaders:     opts.PassUserHeaders, | ||||
| 		BasicAuthPassword:   opts.BasicAuthPassword, | ||||
| 		PassAccessToken:     opts.PassAccessToken, | ||||
| 		SetAuthorization:    opts.SetAuthorization, | ||||
| 		PassAuthorization:   opts.PassAuthorization, | ||||
| 		SkipProviderButton:  opts.SkipProviderButton, | ||||
| 		templates:           loadTemplates(opts.CustomTemplatesDir), | ||||
| 		Footer:              opts.Footer, | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
|  | @ -638,7 +650,7 @@ func (p *OAuthProxy) OAuthCallback(rw http.ResponseWriter, req *http.Request) { | |||
| 		} | ||||
| 		http.Redirect(rw, req, redirect, 302) | ||||
| 	} else { | ||||
| 		logger.PrintAuthf(session.Email, req, logger.AuthSuccess, "Invalid authentication via OAuth2: unauthorized") | ||||
| 		logger.PrintAuthf(session.Email, req, logger.AuthFailure, "Invalid authentication via OAuth2: unauthorized") | ||||
| 		p.ErrorPage(rw, 403, "Permission Denied", "Invalid Account") | ||||
| 	} | ||||
| } | ||||
|  | @ -693,26 +705,42 @@ func (p *OAuthProxy) Proxy(rw http.ResponseWriter, req *http.Request) { | |||
| // Returns nil, ErrNeedsLogin if user needs to login.
 | ||||
| // Set-Cookie headers may be set on the response as a side-effect of calling this method.
 | ||||
| func (p *OAuthProxy) getAuthenticatedSession(rw http.ResponseWriter, req *http.Request) (*sessionsapi.SessionState, error) { | ||||
| 	var session *sessionsapi.SessionState | ||||
| 	var err error | ||||
| 	var saveSession, clearSession, revalidated bool | ||||
| 
 | ||||
| 	if p.skipJwtBearerTokens && req.Header.Get("Authorization") != "" { | ||||
| 		session, err = p.GetJwtSession(req) | ||||
| 		if err != nil { | ||||
| 			logger.Printf("Error retrieving session from token in Authorization header: %s", err) | ||||
| 		} | ||||
| 		if session != nil { | ||||
| 			saveSession = false | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	remoteAddr := getRemoteAddr(req) | ||||
| 	if session == nil { | ||||
| 		session, err = p.LoadCookiedSession(req) | ||||
| 		if err != nil { | ||||
| 			logger.Printf("Error loading cookied session: %s", err) | ||||
| 		} | ||||
| 
 | ||||
| 	session, err := p.LoadCookiedSession(req) | ||||
| 	if err != nil { | ||||
| 		logger.Printf("Error loading cookied session: %s", err) | ||||
| 	} | ||||
| 	if session != nil && session.Age() > p.CookieRefresh && p.CookieRefresh != time.Duration(0) { | ||||
| 		logger.Printf("Refreshing %s old session cookie for %s (refresh after %s)", session.Age(), session, p.CookieRefresh) | ||||
| 		saveSession = true | ||||
| 	} | ||||
| 		if session != nil { | ||||
| 			if session.Age() > p.CookieRefresh && p.CookieRefresh != time.Duration(0) { | ||||
| 				logger.Printf("Refreshing %s old session cookie for %s (refresh after %s)", session.Age(), session, p.CookieRefresh) | ||||
| 				saveSession = true | ||||
| 			} | ||||
| 
 | ||||
| 	var ok bool | ||||
| 	if ok, err = p.provider.RefreshSessionIfNeeded(session); err != nil { | ||||
| 		logger.Printf("%s removing session. error refreshing access token %s %s", remoteAddr, err, session) | ||||
| 		clearSession = true | ||||
| 		session = nil | ||||
| 	} else if ok { | ||||
| 		saveSession = true | ||||
| 		revalidated = true | ||||
| 			if ok, err := p.provider.RefreshSessionIfNeeded(session); err != nil { | ||||
| 				logger.Printf("%s removing session. error refreshing access token %s %s", remoteAddr, err, session) | ||||
| 				clearSession = true | ||||
| 				session = nil | ||||
| 			} else if ok { | ||||
| 				saveSession = true | ||||
| 				revalidated = true | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if session != nil && session.IsExpired() { | ||||
|  | @ -731,11 +759,13 @@ func (p *OAuthProxy) getAuthenticatedSession(rw http.ResponseWriter, req *http.R | |||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if session != nil && session.Email != "" && !p.Validator(session.Email) { | ||||
| 		logger.Printf(session.Email, req, logger.AuthFailure, "Invalid authentication via session: removing session %s", session) | ||||
| 		session = nil | ||||
| 		saveSession = false | ||||
| 		clearSession = true | ||||
| 	if session != nil && session.Email != "" { | ||||
| 		if !p.Validator(session.Email) || !p.provider.ValidateGroup(session.Email) { | ||||
| 			logger.Printf(session.Email, req, logger.AuthFailure, "Invalid authentication via session: removing session %s", session) | ||||
| 			session = nil | ||||
| 			saveSession = false | ||||
| 			clearSession = true | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if saveSession && session != nil { | ||||
|  | @ -854,3 +884,92 @@ func (p *OAuthProxy) ErrorJSON(rw http.ResponseWriter, code int) { | |||
| 	rw.Header().Set("Content-Type", applicationJSON) | ||||
| 	rw.WriteHeader(code) | ||||
| } | ||||
| 
 | ||||
| // GetJwtSession loads a session based on a JWT token in the authorization header.
 | ||||
| func (p *OAuthProxy) GetJwtSession(req *http.Request) (*sessionsapi.SessionState, error) { | ||||
| 	rawBearerToken, err := p.findBearerToken(req) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 
 | ||||
| 	ctx := context.Background() | ||||
| 	var session *sessionsapi.SessionState | ||||
| 	for _, verifier := range p.jwtBearerVerifiers { | ||||
| 		bearerToken, err := verifier.Verify(ctx, rawBearerToken) | ||||
| 
 | ||||
| 		if err != nil { | ||||
| 			logger.Printf("failed to verify bearer token: %v", err) | ||||
| 			continue | ||||
| 		} | ||||
| 
 | ||||
| 		var claims struct { | ||||
| 			Subject  string `json:"sub"` | ||||
| 			Email    string `json:"email"` | ||||
| 			Verified *bool  `json:"email_verified"` | ||||
| 		} | ||||
| 
 | ||||
| 		if err := bearerToken.Claims(&claims); err != nil { | ||||
| 			return nil, fmt.Errorf("failed to parse bearer token claims: %v", err) | ||||
| 		} | ||||
| 
 | ||||
| 		if claims.Email == "" { | ||||
| 			claims.Email = claims.Subject | ||||
| 		} | ||||
| 
 | ||||
| 		if claims.Verified != nil && !*claims.Verified { | ||||
| 			return nil, fmt.Errorf("email in id_token (%s) isn't verified", claims.Email) | ||||
| 		} | ||||
| 
 | ||||
| 		session = &sessionsapi.SessionState{ | ||||
| 			AccessToken:  rawBearerToken, | ||||
| 			IDToken:      rawBearerToken, | ||||
| 			RefreshToken: "", | ||||
| 			ExpiresOn:    bearerToken.Expiry, | ||||
| 			Email:        claims.Email, | ||||
| 			User:         claims.Email, | ||||
| 		} | ||||
| 		return session, nil | ||||
| 	} | ||||
| 	return nil, fmt.Errorf("unable to verify jwt token %s", req.Header.Get("Authorization")) | ||||
| } | ||||
| 
 | ||||
| // findBearerToken finds a valid JWT token from the Authorization header of a given request.
 | ||||
| func (p *OAuthProxy) findBearerToken(req *http.Request) (string, error) { | ||||
| 	auth := req.Header.Get("Authorization") | ||||
| 	s := strings.SplitN(auth, " ", 2) | ||||
| 	if len(s) != 2 { | ||||
| 		return "", fmt.Errorf("invalid authorization header %s", auth) | ||||
| 	} | ||||
| 	jwtRegex := regexp.MustCompile(`^eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]+$`) | ||||
| 	var rawBearerToken string | ||||
| 	if s[0] == "Bearer" && jwtRegex.MatchString(s[1]) { | ||||
| 		rawBearerToken = s[1] | ||||
| 	} else if s[0] == "Basic" { | ||||
| 		// Check if we have a Bearer token masquerading in Basic
 | ||||
| 		b, err := b64.StdEncoding.DecodeString(s[1]) | ||||
| 		if err != nil { | ||||
| 			return "", err | ||||
| 		} | ||||
| 		pair := strings.SplitN(string(b), ":", 2) | ||||
| 		if len(pair) != 2 { | ||||
| 			return "", fmt.Errorf("invalid format %s", b) | ||||
| 		} | ||||
| 		user, password := pair[0], pair[1] | ||||
| 
 | ||||
| 		// check user, user+password, or just password for a token
 | ||||
| 		if jwtRegex.MatchString(user) { | ||||
| 			// Support blank passwords or magic `x-oauth-basic` passwords - nothing else
 | ||||
| 			if password == "" || password == "x-oauth-basic" { | ||||
| 				rawBearerToken = user | ||||
| 			} | ||||
| 		} else if jwtRegex.MatchString(password) { | ||||
| 			// support passwords and ignore user
 | ||||
| 			rawBearerToken = password | ||||
| 		} | ||||
| 	} | ||||
| 	if rawBearerToken == "" { | ||||
| 		return "", fmt.Errorf("no valid bearer token found in authorization header") | ||||
| 	} | ||||
| 
 | ||||
| 	return rawBearerToken, nil | ||||
| } | ||||
|  |  | |||
|  | @ -1,8 +1,10 @@ | |||
| package main | ||||
| 
 | ||||
| import ( | ||||
| 	"context" | ||||
| 	"crypto" | ||||
| 	"encoding/base64" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"io/ioutil" | ||||
| 	"net" | ||||
|  | @ -14,6 +16,7 @@ import ( | |||
| 	"testing" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"github.com/coreos/go-oidc" | ||||
| 	"github.com/mbland/hmacauth" | ||||
| 	"github.com/pusher/oauth2_proxy/logger" | ||||
| 	"github.com/pusher/oauth2_proxy/pkg/apis/sessions" | ||||
|  | @ -226,8 +229,9 @@ func TestIsValidRedirect(t *testing.T) { | |||
| 
 | ||||
| type TestProvider struct { | ||||
| 	*providers.ProviderData | ||||
| 	EmailAddress string | ||||
| 	ValidToken   bool | ||||
| 	EmailAddress   string | ||||
| 	ValidToken     bool | ||||
| 	GroupValidator func(string) bool | ||||
| } | ||||
| 
 | ||||
| func NewTestProvider(providerURL *url.URL, emailAddress string) *TestProvider { | ||||
|  | @ -252,6 +256,9 @@ func NewTestProvider(providerURL *url.URL, emailAddress string) *TestProvider { | |||
| 			Scope: "profile.email", | ||||
| 		}, | ||||
| 		EmailAddress: emailAddress, | ||||
| 		GroupValidator: func(s string) bool { | ||||
| 			return true | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
|  | @ -263,6 +270,13 @@ func (tp *TestProvider) ValidateSessionState(session *sessions.SessionState) boo | |||
| 	return tp.ValidToken | ||||
| } | ||||
| 
 | ||||
| func (tp *TestProvider) ValidateGroup(email string) bool { | ||||
| 	if tp.GroupValidator != nil { | ||||
| 		return tp.GroupValidator(email) | ||||
| 	} | ||||
| 	return true | ||||
| } | ||||
| 
 | ||||
| func TestBasicAuthPassword(t *testing.T) { | ||||
| 	providerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||||
| 		logger.Printf("%#v", r) | ||||
|  | @ -788,6 +802,25 @@ func TestAuthOnlyEndpointUnauthorizedOnEmailValidationFailure(t *testing.T) { | |||
| 	assert.Equal(t, "unauthorized request\n", string(bodyBytes)) | ||||
| } | ||||
| 
 | ||||
| func TestAuthOnlyEndpointUnauthorizedOnProviderGroupValidationFailure(t *testing.T) { | ||||
| 	test := NewAuthOnlyEndpointTest() | ||||
| 	startSession := &sessions.SessionState{ | ||||
| 		Email: "michael.bland@gsa.gov", AccessToken: "my_access_token", CreatedAt: time.Now()} | ||||
| 	test.SaveSession(startSession) | ||||
| 	provider := &TestProvider{ | ||||
| 		ValidToken: true, | ||||
| 		GroupValidator: func(s string) bool { | ||||
| 			return false | ||||
| 		}, | ||||
| 	} | ||||
| 
 | ||||
| 	test.proxy.provider = provider | ||||
| 	test.proxy.ServeHTTP(test.rw, test.req) | ||||
| 	assert.Equal(t, http.StatusUnauthorized, test.rw.Code) | ||||
| 	bodyBytes, _ := ioutil.ReadAll(test.rw.Body) | ||||
| 	assert.Equal(t, "unauthorized request\n", string(bodyBytes)) | ||||
| } | ||||
| 
 | ||||
| func TestAuthOnlyEndpointSetXAuthRequestHeaders(t *testing.T) { | ||||
| 	var pcTest ProcessCookieTest | ||||
| 
 | ||||
|  | @ -1132,3 +1165,173 @@ func TestClearSingleCookie(t *testing.T) { | |||
| 
 | ||||
| 	assert.Equal(t, 1, len(header["Set-Cookie"]), "should have 1 set-cookie header entries") | ||||
| } | ||||
| 
 | ||||
| type NoOpKeySet struct { | ||||
| } | ||||
| 
 | ||||
| func (NoOpKeySet) VerifySignature(ctx context.Context, jwt string) (payload []byte, err error) { | ||||
| 	splitStrings := strings.Split(jwt, ".") | ||||
| 	payloadString := splitStrings[1] | ||||
| 	jsonString, err := base64.RawURLEncoding.DecodeString(payloadString) | ||||
| 	return []byte(jsonString), err | ||||
| } | ||||
| 
 | ||||
| func TestGetJwtSession(t *testing.T) { | ||||
| 	/* token payload: | ||||
| 	{ | ||||
| 	  "sub": "1234567890", | ||||
| 	  "aud": "https://test.myapp.com", | ||||
| 	  "name": "John Doe", | ||||
| 	  "email": "john@example.com", | ||||
| 	  "iss": "https://issuer.example.com", | ||||
| 	  "iat": 1553691215, | ||||
| 	  "exp": 1912151821 | ||||
| 	} | ||||
| 	*/ | ||||
| 	goodJwt := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9." + | ||||
| 		"eyJzdWIiOiIxMjM0NTY3ODkwIiwiYXVkIjoiaHR0cHM6Ly90ZXN0Lm15YXBwLmNvbSIsIm5hbWUiOiJKb2huIERvZSIsImVtY" + | ||||
| 		"WlsIjoiam9obkBleGFtcGxlLmNvbSIsImlzcyI6Imh0dHBzOi8vaXNzdWVyLmV4YW1wbGUuY29tIiwiaWF0IjoxNTUzNjkxMj" + | ||||
| 		"E1LCJleHAiOjE5MTIxNTE4MjF9." + | ||||
| 		"rLVyzOnEldUq_pNkfa-WiV8TVJYWyZCaM2Am_uo8FGg11zD7l-qmz3x1seTvqpH6Y0Ty00fmv6dJnGnC8WMnPXQiodRTfhBSe" + | ||||
| 		"OKZMu0HkMD2sg52zlKkbfLTO6ic5VnbVgwjjrB8am_Ta6w7kyFUaB5C1BsIrrLMldkWEhynbb8" | ||||
| 
 | ||||
| 	keyset := NoOpKeySet{} | ||||
| 	verifier := oidc.NewVerifier("https://issuer.example.com", keyset, | ||||
| 		&oidc.Config{ClientID: "https://test.myapp.com", SkipExpiryCheck: true}) | ||||
| 
 | ||||
| 	test := NewAuthOnlyEndpointTest(func(opts *Options) { | ||||
| 		opts.PassAuthorization = true | ||||
| 		opts.SetAuthorization = true | ||||
| 		opts.SetXAuthRequest = true | ||||
| 		opts.SkipJwtBearerTokens = true | ||||
| 		opts.jwtBearerVerifiers = append(opts.jwtBearerVerifiers, verifier) | ||||
| 	}) | ||||
| 	tp, _ := test.proxy.provider.(*TestProvider) | ||||
| 	tp.GroupValidator = func(s string) bool { | ||||
| 		return true | ||||
| 	} | ||||
| 
 | ||||
| 	authHeader := fmt.Sprintf("Bearer %s", goodJwt) | ||||
| 	test.req.Header = map[string][]string{ | ||||
| 		"Authorization": {authHeader}, | ||||
| 	} | ||||
| 
 | ||||
| 	// Bearer
 | ||||
| 	session, _ := test.proxy.GetJwtSession(test.req) | ||||
| 	assert.Equal(t, session.User, "john@example.com") | ||||
| 	assert.Equal(t, session.Email, "john@example.com") | ||||
| 	assert.Equal(t, session.ExpiresOn, time.Unix(1912151821, 0)) | ||||
| 	assert.Equal(t, session.IDToken, goodJwt) | ||||
| 
 | ||||
| 	test.proxy.ServeHTTP(test.rw, test.req) | ||||
| 	if test.rw.Code >= 400 { | ||||
| 		t.Fatalf("expected 3xx got %d", test.rw.Code) | ||||
| 	} | ||||
| 
 | ||||
| 	// Check PassAuthorization, should overwrite Basic header
 | ||||
| 	assert.Equal(t, test.req.Header.Get("Authorization"), authHeader) | ||||
| 	assert.Equal(t, test.req.Header.Get("X-Forwarded-User"), "john@example.com") | ||||
| 	assert.Equal(t, test.req.Header.Get("X-Forwarded-Email"), "john@example.com") | ||||
| 
 | ||||
| 	// SetAuthorization and SetXAuthRequest
 | ||||
| 	assert.Equal(t, test.rw.Header().Get("Authorization"), authHeader) | ||||
| 	assert.Equal(t, test.rw.Header().Get("X-Auth-Request-User"), "john@example.com") | ||||
| 	assert.Equal(t, test.rw.Header().Get("X-Auth-Request-Email"), "john@example.com") | ||||
| } | ||||
| 
 | ||||
| func TestJwtUnauthorizedOnGroupValidationFailure(t *testing.T) { | ||||
| 	goodJwt := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9." + | ||||
| 		"eyJzdWIiOiIxMjM0NTY3ODkwIiwiYXVkIjoiaHR0cHM6Ly90ZXN0Lm15YXBwLmNvbSIsIm5hbWUiOiJKb2huIERvZSIsImVtY" + | ||||
| 		"WlsIjoiam9obkBleGFtcGxlLmNvbSIsImlzcyI6Imh0dHBzOi8vaXNzdWVyLmV4YW1wbGUuY29tIiwiaWF0IjoxNTUzNjkxMj" + | ||||
| 		"E1LCJleHAiOjE5MTIxNTE4MjF9." + | ||||
| 		"rLVyzOnEldUq_pNkfa-WiV8TVJYWyZCaM2Am_uo8FGg11zD7l-qmz3x1seTvqpH6Y0Ty00fmv6dJnGnC8WMnPXQiodRTfhBSe" + | ||||
| 		"OKZMu0HkMD2sg52zlKkbfLTO6ic5VnbVgwjjrB8am_Ta6w7kyFUaB5C1BsIrrLMldkWEhynbb8" | ||||
| 
 | ||||
| 	keyset := NoOpKeySet{} | ||||
| 	verifier := oidc.NewVerifier("https://issuer.example.com", keyset, | ||||
| 		&oidc.Config{ClientID: "https://test.myapp.com", SkipExpiryCheck: true}) | ||||
| 
 | ||||
| 	test := NewAuthOnlyEndpointTest(func(opts *Options) { | ||||
| 		opts.PassAuthorization = true | ||||
| 		opts.SetAuthorization = true | ||||
| 		opts.SetXAuthRequest = true | ||||
| 		opts.SkipJwtBearerTokens = true | ||||
| 		opts.jwtBearerVerifiers = append(opts.jwtBearerVerifiers, verifier) | ||||
| 	}) | ||||
| 	tp, _ := test.proxy.provider.(*TestProvider) | ||||
| 	// Verify ValidateGroup fails JWT authorization
 | ||||
| 	tp.GroupValidator = func(s string) bool { | ||||
| 		return false | ||||
| 	} | ||||
| 
 | ||||
| 	authHeader := fmt.Sprintf("Bearer %s", goodJwt) | ||||
| 	test.req.Header = map[string][]string{ | ||||
| 		"Authorization": {authHeader}, | ||||
| 	} | ||||
| 	test.proxy.ServeHTTP(test.rw, test.req) | ||||
| 	if test.rw.Code != http.StatusUnauthorized { | ||||
| 		t.Fatalf("expected 401 got %d", test.rw.Code) | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func TestFindJwtBearerToken(t *testing.T) { | ||||
| 	p := OAuthProxy{CookieName: "oauth2", CookieDomain: "abc"} | ||||
| 	getReq := &http.Request{URL: &url.URL{Scheme: "http", Host: "example.com"}} | ||||
| 
 | ||||
| 	validToken := "eyJfoobar.eyJfoobar.12345asdf" | ||||
| 	var token string | ||||
| 
 | ||||
| 	// Bearer
 | ||||
| 	getReq.Header = map[string][]string{ | ||||
| 		"Authorization": {fmt.Sprintf("Bearer %s", validToken)}, | ||||
| 	} | ||||
| 
 | ||||
| 	token, _ = p.findBearerToken(getReq) | ||||
| 	assert.Equal(t, validToken, token) | ||||
| 
 | ||||
| 	// Basic - no password
 | ||||
| 	getReq.SetBasicAuth(token, "") | ||||
| 	token, _ = p.findBearerToken(getReq) | ||||
| 	assert.Equal(t, validToken, token) | ||||
| 
 | ||||
| 	// Basic - sentinel password
 | ||||
| 	getReq.SetBasicAuth(token, "x-oauth-basic") | ||||
| 	token, _ = p.findBearerToken(getReq) | ||||
| 	assert.Equal(t, validToken, token) | ||||
| 
 | ||||
| 	// Basic - any username, password matching jwt pattern
 | ||||
| 	getReq.SetBasicAuth("any-username-you-could-wish-for", token) | ||||
| 	token, _ = p.findBearerToken(getReq) | ||||
| 	assert.Equal(t, validToken, token) | ||||
| 
 | ||||
| 	failures := []string{ | ||||
| 		// Too many parts
 | ||||
| 		"eyJhbGciOiJSU0EtT0FFUCIsImVuYyI6IkExMjhHQ00ifQ.dGVzdA.dGVzdA.dGVzdA.dGVzdA.dGVzdA", | ||||
| 		// Not enough parts
 | ||||
| 		"eyJhbGciOiJSU0EtT0FFUCIsImVuYyI6IkExMjhHQ00ifQ.dGVzdA.dGVzdA.dGVzdA", | ||||
| 		// Invalid encrypted key
 | ||||
| 		"eyJhbGciOiJSU0EtT0FFUCIsImVuYyI6IkExMjhHQ00ifQ.//////.dGVzdA.dGVzdA.dGVzdA", | ||||
| 		// Invalid IV
 | ||||
| 		"eyJhbGciOiJSU0EtT0FFUCIsImVuYyI6IkExMjhHQ00ifQ.dGVzdA.//////.dGVzdA.dGVzdA", | ||||
| 		// Invalid ciphertext
 | ||||
| 		"eyJhbGciOiJSU0EtT0FFUCIsImVuYyI6IkExMjhHQ00ifQ.dGVzdA.dGVzdA.//////.dGVzdA", | ||||
| 		// Invalid tag
 | ||||
| 		"eyJhbGciOiJSU0EtT0FFUCIsImVuYyI6IkExMjhHQ00ifQ.dGVzdA.dGVzdA.dGVzdA.//////", | ||||
| 		// Invalid header
 | ||||
| 		"W10.dGVzdA.dGVzdA.dGVzdA.dGVzdA", | ||||
| 		// Invalid header
 | ||||
| 		"######.dGVzdA.dGVzdA.dGVzdA.dGVzdA", | ||||
| 		// Missing alc/enc params
 | ||||
| 		"e30.dGVzdA.dGVzdA.dGVzdA.dGVzdA", | ||||
| 	} | ||||
| 
 | ||||
| 	for _, failure := range failures { | ||||
| 		getReq.Header = map[string][]string{ | ||||
| 			"Authorization": {fmt.Sprintf("Bearer %s", failure)}, | ||||
| 		} | ||||
| 		_, err := p.findBearerToken(getReq) | ||||
| 		assert.Error(t, err) | ||||
| 	} | ||||
| 
 | ||||
| 	fmt.Printf("%s", token) | ||||
| } | ||||
|  |  | |||
							
								
								
									
										81
									
								
								options.go
								
								
								
								
							
							
						
						
									
										81
									
								
								options.go
								
								
								
								
							|  | @ -61,6 +61,8 @@ type Options struct { | |||
| 
 | ||||
| 	Upstreams             []string      `flag:"upstream" cfg:"upstreams" env:"OAUTH2_PROXY_UPSTREAMS"` | ||||
| 	SkipAuthRegex         []string      `flag:"skip-auth-regex" cfg:"skip_auth_regex" env:"OAUTH2_PROXY_SKIP_AUTH_REGEX"` | ||||
| 	SkipJwtBearerTokens   bool          `flag:"skip-jwt-bearer-tokens" cfg:"skip_jwt_bearer_tokens" env:"OAUTH2_PROXY_SKIP_JWT_BEARER_TOKENS"` | ||||
| 	ExtraJwtIssuers       []string      `flag:"extra-jwt-issuers" cfg:"extra_jwt_issuers" env:"OAUTH2_PROXY_EXTRA_JWT_ISSUERS"` | ||||
| 	PassBasicAuth         bool          `flag:"pass-basic-auth" cfg:"pass_basic_auth" env:"OAUTH2_PROXY_PASS_BASIC_AUTH"` | ||||
| 	BasicAuthPassword     string        `flag:"basic-auth-password" cfg:"basic_auth_password" env:"OAUTH2_PROXY_BASIC_AUTH_PASSWORD"` | ||||
| 	PassAccessToken       bool          `flag:"pass-access-token" cfg:"pass_access_token" env:"OAUTH2_PROXY_PASS_ACCESS_TOKEN"` | ||||
|  | @ -110,13 +112,14 @@ type Options struct { | |||
| 	GCPHealthChecks bool   `flag:"gcp-healthchecks" cfg:"gcp_healthchecks" env:"OAUTH2_PROXY_GCP_HEALTHCHECKS"` | ||||
| 
 | ||||
| 	// internal values that are set after config validation
 | ||||
| 	redirectURL   *url.URL | ||||
| 	proxyURLs     []*url.URL | ||||
| 	CompiledRegex []*regexp.Regexp | ||||
| 	provider      providers.Provider | ||||
| 	sessionStore  sessionsapi.SessionStore | ||||
| 	signatureData *SignatureData | ||||
| 	oidcVerifier  *oidc.IDTokenVerifier | ||||
| 	redirectURL        *url.URL | ||||
| 	proxyURLs          []*url.URL | ||||
| 	CompiledRegex      []*regexp.Regexp | ||||
| 	provider           providers.Provider | ||||
| 	sessionStore       sessionsapi.SessionStore | ||||
| 	signatureData      *SignatureData | ||||
| 	oidcVerifier       *oidc.IDTokenVerifier | ||||
| 	jwtBearerVerifiers []*oidc.IDTokenVerifier | ||||
| } | ||||
| 
 | ||||
| // SignatureData holds hmacauth signature hash and key
 | ||||
|  | @ -168,6 +171,12 @@ func NewOptions() *Options { | |||
| 	} | ||||
| } | ||||
| 
 | ||||
| // jwtIssuer hold parsed JWT issuer info that's used to construct a verifier.
 | ||||
| type jwtIssuer struct { | ||||
| 	issuerURI string | ||||
| 	audience  string | ||||
| } | ||||
| 
 | ||||
| func parseURL(toParse string, urltype string, msgs []string) (*url.URL, []string) { | ||||
| 	parsed, err := url.Parse(toParse) | ||||
| 	if err != nil { | ||||
|  | @ -244,6 +253,25 @@ func (o *Options) Validate() error { | |||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if o.SkipJwtBearerTokens { | ||||
| 		// If we are using an oidc provider, go ahead and add that provider to the list
 | ||||
| 		if o.oidcVerifier != nil { | ||||
| 			o.jwtBearerVerifiers = append(o.jwtBearerVerifiers, o.oidcVerifier) | ||||
| 		} | ||||
| 		// Configure extra issuers
 | ||||
| 		if len(o.ExtraJwtIssuers) > 0 { | ||||
| 			var jwtIssuers []jwtIssuer | ||||
| 			jwtIssuers, msgs = parseJwtIssuers(o.ExtraJwtIssuers, msgs) | ||||
| 			for _, jwtIssuer := range jwtIssuers { | ||||
| 				verifier, err := newVerifierFromJwtIssuer(jwtIssuer) | ||||
| 				if err != nil { | ||||
| 					msgs = append(msgs, fmt.Sprintf("error building verifiers: %s", err)) | ||||
| 				} | ||||
| 				o.jwtBearerVerifiers = append(o.jwtBearerVerifiers, verifier) | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	o.redirectURL, msgs = parseURL(o.RedirectURL, "redirect", msgs) | ||||
| 
 | ||||
| 	for _, u := range o.Upstreams { | ||||
|  | @ -430,6 +458,45 @@ func parseSignatureKey(o *Options, msgs []string) []string { | |||
| 	return msgs | ||||
| } | ||||
| 
 | ||||
| // parseJwtIssuers takes in an array of strings in the form of issuer=audience
 | ||||
| // and parses to an array of jwtIssuer structs.
 | ||||
| func parseJwtIssuers(issuers []string, msgs []string) ([]jwtIssuer, []string) { | ||||
| 	var parsedIssuers []jwtIssuer | ||||
| 	for _, jwtVerifier := range issuers { | ||||
| 		components := strings.Split(jwtVerifier, "=") | ||||
| 		if len(components) < 2 { | ||||
| 			msgs = append(msgs, fmt.Sprintf("invalid jwt verifier uri=audience spec: %s", jwtVerifier)) | ||||
| 			continue | ||||
| 		} | ||||
| 		uri, audience := components[0], strings.Join(components[1:], "=") | ||||
| 		parsedIssuers = append(parsedIssuers, jwtIssuer{issuerURI: uri, audience: audience}) | ||||
| 	} | ||||
| 	return parsedIssuers, msgs | ||||
| } | ||||
| 
 | ||||
| // newVerifierFromJwtIssuer takes in issuer information in jwtIssuer info and returns
 | ||||
| // a verifier for that issuer.
 | ||||
| func newVerifierFromJwtIssuer(jwtIssuer jwtIssuer) (*oidc.IDTokenVerifier, error) { | ||||
| 	config := &oidc.Config{ | ||||
| 		ClientID: jwtIssuer.audience, | ||||
| 	} | ||||
| 	// Try as an OpenID Connect Provider first
 | ||||
| 	var verifier *oidc.IDTokenVerifier | ||||
| 	provider, err := oidc.NewProvider(context.Background(), jwtIssuer.issuerURI) | ||||
| 	if err != nil { | ||||
| 		// Try as JWKS URI
 | ||||
| 		jwksURI := strings.TrimSuffix(jwtIssuer.issuerURI, "/") + "/.well-known/jwks.json" | ||||
| 		_, err := http.NewRequest("GET", jwksURI, nil) | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| 		verifier = oidc.NewVerifier(jwtIssuer.issuerURI, oidc.NewRemoteKeySet(context.Background(), jwksURI), config) | ||||
| 	} else { | ||||
| 		verifier = provider.Verifier(config) | ||||
| 	} | ||||
| 	return verifier, nil | ||||
| } | ||||
| 
 | ||||
| func validateCookieName(o *Options, msgs []string) []string { | ||||
| 	cookie := &http.Cookie{Name: o.CookieName} | ||||
| 	if cookie.String() == "" { | ||||
|  |  | |||
|  | @ -128,7 +128,7 @@ func (p *OIDCProvider) createSessionState(ctx context.Context, token *oauth2.Tok | |||
| 		IDToken:      rawIDToken, | ||||
| 		RefreshToken: token.RefreshToken, | ||||
| 		CreatedAt:    time.Now(), | ||||
| 		ExpiresOn:    token.Expiry, | ||||
| 		ExpiresOn:    idToken.Expiry, | ||||
| 		Email:        claims.Email, | ||||
| 		User:         claims.Subject, | ||||
| 	}, nil | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue