1
0
mirror of https://github.com/oauth2-proxy/oauth2-proxy.git synced 2025-01-08 04:03:58 +02:00
oauth2-proxy/providers/provider_data_test.go
Ian Roberts 63727103db Support for passing through URL query parameters from /oauth2/start to the ID provider's login URL.
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.
2022-02-19 16:11:09 +00:00

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)
}
})
}
}