mirror of
https://github.com/oauth2-proxy/oauth2-proxy.git
synced 2025-01-08 04:03:58 +02:00
63727103db
You must explicitly configure oauth2-proxy (alpha config only) with which parameters are allowed to pass through, and optionally provide an allow-list of valid values and/or regular expressions for each one. Note that this mechanism subsumes the functionality of the "prompt", "approval_prompt" and "acr_values" legacy configuration options, which must be converted to the equivalent YAML when running in alpha config mode.
615 lines
17 KiB
Go
615 lines
17 KiB
Go
package providers
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
|
|
"github.com/stretchr/testify/assert"
|
|
|
|
"github.com/coreos/go-oidc/v3/oidc"
|
|
"github.com/golang-jwt/jwt"
|
|
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
|
|
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/encryption"
|
|
internaloidc "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/providers/oidc"
|
|
. "github.com/onsi/gomega"
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
const (
|
|
idToken = "eyJfoobar123.eyJbaz987.IDToken"
|
|
accessToken = "eyJfoobar123.eyJbaz987.AccessToken"
|
|
refreshToken = "eyJfoobar123.eyJbaz987.RefreshToken"
|
|
|
|
oidcIssuer = "https://issuer.example.com"
|
|
oidcClientID = "https://test.myapp.com"
|
|
oidcSecret = "SuperSecret123456789"
|
|
oidcNonce = "abcde12345edcba09876abcde12345ff"
|
|
|
|
failureTokenID = "this-id-fails-verification"
|
|
)
|
|
|
|
var (
|
|
verified = true
|
|
unverified = false
|
|
|
|
standardClaims = jwt.StandardClaims{
|
|
Audience: oidcClientID,
|
|
ExpiresAt: time.Now().Add(time.Duration(5) * time.Minute).Unix(),
|
|
Id: "id-some-id",
|
|
IssuedAt: time.Now().Unix(),
|
|
Issuer: oidcIssuer,
|
|
NotBefore: 0,
|
|
Subject: "123456789",
|
|
}
|
|
|
|
defaultIDToken = idTokenClaims{
|
|
Name: "Jane Dobbs",
|
|
Email: "janed@me.com",
|
|
Phone: "+4798765432",
|
|
Picture: "http://mugbook.com/janed/me.jpg",
|
|
Groups: []string{"test:a", "test:b"},
|
|
Roles: []string{"test:c", "test:d"},
|
|
Verified: &verified,
|
|
Nonce: encryption.HashNonce([]byte(oidcNonce)),
|
|
StandardClaims: standardClaims,
|
|
}
|
|
|
|
numericGroupsIDToken = idTokenClaims{
|
|
Name: "Jane Dobbs",
|
|
Email: "janed@me.com",
|
|
Phone: "+4798765432",
|
|
Picture: "http://mugbook.com/janed/me.jpg",
|
|
Groups: []interface{}{1, 2, 3},
|
|
Roles: []string{"test:c", "test:d"},
|
|
Verified: &verified,
|
|
Nonce: encryption.HashNonce([]byte(oidcNonce)),
|
|
StandardClaims: standardClaims,
|
|
}
|
|
|
|
complexGroupsIDToken = idTokenClaims{
|
|
Name: "Complex Claim",
|
|
Email: "complex@claims.com",
|
|
Phone: "+5439871234",
|
|
Picture: "http://mugbook.com/complex/claims.jpg",
|
|
Groups: []interface{}{
|
|
map[string]interface{}{
|
|
"groupId": "Admin Group Id",
|
|
"roles": []string{"Admin"},
|
|
},
|
|
12345,
|
|
"Just::A::String",
|
|
},
|
|
Roles: []string{"test:simple", "test:roles"},
|
|
Verified: &verified,
|
|
StandardClaims: standardClaims,
|
|
}
|
|
|
|
unverifiedIDToken = idTokenClaims{
|
|
Name: "Mystery Man",
|
|
Email: "unverified@email.com",
|
|
Phone: "+4025205729",
|
|
Picture: "http://mugbook.com/unverified/email.jpg",
|
|
Groups: []string{"test:a", "test:b"},
|
|
Roles: []string{"test:c", "test:d"},
|
|
Verified: &unverified,
|
|
StandardClaims: standardClaims,
|
|
}
|
|
|
|
minimalIDToken = idTokenClaims{
|
|
StandardClaims: standardClaims,
|
|
}
|
|
)
|
|
|
|
type idTokenClaims struct {
|
|
Name string `json:"preferred_username,omitempty"`
|
|
Email string `json:"email,omitempty"`
|
|
Phone string `json:"phone_number,omitempty"`
|
|
Picture string `json:"picture,omitempty"`
|
|
Groups interface{} `json:"groups,omitempty"`
|
|
Roles interface{} `json:"roles,omitempty"`
|
|
Verified *bool `json:"email_verified,omitempty"`
|
|
Nonce string `json:"nonce,omitempty"`
|
|
jwt.StandardClaims
|
|
}
|
|
|
|
type mockJWKS struct{}
|
|
|
|
func (mockJWKS) VerifySignature(_ context.Context, jwt string) ([]byte, error) {
|
|
decoded, err := base64.RawURLEncoding.DecodeString(strings.Split(jwt, ".")[1])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tokenClaims := &idTokenClaims{}
|
|
err = json.Unmarshal(decoded, tokenClaims)
|
|
if err != nil || tokenClaims.Id == failureTokenID {
|
|
return nil, fmt.Errorf("the validation failed for subject [%v]", tokenClaims.Subject)
|
|
}
|
|
|
|
return decoded, nil
|
|
}
|
|
|
|
func newSignedTestIDToken(tokenClaims idTokenClaims) (string, error) {
|
|
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
|
standardClaims := jwt.NewWithClaims(jwt.SigningMethodRS256, tokenClaims)
|
|
return standardClaims.SignedString(key)
|
|
}
|
|
|
|
func newTestOauth2Token() *oauth2.Token {
|
|
return &oauth2.Token{
|
|
AccessToken: accessToken,
|
|
TokenType: "Bearer",
|
|
RefreshToken: refreshToken,
|
|
Expiry: time.Time{}.Add(time.Duration(5) * time.Second),
|
|
}
|
|
}
|
|
|
|
func TestProviderData_verifyIDToken(t *testing.T) {
|
|
failureIDToken := defaultIDToken
|
|
failureIDToken.Id = failureTokenID
|
|
|
|
testCases := map[string]struct {
|
|
IDToken *idTokenClaims
|
|
Verifier bool
|
|
ExpectIDToken bool
|
|
ExpectedError error
|
|
}{
|
|
"Valid ID Token": {
|
|
IDToken: &defaultIDToken,
|
|
Verifier: true,
|
|
ExpectIDToken: true,
|
|
ExpectedError: nil,
|
|
},
|
|
"Invalid ID Token": {
|
|
IDToken: &failureIDToken,
|
|
Verifier: true,
|
|
ExpectIDToken: false,
|
|
ExpectedError: errors.New("failed to verify token: failed to verify signature: " +
|
|
"the validation failed for subject [123456789]"),
|
|
},
|
|
"Missing ID Token": {
|
|
IDToken: nil,
|
|
Verifier: true,
|
|
ExpectIDToken: false,
|
|
ExpectedError: ErrMissingIDToken,
|
|
},
|
|
"OIDC Verifier not Configured": {
|
|
IDToken: &defaultIDToken,
|
|
Verifier: false,
|
|
ExpectIDToken: false,
|
|
ExpectedError: ErrMissingOIDCVerifier,
|
|
},
|
|
}
|
|
|
|
for testName, tc := range testCases {
|
|
t.Run(testName, func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
token := newTestOauth2Token()
|
|
if tc.IDToken != nil {
|
|
idToken, err := newSignedTestIDToken(*tc.IDToken)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
token = token.WithExtra(map[string]interface{}{
|
|
"id_token": idToken,
|
|
})
|
|
}
|
|
|
|
provider := &ProviderData{}
|
|
if tc.Verifier {
|
|
verificationOptions := internaloidc.IDTokenVerificationOptions{
|
|
AudienceClaims: []string{"aud"},
|
|
ClientID: oidcClientID,
|
|
}
|
|
provider.Verifier = internaloidc.NewVerifier(oidc.NewVerifier(
|
|
oidcIssuer,
|
|
mockJWKS{},
|
|
&oidc.Config{ClientID: oidcClientID},
|
|
), verificationOptions)
|
|
}
|
|
verified, err := provider.verifyIDToken(context.Background(), token)
|
|
if err != nil {
|
|
g.Expect(err).To(Equal(tc.ExpectedError))
|
|
}
|
|
|
|
if tc.ExpectIDToken {
|
|
g.Expect(verified).ToNot(BeNil())
|
|
g.Expect(*verified).To(BeAssignableToTypeOf(oidc.IDToken{}))
|
|
} else {
|
|
g.Expect(verified).To(BeNil())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProviderData_buildSessionFromClaims(t *testing.T) {
|
|
testCases := map[string]struct {
|
|
IDToken idTokenClaims
|
|
AllowUnverified bool
|
|
UserClaim string
|
|
EmailClaim string
|
|
GroupsClaim string
|
|
ExpectedError error
|
|
ExpectedSession *sessions.SessionState
|
|
}{
|
|
"Standard": {
|
|
IDToken: defaultIDToken,
|
|
AllowUnverified: false,
|
|
EmailClaim: "email",
|
|
GroupsClaim: "groups",
|
|
UserClaim: "sub",
|
|
ExpectedSession: &sessions.SessionState{
|
|
User: "123456789",
|
|
Email: "janed@me.com",
|
|
Groups: []string{"test:a", "test:b"},
|
|
PreferredUsername: "Jane Dobbs",
|
|
},
|
|
},
|
|
"Unverified Denied": {
|
|
IDToken: unverifiedIDToken,
|
|
AllowUnverified: false,
|
|
EmailClaim: "email",
|
|
GroupsClaim: "groups",
|
|
ExpectedError: errors.New("email in id_token (unverified@email.com) isn't verified"),
|
|
},
|
|
"Unverified Allowed": {
|
|
IDToken: unverifiedIDToken,
|
|
AllowUnverified: true,
|
|
EmailClaim: "email",
|
|
GroupsClaim: "groups",
|
|
UserClaim: "sub",
|
|
ExpectedSession: &sessions.SessionState{
|
|
User: "123456789",
|
|
Email: "unverified@email.com",
|
|
Groups: []string{"test:a", "test:b"},
|
|
PreferredUsername: "Mystery Man",
|
|
},
|
|
},
|
|
"Complex Groups": {
|
|
IDToken: complexGroupsIDToken,
|
|
AllowUnverified: true,
|
|
EmailClaim: "email",
|
|
GroupsClaim: "groups",
|
|
UserClaim: "sub",
|
|
ExpectedSession: &sessions.SessionState{
|
|
User: "123456789",
|
|
Email: "complex@claims.com",
|
|
Groups: []string{
|
|
"{\"groupId\":\"Admin Group Id\",\"roles\":[\"Admin\"]}",
|
|
"12345",
|
|
"Just::A::String",
|
|
},
|
|
PreferredUsername: "Complex Claim",
|
|
},
|
|
},
|
|
"User Claim Switched": {
|
|
IDToken: defaultIDToken,
|
|
AllowUnverified: true,
|
|
UserClaim: "phone_number",
|
|
EmailClaim: "email",
|
|
GroupsClaim: "groups",
|
|
ExpectedSession: &sessions.SessionState{
|
|
User: "+4798765432",
|
|
Email: "janed@me.com",
|
|
Groups: []string{"test:a", "test:b"},
|
|
PreferredUsername: "Jane Dobbs",
|
|
},
|
|
},
|
|
"User Claim switched to non string": {
|
|
IDToken: defaultIDToken,
|
|
AllowUnverified: true,
|
|
UserClaim: "roles",
|
|
EmailClaim: "email",
|
|
GroupsClaim: "groups",
|
|
ExpectedSession: &sessions.SessionState{
|
|
User: "[\"test:c\",\"test:d\"]",
|
|
Email: "janed@me.com",
|
|
Groups: []string{"test:a", "test:b"},
|
|
PreferredUsername: "Jane Dobbs",
|
|
},
|
|
},
|
|
"Email Claim Switched": {
|
|
IDToken: unverifiedIDToken,
|
|
AllowUnverified: true,
|
|
EmailClaim: "phone_number",
|
|
GroupsClaim: "groups",
|
|
UserClaim: "sub",
|
|
ExpectedSession: &sessions.SessionState{
|
|
User: "123456789",
|
|
Email: "+4025205729",
|
|
Groups: []string{"test:a", "test:b"},
|
|
PreferredUsername: "Mystery Man",
|
|
},
|
|
},
|
|
"Email Claim Switched to Non String": {
|
|
IDToken: unverifiedIDToken,
|
|
AllowUnverified: true,
|
|
EmailClaim: "roles",
|
|
GroupsClaim: "groups",
|
|
UserClaim: "sub",
|
|
ExpectedSession: &sessions.SessionState{
|
|
User: "123456789",
|
|
Email: "[\"test:c\",\"test:d\"]",
|
|
Groups: []string{"test:a", "test:b"},
|
|
PreferredUsername: "Mystery Man",
|
|
},
|
|
},
|
|
"Email Claim Non Existent": {
|
|
IDToken: unverifiedIDToken,
|
|
AllowUnverified: true,
|
|
EmailClaim: "aksjdfhjksadh",
|
|
GroupsClaim: "groups",
|
|
UserClaim: "sub",
|
|
ExpectedSession: &sessions.SessionState{
|
|
User: "123456789",
|
|
Email: "",
|
|
Groups: []string{"test:a", "test:b"},
|
|
PreferredUsername: "Mystery Man",
|
|
},
|
|
},
|
|
"Groups Claim Switched": {
|
|
IDToken: defaultIDToken,
|
|
AllowUnverified: false,
|
|
EmailClaim: "email",
|
|
GroupsClaim: "roles",
|
|
UserClaim: "sub",
|
|
ExpectedSession: &sessions.SessionState{
|
|
User: "123456789",
|
|
Email: "janed@me.com",
|
|
Groups: []string{"test:c", "test:d"},
|
|
PreferredUsername: "Jane Dobbs",
|
|
},
|
|
},
|
|
"Groups Claim Non Existent": {
|
|
IDToken: defaultIDToken,
|
|
AllowUnverified: false,
|
|
EmailClaim: "email",
|
|
GroupsClaim: "alskdjfsalkdjf",
|
|
UserClaim: "sub",
|
|
ExpectedSession: &sessions.SessionState{
|
|
User: "123456789",
|
|
Email: "janed@me.com",
|
|
Groups: nil,
|
|
PreferredUsername: "Jane Dobbs",
|
|
},
|
|
},
|
|
"Groups Claim Numeric values": {
|
|
IDToken: numericGroupsIDToken,
|
|
AllowUnverified: false,
|
|
EmailClaim: "email",
|
|
GroupsClaim: "groups",
|
|
UserClaim: "sub",
|
|
ExpectedSession: &sessions.SessionState{
|
|
User: "123456789",
|
|
Email: "janed@me.com",
|
|
Groups: []string{"1", "2", "3"},
|
|
PreferredUsername: "Jane Dobbs",
|
|
},
|
|
},
|
|
"Groups Claim string values": {
|
|
IDToken: defaultIDToken,
|
|
AllowUnverified: false,
|
|
EmailClaim: "email",
|
|
GroupsClaim: "email",
|
|
UserClaim: "sub",
|
|
ExpectedSession: &sessions.SessionState{
|
|
User: "123456789",
|
|
Email: "janed@me.com",
|
|
Groups: []string{"janed@me.com"},
|
|
PreferredUsername: "Jane Dobbs",
|
|
},
|
|
},
|
|
}
|
|
for testName, tc := range testCases {
|
|
t.Run(testName, func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
verificationOptions := internaloidc.IDTokenVerificationOptions{
|
|
AudienceClaims: []string{"aud"},
|
|
ClientID: oidcClientID,
|
|
}
|
|
provider := &ProviderData{
|
|
Verifier: internaloidc.NewVerifier(oidc.NewVerifier(
|
|
oidcIssuer,
|
|
mockJWKS{},
|
|
&oidc.Config{ClientID: oidcClientID},
|
|
), verificationOptions),
|
|
}
|
|
provider.AllowUnverifiedEmail = tc.AllowUnverified
|
|
provider.UserClaim = tc.UserClaim
|
|
provider.EmailClaim = tc.EmailClaim
|
|
provider.GroupsClaim = tc.GroupsClaim
|
|
|
|
rawIDToken, err := newSignedTestIDToken(tc.IDToken)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
ss, err := provider.buildSessionFromClaims(rawIDToken, "")
|
|
if err != nil {
|
|
g.Expect(err).To(Equal(tc.ExpectedError))
|
|
}
|
|
if ss != nil {
|
|
g.Expect(ss).To(Equal(tc.ExpectedSession))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProviderData_checkNonce(t *testing.T) {
|
|
testCases := map[string]struct {
|
|
Session *sessions.SessionState
|
|
IDToken idTokenClaims
|
|
ExpectedError error
|
|
}{
|
|
"Nonces match": {
|
|
Session: &sessions.SessionState{
|
|
Nonce: []byte(oidcNonce),
|
|
},
|
|
IDToken: defaultIDToken,
|
|
ExpectedError: nil,
|
|
},
|
|
"Nonces do not match": {
|
|
Session: &sessions.SessionState{
|
|
Nonce: []byte("WrongWrongWrong"),
|
|
},
|
|
IDToken: defaultIDToken,
|
|
ExpectedError: errors.New("id_token nonce claim does not match the session nonce"),
|
|
},
|
|
|
|
"Missing nonce claim": {
|
|
Session: &sessions.SessionState{
|
|
Nonce: []byte(oidcNonce),
|
|
},
|
|
IDToken: minimalIDToken,
|
|
ExpectedError: errors.New("id_token nonce claim does not match the session nonce"),
|
|
},
|
|
}
|
|
for testName, tc := range testCases {
|
|
t.Run(testName, func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
// Ensure that the ID token in the session is valid (signed and contains a nonce)
|
|
// as the nonce claim is extracted to compare with the session nonce
|
|
rawIDToken, err := newSignedTestIDToken(tc.IDToken)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
tc.Session.IDToken = rawIDToken
|
|
|
|
verificationOptions := internaloidc.IDTokenVerificationOptions{
|
|
AudienceClaims: []string{"aud"},
|
|
ClientID: oidcClientID,
|
|
}
|
|
provider := &ProviderData{
|
|
Verifier: internaloidc.NewVerifier(oidc.NewVerifier(
|
|
oidcIssuer,
|
|
mockJWKS{},
|
|
&oidc.Config{ClientID: oidcClientID},
|
|
), verificationOptions),
|
|
}
|
|
|
|
if err := provider.checkNonce(tc.Session); err != nil {
|
|
g.Expect(err).To(Equal(tc.ExpectedError))
|
|
} else {
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProviderData_loginURLParameters(t *testing.T) {
|
|
|
|
testCases := []struct {
|
|
name string
|
|
overrides url.Values
|
|
has url.Values
|
|
notHas []string
|
|
}{
|
|
{
|
|
name: "no overrides",
|
|
overrides: url.Values{},
|
|
has: url.Values{
|
|
"fixed": {"fixed-value"},
|
|
"enum_with_default": {"default-value"},
|
|
"free_with_default": {"default-value"},
|
|
},
|
|
notHas: []string{"enum_no_default", "free_no_default"},
|
|
},
|
|
{
|
|
name: "attempt to override fixed value",
|
|
overrides: url.Values{"fixed": {"another-value"}},
|
|
has: url.Values{
|
|
"fixed": {"fixed-value"},
|
|
"enum_with_default": {"default-value"},
|
|
"free_with_default": {"default-value"},
|
|
},
|
|
notHas: []string{"enum_no_default", "free_no_default"},
|
|
},
|
|
{
|
|
name: "set one allowed and one forbidden enum",
|
|
overrides: url.Values{
|
|
"enum_no_default": {"allowed1", "not-allowed"},
|
|
},
|
|
has: url.Values{
|
|
"fixed": {"fixed-value"},
|
|
"enum_with_default": {"default-value"},
|
|
"free_with_default": {"default-value"},
|
|
"enum_no_default": {"allowed1"},
|
|
},
|
|
notHas: []string{"free_no_default"},
|
|
},
|
|
{
|
|
name: "replace default value",
|
|
overrides: url.Values{"free_with_default": {"something-else"}},
|
|
has: url.Values{
|
|
"fixed": {"fixed-value"},
|
|
"enum_with_default": {"default-value"},
|
|
"free_with_default": {"something-else"},
|
|
},
|
|
notHas: []string{"enum_no_default", "free_no_default"},
|
|
},
|
|
{
|
|
name: "set free text value",
|
|
overrides: url.Values{"free_no_default": {"some-value"}},
|
|
has: url.Values{
|
|
"fixed": {"fixed-value"},
|
|
"enum_with_default": {"default-value"},
|
|
"free_with_default": {"default-value"},
|
|
"free_no_default": {"some-value"},
|
|
},
|
|
notHas: []string{"enum_no_default"},
|
|
},
|
|
{
|
|
name: "attempt to set unapproved parameter",
|
|
overrides: url.Values{"malicious_value": {"evil"}},
|
|
has: url.Values{
|
|
"fixed": {"fixed-value"},
|
|
"enum_with_default": {"default-value"},
|
|
"free_with_default": {"default-value"},
|
|
},
|
|
notHas: []string{"enum_no_default", "free_no_default"},
|
|
},
|
|
}
|
|
|
|
// fixed list of two allowed values
|
|
allowed1 := "allowed1"
|
|
allowed2 := "allowed2"
|
|
allowEnum := []options.URLParameterRule{
|
|
{Value: &allowed1},
|
|
{Value: &allowed2},
|
|
}
|
|
// regex that will allow anything
|
|
anything := "^.*$"
|
|
allowAnything := []options.URLParameterRule{
|
|
{Pattern: &anything},
|
|
}
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// set up LoginURLParameters for testing
|
|
data := ProviderData{}
|
|
data.compileLoginParams([]options.LoginURLParameter{
|
|
{Name: "fixed", Default: []string{"fixed-value"}},
|
|
{Name: "enum_with_default", Default: []string{"default-value"}, Allow: allowEnum},
|
|
{Name: "enum_no_default", Allow: allowEnum},
|
|
{Name: "free_with_default", Default: []string{"default-value"}, Allow: allowAnything},
|
|
{Name: "free_no_default", Allow: allowAnything},
|
|
})
|
|
|
|
redirectParams := data.LoginURLParams(tc.overrides)
|
|
for _, k := range tc.notHas {
|
|
assert.NotContains(t, redirectParams, k)
|
|
}
|
|
for k, vs := range tc.has {
|
|
actualVals := redirectParams[k]
|
|
assert.ElementsMatch(t, vs, actualVals)
|
|
}
|
|
})
|
|
}
|
|
}
|