git.iko.soy/typeable/oauth2-proxy
on master help sign in
Support nonce checks in OIDC Provider (#967)

* Set and verify a nonce with OIDC * Create a CSRF object to manage nonces & cookies * Add missing generic cookie unit tests * Add config flag to control OIDC SkipNonce * Send hashed nonces in authentication requests * Encrypt the CSRF cookie * Add clarity to naming & add more helper methods * Make CSRF an interface and keep underlying nonces private * Add ReverseProxy scope to cookie tests * Align to new 1.16 SameSite cookie default * Perform SecretBytes conversion on CSRF cookie crypto * Make state encoding signatures consistent * Mock time in CSRF struct via Clock * Improve InsecureSkipNonce docstring

commit7eeaea0b3f563c7e14f213e47dfda82b40f3fca1 authorNick Meves <nick.meves@greenhouse.io> dateWed 21 Apr 2021, 02:33 (5 years ago) parentd342340
31 files changed+855−165
CHANGELOG.md+4 docs/docs/configuration/alpha_config.md+1 docs/docs/configuration/overview.md+1 main_test.go+6−4 oauthproxy.go+61−80 oauthproxy_test.go+32−8 pkg/apis/options/legacy_options.go+10−6 pkg/apis/options/legacy_options_test.go+1 pkg/apis/options/load_test.go+7−6 pkg/apis/options/providers.go+7 pkg/apis/sessions/session_state.go+7 pkg/apis/sessions/session_state_test.go+4 pkg/cookies/cookies.go+34−31 pkg/cookies/cookies_suite_test.go+35 pkg/cookies/cookies_test.go+79 pkg/cookies/csrf.go+199 pkg/cookies/csrf_test.go+190 pkg/encryption/nonce.go+28−8 pkg/validation/options.go+1 pkg/validation/sessions.go+3−1 providers/azure.go+1−1 providers/azure_test.go+1−1 providers/logingov.go+1−1 providers/logingov_test.go+1−1 providers/oidc.go+33−3 providers/oidc_test.go+24−4 providers/provider_data.go+13 providers/provider_data_test.go+61 providers/provider_default.go+7−7 providers/provider_default_test.go+2−2 providers/providers.go+1−1
CHANGELOG.md+4
@@ -4,10 +4,14 @@
44
55## Important Notes
66
7+- [#967](https://github.com/oauth2-proxy/oauth2-proxy/pull/967) `--insecure-oidc-skip-nonce` is currently `true` by default in case
8+ any existing OIDC Identity Providers don't support it. The default will switch to `false` in a future version.
9+
710## Breaking Changes
811
912## Changes since v7.1.2
1013
14+- [#967](https://github.com/oauth2-proxy/oauth2-proxy/pull/967) Set & verify a nonce with OIDC providers (@NickMeves)
1115- [#1136](https://github.com/oauth2-proxy/oauth2-proxy/pull/1136) Add clock package for better time mocking in tests (@NickMeves)
1216- [#947](https://github.com/oauth2-proxy/oauth2-proxy/pull/947) Multiple provider ingestion and validation in alpha options (first stage: [#926](https://github.com/oauth2-proxy/oauth2-proxy/issues/926)) (@yanasega)
1317
docs/docs/configuration/alpha_config.md+1
@@ -264,6 +264,7 @@ make up the header value
264264| `issuerURL` | _string_ | IssuerURL is the OpenID Connect issuer URL<br/>eg: https://accounts.google.com |
265265| `insecureAllowUnverifiedEmail` | _bool_ | InsecureAllowUnverifiedEmail prevents failures if an email address in an id_token is not verified<br/>default set to 'false' |
266266| `insecureSkipIssuerVerification` | _bool_ | InsecureSkipIssuerVerification skips verification of ID token issuers. When false, ID Token Issuers must match the OIDC discovery URL<br/>default set to 'false' |
267+| `insecureSkipNonce` | _bool_ | InsecureSkipNonce skips verifying the ID Token's nonce claim that must match<br/>the random nonce sent in the initial OAuth flow. Otherwise, the nonce is checked<br/>after the initial OAuth redeem & subsequent token refreshes.<br/>default set to 'true'<br/>Warning: In a future release, this will change to 'false' by default for enhanced security. |
267268| `skipDiscovery` | _bool_ | SkipDiscovery allows to skip OIDC discovery and use manually supplied Endpoints<br/>default set to 'false' |
268269| `jwksURL` | _string_ | JwksURL is the OpenID Connect JWKS URL<br/>eg: https://www.googleapis.com/oauth2/v3/certs |
269270| `emailClaim` | _string_ | EmailClaim indicates which claim contains the user email,<br/>default set to 'email' |
docs/docs/configuration/overview.md+1
@@ -75,6 +75,7 @@ An example [oauth2-proxy.cfg](https://github.com/oauth2-proxy/oauth2-proxy/blob/
7575| `--login-url` | string | Authentication endpoint | |
7676| `--insecure-oidc-allow-unverified-email` | bool | don't fail if an email address in an id_token is not verified | false |
7777| `--insecure-oidc-skip-issuer-verification` | bool | allow the OIDC issuer URL to differ from the expected (currently required for Azure multi-tenant compatibility) | false |
78+| `--insecure-oidc-skip-nonce` | bool | skip verifying the OIDC ID Token's nonce claim | true |
7879| `--oidc-issuer-url` | string | the OpenID Connect issuer URL, e.g. `"https://accounts.google.com"` | |
7980| `--oidc-jwks-url` | string | OIDC JWKS URI for token verification; required if OIDC discovery is disabled | |
8081| `--oidc-email-claim` | string | which OIDC claim contains the user's email | `"email"` |
main_test.go+6−4
@@ -71,6 +71,7 @@ providers:
7171 groupsClaim: groups
7272 emailClaim: email
7373 userIDClaim: email
74+ insecureSkipNonce: true
7475`
7576
7677 const testCoreConfig = `
@@ -138,9 +139,10 @@ redirect_url="http://localhost:4180/oauth2/callback"
138139 Tenant: "common",
139140 },
140141 OIDCConfig: options.OIDCOptions{
141− GroupsClaim: "groups",
142− EmailClaim: "email",
143− UserIDClaim: "email",
142+ GroupsClaim: "groups",
143+ EmailClaim: "email",
144+ UserIDClaim: "email",
145+ InsecureSkipNonce: true,
144146 },
145147 ApprovalPrompt: "force",
146148 },
@@ -228,7 +230,7 @@ redirect_url="http://localhost:4180/oauth2/callback"
228230 configContent: testCoreConfig,
229231 alphaConfigContent: testAlphaConfig + ":",
230232 expectedOptions: func() *options.Options { return nil },
231− expectedErr: errors.New("failed to load alpha options: error unmarshalling config: error converting YAML to JSON: yaml: line 48: did not find expected key"),
233+ expectedErr: errors.New("failed to load alpha options: error unmarshalling config: error converting YAML to JSON: yaml: line 49: did not find expected key"),
232234 }),
233235 Entry("with alpha configuration and bad core configuration", loadConfigurationTableInput{
234236 configContent: testCoreConfig + "unknown_field=\"something\"",
oauthproxy.go+61−80
@@ -23,8 +23,8 @@ import (
2323 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/app/pagewriter"
2424 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/authentication/basic"
2525 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/cookies"
26− "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/encryption"
2726 proxyhttp "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/http"
27+
2828 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/ip"
2929 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
3030 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/middleware"
@@ -60,14 +60,8 @@ type allowedRoute struct {
6060
6161// OAuthProxy is the main authentication proxy
6262type OAuthProxy struct {
63− CSRFCookieName string
64− CookieDomains []string
65− CookiePath string
66− CookieSecure bool
67− CookieHTTPOnly bool
68− CookieExpire time.Duration
69− CookieSameSite string
70− Validator func(string) bool
63+ CookieOptions *options.Cookie
64+ Validator func(string) bool
7165
7266 RobotsPath string
7367 SignInPath string
@@ -179,14 +173,8 @@ func NewOAuthProxy(opts *options.Options, validator func(string) bool) (*OAuthPr
179173 }
180174
181175 p := &OAuthProxy{
182− CSRFCookieName: fmt.Sprintf("%v_%v", opts.Cookie.Name, "csrf"),
183− CookieDomains: opts.Cookie.Domains,
184− CookiePath: opts.Cookie.Path,
185− CookieSecure: opts.Cookie.Secure,
186− CookieHTTPOnly: opts.Cookie.HTTPOnly,
187− CookieExpire: opts.Cookie.Expire,
188− CookieSameSite: opts.Cookie.SameSite,
189− Validator: validator,
176+ CookieOptions: &opts.Cookie,
177+ Validator: validator,
190178
191179 RobotsPath: "/robots.txt",
192180 SignInPath: fmt.Sprintf("%s/sign_in", opts.ProxyPrefix),
@@ -427,47 +415,6 @@ func buildRoutesAllowlist(opts *options.Options) ([]allowedRoute, error) {
427415 return routes, nil
428416}
429417
430−// MakeCSRFCookie creates a cookie for CSRF
431−func (p *OAuthProxy) MakeCSRFCookie(req *http.Request, value string, expiration time.Duration, now time.Time) *http.Cookie {
432− return p.makeCookie(req, p.CSRFCookieName, value, expiration, now)
433−}
434−
435−func (p *OAuthProxy) makeCookie(req *http.Request, name string, value string, expiration time.Duration, now time.Time) *http.Cookie {
436− cookieDomain := cookies.GetCookieDomain(req, p.CookieDomains)
437−
438− if cookieDomain != "" {
439− domain := requestutil.GetRequestHost(req)
440− if h, _, err := net.SplitHostPort(domain); err == nil {
441− domain = h
442− }
443− if !strings.HasSuffix(domain, cookieDomain) {
444− logger.Errorf("Warning: request host is %q but using configured cookie domain of %q", domain, cookieDomain)
445− }
446− }
447−
448− return &http.Cookie{
449− Name: name,
450− Value: value,
451− Path: p.CookiePath,
452− Domain: cookieDomain,
453− HttpOnly: p.CookieHTTPOnly,
454− Secure: p.CookieSecure,
455− Expires: now.Add(expiration),
456− SameSite: cookies.ParseSameSite(p.CookieSameSite),
457− }
458−}
459−
460−// ClearCSRFCookie creates a cookie to unset the CSRF cookie stored in the user's
461−// session
462−func (p *OAuthProxy) ClearCSRFCookie(rw http.ResponseWriter, req *http.Request) {
463− http.SetCookie(rw, p.MakeCSRFCookie(req, "", time.Hour*-1, time.Now()))
464−}
465−
466−// SetCSRFCookie adds a CSRF cookie to the response
467−func (p *OAuthProxy) SetCSRFCookie(rw http.ResponseWriter, req *http.Request, val string) {
468− http.SetCookie(rw, p.MakeCSRFCookie(req, val, p.CookieExpire, time.Now()))
469−}
470−
471418// ClearSessionCookie creates a cookie to unset the user's authentication cookie
472419// stored in the user's session
473420func (p *OAuthProxy) ClearSessionCookie(rw http.ResponseWriter, req *http.Request) error {
@@ -744,21 +691,35 @@ func (p *OAuthProxy) SignOut(rw http.ResponseWriter, req *http.Request) {
744691// OAuthStart starts the OAuth2 authentication flow
745692func (p *OAuthProxy) OAuthStart(rw http.ResponseWriter, req *http.Request) {
746693 prepareNoCache(rw)
747− nonce, err := encryption.Nonce()
694+
695+ csrf, err := cookies.NewCSRF(p.CookieOptions)
748696 if err != nil {
749− logger.Errorf("Error obtaining nonce: %v", err)
697+ logger.Errorf("Error creating CSRF nonce: %v", err)
750698 p.ErrorPage(rw, req, http.StatusInternalServerError, err.Error())
751699 return
752700 }
753− p.SetCSRFCookie(rw, req, nonce)
754− redirect, err := p.getAppRedirect(req)
701+
702+ appRedirect, err := p.getAppRedirect(req)
755703 if err != nil {
756− logger.Errorf("Error obtaining redirect: %v", err)
704+ logger.Errorf("Error obtaining application redirect: %v", err)
757705 p.ErrorPage(rw, req, http.StatusInternalServerError, err.Error())
758706 return
759707 }
760− redirectURI := p.getOAuthRedirectURI(req)
761− http.Redirect(rw, req, p.provider.GetLoginURL(redirectURI, fmt.Sprintf("%v:%v", nonce, redirect)), http.StatusFound)
708+
709+ callbackRedirect := p.getOAuthRedirectURI(req)
710+ loginURL := p.provider.GetLoginURL(
711+ callbackRedirect,
712+ encodeState(csrf.HashOAuthState(), appRedirect),
713+ csrf.HashOIDCNonce(),
714+ )
715+
716+ if _, err := csrf.SetCookie(rw, req); err != nil {
717+ logger.Errorf("Error setting CSRF cookie: %v", err)
718+ p.ErrorPage(rw, req, http.StatusInternalServerError, err.Error())
719+ return
720+ }
721+
722+ http.Redirect(rw, req, loginURL, http.StatusFound)
762723}
763724
764725// OAuthCallback is the OAuth2 authentication flow callback that finishes the
@@ -796,29 +757,33 @@ func (p *OAuthProxy) OAuthCallback(rw http.ResponseWriter, req *http.Request) {
796757 return
797758 }
798759
799− state := strings.SplitN(req.Form.Get("state"), ":", 2)
800− if len(state) != 2 {
801− logger.Error("Error while parsing OAuth2 state: invalid length")
802− p.ErrorPage(rw, req, http.StatusInternalServerError, "State paremeter did not have expected length", "Login Failed: Invalid State after login.")
803− return
804− }
805− nonce := state[0]
806− redirect := state[1]
807− c, err := req.Cookie(p.CSRFCookieName)
760+ csrf, err := cookies.LoadCSRFCookie(req, p.CookieOptions)
808761 if err != nil {
809762 logger.PrintAuthf(session.Email, req, logger.AuthFailure, "Invalid authentication via OAuth2: unable to obtain CSRF cookie")
810763 p.ErrorPage(rw, req, http.StatusForbidden, err.Error(), "Login Failed: Unable to find a valid CSRF token. Please try again.")
811764 return
812765 }
813− p.ClearCSRFCookie(rw, req)
814− if c.Value != nonce {
766+
767+ csrf.ClearCookie(rw, req)
768+
769+ nonce, appRedirect, err := decodeState(req)
770+ if err != nil {
771+ logger.Errorf("Error while parsing OAuth2 state: %v", err)
772+ p.ErrorPage(rw, req, http.StatusInternalServerError, err.Error())
773+ return
774+ }
775+
776+ if !csrf.CheckOAuthState(nonce) {
815777 logger.PrintAuthf(session.Email, req, logger.AuthFailure, "Invalid authentication via OAuth2: CSRF token mismatch, potential attack")
816778 p.ErrorPage(rw, req, http.StatusForbidden, "CSRF token mismatch, potential attack", "Login Failed: Unable to find a valid CSRF token. Please try again.")
817779 return
818780 }
819781
820− if !p.IsValidRedirect(redirect) {
821− redirect = "/"
782+ csrf.SetSessionNonce(session)
783+ p.provider.ValidateSession(req.Context(), session)
784+
785+ if !p.IsValidRedirect(appRedirect) {
786+ appRedirect = "/"
822787 }
823788
824789 // set cookie, or deny
@@ -834,7 +799,7 @@ func (p *OAuthProxy) OAuthCallback(rw http.ResponseWriter, req *http.Request) {
834799 p.ErrorPage(rw, req, http.StatusInternalServerError, err.Error())
835800 return
836801 }
837− http.Redirect(rw, req, redirect, http.StatusFound)
802+ http.Redirect(rw, req, appRedirect, http.StatusFound)
838803 } else {
839804 logger.PrintAuthf(session.Email, req, logger.AuthFailure, "Invalid authentication via OAuth2: unauthorized")
840805 p.ErrorPage(rw, req, http.StatusForbidden, "Invalid session: unauthorized")
@@ -966,7 +931,7 @@ func (p *OAuthProxy) getOAuthRedirectURI(req *http.Request) string {
966931
967932 // If CookieSecure is true, return `https` no matter what
968933 // Not all reverse proxies set X-Forwarded-Proto
969− if p.CookieSecure {
934+ if p.CookieOptions.Secure {
970935 rd.Scheme = schemeHTTPS
971936 }
972937 return rd.String()
@@ -1207,6 +1172,22 @@ func extractAllowedGroups(req *http.Request) map[string]struct{} {
12071172 return groups
12081173}
12091174
1175+// encodedState builds the OAuth state param out of our nonce and
1176+// original application redirect
1177+func encodeState(nonce string, redirect string) string {
1178+ return fmt.Sprintf("%v:%v", nonce, redirect)
1179+}
1180+
1181+// decodeState splits the reflected OAuth state response back into
1182+// the nonce and original application redirect
1183+func decodeState(req *http.Request) (string, string, error) {
1184+ state := strings.SplitN(req.Form.Get("state"), ":", 2)
1185+ if len(state) != 2 {
1186+ return "", "", errors.New("invalid length")
1187+ }
1188+ return state[0], state[1], nil
1189+}
1190+
12101191// addHeadersForProxying adds the appropriate headers the request / response for proxying
12111192func (p *OAuthProxy) addHeadersForProxying(rw http.ResponseWriter, session *sessionsapi.SessionState) {
12121193 if session.Email == "" {
oauthproxy_test.go+32−8
@@ -22,6 +22,7 @@ import (
2222 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/middleware"
2323 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
2424 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
25+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/cookies"
2526 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
2627 sessionscookie "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/sessions/cookie"
2728 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/upstream"
@@ -698,23 +699,42 @@ func (patTest *PassAccessTokenTest) Close() {
698699 patTest.providerServer.Close()
699700}
700701
701−func (patTest *PassAccessTokenTest) getCallbackEndpoint() (httpCode int,
702− cookie string) {
702+func (patTest *PassAccessTokenTest) getCallbackEndpoint() (httpCode int, cookie string) {
703703 rw := httptest.NewRecorder()
704− req, err := http.NewRequest("GET", "/oauth2/callback?code=callback_code&state=nonce:",
705− strings.NewReader(""))
704+
705+ csrf, err := cookies.NewCSRF(patTest.proxy.CookieOptions)
706+ if err != nil {
707+ panic(err)
708+ }
709+
710+ req, err := http.NewRequest(
711+ http.MethodGet,
712+ fmt.Sprintf(
713+ "/oauth2/callback?code=callback_code&state=%s",
714+ encodeState(csrf.HashOAuthState(), "%2F"),
715+ ),
716+ strings.NewReader(""),
717+ )
706718 if err != nil {
707719 return 0, ""
708720 }
709− req.AddCookie(patTest.proxy.MakeCSRFCookie(req, "nonce", time.Hour, time.Now()))
721+
722+ // rw is a dummy here, we just want the csrfCookie to add to our req
723+ csrfCookie, err := csrf.SetCookie(httptest.NewRecorder(), req)
724+ if err != nil {
725+ panic(err)
726+ }
727+ req.AddCookie(csrfCookie)
728+
710729 patTest.proxy.ServeHTTP(rw, req)
730+
711731 return rw.Code, rw.Header().Values("Set-Cookie")[1]
712732}
713733
714734// getEndpointWithCookie makes a requests againt the oauthproxy with passed requestPath
715735// and cookie and returns body and status code.
716736func (patTest *PassAccessTokenTest) getEndpointWithCookie(cookie string, endpoint string) (httpCode int, accessToken string) {
717− cookieName := patTest.opts.Cookie.Name
737+ cookieName := patTest.proxy.CookieOptions.Name
718738 var value string
719739 keyPrefix := cookieName + "="
720740
@@ -983,6 +1003,9 @@ func NewProcessCookieTest(opts ProcessCookieTestOpts, modifiers ...OptionsModifi
9831003 }
9841004 pcTest.proxy.provider.(*TestProvider).SetAllowedGroups(pcTest.opts.Providers[0].AllowedGroups)
9851005
1006+ // Now, zero-out proxy.CookieRefresh for the cases that don't involve
1007+ // access_token validation.
1008+ pcTest.proxy.CookieOptions.Refresh = time.Duration(0)
9861009 pcTest.rw = httptest.NewRecorder()
9871010 pcTest.req, _ = http.NewRequest("GET", "/", strings.NewReader(""))
9881011 pcTest.validateUser = true
@@ -1104,6 +1127,7 @@ func TestProcessCookieFailIfRefreshSetAndCookieExpired(t *testing.T) {
11041127 err = pcTest.SaveSession(startSession)
11051128 assert.NoError(t, err)
11061129
1130+ pcTest.proxy.CookieOptions.Refresh = time.Hour
11071131 session, err := pcTest.LoadCookiedSession()
11081132 assert.NotEqual(t, nil, err)
11091133 if session != nil {
@@ -1999,7 +2023,7 @@ func TestClearSplitCookie(t *testing.T) {
19992023 t.Fatal(err)
20002024 }
20012025
2002− p := OAuthProxy{sessionStore: store}
2026+ p := OAuthProxy{CookieOptions: &opts.Cookie, sessionStore: store}
20032027 var rw = httptest.NewRecorder()
20042028 req := httptest.NewRequest("get", "/", nil)
20052029
@@ -2032,7 +2056,7 @@ func TestClearSingleCookie(t *testing.T) {
20322056 t.Fatal(err)
20332057 }
20342058
2035− p := OAuthProxy{sessionStore: store}
2059+ p := OAuthProxy{CookieOptions: &opts.Cookie, sessionStore: store}
20362060 var rw = httptest.NewRecorder()
20372061 req := httptest.NewRequest("get", "/", nil)
20382062
pkg/apis/options/legacy_options.go+10−6
@@ -48,12 +48,13 @@ func NewLegacyOptions() *LegacyOptions {
4848 },
4949
5050 LegacyProvider: LegacyProvider{
51− ProviderType: "google",
52− AzureTenant: "common",
53− ApprovalPrompt: "force",
54− UserIDClaim: "email",
55− OIDCEmailClaim: "email",
56− OIDCGroupsClaim: "groups",
51+ ProviderType: "google",
52+ AzureTenant: "common",
53+ ApprovalPrompt: "force",
54+ UserIDClaim: "email",
55+ OIDCEmailClaim: "email",
56+ OIDCGroupsClaim: "groups",
57+ InsecureOIDCSkipNonce: true,
5758 },
5859
5960 Options: *NewOptions(),
@@ -492,6 +493,7 @@ type LegacyProvider struct {
492493 OIDCIssuerURL string `flag:"oidc-issuer-url" cfg:"oidc_issuer_url"`
493494 InsecureOIDCAllowUnverifiedEmail bool `flag:"insecure-oidc-allow-unverified-email" cfg:"insecure_oidc_allow_unverified_email"`
494495 InsecureOIDCSkipIssuerVerification bool `flag:"insecure-oidc-skip-issuer-verification" cfg:"insecure_oidc_skip_issuer_verification"`
496+ InsecureOIDCSkipNonce bool `flag:"insecure-oidc-skip-nonce" cfg:"insecure_oidc_skip_nonce"`
495497 SkipOIDCDiscovery bool `flag:"skip-oidc-discovery" cfg:"skip_oidc_discovery"`
496498 OIDCJwksURL string `flag:"oidc-jwks-url" cfg:"oidc_jwks_url"`
497499 OIDCEmailClaim string `flag:"oidc-email-claim" cfg:"oidc_email_claim"`
@@ -540,6 +542,7 @@ func legacyProviderFlagSet() *pflag.FlagSet {
540542 flagSet.String("oidc-issuer-url", "", "OpenID Connect issuer URL (ie: https://accounts.google.com)")
541543 flagSet.Bool("insecure-oidc-allow-unverified-email", false, "Don't fail if an email address in an id_token is not verified")
542544 flagSet.Bool("insecure-oidc-skip-issuer-verification", false, "Do not verify if issuer matches OIDC discovery URL")
545+ flagSet.Bool("insecure-oidc-skip-nonce", true, "skip verifying the OIDC ID Token's nonce claim")
543546 flagSet.Bool("skip-oidc-discovery", false, "Skip OIDC discovery and use manually supplied Endpoints")
544547 flagSet.String("oidc-jwks-url", "", "OpenID Connect JWKS URL (ie: https://www.googleapis.com/oauth2/v3/certs)")
545548 flagSet.String("oidc-groups-claim", providers.OIDCGroupsClaim, "which OIDC claim contains the user groups")
@@ -630,6 +633,7 @@ func (l *LegacyProvider) convert() (Providers, error) {
630633 IssuerURL: l.OIDCIssuerURL,
631634 InsecureAllowUnverifiedEmail: l.InsecureOIDCAllowUnverifiedEmail,
632635 InsecureSkipIssuerVerification: l.InsecureOIDCSkipIssuerVerification,
636+ InsecureSkipNonce: l.InsecureOIDCSkipNonce,
633637 SkipDiscovery: l.SkipOIDCDiscovery,
634638 JwksURL: l.OIDCJwksURL,
635639 UserIDClaim: l.UserIDClaim,
pkg/apis/options/legacy_options_test.go+1
@@ -113,6 +113,7 @@ var _ = Describe("Legacy Options", func() {
113113
114114 opts.Providers[0].ClientID = "oauth-proxy"
115115 opts.Providers[0].ID = "google=oauth-proxy"
116+ opts.Providers[0].OIDCConfig.InsecureSkipNonce = true
116117
117118 converted, err := legacyOpts.ToOptions()
118119 Expect(err).ToNot(HaveOccurred())
pkg/apis/options/load_test.go+7−6
@@ -36,12 +36,13 @@ var _ = Describe("Load", func() {
3636 },
3737
3838 LegacyProvider: LegacyProvider{
39− ProviderType: "google",
40− AzureTenant: "common",
41− ApprovalPrompt: "force",
42− UserIDClaim: "email",
43− OIDCEmailClaim: "email",
44− OIDCGroupsClaim: "groups",
39+ ProviderType: "google",
40+ AzureTenant: "common",
41+ ApprovalPrompt: "force",
42+ UserIDClaim: "email",
43+ OIDCEmailClaim: "email",
44+ OIDCGroupsClaim: "groups",
45+ InsecureOIDCSkipNonce: true,
4546 },
4647
4748 Options: Options{
pkg/apis/options/providers.go+7
@@ -132,6 +132,12 @@ type OIDCOptions struct {
132132 // InsecureSkipIssuerVerification skips verification of ID token issuers. When false, ID Token Issuers must match the OIDC discovery URL
133133 // default set to 'false'
134134 InsecureSkipIssuerVerification bool `json:"insecureSkipIssuerVerification,omitempty"`
135+ // InsecureSkipNonce skips verifying the ID Token's nonce claim that must match
136+ // the random nonce sent in the initial OAuth flow. Otherwise, the nonce is checked
137+ // after the initial OAuth redeem & subsequent token refreshes.
138+ // default set to 'true'
139+ // Warning: In a future release, this will change to 'false' by default for enhanced security.
140+ InsecureSkipNonce bool `json:"insecureSkipNonce,omitempty"`
135141 // SkipDiscovery allows to skip OIDC discovery and use manually supplied Endpoints
136142 // default set to 'false'
137143 SkipDiscovery bool `json:"skipDiscovery,omitempty"`
@@ -169,6 +175,7 @@ func providerDefaults() Providers {
169175 },
170176 OIDCConfig: OIDCOptions{
171177 InsecureAllowUnverifiedEmail: false,
178+ InsecureSkipNonce: true,
172179 SkipDiscovery: false,
173180 UserIDClaim: providers.OIDCEmailClaim, // Deprecated: Use OIDCEmailClaim
174181 EmailClaim: providers.OIDCEmailClaim,
pkg/apis/sessions/session_state.go+7
@@ -24,6 +24,8 @@ type SessionState struct {
2424 IDToken string `msgpack:"it,omitempty"`
2525 RefreshToken string `msgpack:"rt,omitempty"`
2626
27+ Nonce []byte `msgpack:"n,omitempty"`
28+
2729 Email string `msgpack:"e,omitempty"`
2830 User string `msgpack:"u,omitempty"`
2931 Groups []string `msgpack:"g,omitempty"`
@@ -100,6 +102,11 @@ func (s *SessionState) GetClaim(claim string) []string {
100102 }
101103}
102104
105+// CheckNonce compares the Nonce against a potential hash of it
106+func (s *SessionState) CheckNonce(hashed string) bool {
107+ return encryption.CheckNonce(s.Nonce, hashed)
108+}
109+
103110// EncodeSessionState returns an encrypted, lz4 compressed, MessagePack encoded session
104111func (s *SessionState) EncodeSessionState(c encryption.Cipher, compress bool) ([]byte, error) {
105112 packed, err := msgpack.Marshal(s)
pkg/apis/sessions/session_state_test.go+4
@@ -153,6 +153,7 @@ func TestEncodeAndDecodeSessionState(t *testing.T) {
153153 CreatedAt: &created,
154154 ExpiresOn: &expires,
155155 RefreshToken: "RefreshToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7",
156+ Nonce: []byte("abcdef1234567890abcdef1234567890"),
156157 },
157158 "No ExpiresOn": {
158159 Email: "username@example.com",
@@ -162,6 +163,7 @@ func TestEncodeAndDecodeSessionState(t *testing.T) {
162163 IDToken: "IDToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7",
163164 CreatedAt: &created,
164165 RefreshToken: "RefreshToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7",
166+ Nonce: []byte("abcdef1234567890abcdef1234567890"),
165167 },
166168 "No PreferredUsername": {
167169 Email: "username@example.com",
@@ -171,6 +173,7 @@ func TestEncodeAndDecodeSessionState(t *testing.T) {
171173 CreatedAt: &created,
172174 ExpiresOn: &expires,
173175 RefreshToken: "RefreshToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7",
176+ Nonce: []byte("abcdef1234567890abcdef1234567890"),
174177 },
175178 "Minimal session": {
176179 User: "username",
@@ -194,6 +197,7 @@ func TestEncodeAndDecodeSessionState(t *testing.T) {
194197 CreatedAt: &created,
195198 ExpiresOn: &expires,
196199 RefreshToken: "RefreshToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7",
200+ Nonce: []byte("abcdef1234567890abcdef1234567890"),
197201 Groups: []string{"group-a", "group-b"},
198202 },
199203 }
pkg/cookies/cookies.go+34−31
@@ -12,46 +12,33 @@ import (
1212 requestutil "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests/util"
1313)
1414
15−// MakeCookie constructs a cookie from the given parameters,
16−// discovering the domain from the request if not specified.
17−func MakeCookie(req *http.Request, name string, value string, path string, domain string, httpOnly bool, secure bool, expiration time.Duration, now time.Time, sameSite http.SameSite) *http.Cookie {
18− if domain != "" {
19− host := requestutil.GetRequestHost(req)
20− if h, _, err := net.SplitHostPort(host); err == nil {
21− host = h
22− }
23− if !strings.HasSuffix(host, domain) {
24− logger.Errorf("Warning: request host is %q but using configured cookie domain of %q", host, domain)
25− }
15+// MakeCookieFromOptions constructs a cookie based on the given *options.CookieOptions,
16+// value and creation time
17+func MakeCookieFromOptions(req *http.Request, name string, value string, opts *options.Cookie, expiration time.Duration, now time.Time) *http.Cookie {
18+ domain := GetCookieDomain(req, opts.Domains)
19+ // If nothing matches, create the cookie with the shortest domain
20+ if domain == "" && len(opts.Domains) > 0 {
21+ logger.Errorf("Warning: request host %q did not match any of the specific cookie domains of %q",
22+ requestutil.GetRequestHost(req),
23+ strings.Join(opts.Domains, ","),
24+ )
25+ domain = opts.Domains[len(opts.Domains)-1]
2626 }
2727
28− return &http.Cookie{
28+ c := &http.Cookie{
2929 Name: name,
3030 Value: value,
31− Path: path,
31+ Path: opts.Path,
3232 Domain: domain,
33− HttpOnly: httpOnly,
34− Secure: secure,
3533 Expires: now.Add(expiration),
36− SameSite: sameSite,
34+ HttpOnly: opts.HTTPOnly,
35+ Secure: opts.Secure,
36+ SameSite: ParseSameSite(opts.SameSite),
3737 }
38−}
3938
40−// MakeCookieFromOptions constructs a cookie based on the given *options.CookieOptions,
41−// value and creation time
42−func MakeCookieFromOptions(req *http.Request, name string, value string, cookieOpts *options.Cookie, expiration time.Duration, now time.Time) *http.Cookie {
43− domain := GetCookieDomain(req, cookieOpts.Domains)
39+ warnInvalidDomain(c, req)
4440
45− if domain != "" {
46− return MakeCookie(req, name, value, cookieOpts.Path, domain, cookieOpts.HTTPOnly, cookieOpts.Secure, expiration, now, ParseSameSite(cookieOpts.SameSite))
47− }
48− // If nothing matches, create the cookie with the shortest domain
49− defaultDomain := ""
50− if len(cookieOpts.Domains) > 0 {
51− logger.Errorf("Warning: request host %q did not match any of the specific cookie domains of %q", requestutil.GetRequestHost(req), strings.Join(cookieOpts.Domains, ","))
52− defaultDomain = cookieOpts.Domains[len(cookieOpts.Domains)-1]
53− }
54− return MakeCookie(req, name, value, cookieOpts.Path, defaultDomain, cookieOpts.HTTPOnly, cookieOpts.Secure, expiration, now, ParseSameSite(cookieOpts.SameSite))
41+ return c
5542}
5643
5744// GetCookieDomain returns the correct cookie domain given a list of domains
@@ -81,3 +68,19 @@ func ParseSameSite(v string) http.SameSite {
8168 panic(fmt.Sprintf("Invalid value for SameSite: %s", v))
8269 }
8370}
71+
72+// warnInvalidDomain logs a warning if the request host and cookie domain are
73+// mismatched.
74+func warnInvalidDomain(c *http.Cookie, req *http.Request) {
75+ if c.Domain == "" {
76+ return
77+ }
78+
79+ host := requestutil.GetRequestHost(req)
80+ if h, _, err := net.SplitHostPort(host); err == nil {
81+ host = h
82+ }
83+ if !strings.HasSuffix(host, c.Domain) {
84+ logger.Errorf("Warning: request host is %q but using configured cookie domain of %q", host, c.Domain)
85+ }
86+}
pkg/cookies/cookies_suite_test.gonew file+35
@@ -0,0 +1,35 @@
1+package cookies
2+
3+import (
4+ "net/http"
5+ "testing"
6+ "time"
7+
8+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
9+ . "github.com/onsi/ginkgo"
10+ . "github.com/onsi/gomega"
11+)
12+
13+const (
14+ csrfState = "1234asdf1234asdf1234asdf"
15+ csrfNonce = "0987lkjh0987lkjh0987lkjh"
16+
17+ cookieName = "cookie_test_12345"
18+ cookieSecret = "3q48hmFH30FJ2HfJF0239UFJCVcl3kj3"
19+ cookieDomain = "o2p.cookies.test"
20+ cookiePath = "/cookie-tests"
21+
22+ nowEpoch = 1609366421
23+)
24+
25+func TestProviderSuite(t *testing.T) {
26+ logger.SetOutput(GinkgoWriter)
27+
28+ RegisterFailHandler(Fail)
29+ RunSpecs(t, "Cookies")
30+}
31+
32+func testCookieExpires(exp time.Time) string {
33+ var buf [len(http.TimeFormat)]byte
34+ return string(exp.UTC().AppendFormat(buf[:0], http.TimeFormat))
35+}
pkg/cookies/cookies_test.gonew file+79
@@ -0,0 +1,79 @@
1+package cookies
2+
3+import (
4+ "fmt"
5+ "net/http"
6+
7+ middlewareapi "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/middleware"
8+ . "github.com/onsi/ginkgo"
9+ . "github.com/onsi/ginkgo/extensions/table"
10+ . "github.com/onsi/gomega"
11+)
12+
13+var _ = Describe("Cookie Tests", func() {
14+ Context("GetCookieDomain", func() {
15+ type getCookieDomainTableInput struct {
16+ host string
17+ xForwardedHost string
18+ cookieDomains []string
19+ expectedOutput string
20+ }
21+
22+ DescribeTable("should return expected results",
23+ func(in getCookieDomainTableInput) {
24+ req, err := http.NewRequest(
25+ http.MethodGet,
26+ fmt.Sprintf("https://%s/%s", in.host, cookiePath),
27+ nil,
28+ )
29+ Expect(err).ToNot(HaveOccurred())
30+
31+ if in.xForwardedHost != "" {
32+ req.Header.Add("X-Forwarded-Host", in.xForwardedHost)
33+ req = middlewareapi.AddRequestScope(req, &middlewareapi.RequestScope{
34+ ReverseProxy: true,
35+ })
36+ }
37+
38+ Expect(GetCookieDomain(req, in.cookieDomains)).To(Equal(in.expectedOutput))
39+ },
40+ Entry("a single exact match for the Host header", getCookieDomainTableInput{
41+ host: "www.cookies.test",
42+ cookieDomains: []string{"www.cookies.test"},
43+ expectedOutput: "www.cookies.test",
44+ }),
45+ Entry("a single exact match for the X-Forwarded-Host header", getCookieDomainTableInput{
46+ host: "backend.cookies.internal",
47+ xForwardedHost: "www.cookies.test",
48+ cookieDomains: []string{"www.cookies.test"},
49+ expectedOutput: "www.cookies.test",
50+ }),
51+ Entry("a single suffix match for the Host header", getCookieDomainTableInput{
52+ host: "www.cookies.test",
53+ cookieDomains: []string{".cookies.test"},
54+ expectedOutput: ".cookies.test",
55+ }),
56+ Entry("a single suffix match for the X-Forwarded-Host header", getCookieDomainTableInput{
57+ host: "backend.cookies.internal",
58+ xForwardedHost: "www.cookies.test",
59+ cookieDomains: []string{".cookies.test"},
60+ expectedOutput: ".cookies.test",
61+ }),
62+ Entry("the first match is used", getCookieDomainTableInput{
63+ host: "www.cookies.test",
64+ cookieDomains: []string{"www.cookies.test", ".cookies.test"},
65+ expectedOutput: "www.cookies.test",
66+ }),
67+ Entry("the only match is used", getCookieDomainTableInput{
68+ host: "www.cookies.test",
69+ cookieDomains: []string{".cookies.wrong", ".cookies.test"},
70+ expectedOutput: ".cookies.test",
71+ }),
72+ Entry("blank is returned for no matches", getCookieDomainTableInput{
73+ host: "www.cookies.test",
74+ cookieDomains: []string{".cookies.wrong", ".cookies.false"},
75+ expectedOutput: "",
76+ }),
77+ )
78+ })
79+})
pkg/cookies/csrf.gonew file+199
@@ -0,0 +1,199 @@
1+package cookies
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "net/http"
7+ "time"
8+
9+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
10+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
11+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/clock"
12+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/encryption"
13+ "github.com/vmihailenco/msgpack/v4"
14+)
15+
16+// CSRF manages various nonces stored in the CSRF cookie during the initial
17+// authentication flows.
18+type CSRF interface {
19+ HashOAuthState() string
20+ HashOIDCNonce() string
21+ CheckOAuthState(string) bool
22+ CheckOIDCNonce(string) bool
23+
24+ SetSessionNonce(s *sessions.SessionState)
25+
26+ SetCookie(http.ResponseWriter, *http.Request) (*http.Cookie, error)
27+ ClearCookie(http.ResponseWriter, *http.Request)
28+}
29+
30+type csrf struct {
31+ // OAuthState holds the OAuth2 state parameter's nonce component set in the
32+ // initial authentication request and mirrored back in the callback
33+ // redirect from the IdP for CSRF protection.
34+ OAuthState []byte `msgpack:"s,omitempty"`
35+
36+ // OIDCNonce holds the OIDC nonce parameter used in the initial authentication
37+ // and then set in all subsequent OIDC ID Tokens as the nonce claim. This
38+ // is used to mitigate replay attacks.
39+ OIDCNonce []byte `msgpack:"n,omitempty"`
40+
41+ cookieOpts *options.Cookie
42+ time clock.Clock
43+}
44+
45+// NewCSRF creates a CSRF with random nonces
46+func NewCSRF(opts *options.Cookie) (CSRF, error) {
47+ state, err := encryption.Nonce()
48+ if err != nil {
49+ return nil, err
50+ }
51+ nonce, err := encryption.Nonce()
52+ if err != nil {
53+ return nil, err
54+ }
55+
56+ return &csrf{
57+ OAuthState: state,
58+ OIDCNonce: nonce,
59+
60+ cookieOpts: opts,
61+ }, nil
62+}
63+
64+// LoadCSRFCookie loads a CSRF object from a request's CSRF cookie
65+func LoadCSRFCookie(req *http.Request, opts *options.Cookie) (CSRF, error) {
66+ cookie, err := req.Cookie(csrfCookieName(opts))
67+ if err != nil {
68+ return nil, err
69+ }
70+
71+ return decodeCSRFCookie(cookie, opts)
72+}
73+
74+// HashOAuthState returns the hash of the OAuth state nonce
75+func (c *csrf) HashOAuthState() string {
76+ return encryption.HashNonce(c.OAuthState)
77+}
78+
79+// HashOIDCNonce returns the hash of the OIDC nonce
80+func (c *csrf) HashOIDCNonce() string {
81+ return encryption.HashNonce(c.OIDCNonce)
82+}
83+
84+// CheckOAuthState compares the OAuth state nonce against a potential
85+// hash of it
86+func (c *csrf) CheckOAuthState(hashed string) bool {
87+ return encryption.CheckNonce(c.OAuthState, hashed)
88+}
89+
90+// CheckOIDCNonce compares the OIDC nonce against a potential hash of it
91+func (c *csrf) CheckOIDCNonce(hashed string) bool {
92+ return encryption.CheckNonce(c.OIDCNonce, hashed)
93+}
94+
95+// SetSessionNonce sets the OIDCNonce on a SessionState
96+func (c *csrf) SetSessionNonce(s *sessions.SessionState) {
97+ s.Nonce = c.OIDCNonce
98+}
99+
100+// SetCookie encodes the CSRF to a signed cookie and sets it on the ResponseWriter
101+func (c *csrf) SetCookie(rw http.ResponseWriter, req *http.Request) (*http.Cookie, error) {
102+ encoded, err := c.encodeCookie()
103+ if err != nil {
104+ return nil, err
105+ }
106+
107+ cookie := MakeCookieFromOptions(
108+ req,
109+ c.cookieName(),
110+ encoded,
111+ c.cookieOpts,
112+ c.cookieOpts.Expire,
113+ c.time.Now(),
114+ )
115+ http.SetCookie(rw, cookie)
116+
117+ return cookie, nil
118+}
119+
120+// ClearCookie removes the CSRF cookie
121+func (c *csrf) ClearCookie(rw http.ResponseWriter, req *http.Request) {
122+ http.SetCookie(rw, MakeCookieFromOptions(
123+ req,
124+ c.cookieName(),
125+ "",
126+ c.cookieOpts,
127+ time.Hour*-1,
128+ c.time.Now(),
129+ ))
130+}
131+
132+// encodeCookie MessagePack encodes and encrypts the CSRF and then creates a
133+// signed cookie value
134+func (c *csrf) encodeCookie() (string, error) {
135+ packed, err := msgpack.Marshal(c)
136+ if err != nil {
137+ return "", fmt.Errorf("error marshalling CSRF to msgpack: %v", err)
138+ }
139+
140+ encrypted, err := encrypt(packed, c.cookieOpts)
141+ if err != nil {
142+ return "", err
143+ }
144+
145+ return encryption.SignedValue(c.cookieOpts.Secret, c.cookieName(), encrypted, c.time.Now())
146+}
147+
148+// decodeCSRFCookie validates the signature then decrypts and decodes a CSRF
149+// cookie into a CSRF struct
150+func decodeCSRFCookie(cookie *http.Cookie, opts *options.Cookie) (*csrf, error) {
151+ val, _, ok := encryption.Validate(cookie, opts.Secret, opts.Expire)
152+ if !ok {
153+ return nil, errors.New("CSRF cookie failed validation")
154+ }
155+
156+ decrypted, err := decrypt(val, opts)
157+ if err != nil {
158+ return nil, err
159+ }
160+
161+ // Valid cookie, Unmarshal the CSRF
162+ csrf := &csrf{cookieOpts: opts}
163+ err = msgpack.Unmarshal(decrypted, csrf)
164+ if err != nil {
165+ return nil, fmt.Errorf("error unmarshalling data to CSRF: %v", err)
166+ }
167+
168+ return csrf, nil
169+}
170+
171+// cookieName returns the CSRF cookie's name derived from the base
172+// session cookie name
173+func (c *csrf) cookieName() string {
174+ return csrfCookieName(c.cookieOpts)
175+}
176+
177+func csrfCookieName(opts *options.Cookie) string {
178+ return fmt.Sprintf("%v_csrf", opts.Name)
179+}
180+
181+func encrypt(data []byte, opts *options.Cookie) ([]byte, error) {
182+ cipher, err := makeCipher(opts)
183+ if err != nil {
184+ return nil, err
185+ }
186+ return cipher.Encrypt(data)
187+}
188+
189+func decrypt(data []byte, opts *options.Cookie) ([]byte, error) {
190+ cipher, err := makeCipher(opts)
191+ if err != nil {
192+ return nil, err
193+ }
194+ return cipher.Decrypt(data)
195+}
196+
197+func makeCipher(opts *options.Cookie) (encryption.Cipher, error) {
198+ return encryption.NewCFBCipher(encryption.SecretBytes(opts.Secret))
199+}
pkg/cookies/csrf_test.gonew file+190
@@ -0,0 +1,190 @@
1+package cookies
2+
3+import (
4+ "fmt"
5+ "net/http"
6+ "net/http/httptest"
7+ "net/url"
8+ "time"
9+
10+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
11+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
12+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/encryption"
13+ . "github.com/onsi/ginkgo"
14+ . "github.com/onsi/gomega"
15+)
16+
17+var _ = Describe("CSRF Cookie Tests", func() {
18+ var (
19+ cookieOpts *options.Cookie
20+ publicCSRF CSRF
21+ privateCSRF *csrf
22+ )
23+
24+ BeforeEach(func() {
25+ cookieOpts = &options.Cookie{
26+ Name: cookieName,
27+ Secret: cookieSecret,
28+ Domains: []string{cookieDomain},
29+ Path: cookiePath,
30+ Expire: time.Hour,
31+ Secure: true,
32+ HTTPOnly: true,
33+ }
34+
35+ var err error
36+ publicCSRF, err = NewCSRF(cookieOpts)
37+ Expect(err).ToNot(HaveOccurred())
38+
39+ privateCSRF = publicCSRF.(*csrf)
40+ })
41+
42+ Context("NewCSRF", func() {
43+ It("makes unique nonces for OAuth and OIDC", func() {
44+ Expect(privateCSRF.OAuthState).ToNot(BeEmpty())
45+ Expect(privateCSRF.OIDCNonce).ToNot(BeEmpty())
46+ Expect(privateCSRF.OAuthState).ToNot(Equal(privateCSRF.OIDCNonce))
47+ })
48+
49+ It("makes unique nonces between multiple CSRFs", func() {
50+ other, err := NewCSRF(cookieOpts)
51+ Expect(err).ToNot(HaveOccurred())
52+
53+ Expect(privateCSRF.OAuthState).ToNot(Equal(other.(*csrf).OAuthState))
54+ Expect(privateCSRF.OIDCNonce).ToNot(Equal(other.(*csrf).OIDCNonce))
55+ })
56+ })
57+
58+ Context("CheckOAuthState and CheckOIDCNonce", func() {
59+ It("checks that hashed versions match", func() {
60+ privateCSRF.OAuthState = []byte(csrfState)
61+ privateCSRF.OIDCNonce = []byte(csrfNonce)
62+
63+ stateHashed := encryption.HashNonce([]byte(csrfState))
64+ nonceHashed := encryption.HashNonce([]byte(csrfNonce))
65+
66+ Expect(publicCSRF.CheckOAuthState(stateHashed)).To(BeTrue())
67+ Expect(publicCSRF.CheckOIDCNonce(nonceHashed)).To(BeTrue())
68+
69+ Expect(publicCSRF.CheckOAuthState(csrfNonce)).To(BeFalse())
70+ Expect(publicCSRF.CheckOIDCNonce(csrfState)).To(BeFalse())
71+ Expect(publicCSRF.CheckOAuthState(csrfState + csrfNonce)).To(BeFalse())
72+ Expect(publicCSRF.CheckOIDCNonce(csrfNonce + csrfState)).To(BeFalse())
73+ Expect(publicCSRF.CheckOAuthState("")).To(BeFalse())
74+ Expect(publicCSRF.CheckOIDCNonce("")).To(BeFalse())
75+ })
76+ })
77+
78+ Context("SetSessionNonce", func() {
79+ It("sets the session.Nonce", func() {
80+ session := &sessions.SessionState{}
81+ publicCSRF.SetSessionNonce(session)
82+ Expect(session.Nonce).To(Equal(privateCSRF.OIDCNonce))
83+ })
84+ })
85+
86+ Context("encodeCookie and decodeCSRFCookie", func() {
87+ It("encodes and decodes to the same nonces", func() {
88+ privateCSRF.OAuthState = []byte(csrfState)
89+ privateCSRF.OIDCNonce = []byte(csrfNonce)
90+
91+ encoded, err := privateCSRF.encodeCookie()
92+ Expect(err).ToNot(HaveOccurred())
93+
94+ cookie := &http.Cookie{
95+ Name: privateCSRF.cookieName(),
96+ Value: encoded,
97+ }
98+ decoded, err := decodeCSRFCookie(cookie, cookieOpts)
99+ Expect(err).ToNot(HaveOccurred())
100+
101+ Expect(decoded).ToNot(BeNil())
102+ Expect(decoded.OAuthState).To(Equal([]byte(csrfState)))
103+ Expect(decoded.OIDCNonce).To(Equal([]byte(csrfNonce)))
104+ })
105+
106+ It("signs the encoded cookie value", func() {
107+ encoded, err := privateCSRF.encodeCookie()
108+ Expect(err).ToNot(HaveOccurred())
109+
110+ cookie := &http.Cookie{
111+ Name: privateCSRF.cookieName(),
112+ Value: encoded,
113+ }
114+
115+ _, _, valid := encryption.Validate(cookie, cookieOpts.Secret, cookieOpts.Expire)
116+ Expect(valid).To(BeTrue())
117+ })
118+ })
119+
120+ Context("Cookie Management", func() {
121+ var req *http.Request
122+
123+ testNow := time.Unix(nowEpoch, 0)
124+
125+ BeforeEach(func() {
126+ privateCSRF.time.Set(testNow)
127+
128+ req = &http.Request{
129+ Method: http.MethodGet,
130+ Proto: "HTTP/1.1",
131+ Host: cookieDomain,
132+
133+ URL: &url.URL{
134+ Scheme: "https",
135+ Host: cookieDomain,
136+ Path: cookiePath,
137+ },
138+ }
139+ })
140+
141+ AfterEach(func() {
142+ privateCSRF.time.Reset()
143+ })
144+
145+ Context("SetCookie", func() {
146+ It("adds the encoded CSRF cookie to a ResponseWriter", func() {
147+ rw := httptest.NewRecorder()
148+
149+ _, err := publicCSRF.SetCookie(rw, req)
150+ Expect(err).ToNot(HaveOccurred())
151+
152+ Expect(rw.Header().Get("Set-Cookie")).To(ContainSubstring(
153+ fmt.Sprintf("%s=", privateCSRF.cookieName()),
154+ ))
155+ Expect(rw.Header().Get("Set-Cookie")).To(ContainSubstring(
156+ fmt.Sprintf(
157+ "; Path=%s; Domain=%s; Expires=%s; HttpOnly; Secure",
158+ cookiePath,
159+ cookieDomain,
160+ testCookieExpires(testNow.Add(cookieOpts.Expire)),
161+ ),
162+ ))
163+ })
164+ })
165+
166+ Context("ClearCookie", func() {
167+ It("sets a cookie with an empty value in the past", func() {
168+ rw := httptest.NewRecorder()
169+
170+ publicCSRF.ClearCookie(rw, req)
171+
172+ Expect(rw.Header().Get("Set-Cookie")).To(Equal(
173+ fmt.Sprintf(
174+ "%s=; Path=%s; Domain=%s; Expires=%s; HttpOnly; Secure",
175+ privateCSRF.cookieName(),
176+ cookiePath,
177+ cookieDomain,
178+ testCookieExpires(testNow.Add(time.Hour*-1)),
179+ ),
180+ ))
181+ })
182+ })
183+
184+ Context("cookieName", func() {
185+ It("has the cookie options name as a base", func() {
186+ Expect(privateCSRF.cookieName()).To(ContainSubstring(cookieName))
187+ })
188+ })
189+ })
190+})
pkg/encryption/nonce.go+28−8
@@ -1,17 +1,37 @@
11package encryption
22
33import (
4+ "crypto/hmac"
45 "crypto/rand"
5− "fmt"
6+ "encoding/base64"
7+
8+ "golang.org/x/crypto/blake2b"
69)
710
8−// Nonce generates a random 16 byte string to be used as a nonce
9−func Nonce() (nonce string, err error) {
10− b := make([]byte, 16)
11− _, err = rand.Read(b)
11+// Nonce generates a random 32-byte slice to be used as a nonce
12+func Nonce() ([]byte, error) {
13+ b := make([]byte, 32)
14+ _, err := rand.Read(b)
1215 if err != nil {
13− return
16+ return nil, err
1417 }
15− nonce = fmt.Sprintf("%x", b)
16− return
18+ return b, nil
19+}
20+
21+// HashNonce returns the BLAKE2b 256-bit hash of a nonce
22+// NOTE: Error checking (G104) is purposefully skipped:
23+// - `blake2b.New256` has no error path with a nil signing key
24+// - `hash.Hash` interface's `Write` has an error signature, but
25+// `blake2b.digest.Write` does not use it.
26+/* #nosec G104 */
27+func HashNonce(nonce []byte) string {
28+ hasher, _ := blake2b.New256(nil)
29+ hasher.Write(nonce)
30+ sum := hasher.Sum(nil)
31+ return base64.RawURLEncoding.EncodeToString(sum)
32+}
33+
34+// CheckNonce tests if a nonce matches the hashed version of it
35+func CheckNonce(nonce []byte, hashed string) bool {
36+ return hmac.Equal([]byte(HashNonce(nonce)), []byte(hashed))
1737}
pkg/validation/options.go+1
@@ -264,6 +264,7 @@ func parseProviderInfo(o *options.Options, msgs []string) []string {
264264 p.SetTeam(o.Providers[0].BitbucketConfig.Team)
265265 p.SetRepository(o.Providers[0].BitbucketConfig.Repository)
266266 case *providers.OIDCProvider:
267+ p.SkipNonce = o.Providers[0].OIDCConfig.InsecureSkipNonce
267268 if p.Verifier == nil {
268269 msgs = append(msgs, "oidc provider requires an oidc issuer URL")
269270 }
pkg/validation/sessions.go+3−1
@@ -2,6 +2,7 @@ package validation
22
33import (
44 "context"
5+ "encoding/base64"
56 "fmt"
67 "time"
78
@@ -50,10 +51,11 @@ func validateRedisSessionStore(o *options.Options) []string {
5051 return []string{fmt.Sprintf("unable to initialize a redis client: %v", err)}
5152 }
5253
53− nonce, err := encryption.Nonce()
54+ n, err := encryption.Nonce()
5455 if err != nil {
5556 return []string{fmt.Sprintf("unable to generate a redis initialization test key: %v", err)}
5657 }
58+ nonce := base64.RawURLEncoding.EncodeToString(n)
5759
5860 key := fmt.Sprintf("%s-healthcheck-%s", o.Cookie.Name, nonce)
5961 return sendRedisConnectionTest(client, key, nonce)
providers/azure.go+1−1
@@ -107,7 +107,7 @@ func overrideTenantURL(current, defaultURL *url.URL, tenant, path string) {
107107 }
108108}
109109
110−func (p *AzureProvider) GetLoginURL(redirectURI, state string) string {
110+func (p *AzureProvider) GetLoginURL(redirectURI, state, _ string) string {
111111 extraParams := url.Values{}
112112 if p.ProtectedResource != nil && p.ProtectedResource.String() != "" {
113113 extraParams.Add("resource", p.ProtectedResource.String())
providers/azure_test.go+1−1
@@ -336,7 +336,7 @@ func TestAzureProviderRedeem(t *testing.T) {
336336func TestAzureProviderProtectedResourceConfigured(t *testing.T) {
337337 p := testAzureProvider("")
338338 p.ProtectedResource, _ = url.Parse("http://my.resource.test")
339− result := p.GetLoginURL("https://my.test.app/oauth", "")
339+ result := p.GetLoginURL("https://my.test.app/oauth", "", "")
340340 assert.Contains(t, result, "resource="+url.QueryEscape("http://my.resource.test"))
341341}
342342
providers/logingov.go+1−1
@@ -228,7 +228,7 @@ func (p *LoginGovProvider) Redeem(ctx context.Context, redirectURL, code string)
228228}
229229
230230// GetLoginURL overrides GetLoginURL to add login.gov parameters
231−func (p *LoginGovProvider) GetLoginURL(redirectURI, state string) string {
231+func (p *LoginGovProvider) GetLoginURL(redirectURI, state, _ string) string {
232232 extraParams := url.Values{}
233233 if p.AcrValues == "" {
234234 acr := "http://idmanagement.gov/ns/assurance/loa/1"
providers/logingov_test.go+1−1
@@ -292,7 +292,7 @@ func TestLoginGovProviderBadNonce(t *testing.T) {
292292
293293func TestLoginGovProviderGetLoginURL(t *testing.T) {
294294 p, _, _ := newLoginGovProvider()
295− result := p.GetLoginURL("http://redirect/", "")
295+ result := p.GetLoginURL("http://redirect/", "", "")
296296 assert.Contains(t, result, "acr_values="+url.QueryEscape("http://idmanagement.gov/ns/assurance/loa/1"))
297297 assert.Contains(t, result, "nonce=fakenonce")
298298}
providers/oidc.go+33−3
@@ -4,6 +4,7 @@ import (
44 "context"
55 "errors"
66 "fmt"
7+ "net/url"
78 "reflect"
89 "time"
910
@@ -16,16 +17,31 @@ import (
1617// OIDCProvider represents an OIDC based Identity Provider
1718type OIDCProvider struct {
1819 *ProviderData
20+
21+ SkipNonce bool
1922}
2023
2124// NewOIDCProvider initiates a new OIDCProvider
2225func NewOIDCProvider(p *ProviderData) *OIDCProvider {
2326 p.ProviderName = "OpenID Connect"
24− return &OIDCProvider{ProviderData: p}
27+ return &OIDCProvider{
28+ ProviderData: p,
29+ SkipNonce: true,
30+ }
2531}
2632
2733var _ Provider = (*OIDCProvider)(nil)
2834
35+// GetLoginURL makes the LoginURL with optional nonce support
36+func (p *OIDCProvider) GetLoginURL(redirectURI, state, nonce string) string {
37+ extraParams := url.Values{}
38+ if !p.SkipNonce {
39+ extraParams.Add("nonce", nonce)
40+ }
41+ loginURL := makeLoginURL(p.Data(), redirectURI, state, extraParams)
42+ return loginURL.String()
43+}
44+
2945// Redeem exchanges the OAuth2 authentication token for an ID token
3046func (p *OIDCProvider) Redeem(ctx context.Context, redirectURL, code string) (*sessions.SessionState, error) {
3147 clientSecret, err := p.GetClientSecret()
@@ -109,8 +125,22 @@ func (p *OIDCProvider) enrichFromProfileURL(ctx context.Context, s *sessions.Ses
109125
110126// ValidateSession checks that the session's IDToken is still valid
111127func (p *OIDCProvider) ValidateSession(ctx context.Context, s *sessions.SessionState) bool {
112− _, err := p.Verifier.Verify(ctx, s.IDToken)
113− return err == nil
128+ idToken, err := p.Verifier.Verify(ctx, s.IDToken)
129+ if err != nil {
130+ logger.Errorf("id_token verification failed: %v", err)
131+ return false
132+ }
133+
134+ if p.SkipNonce {
135+ return true
136+ }
137+ err = p.checkNonce(s, idToken)
138+ if err != nil {
139+ logger.Errorf("nonce verification failed: %v", err)
140+ return false
141+ }
142+
143+ return true
114144}
115145
116146// RefreshSessionIfNeeded checks if the session has expired and uses the
providers/oidc_test.go+24−4
@@ -2,8 +2,10 @@ package providers
22
33import (
44 "context"
5+ "encoding/base64"
56 "encoding/json"
67 "errors"
8+ "fmt"
79 "net/http"
810 "net/http/httptest"
911 "net/url"
@@ -11,6 +13,7 @@ import (
1113
1214 "github.com/coreos/go-oidc"
1315 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
16+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/encryption"
1417 "github.com/stretchr/testify/assert"
1518)
1619
@@ -23,7 +26,6 @@ type redeemTokenResponse struct {
2326}
2427
2528func newOIDCProvider(serverURL *url.URL) *OIDCProvider {
26−
2729 providerData := &ProviderData{
2830 ProviderName: "oidc",
2931 ClientID: oidcClientID,
@@ -54,7 +56,7 @@ func newOIDCProvider(serverURL *url.URL) *OIDCProvider {
5456 ),
5557 }
5658
57− p := &OIDCProvider{ProviderData: providerData}
59+ p := NewOIDCProvider(providerData)
5860
5961 return p
6062}
@@ -74,8 +76,27 @@ func newTestOIDCSetup(body []byte) (*httptest.Server, *OIDCProvider) {
7476 return server, provider
7577}
7678
77−func TestOIDCProviderRedeem(t *testing.T) {
79+func TestOIDCProviderGetLoginURL(t *testing.T) {
80+ serverURL := &url.URL{
81+ Scheme: "https",
82+ Host: "oauth2proxy.oidctest",
83+ }
84+ provider := newOIDCProvider(serverURL)
85+
86+ n, err := encryption.Nonce()
87+ assert.NoError(t, err)
88+ nonce := base64.RawURLEncoding.EncodeToString(n)
89+
90+ // SkipNonce defaults to true
91+ skipNonce := provider.GetLoginURL("http://redirect/", "", nonce)
92+ assert.NotContains(t, skipNonce, "nonce")
7893
94+ provider.SkipNonce = false
95+ withNonce := provider.GetLoginURL("http://redirect/", "", nonce)
96+ assert.Contains(t, withNonce, fmt.Sprintf("nonce=%s", nonce))
97+}
98+
99+func TestOIDCProviderRedeem(t *testing.T) {
79100 idToken, _ := newSignedTestIDToken(defaultIDToken)
80101 body, _ := json.Marshal(redeemTokenResponse{
81102 AccessToken: accessToken,
@@ -98,7 +119,6 @@ func TestOIDCProviderRedeem(t *testing.T) {
98119}
99120
100121func TestOIDCProviderRedeem_custom_userid(t *testing.T) {
101−
102122 idToken, _ := newSignedTestIDToken(defaultIDToken)
103123 body, _ := json.Marshal(redeemTokenResponse{
104124 AccessToken: accessToken,
providers/provider_data.go+13
@@ -122,6 +122,7 @@ type OIDCClaims struct {
122122 Email string `json:"-"`
123123 Groups []string `json:"-"`
124124 Verified *bool `json:"email_verified"`
125+ Nonce string `json:"nonce"`
125126
126127 raw map[string]interface{}
127128}
@@ -192,6 +193,18 @@ func (p *ProviderData) getClaims(idToken *oidc.IDToken) (*OIDCClaims, error) {
192193 return claims, nil
193194}
194195
196+// checkNonce compares the session's nonce with the IDToken's nonce claim
197+func (p *ProviderData) checkNonce(s *sessions.SessionState, idToken *oidc.IDToken) error {
198+ claims, err := p.getClaims(idToken)
199+ if err != nil {
200+ return fmt.Errorf("id_token claims extraction failed: %v", err)
201+ }
202+ if !s.CheckNonce(claims.Nonce) {
203+ return errors.New("id_token nonce claim does not match the session nonce")
204+ }
205+ return nil
206+}
207+
195208// extractGroups extracts groups from a claim to a list in a type safe manner.
196209// If the claim isn't present, `nil` is returned. If the groups claim is
197210// present but empty, `[]string{}` is returned.
providers/provider_data_test.go+61
@@ -15,6 +15,7 @@ import (
1515 "github.com/coreos/go-oidc"
1616 "github.com/dgrijalva/jwt-go"
1717 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
18+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/encryption"
1819 . "github.com/onsi/gomega"
1920 "golang.org/x/oauth2"
2021)
@@ -27,6 +28,7 @@ const (
2728 oidcIssuer = "https://issuer.example.com"
2829 oidcClientID = "https://test.myapp.com"
2930 oidcSecret = "SuperSecret123456789"
31+ oidcNonce = "abcde12345edcba09876abcde12345ff"
3032
3133 failureTokenID = "this-id-fails-verification"
3234)
@@ -53,6 +55,7 @@ var (
5355 Groups: []string{"test:a", "test:b"},
5456 Roles: []string{"test:c", "test:d"},
5557 Verified: &verified,
58+ Nonce: encryption.HashNonce([]byte(oidcNonce)),
5659 StandardClaims: standardClaims,
5760 }
5861
@@ -96,6 +99,7 @@ type idTokenClaims struct {
9699 Groups interface{} `json:"groups,omitempty"`
97100 Roles interface{} `json:"roles,omitempty"`
98101 Verified *bool `json:"email_verified,omitempty"`
102+ Nonce string `json:"nonce,omitempty"`
99103 jwt.StandardClaims
100104}
101105
@@ -348,6 +352,63 @@ func TestProviderData_buildSessionFromClaims(t *testing.T) {
348352 }
349353}
350354
355+func TestProviderData_checkNonce(t *testing.T) {
356+ testCases := map[string]struct {
357+ Session *sessions.SessionState
358+ IDToken idTokenClaims
359+ ExpectedError error
360+ }{
361+ "Nonces match": {
362+ Session: &sessions.SessionState{
363+ Nonce: []byte(oidcNonce),
364+ },
365+ IDToken: defaultIDToken,
366+ ExpectedError: nil,
367+ },
368+ "Nonces do not match": {
369+ Session: &sessions.SessionState{
370+ Nonce: []byte("WrongWrongWrong"),
371+ },
372+ IDToken: defaultIDToken,
373+ ExpectedError: errors.New("id_token nonce claim does not match the session nonce"),
374+ },
375+
376+ "Missing nonce claim": {
377+ Session: &sessions.SessionState{
378+ Nonce: []byte(oidcNonce),
379+ },
380+ IDToken: minimalIDToken,
381+ ExpectedError: errors.New("id_token nonce claim does not match the session nonce"),
382+ },
383+ }
384+ for testName, tc := range testCases {
385+ t.Run(testName, func(t *testing.T) {
386+ g := NewWithT(t)
387+
388+ provider := &ProviderData{
389+ Verifier: oidc.NewVerifier(
390+ oidcIssuer,
391+ mockJWKS{},
392+ &oidc.Config{ClientID: oidcClientID},
393+ ),
394+ }
395+
396+ rawIDToken, err := newSignedTestIDToken(tc.IDToken)
397+ g.Expect(err).ToNot(HaveOccurred())
398+
399+ idToken, err := provider.Verifier.Verify(context.Background(), rawIDToken)
400+ g.Expect(err).ToNot(HaveOccurred())
401+
402+ err = provider.checkNonce(tc.Session, idToken)
403+ if err != nil {
404+ g.Expect(err).To(Equal(tc.ExpectedError))
405+ } else {
406+ g.Expect(err).ToNot(HaveOccurred())
407+ }
408+ })
409+ }
410+}
411+
351412func TestProviderData_extractGroups(t *testing.T) {
352413 testCases := map[string]struct {
353414 Claims map[string]interface{}
providers/provider_default.go+7−7
@@ -33,6 +33,13 @@ var (
3333 _ Provider = (*ProviderData)(nil)
3434)
3535
36+// GetLoginURL with typical oauth parameters
37+func (p *ProviderData) GetLoginURL(redirectURI, state, _ string) string {
38+ extraParams := url.Values{}
39+ loginURL := makeLoginURL(p, redirectURI, state, extraParams)
40+ return loginURL.String()
41+}
42+
3643// Redeem provides a default implementation of the OAuth2 token redemption process
3744func (p *ProviderData) Redeem(ctx context.Context, redirectURL, code string) (*sessions.SessionState, error) {
3845 if code == "" {
@@ -86,13 +93,6 @@ func (p *ProviderData) Redeem(ctx context.Context, redirectURL, code string) (*s
8693 return nil, fmt.Errorf("no access token found %s", result.Body())
8794}
8895
89−// GetLoginURL with typical oauth parameters
90−func (p *ProviderData) GetLoginURL(redirectURI, state string) string {
91− extraParams := url.Values{}
92− a := makeLoginURL(p, redirectURI, state, extraParams)
93− return a.String()
94−}
95−
9696// GetEmailAddress returns the Account email address
9797// Deprecated: Migrate to EnrichSession
9898func (p *ProviderData) GetEmailAddress(_ context.Context, _ *sessions.SessionState) (string, error) {
providers/provider_default_test.go+2−2
@@ -31,7 +31,7 @@ func TestAcrValuesNotConfigured(t *testing.T) {
3131 },
3232 }
3333
34− result := p.GetLoginURL("https://my.test.app/oauth", "")
34+ result := p.GetLoginURL("https://my.test.app/oauth", "", "")
3535 assert.NotContains(t, result, "acr_values")
3636}
3737
@@ -45,7 +45,7 @@ func TestAcrValuesConfigured(t *testing.T) {
4545 AcrValues: "testValue",
4646 }
4747
48− result := p.GetLoginURL("https://my.test.app/oauth", "")
48+ result := p.GetLoginURL("https://my.test.app/oauth", "", "")
4949 assert.Contains(t, result, "acr_values=testValue")
5050}
5151
providers/providers.go+1−1
@@ -11,11 +11,11 @@ type Provider interface {
1111 Data() *ProviderData
1212 // Deprecated: Migrate to EnrichSession
1313 GetEmailAddress(ctx context.Context, s *sessions.SessionState) (string, error)
14+ GetLoginURL(redirectURI, state, nonce string) string
1415 Redeem(ctx context.Context, redirectURI, code string) (*sessions.SessionState, error)
1516 EnrichSession(ctx context.Context, s *sessions.SessionState) error
1617 Authorize(ctx context.Context, s *sessions.SessionState) (bool, error)
1718 ValidateSession(ctx context.Context, s *sessions.SessionState) bool
18− GetLoginURL(redirectURI, finalRedirect string) string
1919 RefreshSessionIfNeeded(ctx context.Context, s *sessions.SessionState) (bool, error)
2020 CreateSessionFromToken(ctx context.Context, token string) (*sessions.SessionState, error)
2121}