mirror of
https://github.com/vmware-tanzu/pinniped.git
synced 2026-09-28 02:44:17 +00:00
Merge branch 'main' into jtc/add-importas-linter
This commit is contained in:
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -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)
|
||||
|
||||
|
||||
+20
-20
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
+511
@@ -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
|
||||
}
|
||||
+2423
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
+59
-59
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
]
|
||||
|
||||
+12
@@ -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
|
||||
}
|
||||
|
||||
+99
-19
@@ -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
|
||||
}
|
||||
|
||||
+179
@@ -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())
|
||||
}
|
||||
+452
@@ -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
|
||||
|
||||
+71
-5
@@ -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 (
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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}`,
|
||||
},
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user