mirror of
https://github.com/oauth2-proxy/oauth2-proxy.git
synced 2026-10-06 22:51:16 +02:00
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
This commit is contained in:
@@ -48,12 +48,13 @@ func NewLegacyOptions() *LegacyOptions {
|
||||
},
|
||||
|
||||
LegacyProvider: LegacyProvider{
|
||||
ProviderType: "google",
|
||||
AzureTenant: "common",
|
||||
ApprovalPrompt: "force",
|
||||
UserIDClaim: "email",
|
||||
OIDCEmailClaim: "email",
|
||||
OIDCGroupsClaim: "groups",
|
||||
ProviderType: "google",
|
||||
AzureTenant: "common",
|
||||
ApprovalPrompt: "force",
|
||||
UserIDClaim: "email",
|
||||
OIDCEmailClaim: "email",
|
||||
OIDCGroupsClaim: "groups",
|
||||
InsecureOIDCSkipNonce: true,
|
||||
},
|
||||
|
||||
Options: *NewOptions(),
|
||||
@@ -492,6 +493,7 @@ type LegacyProvider struct {
|
||||
OIDCIssuerURL string `flag:"oidc-issuer-url" cfg:"oidc_issuer_url"`
|
||||
InsecureOIDCAllowUnverifiedEmail bool `flag:"insecure-oidc-allow-unverified-email" cfg:"insecure_oidc_allow_unverified_email"`
|
||||
InsecureOIDCSkipIssuerVerification bool `flag:"insecure-oidc-skip-issuer-verification" cfg:"insecure_oidc_skip_issuer_verification"`
|
||||
InsecureOIDCSkipNonce bool `flag:"insecure-oidc-skip-nonce" cfg:"insecure_oidc_skip_nonce"`
|
||||
SkipOIDCDiscovery bool `flag:"skip-oidc-discovery" cfg:"skip_oidc_discovery"`
|
||||
OIDCJwksURL string `flag:"oidc-jwks-url" cfg:"oidc_jwks_url"`
|
||||
OIDCEmailClaim string `flag:"oidc-email-claim" cfg:"oidc_email_claim"`
|
||||
@@ -540,6 +542,7 @@ func legacyProviderFlagSet() *pflag.FlagSet {
|
||||
flagSet.String("oidc-issuer-url", "", "OpenID Connect issuer URL (ie: https://accounts.google.com)")
|
||||
flagSet.Bool("insecure-oidc-allow-unverified-email", false, "Don't fail if an email address in an id_token is not verified")
|
||||
flagSet.Bool("insecure-oidc-skip-issuer-verification", false, "Do not verify if issuer matches OIDC discovery URL")
|
||||
flagSet.Bool("insecure-oidc-skip-nonce", true, "skip verifying the OIDC ID Token's nonce claim")
|
||||
flagSet.Bool("skip-oidc-discovery", false, "Skip OIDC discovery and use manually supplied Endpoints")
|
||||
flagSet.String("oidc-jwks-url", "", "OpenID Connect JWKS URL (ie: https://www.googleapis.com/oauth2/v3/certs)")
|
||||
flagSet.String("oidc-groups-claim", providers.OIDCGroupsClaim, "which OIDC claim contains the user groups")
|
||||
@@ -630,6 +633,7 @@ func (l *LegacyProvider) convert() (Providers, error) {
|
||||
IssuerURL: l.OIDCIssuerURL,
|
||||
InsecureAllowUnverifiedEmail: l.InsecureOIDCAllowUnverifiedEmail,
|
||||
InsecureSkipIssuerVerification: l.InsecureOIDCSkipIssuerVerification,
|
||||
InsecureSkipNonce: l.InsecureOIDCSkipNonce,
|
||||
SkipDiscovery: l.SkipOIDCDiscovery,
|
||||
JwksURL: l.OIDCJwksURL,
|
||||
UserIDClaim: l.UserIDClaim,
|
||||
|
||||
@@ -113,6 +113,7 @@ var _ = Describe("Legacy Options", func() {
|
||||
|
||||
opts.Providers[0].ClientID = "oauth-proxy"
|
||||
opts.Providers[0].ID = "google=oauth-proxy"
|
||||
opts.Providers[0].OIDCConfig.InsecureSkipNonce = true
|
||||
|
||||
converted, err := legacyOpts.ToOptions()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
@@ -36,12 +36,13 @@ var _ = Describe("Load", func() {
|
||||
},
|
||||
|
||||
LegacyProvider: LegacyProvider{
|
||||
ProviderType: "google",
|
||||
AzureTenant: "common",
|
||||
ApprovalPrompt: "force",
|
||||
UserIDClaim: "email",
|
||||
OIDCEmailClaim: "email",
|
||||
OIDCGroupsClaim: "groups",
|
||||
ProviderType: "google",
|
||||
AzureTenant: "common",
|
||||
ApprovalPrompt: "force",
|
||||
UserIDClaim: "email",
|
||||
OIDCEmailClaim: "email",
|
||||
OIDCGroupsClaim: "groups",
|
||||
InsecureOIDCSkipNonce: true,
|
||||
},
|
||||
|
||||
Options: Options{
|
||||
|
||||
@@ -132,6 +132,12 @@ type OIDCOptions struct {
|
||||
// InsecureSkipIssuerVerification skips verification of ID token issuers. When false, ID Token Issuers must match the OIDC discovery URL
|
||||
// default set to 'false'
|
||||
InsecureSkipIssuerVerification bool `json:"insecureSkipIssuerVerification,omitempty"`
|
||||
// InsecureSkipNonce skips verifying the ID Token's nonce claim that must match
|
||||
// the random nonce sent in the initial OAuth flow. Otherwise, the nonce is checked
|
||||
// after the initial OAuth redeem & subsequent token refreshes.
|
||||
// default set to 'true'
|
||||
// Warning: In a future release, this will change to 'false' by default for enhanced security.
|
||||
InsecureSkipNonce bool `json:"insecureSkipNonce,omitempty"`
|
||||
// SkipDiscovery allows to skip OIDC discovery and use manually supplied Endpoints
|
||||
// default set to 'false'
|
||||
SkipDiscovery bool `json:"skipDiscovery,omitempty"`
|
||||
@@ -169,6 +175,7 @@ func providerDefaults() Providers {
|
||||
},
|
||||
OIDCConfig: OIDCOptions{
|
||||
InsecureAllowUnverifiedEmail: false,
|
||||
InsecureSkipNonce: true,
|
||||
SkipDiscovery: false,
|
||||
UserIDClaim: providers.OIDCEmailClaim, // Deprecated: Use OIDCEmailClaim
|
||||
EmailClaim: providers.OIDCEmailClaim,
|
||||
|
||||
@@ -24,6 +24,8 @@ type SessionState struct {
|
||||
IDToken string `msgpack:"it,omitempty"`
|
||||
RefreshToken string `msgpack:"rt,omitempty"`
|
||||
|
||||
Nonce []byte `msgpack:"n,omitempty"`
|
||||
|
||||
Email string `msgpack:"e,omitempty"`
|
||||
User string `msgpack:"u,omitempty"`
|
||||
Groups []string `msgpack:"g,omitempty"`
|
||||
@@ -100,6 +102,11 @@ func (s *SessionState) GetClaim(claim string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
// CheckNonce compares the Nonce against a potential hash of it
|
||||
func (s *SessionState) CheckNonce(hashed string) bool {
|
||||
return encryption.CheckNonce(s.Nonce, hashed)
|
||||
}
|
||||
|
||||
// EncodeSessionState returns an encrypted, lz4 compressed, MessagePack encoded session
|
||||
func (s *SessionState) EncodeSessionState(c encryption.Cipher, compress bool) ([]byte, error) {
|
||||
packed, err := msgpack.Marshal(s)
|
||||
|
||||
@@ -153,6 +153,7 @@ func TestEncodeAndDecodeSessionState(t *testing.T) {
|
||||
CreatedAt: &created,
|
||||
ExpiresOn: &expires,
|
||||
RefreshToken: "RefreshToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7",
|
||||
Nonce: []byte("abcdef1234567890abcdef1234567890"),
|
||||
},
|
||||
"No ExpiresOn": {
|
||||
Email: "username@example.com",
|
||||
@@ -162,6 +163,7 @@ func TestEncodeAndDecodeSessionState(t *testing.T) {
|
||||
IDToken: "IDToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7",
|
||||
CreatedAt: &created,
|
||||
RefreshToken: "RefreshToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7",
|
||||
Nonce: []byte("abcdef1234567890abcdef1234567890"),
|
||||
},
|
||||
"No PreferredUsername": {
|
||||
Email: "username@example.com",
|
||||
@@ -171,6 +173,7 @@ func TestEncodeAndDecodeSessionState(t *testing.T) {
|
||||
CreatedAt: &created,
|
||||
ExpiresOn: &expires,
|
||||
RefreshToken: "RefreshToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7",
|
||||
Nonce: []byte("abcdef1234567890abcdef1234567890"),
|
||||
},
|
||||
"Minimal session": {
|
||||
User: "username",
|
||||
@@ -194,6 +197,7 @@ func TestEncodeAndDecodeSessionState(t *testing.T) {
|
||||
CreatedAt: &created,
|
||||
ExpiresOn: &expires,
|
||||
RefreshToken: "RefreshToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7",
|
||||
Nonce: []byte("abcdef1234567890abcdef1234567890"),
|
||||
Groups: []string{"group-a", "group-b"},
|
||||
},
|
||||
}
|
||||
|
||||
+39
-36
@@ -12,46 +12,33 @@ import (
|
||||
requestutil "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests/util"
|
||||
)
|
||||
|
||||
// MakeCookie constructs a cookie from the given parameters,
|
||||
// discovering the domain from the request if not specified.
|
||||
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 {
|
||||
if domain != "" {
|
||||
host := requestutil.GetRequestHost(req)
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
if !strings.HasSuffix(host, domain) {
|
||||
logger.Errorf("Warning: request host is %q but using configured cookie domain of %q", host, domain)
|
||||
}
|
||||
}
|
||||
|
||||
return &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Path: path,
|
||||
Domain: domain,
|
||||
HttpOnly: httpOnly,
|
||||
Secure: secure,
|
||||
Expires: now.Add(expiration),
|
||||
SameSite: sameSite,
|
||||
}
|
||||
}
|
||||
|
||||
// MakeCookieFromOptions constructs a cookie based on the given *options.CookieOptions,
|
||||
// value and creation time
|
||||
func MakeCookieFromOptions(req *http.Request, name string, value string, cookieOpts *options.Cookie, expiration time.Duration, now time.Time) *http.Cookie {
|
||||
domain := GetCookieDomain(req, cookieOpts.Domains)
|
||||
|
||||
if domain != "" {
|
||||
return MakeCookie(req, name, value, cookieOpts.Path, domain, cookieOpts.HTTPOnly, cookieOpts.Secure, expiration, now, ParseSameSite(cookieOpts.SameSite))
|
||||
}
|
||||
func MakeCookieFromOptions(req *http.Request, name string, value string, opts *options.Cookie, expiration time.Duration, now time.Time) *http.Cookie {
|
||||
domain := GetCookieDomain(req, opts.Domains)
|
||||
// If nothing matches, create the cookie with the shortest domain
|
||||
defaultDomain := ""
|
||||
if len(cookieOpts.Domains) > 0 {
|
||||
logger.Errorf("Warning: request host %q did not match any of the specific cookie domains of %q", requestutil.GetRequestHost(req), strings.Join(cookieOpts.Domains, ","))
|
||||
defaultDomain = cookieOpts.Domains[len(cookieOpts.Domains)-1]
|
||||
if domain == "" && len(opts.Domains) > 0 {
|
||||
logger.Errorf("Warning: request host %q did not match any of the specific cookie domains of %q",
|
||||
requestutil.GetRequestHost(req),
|
||||
strings.Join(opts.Domains, ","),
|
||||
)
|
||||
domain = opts.Domains[len(opts.Domains)-1]
|
||||
}
|
||||
return MakeCookie(req, name, value, cookieOpts.Path, defaultDomain, cookieOpts.HTTPOnly, cookieOpts.Secure, expiration, now, ParseSameSite(cookieOpts.SameSite))
|
||||
|
||||
c := &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Path: opts.Path,
|
||||
Domain: domain,
|
||||
Expires: now.Add(expiration),
|
||||
HttpOnly: opts.HTTPOnly,
|
||||
Secure: opts.Secure,
|
||||
SameSite: ParseSameSite(opts.SameSite),
|
||||
}
|
||||
|
||||
warnInvalidDomain(c, req)
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// GetCookieDomain returns the correct cookie domain given a list of domains
|
||||
@@ -81,3 +68,19 @@ func ParseSameSite(v string) http.SameSite {
|
||||
panic(fmt.Sprintf("Invalid value for SameSite: %s", v))
|
||||
}
|
||||
}
|
||||
|
||||
// warnInvalidDomain logs a warning if the request host and cookie domain are
|
||||
// mismatched.
|
||||
func warnInvalidDomain(c *http.Cookie, req *http.Request) {
|
||||
if c.Domain == "" {
|
||||
return
|
||||
}
|
||||
|
||||
host := requestutil.GetRequestHost(req)
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
if !strings.HasSuffix(host, c.Domain) {
|
||||
logger.Errorf("Warning: request host is %q but using configured cookie domain of %q", host, c.Domain)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package cookies
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
const (
|
||||
csrfState = "1234asdf1234asdf1234asdf"
|
||||
csrfNonce = "0987lkjh0987lkjh0987lkjh"
|
||||
|
||||
cookieName = "cookie_test_12345"
|
||||
cookieSecret = "3q48hmFH30FJ2HfJF0239UFJCVcl3kj3"
|
||||
cookieDomain = "o2p.cookies.test"
|
||||
cookiePath = "/cookie-tests"
|
||||
|
||||
nowEpoch = 1609366421
|
||||
)
|
||||
|
||||
func TestProviderSuite(t *testing.T) {
|
||||
logger.SetOutput(GinkgoWriter)
|
||||
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Cookies")
|
||||
}
|
||||
|
||||
func testCookieExpires(exp time.Time) string {
|
||||
var buf [len(http.TimeFormat)]byte
|
||||
return string(exp.UTC().AppendFormat(buf[:0], http.TimeFormat))
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package cookies
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
middlewareapi "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/middleware"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/ginkgo/extensions/table"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Cookie Tests", func() {
|
||||
Context("GetCookieDomain", func() {
|
||||
type getCookieDomainTableInput struct {
|
||||
host string
|
||||
xForwardedHost string
|
||||
cookieDomains []string
|
||||
expectedOutput string
|
||||
}
|
||||
|
||||
DescribeTable("should return expected results",
|
||||
func(in getCookieDomainTableInput) {
|
||||
req, err := http.NewRequest(
|
||||
http.MethodGet,
|
||||
fmt.Sprintf("https://%s/%s", in.host, cookiePath),
|
||||
nil,
|
||||
)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
if in.xForwardedHost != "" {
|
||||
req.Header.Add("X-Forwarded-Host", in.xForwardedHost)
|
||||
req = middlewareapi.AddRequestScope(req, &middlewareapi.RequestScope{
|
||||
ReverseProxy: true,
|
||||
})
|
||||
}
|
||||
|
||||
Expect(GetCookieDomain(req, in.cookieDomains)).To(Equal(in.expectedOutput))
|
||||
},
|
||||
Entry("a single exact match for the Host header", getCookieDomainTableInput{
|
||||
host: "www.cookies.test",
|
||||
cookieDomains: []string{"www.cookies.test"},
|
||||
expectedOutput: "www.cookies.test",
|
||||
}),
|
||||
Entry("a single exact match for the X-Forwarded-Host header", getCookieDomainTableInput{
|
||||
host: "backend.cookies.internal",
|
||||
xForwardedHost: "www.cookies.test",
|
||||
cookieDomains: []string{"www.cookies.test"},
|
||||
expectedOutput: "www.cookies.test",
|
||||
}),
|
||||
Entry("a single suffix match for the Host header", getCookieDomainTableInput{
|
||||
host: "www.cookies.test",
|
||||
cookieDomains: []string{".cookies.test"},
|
||||
expectedOutput: ".cookies.test",
|
||||
}),
|
||||
Entry("a single suffix match for the X-Forwarded-Host header", getCookieDomainTableInput{
|
||||
host: "backend.cookies.internal",
|
||||
xForwardedHost: "www.cookies.test",
|
||||
cookieDomains: []string{".cookies.test"},
|
||||
expectedOutput: ".cookies.test",
|
||||
}),
|
||||
Entry("the first match is used", getCookieDomainTableInput{
|
||||
host: "www.cookies.test",
|
||||
cookieDomains: []string{"www.cookies.test", ".cookies.test"},
|
||||
expectedOutput: "www.cookies.test",
|
||||
}),
|
||||
Entry("the only match is used", getCookieDomainTableInput{
|
||||
host: "www.cookies.test",
|
||||
cookieDomains: []string{".cookies.wrong", ".cookies.test"},
|
||||
expectedOutput: ".cookies.test",
|
||||
}),
|
||||
Entry("blank is returned for no matches", getCookieDomainTableInput{
|
||||
host: "www.cookies.test",
|
||||
cookieDomains: []string{".cookies.wrong", ".cookies.false"},
|
||||
expectedOutput: "",
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
package cookies
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"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/clock"
|
||||
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/encryption"
|
||||
"github.com/vmihailenco/msgpack/v4"
|
||||
)
|
||||
|
||||
// CSRF manages various nonces stored in the CSRF cookie during the initial
|
||||
// authentication flows.
|
||||
type CSRF interface {
|
||||
HashOAuthState() string
|
||||
HashOIDCNonce() string
|
||||
CheckOAuthState(string) bool
|
||||
CheckOIDCNonce(string) bool
|
||||
|
||||
SetSessionNonce(s *sessions.SessionState)
|
||||
|
||||
SetCookie(http.ResponseWriter, *http.Request) (*http.Cookie, error)
|
||||
ClearCookie(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
type csrf struct {
|
||||
// OAuthState holds the OAuth2 state parameter's nonce component set in the
|
||||
// initial authentication request and mirrored back in the callback
|
||||
// redirect from the IdP for CSRF protection.
|
||||
OAuthState []byte `msgpack:"s,omitempty"`
|
||||
|
||||
// OIDCNonce holds the OIDC nonce parameter used in the initial authentication
|
||||
// and then set in all subsequent OIDC ID Tokens as the nonce claim. This
|
||||
// is used to mitigate replay attacks.
|
||||
OIDCNonce []byte `msgpack:"n,omitempty"`
|
||||
|
||||
cookieOpts *options.Cookie
|
||||
time clock.Clock
|
||||
}
|
||||
|
||||
// NewCSRF creates a CSRF with random nonces
|
||||
func NewCSRF(opts *options.Cookie) (CSRF, error) {
|
||||
state, err := encryption.Nonce()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce, err := encryption.Nonce()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &csrf{
|
||||
OAuthState: state,
|
||||
OIDCNonce: nonce,
|
||||
|
||||
cookieOpts: opts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LoadCSRFCookie loads a CSRF object from a request's CSRF cookie
|
||||
func LoadCSRFCookie(req *http.Request, opts *options.Cookie) (CSRF, error) {
|
||||
cookie, err := req.Cookie(csrfCookieName(opts))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return decodeCSRFCookie(cookie, opts)
|
||||
}
|
||||
|
||||
// HashOAuthState returns the hash of the OAuth state nonce
|
||||
func (c *csrf) HashOAuthState() string {
|
||||
return encryption.HashNonce(c.OAuthState)
|
||||
}
|
||||
|
||||
// HashOIDCNonce returns the hash of the OIDC nonce
|
||||
func (c *csrf) HashOIDCNonce() string {
|
||||
return encryption.HashNonce(c.OIDCNonce)
|
||||
}
|
||||
|
||||
// CheckOAuthState compares the OAuth state nonce against a potential
|
||||
// hash of it
|
||||
func (c *csrf) CheckOAuthState(hashed string) bool {
|
||||
return encryption.CheckNonce(c.OAuthState, hashed)
|
||||
}
|
||||
|
||||
// CheckOIDCNonce compares the OIDC nonce against a potential hash of it
|
||||
func (c *csrf) CheckOIDCNonce(hashed string) bool {
|
||||
return encryption.CheckNonce(c.OIDCNonce, hashed)
|
||||
}
|
||||
|
||||
// SetSessionNonce sets the OIDCNonce on a SessionState
|
||||
func (c *csrf) SetSessionNonce(s *sessions.SessionState) {
|
||||
s.Nonce = c.OIDCNonce
|
||||
}
|
||||
|
||||
// SetCookie encodes the CSRF to a signed cookie and sets it on the ResponseWriter
|
||||
func (c *csrf) SetCookie(rw http.ResponseWriter, req *http.Request) (*http.Cookie, error) {
|
||||
encoded, err := c.encodeCookie()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cookie := MakeCookieFromOptions(
|
||||
req,
|
||||
c.cookieName(),
|
||||
encoded,
|
||||
c.cookieOpts,
|
||||
c.cookieOpts.Expire,
|
||||
c.time.Now(),
|
||||
)
|
||||
http.SetCookie(rw, cookie)
|
||||
|
||||
return cookie, nil
|
||||
}
|
||||
|
||||
// ClearCookie removes the CSRF cookie
|
||||
func (c *csrf) ClearCookie(rw http.ResponseWriter, req *http.Request) {
|
||||
http.SetCookie(rw, MakeCookieFromOptions(
|
||||
req,
|
||||
c.cookieName(),
|
||||
"",
|
||||
c.cookieOpts,
|
||||
time.Hour*-1,
|
||||
c.time.Now(),
|
||||
))
|
||||
}
|
||||
|
||||
// encodeCookie MessagePack encodes and encrypts the CSRF and then creates a
|
||||
// signed cookie value
|
||||
func (c *csrf) encodeCookie() (string, error) {
|
||||
packed, err := msgpack.Marshal(c)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error marshalling CSRF to msgpack: %v", err)
|
||||
}
|
||||
|
||||
encrypted, err := encrypt(packed, c.cookieOpts)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return encryption.SignedValue(c.cookieOpts.Secret, c.cookieName(), encrypted, c.time.Now())
|
||||
}
|
||||
|
||||
// decodeCSRFCookie validates the signature then decrypts and decodes a CSRF
|
||||
// cookie into a CSRF struct
|
||||
func decodeCSRFCookie(cookie *http.Cookie, opts *options.Cookie) (*csrf, error) {
|
||||
val, _, ok := encryption.Validate(cookie, opts.Secret, opts.Expire)
|
||||
if !ok {
|
||||
return nil, errors.New("CSRF cookie failed validation")
|
||||
}
|
||||
|
||||
decrypted, err := decrypt(val, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Valid cookie, Unmarshal the CSRF
|
||||
csrf := &csrf{cookieOpts: opts}
|
||||
err = msgpack.Unmarshal(decrypted, csrf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error unmarshalling data to CSRF: %v", err)
|
||||
}
|
||||
|
||||
return csrf, nil
|
||||
}
|
||||
|
||||
// cookieName returns the CSRF cookie's name derived from the base
|
||||
// session cookie name
|
||||
func (c *csrf) cookieName() string {
|
||||
return csrfCookieName(c.cookieOpts)
|
||||
}
|
||||
|
||||
func csrfCookieName(opts *options.Cookie) string {
|
||||
return fmt.Sprintf("%v_csrf", opts.Name)
|
||||
}
|
||||
|
||||
func encrypt(data []byte, opts *options.Cookie) ([]byte, error) {
|
||||
cipher, err := makeCipher(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cipher.Encrypt(data)
|
||||
}
|
||||
|
||||
func decrypt(data []byte, opts *options.Cookie) ([]byte, error) {
|
||||
cipher, err := makeCipher(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cipher.Decrypt(data)
|
||||
}
|
||||
|
||||
func makeCipher(opts *options.Cookie) (encryption.Cipher, error) {
|
||||
return encryption.NewCFBCipher(encryption.SecretBytes(opts.Secret))
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package cookies
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"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/encryption"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("CSRF Cookie Tests", func() {
|
||||
var (
|
||||
cookieOpts *options.Cookie
|
||||
publicCSRF CSRF
|
||||
privateCSRF *csrf
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
cookieOpts = &options.Cookie{
|
||||
Name: cookieName,
|
||||
Secret: cookieSecret,
|
||||
Domains: []string{cookieDomain},
|
||||
Path: cookiePath,
|
||||
Expire: time.Hour,
|
||||
Secure: true,
|
||||
HTTPOnly: true,
|
||||
}
|
||||
|
||||
var err error
|
||||
publicCSRF, err = NewCSRF(cookieOpts)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
privateCSRF = publicCSRF.(*csrf)
|
||||
})
|
||||
|
||||
Context("NewCSRF", func() {
|
||||
It("makes unique nonces for OAuth and OIDC", func() {
|
||||
Expect(privateCSRF.OAuthState).ToNot(BeEmpty())
|
||||
Expect(privateCSRF.OIDCNonce).ToNot(BeEmpty())
|
||||
Expect(privateCSRF.OAuthState).ToNot(Equal(privateCSRF.OIDCNonce))
|
||||
})
|
||||
|
||||
It("makes unique nonces between multiple CSRFs", func() {
|
||||
other, err := NewCSRF(cookieOpts)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(privateCSRF.OAuthState).ToNot(Equal(other.(*csrf).OAuthState))
|
||||
Expect(privateCSRF.OIDCNonce).ToNot(Equal(other.(*csrf).OIDCNonce))
|
||||
})
|
||||
})
|
||||
|
||||
Context("CheckOAuthState and CheckOIDCNonce", func() {
|
||||
It("checks that hashed versions match", func() {
|
||||
privateCSRF.OAuthState = []byte(csrfState)
|
||||
privateCSRF.OIDCNonce = []byte(csrfNonce)
|
||||
|
||||
stateHashed := encryption.HashNonce([]byte(csrfState))
|
||||
nonceHashed := encryption.HashNonce([]byte(csrfNonce))
|
||||
|
||||
Expect(publicCSRF.CheckOAuthState(stateHashed)).To(BeTrue())
|
||||
Expect(publicCSRF.CheckOIDCNonce(nonceHashed)).To(BeTrue())
|
||||
|
||||
Expect(publicCSRF.CheckOAuthState(csrfNonce)).To(BeFalse())
|
||||
Expect(publicCSRF.CheckOIDCNonce(csrfState)).To(BeFalse())
|
||||
Expect(publicCSRF.CheckOAuthState(csrfState + csrfNonce)).To(BeFalse())
|
||||
Expect(publicCSRF.CheckOIDCNonce(csrfNonce + csrfState)).To(BeFalse())
|
||||
Expect(publicCSRF.CheckOAuthState("")).To(BeFalse())
|
||||
Expect(publicCSRF.CheckOIDCNonce("")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Context("SetSessionNonce", func() {
|
||||
It("sets the session.Nonce", func() {
|
||||
session := &sessions.SessionState{}
|
||||
publicCSRF.SetSessionNonce(session)
|
||||
Expect(session.Nonce).To(Equal(privateCSRF.OIDCNonce))
|
||||
})
|
||||
})
|
||||
|
||||
Context("encodeCookie and decodeCSRFCookie", func() {
|
||||
It("encodes and decodes to the same nonces", func() {
|
||||
privateCSRF.OAuthState = []byte(csrfState)
|
||||
privateCSRF.OIDCNonce = []byte(csrfNonce)
|
||||
|
||||
encoded, err := privateCSRF.encodeCookie()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
cookie := &http.Cookie{
|
||||
Name: privateCSRF.cookieName(),
|
||||
Value: encoded,
|
||||
}
|
||||
decoded, err := decodeCSRFCookie(cookie, cookieOpts)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(decoded).ToNot(BeNil())
|
||||
Expect(decoded.OAuthState).To(Equal([]byte(csrfState)))
|
||||
Expect(decoded.OIDCNonce).To(Equal([]byte(csrfNonce)))
|
||||
})
|
||||
|
||||
It("signs the encoded cookie value", func() {
|
||||
encoded, err := privateCSRF.encodeCookie()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
cookie := &http.Cookie{
|
||||
Name: privateCSRF.cookieName(),
|
||||
Value: encoded,
|
||||
}
|
||||
|
||||
_, _, valid := encryption.Validate(cookie, cookieOpts.Secret, cookieOpts.Expire)
|
||||
Expect(valid).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Context("Cookie Management", func() {
|
||||
var req *http.Request
|
||||
|
||||
testNow := time.Unix(nowEpoch, 0)
|
||||
|
||||
BeforeEach(func() {
|
||||
privateCSRF.time.Set(testNow)
|
||||
|
||||
req = &http.Request{
|
||||
Method: http.MethodGet,
|
||||
Proto: "HTTP/1.1",
|
||||
Host: cookieDomain,
|
||||
|
||||
URL: &url.URL{
|
||||
Scheme: "https",
|
||||
Host: cookieDomain,
|
||||
Path: cookiePath,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
privateCSRF.time.Reset()
|
||||
})
|
||||
|
||||
Context("SetCookie", func() {
|
||||
It("adds the encoded CSRF cookie to a ResponseWriter", func() {
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
_, err := publicCSRF.SetCookie(rw, req)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(rw.Header().Get("Set-Cookie")).To(ContainSubstring(
|
||||
fmt.Sprintf("%s=", privateCSRF.cookieName()),
|
||||
))
|
||||
Expect(rw.Header().Get("Set-Cookie")).To(ContainSubstring(
|
||||
fmt.Sprintf(
|
||||
"; Path=%s; Domain=%s; Expires=%s; HttpOnly; Secure",
|
||||
cookiePath,
|
||||
cookieDomain,
|
||||
testCookieExpires(testNow.Add(cookieOpts.Expire)),
|
||||
),
|
||||
))
|
||||
})
|
||||
})
|
||||
|
||||
Context("ClearCookie", func() {
|
||||
It("sets a cookie with an empty value in the past", func() {
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
publicCSRF.ClearCookie(rw, req)
|
||||
|
||||
Expect(rw.Header().Get("Set-Cookie")).To(Equal(
|
||||
fmt.Sprintf(
|
||||
"%s=; Path=%s; Domain=%s; Expires=%s; HttpOnly; Secure",
|
||||
privateCSRF.cookieName(),
|
||||
cookiePath,
|
||||
cookieDomain,
|
||||
testCookieExpires(testNow.Add(time.Hour*-1)),
|
||||
),
|
||||
))
|
||||
})
|
||||
})
|
||||
|
||||
Context("cookieName", func() {
|
||||
It("has the cookie options name as a base", func() {
|
||||
Expect(privateCSRF.cookieName()).To(ContainSubstring(cookieName))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
+28
-8
@@ -1,17 +1,37 @@
|
||||
package encryption
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"encoding/base64"
|
||||
|
||||
"golang.org/x/crypto/blake2b"
|
||||
)
|
||||
|
||||
// Nonce generates a random 16 byte string to be used as a nonce
|
||||
func Nonce() (nonce string, err error) {
|
||||
b := make([]byte, 16)
|
||||
_, err = rand.Read(b)
|
||||
// Nonce generates a random 32-byte slice to be used as a nonce
|
||||
func Nonce() ([]byte, error) {
|
||||
b := make([]byte, 32)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
nonce = fmt.Sprintf("%x", b)
|
||||
return
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// HashNonce returns the BLAKE2b 256-bit hash of a nonce
|
||||
// NOTE: Error checking (G104) is purposefully skipped:
|
||||
// - `blake2b.New256` has no error path with a nil signing key
|
||||
// - `hash.Hash` interface's `Write` has an error signature, but
|
||||
// `blake2b.digest.Write` does not use it.
|
||||
/* #nosec G104 */
|
||||
func HashNonce(nonce []byte) string {
|
||||
hasher, _ := blake2b.New256(nil)
|
||||
hasher.Write(nonce)
|
||||
sum := hasher.Sum(nil)
|
||||
return base64.RawURLEncoding.EncodeToString(sum)
|
||||
}
|
||||
|
||||
// CheckNonce tests if a nonce matches the hashed version of it
|
||||
func CheckNonce(nonce []byte, hashed string) bool {
|
||||
return hmac.Equal([]byte(HashNonce(nonce)), []byte(hashed))
|
||||
}
|
||||
|
||||
@@ -264,6 +264,7 @@ func parseProviderInfo(o *options.Options, msgs []string) []string {
|
||||
p.SetTeam(o.Providers[0].BitbucketConfig.Team)
|
||||
p.SetRepository(o.Providers[0].BitbucketConfig.Repository)
|
||||
case *providers.OIDCProvider:
|
||||
p.SkipNonce = o.Providers[0].OIDCConfig.InsecureSkipNonce
|
||||
if p.Verifier == nil {
|
||||
msgs = append(msgs, "oidc provider requires an oidc issuer URL")
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package validation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -50,10 +51,11 @@ func validateRedisSessionStore(o *options.Options) []string {
|
||||
return []string{fmt.Sprintf("unable to initialize a redis client: %v", err)}
|
||||
}
|
||||
|
||||
nonce, err := encryption.Nonce()
|
||||
n, err := encryption.Nonce()
|
||||
if err != nil {
|
||||
return []string{fmt.Sprintf("unable to generate a redis initialization test key: %v", err)}
|
||||
}
|
||||
nonce := base64.RawURLEncoding.EncodeToString(n)
|
||||
|
||||
key := fmt.Sprintf("%s-healthcheck-%s", o.Cookie.Name, nonce)
|
||||
return sendRedisConnectionTest(client, key, nonce)
|
||||
|
||||
Reference in New Issue
Block a user