Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support nonce checks in OIDC Provider #967

Merged
merged 14 commits into from
Apr 21, 2021
4 changes: 4 additions & 0 deletions CHANGELOG.md
Expand Up @@ -4,10 +4,14 @@

## Important Notes

- [#967](https://github.com/oauth2-proxy/oauth2-proxy/pull/967) `--insecure-oidc-skip-nonce` is currently `true` by default in case
any existing OIDC Identity Providers don't support it. The default will switch to `false` in a future version.
NickMeves marked this conversation as resolved.
Show resolved Hide resolved

## Breaking Changes

## Changes since v7.1.2

- [#967](https://github.com/oauth2-proxy/oauth2-proxy/pull/967) Set & verify a nonce with OIDC providers (@NickMeves)
- [#1136](https://github.com/oauth2-proxy/oauth2-proxy/pull/1136) Add clock package for better time mocking in tests (@NickMeves)
- [#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)

Expand Down
1 change: 1 addition & 0 deletions docs/docs/configuration/alpha_config.md
Expand Up @@ -264,6 +264,7 @@ make up the header value
| `issuerURL` | _string_ | IssuerURL is the OpenID Connect issuer URL<br/>eg: https://accounts.google.com |
| `insecureAllowUnverifiedEmail` | _bool_ | InsecureAllowUnverifiedEmail prevents failures if an email address in an id_token is not verified<br/>default set to 'false' |
| `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' |
| `insecureSkipNonce` | _bool_ | InsecureSkipNonce skips verifying the ID Token's nonce claim |
| `skipDiscovery` | _bool_ | SkipDiscovery allows to skip OIDC discovery and use manually supplied Endpoints<br/>default set to 'false' |
| `jwksURL` | _string_ | JwksURL is the OpenID Connect JWKS URL<br/>eg: https://www.googleapis.com/oauth2/v3/certs |
| `emailClaim` | _string_ | EmailClaim indicates which claim contains the user email,<br/>default set to 'email' |
Expand Down
1 change: 1 addition & 0 deletions docs/docs/configuration/overview.md
Expand Up @@ -75,6 +75,7 @@ An example [oauth2-proxy.cfg](https://github.com/oauth2-proxy/oauth2-proxy/blob/
| `--login-url` | string | Authentication endpoint | |
| `--insecure-oidc-allow-unverified-email` | bool | don't fail if an email address in an id_token is not verified | false |
| `--insecure-oidc-skip-issuer-verification` | bool | allow the OIDC issuer URL to differ from the expected (currently required for Azure multi-tenant compatibility) | false |
| `--insecure-oidc-skip-nonce` | bool | skip verifying the OIDC ID Token's nonce claim | true |
| `--oidc-issuer-url` | string | the OpenID Connect issuer URL, e.g. `"https://accounts.google.com"` | |
| `--oidc-jwks-url` | string | OIDC JWKS URI for token verification; required if OIDC discovery is disabled | |
| `--oidc-email-claim` | string | which OIDC claim contains the user's email | `"email"` |
Expand Down
10 changes: 6 additions & 4 deletions main_test.go
Expand Up @@ -71,6 +71,7 @@ providers:
groupsClaim: groups
emailClaim: email
userIDClaim: email
insecureSkipNonce: true
`

const testCoreConfig = `
Expand Down Expand Up @@ -138,9 +139,10 @@ redirect_url="http://localhost:4180/oauth2/callback"
Tenant: "common",
},
OIDCConfig: options.OIDCOptions{
GroupsClaim: "groups",
EmailClaim: "email",
UserIDClaim: "email",
GroupsClaim: "groups",
EmailClaim: "email",
UserIDClaim: "email",
InsecureSkipNonce: true,
},
ApprovalPrompt: "force",
},
Expand Down Expand Up @@ -228,7 +230,7 @@ redirect_url="http://localhost:4180/oauth2/callback"
configContent: testCoreConfig,
alphaConfigContent: testAlphaConfig + ":",
expectedOptions: func() *options.Options { return nil },
expectedErr: errors.New("failed to load alpha options: error unmarshalling config: error converting YAML to JSON: yaml: line 48: did not find expected key"),
expectedErr: errors.New("failed to load alpha options: error unmarshalling config: error converting YAML to JSON: yaml: line 49: did not find expected key"),
}),
Entry("with alpha configuration and bad core configuration", loadConfigurationTableInput{
configContent: testCoreConfig + "unknown_field=\"something\"",
Expand Down
141 changes: 61 additions & 80 deletions oauthproxy.go
Expand Up @@ -23,8 +23,8 @@ import (
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/app/pagewriter"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/authentication/basic"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/cookies"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/encryption"
proxyhttp "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/http"

"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/ip"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/middleware"
Expand Down Expand Up @@ -60,14 +60,8 @@ type allowedRoute struct {

// OAuthProxy is the main authentication proxy
type OAuthProxy struct {
CSRFCookieName string
CookieDomains []string
CookiePath string
CookieSecure bool
CookieHTTPOnly bool
CookieExpire time.Duration
CookieSameSite string
Validator func(string) bool
CookieOptions *options.Cookie
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oooh! I like this :D

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first step in a long journey of figuring out what the heck is going on in the OAuthProxy struct -- and why some fields are public and others private 😅

And why other fields even exist at all.

Validator func(string) bool

RobotsPath string
SignInPath string
Expand Down Expand Up @@ -179,14 +173,8 @@ func NewOAuthProxy(opts *options.Options, validator func(string) bool) (*OAuthPr
}

p := &OAuthProxy{
CSRFCookieName: fmt.Sprintf("%v_%v", opts.Cookie.Name, "csrf"),
CookieDomains: opts.Cookie.Domains,
CookiePath: opts.Cookie.Path,
CookieSecure: opts.Cookie.Secure,
CookieHTTPOnly: opts.Cookie.HTTPOnly,
CookieExpire: opts.Cookie.Expire,
CookieSameSite: opts.Cookie.SameSite,
Validator: validator,
CookieOptions: &opts.Cookie,
Validator: validator,

RobotsPath: "/robots.txt",
SignInPath: fmt.Sprintf("%s/sign_in", opts.ProxyPrefix),
Expand Down Expand Up @@ -427,47 +415,6 @@ func buildRoutesAllowlist(opts *options.Options) ([]allowedRoute, error) {
return routes, nil
}

// MakeCSRFCookie creates a cookie for CSRF
func (p *OAuthProxy) MakeCSRFCookie(req *http.Request, value string, expiration time.Duration, now time.Time) *http.Cookie {
return p.makeCookie(req, p.CSRFCookieName, value, expiration, now)
}

func (p *OAuthProxy) makeCookie(req *http.Request, name string, value string, expiration time.Duration, now time.Time) *http.Cookie {
cookieDomain := cookies.GetCookieDomain(req, p.CookieDomains)

if cookieDomain != "" {
domain := requestutil.GetRequestHost(req)
if h, _, err := net.SplitHostPort(domain); err == nil {
domain = h
}
if !strings.HasSuffix(domain, cookieDomain) {
logger.Errorf("Warning: request host is %q but using configured cookie domain of %q", domain, cookieDomain)
}
}

return &http.Cookie{
Name: name,
Value: value,
Path: p.CookiePath,
Domain: cookieDomain,
HttpOnly: p.CookieHTTPOnly,
Secure: p.CookieSecure,
Expires: now.Add(expiration),
SameSite: cookies.ParseSameSite(p.CookieSameSite),
}
}

// ClearCSRFCookie creates a cookie to unset the CSRF cookie stored in the user's
// session
func (p *OAuthProxy) ClearCSRFCookie(rw http.ResponseWriter, req *http.Request) {
http.SetCookie(rw, p.MakeCSRFCookie(req, "", time.Hour*-1, time.Now()))
}

// SetCSRFCookie adds a CSRF cookie to the response
func (p *OAuthProxy) SetCSRFCookie(rw http.ResponseWriter, req *http.Request, val string) {
http.SetCookie(rw, p.MakeCSRFCookie(req, val, p.CookieExpire, time.Now()))
}

// ClearSessionCookie creates a cookie to unset the user's authentication cookie
// stored in the user's session
func (p *OAuthProxy) ClearSessionCookie(rw http.ResponseWriter, req *http.Request) error {
Expand Down Expand Up @@ -744,21 +691,35 @@ func (p *OAuthProxy) SignOut(rw http.ResponseWriter, req *http.Request) {
// OAuthStart starts the OAuth2 authentication flow
func (p *OAuthProxy) OAuthStart(rw http.ResponseWriter, req *http.Request) {
prepareNoCache(rw)
nonce, err := encryption.Nonce()

csrf, err := cookies.NewCSRF(p.CookieOptions)
if err != nil {
logger.Errorf("Error obtaining nonce: %v", err)
logger.Errorf("Error creating CSRF nonce: %v", err)
p.ErrorPage(rw, req, http.StatusInternalServerError, err.Error())
return
}
p.SetCSRFCookie(rw, req, nonce)
redirect, err := p.getAppRedirect(req)

appRedirect, err := p.getAppRedirect(req)
if err != nil {
logger.Errorf("Error obtaining redirect: %v", err)
logger.Errorf("Error obtaining application redirect: %v", err)
p.ErrorPage(rw, req, http.StatusInternalServerError, err.Error())
return
}
redirectURI := p.getOAuthRedirectURI(req)
http.Redirect(rw, req, p.provider.GetLoginURL(redirectURI, fmt.Sprintf("%v:%v", nonce, redirect)), http.StatusFound)

callbackRedirect := p.getOAuthRedirectURI(req)
loginURL := p.provider.GetLoginURL(
callbackRedirect,
encodeState(csrf.HashOAuthState(), appRedirect),
csrf.HashOIDCNonce(),
)

if _, err := csrf.SetCookie(rw, req); err != nil {
logger.Errorf("Error setting CSRF cookie: %v", err)
p.ErrorPage(rw, req, http.StatusInternalServerError, err.Error())
return
}

http.Redirect(rw, req, loginURL, http.StatusFound)
}

// OAuthCallback is the OAuth2 authentication flow callback that finishes the
Expand Down Expand Up @@ -796,29 +757,33 @@ func (p *OAuthProxy) OAuthCallback(rw http.ResponseWriter, req *http.Request) {
return
}

state := strings.SplitN(req.Form.Get("state"), ":", 2)
if len(state) != 2 {
logger.Error("Error while parsing OAuth2 state: invalid length")
p.ErrorPage(rw, req, http.StatusInternalServerError, "State paremeter did not have expected length", "Login Failed: Invalid State after login.")
return
}
nonce := state[0]
redirect := state[1]
c, err := req.Cookie(p.CSRFCookieName)
csrf, err := cookies.LoadCSRFCookie(req, p.CookieOptions)
if err != nil {
logger.PrintAuthf(session.Email, req, logger.AuthFailure, "Invalid authentication via OAuth2: unable to obtain CSRF cookie")
p.ErrorPage(rw, req, http.StatusForbidden, err.Error(), "Login Failed: Unable to find a valid CSRF token. Please try again.")
return
}
p.ClearCSRFCookie(rw, req)
if c.Value != nonce {

csrf.ClearCookie(rw, req)

nonce, appRedirect, err := decodeState(req)
if err != nil {
logger.Errorf("Error while parsing OAuth2 state: %v", err)
p.ErrorPage(rw, req, http.StatusInternalServerError, err.Error())
return
}

if !csrf.CheckOAuthState(nonce) {
logger.PrintAuthf(session.Email, req, logger.AuthFailure, "Invalid authentication via OAuth2: CSRF token mismatch, potential attack")
p.ErrorPage(rw, req, http.StatusForbidden, "CSRF token mismatch, potential attack", "Login Failed: Unable to find a valid CSRF token. Please try again.")
return
}

if !p.IsValidRedirect(redirect) {
redirect = "/"
csrf.SetSessionNonce(session)
p.provider.ValidateSession(req.Context(), session)

if !p.IsValidRedirect(appRedirect) {
appRedirect = "/"
}

// set cookie, or deny
Expand All @@ -834,7 +799,7 @@ func (p *OAuthProxy) OAuthCallback(rw http.ResponseWriter, req *http.Request) {
p.ErrorPage(rw, req, http.StatusInternalServerError, err.Error())
return
}
http.Redirect(rw, req, redirect, http.StatusFound)
http.Redirect(rw, req, appRedirect, http.StatusFound)
} else {
logger.PrintAuthf(session.Email, req, logger.AuthFailure, "Invalid authentication via OAuth2: unauthorized")
p.ErrorPage(rw, req, http.StatusForbidden, "Invalid session: unauthorized")
Expand Down Expand Up @@ -966,7 +931,7 @@ func (p *OAuthProxy) getOAuthRedirectURI(req *http.Request) string {

// If CookieSecure is true, return `https` no matter what
// Not all reverse proxies set X-Forwarded-Proto
if p.CookieSecure {
if p.CookieOptions.Secure {
rd.Scheme = schemeHTTPS
}
return rd.String()
Expand Down Expand Up @@ -1207,6 +1172,22 @@ func extractAllowedGroups(req *http.Request) map[string]struct{} {
return groups
}

// encodedState builds the OAuth state param out of our nonce and
// original application redirect
func encodeState(nonce string, redirect string) string {
return fmt.Sprintf("%v:%v", nonce, redirect)
}

// decodeState splits the reflected OAuth state response back into
// the nonce and original application redirect
func decodeState(req *http.Request) (string, string, error) {
NickMeves marked this conversation as resolved.
Show resolved Hide resolved
state := strings.SplitN(req.Form.Get("state"), ":", 2)
if len(state) != 2 {
return "", "", errors.New("invalid length")
}
return state[0], state[1], nil
}

// addHeadersForProxying adds the appropriate headers the request / response for proxying
func (p *OAuthProxy) addHeadersForProxying(rw http.ResponseWriter, session *sessionsapi.SessionState) {
if session.Email == "" {
Expand Down
40 changes: 32 additions & 8 deletions oauthproxy_test.go
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/middleware"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/cookies"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
sessionscookie "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/sessions/cookie"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/upstream"
Expand Down Expand Up @@ -698,23 +699,42 @@ func (patTest *PassAccessTokenTest) Close() {
patTest.providerServer.Close()
}

func (patTest *PassAccessTokenTest) getCallbackEndpoint() (httpCode int,
cookie string) {
func (patTest *PassAccessTokenTest) getCallbackEndpoint() (httpCode int, cookie string) {
rw := httptest.NewRecorder()
req, err := http.NewRequest("GET", "/oauth2/callback?code=callback_code&state=nonce:",
strings.NewReader(""))

csrf, err := cookies.NewCSRF(patTest.proxy.CookieOptions)
if err != nil {
panic(err)
}

req, err := http.NewRequest(
http.MethodGet,
fmt.Sprintf(
"/oauth2/callback?code=callback_code&state=%s",
encodeState(csrf.HashOAuthState(), "%2F"),
),
strings.NewReader(""),
)
if err != nil {
return 0, ""
}
req.AddCookie(patTest.proxy.MakeCSRFCookie(req, "nonce", time.Hour, time.Now()))

// rw is a dummy here, we just want the csrfCookie to add to our req
csrfCookie, err := csrf.SetCookie(httptest.NewRecorder(), req)
if err != nil {
panic(err)
}
req.AddCookie(csrfCookie)

patTest.proxy.ServeHTTP(rw, req)

return rw.Code, rw.Header().Values("Set-Cookie")[1]
}

// getEndpointWithCookie makes a requests againt the oauthproxy with passed requestPath
// and cookie and returns body and status code.
func (patTest *PassAccessTokenTest) getEndpointWithCookie(cookie string, endpoint string) (httpCode int, accessToken string) {
cookieName := patTest.opts.Cookie.Name
cookieName := patTest.proxy.CookieOptions.Name
var value string
keyPrefix := cookieName + "="

Expand Down Expand Up @@ -983,6 +1003,9 @@ func NewProcessCookieTest(opts ProcessCookieTestOpts, modifiers ...OptionsModifi
}
pcTest.proxy.provider.(*TestProvider).SetAllowedGroups(pcTest.opts.Providers[0].AllowedGroups)

// Now, zero-out proxy.CookieRefresh for the cases that don't involve
// access_token validation.
pcTest.proxy.CookieOptions.Refresh = time.Duration(0)
pcTest.rw = httptest.NewRecorder()
pcTest.req, _ = http.NewRequest("GET", "/", strings.NewReader(""))
pcTest.validateUser = true
Expand Down Expand Up @@ -1104,6 +1127,7 @@ func TestProcessCookieFailIfRefreshSetAndCookieExpired(t *testing.T) {
err = pcTest.SaveSession(startSession)
assert.NoError(t, err)

pcTest.proxy.CookieOptions.Refresh = time.Hour
session, err := pcTest.LoadCookiedSession()
assert.NotEqual(t, nil, err)
if session != nil {
Expand Down Expand Up @@ -1999,7 +2023,7 @@ func TestClearSplitCookie(t *testing.T) {
t.Fatal(err)
}

p := OAuthProxy{sessionStore: store}
p := OAuthProxy{CookieOptions: &opts.Cookie, sessionStore: store}
var rw = httptest.NewRecorder()
req := httptest.NewRequest("get", "/", nil)

Expand Down Expand Up @@ -2032,7 +2056,7 @@ func TestClearSingleCookie(t *testing.T) {
t.Fatal(err)
}

p := OAuthProxy{sessionStore: store}
p := OAuthProxy{CookieOptions: &opts.Cookie, sessionStore: store}
var rw = httptest.NewRecorder()
req := httptest.NewRequest("get", "/", nil)

Expand Down