Merge branch 'main' into jtc/add-importas-linter

This commit is contained in:
Joshua Casey
2024-06-11 09:39:48 -05:00
357 changed files with 23832 additions and 1310 deletions
@@ -4,16 +4,6 @@
// Package authenticator contains helper code for dealing with *Authenticator CRDs.
package authenticator
import (
"crypto/x509"
"encoding/base64"
"fmt"
"k8s.io/client-go/util/cert"
authenticationv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/authentication/v1alpha1"
)
// Closer is a type that can be closed idempotently.
//
// This type is slightly different from io.Closer, because io.Closer can return an error and is not
@@ -21,24 +11,3 @@ import (
type Closer interface {
Close()
}
// CABundle returns a PEM-encoded CA bundle from the provided spec. If the provided spec is nil, a
// nil CA bundle will be returned. If the provided spec contains a CA bundle that is not properly
// encoded, an error will be returned.
func CABundle(spec *authenticationv1alpha1.TLSSpec) (*x509.CertPool, []byte, error) {
if spec == nil || len(spec.CertificateAuthorityData) == 0 {
return nil, nil, nil
}
pem, err := base64.StdEncoding.DecodeString(spec.CertificateAuthorityData)
if err != nil {
return nil, nil, err
}
rootCAs, err := cert.NewPoolFromBytes(pem)
if err != nil {
return nil, nil, fmt.Errorf("certificateAuthorityData is not valid PEM: %w", err)
}
return rootCAs, pem, nil
}
@@ -246,7 +246,7 @@ func (c *jwtCacheFillerController) extractValueAsJWTAuthenticator(value authncac
}
func (c *jwtCacheFillerController) validateTLS(tlsSpec *authenticationv1alpha1.TLSSpec, conditions []*metav1.Condition) (*x509.CertPool, []*metav1.Condition, bool) {
rootCAs, _, err := pinnipedauthenticator.CABundle(tlsSpec)
rootCAs, _, err := pinnipedcontroller.BuildCertPoolAuth(tlsSpec)
if err != nil {
msg := fmt.Sprintf("%s: %s", "invalid TLS configuration", err.Error())
conditions = append(conditions, &metav1.Condition{
@@ -603,7 +603,7 @@ func (c *jwtCacheFillerController) updateStatus(
})
}
_ = conditionsutil.MergeConfigConditions(
_ = conditionsutil.MergeConditions(
conditions,
original.Generation,
&updated.Status.Conditions,
@@ -28,7 +28,6 @@ import (
conciergeclientset "go.pinniped.dev/generated/latest/client/concierge/clientset/versioned"
authinformers "go.pinniped.dev/generated/latest/client/concierge/informers/externalversions/authentication/v1alpha1"
pinnipedcontroller "go.pinniped.dev/internal/controller"
pinnipedauthenticator "go.pinniped.dev/internal/controller/authenticator"
"go.pinniped.dev/internal/controller/authenticator/authncache"
"go.pinniped.dev/internal/controller/conditionsutil"
"go.pinniped.dev/internal/controllerlib"
@@ -265,7 +264,7 @@ func (c *webhookCacheFillerController) validateConnection(certPool *x509.CertPoo
}
func (c *webhookCacheFillerController) validateTLSBundle(tlsSpec *authenticationv1alpha1.TLSSpec, conditions []*metav1.Condition) (*x509.CertPool, []byte, []*metav1.Condition, bool) {
rootCAs, pemBytes, err := pinnipedauthenticator.CABundle(tlsSpec)
rootCAs, pemBytes, err := pinnipedcontroller.BuildCertPoolAuth(tlsSpec)
if err != nil {
msg := fmt.Sprintf("%s: %s", "invalid TLS configuration", err.Error())
conditions = append(conditions, &metav1.Condition{
@@ -360,7 +359,7 @@ func (c *webhookCacheFillerController) updateStatus(
})
}
_ = conditionsutil.MergeConfigConditions(
_ = conditionsutil.MergeConditions(
conditions,
original.Generation,
&updated.Status.Conditions,
@@ -12,29 +12,34 @@ import (
"go.pinniped.dev/internal/plog"
)
// MergeIDPConditions merges conditions into conditionsToUpdate. If returns true if it merged any error conditions.
func MergeIDPConditions(conditions []*metav1.Condition, observedGeneration int64, conditionsToUpdate *[]metav1.Condition, log plog.MinLogger) bool {
hadErrorCondition := false
// MergeConditions merges conditions into conditionsToUpdate.
// Note that LastTransitionTime refers to the time when the status changed,
// but ObservedGeneration should be the current generation for all conditions, since Pinniped should always check every condition.
// It returns true if any resulting condition has non-true status.
func MergeConditions(
conditions []*metav1.Condition,
observedGeneration int64,
conditionsToUpdate *[]metav1.Condition,
log plog.MinLogger,
lastTransitionTime metav1.Time,
) bool {
for i := range conditions {
cond := conditions[i].DeepCopy()
cond.LastTransitionTime = metav1.Now()
cond.LastTransitionTime = lastTransitionTime
cond.ObservedGeneration = observedGeneration
if mergeIDPCondition(conditionsToUpdate, cond) {
if mergeCondition(conditionsToUpdate, cond) {
log.Info("updated condition", "type", cond.Type, "status", cond.Status, "reason", cond.Reason, "message", cond.Message)
}
if cond.Status == metav1.ConditionFalse {
hadErrorCondition = true
}
}
sort.SliceStable(*conditionsToUpdate, func(i, j int) bool {
return (*conditionsToUpdate)[i].Type < (*conditionsToUpdate)[j].Type
})
return hadErrorCondition
return HadErrorCondition(conditions)
}
// mergeIDPCondition merges a new metav1.Condition into a slice of existing conditions. It returns true
// mergeCondition merges a new metav1.Condition into a slice of existing conditions. It returns true
// if the condition has meaningfully changed.
func mergeIDPCondition(existing *[]metav1.Condition, new *metav1.Condition) bool {
func mergeCondition(existing *[]metav1.Condition, new *metav1.Condition) bool {
// Find any existing condition with a matching type.
var old *metav1.Condition
for i := range *existing {
@@ -62,61 +67,7 @@ func mergeIDPCondition(existing *[]metav1.Condition, new *metav1.Condition) bool
return true
}
// Otherwise the entry is already up to date.
return false
}
// MergeConfigConditions merges conditions into conditionsToUpdate. It returns true if it merged any error conditions.
func MergeConfigConditions(conditions []*metav1.Condition, observedGeneration int64, conditionsToUpdate *[]metav1.Condition, log plog.MinLogger, now metav1.Time) bool {
hadErrorCondition := false
for i := range conditions {
cond := conditions[i].DeepCopy()
cond.LastTransitionTime = now
cond.ObservedGeneration = observedGeneration
if mergeConfigCondition(conditionsToUpdate, cond) {
log.Info("updated condition", "type", cond.Type, "status", cond.Status, "reason", cond.Reason, "message", cond.Message)
}
if cond.Status == metav1.ConditionFalse {
hadErrorCondition = true
}
}
sort.SliceStable(*conditionsToUpdate, func(i, j int) bool {
return (*conditionsToUpdate)[i].Type < (*conditionsToUpdate)[j].Type
})
return hadErrorCondition
}
// mergeConfigCondition merges a new metav1.Condition into a slice of existing conditions. It returns true
// if the condition has meaningfully changed.
func mergeConfigCondition(existing *[]metav1.Condition, new *metav1.Condition) bool {
// Find any existing condition with a matching type.
var old *metav1.Condition
for i := range *existing {
if (*existing)[i].Type == new.Type {
old = &(*existing)[i]
continue
}
}
// If there is no existing condition of this type, append this one and we're done.
if old == nil {
*existing = append(*existing, *new)
return true
}
// Set the LastTransitionTime depending on whether the status has changed.
new = new.DeepCopy()
if old.Status == new.Status {
new.LastTransitionTime = old.LastTransitionTime
}
// If anything has actually changed, update the entry and return true.
if !equality.Semantic.DeepEqual(old, new) {
*old = *new
return true
}
// Otherwise the entry is already up to date.
// Otherwise the entry is already up-to-date.
return false
}
@@ -0,0 +1,190 @@
// Copyright 2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package conditionsutil
import (
"bytes"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"go.pinniped.dev/internal/plog"
)
func TestMergeIDPConditions(t *testing.T) {
twoHoursAgo := metav1.Time{Time: time.Now().Add(-2 * time.Hour)}
oneHourAgo := metav1.Time{Time: time.Now().Add(-1 * time.Hour)}
testTime := metav1.Now()
tests := []struct {
name string
newConditions []*metav1.Condition
conditionsToUpdate *[]metav1.Condition
observedGeneration int64
wantResult bool
wantLogSnippets []string
wantConditions []metav1.Condition
}{
{
name: "Adding a new condition with status=True returns false",
newConditions: []*metav1.Condition{
{
Type: "NewType",
Status: metav1.ConditionTrue,
Reason: "new reason",
Message: "new message",
},
},
observedGeneration: int64(999),
conditionsToUpdate: &[]metav1.Condition{},
wantLogSnippets: []string{
`"message":"updated condition","type":"NewType","status":"True"`,
},
wantConditions: []metav1.Condition{
{
Type: "NewType",
Status: metav1.ConditionTrue,
ObservedGeneration: int64(999),
LastTransitionTime: testTime,
Reason: "new reason",
Message: "new message",
},
},
wantResult: false,
},
{
name: "Updating a condition status from False to True returns true",
newConditions: []*metav1.Condition{
{
Type: "UnchangedType",
Status: metav1.ConditionTrue,
Reason: "unchanged reason",
Message: "unchanged message",
},
{
Type: "FalseToTrueType",
Status: metav1.ConditionFalse,
Reason: "new reason",
Message: "new message",
},
{
Type: "NewType",
Status: metav1.ConditionTrue,
Reason: "new reason",
Message: "new message",
},
},
conditionsToUpdate: &[]metav1.Condition{
{
Type: "UnchangedType",
Status: metav1.ConditionTrue,
ObservedGeneration: int64(10),
LastTransitionTime: twoHoursAgo,
Reason: "unchanged reason",
Message: "unchanged message",
},
{
Type: "FalseToTrueType",
Status: metav1.ConditionTrue,
ObservedGeneration: int64(5),
LastTransitionTime: oneHourAgo,
Reason: "old reason",
Message: "old message",
},
},
observedGeneration: int64(100),
wantLogSnippets: []string{
`"message":"updated condition","type":"UnchangedType","status":"True"`,
`"message":"updated condition","type":"NewType","status":"True"`,
`"message":"updated condition","type":"FalseToTrueType","status":"False"`,
},
wantConditions: []metav1.Condition{
{
Type: "FalseToTrueType",
Status: metav1.ConditionFalse,
ObservedGeneration: int64(100),
LastTransitionTime: testTime,
Reason: "new reason",
Message: "new message",
},
{
Type: "NewType",
Status: metav1.ConditionTrue,
ObservedGeneration: int64(100),
LastTransitionTime: testTime,
Reason: "new reason",
Message: "new message",
},
{
Type: "UnchangedType",
Status: metav1.ConditionTrue,
ObservedGeneration: int64(100),
LastTransitionTime: twoHoursAgo,
Reason: "unchanged reason",
Message: "unchanged message",
},
},
wantResult: true,
},
{
name: "No logs when ObservedGeneration is unchanged",
newConditions: []*metav1.Condition{
{
Type: "UnchangedType",
Status: metav1.ConditionFalse,
Reason: "unchanged reason",
Message: "unchanged message",
},
},
conditionsToUpdate: &[]metav1.Condition{
{
Type: "UnchangedType",
Status: metav1.ConditionFalse,
ObservedGeneration: int64(10),
LastTransitionTime: twoHoursAgo,
Reason: "unchanged reason",
Message: "unchanged message",
},
},
observedGeneration: int64(10),
wantConditions: []metav1.Condition{
{
Type: "UnchangedType",
Status: metav1.ConditionFalse,
ObservedGeneration: int64(10),
LastTransitionTime: twoHoursAgo,
Reason: "unchanged reason",
Message: "unchanged message",
},
},
wantResult: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var log bytes.Buffer
logger := plog.TestLogger(t, &log)
result := MergeConditions(
tt.newConditions,
tt.observedGeneration,
tt.conditionsToUpdate,
logger,
testTime,
)
logString := log.String()
require.Equal(t, len(tt.wantLogSnippets), strings.Count(logString, "\n"))
for _, wantLog := range tt.wantLogSnippets {
require.Contains(t, logString, wantLog)
}
require.Equal(t, tt.wantResult, result)
require.Equal(t, tt.wantConditions, *tt.conditionsToUpdate)
})
}
}
@@ -344,7 +344,7 @@ func (c *activeDirectoryWatcherController) validateUpstream(ctx context.Context,
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){
"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID"),
},
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{
pwdLastSetAttribute: attributeUnchangedSinceLogin(pwdLastSetAttribute),
userAccountControlAttribute: validUserAccountControl,
userAccountControlComputedAttribute: validComputedUserAccountControl,
@@ -368,7 +368,7 @@ func (c *activeDirectoryWatcherController) updateStatus(ctx context.Context, ups
log := plog.WithValues("namespace", upstream.Namespace, "name", upstream.Name)
updated := upstream.DeepCopy()
hadErrorCondition := conditionsutil.MergeIDPConditions(conditions, upstream.Generation, &updated.Status.Conditions, log)
hadErrorCondition := conditionsutil.MergeConditions(conditions, upstream.Generation, &updated.Status.Conditions, log, metav1.Now())
updated.Status.Phase = idpv1alpha1.ActiveDirectoryPhaseReady
if hadErrorCondition {
@@ -445,7 +445,7 @@ func getDomainFromDistinguishedName(distinguishedName string) (string, error) {
}
//nolint:gochecknoglobals // this needs to be a global variable so that tests can check pointer equality
var validUserAccountControl = func(entry *ldap.Entry, _ upstreamprovider.RefreshAttributes) error {
var validUserAccountControl = func(entry *ldap.Entry, _ upstreamprovider.LDAPRefreshAttributes) error {
userAccountControl, err := strconv.Atoi(entry.GetAttributeValue(userAccountControlAttribute))
if err != nil {
return err
@@ -459,7 +459,7 @@ var validUserAccountControl = func(entry *ldap.Entry, _ upstreamprovider.Refresh
}
//nolint:gochecknoglobals // this needs to be a global variable so that tests can check pointer equality
var validComputedUserAccountControl = func(entry *ldap.Entry, _ upstreamprovider.RefreshAttributes) error {
var validComputedUserAccountControl = func(entry *ldap.Entry, _ upstreamprovider.LDAPRefreshAttributes) error {
userAccountControl, err := strconv.Atoi(entry.GetAttributeValue(userAccountControlComputedAttribute))
if err != nil {
return err
@@ -473,8 +473,8 @@ var validComputedUserAccountControl = func(entry *ldap.Entry, _ upstreamprovider
}
//nolint:gochecknoglobals // this needs to be a global variable so that tests can check pointer equality
var attributeUnchangedSinceLogin = func(attribute string) func(*ldap.Entry, upstreamprovider.RefreshAttributes) error {
return func(entry *ldap.Entry, storedAttributes upstreamprovider.RefreshAttributes) error {
var attributeUnchangedSinceLogin = func(attribute string) func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error {
return func(entry *ldap.Entry, storedAttributes upstreamprovider.LDAPRefreshAttributes) error {
prevAttributeValue := storedAttributes.AdditionalAttributes[attribute]
newValues := entry.GetRawAttributeValues(attribute)
@@ -228,7 +228,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
GroupNameAttribute: testGroupSearchNameAttrName,
},
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")},
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{
"pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"),
"userAccountControl": validUserAccountControl,
"msDS-User-Account-Control-Computed": validComputedUserAccountControl,
@@ -571,7 +571,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
GroupNameAttribute: testGroupSearchNameAttrName,
},
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")},
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{
"pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"),
"userAccountControl": validUserAccountControl,
"msDS-User-Account-Control-Computed": validComputedUserAccountControl,
@@ -641,7 +641,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
GroupNameAttribute: "sAMAccountName",
},
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")},
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{
"pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"),
"userAccountControl": validUserAccountControl,
"msDS-User-Account-Control-Computed": validComputedUserAccountControl,
@@ -714,7 +714,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
GroupNameAttribute: testGroupSearchNameAttrName,
},
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")},
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{
"pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"),
"userAccountControl": validUserAccountControl,
"msDS-User-Account-Control-Computed": validComputedUserAccountControl,
@@ -794,7 +794,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
GroupNameAttribute: testGroupSearchNameAttrName,
},
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")},
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{
"pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"),
"userAccountControl": validUserAccountControl,
"msDS-User-Account-Control-Computed": validComputedUserAccountControl,
@@ -858,7 +858,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
GroupNameAttribute: testGroupSearchNameAttrName,
},
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")},
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{
"pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"),
"userAccountControl": validUserAccountControl,
"msDS-User-Account-Control-Computed": validComputedUserAccountControl,
@@ -1009,7 +1009,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
GroupNameAttribute: testGroupSearchNameAttrName,
},
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")},
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{
"pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"),
"userAccountControl": validUserAccountControl,
"msDS-User-Account-Control-Computed": validComputedUserAccountControl,
@@ -1159,7 +1159,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
GroupNameAttribute: testGroupSearchNameAttrName,
},
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")},
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{
"pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"),
"userAccountControl": validUserAccountControl,
"msDS-User-Account-Control-Computed": validComputedUserAccountControl,
@@ -1231,7 +1231,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
GroupNameAttribute: testGroupSearchNameAttrName,
},
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")},
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{
"pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"),
"userAccountControl": validUserAccountControl,
"msDS-User-Account-Control-Computed": validComputedUserAccountControl,
@@ -1498,7 +1498,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
},
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")},
GroupAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"sAMAccountName": groupSAMAccountNameWithDomainSuffix},
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{
"pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"),
"userAccountControl": validUserAccountControl,
"msDS-User-Account-Control-Computed": validComputedUserAccountControl,
@@ -1558,7 +1558,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
GroupNameAttribute: testGroupSearchNameAttrName,
},
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")},
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{
"pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"),
"userAccountControl": validUserAccountControl,
"msDS-User-Account-Control-Computed": validComputedUserAccountControl,
@@ -1622,7 +1622,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
GroupNameAttribute: testGroupSearchNameAttrName,
},
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")},
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{
"pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"),
"userAccountControl": validUserAccountControl,
"msDS-User-Account-Control-Computed": validComputedUserAccountControl,
@@ -1686,7 +1686,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
GroupNameAttribute: testGroupSearchNameAttrName,
},
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")},
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{
"pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"),
"userAccountControl": validUserAccountControl,
"msDS-User-Account-Control-Computed": validComputedUserAccountControl,
@@ -1898,7 +1898,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
GroupNameAttribute: testGroupSearchNameAttrName,
},
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")},
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{
"pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"),
"userAccountControl": validUserAccountControl,
"msDS-User-Account-Control-Computed": validComputedUserAccountControl,
@@ -1961,7 +1961,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
SkipGroupRefresh: true,
},
UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")},
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{
"pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"),
"userAccountControl": validUserAccountControl,
"msDS-User-Account-Control-Computed": validComputedUserAccountControl,
@@ -2102,8 +2102,8 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) {
expectedRefreshAttributeChecks := copyOfExpectedValueForResultingCache.RefreshAttributeChecks
actualRefreshAttributeChecks := actualConfig.RefreshAttributeChecks
copyOfExpectedValueForResultingCache.RefreshAttributeChecks = map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{}
actualConfig.RefreshAttributeChecks = map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{}
copyOfExpectedValueForResultingCache.RefreshAttributeChecks = map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{}
actualConfig.RefreshAttributeChecks = map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{}
require.Equal(t, len(expectedRefreshAttributeChecks), len(actualRefreshAttributeChecks))
for k, v := range expectedRefreshAttributeChecks {
require.NotNil(t, actualRefreshAttributeChecks[k])
@@ -2352,7 +2352,7 @@ func TestValidUserAccountControl(t *testing.T) {
for _, test := range tests {
tt := test
t.Run(tt.name, func(t *testing.T) {
err := validUserAccountControl(tt.entry, upstreamprovider.RefreshAttributes{})
err := validUserAccountControl(tt.entry, upstreamprovider.LDAPRefreshAttributes{})
if tt.wantErr != "" {
require.Error(t, err)
@@ -2413,7 +2413,7 @@ func TestValidComputedUserAccountControl(t *testing.T) {
for _, test := range tests {
tt := test
t.Run(tt.name, func(t *testing.T) {
err := validComputedUserAccountControl(tt.entry, upstreamprovider.RefreshAttributes{})
err := validComputedUserAccountControl(tt.entry, upstreamprovider.LDAPRefreshAttributes{})
if tt.wantErr != "" {
require.Error(t, err)
@@ -2488,7 +2488,7 @@ func TestAttributeUnchangedSinceLogin(t *testing.T) {
tt := test
t.Run(tt.name, func(t *testing.T) {
initialValRawEncoded := base64.RawURLEncoding.EncodeToString([]byte(initialVal))
err := attributeUnchangedSinceLogin(attributeName)(tt.entry, upstreamprovider.RefreshAttributes{AdditionalAttributes: map[string]string{attributeName: initialValRawEncoded}})
err := attributeUnchangedSinceLogin(attributeName)(tt.entry, upstreamprovider.LDAPRefreshAttributes{AdditionalAttributes: map[string]string{attributeName: initialValRawEncoded}})
if tt.wantErr != "" {
require.Error(t, err)
require.Equal(t, tt.wantErr, err.Error())
@@ -67,6 +67,7 @@ const (
kindLDAPIdentityProvider = "LDAPIdentityProvider"
kindOIDCIdentityProvider = "OIDCIdentityProvider"
kindActiveDirectoryIdentityProvider = "ActiveDirectoryIdentityProvider"
kindGitHubIdentityProvider = "GitHubIdentityProvider"
celTransformerMaxExpressionRuntime = 5 * time.Second
)
@@ -88,6 +89,7 @@ type federationDomainWatcherController struct {
oidcIdentityProviderInformer idpinformers.OIDCIdentityProviderInformer
ldapIdentityProviderInformer idpinformers.LDAPIdentityProviderInformer
activeDirectoryIdentityProviderInformer idpinformers.ActiveDirectoryIdentityProviderInformer
githubIdentityProviderInformer idpinformers.GitHubIdentityProviderInformer
celTransformer *celtransformer.CELTransformer
allowedKinds sets.Set[string]
@@ -104,9 +106,10 @@ func NewFederationDomainWatcherController(
oidcIdentityProviderInformer idpinformers.OIDCIdentityProviderInformer,
ldapIdentityProviderInformer idpinformers.LDAPIdentityProviderInformer,
activeDirectoryIdentityProviderInformer idpinformers.ActiveDirectoryIdentityProviderInformer,
githubProviderInformer idpinformers.GitHubIdentityProviderInformer,
withInformer pinnipedcontroller.WithInformerOptionFunc,
) controllerlib.Controller {
allowedKinds := sets.New(kindActiveDirectoryIdentityProvider, kindLDAPIdentityProvider, kindOIDCIdentityProvider)
allowedKinds := sets.New(kindActiveDirectoryIdentityProvider, kindLDAPIdentityProvider, kindOIDCIdentityProvider, kindGitHubIdentityProvider)
return controllerlib.New(
controllerlib.Config{
Name: controllerName,
@@ -119,6 +122,7 @@ func NewFederationDomainWatcherController(
oidcIdentityProviderInformer: oidcIdentityProviderInformer,
ldapIdentityProviderInformer: ldapIdentityProviderInformer,
activeDirectoryIdentityProviderInformer: activeDirectoryIdentityProviderInformer,
githubIdentityProviderInformer: githubProviderInformer,
allowedKinds: allowedKinds,
},
},
@@ -148,6 +152,13 @@ func NewFederationDomainWatcherController(
pinnipedcontroller.MatchAnythingIgnoringUpdatesFilter(pinnipedcontroller.SingletonQueue()),
controllerlib.InformerOption{},
),
withInformer(
githubProviderInformer,
// Since this controller only cares about IDP metadata names and UIDs (immutable fields),
// we only need to trigger Sync on creates and deletes.
pinnipedcontroller.MatchAnythingIgnoringUpdatesFilter(pinnipedcontroller.SingletonQueue()),
controllerlib.InformerOption{},
),
)
}
@@ -264,9 +275,12 @@ func (c *federationDomainWatcherController) makeLegacyFederationDomainIssuer(
if err != nil {
return nil, nil, err
}
githubIdentityProviders, err := c.githubIdentityProviderInformer.Lister().List(labels.Everything())
if err != nil {
return nil, nil, err
}
// Check if that there is exactly one IDP defined in the Supervisor namespace of any IDP CRD type.
idpCRsCount := len(oidcIdentityProviders) + len(ldapIdentityProviders) + len(activeDirectoryIdentityProviders)
idpCRsCount := len(oidcIdentityProviders) + len(ldapIdentityProviders) + len(activeDirectoryIdentityProviders) + len(githubIdentityProviders)
switch {
case idpCRsCount == 1:
@@ -286,6 +300,10 @@ func (c *federationDomainWatcherController) makeLegacyFederationDomainIssuer(
defaultFederationDomainIdentityProvider.DisplayName = activeDirectoryIdentityProviders[0].Name
defaultFederationDomainIdentityProvider.UID = activeDirectoryIdentityProviders[0].UID
foundIDPName = activeDirectoryIdentityProviders[0].Name
case len(githubIdentityProviders) == 1:
defaultFederationDomainIdentityProvider.DisplayName = githubIdentityProviders[0].Name
defaultFederationDomainIdentityProvider.UID = githubIdentityProviders[0].UID
foundIDPName = githubIdentityProviders[0].Name
}
// Backwards compatibility mode always uses an empty identity transformation pipeline since no
// transformations are defined on the FederationDomain.
@@ -446,6 +464,8 @@ func (c *federationDomainWatcherController) findIDPsUIDByObjectRef(objectRef cor
foundIDP, err = c.activeDirectoryIdentityProviderInformer.Lister().ActiveDirectoryIdentityProviders(namespace).Get(objectRef.Name)
case kindOIDCIdentityProvider:
foundIDP, err = c.oidcIdentityProviderInformer.Lister().OIDCIdentityProviders(namespace).Get(objectRef.Name)
case kindGitHubIdentityProvider:
foundIDP, err = c.githubIdentityProviderInformer.Lister().GitHubIdentityProviders(namespace).Get(objectRef.Name)
default:
// This shouldn't happen because this helper function is not called when the kind is invalid.
return "", false, fmt.Errorf("unexpected kind: %s", objectRef.Kind)
@@ -813,7 +833,7 @@ func (c *federationDomainWatcherController) updateStatus(
})
}
_ = conditionsutil.MergeConfigConditions(conditions,
_ = conditionsutil.MergeConditions(conditions,
federationDomain.Generation, &updated.Status.Conditions, plog.New().WithName(controllerName), metav1.NewTime(c.clock.Now()))
if equality.Semantic.DeepEqual(federationDomain, updated) {
@@ -42,6 +42,7 @@ func TestFederationDomainWatcherControllerInformerFilters(t *testing.T) {
oidcIdentityProviderInformer := supervisorinformers.NewSharedInformerFactoryWithOptions(nil, 0).IDP().V1alpha1().OIDCIdentityProviders()
ldapIdentityProviderInformer := supervisorinformers.NewSharedInformerFactoryWithOptions(nil, 0).IDP().V1alpha1().LDAPIdentityProviders()
adIdentityProviderInformer := supervisorinformers.NewSharedInformerFactoryWithOptions(nil, 0).IDP().V1alpha1().ActiveDirectoryIdentityProviders()
githubIdentityProviderInformer := supervisorinformers.NewSharedInformerFactoryWithOptions(nil, 0).IDP().V1alpha1().GitHubIdentityProviders()
tests := []struct {
name string
@@ -82,6 +83,13 @@ func TestFederationDomainWatcherControllerInformerFilters(t *testing.T) {
wantAdd: true,
wantUpdate: false,
wantDelete: true,
}, {
name: "any GitHubIdentityProvider adds or deletes, but updates are ignored",
obj: &idpv1alpha1.GitHubIdentityProvider{},
informer: githubIdentityProviderInformer,
wantAdd: true,
wantUpdate: false,
wantDelete: true,
},
}
for _, test := range tests {
@@ -99,6 +107,7 @@ func TestFederationDomainWatcherControllerInformerFilters(t *testing.T) {
oidcIdentityProviderInformer,
ldapIdentityProviderInformer,
adIdentityProviderInformer,
githubIdentityProviderInformer,
withInformer.WithInformer, // make it possible to observe the behavior of the Filters
)
@@ -162,6 +171,14 @@ func TestTestFederationDomainWatcherControllerSync(t *testing.T) {
},
}
gitHubIdentityProvider := &idpv1alpha1.GitHubIdentityProvider{
ObjectMeta: metav1.ObjectMeta{
Name: "some-github-idp",
Namespace: namespace,
UID: "some-github-idp",
},
}
federationDomain1 := &supervisorconfigv1alpha1.FederationDomain{
ObjectMeta: metav1.ObjectMeta{Name: "config1", Namespace: namespace, Generation: 123},
Spec: supervisorconfigv1alpha1.FederationDomainSpec{Issuer: "https://issuer1.com"},
@@ -486,7 +503,7 @@ func TestTestFederationDomainWatcherControllerSync(t *testing.T) {
LastTransitionTime: time,
Reason: "KindUnrecognized",
Message: fmt.Sprintf(`some kinds specified by .spec.identityProviders[].objectRef.kind are `+
`not recognized (should be one of "ActiveDirectoryIdentityProvider", "LDAPIdentityProvider", "OIDCIdentityProvider"): %s`, badKinds),
`not recognized (should be one of "ActiveDirectoryIdentityProvider", "GitHubIdentityProvider", "LDAPIdentityProvider", "OIDCIdentityProvider"): %s`, badKinds),
}
}
@@ -603,6 +620,30 @@ func TestTestFederationDomainWatcherControllerSync(t *testing.T) {
),
},
},
{
name: "legacy config: when no identity provider is specified on federation domains, but exactly one GitHub identity " +
"provider resource exists on cluster, the controller will set a default IDP on each federation domain " +
"matching the only identity provider found",
inputObjects: []runtime.Object{
federationDomain1,
federationDomain2,
gitHubIdentityProvider,
},
wantFDIssuers: []*federationdomainproviders.FederationDomainIssuer{
federationDomainIssuerWithDefaultIDP(t, federationDomain1.Spec.Issuer, gitHubIdentityProvider.ObjectMeta),
federationDomainIssuerWithDefaultIDP(t, federationDomain2.Spec.Issuer, gitHubIdentityProvider.ObjectMeta),
},
wantStatusUpdates: []*configv1alpha1.FederationDomain{
expectedFederationDomainStatusUpdate(federationDomain1,
configv1alpha1.FederationDomainPhaseReady,
allHappyConditionsLegacyConfigurationSuccess(federationDomain1.Spec.Issuer, gitHubIdentityProvider.Name, frozenMetav1Now, 123),
),
expectedFederationDomainStatusUpdate(federationDomain2,
configv1alpha1.FederationDomainPhaseReady,
allHappyConditionsLegacyConfigurationSuccess(federationDomain2.Spec.Issuer, gitHubIdentityProvider.Name, frozenMetav1Now, 123),
),
},
},
{
name: "when there are two valid FederationDomains, but one is already up to date, the sync loop only updates " +
"the out-of-date FederationDomain",
@@ -947,6 +988,7 @@ func TestTestFederationDomainWatcherControllerSync(t *testing.T) {
oidcIdentityProvider,
ldapIdentityProvider,
adIdentityProvider,
gitHubIdentityProvider,
},
wantFDIssuers: []*federationdomainproviders.FederationDomainIssuer{},
wantStatusUpdates: []*supervisorconfigv1alpha1.FederationDomain{
@@ -955,7 +997,7 @@ func TestTestFederationDomainWatcherControllerSync(t *testing.T) {
conditionstestutil.Replace(
allHappyConditionsLegacyConfigurationSuccess(federationDomain1.Spec.Issuer, "", frozenMetav1Now, 123),
[]metav1.Condition{
sadIdentityProvidersFoundConditionIdentityProviderNotSpecified(3, frozenMetav1Now, 123),
sadIdentityProvidersFoundConditionIdentityProviderNotSpecified(4, frozenMetav1Now, 123),
sadReadyCondition(frozenMetav1Now, 123),
}),
),
@@ -993,6 +1035,14 @@ func TestTestFederationDomainWatcherControllerSync(t *testing.T) {
Name: "cant-find-me-still-name",
},
},
{
DisplayName: "cant-find-me-again",
ObjectRef: corev1.TypedLocalObjectReference{
APIGroup: ptr.To(apiGroupSupervisor),
Kind: "GitHubIdentityProvider",
Name: "cant-find-me-again-name",
},
},
},
},
},
@@ -1012,7 +1062,9 @@ func TestTestFederationDomainWatcherControllerSync(t *testing.T) {
cannot find resource specified by .spec.identityProviders[1].objectRef (with name "cant-find-me-either-name")
cannot find resource specified by .spec.identityProviders[2].objectRef (with name "cant-find-me-still-name")`,
cannot find resource specified by .spec.identityProviders[2].objectRef (with name "cant-find-me-still-name")
cannot find resource specified by .spec.identityProviders[3].objectRef (with name "cant-find-me-again-name")`,
), frozenMetav1Now, 123),
sadReadyCondition(frozenMetav1Now, 123),
}),
@@ -1025,6 +1077,7 @@ func TestTestFederationDomainWatcherControllerSync(t *testing.T) {
oidcIdentityProvider,
ldapIdentityProvider,
adIdentityProvider,
gitHubIdentityProvider,
&supervisorconfigv1alpha1.FederationDomain{
ObjectMeta: metav1.ObjectMeta{Name: "config1", Namespace: namespace, Generation: 123},
Spec: supervisorconfigv1alpha1.FederationDomainSpec{
@@ -1054,6 +1107,14 @@ func TestTestFederationDomainWatcherControllerSync(t *testing.T) {
Name: adIdentityProvider.Name,
},
},
{
DisplayName: "can-find-me-four",
ObjectRef: corev1.TypedLocalObjectReference{
APIGroup: ptr.To(apiGroupSupervisor),
Kind: "GitHubIdentityProvider",
Name: gitHubIdentityProvider.Name,
},
},
},
},
},
@@ -1076,6 +1137,11 @@ func TestTestFederationDomainWatcherControllerSync(t *testing.T) {
UID: adIdentityProvider.UID,
Transforms: idtransform.NewTransformationPipeline(),
},
{
DisplayName: "can-find-me-four",
UID: gitHubIdentityProvider.UID,
Transforms: idtransform.NewTransformationPipeline(),
},
}),
},
wantStatusUpdates: []*supervisorconfigv1alpha1.FederationDomain{
@@ -1094,6 +1160,7 @@ func TestTestFederationDomainWatcherControllerSync(t *testing.T) {
oidcIdentityProvider,
ldapIdentityProvider,
adIdentityProvider,
gitHubIdentityProvider,
&supervisorconfigv1alpha1.FederationDomain{
ObjectMeta: metav1.ObjectMeta{Name: "config1", Namespace: namespace, Generation: 123},
Spec: supervisorconfigv1alpha1.FederationDomainSpec{
@@ -1147,6 +1214,14 @@ func TestTestFederationDomainWatcherControllerSync(t *testing.T) {
Name: adIdentityProvider.Name,
},
},
{
DisplayName: "duplicate2",
ObjectRef: corev1.TypedLocalObjectReference{
APIGroup: ptr.To(apiGroupSupervisor),
Kind: "GitHubIdentityProvider",
Name: gitHubIdentityProvider.Name,
},
},
},
},
},
@@ -1173,6 +1248,7 @@ func TestTestFederationDomainWatcherControllerSync(t *testing.T) {
oidcIdentityProvider,
ldapIdentityProvider,
adIdentityProvider,
gitHubIdentityProvider,
&supervisorconfigv1alpha1.FederationDomain{
ObjectMeta: metav1.ObjectMeta{Name: "config1", Namespace: namespace, Generation: 123},
Spec: supervisorconfigv1alpha1.FederationDomainSpec{
@@ -1210,6 +1286,14 @@ func TestTestFederationDomainWatcherControllerSync(t *testing.T) {
Name: adIdentityProvider.Name,
},
},
{
DisplayName: "name5",
ObjectRef: corev1.TypedLocalObjectReference{
APIGroup: ptr.To(apiGroupSupervisor), // correct
Kind: "GitHubIdentityProvider",
Name: gitHubIdentityProvider.Name,
},
},
},
},
},
@@ -1243,6 +1327,7 @@ func TestTestFederationDomainWatcherControllerSync(t *testing.T) {
oidcIdentityProvider,
ldapIdentityProvider,
adIdentityProvider,
gitHubIdentityProvider,
&supervisorconfigv1alpha1.FederationDomain{
ObjectMeta: metav1.ObjectMeta{Name: "config1", Namespace: namespace, Generation: 123},
Spec: supervisorconfigv1alpha1.FederationDomainSpec{
@@ -2022,6 +2107,7 @@ func TestTestFederationDomainWatcherControllerSync(t *testing.T) {
pinnipedInformers.IDP().V1alpha1().OIDCIdentityProviders(),
pinnipedInformers.IDP().V1alpha1().LDAPIdentityProviders(),
pinnipedInformers.IDP().V1alpha1().ActiveDirectoryIdentityProviders(),
pinnipedInformers.IDP().V1alpha1().GitHubIdentityProviders(),
controllerlib.WithInformer,
)
@@ -0,0 +1,511 @@
// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
// Package githubupstreamwatcher implements a controller which watches GitHubIdentityProviders.
package githubupstreamwatcher
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net"
"net/http"
"slices"
"strings"
"golang.org/x/oauth2"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/equality"
k8sapierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
errorsutil "k8s.io/apimachinery/pkg/util/errors"
k8sutilerrors "k8s.io/apimachinery/pkg/util/errors"
corev1informers "k8s.io/client-go/informers/core/v1"
"k8s.io/utils/clock"
"go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1"
supervisorclientset "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned"
idpinformers "go.pinniped.dev/generated/latest/client/supervisor/informers/externalversions/idp/v1alpha1"
pinnipedcontroller "go.pinniped.dev/internal/controller"
"go.pinniped.dev/internal/controller/conditionsutil"
"go.pinniped.dev/internal/controller/supervisorconfig/upstreamwatchers"
"go.pinniped.dev/internal/controllerlib"
"go.pinniped.dev/internal/crypto/ptls"
"go.pinniped.dev/internal/endpointaddr"
"go.pinniped.dev/internal/federationdomain/upstreamprovider"
"go.pinniped.dev/internal/net/phttp"
"go.pinniped.dev/internal/plog"
"go.pinniped.dev/internal/setutil"
"go.pinniped.dev/internal/upstreamgithub"
)
const (
controllerName = "github-upstream-observer"
// Constants related to the client credentials Secret.
gitHubClientSecretType corev1.SecretType = "secrets.pinniped.dev/github-client"
clientIDDataKey, clientSecretDataKey string = "clientID", "clientSecret"
countExpectedConditions = 6
HostValid string = "HostValid"
TLSConfigurationValid string = "TLSConfigurationValid"
OrganizationsPolicyValid string = "OrganizationsPolicyValid"
ClientCredentialsSecretValid string = "ClientCredentialsSecretValid" //nolint:gosec // this is not a credential
GitHubConnectionValid string = "GitHubConnectionValid"
ClaimsValid string = "ClaimsValid"
defaultHost = "github.com"
defaultApiBaseURL = "https://api.github.com"
)
// UpstreamGitHubIdentityProviderICache is a thread safe cache that holds a list of validated upstream GitHub IDP configurations.
type UpstreamGitHubIdentityProviderICache interface {
SetGitHubIdentityProviders([]upstreamprovider.UpstreamGithubIdentityProviderI)
}
type gitHubWatcherController struct {
namespace string
cache UpstreamGitHubIdentityProviderICache
log plog.Logger
client supervisorclientset.Interface
gitHubIdentityProviderInformer idpinformers.GitHubIdentityProviderInformer
secretInformer corev1informers.SecretInformer
clock clock.Clock
dialFunc func(network, addr string, config *tls.Config) (*tls.Conn, error)
}
// New instantiates a new controllerlib.Controller which will populate the provided UpstreamGitHubIdentityProviderICache.
func New(
namespace string,
idpCache UpstreamGitHubIdentityProviderICache,
client supervisorclientset.Interface,
gitHubIdentityProviderInformer idpinformers.GitHubIdentityProviderInformer,
secretInformer corev1informers.SecretInformer,
log plog.Logger,
withInformer pinnipedcontroller.WithInformerOptionFunc,
clock clock.Clock,
dialFunc func(network, addr string, config *tls.Config) (*tls.Conn, error),
) controllerlib.Controller {
c := gitHubWatcherController{
namespace: namespace,
cache: idpCache,
client: client,
log: log.WithName(controllerName),
gitHubIdentityProviderInformer: gitHubIdentityProviderInformer,
secretInformer: secretInformer,
clock: clock,
dialFunc: dialFunc,
}
return controllerlib.New(
controllerlib.Config{Name: controllerName, Syncer: &c},
withInformer(
gitHubIdentityProviderInformer,
pinnipedcontroller.SimpleFilter(func(obj metav1.Object) bool {
gitHubIDP, ok := obj.(*v1alpha1.GitHubIdentityProvider)
return ok && gitHubIDP.Namespace == namespace
}, pinnipedcontroller.SingletonQueue()),
controllerlib.InformerOption{},
),
withInformer(
secretInformer,
pinnipedcontroller.MatchAnySecretOfTypeFilter(gitHubClientSecretType, pinnipedcontroller.SingletonQueue(), namespace),
controllerlib.InformerOption{},
),
)
}
// Sync implements controllerlib.Syncer.
func (c *gitHubWatcherController) Sync(ctx controllerlib.Context) error {
actualUpstreams, err := c.gitHubIdentityProviderInformer.Lister().List(labels.Everything())
if err != nil { // untested
return fmt.Errorf("failed to list GitHubIdentityProviders: %w", err)
}
// Sort them by name just so that the logs output is consistent
slices.SortStableFunc(actualUpstreams, func(a, b *v1alpha1.GitHubIdentityProvider) int {
return strings.Compare(a.Name, b.Name)
})
var applicationErrors []error
validatedUpstreams := make([]upstreamprovider.UpstreamGithubIdentityProviderI, 0, len(actualUpstreams))
for _, upstream := range actualUpstreams {
validatedUpstream, applicationErr := c.validateUpstreamAndUpdateConditions(ctx, upstream)
if applicationErr != nil {
applicationErrors = append(applicationErrors, applicationErr)
} else if validatedUpstream != nil {
validatedUpstreams = append(validatedUpstreams, validatedUpstream)
}
// Else:
// If both validatedUpstream and applicationErr are nil, this must be because the upstream had configuration errors.
// This controller should take no action until the user has reconfigured the upstream.
}
c.cache.SetGitHubIdentityProviders(validatedUpstreams)
// If we have recoverable application errors, let's do a requeue and capture all the applicationErrors too
if len(applicationErrors) > 0 {
applicationErrors = append([]error{controllerlib.ErrSyntheticRequeue}, applicationErrors...)
}
return errorsutil.NewAggregate(applicationErrors)
}
func (c *gitHubWatcherController) validateClientSecret(secretName string) (*metav1.Condition, string, string, error) {
secret, unableToRetrieveSecretErr := c.secretInformer.Lister().Secrets(c.namespace).Get(secretName)
// This error requires user interaction, so ignore it.
if k8sapierrors.IsNotFound(unableToRetrieveSecretErr) {
unableToRetrieveSecretErr = nil
}
buildFalseCondition := func(prefix string) (*metav1.Condition, string, string, error) {
return &metav1.Condition{
Type: ClientCredentialsSecretValid,
Status: metav1.ConditionFalse,
Reason: upstreamwatchers.ReasonNotFound,
Message: fmt.Sprintf("%s: secret from spec.client.SecretName (%q) must be found in namespace %q with type %q and keys %q and %q",
prefix,
secretName,
c.namespace,
gitHubClientSecretType,
clientIDDataKey,
clientSecretDataKey),
}, "", "", unableToRetrieveSecretErr
}
if unableToRetrieveSecretErr != nil || secret == nil {
return buildFalseCondition(fmt.Sprintf("secret %q not found", secretName))
}
if secret.Type != gitHubClientSecretType {
return buildFalseCondition(fmt.Sprintf("wrong secret type %q", secret.Type))
}
clientID := string(secret.Data[clientIDDataKey])
if len(clientID) < 1 {
return buildFalseCondition(fmt.Sprintf("missing key %q", clientIDDataKey))
}
clientSecret := string(secret.Data[clientSecretDataKey])
if len(clientSecret) < 1 {
return buildFalseCondition(fmt.Sprintf("missing key %q", clientSecretDataKey))
}
if len(secret.Data) != 2 {
return buildFalseCondition("extra keys found")
}
return &metav1.Condition{
Type: ClientCredentialsSecretValid,
Status: metav1.ConditionTrue,
Reason: upstreamwatchers.ReasonSuccess,
Message: fmt.Sprintf("clientID and clientSecret have been read from spec.client.SecretName (%q)", secretName),
}, clientID, clientSecret, nil
}
func validateOrganizationsPolicy(organizationsSpec *v1alpha1.GitHubOrganizationsSpec) *metav1.Condition {
var policy v1alpha1.GitHubAllowedAuthOrganizationsPolicy
if organizationsSpec.Policy != nil {
policy = *organizationsSpec.Policy
}
// Should not happen due to CRD defaulting, enum validation, and CEL validation (for recent versions of K8s only!)
// That is why the message here is very minimal
if (policy == v1alpha1.GitHubAllowedAuthOrganizationsPolicyAllGitHubUsers && len(organizationsSpec.Allowed) == 0) ||
(policy == v1alpha1.GitHubAllowedAuthOrganizationsPolicyOnlyUsersFromAllowedOrganizations && len(organizationsSpec.Allowed) > 0) {
return &metav1.Condition{
Type: OrganizationsPolicyValid,
Status: metav1.ConditionTrue,
Reason: upstreamwatchers.ReasonSuccess,
Message: fmt.Sprintf("spec.allowAuthentication.organizations.policy (%q) is valid", policy),
}
}
if len(organizationsSpec.Allowed) > 0 {
return &metav1.Condition{
Type: OrganizationsPolicyValid,
Status: metav1.ConditionFalse,
Reason: "Invalid",
Message: "spec.allowAuthentication.organizations.policy must be 'OnlyUsersFromAllowedOrganizations' when spec.allowAuthentication.organizations.allowed has organizations listed",
}
}
return &metav1.Condition{
Type: OrganizationsPolicyValid,
Status: metav1.ConditionFalse,
Reason: "Invalid",
Message: "spec.allowAuthentication.organizations.policy must be 'AllGitHubUsers' when spec.allowAuthentication.organizations.allowed is empty",
}
}
func (c *gitHubWatcherController) validateUpstreamAndUpdateConditions(ctx controllerlib.Context, upstream *v1alpha1.GitHubIdentityProvider) (
*upstreamgithub.Provider, // If validated, returns the config
error, // This error will only refer to programmatic errors such as inability to perform a Dial or dereference a pointer, not configuration errors
) {
conditions := make([]*metav1.Condition, 0)
applicationErrors := make([]error, 0)
clientSecretCondition, clientID, clientSecret, clientSecretErr := c.validateClientSecret(upstream.Spec.Client.SecretName)
conditions = append(conditions, clientSecretCondition)
if clientSecretErr != nil { // untested
applicationErrors = append(applicationErrors, clientSecretErr)
}
// Should there be some sort of catch-all condition to capture this?
// This does not actually prevent a GitHub IDP from being added to the cache.
// CRD defaulting and validation should eliminate the possibility of an error here.
userAndGroupCondition, groupNameAttribute, usernameAttribute := validateUserAndGroupAttributes(upstream)
conditions = append(conditions, userAndGroupCondition)
organizationPolicyCondition := validateOrganizationsPolicy(&upstream.Spec.AllowAuthentication.Organizations)
conditions = append(conditions, organizationPolicyCondition)
hostCondition, hostPort := validateHost(upstream.Spec.GitHubAPI)
conditions = append(conditions, hostCondition)
tlsConfigCondition, certPool := c.validateTLSConfiguration(upstream.Spec.GitHubAPI.TLS)
conditions = append(conditions, tlsConfigCondition)
githubConnectionCondition, hostURL, httpClient, githubConnectionErr := c.validateGitHubConnection(
hostPort,
certPool,
hostCondition.Status == metav1.ConditionTrue && tlsConfigCondition.Status == metav1.ConditionTrue,
)
if githubConnectionErr != nil {
applicationErrors = append(applicationErrors, githubConnectionErr)
}
conditions = append(conditions, githubConnectionCondition)
// The critical pattern to maintain is that every run of the sync loop will populate the exact number of the exact
// same set of conditions. Conditions depending on other conditions should get Status: metav1.ConditionUnknown, or
// Status: metav1.ConditionFalse, never be omitted.
if len(conditions) != countExpectedConditions { // untested since all code paths return the same number of conditions
applicationErrors = append(applicationErrors, fmt.Errorf("expected %d conditions but found %d conditions", countExpectedConditions, len(conditions)))
return nil, k8sutilerrors.NewAggregate(applicationErrors)
}
hadErrorCondition, updateStatusErr := c.updateStatus(ctx.Context, upstream, conditions)
if updateStatusErr != nil {
applicationErrors = append(applicationErrors, updateStatusErr)
}
// Any error condition means we will not add the IDP to the cache, so just return nil here
if hadErrorCondition {
return nil, k8sutilerrors.NewAggregate(applicationErrors)
}
provider := upstreamgithub.New(
upstreamgithub.ProviderConfig{
Name: upstream.Name,
ResourceUID: upstream.UID,
APIBaseURL: apiBaseUrl(*upstream.Spec.GitHubAPI.Host, hostURL),
GroupNameAttribute: groupNameAttribute,
UsernameAttribute: usernameAttribute,
OAuth2Config: &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
// See https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps
Endpoint: oauth2.Endpoint{
AuthURL: fmt.Sprintf("%s/login/oauth/authorize", hostURL),
DeviceAuthURL: "", // we do not use device code flow
TokenURL: fmt.Sprintf("%s/login/oauth/access_token", hostURL),
AuthStyle: oauth2.AuthStyleInParams,
},
RedirectURL: "", // this will be different for each FederationDomain, so we do not set it here
Scopes: []string{"read:user", "read:org"},
},
AllowedOrganizations: setutil.NewCaseInsensitiveSet(upstream.Spec.AllowAuthentication.Organizations.Allowed...),
HttpClient: httpClient,
},
)
return provider, k8sutilerrors.NewAggregate(applicationErrors)
}
func apiBaseUrl(upstreamSpecHost string, hostURL string) string {
if upstreamSpecHost != defaultHost {
return fmt.Sprintf("%s/api/v3", hostURL)
}
return defaultApiBaseURL
}
func validateHost(gitHubAPIConfig v1alpha1.GitHubAPIConfig) (*metav1.Condition, *endpointaddr.HostPort) {
buildInvalidHost := func(host, reason string) *metav1.Condition {
return &metav1.Condition{
Type: HostValid,
Status: metav1.ConditionFalse,
Reason: "InvalidHost",
Message: fmt.Sprintf("spec.githubAPI.host (%q) is not valid: %s", host, reason),
}
}
// Should not happen due to CRD defaulting
if gitHubAPIConfig.Host == nil || len(*gitHubAPIConfig.Host) < 1 {
return buildInvalidHost("", "must not be empty"), nil
}
host := *gitHubAPIConfig.Host
hostPort, addressParseErr := endpointaddr.Parse(host, 443)
if addressParseErr != nil {
// addressParseErr is not recoverable. It requires user interaction, so do not return the error.
return buildInvalidHost(host, addressParseErr.Error()), nil
}
return &metav1.Condition{
Type: HostValid,
Status: metav1.ConditionTrue,
Reason: upstreamwatchers.ReasonSuccess,
Message: fmt.Sprintf("spec.githubAPI.host (%q) is valid", host),
}, &hostPort
}
func (c *gitHubWatcherController) validateTLSConfiguration(tlsSpec *v1alpha1.TLSSpec) (*metav1.Condition, *x509.CertPool) {
certPool, _, buildCertPoolErr := pinnipedcontroller.BuildCertPoolIDP(tlsSpec)
if buildCertPoolErr != nil {
// buildCertPoolErr is not recoverable with a resync.
// It requires user interaction, so do not return the error.
return &metav1.Condition{
Type: TLSConfigurationValid,
Status: metav1.ConditionFalse,
Reason: "InvalidTLSConfig",
Message: fmt.Sprintf("spec.githubAPI.tls.certificateAuthorityData is not valid: %s", buildCertPoolErr),
}, nil
}
return &metav1.Condition{
Type: TLSConfigurationValid,
Status: metav1.ConditionTrue,
Reason: upstreamwatchers.ReasonSuccess,
Message: "spec.githubAPI.tls.certificateAuthorityData is valid",
}, certPool
}
func (c *gitHubWatcherController) validateGitHubConnection(
hostPort *endpointaddr.HostPort,
certPool *x509.CertPool,
validSoFar bool,
) (*metav1.Condition, string, *http.Client, error) {
if !validSoFar {
return &metav1.Condition{
Type: GitHubConnectionValid,
Status: metav1.ConditionUnknown,
Reason: "UnableToValidate",
Message: "unable to validate; see other conditions for details",
}, "", nil, nil
}
conn, tlsDialErr := c.dialFunc("tcp", hostPort.Endpoint(), ptls.Default(certPool))
if tlsDialErr != nil {
return &metav1.Condition{
Type: GitHubConnectionValid,
Status: metav1.ConditionFalse,
Reason: "UnableToDialServer",
Message: fmt.Sprintf("cannot dial server spec.githubAPI.host (%q): %s", hostPort.Endpoint(), buildDialErrorMessage(tlsDialErr)),
}, "", nil, tlsDialErr
}
return &metav1.Condition{
Type: GitHubConnectionValid,
Status: metav1.ConditionTrue,
Reason: upstreamwatchers.ReasonSuccess,
Message: fmt.Sprintf("spec.githubAPI.host (%q) is reachable and TLS verification succeeds", hostPort.Endpoint()),
}, fmt.Sprintf("https://%s", hostPort.Endpoint()), phttp.Default(certPool), conn.Close()
}
// buildDialErrorMessage standardizes DNS error messages that appear differently on different platforms, so that tests and log grepping is uniform.
func buildDialErrorMessage(tlsDialErr error) string {
reason := tlsDialErr.Error()
var opError *net.OpError
var dnsError *net.DNSError
if errors.As(tlsDialErr, &opError) && errors.As(tlsDialErr, &dnsError) {
dnsError.Server = ""
opError.Err = dnsError
return opError.Error()
}
return reason
}
func validateUserAndGroupAttributes(upstream *v1alpha1.GitHubIdentityProvider) (*metav1.Condition, v1alpha1.GitHubGroupNameAttribute, v1alpha1.GitHubUsernameAttribute) {
buildInvalidCondition := func(message string) *metav1.Condition {
return &metav1.Condition{
Type: ClaimsValid,
Status: metav1.ConditionFalse,
Reason: "Invalid",
Message: message,
}
}
var usernameAttribute v1alpha1.GitHubUsernameAttribute
if upstream.Spec.Claims.Username == nil {
return buildInvalidCondition("spec.claims.username is required"), "", ""
} else {
usernameAttribute = *upstream.Spec.Claims.Username
}
var groupNameAttribute v1alpha1.GitHubGroupNameAttribute
if upstream.Spec.Claims.Groups == nil {
return buildInvalidCondition("spec.claims.groups is required"), "", ""
} else {
groupNameAttribute = *upstream.Spec.Claims.Groups
}
switch usernameAttribute {
case v1alpha1.GitHubUsernameLoginAndID:
case v1alpha1.GitHubUsernameLogin:
case v1alpha1.GitHubUsernameID:
default:
// Should not happen due to CRD enum validation
return buildInvalidCondition(fmt.Sprintf("spec.claims.username (%q) is not valid", usernameAttribute)), "", ""
}
switch groupNameAttribute {
case v1alpha1.GitHubUseTeamNameForGroupName:
case v1alpha1.GitHubUseTeamSlugForGroupName:
default:
// Should not happen due to CRD enum validation
return buildInvalidCondition(fmt.Sprintf("spec.claims.groups (%q) is not valid", groupNameAttribute)), "", ""
}
return &metav1.Condition{
Type: ClaimsValid,
Status: metav1.ConditionTrue,
Reason: upstreamwatchers.ReasonSuccess,
Message: "spec.claims are valid",
}, groupNameAttribute, usernameAttribute
}
func (c *gitHubWatcherController) updateStatus(
ctx context.Context,
upstream *v1alpha1.GitHubIdentityProvider,
conditions []*metav1.Condition) (bool, error) {
log := c.log.WithValues("namespace", upstream.Namespace, "name", upstream.Name)
updated := upstream.DeepCopy()
hadErrorCondition := conditionsutil.MergeConditions(
conditions,
upstream.Generation,
&updated.Status.Conditions,
log,
metav1.NewTime(c.clock.Now()),
)
updated.Status.Phase = v1alpha1.GitHubPhaseReady
if hadErrorCondition {
updated.Status.Phase = v1alpha1.GitHubPhaseError
}
if equality.Semantic.DeepEqual(upstream, updated) {
return hadErrorCondition, nil
}
log.Info("updating GitHubIdentityProvider status", "phase", updated.Status.Phase)
_, updateStatusError := c.client.
IDPV1alpha1().
GitHubIdentityProviders(upstream.Namespace).
UpdateStatus(ctx, updated, metav1.UpdateOptions{})
return hadErrorCondition, updateStatusError
}
@@ -260,7 +260,7 @@ func (c *ldapWatcherController) updateStatus(ctx context.Context, upstream *idpv
log := plog.WithValues("namespace", upstream.Namespace, "name", upstream.Name)
updated := upstream.DeepCopy()
hadErrorCondition := conditionsutil.MergeIDPConditions(conditions, upstream.Generation, &updated.Status.Conditions, log)
hadErrorCondition := conditionsutil.MergeConditions(conditions, upstream.Generation, &updated.Status.Conditions, log, metav1.Now())
updated.Status.Phase = idpv1alpha1.LDAPPhaseReady
if hadErrorCondition {
@@ -133,7 +133,7 @@ func (c *oidcClientWatcherController) updateStatus(
) error {
updated := upstream.DeepCopy()
hadErrorCondition := conditionsutil.MergeConfigConditions(conditions,
hadErrorCondition := conditionsutil.MergeConditions(conditions,
upstream.Generation, &updated.Status.Conditions, plog.New(), metav1.Now())
updated.Status.Phase = supervisorconfigv1alpha1.OIDCClientPhaseReady
@@ -54,7 +54,7 @@ const (
oidcValidatorCacheTTL = 15 * time.Minute
// Constants related to conditions.
typeClientCredentialsValid = "ClientCredentialsValid" //nolint:gosec // this is not a credential
typeClientCredentialsSecretValid = "ClientCredentialsSecretValid" //nolint:gosec // this is not a credential
typeAdditionalAuthorizeParametersValid = "AdditionalAuthorizeParametersValid"
typeOIDCDiscoverySucceeded = "OIDCDiscoverySucceeded"
@@ -260,7 +260,7 @@ func (c *oidcWatcherController) validateUpstream(ctx controllerlib.Context, upst
return nil
}
// validateSecret validates the .spec.client.secretName field and returns the appropriate ClientCredentialsValid condition.
// validateSecret validates the .spec.client.secretName field and returns the appropriate ClientCredentialsSecretValid condition.
func (c *oidcWatcherController) validateSecret(upstream *idpv1alpha1.OIDCIdentityProvider, result *upstreamoidc.ProviderConfig) *metav1.Condition {
secretName := upstream.Spec.Client.SecretName
@@ -268,7 +268,7 @@ func (c *oidcWatcherController) validateSecret(upstream *idpv1alpha1.OIDCIdentit
secret, err := c.secretInformer.Lister().Secrets(upstream.Namespace).Get(secretName)
if err != nil {
return &metav1.Condition{
Type: typeClientCredentialsValid,
Type: typeClientCredentialsSecretValid,
Status: metav1.ConditionFalse,
Reason: upstreamwatchers.ReasonNotFound,
Message: err.Error(),
@@ -278,7 +278,7 @@ func (c *oidcWatcherController) validateSecret(upstream *idpv1alpha1.OIDCIdentit
// Validate the secret .type field.
if secret.Type != oidcClientSecretType {
return &metav1.Condition{
Type: typeClientCredentialsValid,
Type: typeClientCredentialsSecretValid,
Status: metav1.ConditionFalse,
Reason: upstreamwatchers.ReasonWrongType,
Message: fmt.Sprintf("referenced Secret %q has wrong type %q (should be %q)", secretName, secret.Type, oidcClientSecretType),
@@ -290,7 +290,7 @@ func (c *oidcWatcherController) validateSecret(upstream *idpv1alpha1.OIDCIdentit
clientSecret := secret.Data[clientSecretDataKey]
if len(clientID) == 0 || len(clientSecret) == 0 {
return &metav1.Condition{
Type: typeClientCredentialsValid,
Type: typeClientCredentialsSecretValid,
Status: metav1.ConditionFalse,
Reason: upstreamwatchers.ReasonMissingKeys,
Message: fmt.Sprintf("referenced Secret %q is missing required keys %q", secretName, []string{clientIDDataKey, clientSecretDataKey}),
@@ -301,7 +301,7 @@ func (c *oidcWatcherController) validateSecret(upstream *idpv1alpha1.OIDCIdentit
result.Config.ClientID = string(clientID)
result.Config.ClientSecret = string(clientSecret)
return &metav1.Condition{
Type: typeClientCredentialsValid,
Type: typeClientCredentialsSecretValid,
Status: metav1.ConditionTrue,
Reason: upstreamwatchers.ReasonSuccess,
Message: "loaded client credentials",
@@ -412,7 +412,7 @@ func (c *oidcWatcherController) updateStatus(ctx context.Context, upstream *idpv
log := c.log.WithValues("namespace", upstream.Namespace, "name", upstream.Name)
updated := upstream.DeepCopy()
hadErrorCondition := conditionsutil.MergeIDPConditions(conditions, upstream.Generation, &updated.Status.Conditions, log)
hadErrorCondition := conditionsutil.MergeConditions(conditions, upstream.Generation, &updated.Status.Conditions, log, metav1.Now())
updated.Status.Phase = idpv1alpha1.PhaseReady
if hadErrorCondition {
@@ -174,10 +174,10 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) {
inputSecrets: []runtime.Object{},
wantErr: controllerlib.ErrSyntheticRequeue.Error(),
wantLogs: []string{
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="secret \"test-client-secret\" not found" "reason"="SecretNotFound" "status"="False" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="secret \"test-client-secret\" not found" "reason"="SecretNotFound" "status"="False" "type"="ClientCredentialsSecretValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="discovered issuer configuration" "reason"="Success" "status"="True" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="secret \"test-client-secret\" not found" "name"="test-name" "namespace"="test-namespace" "reason"="SecretNotFound" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="secret \"test-client-secret\" not found" "name"="test-name" "namespace"="test-namespace" "reason"="SecretNotFound" "type"="ClientCredentialsSecretValid"`,
},
wantResultingCache: []*oidctestutil.TestUpstreamOIDCIdentityProvider{},
wantResultingUpstreams: []idpv1alpha1.OIDCIdentityProvider{{
@@ -187,7 +187,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) {
Conditions: []metav1.Condition{
happyAdditionalAuthorizeParametersValidCondition,
{
Type: "ClientCredentialsValid",
Type: "ClientCredentialsSecretValid",
Status: "False",
LastTransitionTime: now,
Reason: "SecretNotFound",
@@ -221,10 +221,10 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) {
}},
wantErr: controllerlib.ErrSyntheticRequeue.Error(),
wantLogs: []string{
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="referenced Secret \"test-client-secret\" has wrong type \"some-other-type\" (should be \"secrets.pinniped.dev/oidc-client\")" "reason"="SecretWrongType" "status"="False" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="referenced Secret \"test-client-secret\" has wrong type \"some-other-type\" (should be \"secrets.pinniped.dev/oidc-client\")" "reason"="SecretWrongType" "status"="False" "type"="ClientCredentialsSecretValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="discovered issuer configuration" "reason"="Success" "status"="True" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="referenced Secret \"test-client-secret\" has wrong type \"some-other-type\" (should be \"secrets.pinniped.dev/oidc-client\")" "name"="test-name" "namespace"="test-namespace" "reason"="SecretWrongType" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="referenced Secret \"test-client-secret\" has wrong type \"some-other-type\" (should be \"secrets.pinniped.dev/oidc-client\")" "name"="test-name" "namespace"="test-namespace" "reason"="SecretWrongType" "type"="ClientCredentialsSecretValid"`,
},
wantResultingCache: []*oidctestutil.TestUpstreamOIDCIdentityProvider{},
wantResultingUpstreams: []idpv1alpha1.OIDCIdentityProvider{{
@@ -234,7 +234,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) {
Conditions: []metav1.Condition{
happyAdditionalAuthorizeParametersValidCondition,
{
Type: "ClientCredentialsValid",
Type: "ClientCredentialsSecretValid",
Status: "False",
LastTransitionTime: now,
Reason: "SecretWrongType",
@@ -267,10 +267,10 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) {
}},
wantErr: controllerlib.ErrSyntheticRequeue.Error(),
wantLogs: []string{
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="referenced Secret \"test-client-secret\" is missing required keys [\"clientID\" \"clientSecret\"]" "reason"="SecretMissingKeys" "status"="False" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="referenced Secret \"test-client-secret\" is missing required keys [\"clientID\" \"clientSecret\"]" "reason"="SecretMissingKeys" "status"="False" "type"="ClientCredentialsSecretValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="discovered issuer configuration" "reason"="Success" "status"="True" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="referenced Secret \"test-client-secret\" is missing required keys [\"clientID\" \"clientSecret\"]" "name"="test-name" "namespace"="test-namespace" "reason"="SecretMissingKeys" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="referenced Secret \"test-client-secret\" is missing required keys [\"clientID\" \"clientSecret\"]" "name"="test-name" "namespace"="test-namespace" "reason"="SecretMissingKeys" "type"="ClientCredentialsSecretValid"`,
},
wantResultingCache: []*oidctestutil.TestUpstreamOIDCIdentityProvider{},
wantResultingUpstreams: []idpv1alpha1.OIDCIdentityProvider{{
@@ -280,7 +280,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) {
Conditions: []metav1.Condition{
happyAdditionalAuthorizeParametersValidCondition,
{
Type: "ClientCredentialsValid",
Type: "ClientCredentialsSecretValid",
Status: "False",
LastTransitionTime: now,
Reason: "SecretMissingKeys",
@@ -316,7 +316,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) {
}},
wantErr: controllerlib.ErrSyntheticRequeue.Error(),
wantLogs: []string{
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsSecretValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="spec.certificateAuthorityData is invalid: illegal base64 data at input byte 7" "reason"="InvalidTLSConfig" "status"="False" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="spec.certificateAuthorityData is invalid: illegal base64 data at input byte 7" "name"="test-name" "namespace"="test-namespace" "reason"="InvalidTLSConfig" "type"="OIDCDiscoverySucceeded"`,
@@ -329,7 +329,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) {
Conditions: []metav1.Condition{
happyAdditionalAuthorizeParametersValidCondition,
{
Type: "ClientCredentialsValid",
Type: "ClientCredentialsSecretValid",
Status: "True",
LastTransitionTime: now,
Reason: "Success",
@@ -365,7 +365,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) {
}},
wantErr: controllerlib.ErrSyntheticRequeue.Error(),
wantLogs: []string{
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsSecretValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="spec.certificateAuthorityData is invalid: no certificates found" "reason"="InvalidTLSConfig" "status"="False" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="spec.certificateAuthorityData is invalid: no certificates found" "name"="test-name" "namespace"="test-namespace" "reason"="InvalidTLSConfig" "type"="OIDCDiscoverySucceeded"`,
@@ -378,7 +378,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) {
Conditions: []metav1.Condition{
happyAdditionalAuthorizeParametersValidCondition,
{
Type: "ClientCredentialsValid",
Type: "ClientCredentialsSecretValid",
Status: "True",
LastTransitionTime: now,
Reason: "Success",
@@ -411,7 +411,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) {
}},
wantErr: controllerlib.ErrSyntheticRequeue.Error(),
wantLogs: []string{
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsSecretValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="failed to parse issuer URL: parse \"%invalid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\": invalid URL escape \"%in\"" "reason"="Unreachable" "status"="False" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="failed to parse issuer URL: parse \"%invalid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\": invalid URL escape \"%in\"" "name"="test-name" "namespace"="test-namespace" "reason"="Unreachable" "type"="OIDCDiscoverySucceeded"`,
@@ -424,7 +424,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) {
Conditions: []metav1.Condition{
happyAdditionalAuthorizeParametersValidCondition,
{
Type: "ClientCredentialsValid",
Type: "ClientCredentialsSecretValid",
Status: "True",
LastTransitionTime: now,
Reason: "Success",
@@ -457,7 +457,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) {
}},
wantErr: controllerlib.ErrSyntheticRequeue.Error(),
wantLogs: []string{
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsSecretValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="issuer URL '` + strings.Replace(testIssuerURL, "https", "http", 1) + `' must have \"https\" scheme, not \"http\"" "reason"="Unreachable" "status"="False" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="issuer URL '` + strings.Replace(testIssuerURL, "https", "http", 1) + `' must have \"https\" scheme, not \"http\"" "name"="test-name" "namespace"="test-namespace" "reason"="Unreachable" "type"="OIDCDiscoverySucceeded"`,
@@ -470,7 +470,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) {
Conditions: []metav1.Condition{
happyAdditionalAuthorizeParametersValidCondition,
{
Type: "ClientCredentialsValid",
Type: "ClientCredentialsSecretValid",
Status: "True",
LastTransitionTime: now,
Reason: "Success",
@@ -503,7 +503,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) {
}},
wantErr: controllerlib.ErrSyntheticRequeue.Error(),
wantLogs: []string{
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsSecretValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="issuer URL '` + testIssuerURL + "?sub=foo" + `' cannot contain query or fragment component" "reason"="Unreachable" "status"="False" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="issuer URL '` + testIssuerURL + "?sub=foo" + `' cannot contain query or fragment component" "name"="test-name" "namespace"="test-namespace" "reason"="Unreachable" "type"="OIDCDiscoverySucceeded"`,
@@ -516,7 +516,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) {
Conditions: []metav1.Condition{
happyAdditionalAuthorizeParametersValidCondition,
{
Type: "ClientCredentialsValid",
Type: "ClientCredentialsSecretValid",
Status: "True",
LastTransitionTime: now,
Reason: "Success",
@@ -549,7 +549,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) {
}},
wantErr: controllerlib.ErrSyntheticRequeue.Error(),
wantLogs: []string{
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsSecretValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="issuer URL '` + testIssuerURL + "#fragment" + `' cannot contain query or fragment component" "reason"="Unreachable" "status"="False" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="issuer URL '` + testIssuerURL + "#fragment" + `' cannot contain query or fragment component" "name"="test-name" "namespace"="test-namespace" "reason"="Unreachable" "type"="OIDCDiscoverySucceeded"`,
@@ -562,7 +562,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) {
Conditions: []metav1.Condition{
happyAdditionalAuthorizeParametersValidCondition,
{
Type: "ClientCredentialsValid",
Type: "ClientCredentialsSecretValid",
Status: "True",
LastTransitionTime: now,
Reason: "Success",
@@ -597,7 +597,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) {
wantErr: controllerlib.ErrSyntheticRequeue.Error(),
wantLogs: []string{
`oidc-upstream-observer "msg"="failed to perform OIDC discovery" "error"="Get \"` + testIssuerURL + `/valid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee/.well-known/openid-configuration\": tls: failed to verify certificate: x509: certificate signed by unknown authority" "issuer"="` + testIssuerURL + `/valid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" "name"="test-name" "namespace"="test-namespace"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsSecretValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="failed to perform OIDC discovery against \"` + testIssuerURL + `/valid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\":\nGet \"` + testIssuerURL + `/valid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee/.well-known/openid-configuration\": tls: failed to verify certificate: x509: certificate signed by unknown authority" "reason"="Unreachable" "status"="False" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="failed to perform OIDC discovery against \"` + testIssuerURL + `/valid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\":\nGet \"` + testIssuerURL + `/valid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee/.well-known/openid-configuration\": tls: failed to verify certificate: x509: certificate signed by unknown authority" "name"="test-name" "namespace"="test-namespace" "reason"="Unreachable" "type"="OIDCDiscoverySucceeded"`,
@@ -610,7 +610,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) {
Conditions: []metav1.Condition{
happyAdditionalAuthorizeParametersValidCondition,
{
Type: "ClientCredentialsValid",
Type: "ClientCredentialsSecretValid",
Status: "True",
LastTransitionTime: now,
Reason: "Success",
@@ -645,7 +645,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
}},
wantErr: controllerlib.ErrSyntheticRequeue.Error(),
wantLogs: []string{
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsSecretValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="failed to parse authorization endpoint URL: parse \"%\": invalid URL escape \"%\"" "reason"="InvalidResponse" "status"="False" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="failed to parse authorization endpoint URL: parse \"%\": invalid URL escape \"%\"" "name"="test-name" "namespace"="test-namespace" "reason"="InvalidResponse" "type"="OIDCDiscoverySucceeded"`,
@@ -658,7 +658,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
Conditions: []metav1.Condition{
happyAdditionalAuthorizeParametersValidCondition,
{
Type: "ClientCredentialsValid",
Type: "ClientCredentialsSecretValid",
Status: "True",
LastTransitionTime: now,
Reason: "Success",
@@ -692,7 +692,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
}},
wantErr: controllerlib.ErrSyntheticRequeue.Error(),
wantLogs: []string{
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsSecretValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="failed to parse revocation endpoint URL: parse \"%\": invalid URL escape \"%\"" "reason"="InvalidResponse" "status"="False" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="failed to parse revocation endpoint URL: parse \"%\": invalid URL escape \"%\"" "name"="test-name" "namespace"="test-namespace" "reason"="InvalidResponse" "type"="OIDCDiscoverySucceeded"`,
@@ -705,7 +705,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
Conditions: []metav1.Condition{
happyAdditionalAuthorizeParametersValidCondition,
{
Type: "ClientCredentialsValid",
Type: "ClientCredentialsSecretValid",
Status: "True",
LastTransitionTime: now,
Reason: "Success",
@@ -739,7 +739,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
}},
wantErr: controllerlib.ErrSyntheticRequeue.Error(),
wantLogs: []string{
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsSecretValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="authorization endpoint URL 'http://example.com/authorize' must have \"https\" scheme, not \"http\"" "reason"="InvalidResponse" "status"="False" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="authorization endpoint URL 'http://example.com/authorize' must have \"https\" scheme, not \"http\"" "name"="test-name" "namespace"="test-namespace" "reason"="InvalidResponse" "type"="OIDCDiscoverySucceeded"`,
@@ -752,7 +752,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
Conditions: []metav1.Condition{
happyAdditionalAuthorizeParametersValidCondition,
{
Type: "ClientCredentialsValid",
Type: "ClientCredentialsSecretValid",
Status: "True",
LastTransitionTime: now,
Reason: "Success",
@@ -786,7 +786,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
}},
wantErr: controllerlib.ErrSyntheticRequeue.Error(),
wantLogs: []string{
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsSecretValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="revocation endpoint URL 'http://example.com/revoke' must have \"https\" scheme, not \"http\"" "reason"="InvalidResponse" "status"="False" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="revocation endpoint URL 'http://example.com/revoke' must have \"https\" scheme, not \"http\"" "name"="test-name" "namespace"="test-namespace" "reason"="InvalidResponse" "type"="OIDCDiscoverySucceeded"`,
@@ -799,7 +799,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
Conditions: []metav1.Condition{
happyAdditionalAuthorizeParametersValidCondition,
{
Type: "ClientCredentialsValid",
Type: "ClientCredentialsSecretValid",
Status: "True",
LastTransitionTime: now,
Reason: "Success",
@@ -833,7 +833,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
}},
wantErr: controllerlib.ErrSyntheticRequeue.Error(),
wantLogs: []string{
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsSecretValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="token endpoint URL 'http://example.com/token' must have \"https\" scheme, not \"http\"" "reason"="InvalidResponse" "status"="False" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="token endpoint URL 'http://example.com/token' must have \"https\" scheme, not \"http\"" "name"="test-name" "namespace"="test-namespace" "reason"="InvalidResponse" "type"="OIDCDiscoverySucceeded"`,
@@ -846,7 +846,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
Conditions: []metav1.Condition{
happyAdditionalAuthorizeParametersValidCondition,
{
Type: "ClientCredentialsValid",
Type: "ClientCredentialsSecretValid",
Status: "True",
LastTransitionTime: now,
Reason: "Success",
@@ -880,7 +880,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
}},
wantErr: controllerlib.ErrSyntheticRequeue.Error(),
wantLogs: []string{
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsSecretValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="token endpoint URL '' must have \"https\" scheme, not \"\"" "reason"="InvalidResponse" "status"="False" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="token endpoint URL '' must have \"https\" scheme, not \"\"" "name"="test-name" "namespace"="test-namespace" "reason"="InvalidResponse" "type"="OIDCDiscoverySucceeded"`,
@@ -893,7 +893,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
Conditions: []metav1.Condition{
happyAdditionalAuthorizeParametersValidCondition,
{
Type: "ClientCredentialsValid",
Type: "ClientCredentialsSecretValid",
Status: "True",
LastTransitionTime: now,
Reason: "Success",
@@ -927,7 +927,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
}},
wantErr: controllerlib.ErrSyntheticRequeue.Error(),
wantLogs: []string{
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsSecretValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="authorization endpoint URL '' must have \"https\" scheme, not \"\"" "reason"="InvalidResponse" "status"="False" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="authorization endpoint URL '' must have \"https\" scheme, not \"\"" "name"="test-name" "namespace"="test-namespace" "reason"="InvalidResponse" "type"="OIDCDiscoverySucceeded"`,
@@ -940,7 +940,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
Conditions: []metav1.Condition{
happyAdditionalAuthorizeParametersValidCondition,
{
Type: "ClientCredentialsValid",
Type: "ClientCredentialsSecretValid",
Status: "True",
LastTransitionTime: now,
Reason: "Success",
@@ -974,7 +974,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
Status: idpv1alpha1.OIDCIdentityProviderStatus{
Phase: "Error",
Conditions: []metav1.Condition{
{Type: "ClientCredentialsValid", Status: "False", LastTransitionTime: earlier, Reason: "SomeError1", Message: "some previous error 1"},
{Type: "ClientCredentialsSecretValid", Status: "False", LastTransitionTime: earlier, Reason: "SomeError1", Message: "some previous error 1"},
{Type: "OIDCDiscoverySucceeded", Status: "False", LastTransitionTime: earlier, Reason: "SomeError2", Message: "some previous error 2"},
},
},
@@ -985,7 +985,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
Data: testValidSecretData,
}},
wantLogs: []string{
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsSecretValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="discovered issuer configuration" "reason"="Success" "status"="True" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
},
@@ -1010,7 +1010,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
Phase: "Ready",
Conditions: []metav1.Condition{
happyAdditionalAuthorizeParametersValidCondition,
{Type: "ClientCredentialsValid", Status: "True", LastTransitionTime: now, Reason: "Success", Message: "loaded client credentials"},
{Type: "ClientCredentialsSecretValid", Status: "True", LastTransitionTime: now, Reason: "Success", Message: "loaded client credentials"},
{Type: "OIDCDiscoverySucceeded", Status: "True", LastTransitionTime: now, Reason: "Success", Message: "discovered issuer configuration"},
},
},
@@ -1030,7 +1030,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
Phase: "Ready",
Conditions: []metav1.Condition{
happyAdditionalAuthorizeParametersValidConditionEarlier,
{Type: "ClientCredentialsValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "loaded client credentials"},
{Type: "ClientCredentialsSecretValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "loaded client credentials"},
{Type: "OIDCDiscoverySucceeded", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "discovered issuer configuration"},
},
},
@@ -1041,7 +1041,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
Data: testValidSecretData,
}},
wantLogs: []string{
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsSecretValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="discovered issuer configuration" "reason"="Success" "status"="True" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
},
@@ -1066,7 +1066,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
Phase: "Ready",
Conditions: []metav1.Condition{
{Type: "AdditionalAuthorizeParametersValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "additionalAuthorizeParameters parameter names are allowed", ObservedGeneration: 1234},
{Type: "ClientCredentialsValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "loaded client credentials", ObservedGeneration: 1234},
{Type: "ClientCredentialsSecretValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "loaded client credentials", ObservedGeneration: 1234},
{Type: "OIDCDiscoverySucceeded", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "discovered issuer configuration", ObservedGeneration: 1234},
},
},
@@ -1086,7 +1086,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
Phase: "Ready",
Conditions: []metav1.Condition{
happyAdditionalAuthorizeParametersValidConditionEarlier,
{Type: "ClientCredentialsValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "loaded client credentials"},
{Type: "ClientCredentialsSecretValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "loaded client credentials"},
{Type: "OIDCDiscoverySucceeded", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "discovered issuer configuration"},
},
},
@@ -1097,7 +1097,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
Data: testValidSecretData,
}},
wantLogs: []string{
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsSecretValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="discovered issuer configuration" "reason"="Success" "status"="True" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
},
@@ -1122,7 +1122,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
Phase: "Ready",
Conditions: []metav1.Condition{
{Type: "AdditionalAuthorizeParametersValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "additionalAuthorizeParameters parameter names are allowed", ObservedGeneration: 1234},
{Type: "ClientCredentialsValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "loaded client credentials", ObservedGeneration: 1234},
{Type: "ClientCredentialsSecretValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "loaded client credentials", ObservedGeneration: 1234},
{Type: "OIDCDiscoverySucceeded", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "discovered issuer configuration", ObservedGeneration: 1234},
},
},
@@ -1145,7 +1145,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
Phase: "Ready",
Conditions: []metav1.Condition{
happyAdditionalAuthorizeParametersValidConditionEarlier,
{Type: "ClientCredentialsValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "loaded client credentials"},
{Type: "ClientCredentialsSecretValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "loaded client credentials"},
{Type: "OIDCDiscoverySucceeded", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "discovered issuer configuration"},
},
},
@@ -1156,7 +1156,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
Data: testValidSecretData,
}},
wantLogs: []string{
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsSecretValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="discovered issuer configuration" "reason"="Success" "status"="True" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
},
@@ -1181,7 +1181,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
Phase: "Ready",
Conditions: []metav1.Condition{
{Type: "AdditionalAuthorizeParametersValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "additionalAuthorizeParameters parameter names are allowed", ObservedGeneration: 1234},
{Type: "ClientCredentialsValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "loaded client credentials", ObservedGeneration: 1234},
{Type: "ClientCredentialsSecretValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "loaded client credentials", ObservedGeneration: 1234},
{Type: "OIDCDiscoverySucceeded", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "discovered issuer configuration", ObservedGeneration: 1234},
},
},
@@ -1212,7 +1212,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
Phase: "Ready",
Conditions: []metav1.Condition{
happyAdditionalAuthorizeParametersValidConditionEarlier,
{Type: "ClientCredentialsValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "loaded client credentials"},
{Type: "ClientCredentialsSecretValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "loaded client credentials"},
{Type: "OIDCDiscoverySucceeded", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "discovered issuer configuration"},
},
},
@@ -1223,7 +1223,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
Data: testValidSecretData,
}},
wantLogs: []string{
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsSecretValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="discovered issuer configuration" "reason"="Success" "status"="True" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
},
@@ -1250,7 +1250,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
Phase: "Ready",
Conditions: []metav1.Condition{
{Type: "AdditionalAuthorizeParametersValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "additionalAuthorizeParameters parameter names are allowed", ObservedGeneration: 1234},
{Type: "ClientCredentialsValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "loaded client credentials", ObservedGeneration: 1234},
{Type: "ClientCredentialsSecretValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "loaded client credentials", ObservedGeneration: 1234},
{Type: "OIDCDiscoverySucceeded", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "discovered issuer configuration", ObservedGeneration: 1234},
},
},
@@ -1287,7 +1287,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
}},
wantErr: controllerlib.ErrSyntheticRequeue.Error(),
wantLogs: []string{
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsSecretValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="discovered issuer configuration" "reason"="Success" "status"="True" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="the following additionalAuthorizeParameters are not allowed: response_type,scope,client_id,state,nonce,code_challenge,code_challenge_method,redirect_uri,hd" "reason"="DisallowedParameterName" "status"="False" "type"="AdditionalAuthorizeParametersValid"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="the following additionalAuthorizeParameters are not allowed: response_type,scope,client_id,state,nonce,code_challenge,code_challenge_method,redirect_uri,hd" "name"="test-name" "namespace"="test-namespace" "reason"="DisallowedParameterName" "type"="AdditionalAuthorizeParametersValid"`,
@@ -1301,7 +1301,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
{Type: "AdditionalAuthorizeParametersValid", Status: "False", LastTransitionTime: now, Reason: "DisallowedParameterName",
Message: "the following additionalAuthorizeParameters are not allowed: " +
"response_type,scope,client_id,state,nonce,code_challenge,code_challenge_method,redirect_uri,hd", ObservedGeneration: 1234},
{Type: "ClientCredentialsValid", Status: "True", LastTransitionTime: now, Reason: "Success", Message: "loaded client credentials", ObservedGeneration: 1234},
{Type: "ClientCredentialsSecretValid", Status: "True", LastTransitionTime: now, Reason: "Success", Message: "loaded client credentials", ObservedGeneration: 1234},
{Type: "OIDCDiscoverySucceeded", Status: "True", LastTransitionTime: now, Reason: "Success", Message: "discovered issuer configuration", ObservedGeneration: 1234},
},
},
@@ -1325,7 +1325,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
wantErr: controllerlib.ErrSyntheticRequeue.Error(),
wantLogs: []string{
`oidc-upstream-observer "msg"="failed to perform OIDC discovery" "error"="oidc: issuer did not match the issuer returned by provider, expected \"` + testIssuerURL + `/ends-with-slash\" got \"` + testIssuerURL + `/ends-with-slash/\"" "issuer"="` + testIssuerURL + `/ends-with-slash" "name"="test-name" "namespace"="test-namespace"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsSecretValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="failed to perform OIDC discovery against \"` + testIssuerURL + `/ends-with-slash\":\noidc: issuer did not match the issuer returned by provider, expected \"` + testIssuerURL + `/ends-with-slash\" got \"` + testIssuerURL + `/ends-with-slash/\"" "reason"="Unreachable" "status"="False" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="failed to perform OIDC discovery against \"` + testIssuerURL + `/ends-with-slash\":\noidc: issuer did not match the issuer returned by provider, expected \"` + testIssuerURL + `/ends-with-slash\" got \"` + testIssuerURL + `/ends-with-slash/\"" "name"="test-name" "namespace"="test-namespace" "reason"="Unreachable" "type"="OIDCDiscoverySucceeded"`,
@@ -1338,7 +1338,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana
Conditions: []metav1.Condition{
happyAdditionalAuthorizeParametersValidCondition,
{
Type: "ClientCredentialsValid",
Type: "ClientCredentialsSecretValid",
Status: "True",
LastTransitionTime: now,
Reason: "Success",
@@ -1374,7 +1374,7 @@ oidc: issuer did not match the issuer returned by provider, expected "` + testIs
wantErr: controllerlib.ErrSyntheticRequeue.Error(),
wantLogs: []string{
`oidc-upstream-observer "msg"="failed to perform OIDC discovery" "error"="oidc: issuer did not match the issuer returned by provider, expected \"` + testIssuerURL + `/\" got \"` + testIssuerURL + `\"" "issuer"="` + testIssuerURL + `/" "name"="test-name" "namespace"="test-namespace"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsSecretValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="failed to perform OIDC discovery against \"` + testIssuerURL + `/\":\noidc: issuer did not match the issuer returned by provider, expected \"` + testIssuerURL + `/\" got \"` + testIssuerURL + `\"" "reason"="Unreachable" "status"="False" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="failed to perform OIDC discovery against \"` + testIssuerURL + `/\":\noidc: issuer did not match the issuer returned by provider, expected \"` + testIssuerURL + `/\" got \"` + testIssuerURL + `\"" "name"="test-name" "namespace"="test-namespace" "reason"="Unreachable" "type"="OIDCDiscoverySucceeded"`,
@@ -1387,7 +1387,7 @@ oidc: issuer did not match the issuer returned by provider, expected "` + testIs
Conditions: []metav1.Condition{
happyAdditionalAuthorizeParametersValidCondition,
{
Type: "ClientCredentialsValid",
Type: "ClientCredentialsSecretValid",
Status: "True",
LastTransitionTime: now,
Reason: "Success",
@@ -1448,7 +1448,7 @@ oidc: issuer did not match the issuer returned by provider, expected "` + testIs
require.Equal(t, len(tt.wantResultingCache), len(actualIDPList))
for i := range actualIDPList {
actualIDP := actualIDPList[i].(*upstreamoidc.ProviderConfig)
require.Equal(t, tt.wantResultingCache[i].GetName(), actualIDP.GetName())
require.Equal(t, tt.wantResultingCache[i].GetResourceName(), actualIDP.GetResourceName())
require.Equal(t, tt.wantResultingCache[i].GetClientID(), actualIDP.GetClientID())
require.Equal(t, tt.wantResultingCache[i].GetAuthorizationURL().String(), actualIDP.GetAuthorizationURL().String())
require.Equal(t, tt.wantResultingCache[i].GetUsernameClaim(), actualIDP.GetUsernameClaim())
@@ -246,7 +246,7 @@ func (c *garbageCollectorController) tryRevokeUpstreamOIDCToken(ctx context.Cont
// Try to find the provider that was originally used to create the stored session.
var foundOIDCIdentityProviderI upstreamprovider.UpstreamOIDCIdentityProviderI
for _, p := range c.idpCache.GetOIDCIdentityProviders() {
if p.GetName() == customSessionData.ProviderName && p.GetResourceUID() == customSessionData.ProviderUID {
if p.GetResourceName() == customSessionData.ProviderName && p.GetResourceUID() == customSessionData.ProviderUID {
foundOIDCIdentityProviderI = p
break
}
@@ -122,7 +122,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
spec.Run(t, "Sync", func(t *testing.T, when spec.G, it spec.S) {
const (
installedInNamespace = "some-namespace"
currentSessionStorageVersion = "7" // update this when you update the storage version in the production code
currentSessionStorageVersion = "8" // update this when you update the storage version in the production code
)
var (
+53 -1
View File
@@ -4,9 +4,17 @@
package controller
import (
"crypto/x509"
"encoding/base64"
"fmt"
"slices"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/util/cert"
authv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/authentication/v1alpha1"
idpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1"
"go.pinniped.dev/internal/controllerlib"
)
@@ -43,12 +51,16 @@ func SimpleFilter(match func(metav1.Object) bool, parentFunc controllerlib.Paren
}
}
func MatchAnySecretOfTypeFilter(secretType corev1.SecretType, parentFunc controllerlib.ParentFunc) controllerlib.Filter {
func MatchAnySecretOfTypeFilter(secretType corev1.SecretType, parentFunc controllerlib.ParentFunc, namespaces ...string) controllerlib.Filter {
isSecretOfType := func(obj metav1.Object) bool {
secret, ok := obj.(*corev1.Secret)
if !ok {
return false
}
// Only match on namespace if namespaces are provided
if len(namespaces) > 0 && !slices.Contains(namespaces, secret.Namespace) {
return false
}
return secret.Type == secretType
}
return SimpleFilter(isSecretOfType, parentFunc)
@@ -87,3 +99,43 @@ type WithInformerOptionFunc func(
// Same signature as controllerlib.WithInitialEvent().
type WithInitialEventOptionFunc func(key controllerlib.Key) controllerlib.Option
// BuildCertPoolAuth returns a PEM-encoded CA bundle from the provided spec. If the provided spec is nil, a
// nil CA bundle will be returned. If the provided spec contains a CA bundle that is not properly
// encoded, an error will be returned.
func BuildCertPoolAuth(spec *authv1alpha1.TLSSpec) (*x509.CertPool, []byte, error) {
if spec == nil {
return nil, nil, nil
}
return buildCertPool(spec.CertificateAuthorityData)
}
// BuildCertPoolIDP returns a PEM-encoded CA bundle from the provided spec. If the provided spec is nil, a
// nil CA bundle will be returned. If the provided spec contains a CA bundle that is not properly
// encoded, an error will be returned.
func BuildCertPoolIDP(spec *idpv1alpha1.TLSSpec) (*x509.CertPool, []byte, error) {
if spec == nil {
return nil, nil, nil
}
return buildCertPool(spec.CertificateAuthorityData)
}
func buildCertPool(certificateAuthorityData string) (*x509.CertPool, []byte, error) {
if len(certificateAuthorityData) == 0 {
return nil, nil, nil
}
pem, err := base64.StdEncoding.DecodeString(certificateAuthorityData)
if err != nil {
return nil, nil, err
}
rootCAs, err := cert.NewPoolFromBytes(pem)
if err != nil {
return nil, nil, fmt.Errorf("certificateAuthorityData is not valid PEM: %w", err)
}
return rootCAs, pem, nil
}
+1 -1
View File
@@ -18,7 +18,7 @@ import (
type HostPort struct {
// Host is the validated host part of the input, which may be a hostname or IP.
//
// This string can be be used as an x509 certificate SAN.
// This string can be used as a x509 certificate SAN.
Host string
// Port is the validated port number, which may be defaulted.
@@ -56,7 +56,7 @@ func NewPinnipedSession(
UpstreamUsername: c.UpstreamIdentity.UpstreamUsername,
UpstreamGroups: c.UpstreamIdentity.UpstreamGroups,
ProviderUID: idp.GetProvider().GetResourceUID(),
ProviderName: idp.GetProvider().GetName(),
ProviderName: idp.GetProvider().GetResourceName(),
ProviderType: idp.GetSessionProviderType(),
Warnings: c.UpstreamLoginExtras.Warnings,
}
@@ -24,3 +24,11 @@ func OIDC(upstreamIssuerAsString string, upstreamSubject string, idpDisplayName
oidc.IDTokenClaimSubject, url.QueryEscape(upstreamSubject),
)
}
func GitHub(apiBaseURL, idpDisplayName, login, id string) string {
return fmt.Sprintf("%s?%s=%s&login=%s&id=%s", apiBaseURL,
oidc.IDTokenSubClaimIDPNameQueryParam, url.QueryEscape(idpDisplayName),
url.QueryEscape(login),
url.QueryEscape(id),
)
}
@@ -89,3 +89,41 @@ func TestOIDC(t *testing.T) {
})
}
}
func TestGitHub(t *testing.T) {
tests := []struct {
name string
apiBaseURL string
idpDisplayName string
login string
id string
wantSubject string
}{
{
name: "simple display name",
apiBaseURL: "https://github.com",
idpDisplayName: "simpleName",
login: "some login",
id: "some id",
wantSubject: "https://github.com?idpName=simpleName&login=some+login&id=some+id",
},
{
name: "interesting display name",
apiBaseURL: "https://server.example.com:1234/path",
idpDisplayName: "this is a 👍 display name that 🦭 can handle",
login: "some other login",
id: "some other id",
wantSubject: "https://server.example.com:1234/path?idpName=this+is+a+%F0%9F%91%8D+display+name+that+%F0%9F%A6%AD+can+handle&login=some+other+login&id=some+other+id",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
actual := GitHub(test.apiBaseURL, test.idpDisplayName, test.login, test.id)
require.Equal(t, test.wantSubject, actual)
})
}
}
@@ -1,4 +1,4 @@
// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved.
// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package dynamicupstreamprovider
@@ -17,12 +17,15 @@ type DynamicUpstreamIDPProvider interface {
GetLDAPIdentityProviders() []upstreamprovider.UpstreamLDAPIdentityProviderI
SetActiveDirectoryIdentityProviders(adIDPs []upstreamprovider.UpstreamLDAPIdentityProviderI)
GetActiveDirectoryIdentityProviders() []upstreamprovider.UpstreamLDAPIdentityProviderI
SetGitHubIdentityProviders(gitHubIDPs []upstreamprovider.UpstreamGithubIdentityProviderI)
GetGitHubIdentityProviders() []upstreamprovider.UpstreamGithubIdentityProviderI
}
type dynamicUpstreamIDPProvider struct {
oidcUpstreams []upstreamprovider.UpstreamOIDCIdentityProviderI
ldapUpstreams []upstreamprovider.UpstreamLDAPIdentityProviderI
activeDirectoryUpstreams []upstreamprovider.UpstreamLDAPIdentityProviderI
gitHubUpstreams []upstreamprovider.UpstreamGithubIdentityProviderI
mutex sync.RWMutex
}
@@ -31,6 +34,7 @@ func NewDynamicUpstreamIDPProvider() DynamicUpstreamIDPProvider {
oidcUpstreams: []upstreamprovider.UpstreamOIDCIdentityProviderI{},
ldapUpstreams: []upstreamprovider.UpstreamLDAPIdentityProviderI{},
activeDirectoryUpstreams: []upstreamprovider.UpstreamLDAPIdentityProviderI{},
gitHubUpstreams: []upstreamprovider.UpstreamGithubIdentityProviderI{},
}
}
@@ -70,6 +74,18 @@ func (p *dynamicUpstreamIDPProvider) GetActiveDirectoryIdentityProviders() []ups
return p.activeDirectoryUpstreams
}
func (p *dynamicUpstreamIDPProvider) SetGitHubIdentityProviders(gitHubIDPs []upstreamprovider.UpstreamGithubIdentityProviderI) {
p.mutex.Lock() // acquire a write lock
defer p.mutex.Unlock()
p.gitHubUpstreams = gitHubIDPs
}
func (p *dynamicUpstreamIDPProvider) GetGitHubIdentityProviders() []upstreamprovider.UpstreamGithubIdentityProviderI {
p.mutex.RLock() // acquire a read lock
defer p.mutex.RUnlock()
return p.gitHubUpstreams
}
type RetryableRevocationError struct {
wrapped error
}
@@ -55,6 +55,8 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo
ldapUpstreamResourceUID = "ldap-resource-uid"
activeDirectoryUpstreamName = "some-active-directory-idp"
activeDirectoryUpstreamResourceUID = "active-directory-resource-uid"
githubUpstreamName = "some-github-idp"
githubUpstreamResourceUID = "github-resource-uid"
oidcUpstreamIssuer = "https://my-upstream-issuer.com"
oidcUpstreamSubject = "abc123-some guid" // has a space character which should get escaped in URL
@@ -291,6 +293,15 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo
WithPasswordGrantError(errors.New("should not have used password grant on this instance"))
}
upstreamGitHubIdentityProviderBuilder := func() *oidctestutil.TestUpstreamGitHubIdentityProviderBuilder {
return oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder().
WithName(githubUpstreamName).
WithResourceUID(githubUpstreamResourceUID).
WithClientID("some-github-client-id").
WithAuthorizationURL(upstreamAuthURL.String()).
WithScopes([]string{"scope1", "scope2"}) // the scopes to request when starting the upstream authorization flow
}
passwordGrantUpstreamOIDCIdentityProviderBuilder := func() *oidctestutil.TestUpstreamOIDCIdentityProviderBuilder {
return oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder().
WithName(oidcPasswordGrantUpstreamName).
@@ -463,6 +474,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo
happyGetRequestPathForOIDCPasswordGrantUpstream := modifiedHappyGetRequestPath(map[string]string{"pinniped_idp_name": oidcPasswordGrantUpstreamName})
happyGetRequestPathForLDAPUpstream := modifiedHappyGetRequestPath(map[string]string{"pinniped_idp_name": ldapUpstreamName})
happyGetRequestPathForADUpstream := modifiedHappyGetRequestPath(map[string]string{"pinniped_idp_name": activeDirectoryUpstreamName})
happyGetRequestPathForGithubUpstream := modifiedHappyGetRequestPath(map[string]string{"pinniped_idp_name": githubUpstreamName})
modifiedHappyGetRequestPathForOIDCUpstream := func(queryOverrides map[string]string) string {
queryOverrides["pinniped_idp_name"] = oidcUpstreamName
@@ -480,6 +492,10 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo
queryOverrides["pinniped_idp_name"] = activeDirectoryUpstreamName
return modifiedHappyGetRequestPath(queryOverrides)
}
modifiedHappyGetRequestPathForGithubUpstream := func(queryOverrides map[string]string) string {
queryOverrides["pinniped_idp_name"] = githubUpstreamName
return modifiedHappyGetRequestPath(queryOverrides)
}
happyGetRequestQueryMapForOIDCUpstream := modifiedQueryMap(happyGetRequestQueryMap, map[string]string{"pinniped_idp_name": oidcUpstreamName})
happyGetRequestQueryMapForOIDCPasswordGrantUpstream := modifiedQueryMap(happyGetRequestQueryMap, map[string]string{"pinniped_idp_name": oidcPasswordGrantUpstreamName})
@@ -533,6 +549,17 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo
return urlWithQuery(upstreamAuthURL.String(), query)
}
expectedRedirectLocationForUpstreamGithub := func(expectedUpstreamState string) string {
query := map[string]string{
"response_type": "code",
"scope": "scope1 scope2",
"client_id": "some-github-client-id",
"state": expectedUpstreamState,
"redirect_uri": downstreamIssuer + "/callback",
}
return urlWithQuery(upstreamAuthURL.String(), query)
}
expectedHappyActiveDirectoryUpstreamCustomSession := &psession.CustomSessionData{
Username: happyLDAPUsernameFromAuthenticator,
UpstreamUsername: happyLDAPUsernameFromAuthenticator,
@@ -711,6 +738,41 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo
wantUpstreamStateParamInLocationHeader: true,
wantBodyStringWithLocationInHref: true,
},
{
name: "GitHub upstream browser flow happy path using GET without a CSRF cookie",
idps: testidplister.NewUpstreamIDPListerBuilder().WithGitHub(upstreamGitHubIdentityProviderBuilder().Build()),
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
stateEncoder: happyStateEncoder,
cookieEncoder: happyCookieEncoder,
method: http.MethodGet,
path: happyGetRequestPathForGithubUpstream,
wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType,
wantCSRFValueInCookieHeader: happyCSRF,
wantLocationHeader: expectedRedirectLocationForUpstreamGithub(expectedUpstreamStateParam(nil, "", githubUpstreamName, "github")),
wantUpstreamStateParamInLocationHeader: true,
wantBodyStringWithLocationInHref: true,
},
{
name: "GitHub upstream browser flow happy path using GET without a CSRF cookie using a dynamic client",
idps: testidplister.NewUpstreamIDPListerBuilder().WithGitHub(upstreamGitHubIdentityProviderBuilder().Build()),
kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources,
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
stateEncoder: happyStateEncoder,
cookieEncoder: happyCookieEncoder,
method: http.MethodGet,
path: modifiedHappyGetRequestPathForGithubUpstream(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}),
wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType,
wantCSRFValueInCookieHeader: happyCSRF,
wantLocationHeader: expectedRedirectLocationForUpstreamGithub(expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}, "", githubUpstreamName, "github")),
wantUpstreamStateParamInLocationHeader: true,
wantBodyStringWithLocationInHref: true,
},
{
name: "LDAP upstream browser flow happy path using GET without a CSRF cookie",
idps: testidplister.NewUpstreamIDPListerBuilder().WithLDAP(upstreamLDAPIdentityProviderBuilder().Build()),
@@ -48,6 +48,9 @@ func NewHandler(
authorizeRequester, err := oauthHelper.NewAuthorizeRequest(r.Context(), reconstitutedAuthRequest)
if err != nil {
plog.Error("error using state downstream auth params", err,
"identityProviderDisplayName", idp.GetDisplayName(),
"identityProviderResourceName", idp.GetProvider().GetResourceName(),
"supervisorCallbackURL", redirectURI,
"fositeErr", oidc.FositeErrorForLog(err))
return httperr.New(http.StatusBadRequest, "error using state downstream auth params")
}
@@ -59,6 +62,10 @@ func NewHandler(
identity, loginExtras, err := idp.LoginFromCallback(r.Context(), authcode(r), state.PKCECode, state.Nonce, redirectURI)
if err != nil {
plog.InfoErr("unable to complete login from callback", err,
"identityProviderDisplayName", idp.GetDisplayName(),
"identityProviderResourceName", idp.GetProvider().GetResourceName(),
"supervisorCallbackURL", redirectURI)
return err
}
@@ -69,13 +76,20 @@ func NewHandler(
GrantedScopes: authorizeRequester.GetGrantedScopes(),
})
if err != nil {
plog.InfoErr("unable to create a Pinniped session", err,
"identityProviderDisplayName", idp.GetDisplayName(),
"identityProviderResourceName", idp.GetProvider().GetResourceName(),
"supervisorCallbackURL", redirectURI)
return httperr.Wrap(http.StatusUnprocessableEntity, err.Error(), err)
}
authorizeResponder, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, session)
if err != nil {
plog.WarningErr("error while generating and saving authcode", err,
"identityProviderDisplayName", idp.GetDisplayName(), "fositeErr", oidc.FositeErrorForLog(err))
"identityProviderDisplayName", idp.GetDisplayName(),
"identityProviderResourceName", idp.GetProvider().GetResourceName(),
"supervisorCallbackURL", redirectURI,
"fositeErr", oidc.FositeErrorForLog(err))
return httperr.Wrap(http.StatusInternalServerError, "error while generating and saving authcode", err)
}
File diff suppressed because it is too large Load Diff
@@ -40,6 +40,7 @@ func responseAsJSON(upstreamIDPs federationdomainproviders.FederationDomainIdent
r := v1alpha1.IDPDiscoveryResponse{
PinnipedSupportedIDPTypes: []v1alpha1.PinnipedSupportedIDPType{
{Type: v1alpha1.IDPTypeActiveDirectory},
{Type: v1alpha1.IDPTypeGitHub},
{Type: v1alpha1.IDPTypeLDAP},
{Type: v1alpha1.IDPTypeOIDC},
},
@@ -41,6 +41,7 @@ func TestIDPDiscovery(t *testing.T) {
WithOIDC(oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder().WithName("a-some-oidc-idp").Build()).
WithLDAP(oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().WithName("z-some-ldap-idp").Build()).
WithLDAP(oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().WithName("x-some-ldap-idp").Build()).
WithGitHub(oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder().WithName("g-some-github-idp").Build()).
WithActiveDirectory(oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().WithName("z-some-ad-idp").Build()).
WithActiveDirectory(oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().WithName("y-some-ad-idp").Build()).
BuildFederationDomainIdentityProvidersListerFinder(),
@@ -50,6 +51,7 @@ func TestIDPDiscovery(t *testing.T) {
"pinniped_identity_providers": [
{"name": "a-some-ldap-idp", "type": "ldap", "flows": ["cli_password", "browser_authcode"]},
{"name": "a-some-oidc-idp", "type": "oidc", "flows": ["browser_authcode"]},
{"name": "g-some-github-idp", "type": "github", "flows": ["browser_authcode"]},
{"name": "x-some-ldap-idp", "type": "ldap", "flows": ["cli_password", "browser_authcode"]},
{"name": "x-some-oidc-idp", "type": "oidc", "flows": ["browser_authcode"]},
{"name": "y-some-ad-idp", "type": "activedirectory", "flows": ["cli_password", "browser_authcode"]},
@@ -59,12 +61,14 @@ func TestIDPDiscovery(t *testing.T) {
],
"pinniped_supported_identity_provider_types": [
{"type": "activedirectory"},
{"type": "github"},
{"type": "ldap"},
{"type": "oidc"}
]
}`),
wantSecondResponseBodyJSON: here.Doc(`{
"pinniped_identity_providers": [
{"name": "g-some-github-idp", "type": "github", "flows": ["browser_authcode"]},
{"name": "some-other-ad-idp-1", "type": "activedirectory", "flows": ["cli_password", "browser_authcode"]},
{"name": "some-other-ad-idp-2", "type": "activedirectory", "flows": ["cli_password", "browser_authcode"]},
{"name": "some-other-ldap-idp-1", "type": "ldap", "flows": ["cli_password", "browser_authcode"]},
@@ -74,6 +78,7 @@ func TestIDPDiscovery(t *testing.T) {
],
"pinniped_supported_identity_provider_types": [
{"type": "activedirectory"},
{"type": "github"},
{"type": "ldap"},
{"type": "oidc"}
]
@@ -91,6 +96,7 @@ func TestIDPDiscovery(t *testing.T) {
"pinniped_identity_providers": [],
"pinniped_supported_identity_provider_types": [
{"type": "activedirectory"},
{"type": "github"},
{"type": "ldap"},
{"type": "oidc"}
]
@@ -106,6 +112,7 @@ func TestIDPDiscovery(t *testing.T) {
],
"pinniped_supported_identity_provider_types": [
{"type": "activedirectory"},
{"type": "github"},
{"type": "ldap"},
{"type": "oidc"}
]
@@ -233,7 +233,7 @@ func findProviderByNameAndType(
idpLister federationdomainproviders.FederationDomainIdentityProvidersListerI,
) (resolvedprovider.FederationDomainResolvedIdentityProvider, error) {
for _, p := range idpLister.GetIdentityProviders() {
if p.GetSessionProviderType() == providerType && p.GetProvider().GetName() == providerResourceName {
if p.GetSessionProviderType() == providerType && p.GetProvider().GetResourceName() == providerResourceName {
if p.GetProvider().GetResourceUID() != mustHaveResourceUID {
return nil, errorsx.WithStack(errUpstreamRefreshError().WithHint(
"Provider from upstream session data has changed its resource UID since authentication."))
@@ -51,6 +51,7 @@ import (
"go.pinniped.dev/internal/federationdomain/oidc"
"go.pinniped.dev/internal/federationdomain/oidcclientvalidator"
"go.pinniped.dev/internal/federationdomain/storage"
"go.pinniped.dev/internal/federationdomain/upstreamprovider"
"go.pinniped.dev/internal/fositestorage/accesstoken"
"go.pinniped.dev/internal/fositestorage/authorizationcode"
"go.pinniped.dev/internal/fositestorage/openidconnect"
@@ -268,30 +269,43 @@ var (
}
)
type expectedUpstreamRefresh struct {
type expectedOIDCUpstreamRefresh struct {
performedByUpstreamName string
args *oidctestutil.PerformRefreshArgs
args *oidctestutil.PerformOIDCRefreshArgs
}
type expectedUpstreamValidateTokens struct {
type expectedLDAPUpstreamRefresh struct {
performedByUpstreamName string
args *oidctestutil.PerformLDAPRefreshArgs
}
type expectedGithubUpstreamRefresh struct {
performedByUpstreamName string
args *oidctestutil.GetUserArgs
}
type expectedOIDCUpstreamValidateTokens struct {
performedByUpstreamName string
args *oidctestutil.ValidateTokenAndMergeWithUserInfoArgs
}
type tokenEndpointResponseExpectedValues struct {
wantStatus int
wantSuccessBodyFields []string
wantErrorResponseBody string
wantClientID string
wantRequestedScopes []string
wantGrantedScopes []string
wantUsername string
wantGroups []string
wantUpstreamRefreshCall *expectedUpstreamRefresh
wantUpstreamOIDCValidateTokenCall *expectedUpstreamValidateTokens
wantCustomSessionDataStored *psession.CustomSessionData
wantWarnings []RecordedWarning
wantAdditionalClaims map[string]any
wantStatus int
wantSuccessBodyFields []string
wantErrorResponseBody string
wantClientID string
wantRequestedScopes []string
wantGrantedScopes []string
wantUsername string
wantGroups []string
wantOIDCUpstreamRefreshCall *expectedOIDCUpstreamRefresh
wantLDAPUpstreamRefreshCall *expectedLDAPUpstreamRefresh
wantActiveDirectoryUpstreamRefreshCall *expectedLDAPUpstreamRefresh
wantGithubUpstreamRefreshCall *expectedGithubUpstreamRefresh
wantUpstreamOIDCValidateTokenCall *expectedOIDCUpstreamValidateTokens
wantCustomSessionDataStored *psession.CustomSessionData
wantWarnings []RecordedWarning
wantAdditionalClaims map[string]interface{}
// The expected lifetime of the ID tokens issued by authcode exchange and refresh, but not token exchange.
// When zero, will assume that the test wants the default value for ID token lifetime.
wantIDTokenLifetimeSeconds int
@@ -1828,6 +1842,11 @@ func TestRefreshGrant(t *testing.T) {
activeDirectoryUpstreamType = "activedirectory"
activeDirectoryUpstreamDN = "some-ad-user-dn"
githubUpstreamName = "some-github-idp"
githubUpstreamResourceUID = "github-resource-uid"
githubUpstreamType = "github"
githubUpstreamAccessToken = "some-opaque-access-token-from-github" //nolint:gosec // this is not a credential
transformationUsernamePrefix = "username_prefix:"
transformationGroupsPrefix = "groups_prefix:"
)
@@ -1843,6 +1862,18 @@ func TestRefreshGrant(t *testing.T) {
WithResourceUID(oidcUpstreamResourceUID)
}
upstreamGitHubIdentityProviderBuilder := func() *oidctestutil.TestUpstreamGitHubIdentityProviderBuilder {
goodGitHubUser := &upstreamprovider.GitHubUser{
Username: goodUsername,
Groups: goodGroups,
DownstreamSubject: goodSubject,
}
return oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder().
WithName(githubUpstreamName).
WithResourceUID(githubUpstreamResourceUID).
WithUser(goodGitHubUser)
}
initialUpstreamOIDCRefreshTokenCustomSessionData := func() *psession.CustomSessionData {
return &psession.CustomSessionData{
Username: goodUsername,
@@ -1859,6 +1890,20 @@ func TestRefreshGrant(t *testing.T) {
}
}
initialUpstreamGitHubCustomSessionData := func() *psession.CustomSessionData {
return &psession.CustomSessionData{
Username: goodUsername,
UpstreamUsername: goodUsername,
UpstreamGroups: goodGroups,
ProviderName: githubUpstreamName,
ProviderUID: githubUpstreamResourceUID,
ProviderType: githubUpstreamType,
GitHub: &psession.GitHubSessionData{
UpstreamAccessToken: githubUpstreamAccessToken,
},
}
}
initialUpstreamOIDCRefreshTokenCustomSessionDataWithUsername := func(downstreamUsername string) *psession.CustomSessionData {
customSessionData := initialUpstreamOIDCRefreshTokenCustomSessionData()
customSessionData.Username = downstreamUsername
@@ -1893,42 +1938,63 @@ func TestRefreshGrant(t *testing.T) {
return sessionData
}
happyOIDCUpstreamRefreshCall := func() *expectedUpstreamRefresh {
return &expectedUpstreamRefresh{
happyOIDCUpstreamRefreshCall := func() *expectedOIDCUpstreamRefresh {
return &expectedOIDCUpstreamRefresh{
performedByUpstreamName: oidcUpstreamName,
args: &oidctestutil.PerformRefreshArgs{
args: &oidctestutil.PerformOIDCRefreshArgs{
Ctx: nil, // this will be filled in with the actual request context by the test below
RefreshToken: oidcUpstreamInitialRefreshToken,
},
}
}
happyLDAPUpstreamRefreshCall := func() *expectedUpstreamRefresh {
return &expectedUpstreamRefresh{
happyGitHubUpstreamRefreshCall := func() *expectedGithubUpstreamRefresh {
return &expectedGithubUpstreamRefresh{
performedByUpstreamName: githubUpstreamName,
args: &oidctestutil.GetUserArgs{
Ctx: nil, // this will be filled in with the actual request context by the test below
AccessToken: githubUpstreamAccessToken,
IDPDisplayName: githubUpstreamName,
},
}
}
happyLDAPUpstreamRefreshCall := func() *expectedLDAPUpstreamRefresh {
return &expectedLDAPUpstreamRefresh{
performedByUpstreamName: ldapUpstreamName,
args: &oidctestutil.PerformRefreshArgs{
Ctx: nil,
DN: ldapUpstreamDN,
ExpectedSubject: goodSubject,
ExpectedUsername: goodUsername,
args: &oidctestutil.PerformLDAPRefreshArgs{
Ctx: nil, // this will be filled in with the actual request context by the test below
StoredRefreshAttributes: upstreamprovider.LDAPRefreshAttributes{
Username: goodUsername,
Subject: goodSubject,
DN: ldapUpstreamDN,
Groups: goodGroups,
AdditionalAttributes: nil,
},
IDPDisplayName: ldapUpstreamName,
},
}
}
happyActiveDirectoryUpstreamRefreshCall := func() *expectedUpstreamRefresh {
return &expectedUpstreamRefresh{
happyActiveDirectoryUpstreamRefreshCall := func() *expectedLDAPUpstreamRefresh {
return &expectedLDAPUpstreamRefresh{
performedByUpstreamName: activeDirectoryUpstreamName,
args: &oidctestutil.PerformRefreshArgs{
Ctx: nil,
DN: activeDirectoryUpstreamDN,
ExpectedSubject: goodSubject,
ExpectedUsername: goodUsername,
args: &oidctestutil.PerformLDAPRefreshArgs{
Ctx: nil, // this will be filled in with the actual request context by the test below
StoredRefreshAttributes: upstreamprovider.LDAPRefreshAttributes{
Username: goodUsername,
Subject: goodSubject,
DN: activeDirectoryUpstreamDN,
Groups: goodGroups,
AdditionalAttributes: nil,
},
IDPDisplayName: activeDirectoryUpstreamName,
},
}
}
happyUpstreamValidateTokenCall := func(expectedTokens *oauth2.Token, requireIDToken bool) *expectedUpstreamValidateTokens {
return &expectedUpstreamValidateTokens{
happyUpstreamValidateTokenCall := func(expectedTokens *oauth2.Token, requireIDToken bool) *expectedOIDCUpstreamValidateTokens {
return &expectedOIDCUpstreamValidateTokens{
performedByUpstreamName: oidcUpstreamName,
args: &oidctestutil.ValidateTokenAndMergeWithUserInfoArgs{
Ctx: nil, // this will be filled in with the actual request context by the test below
@@ -1976,7 +2042,7 @@ func TestRefreshGrant(t *testing.T) {
// same as the same values as the authcode exchange case.
want := happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(wantCustomSessionDataStored)
// Should always try to perform an upstream refresh.
want.wantUpstreamRefreshCall = happyOIDCUpstreamRefreshCall()
want.wantOIDCUpstreamRefreshCall = happyOIDCUpstreamRefreshCall()
if expectToValidateToken != nil {
want.wantUpstreamOIDCValidateTokenCall = happyUpstreamValidateTokenCall(expectToValidateToken, true)
}
@@ -1988,13 +2054,22 @@ func TestRefreshGrant(t *testing.T) {
// same as the same values as the authcode exchange case.
want := happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccessWithUsernameAndGroups(wantCustomSessionDataStored, wantDownstreamUsername, wantDownstreamGroups)
// Should always try to perform an upstream refresh.
want.wantUpstreamRefreshCall = happyOIDCUpstreamRefreshCall()
want.wantOIDCUpstreamRefreshCall = happyOIDCUpstreamRefreshCall()
if expectToValidateToken != nil {
want.wantUpstreamOIDCValidateTokenCall = happyUpstreamValidateTokenCall(expectToValidateToken, true)
}
return want
}
happyRefreshTokenResponseForGitHubAndOfflineAccessWithUsernameAndGroups := func(wantCustomSessionDataStored *psession.CustomSessionData, wantDownstreamUsername string, wantDownstreamGroups []string) tokenEndpointResponseExpectedValues {
// Should always have some custom session data stored. The other expectations happens to be the
// same as the same values as the authcode exchange case.
want := happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccessWithUsernameAndGroups(wantCustomSessionDataStored, wantDownstreamUsername, wantDownstreamGroups)
// Should always try to perform an upstream refresh.
want.wantGithubUpstreamRefreshCall = happyGitHubUpstreamRefreshCall()
return want
}
happyRefreshTokenResponseForOpenIDAndOfflineAccessWithAdditionalClaims := func(wantCustomSessionDataStored *psession.CustomSessionData, expectToValidateToken *oauth2.Token, wantAdditionalClaims map[string]any) tokenEndpointResponseExpectedValues {
want := happyRefreshTokenResponseForOpenIDAndOfflineAccess(wantCustomSessionDataStored, expectToValidateToken)
want.wantAdditionalClaims = wantAdditionalClaims
@@ -2003,19 +2078,19 @@ func TestRefreshGrant(t *testing.T) {
happyRefreshTokenResponseForLDAP := func(wantCustomSessionDataStored *psession.CustomSessionData) tokenEndpointResponseExpectedValues {
want := happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(wantCustomSessionDataStored)
want.wantUpstreamRefreshCall = happyLDAPUpstreamRefreshCall()
want.wantLDAPUpstreamRefreshCall = happyLDAPUpstreamRefreshCall()
return want
}
happyRefreshTokenResponseForLDAPWithUsernameAndGroups := func(wantCustomSessionDataStored *psession.CustomSessionData, wantDownstreamUsername string, wantDownstreamGroups []string) tokenEndpointResponseExpectedValues {
want := happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccessWithUsernameAndGroups(wantCustomSessionDataStored, wantDownstreamUsername, wantDownstreamGroups)
want.wantUpstreamRefreshCall = happyLDAPUpstreamRefreshCall()
want.wantLDAPUpstreamRefreshCall = happyLDAPUpstreamRefreshCall()
return want
}
happyRefreshTokenResponseForActiveDirectory := func(wantCustomSessionDataStored *psession.CustomSessionData) tokenEndpointResponseExpectedValues {
want := happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(wantCustomSessionDataStored)
want.wantUpstreamRefreshCall = happyActiveDirectoryUpstreamRefreshCall()
want.wantActiveDirectoryUpstreamRefreshCall = happyActiveDirectoryUpstreamRefreshCall()
return want
}
@@ -2079,6 +2154,14 @@ func TestRefreshGrant(t *testing.T) {
),
}
happyAuthcodeExchangeInputsForGithubUpstream := authcodeExchangeInputs{
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access username groups") },
customSessionData: initialUpstreamGitHubCustomSessionData(),
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(
initialUpstreamGitHubCustomSessionData(),
),
}
happyAuthcodeExchangeInputsForLDAPUpstream := authcodeExchangeInputs{
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access username groups") },
customSessionData: happyLDAPCustomSessionData,
@@ -2151,6 +2234,19 @@ func TestRefreshGrant(t *testing.T) {
),
},
},
{
name: "happy path refresh grant with GitHub upstream",
idps: testidplister.NewUpstreamIDPListerBuilder().WithGitHub(
upstreamGitHubIdentityProviderBuilder().Build()),
authcodeExchange: happyAuthcodeExchangeInputsForGithubUpstream,
refreshRequest: refreshRequestInputs{
want: happyRefreshTokenResponseForGitHubAndOfflineAccessWithUsernameAndGroups(
initialUpstreamGitHubCustomSessionData(),
goodUsername,
goodGroups,
),
},
},
{
name: "happy path refresh grant with OIDC upstream with identity transformations which modify the username and group names when the upstream refresh does not return new username or groups then it reruns the transformations on the old upstream username and groups",
idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(
@@ -2184,7 +2280,7 @@ func TestRefreshGrant(t *testing.T) {
wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"},
wantUsername: transformationUsernamePrefix + goodUsername,
wantGroups: testutil.AddPrefixToEach(transformationGroupsPrefix, goodGroups),
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithRefreshTokenWithoutIDToken(), false),
wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshTokenWithUsername(oidcUpstreamRefreshedRefreshToken, transformationUsernamePrefix+goodUsername),
},
@@ -2218,7 +2314,7 @@ func TestRefreshGrant(t *testing.T) {
},
refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true),
wantStatus: http.StatusUnauthorized,
wantErrorResponseBody: here.Doc(`
@@ -2258,7 +2354,7 @@ func TestRefreshGrant(t *testing.T) {
},
refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true),
wantStatus: http.StatusUnauthorized,
wantErrorResponseBody: here.Doc(`
@@ -2508,7 +2604,7 @@ func TestRefreshGrant(t *testing.T) {
wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken),
wantUsername: "",
wantGroups: goodGroups,
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true),
}),
},
@@ -2563,7 +2659,7 @@ func TestRefreshGrant(t *testing.T) {
wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"},
wantUsername: goodUsername,
wantGroups: goodGroups,
wantUpstreamOIDCValidateTokenCall: &expectedUpstreamValidateTokens{
wantUpstreamOIDCValidateTokenCall: &expectedOIDCUpstreamValidateTokens{
oidcUpstreamName,
&oidctestutil.ValidateTokenAndMergeWithUserInfoArgs{
Ctx: nil, // this will be filled in with the actual request context by the test below
@@ -2606,7 +2702,7 @@ func TestRefreshGrant(t *testing.T) {
wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"},
wantRequestedScopes: []string{"offline_access"},
wantGrantedScopes: []string{"offline_access", "username", "groups"},
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithRefreshTokenWithoutIDToken(), false),
wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken),
wantUsername: goodUsername,
@@ -2632,7 +2728,7 @@ func TestRefreshGrant(t *testing.T) {
wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"},
wantUsername: goodUsername,
wantGroups: goodGroups,
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithRefreshTokenWithoutIDToken(), false),
wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken),
},
@@ -2659,7 +2755,7 @@ func TestRefreshGrant(t *testing.T) {
wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"},
wantUsername: goodUsername,
wantGroups: []string{"new-group1", "new-group2", "new-group3"},
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true),
wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken),
wantWarnings: []RecordedWarning{
@@ -2700,7 +2796,7 @@ func TestRefreshGrant(t *testing.T) {
wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"},
wantUsername: goodUsername,
wantGroups: []string{"new-group1", "new-group2", "new-group3"},
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true),
wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken),
wantWarnings: nil, // dynamic clients should not get these warnings which are intended for the pinniped-cli client
@@ -2728,7 +2824,7 @@ func TestRefreshGrant(t *testing.T) {
wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"},
wantUsername: goodUsername,
wantGroups: []string{"new-group1", "new-group2", "new-group3"},
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true),
wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken),
wantWarnings: []RecordedWarning{
@@ -2759,7 +2855,7 @@ func TestRefreshGrant(t *testing.T) {
wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"},
wantUsername: goodUsername,
wantGroups: []string{}, // the user no longer belongs to any groups
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true),
wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken),
wantWarnings: []RecordedWarning{
@@ -2789,7 +2885,7 @@ func TestRefreshGrant(t *testing.T) {
wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"},
wantUsername: goodUsername,
wantGroups: goodGroups, // the same groups as from the initial login
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true),
wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken),
},
@@ -2814,7 +2910,7 @@ func TestRefreshGrant(t *testing.T) {
wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"},
wantUsername: goodUsername,
wantGroups: []string{"new-group1", "new-group2", "new-group3"},
wantUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(),
wantLDAPUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(),
wantCustomSessionDataStored: happyLDAPCustomSessionData,
wantWarnings: []RecordedWarning{
{Text: `User "some-username" has been added to the following groups: ["new-group1" "new-group2" "new-group3"]`},
@@ -2852,12 +2948,79 @@ func TestRefreshGrant(t *testing.T) {
wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"},
wantUsername: goodUsername,
wantGroups: []string{"new-group1", "new-group2", "new-group3"},
wantUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(),
wantLDAPUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(),
wantCustomSessionDataStored: happyLDAPCustomSessionData,
wantWarnings: nil, // dynamic clients should not get these warnings which are intended for the pinniped-cli client
},
},
},
{
name: "happy path refresh grant when the upstream refresh returns new group memberships from GitHub, it updates groups",
idps: testidplister.NewUpstreamIDPListerBuilder().WithGitHub(oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder().
WithName(githubUpstreamName).
WithResourceUID(githubUpstreamResourceUID).
WithUser(&upstreamprovider.GitHubUser{
Username: goodUsername,
Groups: []string{goodGroups[0], "new-group1", "new-group2", "new-group3"},
DownstreamSubject: goodSubject,
}).Build(),
),
authcodeExchange: happyAuthcodeExchangeInputsForGithubUpstream,
refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusOK,
wantClientID: pinnipedCLIClientID,
wantSuccessBodyFields: []string{"refresh_token", "access_token", "id_token", "token_type", "expires_in", "scope"},
wantRequestedScopes: []string{"openid", "offline_access", "username", "groups"},
wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"},
wantUsername: goodUsername,
wantGroups: []string{goodGroups[0], "new-group1", "new-group2", "new-group3"},
wantGithubUpstreamRefreshCall: happyGitHubUpstreamRefreshCall(),
wantCustomSessionDataStored: initialUpstreamGitHubCustomSessionData(),
wantWarnings: []RecordedWarning{
{Text: `User "some-username" has been added to the following groups: ["new-group1" "new-group2" "new-group3"]`},
{Text: `User "some-username" has been removed from the following groups: ["groups2"]`},
},
},
},
},
{
name: "happy path refresh grant when the upstream refresh returns new group memberships from GitHub, it updates groups, using dynamic client - updates groups without outputting warnings",
idps: testidplister.NewUpstreamIDPListerBuilder().WithGitHub(oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder().
WithName(githubUpstreamName).
WithResourceUID(githubUpstreamResourceUID).
WithUser(&upstreamprovider.GitHubUser{
Username: goodUsername,
Groups: []string{goodGroups[0], "new-group1", "new-group2", "new-group3"},
DownstreamSubject: goodSubject,
}).Build(),
),
kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources,
authcodeExchange: authcodeExchangeInputs{
customSessionData: initialUpstreamGitHubCustomSessionData(),
modifyAuthRequest: func(r *http.Request) {
addDynamicClientIDToFormPostBody(r)
r.Form.Set("scope", "openid offline_access username groups")
},
modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth,
want: withWantDynamicClientID(happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamGitHubCustomSessionData())),
},
refreshRequest: refreshRequestInputs{
modifyTokenRequest: modifyRefreshTokenRequestWithDynamicClientAuth,
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusOK,
wantClientID: dynamicClientID,
wantSuccessBodyFields: []string{"refresh_token", "access_token", "id_token", "token_type", "expires_in", "scope"},
wantRequestedScopes: []string{"openid", "offline_access", "username", "groups"},
wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"},
wantUsername: goodUsername,
wantGroups: []string{goodGroups[0], "new-group1", "new-group2", "new-group3"},
wantGithubUpstreamRefreshCall: happyGitHubUpstreamRefreshCall(),
wantCustomSessionDataStored: initialUpstreamGitHubCustomSessionData(),
wantWarnings: nil, // dynamic clients should not get these warnings which are intended for the pinniped-cli client
},
},
},
{
name: "happy path refresh grant when the upstream refresh returns empty list of group memberships from LDAP, it updates groups to an empty list",
idps: testidplister.NewUpstreamIDPListerBuilder().WithLDAP(oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().
@@ -2877,7 +3040,7 @@ func TestRefreshGrant(t *testing.T) {
wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"},
wantUsername: goodUsername,
wantGroups: []string{},
wantUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(),
wantLDAPUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(),
wantCustomSessionDataStored: happyLDAPCustomSessionData,
wantWarnings: []RecordedWarning{
{Text: `User "some-username" has been removed from the following groups: ["group1" "groups2"]`},
@@ -2920,7 +3083,7 @@ func TestRefreshGrant(t *testing.T) {
wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, // username and groups were not requested, but granted anyway for backwards compatibility
wantUsername: goodUsername,
wantGroups: []string{"new-group1", "new-group2", "new-group3"},
wantUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(),
wantLDAPUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(),
wantCustomSessionDataStored: happyLDAPCustomSessionData,
wantWarnings: []RecordedWarning{
{Text: `User "some-username" has been added to the following groups: ["new-group1" "new-group2" "new-group3"]`},
@@ -2966,7 +3129,7 @@ func TestRefreshGrant(t *testing.T) {
wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, // username and groups were not requested, but granted anyway for backwards compatibility
wantUsername: goodUsername,
wantGroups: []string{"new-group1", "new-group2", "new-group3"},
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true),
wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken),
wantWarnings: []RecordedWarning{
@@ -3019,7 +3182,7 @@ func TestRefreshGrant(t *testing.T) {
wantGrantedScopes: []string{"openid", "offline_access", "username"},
wantUsername: goodUsername,
wantGroups: nil,
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true),
wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken),
},
@@ -3070,7 +3233,7 @@ func TestRefreshGrant(t *testing.T) {
r.SetBasicAuth(dynamicClientID, testutil.PlaintextPassword1) // Use basic auth header instead.
},
want: tokenEndpointResponseExpectedValues{
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true),
wantStatus: http.StatusUnauthorized,
// auth was rejected because of the upstream group to which the user belonged, as shown by the configured RejectedAuthenticationMessage appearing here
@@ -3120,7 +3283,7 @@ func TestRefreshGrant(t *testing.T) {
wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"},
wantUsername: goodUsername,
wantGroups: []string{"new-group1", "new-group2", "new-group3"}, // groups are updated even though the scope was not included
wantUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(),
wantLDAPUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(),
wantCustomSessionDataStored: happyLDAPCustomSessionData,
wantWarnings: []RecordedWarning{
{Text: `User "some-username" has been added to the following groups: ["new-group1" "new-group2" "new-group3"]`},
@@ -3145,7 +3308,7 @@ func TestRefreshGrant(t *testing.T) {
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusUnauthorized,
wantErrorResponseBody: fositeUpstreamGroupClaimErrorBody,
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true),
},
},
@@ -3227,7 +3390,7 @@ func TestRefreshGrant(t *testing.T) {
wantGrantedScopes: []string{"openid", "offline_access", "pinniped:request-audience", "username", "groups"},
wantUsername: goodUsername,
wantGroups: goodGroups,
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true),
wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken),
},
@@ -3719,14 +3882,32 @@ func TestRefreshGrant(t *testing.T) {
},
},
{
name: "when the upstream refresh fails during the refresh request",
name: "when the upstream refresh fails during the refresh request using OIDC upstream",
idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().
WithPerformRefreshError(errors.New("some upstream refresh error")).Build()),
authcodeExchange: happyAuthcodeExchangeInputsForOIDCUpstream,
refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantStatus: http.StatusUnauthorized,
wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantStatus: http.StatusUnauthorized,
wantErrorResponseBody: here.Doc(`
{
"error": "error",
"error_description": "Error during upstream refresh. Upstream refresh failed."
}
`),
},
},
},
{
name: "when the upstream refresh fails during the refresh request using GitHub upstream",
idps: testidplister.NewUpstreamIDPListerBuilder().WithGitHub(upstreamGitHubIdentityProviderBuilder().
WithGetUserError(errors.New("some upstream refresh error")).Build()),
authcodeExchange: happyAuthcodeExchangeInputsForGithubUpstream,
refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{
wantGithubUpstreamRefreshCall: happyGitHubUpstreamRefreshCall(),
wantStatus: http.StatusUnauthorized,
wantErrorResponseBody: here.Doc(`
{
"error": "error",
@@ -3746,7 +3927,7 @@ func TestRefreshGrant(t *testing.T) {
authcodeExchange: happyAuthcodeExchangeInputsForOIDCUpstream,
refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true),
wantStatus: http.StatusUnauthorized,
wantErrorResponseBody: here.Doc(`
@@ -3774,7 +3955,7 @@ func TestRefreshGrant(t *testing.T) {
authcodeExchange: happyAuthcodeExchangeInputsForOIDCUpstream,
refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true),
wantStatus: http.StatusUnauthorized,
wantErrorResponseBody: here.Doc(`
@@ -3799,7 +3980,7 @@ func TestRefreshGrant(t *testing.T) {
authcodeExchange: happyAuthcodeExchangeInputsForOIDCUpstream,
refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true),
wantStatus: http.StatusUnauthorized,
wantErrorResponseBody: here.Doc(`
@@ -3826,7 +4007,7 @@ func TestRefreshGrant(t *testing.T) {
authcodeExchange: happyAuthcodeExchangeInputsForOIDCUpstream,
refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true),
wantStatus: http.StatusUnauthorized,
wantErrorResponseBody: here.Doc(`
@@ -3853,7 +4034,7 @@ func TestRefreshGrant(t *testing.T) {
authcodeExchange: happyAuthcodeExchangeInputsForOIDCUpstream,
refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{
wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantOIDCUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(),
wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true),
wantStatus: http.StatusUnauthorized,
wantErrorResponseBody: here.Doc(`
@@ -3943,8 +4124,8 @@ func TestRefreshGrant(t *testing.T) {
},
refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{
wantUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(),
wantStatus: http.StatusUnauthorized,
wantLDAPUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(),
wantStatus: http.StatusUnauthorized,
wantErrorResponseBody: here.Doc(`
{
"error": "error",
@@ -3983,8 +4164,8 @@ func TestRefreshGrant(t *testing.T) {
},
refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{
wantUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(),
wantStatus: http.StatusUnauthorized,
wantLDAPUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(),
wantStatus: http.StatusUnauthorized,
wantErrorResponseBody: here.Doc(`
{
"error": "error",
@@ -4057,7 +4238,7 @@ func TestRefreshGrant(t *testing.T) {
wantCustomSessionDataStored: happyLDAPCustomSessionData,
wantUsername: "",
wantGroups: goodGroups,
wantUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(),
wantLDAPUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(),
}),
},
},
@@ -4251,8 +4432,8 @@ func TestRefreshGrant(t *testing.T) {
authcodeExchange: happyAuthcodeExchangeInputsForLDAPUpstream,
refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{
wantUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(),
wantStatus: http.StatusUnauthorized,
wantLDAPUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(),
wantStatus: http.StatusUnauthorized,
wantErrorResponseBody: here.Doc(`
{
"error": "error",
@@ -4280,8 +4461,8 @@ func TestRefreshGrant(t *testing.T) {
},
refreshRequest: refreshRequestInputs{
want: tokenEndpointResponseExpectedValues{
wantUpstreamRefreshCall: happyActiveDirectoryUpstreamRefreshCall(),
wantStatus: http.StatusUnauthorized,
wantActiveDirectoryUpstreamRefreshCall: happyActiveDirectoryUpstreamRefreshCall(),
wantStatus: http.StatusUnauthorized,
wantErrorResponseBody: here.Doc(`
{
"error": "error",
@@ -4534,7 +4715,7 @@ func TestRefreshGrant(t *testing.T) {
// Performing an authcode exchange should not have caused any upstream refresh, which should only
// happen during a downstream refresh.
test.idps.RequireExactlyZeroCallsToPerformRefresh(t)
test.idps.RequireExactlyZeroCallsToAnyUpstreamRefresh(t)
test.idps.RequireExactlyZeroCallsToValidateToken(t)
// Wait one second before performing the refresh so we can see that the refreshed ID token has new issued
@@ -4566,19 +4747,38 @@ func TestRefreshGrant(t *testing.T) {
t.Logf("second response: %#v", refreshResponse)
t.Logf("second response body: %q", refreshResponse.Body.String())
// Test that we did or did not make a call to the upstream OIDC provider interface to perform a token refresh.
if test.refreshRequest.want.wantUpstreamRefreshCall != nil {
test.refreshRequest.want.wantUpstreamRefreshCall.args.Ctx = reqContext
test.idps.RequireExactlyOneCallToPerformRefresh(t,
test.refreshRequest.want.wantUpstreamRefreshCall.performedByUpstreamName,
test.refreshRequest.want.wantUpstreamRefreshCall.args,
// Test that we did or did not make a call to the upstream provider's interface to perform refresh.
switch {
case test.refreshRequest.want.wantOIDCUpstreamRefreshCall != nil:
test.refreshRequest.want.wantOIDCUpstreamRefreshCall.args.Ctx = reqContext
test.idps.RequireExactlyOneCallToOIDCPerformRefresh(t,
test.refreshRequest.want.wantOIDCUpstreamRefreshCall.performedByUpstreamName,
test.refreshRequest.want.wantOIDCUpstreamRefreshCall.args,
)
} else {
test.idps.RequireExactlyZeroCallsToPerformRefresh(t)
case test.refreshRequest.want.wantLDAPUpstreamRefreshCall != nil:
test.refreshRequest.want.wantLDAPUpstreamRefreshCall.args.Ctx = reqContext
test.idps.RequireExactlyOneCallToLDAPPerformRefresh(t,
test.refreshRequest.want.wantLDAPUpstreamRefreshCall.performedByUpstreamName,
test.refreshRequest.want.wantLDAPUpstreamRefreshCall.args,
)
case test.refreshRequest.want.wantActiveDirectoryUpstreamRefreshCall != nil:
test.refreshRequest.want.wantActiveDirectoryUpstreamRefreshCall.args.Ctx = reqContext
test.idps.RequireExactlyOneCallToActiveDirectoryPerformRefresh(t,
test.refreshRequest.want.wantActiveDirectoryUpstreamRefreshCall.performedByUpstreamName,
test.refreshRequest.want.wantActiveDirectoryUpstreamRefreshCall.args,
)
case test.refreshRequest.want.wantGithubUpstreamRefreshCall != nil:
test.refreshRequest.want.wantGithubUpstreamRefreshCall.args.Ctx = reqContext
test.idps.RequireExactlyOneCallToGithubGetUser(t,
test.refreshRequest.want.wantGithubUpstreamRefreshCall.performedByUpstreamName,
test.refreshRequest.want.wantGithubUpstreamRefreshCall.args,
)
default:
test.idps.RequireExactlyZeroCallsToAnyUpstreamRefresh(t)
}
// Test that we did or did not make a call to the upstream OIDC provider interface to validate the
// new ID token that was returned by the upstream refresh.
// new ID token that was returned by the upstream refresh, in the case of an OIDC upstream.
if test.refreshRequest.want.wantUpstreamOIDCValidateTokenCall != nil {
test.refreshRequest.want.wantUpstreamOIDCValidateTokenCall.args.Ctx = reqContext
test.idps.RequireExactlyOneCallToValidateToken(t,
@@ -123,6 +123,7 @@ func TestManager(t *testing.T) {
"pinniped_identity_providers": [%s],
"pinniped_supported_identity_provider_types": [
{"type":"activedirectory"},
{"type":"github"},
{"type":"ldap"},
{"type":"oidc"}
]
@@ -11,6 +11,7 @@ import (
"go.pinniped.dev/internal/federationdomain/idplister"
"go.pinniped.dev/internal/federationdomain/resolvedprovider"
"go.pinniped.dev/internal/federationdomain/resolvedprovider/resolvedgithub"
"go.pinniped.dev/internal/federationdomain/resolvedprovider/resolvedldap"
"go.pinniped.dev/internal/federationdomain/resolvedprovider/resolvedoidc"
"go.pinniped.dev/internal/idtransform"
@@ -144,6 +145,7 @@ func (u *FederationDomainIdentityProvidersListerFinder) GetIdentityProviders() [
cachedOIDCProviders := u.wrappedLister.GetOIDCIdentityProviders()
cachedLDAPProviders := u.wrappedLister.GetLDAPIdentityProviders()
cachedADProviders := u.wrappedLister.GetActiveDirectoryIdentityProviders()
cachedGitHubProviders := u.wrappedLister.GetGitHubIdentityProviders()
providers := []resolvedprovider.FederationDomainResolvedIdentityProvider{}
// Every configured identityProvider on the FederationDomain uses an objetRef to an underlying IDP CR that might
// be available as a provider in the wrapped cache. For each configured identityProvider/displayName...
@@ -184,6 +186,16 @@ func (u *FederationDomainIdentityProvidersListerFinder) GetIdentityProviders() [
})
}
}
for _, p := range cachedGitHubProviders {
if idp.UID == p.GetResourceUID() {
providers = append(providers, &resolvedgithub.FederationDomainResolvedGitHubIdentityProvider{
DisplayName: idp.DisplayName,
Provider: p,
SessionProviderType: psession.ProviderTypeGitHub,
Transforms: idp.Transforms,
})
}
}
}
return providers
}
@@ -10,6 +10,7 @@ import (
"go.pinniped.dev/internal/federationdomain/idplister"
"go.pinniped.dev/internal/federationdomain/resolvedprovider"
"go.pinniped.dev/internal/federationdomain/resolvedprovider/resolvedgithub"
"go.pinniped.dev/internal/federationdomain/resolvedprovider/resolvedldap"
"go.pinniped.dev/internal/federationdomain/resolvedprovider/resolvedoidc"
"go.pinniped.dev/internal/testutil/oidctestutil"
@@ -52,6 +53,14 @@ func TestFederationDomainIdentityProvidersListerFinder(t *testing.T) {
WithName("my-ad-idp2").
WithResourceUID("my-ad-uid-idp2").
Build()
myDefaultGitHubIDP := oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder().
WithName("my-default-github-idp").
WithResourceUID("my-default-github-uid-idp").
Build()
myGitHubIDP1 := oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder().
WithName("my-github-idp1").
WithResourceUID("my-github-uid-idp1").
Build()
// FederationDomainIssuers
fakeIssuerURL := "https://www.fakeissuerurl.com"
@@ -77,13 +86,20 @@ func TestFederationDomainIdentityProvidersListerFinder(t *testing.T) {
})
require.NoError(t, err)
fdIssuerWithOIDCAndLDAPAndADIDPs, err := NewFederationDomainIssuer(fakeIssuerURL, []*FederationDomainIdentityProvider{
fdIssuerWithDefaultGitHubIDP, err := NewFederationDomainIssuerWithDefaultIDP(fakeIssuerURL, &FederationDomainIdentityProvider{
DisplayName: "my-default-github-idp",
UID: "my-default-github-uid-idp",
})
require.NoError(t, err)
fdIssuerWithOIDCAndLDAPAndADAndGitHubIDPs, err := NewFederationDomainIssuer(fakeIssuerURL, []*FederationDomainIdentityProvider{
{DisplayName: "my-oidc-idp1", UID: "my-oidc-uid-idp1"},
{DisplayName: "my-oidc-idp2", UID: "my-oidc-uid-idp2"},
{DisplayName: "my-ldap-idp1", UID: "my-ldap-uid-idp1"},
{DisplayName: "my-ldap-idp2", UID: "my-ldap-uid-idp2"},
{DisplayName: "my-ad-idp1", UID: "my-ad-uid-idp1"},
{DisplayName: "my-ad-idp2", UID: "my-ad-uid-idp2"},
{DisplayName: "my-github-idp1", UID: "my-github-uid-idp1"},
})
require.NoError(t, err)
@@ -99,6 +115,7 @@ func TestFederationDomainIdentityProvidersListerFinder(t *testing.T) {
{DisplayName: "my-ldap-idp4", UID: "my-ldap-uid-idp4"},
{DisplayName: "my-ad-idp2", UID: "my-ad-uid-idp2"},
{DisplayName: "my-ad-idp3", UID: "my-ad-uid-idp3"},
{DisplayName: "my-github-idp1", UID: "my-github-uid-idp1"},
})
require.NoError(t, err)
@@ -133,6 +150,11 @@ func TestFederationDomainIdentityProvidersListerFinder(t *testing.T) {
Provider: myADIDP1,
SessionProviderType: "activedirectory",
}
myGitHub1Resolved := &resolvedgithub.FederationDomainResolvedGitHubIdentityProvider{
DisplayName: "my-github-idp1",
Provider: myGitHubIDP1,
SessionProviderType: "github",
}
myDefaultOIDCIDPResolved := &resolvedoidc.FederationDomainResolvedOIDCIdentityProvider{
DisplayName: "my-default-oidc-idp",
@@ -144,15 +166,21 @@ func TestFederationDomainIdentityProvidersListerFinder(t *testing.T) {
Provider: myDefaultLDAPIDP,
SessionProviderType: "ldap",
}
myDefaultGitHubIDPResolved := &resolvedgithub.FederationDomainResolvedGitHubIdentityProvider{
DisplayName: "my-default-github-idp",
Provider: myDefaultGitHubIDP,
SessionProviderType: "github",
}
testFindUpstreamIDPByDisplayName := []struct {
name string
wrappedLister idplister.UpstreamIdentityProvidersLister
federationDomainIssuer *FederationDomainIssuer
findIDPByDisplayName string
wantOIDCIDPByDisplayName *resolvedoidc.FederationDomainResolvedOIDCIdentityProvider
wantLDAPIDPByDisplayName *resolvedldap.FederationDomainResolvedLDAPIdentityProvider
wantError string
name string
wrappedLister idplister.UpstreamIdentityProvidersLister
federationDomainIssuer *FederationDomainIssuer
findIDPByDisplayName string
wantOIDCIDPByDisplayName *resolvedoidc.FederationDomainResolvedOIDCIdentityProvider
wantLDAPIDPByDisplayName *resolvedldap.FederationDomainResolvedLDAPIdentityProvider
wantGitHubIDPByDisplayName *resolvedgithub.FederationDomainResolvedGitHubIdentityProvider
wantError string
}{
{
name: "FindUpstreamIDPByDisplayName will find an upstream IdP by display name with one IDP configured",
@@ -182,8 +210,9 @@ func TestFederationDomainIdentityProvidersListerFinder(t *testing.T) {
WithOIDC(myOIDCIDP2).
WithLDAP(myLDAPIDP1).
WithLDAP(myLDAPIDP2).
WithGitHub(myGitHubIDP1).
BuildDynamicUpstreamIDPProvider(),
federationDomainIssuer: fdIssuerWithOIDCAndLDAPAndADIDPs,
federationDomainIssuer: fdIssuerWithOIDCAndLDAPAndADAndGitHubIDPs,
wantOIDCIDPByDisplayName: myOIDCIDP1Resolved,
},
{
@@ -195,6 +224,7 @@ func TestFederationDomainIdentityProvidersListerFinder(t *testing.T) {
WithLDAP(myLDAPIDP1).
WithLDAP(myLDAPIDP2).
WithActiveDirectory(myADIDP1).
WithGitHub(myGitHubIDP1).
BuildDynamicUpstreamIDPProvider(),
federationDomainIssuer: fdIssuerWithOIDCIDP1,
wantOIDCIDPByDisplayName: myOIDCIDP1Resolved,
@@ -208,11 +238,13 @@ func TestFederationDomainIdentityProvidersListerFinder(t *testing.T) {
WithLDAP(myLDAPIDP1).
WithLDAP(myLDAPIDP2).
WithActiveDirectory(myADIDP1).
WithGitHub(myGitHubIDP1).
BuildDynamicUpstreamIDPProvider(),
federationDomainIssuer: fdIssuerWithOIDCAndLDAPAndADIDPs,
federationDomainIssuer: fdIssuerWithOIDCAndLDAPAndADAndGitHubIDPs,
wantLDAPIDPByDisplayName: myLDAPIDP1Resolved,
},
{
name: "FindUpstreamIDPByDisplayName will find an upstream IDP of type AD (LDAP) by display name",
name: "FindUpstreamIDPByDisplayName will find an upstream IDP of type AD (LDAP) by display name",
findIDPByDisplayName: "my-ad-idp1",
wrappedLister: testidplister.NewUpstreamIDPListerBuilder().
WithOIDC(myOIDCIDP1).
@@ -220,10 +252,25 @@ func TestFederationDomainIdentityProvidersListerFinder(t *testing.T) {
WithLDAP(myLDAPIDP1).
WithLDAP(myLDAPIDP2).
WithActiveDirectory(myADIDP1).
WithGitHub(myGitHubIDP1).
BuildDynamicUpstreamIDPProvider(),
federationDomainIssuer: fdIssuerWithOIDCAndLDAPAndADIDPs,
federationDomainIssuer: fdIssuerWithOIDCAndLDAPAndADAndGitHubIDPs,
wantLDAPIDPByDisplayName: myADIDP1Resolved,
},
{
name: "FindUpstreamIDPByDisplayName will find an upstream IDP of type GitHub by display name",
findIDPByDisplayName: "my-github-idp1",
wrappedLister: testidplister.NewUpstreamIDPListerBuilder().
WithOIDC(myOIDCIDP1).
WithOIDC(myOIDCIDP2).
WithLDAP(myLDAPIDP1).
WithLDAP(myLDAPIDP2).
WithActiveDirectory(myADIDP1).
WithGitHub(myGitHubIDP1).
BuildDynamicUpstreamIDPProvider(),
federationDomainIssuer: fdIssuerWithOIDCAndLDAPAndADAndGitHubIDPs,
wantGitHubIDPByDisplayName: myGitHub1Resolved,
},
{
name: "FindUpstreamIDPByDisplayName will error if IDP by display name is not found - no such display name",
findIDPByDisplayName: "i-cant-find-my-idp",
@@ -233,8 +280,9 @@ func TestFederationDomainIdentityProvidersListerFinder(t *testing.T) {
WithLDAP(myLDAPIDP1).
WithLDAP(myLDAPIDP2).
WithActiveDirectory(myADIDP1).
WithGitHub(myGitHubIDP1).
BuildDynamicUpstreamIDPProvider(),
federationDomainIssuer: fdIssuerWithOIDCAndLDAPAndADIDPs,
federationDomainIssuer: fdIssuerWithOIDCAndLDAPAndADAndGitHubIDPs,
wantError: `identity provider not found: "i-cant-find-my-idp"`,
},
{
@@ -265,6 +313,9 @@ func TestFederationDomainIdentityProvidersListerFinder(t *testing.T) {
if tt.wantLDAPIDPByDisplayName != nil {
require.Equal(t, tt.wantLDAPIDPByDisplayName, foundIDP)
}
if tt.wantGitHubIDPByDisplayName != nil {
require.Equal(t, tt.wantGitHubIDPByDisplayName, foundIDP)
}
})
}
@@ -274,6 +325,7 @@ func TestFederationDomainIdentityProvidersListerFinder(t *testing.T) {
federationDomainIssuer *FederationDomainIssuer
wantDefaultOIDCIDP *resolvedoidc.FederationDomainResolvedOIDCIdentityProvider
wantDefaultLDAPIDP *resolvedldap.FederationDomainResolvedLDAPIdentityProvider
wantDefaultGitHubIDP *resolvedgithub.FederationDomainResolvedGitHubIdentityProvider
wantError string
}{
{
@@ -292,6 +344,14 @@ func TestFederationDomainIdentityProvidersListerFinder(t *testing.T) {
federationDomainIssuer: fdIssuerWithDefaultLDAPIDP,
wantDefaultLDAPIDP: myDefaultLDAPIDPResolved,
},
{
name: "FindDefaultIDP resturns a GitHubIdentityProvider if there is a GitHubIdentityProvider defined as the default IDP",
wrappedLister: testidplister.NewUpstreamIDPListerBuilder().
WithGitHub(myDefaultGitHubIDP).
BuildDynamicUpstreamIDPProvider(),
federationDomainIssuer: fdIssuerWithDefaultGitHubIDP,
wantDefaultGitHubIDP: myDefaultGitHubIDPResolved,
},
{
name: "FindDefaultIDP returns an error if there is no default IDP to return",
wrappedLister: testidplister.NewUpstreamIDPListerBuilder().
@@ -340,6 +400,9 @@ func TestFederationDomainIdentityProvidersListerFinder(t *testing.T) {
if tt.wantDefaultLDAPIDP != nil {
require.Equal(t, tt.wantDefaultLDAPIDP, foundIDP)
}
if tt.wantDefaultGitHubIDP != nil {
require.Equal(t, tt.wantDefaultGitHubIDP, foundIDP)
}
})
}
@@ -357,14 +420,16 @@ func TestFederationDomainIdentityProvidersListerFinder(t *testing.T) {
WithLDAP(myLDAPIDP1).
WithLDAP(myLDAPIDP2).
WithActiveDirectory(myADIDP1).
WithGitHub(myGitHubIDP1).
BuildDynamicUpstreamIDPProvider(),
federationDomainIssuer: fdIssuerWithOIDCAndLDAPAndADIDPs,
federationDomainIssuer: fdIssuerWithOIDCAndLDAPAndADAndGitHubIDPs,
wantIDPs: []resolvedprovider.FederationDomainResolvedIdentityProvider{
myOIDCIDP1Resolved,
myOIDCIDP2Resolved,
myLDAPIDP1Resolved,
myLDAPIDP2Resolved,
myADIDP1Resolved,
myGitHub1Resolved,
},
},
{
@@ -378,6 +443,7 @@ func TestFederationDomainIdentityProvidersListerFinder(t *testing.T) {
Build()).
WithLDAP(myLDAPIDP1).
WithActiveDirectory(myADIDP1).
WithGitHub(myGitHubIDP1).
BuildDynamicUpstreamIDPProvider(),
federationDomainIssuer: fdIssuerWithLotsOfIDPs,
wantIDPs: []resolvedprovider.FederationDomainResolvedIdentityProvider{
@@ -385,13 +451,14 @@ func TestFederationDomainIdentityProvidersListerFinder(t *testing.T) {
myOIDCIDP2Resolved,
myLDAPIDP1Resolved,
myADIDP1Resolved,
myGitHub1Resolved,
},
},
{
name: "GetIdentityProviders will return empty list if no IDPs are found",
wrappedLister: testidplister.NewUpstreamIDPListerBuilder().
BuildDynamicUpstreamIDPProvider(),
federationDomainIssuer: fdIssuerWithOIDCAndLDAPAndADIDPs,
federationDomainIssuer: fdIssuerWithOIDCAndLDAPAndADAndGitHubIDPs,
wantIDPs: []resolvedprovider.FederationDomainResolvedIdentityProvider{},
},
}
@@ -417,7 +484,7 @@ func TestFederationDomainIdentityProvidersListerFinder(t *testing.T) {
name: "IDPCount when there are none to be found",
wrappedLister: testidplister.NewUpstreamIDPListerBuilder().
BuildDynamicUpstreamIDPProvider(),
federationDomainIssuer: fdIssuerWithOIDCAndLDAPAndADIDPs,
federationDomainIssuer: fdIssuerWithOIDCAndLDAPAndADAndGitHubIDPs,
wantCount: 0,
},
{
@@ -440,9 +507,14 @@ func TestFederationDomainIdentityProvidersListerFinder(t *testing.T) {
WithName("my-ad-idp-that-isnt-in-fd-issuer").
WithResourceUID("my-ad-idp-that-isnt-in-fd-issuer").
Build()).
WithGitHub(myGitHubIDP1).
WithGitHub(oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder().
WithName("my-github-idp-that-isnt-in-fd-issuer").
WithResourceUID("my-github-idp-that-isnt-in-fd-issuer").
Build()).
BuildDynamicUpstreamIDPProvider(),
federationDomainIssuer: fdIssuerWithOIDCAndLDAPAndADIDPs,
wantCount: 5,
federationDomainIssuer: fdIssuerWithOIDCAndLDAPAndADAndGitHubIDPs,
wantCount: 6,
},
}
@@ -478,6 +550,14 @@ func TestFederationDomainIdentityProvidersListerFinder(t *testing.T) {
federationDomainIssuer: fdIssuerWithDefaultLDAPIDP,
wantHasDefaultIDP: true,
},
{
name: "HasDefaultIDP when there is a GitHub provider set as default",
wrappedLister: testidplister.NewUpstreamIDPListerBuilder().
WithGitHub(myDefaultGitHubIDP).
BuildDynamicUpstreamIDPProvider(),
federationDomainIssuer: fdIssuerWithDefaultGitHubIDP,
wantHasDefaultIDP: true,
},
{
name: "HasDefaultIDP when there is one set even if it cannot be found",
wrappedLister: testidplister.NewUpstreamIDPListerBuilder().
@@ -493,7 +573,7 @@ func TestFederationDomainIdentityProvidersListerFinder(t *testing.T) {
name: "HasDefaultIDP when there is none set",
wrappedLister: testidplister.NewUpstreamIDPListerBuilder().
BuildDynamicUpstreamIDPProvider(),
federationDomainIssuer: fdIssuerWithOIDCAndLDAPAndADIDPs,
federationDomainIssuer: fdIssuerWithOIDCAndLDAPAndADAndGitHubIDPs,
wantHasDefaultIDP: false,
},
}
@@ -1,4 +1,4 @@
// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved.
// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package idplister
@@ -19,8 +19,13 @@ type UpstreamActiveDirectoryIdentityProviderLister interface {
GetActiveDirectoryIdentityProviders() []upstreamprovider.UpstreamLDAPIdentityProviderI
}
type UpstreamGitHubIdentityProviderLister interface {
GetGitHubIdentityProviders() []upstreamprovider.UpstreamGithubIdentityProviderI
}
type UpstreamIdentityProvidersLister interface {
UpstreamOIDCIdentityProvidersLister
UpstreamLDAPIdentityProvidersLister
UpstreamActiveDirectoryIdentityProviderLister
UpstreamGitHubIdentityProviderLister
}
@@ -0,0 +1,179 @@
// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package resolvedgithub
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/ory/fosite"
"golang.org/x/oauth2"
"go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1"
"go.pinniped.dev/internal/federationdomain/resolvedprovider"
"go.pinniped.dev/internal/federationdomain/upstreamprovider"
"go.pinniped.dev/internal/httputil/httperr"
"go.pinniped.dev/internal/idtransform"
"go.pinniped.dev/internal/psession"
"go.pinniped.dev/pkg/oidcclient/nonce"
"go.pinniped.dev/pkg/oidcclient/pkce"
)
// FederationDomainResolvedGitHubIdentityProvider represents a FederationDomainIdentityProvider which has
// been resolved dynamically based on the currently loaded IDP CRs to include the provider.UpstreamGitHubIdentityProviderI
// and other metadata about the provider.
type FederationDomainResolvedGitHubIdentityProvider struct {
DisplayName string
Provider upstreamprovider.UpstreamGithubIdentityProviderI
SessionProviderType psession.ProviderType
Transforms *idtransform.TransformationPipeline
}
var _ resolvedprovider.FederationDomainResolvedIdentityProvider = (*FederationDomainResolvedGitHubIdentityProvider)(nil)
func (p *FederationDomainResolvedGitHubIdentityProvider) GetDisplayName() string {
return p.DisplayName
}
func (p *FederationDomainResolvedGitHubIdentityProvider) GetProvider() upstreamprovider.UpstreamIdentityProviderI {
return p.Provider
}
func (p *FederationDomainResolvedGitHubIdentityProvider) GetSessionProviderType() psession.ProviderType {
return p.SessionProviderType
}
func (p *FederationDomainResolvedGitHubIdentityProvider) GetIDPDiscoveryType() v1alpha1.IDPType {
return v1alpha1.IDPTypeGitHub
}
func (p *FederationDomainResolvedGitHubIdentityProvider) GetIDPDiscoveryFlows() []v1alpha1.IDPFlow {
return []v1alpha1.IDPFlow{v1alpha1.IDPFlowBrowserAuthcode}
}
func (p *FederationDomainResolvedGitHubIdentityProvider) GetTransforms() *idtransform.TransformationPipeline {
return p.Transforms
}
func (p *FederationDomainResolvedGitHubIdentityProvider) CloneIDPSpecificSessionDataFromSession(session *psession.CustomSessionData) interface{} {
if session.GitHub == nil {
return nil
}
return session.GitHub.Clone()
}
func (p *FederationDomainResolvedGitHubIdentityProvider) ApplyIDPSpecificSessionDataToSession(session *psession.CustomSessionData, idpSpecificSessionData interface{}) {
session.GitHub = idpSpecificSessionData.(*psession.GitHubSessionData)
}
func (p *FederationDomainResolvedGitHubIdentityProvider) UpstreamAuthorizeRedirectURL(
state *resolvedprovider.UpstreamAuthorizeRequestState,
downstreamIssuerURL string,
) (string, error) {
upstreamOAuthConfig := oauth2.Config{
ClientID: p.Provider.GetClientID(),
Endpoint: oauth2.Endpoint{
AuthURL: p.Provider.GetAuthorizationURL(),
},
RedirectURL: fmt.Sprintf("%s/callback", downstreamIssuerURL),
Scopes: p.Provider.GetScopes(),
}
redirectURL := upstreamOAuthConfig.AuthCodeURL(state.EncodedStateParam)
return redirectURL, nil
}
func (p *FederationDomainResolvedGitHubIdentityProvider) Login(
_ context.Context,
_ string,
_ string,
) (*resolvedprovider.Identity, *resolvedprovider.IdentityLoginExtras, error) {
return nil, nil, errors.New("function Login not yet implemented for GitHub IDP")
}
func (p *FederationDomainResolvedGitHubIdentityProvider) LoginFromCallback(
ctx context.Context,
authCode string,
_ pkce.Code, // GitHub does not support PKCE, see https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps
_ nonce.Nonce, // GitHub does not support OIDC, therefore there is no ID token that could contain the "nonce".
redirectURI string,
) (*resolvedprovider.Identity, *resolvedprovider.IdentityLoginExtras, error) {
accessToken, err := p.Provider.ExchangeAuthcode(ctx, authCode, redirectURI)
if err != nil {
return nil, nil, httperr.Wrap(http.StatusBadGateway,
"failed to exchange authcode using GitHub API",
err,
)
}
user, err := p.Provider.GetUser(ctx, accessToken, p.GetDisplayName())
if errors.As(err, &upstreamprovider.GitHubLoginDeniedError{}) {
// We specifically want errors of type GitHubLoginDeniedError to have a user-displayed message.
// Don't wrap the error since we include it in the sprintf here.
return nil, nil, httperr.Newf(http.StatusForbidden,
"login denied due to configuration on GitHubIdentityProvider with display name %q: %s",
p.GetDisplayName(), err)
} else if err != nil {
return nil, nil, httperr.Wrap(http.StatusUnprocessableEntity,
"failed to get user info from GitHub API",
err,
)
}
return &resolvedprovider.Identity{
UpstreamUsername: user.Username,
UpstreamGroups: user.Groups,
DownstreamSubject: user.DownstreamSubject,
IDPSpecificSessionData: &psession.GitHubSessionData{
UpstreamAccessToken: accessToken,
},
},
&resolvedprovider.IdentityLoginExtras{
DownstreamAdditionalClaims: nil, // not using this for GitHub
Warnings: nil, // not using this for GitHub
},
nil // no error
}
func (p *FederationDomainResolvedGitHubIdentityProvider) UpstreamRefresh(
ctx context.Context,
identity *resolvedprovider.Identity,
) (*resolvedprovider.RefreshedIdentity, error) {
githubSessionData, ok := identity.IDPSpecificSessionData.(*psession.GitHubSessionData)
if !ok {
// This should not really happen.
return nil, p.refreshErr(errors.New("wrong data type found for IDPSpecificSessionData"))
}
if len(githubSessionData.UpstreamAccessToken) == 0 {
// This should not really happen.
return nil, p.refreshErr(errors.New("session is missing GitHub access token"))
}
// Get the user's GitHub identity and groups again using the cached access token.
refreshedUserInfo, err := p.Provider.GetUser(ctx, githubSessionData.UpstreamAccessToken, p.GetDisplayName())
if err != nil {
return nil, p.refreshErr(err)
}
if refreshedUserInfo.DownstreamSubject != identity.DownstreamSubject {
// The user's upstream identity changed since the initial login in a surprising way.
return nil, p.refreshErr(fmt.Errorf("user's calculated downstream subject at initial login was %q but now is %q",
identity.DownstreamSubject, refreshedUserInfo.DownstreamSubject))
}
return &resolvedprovider.RefreshedIdentity{
UpstreamUsername: refreshedUserInfo.Username,
UpstreamGroups: refreshedUserInfo.Groups,
IDPSpecificSessionData: nil, // nil means that no update to the GitHub-specific portion of the session data is required
}, nil
}
func (p *FederationDomainResolvedGitHubIdentityProvider) refreshErr(err error) *fosite.RFC6749Error {
return resolvedprovider.ErrUpstreamRefreshError().
WithHint("Upstream refresh failed.").
WithTrace(err).
WithDebugf("provider name: %q, provider type: %q", p.Provider.GetResourceName(), p.GetSessionProviderType())
}
@@ -0,0 +1,452 @@
// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package resolvedgithub
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/ory/fosite"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
idpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1"
idpdiscoveryv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1"
"go.pinniped.dev/internal/federationdomain/resolvedprovider"
"go.pinniped.dev/internal/federationdomain/upstreamprovider"
"go.pinniped.dev/internal/httputil/httperr"
"go.pinniped.dev/internal/psession"
"go.pinniped.dev/internal/setutil"
"go.pinniped.dev/internal/testutil/oidctestutil"
"go.pinniped.dev/internal/testutil/transformtestutil"
"go.pinniped.dev/internal/upstreamgithub"
)
func TestFederationDomainResolvedGitHubIdentityProvider(t *testing.T) {
transforms := transformtestutil.NewRejectAllAuthPipeline(t)
provider := upstreamgithub.New(upstreamgithub.ProviderConfig{
Name: "fake-provider-config",
ResourceUID: "fake-resource-uid",
APIBaseURL: "https://fake-api-host.com",
UsernameAttribute: idpv1alpha1.GitHubUsernameID,
GroupNameAttribute: idpv1alpha1.GitHubUseTeamSlugForGroupName,
AllowedOrganizations: setutil.NewCaseInsensitiveSet("org1", "org2"),
HttpClient: nil, // not needed yet for this test
OAuth2Config: &oauth2.Config{
ClientID: "fake-client-id",
ClientSecret: "fake-client-secret",
Scopes: []string{"read:user", "read:org"},
Endpoint: oauth2.Endpoint{
AuthURL: "https://fake-authorization-url",
DeviceAuthURL: "",
TokenURL: "https://fake-token-url",
AuthStyle: oauth2.AuthStyleInParams,
},
},
})
subject := FederationDomainResolvedGitHubIdentityProvider{
DisplayName: "fake-display-name",
Provider: provider,
SessionProviderType: psession.ProviderTypeGitHub,
Transforms: transforms,
}
require.Equal(t, "fake-display-name", subject.GetDisplayName())
require.Equal(t, provider, subject.GetProvider())
require.Equal(t, psession.ProviderTypeGitHub, subject.GetSessionProviderType())
require.Equal(t, idpdiscoveryv1alpha1.IDPTypeGitHub, subject.GetIDPDiscoveryType())
require.Equal(t, []idpdiscoveryv1alpha1.IDPFlow{idpdiscoveryv1alpha1.IDPFlowBrowserAuthcode}, subject.GetIDPDiscoveryFlows())
require.Equal(t, transforms, subject.GetTransforms())
originalCustomSession := &psession.CustomSessionData{
Username: "fake-username",
UpstreamUsername: "fake-upstream-username",
GitHub: &psession.GitHubSessionData{UpstreamAccessToken: "fake-upstream-access-token"},
}
clonedCustomSession := subject.CloneIDPSpecificSessionDataFromSession(originalCustomSession)
require.Equal(t,
&psession.GitHubSessionData{UpstreamAccessToken: "fake-upstream-access-token"},
clonedCustomSession,
)
require.NotSame(t, originalCustomSession, clonedCustomSession)
customSessionToBeMutated := &psession.CustomSessionData{
Username: "fake-username2",
UpstreamUsername: "fake-upstream-username2",
}
subject.ApplyIDPSpecificSessionDataToSession(customSessionToBeMutated, &psession.GitHubSessionData{UpstreamAccessToken: "OTHER-upstream-access-token"})
require.Equal(t, &psession.CustomSessionData{
Username: "fake-username2",
UpstreamUsername: "fake-upstream-username2",
GitHub: &psession.GitHubSessionData{UpstreamAccessToken: "OTHER-upstream-access-token"},
}, customSessionToBeMutated)
redirectURL, err := subject.UpstreamAuthorizeRedirectURL(
&resolvedprovider.UpstreamAuthorizeRequestState{
EncodedStateParam: "encodedStateParam12345",
PKCE: "pkce6789",
Nonce: "nonce1289",
},
"https://localhost/fake/path",
)
require.NoError(t, err)
// Note that GitHub does not require (or document) the standard response_type=code param, but in manual testing
// of GitHub authorize endpoint, it seems to ignore the param. The oauth2 package wants to add the param, so
// we will let it.
require.Equal(t,
"https://fake-authorization-url?"+
"client_id=fake-client-id&"+
"redirect_uri=https%3A%2F%2Flocalhost%2Ffake%2Fpath%2Fcallback&"+
"response_type=code&"+
"scope=read%3Auser+read%3Aorg&"+
"state=encodedStateParam12345",
redirectURL,
)
}
func TestLoginFromCallback(t *testing.T) {
uniqueCtx := context.WithValue(context.Background(), "some-unique-key", "some-value") //nolint:staticcheck // okay to use string key for test
tests := []struct {
name string
provider *oidctestutil.TestUpstreamGitHubIdentityProvider
idpDisplayName string
authcode string
redirectURI string
wantExchangeAuthcodeCall bool
wantExchangeAuthcodeArgs *oidctestutil.ExchangeAuthcodeArgs
wantGetUserCall bool
wantGetUserArgs *oidctestutil.GetUserArgs
wantIdentity *resolvedprovider.Identity
wantExtras *resolvedprovider.IdentityLoginExtras
wantErrMsg string
wantErrResponseMsg string
wantErrStatusCode int
}{
{
name: "happy path",
provider: oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder().
WithAccessToken("fake-access-token").
WithUser(&upstreamprovider.GitHubUser{
Username: "fake-username",
Groups: []string{"fake-group1", "fake-group2"},
DownstreamSubject: "https://fake-downstream-subject",
}).
Build(),
idpDisplayName: "fake-display-name",
authcode: "fake-authcode",
redirectURI: "https://fake-redirect-uri",
wantExchangeAuthcodeCall: true,
wantExchangeAuthcodeArgs: &oidctestutil.ExchangeAuthcodeArgs{
Ctx: uniqueCtx,
Authcode: "fake-authcode",
RedirectURI: "https://fake-redirect-uri",
},
wantGetUserCall: true,
wantGetUserArgs: &oidctestutil.GetUserArgs{
Ctx: uniqueCtx,
AccessToken: "fake-access-token",
IDPDisplayName: "fake-display-name",
},
wantIdentity: &resolvedprovider.Identity{
UpstreamUsername: "fake-username",
UpstreamGroups: []string{"fake-group1", "fake-group2"},
DownstreamSubject: "https://fake-downstream-subject",
IDPSpecificSessionData: &psession.GitHubSessionData{
UpstreamAccessToken: "fake-access-token",
},
},
wantExtras: &resolvedprovider.IdentityLoginExtras{},
},
{
name: "error while exchanging authcode",
provider: oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder().
WithAuthcodeExchangeError(errors.New("fake authcode exchange error")).
Build(),
idpDisplayName: "fake-display-name",
authcode: "fake-authcode",
redirectURI: "https://fake-redirect-uri",
wantExchangeAuthcodeCall: true,
wantExchangeAuthcodeArgs: &oidctestutil.ExchangeAuthcodeArgs{
Ctx: uniqueCtx,
Authcode: "fake-authcode",
RedirectURI: "https://fake-redirect-uri",
},
wantGetUserCall: false,
wantIdentity: nil,
wantExtras: nil,
wantErrMsg: "failed to exchange authcode using GitHub API: fake authcode exchange error",
wantErrResponseMsg: "Bad Gateway: failed to exchange authcode using GitHub API",
wantErrStatusCode: http.StatusBadGateway,
},
{
name: "generic error while getting user info",
provider: oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder().
WithAccessToken("fake-access-token").
WithGetUserError(errors.New("fake user info error")).
Build(),
idpDisplayName: "fake-display-name",
authcode: "fake-authcode",
redirectURI: "https://fake-redirect-uri",
wantExchangeAuthcodeCall: true,
wantExchangeAuthcodeArgs: &oidctestutil.ExchangeAuthcodeArgs{
Ctx: uniqueCtx,
Authcode: "fake-authcode",
RedirectURI: "https://fake-redirect-uri",
},
wantGetUserCall: true,
wantGetUserArgs: &oidctestutil.GetUserArgs{
Ctx: uniqueCtx,
AccessToken: "fake-access-token",
IDPDisplayName: "fake-display-name",
},
wantIdentity: nil,
wantExtras: nil,
wantErrMsg: "failed to get user info from GitHub API: fake user info error",
wantErrResponseMsg: "Unprocessable Entity: failed to get user info from GitHub API",
wantErrStatusCode: http.StatusUnprocessableEntity,
},
{
name: "loginDenied error while getting user info",
provider: oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder().
WithAccessToken("fake-access-token").
WithGetUserError(upstreamprovider.NewGitHubLoginDeniedError("some login denied error")).
Build(),
idpDisplayName: "fake-display-name",
authcode: "fake-authcode",
redirectURI: "https://fake-redirect-uri",
wantExchangeAuthcodeCall: true,
wantExchangeAuthcodeArgs: &oidctestutil.ExchangeAuthcodeArgs{
Ctx: uniqueCtx,
Authcode: "fake-authcode",
RedirectURI: "https://fake-redirect-uri",
},
wantGetUserCall: true,
wantGetUserArgs: &oidctestutil.GetUserArgs{
Ctx: uniqueCtx,
AccessToken: "fake-access-token",
IDPDisplayName: "fake-display-name",
},
wantIdentity: nil,
wantExtras: nil,
wantErrMsg: `login denied due to configuration on GitHubIdentityProvider with display name "fake-display-name": some login denied error`,
wantErrResponseMsg: `Forbidden: login denied due to configuration on GitHubIdentityProvider with display name "fake-display-name": some login denied error`,
wantErrStatusCode: http.StatusForbidden,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
subject := FederationDomainResolvedGitHubIdentityProvider{
DisplayName: test.idpDisplayName,
Provider: test.provider,
SessionProviderType: psession.ProviderTypeGitHub,
Transforms: transformtestutil.NewRejectAllAuthPipeline(t),
}
identity, loginExtras, err := subject.LoginFromCallback(uniqueCtx,
test.authcode,
"pkce-will-be-ignored",
"nonce-will-be-ignored",
test.redirectURI,
)
if test.wantExchangeAuthcodeCall {
require.Equal(t, 1, test.provider.ExchangeAuthcodeCallCount())
require.Equal(t, test.wantExchangeAuthcodeArgs, test.provider.ExchangeAuthcodeArgs(0))
} else {
require.Zero(t, test.provider.ExchangeAuthcodeCallCount())
}
if test.wantGetUserCall {
require.Equal(t, 1, test.provider.GetUserCallCount())
require.Equal(t, test.wantGetUserArgs, test.provider.GetUserArgs(0))
} else {
require.Zero(t, test.provider.GetUserCallCount())
}
if test.wantErrResponseMsg == "" {
require.NoError(t, err)
} else {
require.Implements(t, (*httperr.Responder)(nil), err)
errAsResponder := err.(httperr.Responder)
rec := httptest.NewRecorder()
errAsResponder.Respond(rec)
require.Equal(t, test.wantErrStatusCode, rec.Code)
require.Equal(t, test.wantErrResponseMsg+"\n", rec.Body.String())
require.EqualError(t, errAsResponder, test.wantErrMsg)
}
require.Equal(t, test.wantExtras, loginExtras)
require.Equal(t, test.wantIdentity, identity)
})
}
}
func TestUpstreamRefresh(t *testing.T) {
uniqueCtx := context.WithValue(context.Background(), "some-unique-key", "some-value") //nolint:staticcheck // okay to use string key for test
tests := []struct {
name string
provider *oidctestutil.TestUpstreamGitHubIdentityProvider
idpDisplayName string
identity *resolvedprovider.Identity
wantGetUserCall bool
wantGetUserArgs *oidctestutil.GetUserArgs
wantRefreshedIdentity *resolvedprovider.RefreshedIdentity
wantWrappedErr string
}{
{
name: "happy path",
provider: oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder().
WithUser(&upstreamprovider.GitHubUser{
Username: "refreshed-username",
Groups: []string{"refreshed-group1", "refreshed-group2"},
DownstreamSubject: "https://fake-downstream-subject",
}).
Build(),
identity: &resolvedprovider.Identity{
UpstreamUsername: "initial-username",
UpstreamGroups: []string{"initial-group1", "initial-group2"},
DownstreamSubject: "https://fake-downstream-subject",
IDPSpecificSessionData: &psession.GitHubSessionData{UpstreamAccessToken: "fake-access-token"},
},
idpDisplayName: "fake-display-name",
wantGetUserCall: true,
wantGetUserArgs: &oidctestutil.GetUserArgs{
Ctx: uniqueCtx,
AccessToken: "fake-access-token",
IDPDisplayName: "fake-display-name",
},
wantRefreshedIdentity: &resolvedprovider.RefreshedIdentity{
UpstreamUsername: "refreshed-username",
UpstreamGroups: []string{"refreshed-group1", "refreshed-group2"},
IDPSpecificSessionData: nil,
},
},
{
name: "error while getting user info",
provider: oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder().
WithName("fake-provider-name").
WithGetUserError(errors.New("fake github GetUser error message")).
Build(),
identity: &resolvedprovider.Identity{
UpstreamUsername: "initial-username",
UpstreamGroups: []string{"initial-group1", "initial-group2"},
DownstreamSubject: "https://fake-downstream-subject",
IDPSpecificSessionData: &psession.GitHubSessionData{UpstreamAccessToken: "fake-access-token"},
},
idpDisplayName: "fake-display-name",
wantGetUserCall: true,
wantGetUserArgs: &oidctestutil.GetUserArgs{
Ctx: uniqueCtx,
AccessToken: "fake-access-token",
IDPDisplayName: "fake-display-name",
},
wantRefreshedIdentity: nil,
wantWrappedErr: "fake github GetUser error message",
},
{
name: "wrong session data type, which should not really happen",
provider: oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder().
WithName("fake-provider-name").
Build(),
identity: &resolvedprovider.Identity{
UpstreamUsername: "initial-username",
UpstreamGroups: []string{"initial-group1", "initial-group2"},
DownstreamSubject: "https://fake-downstream-subject",
IDPSpecificSessionData: &psession.LDAPSessionData{}, // wrong type
},
idpDisplayName: "fake-display-name",
wantGetUserCall: false,
wantRefreshedIdentity: nil,
wantWrappedErr: "wrong data type found for IDPSpecificSessionData",
},
{
name: "session is missing github access token, which should not really happen",
provider: oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder().
WithName("fake-provider-name").
Build(),
identity: &resolvedprovider.Identity{
UpstreamUsername: "initial-username",
UpstreamGroups: []string{"initial-group1", "initial-group2"},
DownstreamSubject: "https://fake-downstream-subject",
IDPSpecificSessionData: &psession.GitHubSessionData{UpstreamAccessToken: ""}, // missing token
},
idpDisplayName: "fake-display-name",
wantGetUserCall: false,
wantRefreshedIdentity: nil,
wantWrappedErr: "session is missing GitHub access token",
},
{
name: "users downstream subject changes based on an unexpected change in the upstream identity",
provider: oidctestutil.NewTestUpstreamGitHubIdentityProviderBuilder().
WithName("fake-provider-name").
WithUser(&upstreamprovider.GitHubUser{
Username: "refreshed-username",
Groups: []string{"refreshed-group1", "refreshed-group2"},
DownstreamSubject: "https://unexpected-different-downstream-subject", // unexpected change in calculated subject during refresh
}).
Build(),
identity: &resolvedprovider.Identity{
UpstreamUsername: "initial-username",
UpstreamGroups: []string{"initial-group1", "initial-group2"},
DownstreamSubject: "https://fake-downstream-subject",
IDPSpecificSessionData: &psession.GitHubSessionData{UpstreamAccessToken: "fake-access-token"},
},
idpDisplayName: "fake-display-name",
wantGetUserCall: true,
wantGetUserArgs: &oidctestutil.GetUserArgs{
Ctx: uniqueCtx,
AccessToken: "fake-access-token",
IDPDisplayName: "fake-display-name",
},
wantRefreshedIdentity: nil,
wantWrappedErr: `user's calculated downstream subject at initial login was "https://fake-downstream-subject" ` +
`but now is "https://unexpected-different-downstream-subject"`,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
subject := FederationDomainResolvedGitHubIdentityProvider{
DisplayName: test.idpDisplayName,
Provider: test.provider,
SessionProviderType: psession.ProviderTypeGitHub,
Transforms: transformtestutil.NewRejectAllAuthPipeline(t),
}
refreshedIdentity, err := subject.UpstreamRefresh(uniqueCtx, test.identity)
if test.wantGetUserCall {
require.Equal(t, 1, test.provider.GetUserCallCount())
require.Equal(t, test.wantGetUserArgs, test.provider.GetUserArgs(0))
} else {
require.Zero(t, test.provider.GetUserCallCount())
}
if test.wantWrappedErr == "" {
require.NoError(t, err)
} else {
require.NotNil(t, err, "expected to get an error but did not get one")
errAsFositeErr, ok := err.(*fosite.RFC6749Error)
require.True(t, ok)
require.EqualError(t, errAsFositeErr.Unwrap(), test.wantWrappedErr)
require.Equal(t, "error", errAsFositeErr.ErrorField)
require.Equal(t, "Error during upstream refresh.", errAsFositeErr.DescriptionField)
require.Equal(t, http.StatusUnauthorized, errAsFositeErr.CodeField)
require.Equal(t, `provider name: "fake-provider-name", provider type: "github"`, errAsFositeErr.DebugField)
}
require.Equal(t, test.wantRefreshedIdentity, refreshedIdentity)
})
}
}
@@ -76,7 +76,7 @@ func (p *FederationDomainResolvedLDAPIdentityProvider) CloneIDPSpecificSessionDa
return nil
}
return session.ActiveDirectory.Clone()
case psession.ProviderTypeOIDC: // this is just here to avoid a lint error about not handling all cases
case psession.ProviderTypeOIDC, psession.ProviderTypeGitHub: // this is just here to avoid a lint error about not handling all cases
fallthrough
default:
return nil
@@ -128,7 +128,7 @@ func (p *FederationDomainResolvedLDAPIdentityProvider) Login(
) (*resolvedprovider.Identity, *resolvedprovider.IdentityLoginExtras, error) {
authenticateResponse, authenticated, err := p.Provider.AuthenticateUser(ctx, submittedUsername, submittedPassword)
if err != nil {
plog.WarningErr("unexpected error during upstream LDAP authentication", err, "upstreamName", p.Provider.GetName())
plog.WarningErr("unexpected error during upstream LDAP authentication", err, "upstreamName", p.Provider.GetResourceName())
return nil, nil, ErrUnexpectedUpstreamLDAPError.WithWrap(err)
}
if !authenticated {
@@ -151,7 +151,7 @@ func (p *FederationDomainResolvedLDAPIdentityProvider) Login(
UserDN: authenticateResponse.DN,
ExtraRefreshAttributes: authenticateResponse.ExtraRefreshAttributes,
}
case psession.ProviderTypeOIDC: // this is just here to avoid a lint error about not handling all cases
case psession.ProviderTypeOIDC, psession.ProviderTypeGitHub: // this is just here to avoid a lint error about not handling all cases
fallthrough
default:
return nil, nil, ErrUnexpectedUpstreamLDAPError.WithWrap(fmt.Errorf("unexpected provider type %q", p.GetSessionProviderType()))
@@ -205,13 +205,13 @@ func (p *FederationDomainResolvedLDAPIdentityProvider) UpstreamRefresh(
}
dn = sessionData.UserDN
additionalAttributes = sessionData.ExtraRefreshAttributes
case psession.ProviderTypeOIDC: // this is just here to avoid a lint error about not handling all cases
case psession.ProviderTypeOIDC, psession.ProviderTypeGitHub: // this is just here to avoid a lint error about not handling all cases
fallthrough
default:
// This shouldn't really happen.
return nil, resolvedprovider.ErrUpstreamRefreshError().WithHintf(
"Unexpected provider type during refresh %q", p.GetSessionProviderType()).WithTrace(err).
WithDebugf("provider name: %q, provider type: %q", p.Provider.GetName(), p.GetSessionProviderType())
WithDebugf("provider name: %q, provider type: %q", p.Provider.GetResourceName(), p.GetSessionProviderType())
}
if dn == "" {
@@ -219,9 +219,11 @@ func (p *FederationDomainResolvedLDAPIdentityProvider) UpstreamRefresh(
}
plog.Debug("attempting upstream refresh request",
"providerName", p.Provider.GetName(), "providerType", p.GetSessionProviderType(), "providerUID", p.Provider.GetResourceUID())
"identityProviderResourceName", p.Provider.GetResourceName(),
"identityProviderType", p.GetSessionProviderType(),
"identityProviderUID", p.Provider.GetResourceUID())
refreshedUntransformedGroups, err := p.Provider.PerformRefresh(ctx, upstreamprovider.RefreshAttributes{
refreshedUntransformedGroups, err := p.Provider.PerformRefresh(ctx, upstreamprovider.LDAPRefreshAttributes{
Username: identity.UpstreamUsername,
Subject: identity.DownstreamSubject,
DN: dn,
@@ -231,7 +233,7 @@ func (p *FederationDomainResolvedLDAPIdentityProvider) UpstreamRefresh(
if err != nil {
return nil, resolvedprovider.ErrUpstreamRefreshError().WithHint(
"Upstream refresh failed.").WithTrace(err).
WithDebugf("provider name: %q, provider type: %q", p.Provider.GetName(), p.GetSessionProviderType())
WithDebugf("provider name: %q, provider type: %q", p.Provider.GetResourceName(), p.GetSessionProviderType())
}
return &resolvedprovider.RefreshedIdentity{
@@ -191,8 +191,7 @@ func (p *FederationDomainResolvedOIDCIdentityProvider) LoginFromCallback(
redirectURI,
)
if err != nil {
plog.WarningErr("error exchanging and validating upstream tokens", err, "upstreamName", p.Provider.GetName())
return nil, nil, httperr.New(http.StatusBadGateway, "error exchanging and validating upstream tokens")
return nil, nil, httperr.Wrap(http.StatusBadGateway, "error exchanging and validating upstream tokens", err)
}
subject, upstreamUsername, upstreamGroups, err := getIdentityFromUpstreamIDToken(
@@ -241,7 +240,9 @@ func (p *FederationDomainResolvedOIDCIdentityProvider) UpstreamRefresh(
}
plog.Debug("attempting upstream refresh request",
"providerName", p.Provider.GetName(), "providerType", p.GetSessionProviderType(), "providerUID", p.Provider.GetResourceUID())
"identityProviderResourceName", p.Provider.GetResourceName(),
"identityProviderType", p.GetSessionProviderType(),
"identityProviderUID", p.Provider.GetResourceUID())
var tokens *oauth2.Token
if refreshTokenStored {
@@ -249,7 +250,7 @@ func (p *FederationDomainResolvedOIDCIdentityProvider) UpstreamRefresh(
if err != nil {
return nil, resolvedprovider.ErrUpstreamRefreshError().WithHint(
"Upstream refresh failed.",
).WithTrace(err).WithDebugf("provider name: %q, provider type: %q", p.Provider.GetName(), p.GetSessionProviderType())
).WithTrace(err).WithDebugf("provider name: %q, provider type: %q", p.Provider.GetResourceName(), p.GetSessionProviderType())
}
} else {
tokens = &oauth2.Token{AccessToken: sessionData.UpstreamAccessToken}
@@ -270,13 +271,13 @@ func (p *FederationDomainResolvedOIDCIdentityProvider) UpstreamRefresh(
if err != nil {
return nil, resolvedprovider.ErrUpstreamRefreshError().WithHintf(
"Upstream refresh returned an invalid ID token or UserInfo response.").WithTrace(err).
WithDebugf("provider name: %q, provider type: %q", p.Provider.GetName(), p.GetSessionProviderType())
WithDebugf("provider name: %q, provider type: %q", p.Provider.GetResourceName(), p.GetSessionProviderType())
}
mergedClaims := validatedTokens.IDToken.Claims
// To the extent possible, check that the user's basic identity hasn't changed. We check that their downstream
// username has not changed separately below, as part of reapplying the transformations.
err = validateUpstreamSubjectAndIssuerUnchangedSinceInitialLogin(mergedClaims, sessionData, p.Provider.GetName(), p.GetSessionProviderType())
err = validateUpstreamSubjectAndIssuerUnchangedSinceInitialLogin(mergedClaims, sessionData, p.Provider.GetResourceName(), p.GetSessionProviderType())
if err != nil {
return nil, err
}
@@ -292,7 +293,7 @@ func (p *FederationDomainResolvedOIDCIdentityProvider) UpstreamRefresh(
if err != nil {
return nil, resolvedprovider.ErrUpstreamRefreshError().WithHintf(
"Upstream refresh error while extracting groups claim.").WithTrace(err).
WithDebugf("provider name: %q, provider type: %q", p.Provider.GetName(), p.GetSessionProviderType())
WithDebugf("provider name: %q, provider type: %q", p.Provider.GetResourceName(), p.GetSessionProviderType())
}
// It's possible that a username wasn't returned by the upstream provider during refresh,
@@ -312,7 +313,9 @@ func (p *FederationDomainResolvedOIDCIdentityProvider) UpstreamRefresh(
// overwriting the old one.
if tokens.RefreshToken != "" {
plog.Debug("upstream refresh request returned a new refresh token",
"providerName", p.Provider.GetName(), "providerType", p.GetSessionProviderType(), "providerUID", p.Provider.GetResourceUID())
"identityProviderResourceName", p.Provider.GetResourceName(),
"identityProviderType", p.GetSessionProviderType(),
"identityProviderUID", p.Provider.GetResourceUID())
updatedSessionData.UpstreamRefreshToken = tokens.RefreshToken
}
@@ -370,11 +373,11 @@ func makeDownstreamOIDCSessionData(
oidcUpstream upstreamprovider.UpstreamOIDCIdentityProviderI,
token *oidctypes.Token,
) (*psession.OIDCSessionData, []string, error) {
upstreamSubject, err := extractStringClaimValue(oidc.IDTokenClaimSubject, oidcUpstream.GetName(), token.IDToken.Claims)
upstreamSubject, err := extractStringClaimValue(oidc.IDTokenClaimSubject, oidcUpstream.GetResourceName(), token.IDToken.Claims)
if err != nil {
return nil, nil, err
}
upstreamIssuer, err := extractStringClaimValue(oidc.IDTokenClaimIssuer, oidcUpstream.GetName(), token.IDToken.Claims)
upstreamIssuer, err := extractStringClaimValue(oidc.IDTokenClaimIssuer, oidcUpstream.GetResourceName(), token.IDToken.Claims)
if err != nil {
return nil, nil, err
}
@@ -387,7 +390,7 @@ func makeDownstreamOIDCSessionData(
const pleaseCheck = "please check configuration of OIDCIdentityProvider and the client in the " +
"upstream provider's API/UI and try to get a refresh token if possible"
logKV := []any{
"upstreamName", oidcUpstream.GetName(),
"identityProviderResourceName", oidcUpstream.GetResourceName(),
"scopes", oidcUpstream.GetScopes(),
"additionalParams", oidcUpstream.GetAdditionalAuthcodeParams(),
}
@@ -452,7 +455,7 @@ func mapAdditionalClaimsFromUpstreamIDToken(
if !ok {
plog.Warning(
"additionalClaims mapping claim in upstream ID token missing",
"upstreamName", upstreamIDPConfig.GetName(),
"identityProviderResourceName", upstreamIDPConfig.GetResourceName(),
"claimName", upstreamClaimName,
)
} else {
@@ -469,11 +472,11 @@ func getDownstreamSubjectAndUpstreamUsernameFromUpstreamIDToken(
) (string, string, error) {
// The spec says the "sub" claim is only unique per issuer,
// so we will prepend the issuer string to make it globally unique.
upstreamIssuer, err := extractStringClaimValue(oidc.IDTokenClaimIssuer, upstreamIDPConfig.GetName(), idTokenClaims)
upstreamIssuer, err := extractStringClaimValue(oidc.IDTokenClaimIssuer, upstreamIDPConfig.GetResourceName(), idTokenClaims)
if err != nil {
return "", "", err
}
upstreamSubject, err := extractStringClaimValue(oidc.IDTokenClaimSubject, upstreamIDPConfig.GetName(), idTokenClaims)
upstreamSubject, err := extractStringClaimValue(oidc.IDTokenClaimSubject, upstreamIDPConfig.GetResourceName(), idTokenClaims)
if err != nil {
return "", "", err
}
@@ -492,7 +495,7 @@ func getDownstreamSubjectAndUpstreamUsernameFromUpstreamIDToken(
if !ok {
plog.Warning(
"username claim configured as \"email\" and upstream email_verified claim is not a boolean",
"upstreamName", upstreamIDPConfig.GetName(),
"identityProviderResourceName", upstreamIDPConfig.GetResourceName(),
"configuredUsernameClaim", usernameClaimName,
"emailVerifiedClaim", emailVerifiedAsInterface,
)
@@ -501,14 +504,14 @@ func getDownstreamSubjectAndUpstreamUsernameFromUpstreamIDToken(
if !emailVerified {
plog.Warning(
"username claim configured as \"email\" and upstream email_verified claim has false value",
"upstreamName", upstreamIDPConfig.GetName(),
"identityProviderResourceName", upstreamIDPConfig.GetResourceName(),
"configuredUsernameClaim", usernameClaimName,
)
return "", "", emailVerifiedClaimFalseErr
}
}
username, err := extractStringClaimValue(usernameClaimName, upstreamIDPConfig.GetName(), idTokenClaims)
username, err := extractStringClaimValue(usernameClaimName, upstreamIDPConfig.GetResourceName(), idTokenClaims)
if err != nil {
return "", "", err
}
@@ -571,7 +574,7 @@ func getGroupsFromUpstreamIDToken(
if !ok {
plog.Warning(
"no groups claim in upstream ID token",
"upstreamName", upstreamIDPConfig.GetName(),
"identityProviderResourceName", upstreamIDPConfig.GetResourceName(),
"configuredGroupsClaim", groupsClaimName,
)
return nil, nil // the upstream IDP may have omitted the claim if the user has no groups
@@ -581,7 +584,7 @@ func getGroupsFromUpstreamIDToken(
if !okAsArray {
plog.Warning(
"groups claim in upstream ID token has invalid format",
"upstreamName", upstreamIDPConfig.GetName(),
"identityProviderResourceName", upstreamIDPConfig.GetResourceName(),
"configuredGroupsClaim", groupsClaimName,
)
return nil, requiredClaimInvalidFormatErr
@@ -10,7 +10,9 @@ import (
"golang.org/x/oauth2"
"k8s.io/apimachinery/pkg/types"
"go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1"
"go.pinniped.dev/internal/authenticators"
"go.pinniped.dev/internal/setutil"
"go.pinniped.dev/pkg/oidcclient/nonce"
"go.pinniped.dev/pkg/oidcclient/oidctypes"
"go.pinniped.dev/pkg/oidcclient/pkce"
@@ -24,9 +26,9 @@ const (
AccessTokenType RevocableTokenType = "access_token"
)
// RefreshAttributes contains information about the user from the original login request
// LDAPRefreshAttributes contains information about the user from the original login request
// and previous refreshes to be used during an LDAP session refresh.
type RefreshAttributes struct {
type LDAPRefreshAttributes struct {
Username string
Subject string
DN string
@@ -37,11 +39,11 @@ type RefreshAttributes struct {
// UpstreamIdentityProviderI includes the interface functions that are common to all upstream identity provider types.
// These represent the identity provider resources, i.e. OIDCIdentityProvider, etc.
type UpstreamIdentityProviderI interface {
// GetName returns a name for this upstream provider. The controller watching the identity provider resources will
// GetResourceName returns a name for this upstream provider. The controller watching the identity provider resources will
// set this to be the Name of the CR from its metadata. Note that this is different from the DisplayName configured
// in each FederationDomain that uses this provider, so this name is for internal use only, not for interacting
// with clients. Clients should not expect to see this name or send this name.
GetName() string
GetResourceName() string
// GetResourceUID returns the Kubernetes resource ID
GetResourceUID() types.UID
@@ -123,5 +125,69 @@ type UpstreamLDAPIdentityProviderI interface {
authenticators.UserAuthenticator
// PerformRefresh performs a refresh against the upstream LDAP identity provider
PerformRefresh(ctx context.Context, storedRefreshAttributes RefreshAttributes, idpDisplayName string) (groups []string, err error)
PerformRefresh(ctx context.Context, storedRefreshAttributes LDAPRefreshAttributes, idpDisplayName string) (groups []string, err error)
}
type GitHubUser struct {
Username string // could be login name, id, or login:id
Groups []string // could be names or slugs
DownstreamSubject string // the whole downstream subject URI
}
// GitHubLoginDeniedError can be returned by UpstreamGithubIdentityProviderI GetUser() when a policy
// configured on GitHubIdentityProvider should prevent this user from completing authentication.
type GitHubLoginDeniedError struct {
message string
}
func NewGitHubLoginDeniedError(message string) GitHubLoginDeniedError {
return GitHubLoginDeniedError{message: message}
}
func (g GitHubLoginDeniedError) Error() string {
return g.message
}
var _ error = &GitHubLoginDeniedError{}
type UpstreamGithubIdentityProviderI interface {
UpstreamIdentityProviderI
// GetClientID returns the OAuth client ID registered with the upstream provider to be used in the authorization code flow.
GetClientID() string
// GetScopes returns the scopes to request in authorization (authcode or password grant) flow.
GetScopes() []string
// GetUsernameAttribute returns the attribute from the GitHub API user response to use for the downstream username.
// See https://docs.github.com/en/rest/users/users?apiVersion=2022-11-28#get-the-authenticated-user.
// Note that this is a constructed value - do not expect that the result will exactly match one of the JSON fields.
GetUsernameAttribute() v1alpha1.GitHubUsernameAttribute
// GetGroupNameAttribute returns the attribute from the GitHub API team response to use for the downstream group names.
// See https://docs.github.com/en/rest/teams/teams?apiVersion=2022-11-28#list-teams-for-the-authenticated-user.
// Note that this is a constructed value - do not expect that the result will exactly match one of the JSON fields.
GetGroupNameAttribute() v1alpha1.GitHubGroupNameAttribute
// GetAllowedOrganizations returns a list of organizations configured to allow authentication.
// If this list has contents, a user must have membership in at least one of these organizations to log in,
// and only teams from the listed organizations should be represented as groups for the downstream token.
// If this list is empty, then any user can log in regardless of org membership, and any observable
// teams memberships should be represented as groups for the downstream token.
GetAllowedOrganizations() *setutil.CaseInsensitiveSet
// GetAuthorizationURL returns the authorization URL for the configured GitHub. This will look like:
// https://<spec.githubAPI.host>/login/oauth/authorize
// It will not include any query parameters or fragment. Any subdomains or port will come from <spec.githubAPI.host>.
// It will never include a username or password in the authority section.
GetAuthorizationURL() string
// ExchangeAuthcode performs an upstream GitHub authorization code exchange.
// Returns the raw access token. The access token expiry is not known.
ExchangeAuthcode(ctx context.Context, authcode string, redirectURI string) (string, error)
// GetUser calls the user, orgs, and teams APIs of GitHub using the accessToken.
// It validates any required org memberships. It returns a User or an error.
// The IDP display name is passed to aid in building a suitable downstream subject string.
GetUser(ctx context.Context, accessToken string, idpDisplayName string) (*GitHubUser, error)
}
@@ -35,7 +35,8 @@ const (
// Version 5 is when we added the UpstreamUsername and UpstreamGroups fields to psession.CustomSessionData.
// Version 6 is when we upgraded fosite in Dec 2023.
// Version 7 is when OIDCClients were given configurable ID token lifetimes.
accessTokenStorageVersion = "7"
// Version 8 is when GitHubIdentityProvider was added.
accessTokenStorageVersion = "8"
)
type RevocationStorage interface {
@@ -30,7 +30,7 @@ import (
const (
namespace = "test-ns"
expectedVersion = "7" // update this when you update the storage version in the production code
expectedVersion = "8" // update this when you update the storage version in the production code
)
var (
@@ -36,7 +36,8 @@ const (
// Version 5 is when we added the UpstreamUsername and UpstreamGroups fields to psession.CustomSessionData.
// Version 6 is when we upgraded fosite in Dec 2023.
// Version 7 is when OIDCClients were given configurable ID token lifetimes.
authorizeCodeStorageVersion = "7"
// Version 8 is when GitHubIdentityProvider was added.
authorizeCodeStorageVersion = "8"
)
var _ fositeoauth2.AuthorizeCodeStorage = &authorizeCodeStorage{}
@@ -265,7 +266,7 @@ const ExpectedAuthorizeCodeSessionJSONFromFuzzing = `{
],
"request_object_signing_alg": "廜+v,淬Ʋ4Dʧ呩锏緍场脋",
"token_endpoint_auth_signing_alg": "ưƓǴ罷ǹ~]ea胠Ĺĩv絹b垇I",
"IDTokenLifetimeConfiguration":2.593156354696909e+18
"IDTokenLifetimeConfiguration": 2593156354696908951
},
"scopes": [
"ǀŻQ'k頂箨J-",
@@ -382,17 +383,20 @@ const ExpectedAuthorizeCodeSessionJSONFromFuzzing = `{
"IȽ齤士bEǎ": "跞@)¿,ɭS隑ip偶宾儮猷V麹",
"ȝƋ鬯犦獢9c5¤.岵": "浛a齙\\蹼偦歛"
}
},
"github": {
"upstreamAccessToken": " 皦pSǬŝ社Vƅȭǝ*擦28Dž"
}
}
},
"requestedAudience": [
" 皦pSǬŝ社Vƅȭǝ*擦28Dž",
"vư"
"甍 ć\u003cʘ筫",
"蛖a³2ʫ承dʬ)ġ,TÀqy_"
],
"grantedAudience": [
"置b",
"筫MN\u0026錝D肁Ŷɽ蔒PR}Ųʓl{"
"$+溪ŸȢŒų崓ļ憽",
"姧骦:駝重EȫʆɵʮGɃ"
]
},
"version": "7"
"version": "8"
}`
@@ -43,7 +43,7 @@ import (
const (
namespace = "test-ns"
expectedVersion = "7" // update this when you update the storage version in the production code
expectedVersion = "8" // update this when you update the storage version in the production code
)
var (
@@ -36,7 +36,8 @@ const (
// Version 5 is when we added the UpstreamUsername and UpstreamGroups fields to psession.CustomSessionData.
// Version 6 is when we upgraded fosite in Dec 2023.
// Version 7 is when OIDCClients were given configurable ID token lifetimes.
oidcStorageVersion = "7"
// Version 8 is when GitHubIdentityProvider was added.
oidcStorageVersion = "8"
)
var _ openid.OpenIDConnectRequestStorage = &openIDConnectRequestStorage{}
@@ -29,7 +29,7 @@ import (
const (
namespace = "test-ns"
expectedVersion = "7" // update this when you update the storage version in the production code
expectedVersion = "8" // update this when you update the storage version in the production code
)
var (
+2 -1
View File
@@ -34,7 +34,8 @@ const (
// Version 5 is when we added the UpstreamUsername and UpstreamGroups fields to psession.CustomSessionData.
// Version 6 is when we upgraded fosite in Dec 2023.
// Version 7 is when OIDCClients were given configurable ID token lifetimes.
pkceStorageVersion = "7"
// Version 8 is when GitHubIdentityProvider was added.
pkceStorageVersion = "8"
)
var _ pkce.PKCERequestStorage = &pkceStorage{}
+1 -1
View File
@@ -29,7 +29,7 @@ import (
const (
namespace = "test-ns"
expectedVersion = "7" // update this when you update the storage version in the production code
expectedVersion = "8" // update this when you update the storage version in the production code
)
var (
@@ -35,7 +35,8 @@ const (
// Version 5 is when we added the UpstreamUsername and UpstreamGroups fields to psession.CustomSessionData.
// Version 6 is when we upgraded fosite in Dec 2023.
// Version 7 is when OIDCClients were given configurable ID token lifetimes.
refreshTokenStorageVersion = "7"
// Version 8 is when GitHubIdentityProvider was added.
refreshTokenStorageVersion = "8"
)
type RevocationStorage interface {
@@ -30,7 +30,7 @@ import (
const (
namespace = "test-ns"
expectedVersion = "7" // update this when you update the storage version in the production code
expectedVersion = "8" // update this when you update the storage version in the production code
)
var (
+235
View File
@@ -0,0 +1,235 @@
// Copyright 2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package githubclient
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"slices"
"strings"
"github.com/google/go-github/v62/github"
"k8s.io/apimachinery/pkg/util/sets"
"go.pinniped.dev/internal/plog"
"go.pinniped.dev/internal/setutil"
)
const (
emptyUserMeansTheAuthenticatedUser = ""
pageSize = 100
)
type UserInfo struct {
ID string
Login string
}
type TeamInfo struct {
Name string
Slug string
Org string
}
type GitHubInterface interface {
GetUserInfo(ctx context.Context) (*UserInfo, error)
GetOrgMembership(ctx context.Context) ([]string, error)
GetTeamMembership(ctx context.Context, allowedOrganizations *setutil.CaseInsensitiveSet) ([]TeamInfo, error)
}
type githubClient struct {
client *github.Client
}
var _ GitHubInterface = (*githubClient)(nil)
func NewGitHubClient(httpClient *http.Client, apiBaseURL, token string) (GitHubInterface, error) {
const errorPrefix = "unable to build new github client"
if httpClient == nil {
return nil, fmt.Errorf("%s: httpClient cannot be nil", errorPrefix)
}
parsedURL, err := url.Parse(apiBaseURL)
if err != nil {
return nil, fmt.Errorf("%s: %w", errorPrefix, err)
}
if !strings.HasSuffix(parsedURL.Path, "/") {
parsedURL.Path += "/"
}
if parsedURL.Scheme != "https" {
return nil, fmt.Errorf(`%s: apiBaseURL must use "https" protocol, found %q instead`, errorPrefix, parsedURL.Scheme)
}
if token == "" {
return nil, fmt.Errorf("%s: token cannot be empty string", errorPrefix)
}
client := github.NewClient(httpClient).WithAuthToken(token)
client.BaseURL = parsedURL
return &githubClient{
client: client,
}, nil
}
// GetUserInfo returns the "Login" and "ID" attributes of the logged-in user.
func (g *githubClient) GetUserInfo(ctx context.Context) (*UserInfo, error) {
const errorPrefix = "error fetching authenticated user"
user, _, err := g.client.Users.Get(ctx, emptyUserMeansTheAuthenticatedUser)
if err != nil {
return nil, fmt.Errorf("%s: %w", errorPrefix, err)
}
if user == nil { // untested
return nil, fmt.Errorf("%s: user is nil", errorPrefix)
}
plog.Trace("got raw GitHub API user results", "user", user)
userInfo := &UserInfo{
Login: user.GetLogin(),
ID: fmt.Sprintf("%d", user.GetID()),
}
if userInfo.ID == "0" {
return nil, fmt.Errorf(`%s: the "id" attribute is missing`, errorPrefix)
}
if userInfo.Login == "" {
return nil, fmt.Errorf(`%s: the "login" attribute is missing`, errorPrefix)
}
plog.Trace("calculated response from GitHub user endpoint", "user", userInfo)
return userInfo, nil
}
// GetOrgMembership returns an array of the "Login" attributes for all organizations to which the authenticated user belongs.
func (g *githubClient) GetOrgMembership(ctx context.Context) ([]string, error) {
const errorPrefix = "error fetching organizations for authenticated user"
organizationLogins := sets.New[string]()
opt := &github.ListOptions{PerPage: pageSize}
// get all pages of results
for {
organizationResults, response, err := g.client.Organizations.List(ctx, emptyUserMeansTheAuthenticatedUser, opt)
if err != nil {
return nil, fmt.Errorf("%s: %w", errorPrefix, err)
}
plog.Trace("got raw GitHub API org results", "orgs", organizationResults, "hasNextPage", response.NextPage)
for _, organization := range organizationResults {
organizationLogins.Insert(organization.GetLogin())
}
if response.NextPage == 0 {
break
}
opt.Page = response.NextPage
}
if organizationLogins.Has("") {
return nil, fmt.Errorf(`%s: one or more organizations is missing the "login" attribute`, errorPrefix)
}
plog.Trace("calculated response from GitHub org membership endpoint", "orgs", organizationLogins.UnsortedList())
return organizationLogins.UnsortedList(), nil
}
func isOrgAllowed(allowedOrganizations *setutil.CaseInsensitiveSet, login string) bool {
return allowedOrganizations.Empty() || allowedOrganizations.ContainsIgnoringCase(login)
}
func buildAndValidateParentTeam(githubTeam *github.Team, organizationLogin string) (*TeamInfo, error) {
return buildTeam(githubTeam, organizationLogin)
}
func buildAndValidateTeam(githubTeam *github.Team) (*TeamInfo, error) {
if githubTeam.GetOrganization() == nil {
return nil, errors.New(`missing the "organization" attribute for a team`)
}
organizationLogin := githubTeam.GetOrganization().GetLogin()
if organizationLogin == "" {
return nil, errors.New(`missing the organization's "login" attribute for a team`)
}
return buildTeam(githubTeam, organizationLogin)
}
func buildTeam(githubTeam *github.Team, organizationLogin string) (*TeamInfo, error) {
teamInfo := &TeamInfo{
Name: githubTeam.GetName(),
Slug: githubTeam.GetSlug(),
Org: organizationLogin,
}
if teamInfo.Name == "" {
return nil, errors.New(`the "name" attribute is missing for a team`)
}
if teamInfo.Slug == "" {
return nil, errors.New(`the "slug" attribute is missing for a team`)
}
return teamInfo, nil
}
// GetTeamMembership returns a description of each team to which the authenticated user belongs.
// If allowedOrganizations is not empty, will filter the results to only those teams which belong to the allowed organizations.
// Parent teams will also be returned.
func (g *githubClient) GetTeamMembership(ctx context.Context, allowedOrganizations *setutil.CaseInsensitiveSet) ([]TeamInfo, error) {
const errorPrefix = "error fetching team membership for authenticated user"
teamInfos := sets.New[TeamInfo]()
opt := &github.ListOptions{PerPage: pageSize}
// get all pages of results
for {
teamsResults, response, err := g.client.Teams.ListUserTeams(ctx, opt)
if err != nil {
return nil, fmt.Errorf("%s: %w", errorPrefix, err)
}
plog.Trace("got raw GitHub API team results", "teams", teamsResults, "hasNextPage", response.NextPage)
for _, team := range teamsResults {
teamInfo, err := buildAndValidateTeam(team)
if err != nil {
return nil, fmt.Errorf("%s: %w", errorPrefix, err)
}
if !isOrgAllowed(allowedOrganizations, teamInfo.Org) {
continue
}
teamInfos.Insert(*teamInfo)
parent := team.GetParent()
if parent != nil {
// The GitHub API does not return the Organization for the Parent of the team.
// Use the org of the child as the org of the parent, since they must come from the same org.
parentTeamInfo, err := buildAndValidateParentTeam(parent, teamInfo.Org)
if err != nil {
return nil, fmt.Errorf("%s: %w", errorPrefix, err)
}
teamInfos.Insert(*parentTeamInfo)
}
}
if response.NextPage == 0 {
break
}
opt.Page = response.NextPage
}
// Sort by org and then by name, just so we always return teams in the same order.
sortedTeams := teamInfos.UnsortedList()
slices.SortStableFunc(sortedTeams, func(a, b TeamInfo) int {
orgsCompared := strings.Compare(a.Org, b.Org)
if orgsCompared == 0 {
return strings.Compare(a.Slug, b.Slug)
}
return orgsCompared
})
plog.Trace("calculated response from GitHub teams endpoint", "teams", sortedTeams)
return sortedTeams, nil
}
+833
View File
@@ -0,0 +1,833 @@
// Copyright 2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package githubclient
import (
"context"
"net/http"
"strings"
"testing"
"github.com/google/go-github/v62/github"
"github.com/migueleliasweb/go-github-mock/src/mock"
"github.com/stretchr/testify/require"
"k8s.io/client-go/util/cert"
"go.pinniped.dev/internal/net/phttp"
"go.pinniped.dev/internal/setutil"
"go.pinniped.dev/internal/testutil/tlsserver"
)
func TestNewGitHubClient(t *testing.T) {
t.Parallel()
t.Run("rejects nil http client", func(t *testing.T) {
_, err := NewGitHubClient(nil, "https://api.github.com/", "")
require.EqualError(t, err, "unable to build new github client: httpClient cannot be nil")
})
tests := []struct {
name string
apiBaseURL string
token string
wantBaseURL string
wantErr string
}{
{
name: "happy path with https://api.github.com/",
apiBaseURL: "https://api.github.com/",
token: "some-token",
wantBaseURL: "https://api.github.com/",
},
{
name: "adds trailing slash to path for https://api.github.com",
apiBaseURL: "https://api.github.com",
token: "other-token",
wantBaseURL: "https://api.github.com/",
},
{
name: "adds trailing slash to path for Enterprise URL https://fake.enterprise.tld/api/v3",
apiBaseURL: "https://fake.enterprise.tld/api/v3",
token: "some-enterprise-token",
wantBaseURL: "https://fake.enterprise.tld/api/v3/",
},
{
name: "rejects apiBaseURL without https:// scheme",
apiBaseURL: "scp://github.com",
token: "some-token",
wantErr: `unable to build new github client: apiBaseURL must use "https" protocol, found "scp" instead`,
},
{
name: "rejects apiBaseURL with empty scheme",
apiBaseURL: "github.com",
token: "some-token",
wantErr: `unable to build new github client: apiBaseURL must use "https" protocol, found "" instead`,
},
{
name: "rejects empty token",
apiBaseURL: "https://api.github.com/",
wantErr: "unable to build new github client: token cannot be empty string",
},
{
name: "returns errors from url.Parse",
apiBaseURL: "https:// example.com",
token: "some-token",
wantErr: `unable to build new github client: parse "https:// example.com": invalid character " " in host name`,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
called := false
testServer, testServerCA := tlsserver.TestServerIPv4(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Len(t, r.Header["Authorization"], 1)
require.Equal(t, "Bearer "+test.token, r.Header.Get("Authorization"))
called = true
}), nil)
t.Cleanup(func() {
require.True(t, (test.wantErr == "" && called) || (test.wantErr != "" && !called))
})
pool, err := cert.NewPoolFromBytes(testServerCA)
require.NoError(t, err)
httpClient := phttp.Default(pool)
actualI, err := NewGitHubClient(httpClient, test.apiBaseURL, test.token)
if test.wantErr != "" {
require.EqualError(t, err, test.wantErr)
} else {
require.NoError(t, err)
require.NotNil(t, actualI)
actual, ok := actualI.(*githubClient)
require.True(t, ok)
require.NotNil(t, actual.client.BaseURL)
require.Equal(t, test.wantBaseURL, actual.client.BaseURL.String())
// Force the githubClient's httpClient roundTrippers to run and add the Authorization header
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, testServer.URL, nil)
require.NoError(t, err)
_, err = actual.client.Client().Do(req) //nolint:bodyclose
require.NoError(t, err)
}
})
}
}
func TestGetUser(t *testing.T) {
t.Parallel()
tests := []struct {
name string
httpClient *http.Client
token string
ctx context.Context
wantErr string
wantUserInfo UserInfo
}{
{
name: "happy path",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetUser,
github.User{
Login: github.String("some-username"),
ID: github.Int64(12345678),
},
),
),
token: "some-token",
wantUserInfo: UserInfo{
Login: "some-username",
ID: "12345678",
},
},
{
name: "the token is added in the Authorization header",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetUser,
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Len(t, r.Header["Authorization"], 1)
require.Equal(t, "Bearer does-this-token-work", r.Header.Get("Authorization"))
_, err := w.Write([]byte(`{"login":"some-authenticated-username","id":999888}`))
require.NoError(t, err)
}),
),
),
token: "does-this-token-work",
wantUserInfo: UserInfo{
Login: "some-authenticated-username",
ID: "999888",
},
},
{
name: "handles missing login",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetUser,
github.User{
ID: github.Int64(12345678),
},
),
),
token: "does-this-token-work",
wantErr: `error fetching authenticated user: the "login" attribute is missing`,
},
{
name: "handles missing ID",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetUser,
github.User{
Login: github.String("some-username"),
},
),
),
token: "does-this-token-work",
wantErr: `error fetching authenticated user: the "id" attribute is missing`,
},
{
name: "passes the context parameter into the API call",
token: "some-token",
httpClient: mock.NewMockedHTTPClient(),
ctx: func() context.Context {
canceledCtx, cancel := context.WithCancel(context.Background())
cancel()
return canceledCtx
}(),
wantErr: "error fetching authenticated user: context canceled",
},
{
name: "returns errors from the API",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetUser,
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mock.WriteError(
w,
http.StatusInternalServerError,
"internal server error from the server",
)
}),
),
),
token: "some-token",
wantErr: "error fetching authenticated user: GET {SERVER_URL}/user: 500 internal server error from the server []",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
githubClient := &githubClient{
client: github.NewClient(test.httpClient).WithAuthToken(test.token),
}
ctx := context.Background()
if test.ctx != nil {
ctx = test.ctx
}
actual, err := githubClient.GetUserInfo(ctx)
if test.wantErr != "" {
rt, ok := test.httpClient.Transport.(*mock.EnforceHostRoundTripper)
require.True(t, ok)
test.wantErr = strings.ReplaceAll(test.wantErr, "{SERVER_URL}", rt.Host)
require.EqualError(t, err, test.wantErr)
} else {
require.NoError(t, err)
require.NotNil(t, actual)
require.Equal(t, test.wantUserInfo, *actual)
}
})
}
}
func TestGetOrgMembership(t *testing.T) {
t.Parallel()
tests := []struct {
name string
httpClient *http.Client
token string
ctx context.Context
wantErr string
wantOrgs []string
}{
{
name: "happy path",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetUserOrgs,
[]github.Organization{
{Login: github.String("org1")},
{Login: github.String("org2")},
{Login: github.String("org3")},
},
),
),
token: "some-token",
wantOrgs: []string{"org1", "org2", "org3"},
},
{
name: "happy path with pagination",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchPages(
mock.GetUserOrgs,
[]github.Organization{
{Login: github.String("page1-org1")},
{Login: github.String("page1-org2")},
{Login: github.String("page1-org3")},
},
[]github.Organization{
{Login: github.String("page2-org1")},
{Login: github.String("page2-org2")},
{Login: github.String("page2-org3")},
},
),
),
token: "some-token",
wantOrgs: []string{"page1-org1", "page1-org2", "page1-org3", "page2-org1", "page2-org2", "page2-org3"},
},
{
name: "the token is added in the Authorization header",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetUserOrgs,
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Len(t, r.Header["Authorization"], 1)
require.Equal(t, "Bearer does-this-token-work", r.Header.Get("Authorization"))
_, err := w.Write([]byte(`[{"login":"some-org-to-which-the-authenticated-user-belongs"}]`))
require.NoError(t, err)
}),
),
),
token: "does-this-token-work",
wantOrgs: []string{"some-org-to-which-the-authenticated-user-belongs"},
},
{
name: "errors when a Login field is empty",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetUserOrgs,
[]github.Organization{
{Login: github.String("page1-org1")},
{Login: nil},
{Login: github.String("page1-org3")},
},
),
),
token: "some-token",
wantErr: `error fetching organizations for authenticated user: one or more organizations is missing the "login" attribute`,
},
{
name: "passes the context parameter into the API call",
token: "some-token",
httpClient: mock.NewMockedHTTPClient(),
ctx: func() context.Context {
canceledCtx, cancel := context.WithCancel(context.Background())
cancel()
return canceledCtx
}(),
wantErr: "error fetching organizations for authenticated user: context canceled",
},
{
name: "returns errors from the API",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetUserOrgs,
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mock.WriteError(
w,
http.StatusFailedDependency,
"some random client error",
)
}),
),
),
token: "some-token",
wantErr: "error fetching organizations for authenticated user: GET {SERVER_URL}/user/orgs?per_page=100: 424 some random client error []",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
githubClient := &githubClient{
client: github.NewClient(test.httpClient).WithAuthToken(test.token),
}
ctx := context.Background()
if test.ctx != nil {
ctx = test.ctx
}
actual, err := githubClient.GetOrgMembership(ctx)
if test.wantErr != "" {
rt, ok := test.httpClient.Transport.(*mock.EnforceHostRoundTripper)
require.True(t, ok)
test.wantErr = strings.ReplaceAll(test.wantErr, "{SERVER_URL}", rt.Host)
require.EqualError(t, err, test.wantErr)
return
}
require.NotNil(t, actual)
require.ElementsMatch(t, test.wantOrgs, actual)
})
}
}
func TestGetTeamMembership(t *testing.T) {
t.Parallel()
tests := []struct {
name string
httpClient *http.Client
token string
ctx context.Context
allowedOrganizations *setutil.CaseInsensitiveSet
wantErr string
wantTeams []TeamInfo
}{
{
name: "happy path",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetUserTeams,
[]github.Team{
{
Name: github.String("orgAlpha-team1-name"),
Slug: github.String("orgAlpha-team1-slug"),
Organization: &github.Organization{
Login: github.String("alpha"),
},
},
{
Name: github.String("orgAlpha-team2-name"),
Slug: github.String("orgAlpha-team2-slug"),
Organization: &github.Organization{
Login: github.String("alpha"),
},
},
{
Name: github.String("orgAlpha-team3-name"),
Slug: github.String("orgAlpha-team3-slug"),
Organization: &github.Organization{
Login: github.String("alpha"),
},
},
{
Name: github.String("orgBeta-team1-name"),
Slug: github.String("orgBeta-team1-slug"),
Organization: &github.Organization{
Login: github.String("beta"),
},
},
},
),
),
token: "some-token",
allowedOrganizations: setutil.NewCaseInsensitiveSet("alpha", "beta"),
wantTeams: []TeamInfo{
{
Name: "orgAlpha-team1-name",
Slug: "orgAlpha-team1-slug",
Org: "alpha",
},
{
Name: "orgAlpha-team2-name",
Slug: "orgAlpha-team2-slug",
Org: "alpha",
},
{
Name: "orgAlpha-team3-name",
Slug: "orgAlpha-team3-slug",
Org: "alpha",
},
{
Name: "orgBeta-team1-name",
Slug: "orgBeta-team1-slug",
Org: "beta",
},
},
},
{
name: "filters by allowedOrganizations in a case-insensitive way, but preserves case as returned by GitHub API in the result",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetUserTeams,
[]github.Team{
{
Name: github.String("team1-name"),
Slug: github.String("team1-slug"),
Organization: &github.Organization{
Login: github.String("alPhA"),
},
},
{
Name: github.String("team2-name"),
Slug: github.String("team2-slug"),
Organization: &github.Organization{
Login: github.String("bEtA"),
},
},
{
Name: github.String("team3-name"),
Slug: github.String("team3-slug"),
Organization: &github.Organization{
Login: github.String("gAmmA"),
},
},
},
),
),
token: "some-token",
allowedOrganizations: setutil.NewCaseInsensitiveSet("ALPHA", "gamma"),
wantTeams: []TeamInfo{
{
Name: "team1-name",
Slug: "team1-slug",
Org: "alPhA",
},
{
Name: "team3-name",
Slug: "team3-slug",
Org: "gAmmA",
},
},
},
{
name: "when allowedOrganizations is empty, return all teams",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetUserTeams,
[]github.Team{
{
Name: github.String("team1-name"),
Slug: github.String("team1-slug"),
Organization: &github.Organization{
Login: github.String("alpha"),
},
},
{
Name: github.String("team2-name"),
Slug: github.String("team2-slug"),
Organization: &github.Organization{
Login: github.String("beta"),
},
},
{
Name: github.String("team3-name"),
Slug: github.String("team3-slug"),
Parent: &github.Team{
Name: github.String("delta-team-name"),
Slug: github.String("delta-team-slug"),
Organization: nil, // the real GitHub API does not return Org on "Parent" team.
},
Organization: &github.Organization{
Login: github.String("gamma"),
},
},
},
),
),
token: "some-token",
wantTeams: []TeamInfo{
{
Name: "team1-name",
Slug: "team1-slug",
Org: "alpha",
},
{
Name: "team2-name",
Slug: "team2-slug",
Org: "beta",
},
{
Name: "delta-team-name",
Slug: "delta-team-slug",
Org: "gamma",
},
{
Name: "team3-name",
Slug: "team3-slug",
Org: "gamma",
},
},
},
{
name: "includes parent team in allowed orgs if present",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetUserTeams,
[]github.Team{
{
Name: github.String("team-name-with-parent"),
Slug: github.String("team-slug-with-parent"),
Parent: &github.Team{
Name: github.String("parent-team-name"),
Slug: github.String("parent-team-slug"),
Organization: nil, // the real GitHub API does not return Org on "Parent" team.
},
Organization: &github.Organization{
Login: github.String("org-with-nested-teams"),
},
},
{
Name: github.String("team-name-with-same-parent-again"),
Slug: github.String("team-slug-with-same-parent-again"),
Parent: &github.Team{
Name: github.String("parent-team-name"),
Slug: github.String("parent-team-slug"),
Organization: nil, // the real GitHub API does not return Org on "Parent" team.
},
Organization: &github.Organization{
Login: github.String("org-with-nested-teams"),
},
},
{
Name: github.String("parent-team-name"),
Slug: github.String("parent-team-slug"),
Organization: &github.Organization{
Login: github.String("org-with-nested-teams"),
},
},
{
Name: github.String("team-name-with-parent-from-disallowed-org"),
Slug: github.String("team-slug-with-parent-from-disallowed-org"),
Parent: &github.Team{
Name: github.String("parent-team-name-from-disallowed-org"),
Slug: github.String("parent-team-slug-from-disallowed-org"),
Organization: nil, // the real GitHub API does not return Org on "Parent" team.
},
Organization: &github.Organization{
Login: github.String("disallowed-org"),
},
},
{
Name: github.String("team-name-without-parent"),
Slug: github.String("team-slug-without-parent"),
Organization: &github.Organization{
Login: github.String("beta"),
},
},
},
),
),
token: "some-token",
allowedOrganizations: setutil.NewCaseInsensitiveSet("org-with-nested-teams", "beta"),
wantTeams: []TeamInfo{
{
Name: "team-name-without-parent",
Slug: "team-slug-without-parent",
Org: "beta",
},
{
Name: "parent-team-name",
Slug: "parent-team-slug",
Org: "org-with-nested-teams",
},
{
Name: "team-name-with-parent",
Slug: "team-slug-with-parent",
Org: "org-with-nested-teams",
},
{
Name: "team-name-with-same-parent-again",
Slug: "team-slug-with-same-parent-again",
Org: "org-with-nested-teams",
},
},
},
{
name: "happy path with pagination",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchPages(
mock.GetUserTeams,
[]github.Team{
{
Name: github.String("page1-team-name"),
Slug: github.String("page1-team-slug"),
Organization: &github.Organization{
Login: github.String("page1-org-name"),
},
},
},
[]github.Team{
{
Name: github.String("page2-team-name"),
Slug: github.String("page2-team-slug"),
Organization: &github.Organization{
Login: github.String("page2-org-name"),
},
},
},
),
),
token: "some-token",
allowedOrganizations: setutil.NewCaseInsensitiveSet("page1-org-name", "page2-org-name"),
wantTeams: []TeamInfo{
{
Name: "page1-team-name",
Slug: "page1-team-slug",
Org: "page1-org-name",
},
{
Name: "page2-team-name",
Slug: "page2-team-slug",
Org: "page2-org-name",
},
},
},
{
name: "missing organization attribute returns an error",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetUserTeams,
[]github.Team{
{
Name: github.String("team-name"),
Slug: github.String("team-slug"),
},
},
),
),
wantErr: `error fetching team membership for authenticated user: missing the "organization" attribute for a team`,
},
{
name: "missing organization's login attribute returns an error",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetUserTeams,
[]github.Team{
{
Name: github.String("team-name"),
Slug: github.String("team-slug"),
Organization: &github.Organization{},
},
},
),
),
wantErr: `error fetching team membership for authenticated user: missing the organization's "login" attribute for a team`,
},
{
name: "missing the name attribute for a team returns an error",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetUserTeams,
[]github.Team{
{
Slug: github.String("team-slug"),
Organization: &github.Organization{
Login: github.String("some-org"),
},
},
},
),
),
wantErr: `error fetching team membership for authenticated user: the "name" attribute is missing for a team`,
},
{
name: "missing the slug attribute for a team returns an error",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetUserTeams,
[]github.Team{
{
Name: github.String("team-name"),
Organization: &github.Organization{
Login: github.String("some-org"),
},
},
},
),
),
wantErr: `error fetching team membership for authenticated user: the "slug" attribute is missing for a team`,
},
{
name: "the token is added in the Authorization header",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetUserTeams,
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Len(t, r.Header["Authorization"], 1)
require.Equal(t, "Bearer does-this-token-work", r.Header.Get("Authorization"))
_, err := w.Write([]byte(`[{"name":"team1-name","slug":"team1-slug","organization":{"login":"org-login"}}]`))
require.NoError(t, err)
}),
),
),
token: "does-this-token-work",
allowedOrganizations: setutil.NewCaseInsensitiveSet("org-login"),
wantTeams: []TeamInfo{
{
Name: "team1-name",
Slug: "team1-slug",
Org: "org-login",
},
},
},
{
name: "passes the context parameter into the API call",
token: "some-token",
httpClient: mock.NewMockedHTTPClient(),
ctx: func() context.Context {
canceledCtx, cancel := context.WithCancel(context.Background())
cancel()
return canceledCtx
}(),
wantErr: "error fetching team membership for authenticated user: context canceled",
},
{
name: "returns errors from the API",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetUserTeams,
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mock.WriteError(
w,
http.StatusFailedDependency,
"some random client error",
)
}),
),
),
token: "some-token",
wantErr: "error fetching team membership for authenticated user: GET {SERVER_URL}/user/teams?per_page=100: 424 some random client error []",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
githubClient := &githubClient{
client: github.NewClient(test.httpClient).WithAuthToken(test.token),
}
ctx := context.Background()
if test.ctx != nil {
ctx = test.ctx
}
actual, err := githubClient.GetTeamMembership(ctx, test.allowedOrganizations)
if test.wantErr != "" {
rt, ok := test.httpClient.Transport.(*mock.EnforceHostRoundTripper)
require.True(t, ok)
test.wantErr = strings.ReplaceAll(test.wantErr, "{SERVER_URL}", rt.Host)
require.EqualError(t, err, test.wantErr)
} else {
require.NoError(t, err)
require.NotNil(t, actual)
require.Equal(t, test.wantTeams, actual)
}
})
}
}
@@ -0,0 +1,6 @@
// Copyright 2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package mockgithubclient
//go:generate go run -v go.uber.org/mock/mockgen -destination=mockgithubclient.go -package=mockgithubclient -copyright_file=../../../hack/header.txt go.pinniped.dev/internal/githubclient GitHubInterface
@@ -0,0 +1,91 @@
// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
//
// Code generated by MockGen. DO NOT EDIT.
// Source: go.pinniped.dev/internal/githubclient (interfaces: GitHubInterface)
//
// Generated by this command:
//
// mockgen -destination=mockgithubclient.go -package=mockgithubclient -copyright_file=../../../hack/header.txt go.pinniped.dev/internal/githubclient GitHubInterface
//
// Package mockgithubclient is a generated GoMock package.
package mockgithubclient
import (
context "context"
reflect "reflect"
githubclient "go.pinniped.dev/internal/githubclient"
setutil "go.pinniped.dev/internal/setutil"
gomock "go.uber.org/mock/gomock"
)
// MockGitHubInterface is a mock of GitHubInterface interface.
type MockGitHubInterface struct {
ctrl *gomock.Controller
recorder *MockGitHubInterfaceMockRecorder
}
// MockGitHubInterfaceMockRecorder is the mock recorder for MockGitHubInterface.
type MockGitHubInterfaceMockRecorder struct {
mock *MockGitHubInterface
}
// NewMockGitHubInterface creates a new mock instance.
func NewMockGitHubInterface(ctrl *gomock.Controller) *MockGitHubInterface {
mock := &MockGitHubInterface{ctrl: ctrl}
mock.recorder = &MockGitHubInterfaceMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockGitHubInterface) EXPECT() *MockGitHubInterfaceMockRecorder {
return m.recorder
}
// GetOrgMembership mocks base method.
func (m *MockGitHubInterface) GetOrgMembership(arg0 context.Context) ([]string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetOrgMembership", arg0)
ret0, _ := ret[0].([]string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetOrgMembership indicates an expected call of GetOrgMembership.
func (mr *MockGitHubInterfaceMockRecorder) GetOrgMembership(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrgMembership", reflect.TypeOf((*MockGitHubInterface)(nil).GetOrgMembership), arg0)
}
// GetTeamMembership mocks base method.
func (m *MockGitHubInterface) GetTeamMembership(arg0 context.Context, arg1 *setutil.CaseInsensitiveSet) ([]githubclient.TeamInfo, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetTeamMembership", arg0, arg1)
ret0, _ := ret[0].([]githubclient.TeamInfo)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetTeamMembership indicates an expected call of GetTeamMembership.
func (mr *MockGitHubInterfaceMockRecorder) GetTeamMembership(arg0, arg1 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeamMembership", reflect.TypeOf((*MockGitHubInterface)(nil).GetTeamMembership), arg0, arg1)
}
// GetUserInfo mocks base method.
func (m *MockGitHubInterface) GetUserInfo(arg0 context.Context) (*githubclient.UserInfo, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetUserInfo", arg0)
ret0, _ := ret[0].(*githubclient.UserInfo)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetUserInfo indicates an expected call of GetUserInfo.
func (mr *MockGitHubInterfaceMockRecorder) GetUserInfo(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserInfo", reflect.TypeOf((*MockGitHubInterface)(nil).GetUserInfo), arg0)
}
@@ -149,18 +149,18 @@ func (mr *MockUpstreamOIDCIdentityProviderIMockRecorder) GetGroupsClaim() *gomoc
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupsClaim", reflect.TypeOf((*MockUpstreamOIDCIdentityProviderI)(nil).GetGroupsClaim))
}
// GetName mocks base method.
func (m *MockUpstreamOIDCIdentityProviderI) GetName() string {
// GetResourceName mocks base method.
func (m *MockUpstreamOIDCIdentityProviderI) GetResourceName() string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetName")
ret := m.ctrl.Call(m, "GetResourceName")
ret0, _ := ret[0].(string)
return ret0
}
// GetName indicates an expected call of GetName.
func (mr *MockUpstreamOIDCIdentityProviderIMockRecorder) GetName() *gomock.Call {
// GetResourceName indicates an expected call of GetResourceName.
func (mr *MockUpstreamOIDCIdentityProviderIMockRecorder) GetResourceName() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetName", reflect.TypeOf((*MockUpstreamOIDCIdentityProviderI)(nil).GetName))
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetResourceName", reflect.TypeOf((*MockUpstreamOIDCIdentityProviderI)(nil).GetResourceName))
}
// GetResourceUID mocks base method.
+1
View File
@@ -12,6 +12,7 @@
//
// info should be reserved for "nice to know" information. It should be possible to run a production
// pinniped server at the info log level with no performance degradation due to high log volume.
//
// debug should be used for information targeted at developers and to aid in support cases. Care must
// be taken at this level to not leak any secrets into the log stream. That is, even though debug may
// cause performance issues in production, it must not cause security issues in production.
+11 -11
View File
@@ -241,7 +241,7 @@ func TestPlog(t *testing.T) {
testAllPlogMethods(l.withDepth(-2))
},
want: `
{"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.1/logr.go:<line>$logr.Logger.Error","message":"e","panda":2,"error":"some err"}
{"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:<line>$logr.Logger.Error","message":"e","panda":2,"error":"some err"}
{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:<line>$plog.pLogger.warningDepth","message":"w","warning":true,"panda":2}
{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:<line>$plog.pLogger.warningDepth","message":"we","warning":true,"error":"some err","panda":2}
{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:<line>$plog.pLogger.infoDepth","message":"i","panda":2}
@@ -250,8 +250,8 @@ func TestPlog(t *testing.T) {
{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:<line>$plog.pLogger.debugDepth","message":"de","error":"some err","panda":2}
{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:<line>$plog.pLogger.traceDepth","message":"t","panda":2}
{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:<line>$plog.pLogger.traceDepth","message":"te","error":"some err","panda":2}
{"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.1/logr.go:<line>$logr.Logger.Info","message":"all","panda":2}
{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.1/logr.go:<line>$logr.Logger.Info","message":"always","panda":2}
{"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:<line>$logr.Logger.Info","message":"all","panda":2}
{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:<line>$logr.Logger.Info","message":"always","panda":2}
`,
},
{
@@ -261,14 +261,14 @@ func TestPlog(t *testing.T) {
},
want: `
{"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","caller":"zapr@v1.3.0/zapr.go:<line>$zapr.(*zapLogger).Error","message":"e","panda":2,"error":"some err"}
{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.1/logr.go:<line>$logr.Logger.Info","message":"w","warning":true,"panda":2}
{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.1/logr.go:<line>$logr.Logger.Info","message":"we","warning":true,"error":"some err","panda":2}
{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.1/logr.go:<line>$logr.Logger.Info","message":"i","panda":2}
{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.1/logr.go:<line>$logr.Logger.Info","message":"ie","error":"some err","panda":2}
{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.1/logr.go:<line>$logr.Logger.Info","message":"d","panda":2}
{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.1/logr.go:<line>$logr.Logger.Info","message":"de","error":"some err","panda":2}
{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.1/logr.go:<line>$logr.Logger.Info","message":"t","panda":2}
{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.1/logr.go:<line>$logr.Logger.Info","message":"te","error":"some err","panda":2}
{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:<line>$logr.Logger.Info","message":"w","warning":true,"panda":2}
{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:<line>$logr.Logger.Info","message":"we","warning":true,"error":"some err","panda":2}
{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:<line>$logr.Logger.Info","message":"i","panda":2}
{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:<line>$logr.Logger.Info","message":"ie","error":"some err","panda":2}
{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:<line>$logr.Logger.Info","message":"d","panda":2}
{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:<line>$logr.Logger.Info","message":"de","error":"some err","panda":2}
{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:<line>$logr.Logger.Info","message":"t","panda":2}
{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:<line>$logr.Logger.Info","message":"te","error":"some err","panda":2}
{"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","caller":"zapr@v1.3.0/zapr.go:<line>$zapr.(*zapLogger).Info","message":"all","panda":2}
{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"zapr@v1.3.0/zapr.go:<line>$zapr.(*zapLogger).Info","message":"always","panda":2}`,
},
+13
View File
@@ -74,6 +74,9 @@ type CustomSessionData struct {
// Only used when ProviderType == "activedirectory".
ActiveDirectory *ActiveDirectorySessionData `json:"activedirectory,omitempty"`
// Only used when ProviderType == "github".
GitHub *GitHubSessionData `json:"github,omitempty"`
}
type ProviderType string
@@ -82,6 +85,7 @@ const (
ProviderTypeOIDC ProviderType = "oidc"
ProviderTypeLDAP ProviderType = "ldap"
ProviderTypeActiveDirectory ProviderType = "activedirectory"
ProviderTypeGitHub ProviderType = "github"
)
// OIDCSessionData is the additional data needed by Pinniped when the upstream IDP is an OIDC provider.
@@ -140,6 +144,15 @@ func (s *ActiveDirectorySessionData) Clone() *ActiveDirectorySessionData {
}
}
type GitHubSessionData struct {
UpstreamAccessToken string `json:"upstreamAccessToken"`
}
func (s *GitHubSessionData) Clone() *GitHubSessionData {
dataCopy := *s // this shortcut works because all fields in this type are currently strings (no pointers)
return &dataCopy
}
// NewPinnipedSession returns a new empty session.
func NewPinnipedSession() *PinnipedSession {
return &PinnipedSession{
+43
View File
@@ -0,0 +1,43 @@
// Copyright 2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package setutil
import (
"strings"
"k8s.io/apimachinery/pkg/util/sets"
"go.pinniped.dev/internal/sliceutil"
)
type CaseInsensitiveSet struct {
lowercasedContents sets.Set[string]
}
func NewCaseInsensitiveSet(items ...string) *CaseInsensitiveSet {
return &CaseInsensitiveSet{
lowercasedContents: sets.New(sliceutil.Map(items, strings.ToLower)...),
}
}
func (s *CaseInsensitiveSet) HasAnyIgnoringCase(items []string) bool {
if s == nil {
return false
}
return s.lowercasedContents.HasAny(sliceutil.Map(items, strings.ToLower)...)
}
func (s *CaseInsensitiveSet) ContainsIgnoringCase(item string) bool {
if s == nil {
return false
}
return s.lowercasedContents.Has(strings.ToLower(item))
}
func (s *CaseInsensitiveSet) Empty() bool {
if s == nil {
return true
}
return s.lowercasedContents.Len() == 0
}
+34
View File
@@ -0,0 +1,34 @@
// Copyright 2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package setutil
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestCaseInsensitiveSet(t *testing.T) {
var nilSet *CaseInsensitiveSet
require.True(t, nilSet.Empty())
require.False(t, nilSet.HasAnyIgnoringCase([]string{"a", "b"}))
require.False(t, nilSet.HasAnyIgnoringCase(nil))
require.False(t, nilSet.ContainsIgnoringCase("a"))
require.False(t, nilSet.ContainsIgnoringCase("a"))
emptySet := NewCaseInsensitiveSet()
require.True(t, emptySet.Empty())
require.False(t, emptySet.HasAnyIgnoringCase([]string{"a", "b"}))
require.False(t, emptySet.HasAnyIgnoringCase(nil))
require.False(t, emptySet.ContainsIgnoringCase("a"))
require.False(t, emptySet.ContainsIgnoringCase("a"))
set := NewCaseInsensitiveSet("A", "B", "c")
require.False(t, set.Empty())
require.False(t, set.HasAnyIgnoringCase([]string{"x", "y"}))
require.True(t, set.HasAnyIgnoringCase([]string{"a", "x"}))
require.False(t, set.HasAnyIgnoringCase(nil))
require.False(t, set.ContainsIgnoringCase("x"))
require.True(t, set.ContainsIgnoringCase("a"))
}
+13
View File
@@ -0,0 +1,13 @@
// Copyright 2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package sliceutil
// Map transforms a slice from an input type I to an output type O using a transform func.
func Map[I, O any](in []I, transform func(I) O) []O {
out := make([]O, len(in))
for i := range in {
out[i] = transform(in[i])
}
return out
}
+74
View File
@@ -0,0 +1,74 @@
// Copyright 2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package sliceutil
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestMap(t *testing.T) {
type testCase[I any, O any] struct {
name string
in []I
transformFunc func(I) O
want []O
}
stringStringTests := []testCase[string, string]{
{
name: "downcase func",
in: []string{"Aa", "bB", "CC"},
transformFunc: strings.ToLower,
want: []string{"aa", "bb", "cc"},
},
{
name: "upcase func",
in: []string{"Aa", "bB", "CC"},
transformFunc: strings.ToUpper,
want: []string{"AA", "BB", "CC"},
},
{
name: "when in is nil, then out is an empty slice",
in: nil,
transformFunc: strings.ToUpper,
want: []string{},
},
}
for _, tt := range stringStringTests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
actual := Map(tt.in, tt.transformFunc)
require.Equal(t, tt.want, actual)
})
}
stringIntTests := []testCase[string, int]{
{
name: "len func",
in: []string{"Aa", "bBb", "CCcC"},
transformFunc: func(s string) int {
return len(s)
},
want: []int{2, 3, 4},
},
{
name: "index func",
in: []string{"Aab", "bB", "CC"},
transformFunc: func(s string) int {
return strings.Index(s, "b")
},
want: []int{2, 0, -1},
},
}
for _, tt := range stringIntTests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
actual := Map(tt.in, tt.transformFunc)
require.Equal(t, tt.want, actual)
})
}
}
+15
View File
@@ -51,6 +51,7 @@ import (
"go.pinniped.dev/internal/controller/supervisorconfig"
"go.pinniped.dev/internal/controller/supervisorconfig/activedirectoryupstreamwatcher"
"go.pinniped.dev/internal/controller/supervisorconfig/generator"
"go.pinniped.dev/internal/controller/supervisorconfig/githubupstreamwatcher"
"go.pinniped.dev/internal/controller/supervisorconfig/ldapupstreamwatcher"
"go.pinniped.dev/internal/controller/supervisorconfig/oidcclientwatcher"
"go.pinniped.dev/internal/controller/supervisorconfig/oidcupstreamwatcher"
@@ -177,6 +178,7 @@ func prepareControllers(
pinnipedInformers.IDP().V1alpha1().OIDCIdentityProviders(),
pinnipedInformers.IDP().V1alpha1().LDAPIdentityProviders(),
pinnipedInformers.IDP().V1alpha1().ActiveDirectoryIdentityProviders(),
pinnipedInformers.IDP().V1alpha1().GitHubIdentityProviders(),
controllerlib.WithInformer,
),
singletonWorker,
@@ -323,6 +325,19 @@ func prepareControllers(
controllerlib.WithInformer,
),
singletonWorker).
WithController(
githubupstreamwatcher.New(
podInfo.Namespace,
dynamicUpstreamIDPProvider,
pinnipedClient,
pinnipedInformers.IDP().V1alpha1().GitHubIdentityProviders(),
secretInformer,
plog.New(),
controllerlib.WithInformer,
clock.RealClock{},
tls.Dial,
),
singletonWorker).
WithController(
apicerts.NewCertsManagerController(
podInfo.Namespace,
@@ -8,6 +8,8 @@ import (
"github.com/gorilla/securecookie"
"github.com/stretchr/testify/require"
idpdiscoveryv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1"
)
// ExpectedUpstreamStateParamFormat is a separate type from the production code to ensure that the state
@@ -52,8 +54,13 @@ func (b *UpstreamStateParamBuilder) WithPKCE(pkce string) *UpstreamStateParamBui
return b
}
func (b *UpstreamStateParamBuilder) WithUpstreamIDPType(upstreamIDPType string) *UpstreamStateParamBuilder {
b.T = upstreamIDPType
func (b *UpstreamStateParamBuilder) WithUpstreamIDPType(upstreamIDPType idpdiscoveryv1alpha1.IDPType) *UpstreamStateParamBuilder {
b.T = string(upstreamIDPType)
return b
}
func (b *UpstreamStateParamBuilder) WithUpstreamIDPName(upstreamIDPName string) *UpstreamStateParamBuilder {
b.U = upstreamIDPName
return b
}
@@ -0,0 +1,264 @@
// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package oidctestutil
import (
"context"
"k8s.io/apimachinery/pkg/types"
"go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1"
"go.pinniped.dev/internal/federationdomain/upstreamprovider"
"go.pinniped.dev/internal/idtransform"
"go.pinniped.dev/internal/setutil"
)
// ExchangeAuthcodeArgs is used to spy on calls to
// TestUpstreamGitHubIdentityProvider.ExchangeAuthcodeFunc().
type ExchangeAuthcodeArgs struct {
Ctx context.Context
Authcode string
RedirectURI string
}
// GetUserArgs is used to spy on calls to
// TestUpstreamGitHubIdentityProvider.GetUserFunc().
type GetUserArgs struct {
Ctx context.Context
AccessToken string
IDPDisplayName string
}
type TestUpstreamGitHubIdentityProviderBuilder struct {
name string
resourceUID types.UID
clientID string
scopes []string
displayNameForFederationDomain string
transformsForFederationDomain *idtransform.TransformationPipeline
usernameAttribute v1alpha1.GitHubUsernameAttribute
groupNameAttribute v1alpha1.GitHubGroupNameAttribute
allowedOrganizations *setutil.CaseInsensitiveSet
authorizationURL string
authcodeExchangeErr error
accessToken string
getUserErr error
getUserUser *upstreamprovider.GitHubUser
}
func (u *TestUpstreamGitHubIdentityProviderBuilder) WithName(value string) *TestUpstreamGitHubIdentityProviderBuilder {
u.name = value
return u
}
func (u *TestUpstreamGitHubIdentityProviderBuilder) WithResourceUID(value types.UID) *TestUpstreamGitHubIdentityProviderBuilder {
u.resourceUID = value
return u
}
func (u *TestUpstreamGitHubIdentityProviderBuilder) WithClientID(value string) *TestUpstreamGitHubIdentityProviderBuilder {
u.clientID = value
return u
}
func (u *TestUpstreamGitHubIdentityProviderBuilder) WithScopes(value []string) *TestUpstreamGitHubIdentityProviderBuilder {
u.scopes = value
return u
}
func (u *TestUpstreamGitHubIdentityProviderBuilder) WithDisplayNameForFederationDomain(value string) *TestUpstreamGitHubIdentityProviderBuilder {
u.displayNameForFederationDomain = value
return u
}
func (u *TestUpstreamGitHubIdentityProviderBuilder) WithUsernameAttribute(value v1alpha1.GitHubUsernameAttribute) *TestUpstreamGitHubIdentityProviderBuilder {
u.usernameAttribute = value
return u
}
func (u *TestUpstreamGitHubIdentityProviderBuilder) WithGroupNameAttribute(value v1alpha1.GitHubGroupNameAttribute) *TestUpstreamGitHubIdentityProviderBuilder {
u.groupNameAttribute = value
return u
}
func (u *TestUpstreamGitHubIdentityProviderBuilder) WithAllowedOrganizations(value *setutil.CaseInsensitiveSet) *TestUpstreamGitHubIdentityProviderBuilder {
u.allowedOrganizations = value
return u
}
func (u *TestUpstreamGitHubIdentityProviderBuilder) WithAuthorizationURL(value string) *TestUpstreamGitHubIdentityProviderBuilder {
u.authorizationURL = value
return u
}
func (u *TestUpstreamGitHubIdentityProviderBuilder) WithAccessToken(token string) *TestUpstreamGitHubIdentityProviderBuilder {
u.accessToken = token
return u
}
func (u *TestUpstreamGitHubIdentityProviderBuilder) WithAuthcodeExchangeError(err error) *TestUpstreamGitHubIdentityProviderBuilder {
u.authcodeExchangeErr = err
return u
}
func (u *TestUpstreamGitHubIdentityProviderBuilder) WithUser(user *upstreamprovider.GitHubUser) *TestUpstreamGitHubIdentityProviderBuilder {
u.getUserUser = user
return u
}
func (u *TestUpstreamGitHubIdentityProviderBuilder) WithGetUserError(err error) *TestUpstreamGitHubIdentityProviderBuilder {
u.getUserErr = err
return u
}
func (u *TestUpstreamGitHubIdentityProviderBuilder) WithTransformsForFederationDomain(transforms *idtransform.TransformationPipeline) *TestUpstreamGitHubIdentityProviderBuilder {
u.transformsForFederationDomain = transforms
return u
}
func (u *TestUpstreamGitHubIdentityProviderBuilder) Build() *TestUpstreamGitHubIdentityProvider {
if u.displayNameForFederationDomain == "" {
// default it to the CR name
u.displayNameForFederationDomain = u.name
}
if u.transformsForFederationDomain == nil {
// default to an empty pipeline
u.transformsForFederationDomain = idtransform.NewTransformationPipeline()
}
return &TestUpstreamGitHubIdentityProvider{
Name: u.name,
ClientID: u.clientID,
ResourceUID: u.resourceUID,
Scopes: u.scopes,
DisplayNameForFederationDomain: u.displayNameForFederationDomain,
TransformsForFederationDomain: u.transformsForFederationDomain,
UsernameAttribute: u.usernameAttribute,
GroupNameAttribute: u.groupNameAttribute,
AllowedOrganizations: u.allowedOrganizations,
AuthorizationURL: u.authorizationURL,
GetUserFunc: func(ctx context.Context, accessToken string) (*upstreamprovider.GitHubUser, error) {
if u.getUserErr != nil {
return nil, u.getUserErr
}
return u.getUserUser, nil
},
ExchangeAuthcodeFunc: func(ctx context.Context, authcode string) (string, error) {
if u.authcodeExchangeErr != nil {
return "", u.authcodeExchangeErr
}
return u.accessToken, nil
},
}
}
func NewTestUpstreamGitHubIdentityProviderBuilder() *TestUpstreamGitHubIdentityProviderBuilder {
return &TestUpstreamGitHubIdentityProviderBuilder{}
}
type TestUpstreamGitHubIdentityProvider struct {
Name string
ClientID string
ResourceUID types.UID
Scopes []string
DisplayNameForFederationDomain string
TransformsForFederationDomain *idtransform.TransformationPipeline
UsernameAttribute v1alpha1.GitHubUsernameAttribute
GroupNameAttribute v1alpha1.GitHubGroupNameAttribute
AllowedOrganizations *setutil.CaseInsensitiveSet
AuthorizationURL string
GetUserFunc func(ctx context.Context, accessToken string) (*upstreamprovider.GitHubUser, error)
ExchangeAuthcodeFunc func(ctx context.Context, authcode string) (string, error)
// Fields for tracking actual calls make to mock functions.
exchangeAuthcodeCallCount int
exchangeAuthcodeArgs []*ExchangeAuthcodeArgs
getUserCallCount int
getUserArgs []*GetUserArgs
}
var _ upstreamprovider.UpstreamGithubIdentityProviderI = &TestUpstreamGitHubIdentityProvider{}
func (u *TestUpstreamGitHubIdentityProvider) GetResourceUID() types.UID {
return u.ResourceUID
}
func (u *TestUpstreamGitHubIdentityProvider) GetResourceName() string {
return u.Name
}
func (u *TestUpstreamGitHubIdentityProvider) GetScopes() []string {
return u.Scopes
}
func (u *TestUpstreamGitHubIdentityProvider) GetClientID() string {
return u.ClientID
}
func (u *TestUpstreamGitHubIdentityProvider) GetUsernameAttribute() v1alpha1.GitHubUsernameAttribute {
return u.UsernameAttribute
}
func (u *TestUpstreamGitHubIdentityProvider) GetGroupNameAttribute() v1alpha1.GitHubGroupNameAttribute {
return u.GroupNameAttribute
}
func (u *TestUpstreamGitHubIdentityProvider) GetAllowedOrganizations() *setutil.CaseInsensitiveSet {
return u.AllowedOrganizations
}
func (u *TestUpstreamGitHubIdentityProvider) GetAuthorizationURL() string {
return u.AuthorizationURL
}
func (u *TestUpstreamGitHubIdentityProvider) ExchangeAuthcode(
ctx context.Context,
authcode string,
redirectURI string,
) (string, error) {
if u.exchangeAuthcodeArgs == nil {
u.exchangeAuthcodeArgs = make([]*ExchangeAuthcodeArgs, 0)
}
u.exchangeAuthcodeCallCount++
u.exchangeAuthcodeArgs = append(u.exchangeAuthcodeArgs, &ExchangeAuthcodeArgs{
Ctx: ctx,
Authcode: authcode,
RedirectURI: redirectURI,
})
return u.ExchangeAuthcodeFunc(ctx, authcode)
}
func (u *TestUpstreamGitHubIdentityProvider) ExchangeAuthcodeCallCount() int {
return u.exchangeAuthcodeCallCount
}
func (u *TestUpstreamGitHubIdentityProvider) ExchangeAuthcodeArgs(call int) *ExchangeAuthcodeArgs {
if u.exchangeAuthcodeArgs == nil {
u.exchangeAuthcodeArgs = make([]*ExchangeAuthcodeArgs, 0)
}
return u.exchangeAuthcodeArgs[call]
}
func (u *TestUpstreamGitHubIdentityProvider) GetUser(ctx context.Context, accessToken string, idpDisplayName string) (*upstreamprovider.GitHubUser, error) {
if u.getUserArgs == nil {
u.getUserArgs = make([]*GetUserArgs, 0)
}
u.getUserCallCount++
u.getUserArgs = append(u.getUserArgs, &GetUserArgs{
Ctx: ctx,
AccessToken: accessToken,
IDPDisplayName: idpDisplayName,
})
return u.GetUserFunc(ctx, accessToken)
}
func (u *TestUpstreamGitHubIdentityProvider) GetUserCallCount() int {
return u.getUserCallCount
}
func (u *TestUpstreamGitHubIdentityProvider) GetUserArgs(call int) *GetUserArgs {
if u.getUserArgs == nil {
u.getUserArgs = make([]*GetUserArgs, 0)
}
return u.getUserArgs[call]
}
@@ -14,6 +14,12 @@ import (
"go.pinniped.dev/internal/idtransform"
)
type PerformLDAPRefreshArgs struct {
Ctx context.Context
StoredRefreshAttributes upstreamprovider.LDAPRefreshAttributes
IDPDisplayName string
}
func NewTestUpstreamLDAPIdentityProviderBuilder() *TestUpstreamLDAPIdentityProviderBuilder {
return &TestUpstreamLDAPIdentityProviderBuilder{}
}
@@ -102,7 +108,7 @@ type TestUpstreamLDAPIdentityProvider struct {
// Fields for tracking actual calls make to mock functions.
performRefreshCallCount int
performRefreshArgs []*PerformRefreshArgs
performRefreshArgs []*PerformLDAPRefreshArgs
}
var _ upstreamprovider.UpstreamLDAPIdentityProviderI = &TestUpstreamLDAPIdentityProvider{}
@@ -111,7 +117,7 @@ func (u *TestUpstreamLDAPIdentityProvider) GetResourceUID() types.UID {
return u.ResourceUID
}
func (u *TestUpstreamLDAPIdentityProvider) GetName() string {
func (u *TestUpstreamLDAPIdentityProvider) GetResourceName() string {
return u.Name
}
@@ -123,16 +129,15 @@ func (u *TestUpstreamLDAPIdentityProvider) GetURL() *url.URL {
return u.URL
}
func (u *TestUpstreamLDAPIdentityProvider) PerformRefresh(ctx context.Context, storedRefreshAttributes upstreamprovider.RefreshAttributes, _idpDisplayName string) ([]string, error) {
func (u *TestUpstreamLDAPIdentityProvider) PerformRefresh(ctx context.Context, storedRefreshAttributes upstreamprovider.LDAPRefreshAttributes, idpDisplayName string) ([]string, error) {
if u.performRefreshArgs == nil {
u.performRefreshArgs = make([]*PerformRefreshArgs, 0)
u.performRefreshArgs = make([]*PerformLDAPRefreshArgs, 0)
}
u.performRefreshCallCount++
u.performRefreshArgs = append(u.performRefreshArgs, &PerformRefreshArgs{
Ctx: ctx,
DN: storedRefreshAttributes.DN,
ExpectedUsername: storedRefreshAttributes.Username,
ExpectedSubject: storedRefreshAttributes.Subject,
u.performRefreshArgs = append(u.performRefreshArgs, &PerformLDAPRefreshArgs{
Ctx: ctx,
StoredRefreshAttributes: storedRefreshAttributes,
IDPDisplayName: idpDisplayName,
})
if u.PerformRefreshErr != nil {
return nil, u.PerformRefreshErr
@@ -144,9 +149,9 @@ func (u *TestUpstreamLDAPIdentityProvider) PerformRefreshCallCount() int {
return u.performRefreshCallCount
}
func (u *TestUpstreamLDAPIdentityProvider) PerformRefreshArgs(call int) *PerformRefreshArgs {
func (u *TestUpstreamLDAPIdentityProvider) PerformRefreshArgs(call int) *PerformLDAPRefreshArgs {
if u.performRefreshArgs == nil {
u.performRefreshArgs = make([]*PerformRefreshArgs, 0)
u.performRefreshArgs = make([]*PerformLDAPRefreshArgs, 0)
}
return u.performRefreshArgs[call]
}
@@ -36,14 +36,11 @@ type PasswordCredentialsGrantAndValidateTokensArgs struct {
Password string
}
// PerformRefreshArgs is used to spy on calls to
// PerformOIDCRefreshArgs is used to spy on calls to
// TestUpstreamOIDCIdentityProvider.PerformRefreshFunc().
type PerformRefreshArgs struct {
Ctx context.Context
RefreshToken string
DN string
ExpectedUsername string
ExpectedSubject string
type PerformOIDCRefreshArgs struct {
Ctx context.Context
RefreshToken string
}
// RevokeTokenArgs is used to spy on calls to
@@ -105,7 +102,7 @@ type TestUpstreamOIDCIdentityProvider struct {
passwordCredentialsGrantAndValidateTokensCallCount int
passwordCredentialsGrantAndValidateTokensArgs []*PasswordCredentialsGrantAndValidateTokensArgs
performRefreshCallCount int
performRefreshArgs []*PerformRefreshArgs
performRefreshArgs []*PerformOIDCRefreshArgs
revokeTokenCallCount int
revokeTokenArgs []*RevokeTokenArgs
validateTokenAndMergeWithUserInfoCallCount int
@@ -126,7 +123,7 @@ func (u *TestUpstreamOIDCIdentityProvider) GetAdditionalClaimMappings() map[stri
return u.AdditionalClaimMappings
}
func (u *TestUpstreamOIDCIdentityProvider) GetName() string {
func (u *TestUpstreamOIDCIdentityProvider) GetResourceName() string {
return u.Name
}
@@ -217,10 +214,10 @@ func (u *TestUpstreamOIDCIdentityProvider) PasswordCredentialsGrantAndValidateTo
func (u *TestUpstreamOIDCIdentityProvider) PerformRefresh(ctx context.Context, refreshToken string) (*oauth2.Token, error) {
if u.performRefreshArgs == nil {
u.performRefreshArgs = make([]*PerformRefreshArgs, 0)
u.performRefreshArgs = make([]*PerformOIDCRefreshArgs, 0)
}
u.performRefreshCallCount++
u.performRefreshArgs = append(u.performRefreshArgs, &PerformRefreshArgs{
u.performRefreshArgs = append(u.performRefreshArgs, &PerformOIDCRefreshArgs{
Ctx: ctx,
RefreshToken: refreshToken,
})
@@ -244,9 +241,9 @@ func (u *TestUpstreamOIDCIdentityProvider) PerformRefreshCallCount() int {
return u.performRefreshCallCount
}
func (u *TestUpstreamOIDCIdentityProvider) PerformRefreshArgs(call int) *PerformRefreshArgs {
func (u *TestUpstreamOIDCIdentityProvider) PerformRefreshArgs(call int) *PerformOIDCRefreshArgs {
if u.performRefreshArgs == nil {
u.performRefreshArgs = make([]*PerformRefreshArgs, 0)
u.performRefreshArgs = make([]*PerformOIDCRefreshArgs, 0)
}
return u.performRefreshArgs[call]
}
+185 -55
View File
@@ -12,6 +12,7 @@ import (
"go.pinniped.dev/internal/federationdomain/dynamicupstreamprovider"
"go.pinniped.dev/internal/federationdomain/resolvedprovider"
"go.pinniped.dev/internal/federationdomain/resolvedprovider/resolvedgithub"
"go.pinniped.dev/internal/federationdomain/resolvedprovider/resolvedldap"
"go.pinniped.dev/internal/federationdomain/resolvedprovider/resolvedoidc"
"go.pinniped.dev/internal/federationdomain/upstreamprovider"
@@ -25,6 +26,7 @@ type TestFederationDomainIdentityProvidersListerFinder struct {
upstreamOIDCIdentityProviders []*oidctestutil.TestUpstreamOIDCIdentityProvider
upstreamLDAPIdentityProviders []*oidctestutil.TestUpstreamLDAPIdentityProvider
upstreamActiveDirectoryIdentityProviders []*oidctestutil.TestUpstreamLDAPIdentityProvider
upstreamGitHubIdentityProviders []*oidctestutil.TestUpstreamGitHubIdentityProvider
defaultIDPDisplayName string
}
@@ -38,7 +40,7 @@ func (t *TestFederationDomainIdentityProvidersListerFinder) IDPCount() int {
func (t *TestFederationDomainIdentityProvidersListerFinder) GetIdentityProviders() []resolvedprovider.FederationDomainResolvedIdentityProvider {
fdIDPs := make([]resolvedprovider.FederationDomainResolvedIdentityProvider,
len(t.upstreamOIDCIdentityProviders)+len(t.upstreamLDAPIdentityProviders)+len(t.upstreamActiveDirectoryIdentityProviders))
len(t.upstreamOIDCIdentityProviders)+len(t.upstreamLDAPIdentityProviders)+len(t.upstreamActiveDirectoryIdentityProviders)+len(t.upstreamGitHubIdentityProviders))
i := 0
for _, testIDP := range t.upstreamOIDCIdentityProviders {
fdIDP := &resolvedoidc.FederationDomainResolvedOIDCIdentityProvider{
@@ -70,6 +72,16 @@ func (t *TestFederationDomainIdentityProvidersListerFinder) GetIdentityProviders
fdIDPs[i] = fdIDP
i++
}
for _, testIDP := range t.upstreamGitHubIdentityProviders {
fdIDP := &resolvedgithub.FederationDomainResolvedGitHubIdentityProvider{
DisplayName: testIDP.DisplayNameForFederationDomain,
Provider: testIDP,
SessionProviderType: psession.ProviderTypeGitHub,
Transforms: testIDP.TransformsForFederationDomain,
}
fdIDPs[i] = fdIDP
i++
}
return fdIDPs
}
@@ -111,6 +123,16 @@ func (t *TestFederationDomainIdentityProvidersListerFinder) FindUpstreamIDPByDis
}, nil
}
}
for _, testIDP := range t.upstreamGitHubIdentityProviders {
if upstreamIDPDisplayName == testIDP.DisplayNameForFederationDomain {
return &resolvedgithub.FederationDomainResolvedGitHubIdentityProvider{
DisplayName: testIDP.DisplayNameForFederationDomain,
Provider: testIDP,
SessionProviderType: psession.ProviderTypeGitHub,
Transforms: testIDP.TransformsForFederationDomain,
}, nil
}
}
return nil, fmt.Errorf("did not find IDP with name %q", upstreamIDPDisplayName)
}
@@ -126,12 +148,17 @@ func (t *TestFederationDomainIdentityProvidersListerFinder) SetActiveDirectoryId
t.upstreamActiveDirectoryIdentityProviders = providers
}
func (t *TestFederationDomainIdentityProvidersListerFinder) SetGitHubIdentityProviders(providers []*oidctestutil.TestUpstreamGitHubIdentityProvider) {
t.upstreamGitHubIdentityProviders = providers
}
// UpstreamIDPListerBuilder can be used to build either a dynamicupstreamprovider.DynamicUpstreamIDPProvider
// or a FederationDomainIdentityProvidersListerFinderI for testing.
type UpstreamIDPListerBuilder struct {
upstreamOIDCIdentityProviders []*oidctestutil.TestUpstreamOIDCIdentityProvider
upstreamLDAPIdentityProviders []*oidctestutil.TestUpstreamLDAPIdentityProvider
upstreamActiveDirectoryIdentityProviders []*oidctestutil.TestUpstreamLDAPIdentityProvider
upstreamGitHubIdentityProviders []*oidctestutil.TestUpstreamGitHubIdentityProvider
defaultIDPDisplayName string
}
@@ -150,6 +177,11 @@ func (b *UpstreamIDPListerBuilder) WithActiveDirectory(upstreamActiveDirectoryId
return b
}
func (b *UpstreamIDPListerBuilder) WithGitHub(upstreamGithubIdentityProviders ...*oidctestutil.TestUpstreamGitHubIdentityProvider) *UpstreamIDPListerBuilder {
b.upstreamGitHubIdentityProviders = append(b.upstreamGitHubIdentityProviders, upstreamGithubIdentityProviders...)
return b
}
func (b *UpstreamIDPListerBuilder) WithDefaultIDPDisplayName(defaultIDPDisplayName string) *UpstreamIDPListerBuilder {
b.defaultIDPDisplayName = defaultIDPDisplayName
return b
@@ -160,6 +192,7 @@ func (b *UpstreamIDPListerBuilder) BuildFederationDomainIdentityProvidersListerF
upstreamOIDCIdentityProviders: b.upstreamOIDCIdentityProviders,
upstreamLDAPIdentityProviders: b.upstreamLDAPIdentityProviders,
upstreamActiveDirectoryIdentityProviders: b.upstreamActiveDirectoryIdentityProviders,
upstreamGitHubIdentityProviders: b.upstreamGitHubIdentityProviders,
defaultIDPDisplayName: b.defaultIDPDisplayName,
}
}
@@ -185,6 +218,12 @@ func (b *UpstreamIDPListerBuilder) BuildDynamicUpstreamIDPProvider() dynamicupst
}
idpProvider.SetActiveDirectoryIdentityProviders(adUpstreams)
githubUpstreams := make([]upstreamprovider.UpstreamGithubIdentityProviderI, len(b.upstreamGitHubIdentityProviders))
for i := range b.upstreamGitHubIdentityProviders {
githubUpstreams[i] = upstreamprovider.UpstreamGithubIdentityProviderI(b.upstreamGitHubIdentityProviders[i])
}
idpProvider.SetGitHubIdentityProviders(githubUpstreams)
return idpProvider
}
@@ -225,7 +264,7 @@ func (b *UpstreamIDPListerBuilder) RequireExactlyZeroCallsToPasswordCredentialsG
)
}
func (b *UpstreamIDPListerBuilder) RequireExactlyOneCallToExchangeAuthcodeAndValidateTokens(
func (b *UpstreamIDPListerBuilder) RequireExactlyOneOIDCAuthcodeExchange(
t *testing.T,
expectedPerformedByUpstreamName string,
expectedArgs *oidctestutil.ExchangeAuthcodeAndValidateTokenArgs,
@@ -233,79 +272,170 @@ func (b *UpstreamIDPListerBuilder) RequireExactlyOneCallToExchangeAuthcodeAndVal
t.Helper()
var actualArgs *oidctestutil.ExchangeAuthcodeAndValidateTokenArgs
var actualNameOfUpstreamWhichMadeCall string
actualCallCountAcrossAllOIDCUpstreams := 0
for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders {
callCountOnThisUpstream := upstreamOIDC.ExchangeAuthcodeAndValidateTokensCallCount()
actualCallCountAcrossAllOIDCUpstreams += callCountOnThisUpstream
actualCallCount := 0
for _, upstream := range b.upstreamOIDCIdentityProviders {
callCountOnThisUpstream := upstream.ExchangeAuthcodeAndValidateTokensCallCount()
actualCallCount += callCountOnThisUpstream
if callCountOnThisUpstream == 1 {
actualNameOfUpstreamWhichMadeCall = upstreamOIDC.Name
actualArgs = upstreamOIDC.ExchangeAuthcodeAndValidateTokensArgs(0)
actualNameOfUpstreamWhichMadeCall = upstream.Name
actualArgs = upstream.ExchangeAuthcodeAndValidateTokensArgs(0)
}
}
require.Equal(t, 1, actualCallCountAcrossAllOIDCUpstreams,
"should have been exactly one call to ExchangeAuthcodeAndValidateTokens() by all OIDC upstreams",
require.Equal(t, 1, actualCallCount,
"expected exactly one call to OIDC ExchangeAuthcodeAndValidateTokens()",
)
require.Equal(t, expectedPerformedByUpstreamName, actualNameOfUpstreamWhichMadeCall,
"ExchangeAuthcodeAndValidateTokens() was called on the wrong OIDC upstream",
"OIDC ExchangeAuthcodeAndValidateTokens() was called on the wrong upstream name",
)
require.Equal(t, expectedArgs, actualArgs)
}
func (b *UpstreamIDPListerBuilder) RequireExactlyZeroCallsToExchangeAuthcodeAndValidateTokens(t *testing.T) {
t.Helper()
actualCallCountAcrossAllOIDCUpstreams := 0
for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders {
actualCallCountAcrossAllOIDCUpstreams += upstreamOIDC.ExchangeAuthcodeAndValidateTokensCallCount()
}
require.Equal(t, 0, actualCallCountAcrossAllOIDCUpstreams,
"expected exactly zero calls to ExchangeAuthcodeAndValidateTokens()",
)
}
func (b *UpstreamIDPListerBuilder) RequireExactlyOneCallToPerformRefresh(
func (b *UpstreamIDPListerBuilder) RequireExactlyOneGitHubAuthcodeExchange(
t *testing.T,
expectedPerformedByUpstreamName string,
expectedArgs *oidctestutil.PerformRefreshArgs,
expectedArgs *oidctestutil.ExchangeAuthcodeArgs,
) {
t.Helper()
var actualArgs *oidctestutil.PerformRefreshArgs
var actualArgs *oidctestutil.ExchangeAuthcodeArgs
var actualNameOfUpstreamWhichMadeCall string
actualCallCountAcrossAllUpstreams := 0
for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders {
callCountOnThisUpstream := upstreamOIDC.PerformRefreshCallCount()
actualCallCountAcrossAllUpstreams += callCountOnThisUpstream
actualCallCount := 0
for _, upstream := range b.upstreamGitHubIdentityProviders {
callCountOnThisUpstream := upstream.ExchangeAuthcodeCallCount()
actualCallCount += callCountOnThisUpstream
if callCountOnThisUpstream == 1 {
actualNameOfUpstreamWhichMadeCall = upstreamOIDC.Name
actualArgs = upstreamOIDC.PerformRefreshArgs(0)
actualNameOfUpstreamWhichMadeCall = upstream.Name
actualArgs = upstream.ExchangeAuthcodeArgs(0)
}
}
for _, upstreamLDAP := range b.upstreamLDAPIdentityProviders {
callCountOnThisUpstream := upstreamLDAP.PerformRefreshCallCount()
actualCallCountAcrossAllUpstreams += callCountOnThisUpstream
if callCountOnThisUpstream == 1 {
actualNameOfUpstreamWhichMadeCall = upstreamLDAP.Name
actualArgs = upstreamLDAP.PerformRefreshArgs(0)
}
}
for _, upstreamAD := range b.upstreamActiveDirectoryIdentityProviders {
callCountOnThisUpstream := upstreamAD.PerformRefreshCallCount()
actualCallCountAcrossAllUpstreams += callCountOnThisUpstream
if callCountOnThisUpstream == 1 {
actualNameOfUpstreamWhichMadeCall = upstreamAD.Name
actualArgs = upstreamAD.PerformRefreshArgs(0)
}
}
require.Equal(t, 1, actualCallCountAcrossAllUpstreams,
"should have been exactly one call to PerformRefresh() by all upstreams",
require.Equal(t, 1, actualCallCount,
"expected exactly one call to GitHub ExchangeAuthcode()",
)
require.Equal(t, expectedPerformedByUpstreamName, actualNameOfUpstreamWhichMadeCall,
"PerformRefresh() was called on the wrong upstream",
"GitHub ExchangeAuthcode() was called on the wrong upstream name",
)
require.Equal(t, expectedArgs, actualArgs)
}
func (b *UpstreamIDPListerBuilder) RequireExactlyZeroCallsToPerformRefresh(t *testing.T) {
func (b *UpstreamIDPListerBuilder) RequireExactlyZeroAuthcodeExchanges(t *testing.T) {
t.Helper()
actualCallCount := 0
for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders {
actualCallCount += upstreamOIDC.ExchangeAuthcodeAndValidateTokensCallCount()
}
for _, upstreamGitHub := range b.upstreamGitHubIdentityProviders {
actualCallCount += upstreamGitHub.ExchangeAuthcodeCallCount()
}
require.Equal(t, 0, actualCallCount,
"expected exactly zero calls to OIDC ExchangeAuthcodeAndValidateTokens() or GitHub ExchangeAuthcode()",
)
}
func (b *UpstreamIDPListerBuilder) RequireExactlyOneCallToOIDCPerformRefresh(
t *testing.T,
expectedPerformedByUpstreamName string,
expectedArgs *oidctestutil.PerformOIDCRefreshArgs,
) {
t.Helper()
var actualArgs *oidctestutil.PerformOIDCRefreshArgs
var actualNameOfUpstreamWhichMadeCall string
for _, upstream := range b.upstreamOIDCIdentityProviders {
callCountOnThisUpstream := upstream.PerformRefreshCallCount()
if callCountOnThisUpstream == 1 {
actualNameOfUpstreamWhichMadeCall = upstream.Name
actualArgs = upstream.PerformRefreshArgs(0)
}
}
require.Equal(t, 1, b.CountAllCallsToAnyUpstreamRefresh(),
"should have been exactly one call to upstream refresh by all upstreams",
)
require.Equal(t, expectedPerformedByUpstreamName, actualNameOfUpstreamWhichMadeCall,
"upstream refresh was called on the wrong upstream",
)
require.Equal(t, expectedArgs, actualArgs)
}
func (b *UpstreamIDPListerBuilder) RequireExactlyOneCallToActiveDirectoryPerformRefresh(
t *testing.T,
expectedPerformedByUpstreamName string,
expectedArgs *oidctestutil.PerformLDAPRefreshArgs,
) {
t.Helper()
var actualArgs *oidctestutil.PerformLDAPRefreshArgs
var actualNameOfUpstreamWhichMadeCall string
for _, upstream := range b.upstreamActiveDirectoryIdentityProviders {
callCountOnThisUpstream := upstream.PerformRefreshCallCount()
if callCountOnThisUpstream == 1 {
actualNameOfUpstreamWhichMadeCall = upstream.Name
actualArgs = upstream.PerformRefreshArgs(0)
}
}
require.Equal(t, 1, b.CountAllCallsToAnyUpstreamRefresh(),
"should have been exactly one call to upstream refresh by all upstreams",
)
require.Equal(t, expectedPerformedByUpstreamName, actualNameOfUpstreamWhichMadeCall,
"upstream refresh was called on the wrong upstream",
)
require.Equal(t, expectedArgs, actualArgs)
}
func (b *UpstreamIDPListerBuilder) RequireExactlyOneCallToLDAPPerformRefresh(
t *testing.T,
expectedPerformedByUpstreamName string,
expectedArgs *oidctestutil.PerformLDAPRefreshArgs,
) {
t.Helper()
var actualArgs *oidctestutil.PerformLDAPRefreshArgs
var actualNameOfUpstreamWhichMadeCall string
for _, upstream := range b.upstreamLDAPIdentityProviders {
callCountOnThisUpstream := upstream.PerformRefreshCallCount()
if callCountOnThisUpstream == 1 {
actualNameOfUpstreamWhichMadeCall = upstream.Name
actualArgs = upstream.PerformRefreshArgs(0)
}
}
require.Equal(t, 1, b.CountAllCallsToAnyUpstreamRefresh(),
"should have been exactly one call to upstream refresh by all upstreams",
)
require.Equal(t, expectedPerformedByUpstreamName, actualNameOfUpstreamWhichMadeCall,
"upstream refresh was called on the wrong upstream",
)
require.Equal(t, expectedArgs, actualArgs)
}
func (b *UpstreamIDPListerBuilder) RequireExactlyOneCallToGithubGetUser(
t *testing.T,
expectedPerformedByUpstreamName string,
expectedArgs *oidctestutil.GetUserArgs,
) {
t.Helper()
var actualArgs *oidctestutil.GetUserArgs
var actualNameOfUpstreamWhichMadeCall string
for _, upstream := range b.upstreamGitHubIdentityProviders {
// GitHub calls GetUser during both the original authcode exchange and the refresh.
callCountOnThisUpstream := upstream.GetUserCallCount()
if callCountOnThisUpstream == 1 {
actualNameOfUpstreamWhichMadeCall = upstream.Name
actualArgs = upstream.GetUserArgs(0)
}
}
require.Equal(t, 1, b.CountAllCallsToAnyUpstreamRefresh(),
"should have been exactly one call to upstream refresh by all upstreams",
)
require.Equal(t, expectedPerformedByUpstreamName, actualNameOfUpstreamWhichMadeCall,
"upstream refresh was called on the wrong upstream",
)
require.Equal(t, expectedArgs, actualArgs)
}
func (b *UpstreamIDPListerBuilder) RequireExactlyZeroCallsToAnyUpstreamRefresh(t *testing.T) {
t.Helper()
require.Equal(t, 0, b.CountAllCallsToAnyUpstreamRefresh(),
"expected exactly zero calls to any upstream refresh mocks",
)
}
func (b *UpstreamIDPListerBuilder) CountAllCallsToAnyUpstreamRefresh() int {
actualCallCountAcrossAllUpstreams := 0
for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders {
actualCallCountAcrossAllUpstreams += upstreamOIDC.PerformRefreshCallCount()
@@ -316,10 +446,10 @@ func (b *UpstreamIDPListerBuilder) RequireExactlyZeroCallsToPerformRefresh(t *te
for _, upstreamActiveDirectory := range b.upstreamActiveDirectoryIdentityProviders {
actualCallCountAcrossAllUpstreams += upstreamActiveDirectory.PerformRefreshCallCount()
}
require.Equal(t, 0, actualCallCountAcrossAllUpstreams,
"expected exactly zero calls to PerformRefresh()",
)
for _, upstreamGithub := range b.upstreamGitHubIdentityProviders {
actualCallCountAcrossAllUpstreams += upstreamGithub.GetUserCallCount()
}
return actualCallCountAcrossAllUpstreams
}
func (b *UpstreamIDPListerBuilder) RequireExactlyOneCallToValidateToken(
-1
View File
@@ -43,7 +43,6 @@ func TestServerIPv6(t *testing.T, handler http.Handler, f func(*httptest.Server)
Listener: listener,
Config: &http.Server{Handler: handler}, //nolint:gosec //ReadHeaderTimeout is not needed for a localhost listener
}
return testServer(t, server, f)
}
+88
View File
@@ -0,0 +1,88 @@
// Copyright 2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package totp
import (
"crypto/hmac"
"crypto/sha1" //nolint:gosec // This is an implementation of an RFC that used SHA-1
"encoding/base32"
"encoding/binary"
"fmt"
"math"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// This code is borrowed from
// https://github.com/yitsushi/totp-cli/blob/b26f5673ae2e5cc682fc1f5ed771cb08a6403283/internal/security/otp.go
// and
// https://github.com/yitsushi/totp-cli/blob/b26f5673ae2e5cc682fc1f5ed771cb08a6403283/internal/security/error.go
// which is MIT licensed. The MIT license allows copying.
// We are choosing to copying rather than take on a whole new project dependency just for a small test helper.
const (
mask1 = 0xf
mask2 = 0x7f
mask3 = 0xff
timeSplitInSeconds = 30
shift24 = 24
shift16 = 16
shift8 = 8
sumByteLength = 8
)
// OTPError is an error describing an error during generation.
type OTPError struct {
Message string
}
func (e OTPError) Error() string {
return "otp error: " + e.Message
}
// GenerateOTPCode generates a 6 digit TOTP from the secret Token.
func GenerateOTPCode(t *testing.T, token string, when time.Time) (string, int64) {
t.Helper()
require.NotEmpty(t, token)
timer := uint64(math.Floor(float64(when.Unix()) / float64(timeSplitInSeconds)))
remainingTime := timeSplitInSeconds - when.Unix()%timeSplitInSeconds
// Remove spaces, some providers are giving us in a readable format,
// so they add spaces in there. If it's not removed while pasting in,
// remove it now.
token = strings.ReplaceAll(token, " ", "")
// It should be uppercase always
token = strings.ToUpper(token)
secretBytes, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(token)
require.NoError(t, err)
length := 6
buf := make([]byte, sumByteLength)
mac := hmac.New(sha1.New, secretBytes)
binary.BigEndian.PutUint64(buf, timer)
_, _ = mac.Write(buf)
sum := mac.Sum(nil)
// http://tools.ietf.org/html/rfc4226#section-5.4
offset := sum[len(sum)-1] & mask1
value := int64(((int(sum[offset]) & mask2) << shift24) |
((int(sum[offset+1] & mask3)) << shift16) |
((int(sum[offset+2] & mask3)) << shift8) |
(int(sum[offset+3]) & mask3))
modulo := int32(value % int64(math.Pow10(length)))
format := fmt.Sprintf("%%0%dd", length)
return fmt.Sprintf(format, modulo), remainingTime
}
+47
View File
@@ -0,0 +1,47 @@
// Copyright 2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package totp
import (
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestGenerateOTPCode(t *testing.T) {
tests := []struct {
name string
token string
when time.Time
wantCode string
wantRemainingLifetimeSeconds int64
}{
{
name: "Use a token from online example",
token: "JBSWY3DPEHPK3PXP", // https://github.com/pquerna/otp/blob/3357de7c04813a328d6a1e4a514854213e0f8ce8/totp/totp.go#L180
when: time.Unix(1715205169, 0),
wantCode: "780919",
wantRemainingLifetimeSeconds: 11,
},
{
name: "Use a token that was randomly generated",
token: "EDAYKXL3TEYZNQ3O4N5KPSUAQQLZYUJG",
when: time.Unix(1715225917, 0),
wantCode: "920615",
wantRemainingLifetimeSeconds: 23,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
actualCode, actualRemainingLifetimeSeconds := GenerateOTPCode(t, test.token, test.when)
require.Equal(t, test.wantCode, actualCode)
require.Equal(t, test.wantRemainingLifetimeSeconds, actualRemainingLifetimeSeconds)
})
}
}
+192
View File
@@ -0,0 +1,192 @@
// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
// Package upstreamgithub implements an abstraction of upstream GitHub provider interactions.
package upstreamgithub
import (
"context"
"fmt"
"net/http"
coreosoidc "github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
"k8s.io/apimachinery/pkg/types"
supervisoridpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1"
"go.pinniped.dev/internal/federationdomain/downstreamsubject"
"go.pinniped.dev/internal/federationdomain/upstreamprovider"
"go.pinniped.dev/internal/githubclient"
"go.pinniped.dev/internal/plog"
"go.pinniped.dev/internal/setutil"
)
// ProviderConfig holds the active configuration of an upstream GitHub provider.
type ProviderConfig struct {
Name string
ResourceUID types.UID
// APIBaseURL is the url of the GitHub API, not including the path to a specific API endpoint.
// According to the GitHub docs, it should be either https://api.github.com/ for cloud
// or https://HOSTNAME/api/v3/ for Enterprise Server.
APIBaseURL string
UsernameAttribute supervisoridpv1alpha1.GitHubUsernameAttribute
GroupNameAttribute supervisoridpv1alpha1.GitHubGroupNameAttribute
// AllowedOrganizations, when empty, means to allow users from all orgs.
AllowedOrganizations *setutil.CaseInsensitiveSet
// HttpClient is a client that can be used to call the GitHub APIs and token endpoint.
// This client should be configured with the user-provided CA bundle and a timeout.
HttpClient *http.Client
// OAuth2Config contains ClientID, ClientSecret, Scopes, and Endpoint (which contains auth and token endpoint URLs,
// and auth style for the token endpoint).
// OAuth2Config will not be used to compute the authorize URL because the redirect back to the Supervisor's
// callback must be different per FederationDomain. It holds data that may be useful when calculating the
// authorize URL, so that data is exposed by interface methods. However, it can be used to call the token endpoint,
// for which there is no RedirectURL needed.
OAuth2Config *oauth2.Config
}
type Provider struct {
c ProviderConfig
buildGitHubClient func(httpClient *http.Client, apiBaseURL, token string) (githubclient.GitHubInterface, error)
}
var _ upstreamprovider.UpstreamGithubIdentityProviderI = &Provider{}
// New creates a Provider. The config is not a pointer to ensure that a copy of the config is created,
// making the resulting Provider use an effectively read-only configuration.
func New(config ProviderConfig) *Provider {
return &Provider{
c: config,
buildGitHubClient: githubclient.NewGitHubClient,
}
}
func (p *Provider) GetResourceName() string {
return p.c.Name
}
func (p *Provider) GetResourceUID() types.UID {
return p.c.ResourceUID
}
func (p *Provider) GetClientID() string {
return p.c.OAuth2Config.ClientID
}
func (p *Provider) GetScopes() []string {
return p.c.OAuth2Config.Scopes
}
func (p *Provider) GetUsernameAttribute() supervisoridpv1alpha1.GitHubUsernameAttribute {
return p.c.UsernameAttribute
}
func (p *Provider) GetGroupNameAttribute() supervisoridpv1alpha1.GitHubGroupNameAttribute {
return p.c.GroupNameAttribute
}
func (p *Provider) GetAllowedOrganizations() *setutil.CaseInsensitiveSet {
return p.c.AllowedOrganizations
}
func (p *Provider) GetAuthorizationURL() string {
return p.c.OAuth2Config.Endpoint.AuthURL
}
func (p *Provider) ExchangeAuthcode(ctx context.Context, authcode string, redirectURI string) (string, error) {
tok, err := p.c.OAuth2Config.Exchange(
coreosoidc.ClientContext(ctx, p.c.HttpClient),
authcode,
oauth2.SetAuthURLParam("redirect_uri", redirectURI),
)
if err != nil {
return "", fmt.Errorf("error exchanging authorization code using GitHub API: %w", err)
}
return tok.AccessToken, nil
}
// GetUser will use the provided configuration to make HTTPS calls to the GitHub API to get the identity of the
// authenticated user and to discover their org and team memberships.
// If the user's information meets the AllowedOrganization criteria specified on the GitHubIdentityProvider,
// they will be allowed to log in.
// Note that errors from the githubclient package already have helpful error prefixes, so there is no need for additional prefixes here.
func (p *Provider) GetUser(ctx context.Context, accessToken string, idpDisplayName string) (*upstreamprovider.GitHubUser, error) {
githubClient, err := p.buildGitHubClient(p.c.HttpClient, p.c.APIBaseURL, accessToken)
if err != nil {
return nil, err
}
githubUser := upstreamprovider.GitHubUser{}
userInfo, err := githubClient.GetUserInfo(ctx)
if err != nil {
return nil, err
}
githubUser.DownstreamSubject = downstreamsubject.GitHub(p.c.APIBaseURL, idpDisplayName, userInfo.Login, userInfo.ID)
switch p.c.UsernameAttribute {
case supervisoridpv1alpha1.GitHubUsernameLoginAndID:
githubUser.Username = fmt.Sprintf("%s:%s", userInfo.Login, userInfo.ID)
case supervisoridpv1alpha1.GitHubUsernameLogin:
githubUser.Username = userInfo.Login
case supervisoridpv1alpha1.GitHubUsernameID:
githubUser.Username = userInfo.ID
default:
return nil, fmt.Errorf("bad configuration: unknown GitHub username attribute: %s", p.c.UsernameAttribute)
}
orgMembership, err := githubClient.GetOrgMembership(ctx)
if err != nil {
return nil, err
}
if !p.c.AllowedOrganizations.Empty() && !p.c.AllowedOrganizations.HasAnyIgnoringCase(orgMembership) {
plog.Warning("user is not allowed to log in due to organization membership policy", // do not log username to avoid PII
"userBelongsToOrganizations", orgMembership,
"configuredAllowedOrganizations", p.c.AllowedOrganizations,
"identityProviderDisplayName", idpDisplayName,
"identityProviderResourceName", p.GetResourceName())
plog.Trace("user is not allowed to log in due to organization membership policy", // okay to log PII at trace level
"githubLogin", userInfo.Login,
"githubID", userInfo.ID,
"calculatedUsername", githubUser.Username,
"userBelongsToOrganizations", orgMembership,
"configuredAllowedOrganizations", p.c.AllowedOrganizations,
"identityProviderDisplayName", idpDisplayName,
"identityProviderResourceName", p.GetResourceName())
return nil, upstreamprovider.NewGitHubLoginDeniedError("user is not allowed to log in due to organization membership policy")
}
teamMembership, err := githubClient.GetTeamMembership(ctx, p.c.AllowedOrganizations)
if err != nil {
return nil, err
}
for _, team := range teamMembership {
downstreamGroup := ""
switch p.c.GroupNameAttribute {
case supervisoridpv1alpha1.GitHubUseTeamNameForGroupName:
downstreamGroup = fmt.Sprintf("%s/%s", team.Org, team.Name)
case supervisoridpv1alpha1.GitHubUseTeamSlugForGroupName:
downstreamGroup = fmt.Sprintf("%s/%s", team.Org, team.Slug)
default:
return nil, fmt.Errorf("bad configuration: unknown GitHub group name attribute: %s", p.c.GroupNameAttribute)
}
githubUser.Groups = append(githubUser.Groups, downstreamGroup)
}
return &githubUser, nil
}
// GetConfig returns the config. This is not part of the UpstreamGithubIdentityProviderI interface and is just for testing.
func (p *Provider) GetConfig() ProviderConfig {
return p.c
}
@@ -0,0 +1,511 @@
// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package upstreamgithub
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"golang.org/x/oauth2"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/rand"
"k8s.io/client-go/util/cert"
supervisoridpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1"
"go.pinniped.dev/internal/federationdomain/upstreamprovider"
"go.pinniped.dev/internal/githubclient"
"go.pinniped.dev/internal/mocks/mockgithubclient"
"go.pinniped.dev/internal/setutil"
"go.pinniped.dev/internal/testutil/tlsserver"
)
func TestGitHubProvider(t *testing.T) {
subject := New(ProviderConfig{
Name: "foo",
ResourceUID: "resource-uid-12345",
APIBaseURL: "https://fake-base-url",
UsernameAttribute: "fake-username-attribute",
GroupNameAttribute: "fake-group-name-attribute",
OAuth2Config: &oauth2.Config{
ClientID: "fake-client-id",
ClientSecret: "fake-client-secret",
Scopes: []string{"scope1", "scope2"},
Endpoint: oauth2.Endpoint{
AuthURL: "https://fake-authorization-url",
DeviceAuthURL: "",
TokenURL: "https://fake-token-url",
AuthStyle: oauth2.AuthStyleInParams,
},
},
AllowedOrganizations: setutil.NewCaseInsensitiveSet("fake-org", "fake-org2"),
HttpClient: &http.Client{
Timeout: 1234509,
},
})
require.Equal(t, ProviderConfig{
Name: "foo",
ResourceUID: "resource-uid-12345",
APIBaseURL: "https://fake-base-url",
UsernameAttribute: "fake-username-attribute",
GroupNameAttribute: "fake-group-name-attribute",
OAuth2Config: &oauth2.Config{
ClientID: "fake-client-id",
ClientSecret: "fake-client-secret",
Scopes: []string{"scope1", "scope2"},
Endpoint: oauth2.Endpoint{
AuthURL: "https://fake-authorization-url",
DeviceAuthURL: "",
TokenURL: "https://fake-token-url",
AuthStyle: oauth2.AuthStyleInParams,
},
},
AllowedOrganizations: setutil.NewCaseInsensitiveSet("fake-org", "fake-org2"),
HttpClient: &http.Client{
Timeout: 1234509,
},
}, subject.GetConfig())
require.Equal(t, "foo", subject.GetResourceName())
require.Equal(t, types.UID("resource-uid-12345"), subject.GetResourceUID())
require.Equal(t, "fake-client-id", subject.GetClientID())
require.Equal(t, "fake-client-id", subject.GetClientID())
require.Equal(t, supervisoridpv1alpha1.GitHubUsernameAttribute("fake-username-attribute"), subject.GetUsernameAttribute())
require.Equal(t, supervisoridpv1alpha1.GitHubGroupNameAttribute("fake-group-name-attribute"), subject.GetGroupNameAttribute())
require.Equal(t, setutil.NewCaseInsensitiveSet("fake-org", "fake-org2"), subject.GetAllowedOrganizations())
require.Equal(t, "https://fake-authorization-url", subject.GetAuthorizationURL())
require.Equal(t, &http.Client{
Timeout: 1234509,
}, subject.GetConfig().HttpClient)
}
func TestExchangeAuthcode(t *testing.T) {
const fakeGitHubAccessToken = "gho_16C7e42F292c6912E7710c838347Ae178B4a" //nolint:gosec // this is not a credential
tests := []struct {
name string
tokenEndpointPath string
wantErr string
}{
{
name: "happy path",
tokenEndpointPath: "/token",
},
{
name: "when the GitHub token endpoint returns an error",
tokenEndpointPath: "/token-error",
wantErr: "error exchanging authorization code using GitHub API: oauth2: cannot fetch token: 401 Unauthorized\nResponse: some github error",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
testServer, testServerCA := tlsserver.TestServerIPv4(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// See documentation at https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps
// GitHub docs say to use a POST.
require.Equal(t, http.MethodPost, r.Method)
// The OAuth client library happens to choose to send these headers. Asserting here for our own understanding.
require.Len(t, r.Header, 4)
require.Equal(t, "application/x-www-form-urlencoded", r.Header.Get("Content-Type"))
require.Equal(t, "gzip", r.Header.Get("Accept-Encoding"))
require.NotEmpty(t, r.Header.Get("User-Agent"))
require.NotEmpty(t, r.Header.Get("Content-Length"))
// Get the params.
err := r.ParseForm()
require.NoError(t, err)
params := r.PostForm
require.Len(t, params, 5)
// These four params are documented by GitHub.
require.Equal(t, "fake-client-id", params.Get("client_id"))
require.Equal(t, "fake-client-secret", params.Get("client_secret"))
require.Equal(t, "https://fake-redirect-url", params.Get("redirect_uri"))
require.Equal(t, "fake-authcode", params.Get("code"))
// This param is not documented by GitHub, but is standard OAuth2. GitHub should respect or ignore it.
require.Equal(t, "authorization_code", params.Get("grant_type"))
// The GitHub docs say that it will return a URL encoded form by default, so I assume it would set this header.
w.Header().Set("content-type", "application/x-www-form-urlencoded")
switch r.URL.Path {
case "/token":
// Example response from GitHub docs.
responseBody := "access_token=" + fakeGitHubAccessToken + "&scope=repo%2Cgist&token_type=bearer"
w.WriteHeader(http.StatusOK)
_, err = w.Write([]byte(responseBody))
require.NoError(t, err)
case "/token-error":
responseBody := "some github error"
w.WriteHeader(http.StatusUnauthorized)
_, err = w.Write([]byte(responseBody))
require.NoError(t, err)
default:
t.Fatalf("tried to call provider at unexpected endpoint: %s", r.URL.Path)
}
}), nil)
testServerPool, err := cert.NewPoolFromBytes(testServerCA)
require.NoError(t, err)
subject := New(ProviderConfig{
OAuth2Config: &oauth2.Config{
ClientID: "fake-client-id",
ClientSecret: "fake-client-secret",
Scopes: []string{"scope1", "scope2"},
Endpoint: oauth2.Endpoint{
AuthURL: "https://fake-auth-url",
TokenURL: testServer.URL + test.tokenEndpointPath,
AuthStyle: oauth2.AuthStyleInParams,
},
},
HttpClient: &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
RootCAs: testServerPool,
}},
},
})
accessToken, err := subject.ExchangeAuthcode(context.Background(), "fake-authcode", "https://fake-redirect-url")
if test.wantErr != "" {
require.EqualError(t, err, test.wantErr)
require.Empty(t, accessToken)
} else {
require.NoError(t, err)
require.Equal(t, fakeGitHubAccessToken, accessToken)
}
})
}
}
func TestGetUser(t *testing.T) {
const idpDisplayName = "idp display name 😀"
const encodedIDPDisplayName = "idp+display+name+%F0%9F%98%80"
someContext := context.Background()
someHttpClient := &http.Client{
Timeout: 1234509,
}
tests := []struct {
name string
providerConfig ProviderConfig
buildGitHubClientError error
buildMockResponses func(hubInterface *mockgithubclient.MockGitHubInterface)
wantUser *upstreamprovider.GitHubUser
wantErrMsg string
wantErr error
}{
{
name: "happy path with username=login:id",
providerConfig: ProviderConfig{
APIBaseURL: "https://some-url",
HttpClient: someHttpClient,
UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameLoginAndID,
},
buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) {
mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{
Login: "some-github-login",
ID: "some-github-id",
}, nil)
mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(nil, nil)
mockGitHubInterface.EXPECT().GetTeamMembership(someContext, gomock.Any()).Return(nil, nil)
},
wantUser: &upstreamprovider.GitHubUser{
Username: "some-github-login:some-github-id",
DownstreamSubject: fmt.Sprintf("https://some-url?idpName=%s&login=some-github-login&id=some-github-id", encodedIDPDisplayName),
},
},
{
name: "happy path with username=login",
providerConfig: ProviderConfig{
APIBaseURL: "https://some-url",
HttpClient: someHttpClient,
UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameLogin,
},
buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) {
mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{
Login: "some-github-login",
ID: "some-github-id",
}, nil)
mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(nil, nil)
mockGitHubInterface.EXPECT().GetTeamMembership(someContext, nil).Return(nil, nil)
},
wantUser: &upstreamprovider.GitHubUser{
Username: "some-github-login",
DownstreamSubject: fmt.Sprintf("https://some-url?idpName=%s&login=some-github-login&id=some-github-id", encodedIDPDisplayName),
},
},
{
name: "happy path with username=id",
providerConfig: ProviderConfig{
APIBaseURL: "https://some-url",
HttpClient: someHttpClient,
UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameID,
},
buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) {
mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{
Login: "some-github-login",
ID: "some-github-id",
}, nil)
mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(nil, nil)
mockGitHubInterface.EXPECT().GetTeamMembership(someContext, nil).Return(nil, nil)
},
wantUser: &upstreamprovider.GitHubUser{
Username: "some-github-id",
DownstreamSubject: fmt.Sprintf("https://some-url?idpName=%s&login=some-github-login&id=some-github-id", encodedIDPDisplayName),
},
},
{
name: "happy path with user in allowed organizations",
providerConfig: ProviderConfig{
APIBaseURL: "https://some-url",
HttpClient: someHttpClient,
UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameLoginAndID,
AllowedOrganizations: setutil.NewCaseInsensitiveSet("ALLOWED-ORG1", "ALLOWED-ORG2"),
},
buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) {
mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{
Login: "some-github-login",
ID: "some-github-id",
}, nil)
mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return([]string{"allowed-org2"}, nil)
mockGitHubInterface.EXPECT().GetTeamMembership(someContext, setutil.NewCaseInsensitiveSet("ALLOWED-ORG1", "ALLOWED-ORG2")).Return(nil, nil)
},
wantUser: &upstreamprovider.GitHubUser{
Username: "some-github-login:some-github-id",
DownstreamSubject: fmt.Sprintf("https://some-url?idpName=%s&login=some-github-login&id=some-github-id", encodedIDPDisplayName),
},
},
{
name: "returns error when the user does not belong to the allowed organizations",
providerConfig: ProviderConfig{
APIBaseURL: "https://some-url",
HttpClient: someHttpClient,
UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameID,
AllowedOrganizations: setutil.NewCaseInsensitiveSet("allowed-org"),
},
buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) {
mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{
Login: "some-github-login",
ID: "some-github-id",
}, nil)
mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return([]string{"disallowed-org"}, nil)
},
wantErr: upstreamprovider.NewGitHubLoginDeniedError("user is not allowed to log in due to organization membership policy"),
},
{
name: "happy path with groups=name",
providerConfig: ProviderConfig{
APIBaseURL: "https://some-url",
HttpClient: someHttpClient,
UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameLoginAndID,
AllowedOrganizations: setutil.NewCaseInsensitiveSet("allowed-org1", "allowed-org2"),
GroupNameAttribute: supervisoridpv1alpha1.GitHubUseTeamNameForGroupName,
},
buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) {
mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{
Login: "some-github-login",
ID: "some-github-id",
}, nil)
mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return([]string{"allowed-org2"}, nil)
mockGitHubInterface.EXPECT().GetTeamMembership(someContext, setutil.NewCaseInsensitiveSet("allowed-org1", "allowed-org2")).Return([]githubclient.TeamInfo{
{
Name: "org1-team1-name",
Slug: "org1-team1-slug",
Org: "org1-name",
},
{
Name: "org1-team2-name",
Slug: "org1-team2-slug",
Org: "org1-name",
},
{
Name: "org2-team1-name",
Slug: "org2-team1-slug",
Org: "org2-name",
},
}, nil)
},
wantUser: &upstreamprovider.GitHubUser{
Username: "some-github-login:some-github-id",
Groups: []string{"org1-name/org1-team1-name", "org1-name/org1-team2-name", "org2-name/org2-team1-name"},
DownstreamSubject: fmt.Sprintf("https://some-url?idpName=%s&login=some-github-login&id=some-github-id", encodedIDPDisplayName),
},
},
{
name: "happy path with groups=slug",
providerConfig: ProviderConfig{
APIBaseURL: "https://some-url",
HttpClient: someHttpClient,
UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameLoginAndID,
AllowedOrganizations: setutil.NewCaseInsensitiveSet("allowed-org1", "allowed-org2"),
GroupNameAttribute: supervisoridpv1alpha1.GitHubUseTeamSlugForGroupName,
},
buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) {
mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{
Login: "some-github-login",
ID: "some-github-id",
}, nil)
mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return([]string{"allowed-org2"}, nil)
mockGitHubInterface.EXPECT().GetTeamMembership(someContext, setutil.NewCaseInsensitiveSet("allowed-org1", "allowed-org2")).Return([]githubclient.TeamInfo{
{
Name: "org1-team1-name",
Slug: "org1-team1-slug",
Org: "org1-name",
},
{
Name: "org1-team2-name",
Slug: "org1-team2-slug",
Org: "org1-name",
},
{
Name: "org2-team1-name",
Slug: "org2-team1-slug",
Org: "org2-name",
},
}, nil)
},
wantUser: &upstreamprovider.GitHubUser{
Username: "some-github-login:some-github-id",
Groups: []string{"org1-name/org1-team1-slug", "org1-name/org1-team2-slug", "org2-name/org2-team1-slug"},
DownstreamSubject: fmt.Sprintf("https://some-url?idpName=%s&login=some-github-login&id=some-github-id", encodedIDPDisplayName),
},
},
{
name: "returns errors from buildGitHubClient()",
providerConfig: ProviderConfig{
APIBaseURL: "https://some-url",
HttpClient: someHttpClient,
},
buildGitHubClientError: errors.New("error from building a github client"),
wantErrMsg: "error from building a github client",
},
{
name: "returns errors from githubClient.GetUserInfo()",
providerConfig: ProviderConfig{
APIBaseURL: "https://some-url",
HttpClient: someHttpClient,
},
buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) {
mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(nil, errors.New("error from githubClient.GetUserInfo"))
},
wantErrMsg: "error from githubClient.GetUserInfo",
},
{
name: "returns errors from githubClient.GetOrgMembership()",
providerConfig: ProviderConfig{
APIBaseURL: "https://some-url",
HttpClient: someHttpClient,
UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameLoginAndID,
},
buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) {
mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{}, nil)
mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(nil, errors.New("error from githubClient.GetOrgMembership"))
},
wantErrMsg: "error from githubClient.GetOrgMembership",
},
{
name: "returns errors from githubClient.GetTeamMembership()",
providerConfig: ProviderConfig{
APIBaseURL: "https://some-url",
HttpClient: someHttpClient,
UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameLoginAndID,
},
buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) {
mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{}, nil)
mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(nil, nil)
mockGitHubInterface.EXPECT().GetTeamMembership(someContext, gomock.Any()).Return(nil, errors.New("error from githubClient.GetTeamMembership"))
},
wantErrMsg: "error from githubClient.GetTeamMembership",
},
{
name: "bad configuration: UsernameAttribute",
providerConfig: ProviderConfig{
APIBaseURL: "https://some-url",
HttpClient: someHttpClient,
UsernameAttribute: "this-is-not-legal-value-from-the-enum",
},
buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) {
mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{
Login: "some-github-login",
ID: "some-github-id",
}, nil)
},
wantErrMsg: "bad configuration: unknown GitHub username attribute: this-is-not-legal-value-from-the-enum",
},
{
name: "bad configuration: GroupNameAttribute",
providerConfig: ProviderConfig{
APIBaseURL: "https://some-url",
HttpClient: someHttpClient,
UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameLoginAndID,
GroupNameAttribute: "this-is-not-legal-value-from-the-enum",
},
buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) {
mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{
Login: "some-github-login",
ID: "some-github-id",
}, nil)
mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(nil, nil)
mockGitHubInterface.EXPECT().GetTeamMembership(someContext, nil).Return([]githubclient.TeamInfo{
{
Name: "org1-team1-name",
Slug: "org1-team1-slug",
Org: "org1-name",
},
}, nil)
},
wantErrMsg: "bad configuration: unknown GitHub group name attribute: this-is-not-legal-value-from-the-enum",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
accessToken := "some-opaque-github-access-token" + rand.String(8)
mockGitHubInterface := mockgithubclient.NewMockGitHubInterface(ctrl)
if test.buildMockResponses != nil {
test.buildMockResponses(mockGitHubInterface)
}
p := New(test.providerConfig)
p.buildGitHubClient = func(httpClient *http.Client, apiBaseURL, token string) (githubclient.GitHubInterface, error) {
require.Equal(t, test.providerConfig.HttpClient, httpClient)
require.Equal(t, test.providerConfig.APIBaseURL, apiBaseURL)
require.Equal(t, accessToken, token)
return mockGitHubInterface, test.buildGitHubClientError
}
actualUser, actualErr := p.GetUser(context.Background(), accessToken, idpDisplayName)
switch {
case test.wantErrMsg != "":
require.EqualError(t, actualErr, test.wantErrMsg)
require.Nil(t, actualUser)
case test.wantErr != nil:
require.Equal(t, test.wantErr, actualErr)
require.Nil(t, actualUser)
default:
require.NoError(t, actualErr)
require.Equal(t, test.wantUser, actualUser)
}
})
}
}
+10 -10
View File
@@ -118,7 +118,7 @@ type ProviderConfig struct {
GroupAttributeParsingOverrides map[string]func(*ldap.Entry) (string, error)
// RefreshAttributeChecks are extra checks that attributes in a refresh response are as expected.
RefreshAttributeChecks map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error
RefreshAttributeChecks map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error
}
// UserSearchConfig contains information about how to search for users in the upstream LDAP IDP.
@@ -186,8 +186,8 @@ func closeAndLogError(conn Conn, doingWhat string) {
}
}
func (p *Provider) PerformRefresh(ctx context.Context, storedRefreshAttributes upstreamprovider.RefreshAttributes, idpDisplayName string) ([]string, error) {
t := trace.FromContext(ctx).Nest("slow ldap refresh attempt", trace.Field{Key: "providerName", Value: p.GetName()})
func (p *Provider) PerformRefresh(ctx context.Context, storedRefreshAttributes upstreamprovider.LDAPRefreshAttributes, idpDisplayName string) ([]string, error) {
t := trace.FromContext(ctx).Nest("slow ldap refresh attempt", trace.Field{Key: "providerName", Value: p.GetResourceName()})
defer t.LogIfLong(500 * time.Millisecond) // to help users debug slow LDAP searches
userDN := storedRefreshAttributes.DN
@@ -373,7 +373,7 @@ func (p *Provider) tlsConfig() (*tls.Config, error) {
}
// GetName returns a name for this upstream provider.
func (p *Provider) GetName() string {
func (p *Provider) GetResourceName() string {
return p.c.Name
}
@@ -435,7 +435,7 @@ func (p *Provider) AuthenticateUser(ctx context.Context, username, password stri
}
func (p *Provider) authenticateUserImpl(ctx context.Context, username string, bindFunc func(conn Conn, foundUserDN string) error) (*authenticators.Response, bool, error) {
t := trace.FromContext(ctx).Nest("slow ldap authenticate user attempt", trace.Field{Key: "providerName", Value: p.GetName()})
t := trace.FromContext(ctx).Nest("slow ldap authenticate user attempt", trace.Field{Key: "providerName", Value: p.GetResourceName()})
defer t.LogIfLong(500 * time.Millisecond) // to help users debug slow LDAP searches
err := p.validateConfig()
@@ -528,7 +528,7 @@ func (p *Provider) validateConfig() error {
}
func (p *Provider) SearchForDefaultNamingContext(ctx context.Context) (string, error) {
t := trace.FromContext(ctx).Nest("slow ldap attempt when searching for default naming context", trace.Field{Key: "providerName", Value: p.GetName()})
t := trace.FromContext(ctx).Nest("slow ldap attempt when searching for default naming context", trace.Field{Key: "providerName", Value: p.GetResourceName()})
defer t.LogIfLong(500 * time.Millisecond) // to help users debug slow LDAP searches
conn, err := p.dial(ctx)
@@ -564,7 +564,7 @@ func (p *Provider) searchAndBindUser(conn Conn, username string, bindFunc func(c
searchResult, err := conn.Search(p.userSearchRequest(username))
if err != nil {
plog.All(`error searching for user`,
"upstreamName", p.GetName(),
"upstreamName", p.GetResourceName(),
"username", username,
"err", err,
)
@@ -573,11 +573,11 @@ func (p *Provider) searchAndBindUser(conn Conn, username string, bindFunc func(c
if len(searchResult.Entries) == 0 {
if plog.Enabled(plog.LevelAll) {
plog.All("error finding user: user not found (if this username is valid, please check the user search configuration)",
"upstreamName", p.GetName(),
"upstreamName", p.GetResourceName(),
"username", username,
)
} else {
plog.Debug("error finding user: user not found (cowardly avoiding printing username because log level is not 'all')", "upstreamName", p.GetName())
plog.Debug("error finding user: user not found (cowardly avoiding printing username because log level is not 'all')", "upstreamName", p.GetResourceName())
}
return nil, nil
}
@@ -632,7 +632,7 @@ func (p *Provider) searchAndBindUser(conn Conn, username string, bindFunc func(c
err = bindFunc(conn, userEntry.DN)
if err != nil {
plog.DebugErr("error binding for user (if this is not the expected dn for this username, please check the user search configuration)",
err, "upstreamName", p.GetName(), "username", username, "dn", userEntry.DN)
err, "upstreamName", p.GetResourceName(), "username", username, "dn", userEntry.DN)
ldapErr := &ldap.Error{}
if errors.As(err, &ldapErr) && ldapErr.ResultCode == ldap.LDAPResultInvalidCredentials {
return nil, nil
+9 -9
View File
@@ -641,8 +641,8 @@ func TestEndUserAuthentication(t *testing.T) {
username: testUpstreamUsername,
password: testUpstreamPassword,
providerConfig: providerConfig(func(p *ProviderConfig) {
p.RefreshAttributeChecks = map[string]func(entry *ldap.Entry, attributes upstreamprovider.RefreshAttributes) error{
"some-attribute-to-check-during-refresh": func(entry *ldap.Entry, attributes upstreamprovider.RefreshAttributes) error {
p.RefreshAttributeChecks = map[string]func(entry *ldap.Entry, attributes upstreamprovider.LDAPRefreshAttributes) error{
"some-attribute-to-check-during-refresh": func(entry *ldap.Entry, attributes upstreamprovider.LDAPRefreshAttributes) error {
return nil
},
}
@@ -679,8 +679,8 @@ func TestEndUserAuthentication(t *testing.T) {
username: testUpstreamUsername,
password: testUpstreamPassword,
providerConfig: providerConfig(func(p *ProviderConfig) {
p.RefreshAttributeChecks = map[string]func(entry *ldap.Entry, attributes upstreamprovider.RefreshAttributes) error{
"some-attribute-to-check-during-refresh": func(entry *ldap.Entry, attributes upstreamprovider.RefreshAttributes) error {
p.RefreshAttributeChecks = map[string]func(entry *ldap.Entry, attributes upstreamprovider.LDAPRefreshAttributes) error{
"some-attribute-to-check-during-refresh": func(entry *ldap.Entry, attributes upstreamprovider.LDAPRefreshAttributes) error {
return nil
},
}
@@ -1527,8 +1527,8 @@ func TestUpstreamRefresh(t *testing.T) {
Filter: testGroupSearchFilter,
GroupNameAttribute: testGroupSearchGroupNameAttribute,
},
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{
pwdLastSetAttribute: func(*ldap.Entry, upstreamprovider.RefreshAttributes) error { return nil },
RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{
pwdLastSetAttribute: func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error { return nil },
},
}
if editFunc != nil {
@@ -2124,8 +2124,8 @@ func TestUpstreamRefresh(t *testing.T) {
{
name: "search result has a changed pwdLastSet value",
providerConfig: providerConfig(func(p *ProviderConfig) {
p.RefreshAttributeChecks = map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{
pwdLastSetAttribute: func(*ldap.Entry, upstreamprovider.RefreshAttributes) error {
p.RefreshAttributeChecks = map[string]func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error{
pwdLastSetAttribute: func(*ldap.Entry, upstreamprovider.LDAPRefreshAttributes) error {
return errors.New(`value for attribute "pwdLastSet" has changed since initial value at login`)
},
}
@@ -2201,7 +2201,7 @@ func TestUpstreamRefresh(t *testing.T) {
"ldaps://ldap.example.com:8443?base=some-upstream-user-base-dn&idpName=%s&sub=c29tZS11cHN0cmVhbS11aWQtdmFsdWU",
testUpstreamName,
)
groups, err := ldapProvider.PerformRefresh(context.Background(), upstreamprovider.RefreshAttributes{
groups, err := ldapProvider.PerformRefresh(context.Background(), upstreamprovider.LDAPRefreshAttributes{
Username: testUserSearchResultUsernameAttributeValue,
Subject: subject,
DN: tt.refreshUserDN,
+1 -1
View File
@@ -84,7 +84,7 @@ func (p *ProviderConfig) GetAdditionalClaimMappings() map[string]string {
return p.AdditionalClaimMappings
}
func (p *ProviderConfig) GetName() string {
func (p *ProviderConfig) GetResourceName() string {
return p.Name
}
+1 -1
View File
@@ -47,7 +47,7 @@ func TestProviderConfig(t *testing.T) {
rawClaims: []byte(`{"userinfo_endpoint": "https://example.com/userinfo"}`),
},
}
require.Equal(t, "test-name", p.GetName())
require.Equal(t, "test-name", p.GetResourceName())
require.Equal(t, "test-client-id", p.GetClientID())
require.Equal(t, "https://example.com", p.GetAuthorizationURL().String())
require.ElementsMatch(t, []string{"scope1", "scope2"}, p.GetScopes())