Allow dynamic clients to be used in downstream OIDC flows

This is only a first commit towards making this feature work.
- Hook dynamic clients into fosite by returning them from the storage
  interface (after finding and validating them)
- In the auth endpoint, prevent the use of the username and password
  headers for dynamic clients to force them to use the browser-based
  login flows for all the upstream types
- Add happy path integration tests in supervisor_login_test.go
- Add lots of comments (and some small refactors) in
  supervisor_login_test.go to make it much easier to understand
- Add lots of unit tests for the auth endpoint regarding dynamic clients
  (more unit tests to be added for other endpoints in follow-up commits)
- Enhance crud.go to make lifetime=0 mean never garbage collect,
  since we want client secret storage Secrets to last forever
- Move the OIDCClient validation code to a package where it can be
  shared between the controller and the fosite storage interface
- Make shared test helpers for tests that need to create OIDC client
  secret storage Secrets
- Create a public const for "pinniped-cli" now that we are using that
  string in several places in the production code
This commit is contained in:
Ryan Richard
2022-07-14 09:51:11 -07:00
parent be85e1ed0a
commit e0ecdc004b
28 changed files with 1692 additions and 461 deletions
@@ -8,9 +8,6 @@ import (
"fmt"
"strings"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/crypto/bcrypt"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/equality"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -23,37 +20,14 @@ import (
pinnipedcontroller "go.pinniped.dev/internal/controller"
"go.pinniped.dev/internal/controller/conditionsutil"
"go.pinniped.dev/internal/controllerlib"
"go.pinniped.dev/internal/oidc/oidcclientvalidator"
"go.pinniped.dev/internal/oidcclientsecretstorage"
"go.pinniped.dev/internal/plog"
)
const (
clientSecretExists = "ClientSecretExists"
allowedGrantTypesValid = "AllowedGrantTypesValid"
allowedScopesValid = "AllowedScopesValid"
reasonSuccess = "Success"
reasonMissingRequiredValue = "MissingRequiredValue"
reasonNoClientSecretFound = "NoClientSecretFound"
reasonInvalidClientSecretFound = "InvalidClientSecretFound"
authorizationCodeGrantTypeName = "authorization_code"
refreshTokenGrantTypeName = "refresh_token"
tokenExchangeGrantTypeName = "urn:ietf:params:oauth:grant-type:token-exchange" //nolint:gosec // this is not a credential
openidScopeName = oidc.ScopeOpenID
offlineAccessScopeName = oidc.ScopeOfflineAccess
requestAudienceScopeName = "pinniped:request-audience"
usernameScopeName = "username"
groupsScopeName = "groups"
allowedGrantTypesFieldName = "allowedGrantTypes"
allowedScopesFieldName = "allowedScopes"
secretTypeToObserve = "storage.pinniped.dev/oidc-client-secret" //nolint:gosec // this is not a credential
oidcClientPrefixToObserve = "client.oauth.pinniped.dev-" //nolint:gosec // this is not a credential
minimumRequiredBcryptCost = 15
)
type oidcClientWatcherController struct {
@@ -133,9 +107,9 @@ func (c *oidcClientWatcherController) Sync(ctx controllerlib.Context) error {
secret = nil
}
conditions, totalClientSecrets := validateOIDCClient(oidcClient, secret)
_, conditions, clientSecrets := oidcclientvalidator.Validate(oidcClient, secret)
if err := c.updateStatus(ctx.Context, oidcClient, conditions, totalClientSecrets); err != nil {
if err := c.updateStatus(ctx.Context, oidcClient, conditions, len(clientSecrets)); err != nil {
return fmt.Errorf("cannot update OIDCClient '%s/%s': %w", oidcClient.Namespace, oidcClient.Name, err)
}
@@ -150,185 +124,6 @@ func (c *oidcClientWatcherController) Sync(ctx controllerlib.Context) error {
return nil
}
// validateOIDCClient validates the OIDCClient and its corresponding client secret storage Secret.
// When the corresponding client secret storage Secret was not found, pass nil to this function to
// get the validation error for that case. It returns a slice of conditions along with the number
// of client secrets found.
func validateOIDCClient(oidcClient *v1alpha1.OIDCClient, secret *v1.Secret) ([]*v1alpha1.Condition, int) {
c, totalClientSecrets := validateSecret(secret, make([]*v1alpha1.Condition, 0, 3))
c = validateAllowedGrantTypes(oidcClient, c)
c = validateAllowedScopes(oidcClient, c)
return c, totalClientSecrets
}
// validateAllowedScopes checks if allowedScopes is valid on the OIDCClient.
func validateAllowedScopes(oidcClient *v1alpha1.OIDCClient, conditions []*v1alpha1.Condition) []*v1alpha1.Condition {
m := make([]string, 0, 4)
if !allowedScopesContains(oidcClient, openidScopeName) {
m = append(m, fmt.Sprintf("%q must always be included in %q", openidScopeName, allowedScopesFieldName))
}
if allowedGrantTypesContains(oidcClient, refreshTokenGrantTypeName) && !allowedScopesContains(oidcClient, offlineAccessScopeName) {
m = append(m, fmt.Sprintf("%q must be included in %q when %q is included in %q",
offlineAccessScopeName, allowedScopesFieldName, refreshTokenGrantTypeName, allowedGrantTypesFieldName))
}
if allowedScopesContains(oidcClient, requestAudienceScopeName) &&
(!allowedScopesContains(oidcClient, usernameScopeName) || !allowedScopesContains(oidcClient, groupsScopeName)) {
m = append(m, fmt.Sprintf("%q and %q must be included in %q when %q is included in %q",
usernameScopeName, groupsScopeName, allowedScopesFieldName, requestAudienceScopeName, allowedScopesFieldName))
}
if allowedGrantTypesContains(oidcClient, tokenExchangeGrantTypeName) && !allowedScopesContains(oidcClient, requestAudienceScopeName) {
m = append(m, fmt.Sprintf("%q must be included in %q when %q is included in %q",
requestAudienceScopeName, allowedScopesFieldName, tokenExchangeGrantTypeName, allowedGrantTypesFieldName))
}
if len(m) == 0 {
conditions = append(conditions, &v1alpha1.Condition{
Type: allowedScopesValid,
Status: v1alpha1.ConditionTrue,
Reason: reasonSuccess,
Message: fmt.Sprintf("%q is valid", allowedScopesFieldName),
})
} else {
conditions = append(conditions, &v1alpha1.Condition{
Type: allowedScopesValid,
Status: v1alpha1.ConditionFalse,
Reason: reasonMissingRequiredValue,
Message: strings.Join(m, "; "),
})
}
return conditions
}
// validateAllowedGrantTypes checks if allowedGrantTypes is valid on the OIDCClient.
func validateAllowedGrantTypes(oidcClient *v1alpha1.OIDCClient, conditions []*v1alpha1.Condition) []*v1alpha1.Condition {
m := make([]string, 0, 3)
if !allowedGrantTypesContains(oidcClient, authorizationCodeGrantTypeName) {
m = append(m, fmt.Sprintf("%q must always be included in %q",
authorizationCodeGrantTypeName, allowedGrantTypesFieldName))
}
if allowedScopesContains(oidcClient, offlineAccessScopeName) && !allowedGrantTypesContains(oidcClient, refreshTokenGrantTypeName) {
m = append(m, fmt.Sprintf("%q must be included in %q when %q is included in %q",
refreshTokenGrantTypeName, allowedGrantTypesFieldName, offlineAccessScopeName, allowedScopesFieldName))
}
if allowedScopesContains(oidcClient, requestAudienceScopeName) && !allowedGrantTypesContains(oidcClient, tokenExchangeGrantTypeName) {
m = append(m, fmt.Sprintf("%q must be included in %q when %q is included in %q",
tokenExchangeGrantTypeName, allowedGrantTypesFieldName, requestAudienceScopeName, allowedScopesFieldName))
}
if len(m) == 0 {
conditions = append(conditions, &v1alpha1.Condition{
Type: allowedGrantTypesValid,
Status: v1alpha1.ConditionTrue,
Reason: reasonSuccess,
Message: fmt.Sprintf("%q is valid", allowedGrantTypesFieldName),
})
} else {
conditions = append(conditions, &v1alpha1.Condition{
Type: allowedGrantTypesValid,
Status: v1alpha1.ConditionFalse,
Reason: reasonMissingRequiredValue,
Message: strings.Join(m, "; "),
})
}
return conditions
}
// validateSecret checks if the client secret storage Secret is valid and contains at least one client secret.
// It returns the updated conditions slice along with the number of client secrets found.
func validateSecret(secret *v1.Secret, conditions []*v1alpha1.Condition) ([]*v1alpha1.Condition, int) {
if secret == nil {
// Invalid: no storage Secret found.
conditions = append(conditions, &v1alpha1.Condition{
Type: clientSecretExists,
Status: v1alpha1.ConditionFalse,
Reason: reasonNoClientSecretFound,
Message: "no client secret found (no Secret storage found)",
})
return conditions, 0
}
storedClientSecret, err := oidcclientsecretstorage.ReadFromSecret(secret)
if err != nil {
// Invalid: storage Secret exists but its data could not be parsed.
conditions = append(conditions, &v1alpha1.Condition{
Type: clientSecretExists,
Status: v1alpha1.ConditionFalse,
Reason: reasonNoClientSecretFound,
Message: fmt.Sprintf("error reading client secret storage: %s", err.Error()),
})
return conditions, 0
}
// Successfully read the stored client secrets, so check if there are any stored in the list.
storedClientSecretsCount := len(storedClientSecret.SecretHashes)
if storedClientSecretsCount == 0 {
// Invalid: no client secrets stored.
conditions = append(conditions, &v1alpha1.Condition{
Type: clientSecretExists,
Status: v1alpha1.ConditionFalse,
Reason: reasonNoClientSecretFound,
Message: "no client secret found (empty list in storage)",
})
return conditions, 0
}
// Check each hashed password's format and bcrypt cost.
bcryptErrs := make([]string, 0, storedClientSecretsCount)
for i, p := range storedClientSecret.SecretHashes {
cost, err := bcrypt.Cost([]byte(p))
if err != nil {
bcryptErrs = append(bcryptErrs, fmt.Sprintf(
"hashed client secret at index %d: %s",
i, err.Error()))
} else if cost < minimumRequiredBcryptCost {
bcryptErrs = append(bcryptErrs, fmt.Sprintf(
"hashed client secret at index %d: bcrypt cost %d is below the required minimum of %d",
i, cost, minimumRequiredBcryptCost))
}
}
if len(bcryptErrs) > 0 {
// Invalid: some stored client secrets were not valid.
conditions = append(conditions, &v1alpha1.Condition{
Type: clientSecretExists,
Status: v1alpha1.ConditionFalse,
Reason: reasonInvalidClientSecretFound,
Message: strings.Join(bcryptErrs, "; "),
})
return conditions, storedClientSecretsCount
}
// Valid: has at least one client secret stored for this OIDC client, and all stored client secrets are valid.
conditions = append(conditions, &v1alpha1.Condition{
Type: clientSecretExists,
Status: v1alpha1.ConditionTrue,
Reason: reasonSuccess,
Message: fmt.Sprintf("%d client secret(s) found", storedClientSecretsCount),
})
return conditions, storedClientSecretsCount
}
func allowedGrantTypesContains(haystack *v1alpha1.OIDCClient, needle string) bool {
for _, hay := range haystack.Spec.AllowedGrantTypes {
if hay == v1alpha1.GrantType(needle) {
return true
}
}
return false
}
func allowedScopesContains(haystack *v1alpha1.OIDCClient, needle string) bool {
for _, hay := range haystack.Spec.AllowedScopes {
if hay == v1alpha1.Scope(needle) {
return true
}
}
return false
}
func (c *oidcClientWatcherController) updateStatus(
ctx context.Context,
upstream *v1alpha1.OIDCClient,
@@ -5,9 +5,7 @@ package oidcclientwatcher
import (
"context"
"encoding/base32"
"fmt"
"strings"
"testing"
"time"
@@ -257,51 +255,6 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) {
}
}
secretNameForUID := func(uid string) string {
// See GetName() in OIDCClientSecretStorage for how the production code determines the Secret name.
// This test helper is intended to choose the same name.
return "pinniped-storage-oidc-client-secret-" +
strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString([]byte(uid)))
}
secretStringDataWithZeroClientSecrets := map[string][]byte{
"pinniped-storage-data": []byte(`{"version":"1","hashes":[]}`),
"pinniped-storage-version": []byte("1"),
}
secretStringDataWithOneClientSecret := map[string][]byte{
"pinniped-storage-data": []byte(`{"version":"1","hashes":["` + testBcryptSecret1 + `"]}`),
"pinniped-storage-version": []byte("1"),
}
secretStringDataWithTwoClientSecrets := map[string][]byte{
"pinniped-storage-data": []byte(`{"version":"1","hashes":["` + testBcryptSecret1 + `","` + testBcryptSecret2 + `"]}`),
"pinniped-storage-version": []byte("1"),
}
secretStringDataWithSomeInvalidClientSecrets := map[string][]byte{
"pinniped-storage-data": []byte(`{"version":"1","hashes":["` +
testBcryptSecret1 + `","` + testInvalidBcryptSecretCostTooLow + `","` + testInvalidBcryptSecretInvalidFormat + `"]}`),
"pinniped-storage-version": []byte("1"),
}
secretStringDataWithWrongVersion := map[string][]byte{
"pinniped-storage-data": []byte(`{"version":"wrong-version","hashes":[]}`),
"pinniped-storage-version": []byte("1"),
}
storageSecretForUIDWithData := func(uid string, data map[string][]byte) *corev1.Secret {
return &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Namespace: testNamespace,
Name: secretNameForUID(uid),
Labels: map[string]string{"storage.pinniped.dev/type": "oidc-client-secret"},
},
Type: "storage.pinniped.dev/oidc-client-secret",
Data: data,
}
}
tests := []struct {
name string
inputObjects []runtime.Object
@@ -338,7 +291,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) {
},
},
},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{
{
@@ -367,7 +320,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) {
AllowedScopes: []configv1alpha1.Scope{"openid"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithTwoClientSecrets)},
inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1, testBcryptSecret2})},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
@@ -400,7 +353,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) {
TotalClientSecrets: 1,
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})},
wantAPIActions: 0, // no updates
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
@@ -443,7 +396,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) {
AllowedScopes: []configv1alpha1.Scope{"openid"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithWrongVersion)},
inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUIDWithWrongVersion(t, testNamespace, testUID)},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
@@ -466,7 +419,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) {
AllowedScopes: []configv1alpha1.Scope{"openid"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithZeroClientSecrets)},
inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{})},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
@@ -490,7 +443,10 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) {
AllowedScopes: []configv1alpha1.Scope{"openid"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithSomeInvalidClientSecrets)},
inputSecrets: []runtime.Object{
testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID,
[]string{testBcryptSecret1, testInvalidBcryptSecretCostTooLow, testInvalidBcryptSecretInvalidFormat}),
},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
@@ -500,10 +456,11 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) {
happyAllowedGrantTypesCondition(now, 1234),
happyAllowedScopesCondition(now, 1234),
sadInvalidClientSecretsCondition(now, 1234,
"hashed client secret at index 1: bcrypt cost 14 is below the required minimum of 15; "+
"3 stored client secrets found, but some were invalid, so none will be used: "+
"hashed client secret at index 1: bcrypt cost 14 is below the required minimum of 15; "+
"hashed client secret at index 2: crypto/bcrypt: hashedSecret too short to be a bcrypted password"),
},
TotalClientSecrets: 3,
TotalClientSecrets: 0,
},
}},
},
@@ -522,7 +479,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) {
Spec: configv1alpha1.OIDCClientSpec{},
},
},
inputSecrets: []runtime.Object{storageSecretForUIDWithData("uid1", secretStringDataWithOneClientSecret)},
inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, "uid1", []string{testBcryptSecret1})},
wantAPIActions: 2, // one update for each OIDCClient
wantResultingOIDCClients: []configv1alpha1.OIDCClient{
{
@@ -570,7 +527,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) {
TotalClientSecrets: 1,
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 4567, UID: testUID},
@@ -596,7 +553,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) {
},
}},
wantAPIActions: 1, // one update
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})},
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Status: configv1alpha1.OIDCClientStatus{
@@ -620,7 +577,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) {
},
}},
wantAPIActions: 1, // one update
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})},
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Status: configv1alpha1.OIDCClientStatus{
@@ -649,7 +606,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) {
},
}},
wantAPIActions: 1, // one update
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})},
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Status: configv1alpha1.OIDCClientStatus{
@@ -676,7 +633,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) {
AllowedScopes: []configv1alpha1.Scope{"openid", "pinniped:request-audience", "username", "groups"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
@@ -700,7 +657,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) {
AllowedScopes: []configv1alpha1.Scope{"openid"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
@@ -724,7 +681,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) {
AllowedScopes: []configv1alpha1.Scope{"openid", "pinniped:request-audience"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
@@ -748,7 +705,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) {
AllowedScopes: []configv1alpha1.Scope{"openid", "pinniped:request-audience", "groups"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
@@ -772,7 +729,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) {
AllowedScopes: []configv1alpha1.Scope{"openid", "pinniped:request-audience", "username"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
@@ -796,7 +753,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) {
AllowedScopes: []configv1alpha1.Scope{"openid"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
@@ -820,7 +777,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) {
AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
@@ -844,7 +801,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) {
AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
@@ -868,7 +825,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) {
AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "username"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
@@ -892,7 +849,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) {
AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "groups"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
@@ -916,7 +873,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) {
AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "username", "groups"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
@@ -940,7 +897,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) {
AllowedScopes: []configv1alpha1.Scope{"openid", "username"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
@@ -964,7 +921,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) {
AllowedScopes: []configv1alpha1.Scope{"openid", "username"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
@@ -988,7 +945,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) {
AllowedScopes: []configv1alpha1.Scope{"openid", "username", "groups"},
},
}},
inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)},
inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})},
wantAPIActions: 1, // one update
wantResultingOIDCClients: []configv1alpha1.OIDCClient{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
+8 -3
View File
@@ -193,14 +193,19 @@ func (s *secretsStorage) toSecret(signature, resourceVersion string, data JSON,
labelsToAdd[labelName] = labelValue
}
var annotations map[string]string
if s.lifetime > 0 {
annotations = map[string]string{
SecretLifetimeAnnotationKey: s.clock().Add(s.lifetime).UTC().Format(SecretLifetimeAnnotationDateFormat),
}
}
return &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: s.GetName(signature),
ResourceVersion: resourceVersion,
Labels: labelsToAdd,
Annotations: map[string]string{
SecretLifetimeAnnotationKey: s.clock().Add(s.lifetime).UTC().Format(SecretLifetimeAnnotationDateFormat),
},
Annotations: annotations,
OwnerReferences: nil,
},
Data: map[string][]byte{
+68 -1
View File
@@ -62,6 +62,7 @@ func TestStorage(t *testing.T) {
name string
resource string
mocks func(*testing.T, mocker)
lifetime func() time.Duration
run func(*testing.T, Storage, *clocktesting.FakeClock) error
wantActions []coretesting.Action
wantSecrets []corev1.Secret
@@ -1014,7 +1015,69 @@ func TestStorage(t *testing.T) {
},
wantErr: "",
},
{
name: "create and get with infinite lifetime when lifetime is specified as zero",
resource: "access-tokens",
mocks: nil,
lifetime: func() time.Duration { return 0 }, // 0 == infinity
run: func(t *testing.T, storage Storage, fakeClock *clocktesting.FakeClock) error {
signature := hmac.AuthorizeCodeSignature(authorizationCode1)
require.NotEmpty(t, signature)
require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is
data := &testJSON{Data: "create-and-get"}
rv1, err := storage.Create(ctx, signature, data, nil)
require.Empty(t, rv1) // fake client does not set this
require.NoError(t, err)
out := &testJSON{}
rv2, err := storage.Get(ctx, signature, out)
require.Empty(t, rv2) // fake client does not set this
require.NoError(t, err)
require.Equal(t, data, out)
return nil
},
wantActions: []coretesting.Action{
coretesting.NewCreateAction(secretsGVR, namespace, &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "pinniped-storage-access-tokens-i6mhp4azwdxshgsy3s2mvedxpxuh3nudh3ot3m4xamlugj4e6qoq",
ResourceVersion: "",
// No garbage collection annotation was added.
Labels: map[string]string{
"storage.pinniped.dev/type": "access-tokens",
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"Data":"create-and-get"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/access-tokens",
}),
coretesting.NewGetAction(secretsGVR, namespace, "pinniped-storage-access-tokens-i6mhp4azwdxshgsy3s2mvedxpxuh3nudh3ot3m4xamlugj4e6qoq"),
},
wantSecrets: []corev1.Secret{
{
ObjectMeta: metav1.ObjectMeta{
Name: "pinniped-storage-access-tokens-i6mhp4azwdxshgsy3s2mvedxpxuh3nudh3ot3m4xamlugj4e6qoq",
Namespace: namespace,
ResourceVersion: "",
// No garbage collection annotation was added.
Labels: map[string]string{
"storage.pinniped.dev/type": "access-tokens",
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"Data":"create-and-get"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/access-tokens",
},
},
wantErr: "",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
@@ -1024,9 +1087,13 @@ func TestStorage(t *testing.T) {
if tt.mocks != nil {
tt.mocks(t, client)
}
useLifetime := lifetime
if tt.lifetime != nil {
useLifetime = tt.lifetime()
}
secrets := client.CoreV1().Secrets(namespace)
fakeClock := clocktesting.NewFakeClock(fakeNow)
storage := New(tt.resource, secrets, fakeClock.Now, lifetime)
storage := New(tt.resource, secrets, fakeClock.Now, useLifetime)
err := tt.run(t, storage, fakeClock)
+21 -1
View File
@@ -20,6 +20,7 @@ import (
"go.pinniped.dev/internal/httputil/httperr"
"go.pinniped.dev/internal/httputil/securityheader"
"go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/oidc/clientregistry"
"go.pinniped.dev/internal/oidc/csrftoken"
"go.pinniped.dev/internal/oidc/downstreamsession"
"go.pinniped.dev/internal/oidc/login"
@@ -126,6 +127,10 @@ func handleAuthRequestForLDAPUpstreamCLIFlow(
return nil
}
if !requireStaticClientForUsernameAndPasswordHeaders(w, oauthHelper, authorizeRequester) {
return nil
}
username, password, hadUsernamePasswordValues := requireNonEmptyUsernameAndPasswordHeaders(r, w, oauthHelper, authorizeRequester)
if !hadUsernamePasswordValues {
return nil
@@ -199,6 +204,10 @@ func handleAuthRequestForOIDCUpstreamPasswordGrant(
return nil
}
if !requireStaticClientForUsernameAndPasswordHeaders(w, oauthHelper, authorizeRequester) {
return nil
}
username, password, hadUsernamePasswordValues := requireNonEmptyUsernameAndPasswordHeaders(r, w, oauthHelper, authorizeRequester)
if !hadUsernamePasswordValues {
return nil
@@ -312,6 +321,15 @@ func handleAuthRequestForOIDCUpstreamBrowserFlow(
return nil
}
func requireStaticClientForUsernameAndPasswordHeaders(w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, authorizeRequester fosite.AuthorizeRequester) bool {
isStaticClient := authorizeRequester.GetClient().GetID() == clientregistry.PinnipedCLIClientID
if !isStaticClient {
oidc.WriteAuthorizeError(w, oauthHelper, authorizeRequester,
fosite.ErrAccessDenied.WithHintf("This client is not allowed to submit username or password headers to this endpoint."), true)
}
return isStaticClient
}
func requireNonEmptyUsernameAndPasswordHeaders(r *http.Request, w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, authorizeRequester fosite.AuthorizeRequester) (string, string, bool) {
username := r.Header.Get(supervisoroidc.AuthorizeUsernameHeaderName)
password := r.Header.Get(supervisoroidc.AuthorizePasswordHeaderName)
@@ -330,10 +348,12 @@ func newAuthorizeRequest(r *http.Request, w http.ResponseWriter, oauthHelper fos
return nil, false
}
// Automatically grant the openid, offline_access, and pinniped:request-audience scopes, but only if they were requested.
// Automatically grant the openid, offline_access, pinniped:request-audience, and groups scopes, but only if they were requested.
// Grant the openid scope (for now) if they asked for it so that `NewAuthorizeResponse` will perform its OIDC validations.
// There don't seem to be any validations inside `NewAuthorizeResponse` related to the offline_access scope
// at this time, however we will temporarily grant the scope just in case that changes in a future release of fosite.
// This is instead of asking the user to approve these scopes. Note that `NewAuthorizeRequest` would have returned
// an error if the client requested a scope that they are not allowed to request, so we don't need to worry about that here.
downstreamsession.GrantScopesIfRequested(authorizeRequester, []string{coreosoidc.ScopeOpenID, coreosoidc.ScopeOfflineAccess, oidc.RequestAudienceScope, oidc.DownstreamGroupsScope})
return authorizeRequester, true
+520 -15
View File
@@ -24,8 +24,12 @@ import (
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/client-go/kubernetes/fake"
v1 "k8s.io/client-go/kubernetes/typed/core/v1"
kubetesting "k8s.io/client-go/testing"
"k8s.io/utils/pointer"
configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1"
supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake"
"go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/typed/config/v1alpha1"
"go.pinniped.dev/internal/authenticators"
"go.pinniped.dev/internal/here"
"go.pinniped.dev/internal/oidc"
@@ -67,11 +71,16 @@ func TestAuthorizationEndpoint(t *testing.T) {
downstreamPKCEChallenge = "some-challenge"
downstreamPKCEChallengeMethod = "S256"
happyState = "8b-state"
downstreamClientID = "pinniped-cli"
upstreamLDAPURL = "ldaps://some-ldap-host:123?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev"
htmlContentType = "text/html; charset=utf-8"
jsonContentType = "application/json; charset=utf-8"
formContentType = "application/x-www-form-urlencoded"
pinnipedCLIClientID = "pinniped-cli"
dynamicClientID = "client.oauth.pinniped.dev-test-name"
dynamicClientUID = "fake-client-uid"
//nolint:gosec // this is not a credential
dynamicClientHashedSecret = "$2y$15$Kh7cRj0ScSD5QelE3ZNSl.nF04JDv7zb3SgGN.tSfLIX.4kt3UX7m" // bcrypt of "password1" at cost 15
)
require.Len(t, happyState, 8, "we expect fosite to allow 8 byte state params, so we want to test that boundary case")
@@ -177,6 +186,12 @@ func TestAuthorizationEndpoint(t *testing.T) {
"state": happyState,
}
fositeAccessDeniedWithUsernamePasswordHeadersDisallowedHintErrorQuery = map[string]string{
"error": "access_denied",
"error_description": "The resource owner or authorization server denied the request. This client is not allowed to submit username or password headers to this endpoint.",
"state": happyState,
}
fositeAccessDeniedWithInvalidEmailVerifiedHintErrorQuery = map[string]string{
"error": "access_denied",
"error_description": "The resource owner or authorization server denied the request. Reason: email_verified claim in upstream ID token has invalid format.",
@@ -219,16 +234,18 @@ func TestAuthorizationEndpoint(t *testing.T) {
jwksProviderIsUnused := jwks.NewDynamicJWKSProvider()
timeoutsConfiguration := oidc.DefaultOIDCTimeoutsConfiguration()
createOauthHelperWithRealStorage := func(secretsClient v1.SecretInterface) (fosite.OAuth2Provider, *oidc.KubeStorage) {
createOauthHelperWithRealStorage := func(secretsClient v1.SecretInterface, oidcClientsClient v1alpha1.OIDCClientInterface) (fosite.OAuth2Provider, *oidc.KubeStorage) {
// Configure fosite the same way that the production code would when using Kube storage.
// Inject this into our test subject at the last second so we get a fresh storage for every test.
kubeOauthStore := oidc.NewKubeStorage(secretsClient, timeoutsConfiguration)
kubeOauthStore := oidc.NewKubeStorage(secretsClient, oidcClientsClient, timeoutsConfiguration)
return oidc.FositeOauth2Helper(kubeOauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration), kubeOauthStore
}
// Configure fosite the same way that the production code would, using NullStorage to turn off storage.
nullOauthStore := oidc.NullStorage{}
oauthHelperWithNullStorage := oidc.FositeOauth2Helper(nullOauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration)
createOauthHelperWithNullStorage := func(secretsClient v1.SecretInterface, oidcClientsClient v1alpha1.OIDCClientInterface) (fosite.OAuth2Provider, *oidc.NullStorage) {
// Configure fosite the same way that the production code would, using NullStorage to turn off storage.
nullOauthStore := oidc.NewNullStorage(secretsClient, oidcClientsClient)
return oidc.FositeOauth2Helper(nullOauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration), nullOauthStore
}
upstreamAuthURL, err := url.Parse("https://some-upstream-idp:8443/auth")
require.NoError(t, err)
@@ -381,7 +398,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
happyGetRequestQueryMap := map[string]string{
"response_type": "code",
"scope": strings.Join(happyDownstreamScopesRequested, " "),
"client_id": downstreamClientID,
"client_id": pinnipedCLIClientID,
"state": happyState,
"nonce": downstreamNonce,
"code_challenge": downstreamPKCEChallenge,
@@ -494,6 +511,26 @@ func TestAuthorizationEndpoint(t *testing.T) {
},
}
fullyCapableDynamicClient := &configv1alpha1.OIDCClient{
ObjectMeta: metav1.ObjectMeta{Namespace: "some-namespace", Name: dynamicClientID, Generation: 1, UID: dynamicClientUID},
Spec: configv1alpha1.OIDCClientSpec{
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"},
AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"},
AllowedRedirectURIs: []configv1alpha1.RedirectURI{downstreamRedirectURI},
},
}
allDynamicClientScopes := "openid offline_access pinniped:request-audience username groups"
storageSecretWithOneClientSecretForDynamicClient := testutil.OIDCClientSecretStorageSecretForUID(t,
"some-namespace", dynamicClientUID, []string{dynamicClientHashedSecret},
)
addFullyCapableDynamicClientAndSecretToKubeResources := func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) {
require.NoError(t, supervisorClient.Tracker().Add(fullyCapableDynamicClient))
require.NoError(t, kubeClient.Tracker().Add(storageSecretWithOneClientSecretForDynamicClient))
}
// Note that fosite puts the granted scopes as a param in the redirect URI even though the spec doesn't seem to require it
happyAuthcodeDownstreamRedirectLocationRegexp := downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+groups&state=` + happyState
@@ -517,6 +554,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
csrfCookie string
customUsernameHeader *string // nil means do not send header, empty means send header with empty value
customPasswordHeader *string // nil means do not send header, empty means send header with empty value
kubeResources func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset)
wantStatus int
wantContentType string
@@ -540,6 +578,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantDownstreamPKCEChallenge string
wantDownstreamPKCEChallengeMethod string
wantDownstreamNonce string
wantDownstreamClientID string // defaults to wanting "pinniped-cli" when not set
wantUnnecessaryStoredRecords int
wantPasswordGrantCall *expectedPasswordGrant
wantDownstreamCustomSessionData *psession.CustomSessionData
@@ -562,6 +601,24 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantUpstreamStateParamInLocationHeader: true,
wantBodyStringWithLocationInHref: true,
},
{
name: "OIDC upstream browser flow happy path using GET without a CSRF cookie using a dynamic client",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()),
kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources,
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
stateEncoder: happyStateEncoder,
cookieEncoder: happyCookieEncoder,
method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}),
wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType,
wantCSRFValueInCookieHeader: happyCSRF,
wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}, "", oidcUpstreamName, "oidc"), nil),
wantUpstreamStateParamInLocationHeader: true,
wantBodyStringWithLocationInHref: true,
},
{
name: "LDAP upstream browser flow happy path using GET without a CSRF cookie",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
@@ -579,6 +636,24 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantUpstreamStateParamInLocationHeader: true,
wantBodyStringWithLocationInHref: true,
},
{
name: "LDAP upstream browser flow happy path using GET without a CSRF cookie using a dynamic client",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources,
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
stateEncoder: happyStateEncoder,
cookieEncoder: happyCookieEncoder,
method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}),
wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType,
wantCSRFValueInCookieHeader: happyCSRF,
wantLocationHeader: urlWithQuery(downstreamIssuer+"/login", map[string]string{"state": expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}, "", ldapUpstreamName, "ldap")}),
wantUpstreamStateParamInLocationHeader: true,
wantBodyStringWithLocationInHref: true,
},
{
name: "Active Directory upstream browser flow happy path using GET without a CSRF cookie",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider),
@@ -596,6 +671,24 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantUpstreamStateParamInLocationHeader: true,
wantBodyStringWithLocationInHref: true,
},
{
name: "Active Directory upstream browser flow happy path using GET without a CSRF cookie using a dynamic client",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider),
kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources,
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
stateEncoder: happyStateEncoder,
cookieEncoder: happyCookieEncoder,
method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}),
wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType,
wantCSRFValueInCookieHeader: happyCSRF,
wantLocationHeader: urlWithQuery(downstreamIssuer+"/login", map[string]string{"state": expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}, "", activeDirectoryUpstreamName, "activedirectory")}),
wantUpstreamStateParamInLocationHeader: true,
wantBodyStringWithLocationInHref: true,
},
{
name: "OIDC upstream password grant happy path using GET",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()),
@@ -730,6 +823,26 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, "", oidcUpstreamName, "oidc"), nil),
wantUpstreamStateParamInLocationHeader: true,
},
{
name: "OIDC upstream browser flow happy path using POST with a dynamic client",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()),
kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources,
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
stateEncoder: happyStateEncoder,
cookieEncoder: happyCookieEncoder,
method: http.MethodPost,
path: "/some/path",
contentType: formContentType,
body: encodeQuery(modifiedHappyGetRequestQueryMap(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes})),
wantStatus: http.StatusSeeOther,
wantContentType: "",
wantBodyString: "",
wantCSRFValueInCookieHeader: happyCSRF,
wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}, "", oidcUpstreamName, "oidc"), nil),
wantUpstreamStateParamInLocationHeader: true,
},
{
name: "LDAP upstream browser flow happy path using POST",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
@@ -749,6 +862,26 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantLocationHeader: urlWithQuery(downstreamIssuer+"/login", map[string]string{"state": expectedUpstreamStateParam(nil, "", ldapUpstreamName, "ldap")}),
wantUpstreamStateParamInLocationHeader: true,
},
{
name: "LDAP upstream browser flow happy path using POST with a dynamic client",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources,
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
stateEncoder: happyStateEncoder,
cookieEncoder: happyCookieEncoder,
method: http.MethodPost,
path: "/some/path",
contentType: formContentType,
body: encodeQuery(modifiedHappyGetRequestQueryMap(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes})),
wantStatus: http.StatusSeeOther,
wantContentType: "",
wantBodyString: "",
wantCSRFValueInCookieHeader: happyCSRF,
wantLocationHeader: urlWithQuery(downstreamIssuer+"/login", map[string]string{"state": expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}, "", ldapUpstreamName, "ldap")}),
wantUpstreamStateParamInLocationHeader: true,
},
{
name: "Active Directory upstream browser flow happy path using POST",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider),
@@ -768,6 +901,26 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantLocationHeader: urlWithQuery(downstreamIssuer+"/login", map[string]string{"state": expectedUpstreamStateParam(nil, "", activeDirectoryUpstreamName, "activedirectory")}),
wantUpstreamStateParamInLocationHeader: true,
},
{
name: "Active Directory upstream browser flow happy path using POST with a dynamic client",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider),
kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources,
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
stateEncoder: happyStateEncoder,
cookieEncoder: happyCookieEncoder,
method: http.MethodPost,
path: "/some/path",
contentType: formContentType,
body: encodeQuery(modifiedHappyGetRequestQueryMap(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes})),
wantStatus: http.StatusSeeOther,
wantContentType: "",
wantBodyString: "",
wantCSRFValueInCookieHeader: happyCSRF,
wantLocationHeader: urlWithQuery(downstreamIssuer+"/login", map[string]string{"state": expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}, "", activeDirectoryUpstreamName, "activedirectory")}),
wantUpstreamStateParamInLocationHeader: true,
},
{
name: "OIDC upstream password grant happy path using POST",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()),
@@ -945,6 +1098,32 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantUpstreamStateParamInLocationHeader: true,
wantBodyStringWithLocationInHref: true,
},
{
name: "OIDC upstream browser flow happy path using dynamic client when downstream redirect uri matches what is configured for client except for the port number",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()),
kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources,
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
stateEncoder: happyStateEncoder,
cookieEncoder: happyCookieEncoder,
method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{
"redirect_uri": downstreamRedirectURIWithDifferentPort, // not the same port number that is registered for the client
"client_id": dynamicClientID,
"scope": allDynamicClientScopes,
}),
wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType,
wantCSRFValueInCookieHeader: happyCSRF,
wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{
"redirect_uri": downstreamRedirectURIWithDifferentPort, // not the same port number that is registered for the client
"client_id": dynamicClientID,
"scope": allDynamicClientScopes,
}, "", oidcUpstreamName, "oidc"), nil),
wantUpstreamStateParamInLocationHeader: true,
wantBodyStringWithLocationInHref: true,
},
{
name: "OIDC upstream password grant happy path when downstream redirect uri matches what is configured for client except for the port number",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()),
@@ -1342,6 +1521,45 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithPasswordGrantDisallowedHintErrorQuery),
wantBodyString: "",
},
{
name: "dynamic clients are not allowed to use OIDC password grant because we don't want them to handle user credentials",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()),
kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources,
method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}),
customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername),
customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword),
wantStatus: http.StatusFound,
wantContentType: jsonContentType,
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithUsernamePasswordHeadersDisallowedHintErrorQuery),
wantBodyString: "",
},
{
name: "dynamic clients are not allowed to use LDAP CLI-flow authentication because we don't want them to handle user credentials",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources,
method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}),
customUsernameHeader: pointer.StringPtr(happyLDAPUsername),
customPasswordHeader: pointer.StringPtr(happyLDAPPassword),
wantStatus: http.StatusFound,
wantContentType: jsonContentType,
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithUsernamePasswordHeadersDisallowedHintErrorQuery),
wantBodyString: "",
},
{
name: "dynamic clients are not allowed to use Active Directory CLI-flow authentication because we don't want them to handle user credentials",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider),
kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources,
method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}),
customUsernameHeader: pointer.StringPtr(happyLDAPUsername),
customPasswordHeader: pointer.StringPtr(happyLDAPPassword),
wantStatus: http.StatusFound,
wantContentType: jsonContentType,
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithUsernamePasswordHeadersDisallowedHintErrorQuery),
wantBodyString: "",
},
{
name: "downstream redirect uri does not match what is configured for client when using OIDC upstream browser flow",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()),
@@ -1358,6 +1576,25 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantContentType: jsonContentType,
wantBodyJSON: fositeInvalidRedirectURIErrorBody,
},
{
name: "downstream redirect uri does not match what is configured for client when using OIDC upstream browser flow with a dynamic client",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()),
kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources,
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
stateEncoder: happyStateEncoder,
cookieEncoder: happyCookieEncoder,
method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{
"redirect_uri": "http://127.0.0.1/does-not-match-what-is-configured-for-dynamic-client",
"client_id": dynamicClientID,
"scope": allDynamicClientScopes,
}),
wantStatus: http.StatusBadRequest,
wantContentType: jsonContentType,
wantBodyJSON: fositeInvalidRedirectURIErrorBody,
},
{
name: "downstream redirect uri does not match what is configured for client when using OIDC upstream password grant",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()),
@@ -1455,6 +1692,26 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeUnsupportedResponseTypeErrorQuery),
wantBodyString: "",
},
{
name: "response type is unsupported when using OIDC upstream browser flow with dynamic client",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()),
kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources,
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
stateEncoder: happyStateEncoder,
cookieEncoder: happyCookieEncoder,
method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{
"response_type": "unsupported",
"client_id": dynamicClientID,
"scope": allDynamicClientScopes,
}),
wantStatus: http.StatusSeeOther,
wantContentType: jsonContentType,
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeUnsupportedResponseTypeErrorQuery),
wantBodyString: "",
},
{
name: "response type is unsupported when using OIDC upstream password grant",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()),
@@ -1489,6 +1746,21 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeUnsupportedResponseTypeErrorQuery),
wantBodyString: "",
},
{
name: "response type is unsupported when using LDAP browser upstream with dynamic client",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources,
method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{
"response_type": "unsupported",
"client_id": dynamicClientID,
"scope": allDynamicClientScopes,
}),
wantStatus: http.StatusSeeOther,
wantContentType: jsonContentType,
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeUnsupportedResponseTypeErrorQuery),
wantBodyString: "",
},
{
name: "response type is unsupported when using active directory cli upstream",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider),
@@ -1511,6 +1783,21 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeUnsupportedResponseTypeErrorQuery),
wantBodyString: "",
},
{
name: "response type is unsupported when using active directory browser upstream with dynamic client",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider),
kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources,
method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{
"response_type": "unsupported",
"client_id": dynamicClientID,
"scope": allDynamicClientScopes,
}),
wantStatus: http.StatusSeeOther,
wantContentType: jsonContentType,
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeUnsupportedResponseTypeErrorQuery),
wantBodyString: "",
},
{
name: "downstream scopes do not match what is configured for client using OIDC upstream browser flow",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()),
@@ -1526,6 +1813,22 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidScopeErrorQuery),
wantBodyString: "",
},
{
name: "downstream scopes do not match what is configured for client using OIDC upstream browser flow with dynamic client",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()),
kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources,
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
stateEncoder: happyStateEncoder,
cookieEncoder: happyCookieEncoder,
method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": "openid tuna"}),
wantStatus: http.StatusSeeOther,
wantContentType: jsonContentType,
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidScopeErrorQuery),
wantBodyString: "",
},
{
name: "downstream scopes do not match what is configured for client using OIDC upstream password grant",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()),
@@ -1552,6 +1855,21 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantContentType: htmlContentType,
wantBodyRegex: `<input type="hidden" name="encoded_params" value="error=invalid_scope&amp;error_description=The&#43;requested&#43;scope&#43;is&#43;invalid`,
},
{
name: "response_mode form_post is not allowed for dynamic clients",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()),
kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources,
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
stateEncoder: happyStateEncoder,
cookieEncoder: happyCookieEncoder,
method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"response_mode": "form_post", "client_id": dynamicClientID, "scope": allDynamicClientScopes}),
wantStatus: http.StatusOK, // this is weird, but fosite uses a form_post response to tell the client that it is not allowed to use form_post responses
wantContentType: htmlContentType,
wantBodyRegex: `<input type="hidden" name="encoded_params" value="error=unsupported_response_mode&amp;error_description=The&#43;authorization&#43;server&#43;does&#43;not&#43;support&#43;obtaining&#43;a&#43;response&#43;using&#43;this&#43;response&#43;mode.`,
},
{
name: "downstream scopes do not match what is configured for client using LDAP upstream",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
@@ -1591,6 +1909,22 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingResponseTypeErrorQuery),
wantBodyString: "",
},
{
name: "missing response type in request using OIDC upstream browser flow with dynamic client",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()),
kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources,
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
stateEncoder: happyStateEncoder,
cookieEncoder: happyCookieEncoder,
method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes, "response_type": ""}),
wantStatus: http.StatusSeeOther,
wantContentType: jsonContentType,
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingResponseTypeErrorQuery),
wantBodyString: "",
},
{
name: "missing response type in request using OIDC upstream password grant",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()),
@@ -1625,6 +1959,17 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingResponseTypeErrorQuery),
wantBodyString: "",
},
{
name: "missing response type in request using LDAP browser upstream with dynamic client",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources,
method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes, "response_type": ""}),
wantStatus: http.StatusSeeOther,
wantContentType: jsonContentType,
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingResponseTypeErrorQuery),
wantBodyString: "",
},
{
name: "missing response type in request using Active Directory cli upstream",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider),
@@ -1647,6 +1992,17 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingResponseTypeErrorQuery),
wantBodyString: "",
},
{
name: "missing response type in request using Active Directory browser upstream with dynamic client",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider),
kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources,
method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes, "response_type": ""}),
wantStatus: http.StatusSeeOther,
wantContentType: jsonContentType,
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingResponseTypeErrorQuery),
wantBodyString: "",
},
{
name: "missing client id in request using OIDC upstream browser flow",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()),
@@ -1696,6 +2052,22 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeErrorQuery),
wantBodyString: "",
},
{
name: "missing PKCE code_challenge in request using OIDC upstream browser flow with dynamic client", // See https://tools.ietf.org/html/rfc7636#section-4.4.1
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()),
kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources,
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
stateEncoder: happyStateEncoder,
cookieEncoder: happyCookieEncoder,
method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes, "code_challenge": ""}),
wantStatus: http.StatusSeeOther,
wantContentType: jsonContentType,
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeErrorQuery),
wantBodyString: "",
},
{
name: "missing PKCE code_challenge in request using OIDC upstream password grant", // See https://tools.ietf.org/html/rfc7636#section-4.4.1
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()),
@@ -1738,6 +2110,22 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidCodeChallengeErrorQuery),
wantBodyString: "",
},
{
name: "invalid value for PKCE code_challenge_method in request using OIDC upstream browser flow with dynamic client", // https://tools.ietf.org/html/rfc7636#section-4.3
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()),
kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources,
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
stateEncoder: happyStateEncoder,
cookieEncoder: happyCookieEncoder,
method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes, "code_challenge_method": "this-is-not-a-valid-pkce-alg"}),
wantStatus: http.StatusSeeOther,
wantContentType: jsonContentType,
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidCodeChallengeErrorQuery),
wantBodyString: "",
},
{
name: "invalid value for PKCE code_challenge_method in request using OIDC upstream password grant", // https://tools.ietf.org/html/rfc7636#section-4.3
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()),
@@ -1780,6 +2168,22 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeMethodErrorQuery),
wantBodyString: "",
},
{
name: "when PKCE code_challenge_method in request is `plain` using OIDC upstream browser flow with dynamic client", // https://tools.ietf.org/html/rfc7636#section-4.3
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()),
kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources,
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
stateEncoder: happyStateEncoder,
cookieEncoder: happyCookieEncoder,
method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes, "code_challenge_method": "plain"}),
wantStatus: http.StatusSeeOther,
wantContentType: jsonContentType,
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeMethodErrorQuery),
wantBodyString: "",
},
{
name: "when PKCE code_challenge_method in request is `plain` using OIDC upstream password grant", // https://tools.ietf.org/html/rfc7636#section-4.3
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()),
@@ -1822,6 +2226,22 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeMethodErrorQuery),
wantBodyString: "",
},
{
name: "missing PKCE code_challenge_method in request using OIDC upstream browser flow with dynamic client", // See https://tools.ietf.org/html/rfc7636#section-4.4.1
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()),
kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources,
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
stateEncoder: happyStateEncoder,
cookieEncoder: happyCookieEncoder,
method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes, "code_challenge_method": ""}),
wantStatus: http.StatusSeeOther,
wantContentType: jsonContentType,
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeMethodErrorQuery),
wantBodyString: "",
},
{
name: "missing PKCE code_challenge_method in request using OIDC upstream password grant", // See https://tools.ietf.org/html/rfc7636#section-4.4.1
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()),
@@ -1866,6 +2286,24 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositePromptHasNoneAndOtherValueErrorQuery),
wantBodyString: "",
},
{
// This is just one of the many OIDC validations run by fosite. This test is to ensure that we are running
// through that part of the fosite library when using an OIDC upstream browser flow with a dynamic client.
name: "prompt param is not allowed to have none and another legal value at the same time using OIDC upstream browser flow with dynamic client",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()),
kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources,
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
stateEncoder: happyStateEncoder,
cookieEncoder: happyCookieEncoder,
method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes, "prompt": "none login"}),
wantStatus: http.StatusSeeOther,
wantContentType: jsonContentType,
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositePromptHasNoneAndOtherValueErrorQuery),
wantBodyString: "",
},
{
// This is just one of the many OIDC validations run by fosite. This test is to ensure that we are running
// through that part of the fosite library when using an OIDC upstream password grant.
@@ -1917,6 +2355,27 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantUpstreamStateParamInLocationHeader: true,
wantBodyStringWithLocationInHref: true,
},
{
name: "happy path: downstream OIDC validations are skipped when the openid scope was not requested using OIDC upstream browser flow with dynamic client",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()),
kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources,
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
stateEncoder: happyStateEncoder,
cookieEncoder: happyCookieEncoder,
method: http.MethodGet,
// The following prompt value is illegal when openid is requested, but note that openid is not requested.
path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": "groups", "prompt": "none login"}),
wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType,
wantCSRFValueInCookieHeader: happyCSRF,
wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(
map[string]string{"client_id": dynamicClientID, "scope": "groups", "prompt": "none login"}, "", oidcUpstreamName, "oidc",
), nil),
wantUpstreamStateParamInLocationHeader: true,
wantBodyStringWithLocationInHref: true,
},
{
name: "happy path: downstream OIDC validations are skipped when the openid scope was not requested using OIDC upstream password grant",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()),
@@ -2396,6 +2855,22 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidStateErrorQuery),
wantBodyString: "",
},
{
name: "downstream state does not have enough entropy using OIDC upstream browser flow with dynamic client",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()),
kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources,
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
stateEncoder: happyStateEncoder,
cookieEncoder: happyCookieEncoder,
method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes, "state": "short"}),
wantStatus: http.StatusSeeOther,
wantContentType: jsonContentType,
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidStateErrorQuery),
wantBodyString: "",
},
{
name: "downstream state does not have enough entropy using OIDC upstream password grant",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()),
@@ -2573,7 +3048,11 @@ func TestAuthorizationEndpoint(t *testing.T) {
},
}
runOneTestCase := func(t *testing.T, test testCase, subject http.Handler, kubeOauthStore *oidc.KubeStorage, kubeClient *fake.Clientset, secretsClient v1.SecretInterface) {
runOneTestCase := func(t *testing.T, test testCase, subject http.Handler, kubeOauthStore *oidc.KubeStorage, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset, secretsClient v1.SecretInterface) {
if test.kubeResources != nil {
test.kubeResources(t, supervisorClient, kubeClient)
}
reqContext := context.WithValue(context.Background(), struct{ name string }{name: "test"}, "request-context")
req := httptest.NewRequest(test.method, test.path, strings.NewReader(test.body)).WithContext(reqContext)
req.Header.Set("Content-Type", test.contentType)
@@ -2622,8 +3101,11 @@ func TestAuthorizationEndpoint(t *testing.T) {
// OIDC validations are checked in fosite after the OAuth authcode (and sometimes the OIDC session)
// is stored, so it is possible with an LDAP upstream to store objects and then return an error to
// the client anyway (which makes the stored objects useless, but oh well).
require.Len(t, kubeClient.Actions(), test.wantUnnecessaryStoredRecords)
require.Len(t, filterActions(kubeClient.Actions()), test.wantUnnecessaryStoredRecords)
case test.wantRedirectLocationRegexp != "":
if test.wantDownstreamClientID == "" {
test.wantDownstreamClientID = pinnipedCLIClientID // default assertion value when not provided by test case
}
require.Len(t, rsp.Header().Values("Location"), 1)
oidctestutil.RequireAuthCodeRegexpMatch(
t,
@@ -2640,7 +3122,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
test.wantDownstreamPKCEChallenge,
test.wantDownstreamPKCEChallengeMethod,
test.wantDownstreamNonce,
downstreamClientID,
test.wantDownstreamClientID,
test.wantDownstreamRedirectURI,
test.wantDownstreamCustomSessionData,
)
@@ -2688,8 +3170,11 @@ func TestAuthorizationEndpoint(t *testing.T) {
test := test
t.Run(test.name, func(t *testing.T) {
kubeClient := fake.NewSimpleClientset()
supervisorClient := supervisorfake.NewSimpleClientset()
secretsClient := kubeClient.CoreV1().Secrets("some-namespace")
oauthHelperWithRealStorage, kubeOauthStore := createOauthHelperWithRealStorage(secretsClient)
oidcClientsClient := supervisorClient.ConfigV1alpha1().OIDCClients("some-namespace")
oauthHelperWithRealStorage, kubeOauthStore := createOauthHelperWithRealStorage(secretsClient, oidcClientsClient)
oauthHelperWithNullStorage, _ := createOauthHelperWithNullStorage(secretsClient, oidcClientsClient)
subject := NewHandler(
downstreamIssuer,
test.idps.Build(),
@@ -2697,7 +3182,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
test.generateCSRF, test.generatePKCE, test.generateNonce,
test.stateEncoder, test.cookieEncoder,
)
runOneTestCase(t, test, subject, kubeOauthStore, kubeClient, secretsClient)
runOneTestCase(t, test, subject, kubeOauthStore, supervisorClient, kubeClient, secretsClient)
})
}
@@ -2707,8 +3192,11 @@ func TestAuthorizationEndpoint(t *testing.T) {
require.Equal(t, "OIDC upstream browser flow happy path using GET without a CSRF cookie", test.name)
kubeClient := fake.NewSimpleClientset()
supervisorClient := supervisorfake.NewSimpleClientset()
secretsClient := kubeClient.CoreV1().Secrets("some-namespace")
oauthHelperWithRealStorage, kubeOauthStore := createOauthHelperWithRealStorage(secretsClient)
oidcClientsClient := supervisorClient.ConfigV1alpha1().OIDCClients("some-namespace")
oauthHelperWithRealStorage, kubeOauthStore := createOauthHelperWithRealStorage(secretsClient, oidcClientsClient)
oauthHelperWithNullStorage, _ := createOauthHelperWithNullStorage(secretsClient, oidcClientsClient)
idpLister := test.idps.Build()
subject := NewHandler(
downstreamIssuer,
@@ -2718,7 +3206,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
test.stateEncoder, test.cookieEncoder,
)
runOneTestCase(t, test, subject, kubeOauthStore, kubeClient, secretsClient)
runOneTestCase(t, test, subject, kubeOauthStore, supervisorClient, kubeClient, secretsClient)
// Call the idpLister's setter to change the upstream IDP settings.
newProviderSettings := oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder().
@@ -2756,7 +3244,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
// modified expectations. This should ensure that the implementation is using the in-memory cache
// of upstream IDP settings appropriately in terms of always getting the values from the cache
// on every request.
runOneTestCase(t, test, subject, kubeOauthStore, kubeClient, secretsClient)
runOneTestCase(t, test, subject, kubeOauthStore, supervisorClient, kubeClient, secretsClient)
})
}
@@ -2826,3 +3314,20 @@ func requireEqualURLs(t *testing.T, actualURL string, expectedURL string, ignore
}
require.Equal(t, expectedLocationQuery, actualLocationQuery)
}
// filterActions ignores any reads made to get a storage secret corresponding to an OIDCClient, since these
// are normal actions when the request is using a dynamic client's client_id, and we don't need to make assertions
// about these Secrets since they are not related to session storage.
func filterActions(actions []kubetesting.Action) []kubetesting.Action {
filtered := make([]kubetesting.Action, 0, len(actions))
for _, action := range actions {
if action.Matches("get", "secrets") {
getAction := action.(kubetesting.GetAction)
if strings.HasPrefix(getAction.GetName(), "pinniped-storage-oidc-client-secret-") {
continue // filter out OIDCClient's storage secret reads
}
}
filtered = append(filtered, action) // otherwise include the action
}
return filtered
}
+3 -1
View File
@@ -52,7 +52,9 @@ func NewHandler(
return httperr.New(http.StatusBadRequest, "error using state downstream auth params")
}
// Automatically grant the openid, offline_access, and pinniped:request-audience scopes, but only if they were requested.
// Automatically grant the openid, offline_access, pinniped:request-audience, and groups scopes, but only if they were requested.
// This is instead of asking the user to approve these scopes. Note that `NewAuthorizeRequest` would have returned
// an error if the client requested a scope that they are not allowed to request, so we don't need to worry about that here.
downstreamsession.GrantScopesIfRequested(authorizeRequester, []string{coreosoidc.ScopeOpenID, coreosoidc.ScopeOfflineAccess, oidc.RequestAudienceScope, oidc.DownstreamGroupsScope})
token, err := upstreamIDPConfig.ExchangeAuthcodeAndValidateTokens(
@@ -18,6 +18,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes/fake"
supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake"
"go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/oidc/jwks"
"go.pinniped.dev/internal/psession"
@@ -1072,11 +1073,12 @@ func TestCallbackEndpoint(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
client := fake.NewSimpleClientset()
secrets := client.CoreV1().Secrets("some-namespace")
oidcClientsClient := supervisorfake.NewSimpleClientset().ConfigV1alpha1().OIDCClients("some-namespace")
// Configure fosite the same way that the production code would.
// Inject this into our test subject at the last second so we get a fresh storage for every test.
timeoutsConfiguration := oidc.DefaultOIDCTimeoutsConfiguration()
oauthStore := oidc.NewKubeStorage(secrets, timeoutsConfiguration)
oauthStore := oidc.NewKubeStorage(secrets, oidcClientsClient, timeoutsConfiguration)
hmacSecretFunc := func() []byte { return []byte("some secret - must have at least 32 bytes") }
require.GreaterOrEqual(t, len(hmacSecretFunc()), 32, "fosite requires that hmac secrets have at least 32 bytes")
jwksProviderIsUnused := jwks.NewDynamicJWKSProvider()
+138 -20
View File
@@ -11,47 +11,106 @@ import (
"github.com/coreos/go-oidc/v3/oidc"
"github.com/ory/fosite"
"k8s.io/apimachinery/pkg/api/errors"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1"
supervisorclient "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/typed/config/v1alpha1"
"go.pinniped.dev/internal/oidc/oidcclientvalidator"
"go.pinniped.dev/internal/oidcclientsecretstorage"
"go.pinniped.dev/internal/plog"
)
// Client represents a Pinniped OAuth/OIDC client.
// PinnipedCLIClientID is the client ID of the statically defined public OIDC client which is used by the CLI.
const PinnipedCLIClientID = "pinniped-cli"
// Client represents a Pinniped OAuth/OIDC client. It can be the static pinniped-cli client
// or a dynamic client defined by an OIDCClient CR.
type Client struct {
fosite.DefaultOpenIDConnectClient
}
func (c Client) GetResponseModes() []fosite.ResponseModeType {
// For now, all Pinniped clients always support "" (unspecified), "query", and "form_post" response modes.
return []fosite.ResponseModeType{fosite.ResponseModeDefault, fosite.ResponseModeQuery, fosite.ResponseModeFormPost}
}
// It implements both the base, OIDC, and response_mode client interfaces of Fosite.
// Client implements the base, OIDC, and response_mode client interfaces of Fosite.
var (
_ fosite.Client = (*Client)(nil)
_ fosite.OpenIDConnectClient = (*Client)(nil)
_ fosite.ResponseModeClient = (*Client)(nil)
)
// StaticClientManager is a fosite.ClientManager with statically-defined clients.
type StaticClientManager struct{}
func (c Client) GetResponseModes() []fosite.ResponseModeType {
if c.ID == PinnipedCLIClientID {
// The pinniped-cli client supports "" (unspecified), "query", and "form_post" response modes.
return []fosite.ResponseModeType{fosite.ResponseModeDefault, fosite.ResponseModeQuery, fosite.ResponseModeFormPost}
}
// For now, all other clients support only "" (unspecified) and "query" response modes.
return []fosite.ResponseModeType{fosite.ResponseModeDefault, fosite.ResponseModeQuery}
}
var _ fosite.ClientManager = (*StaticClientManager)(nil)
// ClientManager is a fosite.ClientManager with a statically-defined client and with dynamically-defined clients.
type ClientManager struct {
oidcClientsClient supervisorclient.OIDCClientInterface
storage *oidcclientsecretstorage.OIDCClientSecretStorage
}
// GetClient returns a static client specified by the given ID.
var _ fosite.ClientManager = (*ClientManager)(nil)
func NewClientManager(
oidcClientsClient supervisorclient.OIDCClientInterface,
storage *oidcclientsecretstorage.OIDCClientSecretStorage,
) *ClientManager {
return &ClientManager{
oidcClientsClient: oidcClientsClient,
storage: storage,
}
}
// GetClient returns the client specified by the given ID.
//
// It returns a fosite.ErrNotFound if an unknown client is specified.
func (StaticClientManager) GetClient(_ context.Context, id string) (fosite.Client, error) {
switch id {
case "pinniped-cli":
// Other errors returned are plain errors, because fosite will wrap them into a new ErrInvalidClient error and
// use the plain error's text as that error's debug message (see client_authentication.go in fosite).
func (m *ClientManager) GetClient(ctx context.Context, id string) (fosite.Client, error) {
if id == PinnipedCLIClientID {
// Return the static client. No lookups needed.
return PinnipedCLI(), nil
default:
}
// Try to look up an OIDCClient with the given client ID (which will be the Name of the OIDCClient).
oidcClient, err := m.oidcClientsClient.Get(ctx, id, v1.GetOptions{})
if errors.IsNotFound(err) {
return nil, fosite.ErrNotFound.WithDescription("no such client")
}
if err != nil {
// Log the error so an admin can see why the lookup failed at the time of the request.
plog.Error("OIDC client lookup GetClient() failed to get OIDCClient", err, "clientID", id)
return nil, fmt.Errorf("failed to get client %q", id)
}
// Try to find the corresponding client secret storage Secret.
storageSecret, err := m.storage.GetStorageSecret(ctx, oidcClient.UID)
if err != nil {
// Log the error so an admin can see why the lookup failed at the time of the request.
plog.Error("OIDC client lookup GetClient() failed to get storage secret for OIDCClient", err, "clientID", id)
return nil, fmt.Errorf("failed to get storage secret for client %q", id)
}
// Check if the OIDCClient and its corresponding Secret are valid.
valid, conditions, clientSecrets := oidcclientvalidator.Validate(oidcClient, storageSecret)
if !valid {
// Log the conditions so an admin can see exactly what was invalid at the time of the request.
plog.Debug("OIDC client lookup GetClient() found an invalid client", "clientID", id, "conditions", conditions)
return nil, fmt.Errorf("client %q exists but is invalid or not ready", id)
}
// Everything is valid, so return the client. Note that it has at least one client secret to be considered valid.
return oidcClientCRToFositeClient(oidcClient, clientSecrets), nil
}
// ClientAssertionJWTValid returns an error if the JTI is
// known or the DB check failed and nil if the JTI is not known.
//
// This functionality is not supported by the StaticClientManager.
func (StaticClientManager) ClientAssertionJWTValid(ctx context.Context, jti string) error {
// This functionality is not supported by the ClientManager.
func (*ClientManager) ClientAssertionJWTValid(ctx context.Context, jti string) error {
return fmt.Errorf("not implemented")
}
@@ -60,8 +119,8 @@ func (StaticClientManager) ClientAssertionJWTValid(ctx context.Context, jti stri
// up any existing JTIs that have expired as those tokens can
// not be replayed due to the expiry.
//
// This functionality is not supported by the StaticClientManager.
func (StaticClientManager) SetClientAssertionJWT(ctx context.Context, jti string, exp time.Time) error {
// This functionality is not supported by the ClientManager.
func (*ClientManager) SetClientAssertionJWT(ctx context.Context, jti string, exp time.Time) error {
return fmt.Errorf("not implemented")
}
@@ -70,7 +129,7 @@ func PinnipedCLI() *Client {
return &Client{
DefaultOpenIDConnectClient: fosite.DefaultOpenIDConnectClient{
DefaultClient: &fosite.DefaultClient{
ID: "pinniped-cli",
ID: PinnipedCLIClientID,
Secret: nil,
RedirectURIs: []string{"http://127.0.0.1/callback"},
GrantTypes: fosite.Arguments{
@@ -99,3 +158,62 @@ func PinnipedCLI() *Client {
},
}
}
func oidcClientCRToFositeClient(oidcClient *configv1alpha1.OIDCClient, clientSecrets []string) *Client {
return &Client{
DefaultOpenIDConnectClient: fosite.DefaultOpenIDConnectClient{
DefaultClient: &fosite.DefaultClient{
ID: oidcClient.Name,
// We set RotatedSecrets, but we don't need to also set Secret because the client_authentication.go code
// will always call the hasher on the empty Secret first, and the bcrypt hasher will always fail very
// quickly (ErrHashTooShort error), and then client_authentication.go will move on to using the
// RotatedSecrets instead.
RotatedSecrets: stringSliceToByteSlices(clientSecrets),
RedirectURIs: redirectURIsToStrings(oidcClient.Spec.AllowedRedirectURIs),
GrantTypes: grantTypesToArguments(oidcClient.Spec.AllowedGrantTypes),
ResponseTypes: []string{"code"},
Scopes: scopesToArguments(oidcClient.Spec.AllowedScopes),
Audience: nil,
Public: false,
},
RequestURIs: nil,
JSONWebKeys: nil,
JSONWebKeysURI: "",
RequestObjectSigningAlgorithm: "",
TokenEndpointAuthSigningAlgorithm: oidc.RS256,
TokenEndpointAuthMethod: "client_secret_basic",
},
}
}
func scopesToArguments(scopes []configv1alpha1.Scope) fosite.Arguments {
a := make(fosite.Arguments, len(scopes))
for i, scope := range scopes {
a[i] = string(scope)
}
return a
}
func grantTypesToArguments(grantTypes []configv1alpha1.GrantType) fosite.Arguments {
a := make(fosite.Arguments, len(grantTypes))
for i, grantType := range grantTypes {
a[i] = string(grantType)
}
return a
}
func redirectURIsToStrings(uris []configv1alpha1.RedirectURI) []string {
s := make([]string, len(uris))
for i, uri := range uris {
s[i] = string(uri)
}
return s
}
func stringSliceToByteSlices(s []string) [][]byte {
b := make([][]byte, len(s))
for i, str := range s {
b[i] = []byte(str)
}
return b
}
@@ -6,45 +6,239 @@ package clientregistry
import (
"context"
"encoding/json"
"fmt"
"testing"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/ory/fosite"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/kubernetes/fake"
coretesting "k8s.io/client-go/testing"
configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1"
supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake"
"go.pinniped.dev/internal/oidcclientsecretstorage"
"go.pinniped.dev/internal/testutil"
)
func TestStaticRegistry(t *testing.T) {
func TestClientManager(t *testing.T) {
ctx := context.Background()
t.Run("unimplemented methods", func(t *testing.T) {
registry := StaticClientManager{}
require.EqualError(t, registry.ClientAssertionJWTValid(ctx, "some-token-id"), "not implemented")
require.EqualError(t, registry.SetClientAssertionJWT(ctx, "some-token-id", time.Now()), "not implemented")
})
const (
testName = "client.oauth.pinniped.dev-test-name"
testNamespace = "test-namespace"
testUID = "test-uid-123"
t.Run("not found", func(t *testing.T) {
registry := StaticClientManager{}
got, err := registry.GetClient(ctx, "does-not-exist")
require.Error(t, err)
require.Nil(t, got)
rfcErr := fosite.ErrorToRFC6749Error(err)
require.NotNil(t, rfcErr)
require.Equal(t, rfcErr.CodeField, 404)
require.Equal(t, rfcErr.GetDescription(), "no such client")
})
//nolint:gosec // this is not a credential
testBcryptSecret1 = "$2y$15$Kh7cRj0ScSD5QelE3ZNSl.nF04JDv7zb3SgGN.tSfLIX.4kt3UX7m" // bcrypt of "password1" at cost 15
//nolint:gosec // this is not a credential
testBcryptSecret2 = "$2y$15$Kh7cRj0ScSD5QelE3ZNSl.nF04JDv7zb3SgGN.tSfLIX.4kt3UX7m" // bcrypt of "password2" at cost 15
)
t.Run("pinniped CLI", func(t *testing.T) {
registry := StaticClientManager{}
got, err := registry.GetClient(ctx, "pinniped-cli")
require.NoError(t, err)
require.NotNil(t, got)
require.IsType(t, &Client{}, got)
})
tests := []struct {
name string
secrets []*corev1.Secret
oidcClients []*configv1alpha1.OIDCClient
addKubeReactions func(client *fake.Clientset)
addSupervisorReactions func(client *supervisorfake.Clientset)
run func(t *testing.T, subject *ClientManager)
}{
{
name: "unimplemented methods",
run: func(t *testing.T, subject *ClientManager) {
require.EqualError(t, subject.ClientAssertionJWTValid(ctx, "some-token-id"), "not implemented")
require.EqualError(t, subject.SetClientAssertionJWT(ctx, "some-token-id", time.Now()), "not implemented")
},
},
{
name: "find pinniped-cli client when no dynamic clients exist",
run: func(t *testing.T, subject *ClientManager) {
got, err := subject.GetClient(ctx, "pinniped-cli")
require.NoError(t, err)
require.IsType(t, &Client{}, got)
requireEqualsPinnipedCLI(t, got.(*Client))
},
},
{
name: "find pinniped-cli client when some dynamic clients also exist",
oidcClients: []*configv1alpha1.OIDCClient{
{ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}},
},
run: func(t *testing.T, subject *ClientManager) {
got, err := subject.GetClient(ctx, "pinniped-cli")
require.NoError(t, err)
require.IsType(t, &Client{}, got)
requireEqualsPinnipedCLI(t, got.(*Client))
},
},
{
name: "client not found",
oidcClients: []*configv1alpha1.OIDCClient{
{ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}},
},
run: func(t *testing.T, subject *ClientManager) {
got, err := subject.GetClient(ctx, "does-not-exist")
require.Error(t, err)
require.Nil(t, got)
rfcErr := fosite.ErrorToRFC6749Error(err)
require.NotNil(t, rfcErr)
require.Equal(t, rfcErr.CodeField, 404)
require.Equal(t, rfcErr.GetDescription(), "no such client")
},
},
{
name: "find a dynamic client when its storage secret does not exist (client is invalid because is has no client secret)",
oidcClients: []*configv1alpha1.OIDCClient{
{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Spec: configv1alpha1.OIDCClientSpec{
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"},
AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"},
AllowedRedirectURIs: []configv1alpha1.RedirectURI{"http://localhost:80", "https://foobar.com/callback"},
},
},
},
run: func(t *testing.T, subject *ClientManager) {
got, err := subject.GetClient(ctx, testName)
require.EqualError(t, err, fmt.Sprintf("client %q exists but is invalid or not ready", testName))
require.Nil(t, got)
},
},
{
name: "find a dynamic client which is invalid due to its spec",
oidcClients: []*configv1alpha1.OIDCClient{
{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Spec: configv1alpha1.OIDCClientSpec{
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code"},
AllowedScopes: []configv1alpha1.Scope{}, // at least "openid" is required here, so this makes the client invalid
AllowedRedirectURIs: []configv1alpha1.RedirectURI{"http://localhost:80"},
},
},
},
secrets: []*corev1.Secret{
testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1, testBcryptSecret2}),
},
run: func(t *testing.T, subject *ClientManager) {
got, err := subject.GetClient(ctx, testName)
require.EqualError(t, err, fmt.Sprintf("client %q exists but is invalid or not ready", testName))
require.Nil(t, got)
},
},
{
name: "when there is an unexpected error getting the OIDCClient",
addSupervisorReactions: func(client *supervisorfake.Clientset) {
client.PrependReactor("get", "oidcclients", func(action coretesting.Action) (handled bool, ret runtime.Object, err error) {
return true, nil, fmt.Errorf("some get OIDCClients error")
})
},
run: func(t *testing.T, subject *ClientManager) {
got, err := subject.GetClient(ctx, testName)
require.EqualError(t, err, fmt.Sprintf("failed to get client %q", testName))
require.Nil(t, got)
},
},
{
name: "when there is an unexpected error getting the storage secret for the OIDCClient",
oidcClients: []*configv1alpha1.OIDCClient{
{ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}},
},
addKubeReactions: func(client *fake.Clientset) {
client.PrependReactor("get", "secrets", func(action coretesting.Action) (handled bool, ret runtime.Object, err error) {
return true, nil, fmt.Errorf("some get Secrets error")
})
},
run: func(t *testing.T, subject *ClientManager) {
got, err := subject.GetClient(ctx, testName)
require.EqualError(t, err, fmt.Sprintf("failed to get storage secret for client %q", testName))
require.Nil(t, got)
},
},
{
name: "find a valid dynamic client",
oidcClients: []*configv1alpha1.OIDCClient{
{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID},
Spec: configv1alpha1.OIDCClientSpec{
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"},
AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"},
AllowedRedirectURIs: []configv1alpha1.RedirectURI{"http://localhost:80", "https://foobar.com/callback"},
},
},
{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: "other-client", Generation: 1234, UID: testUID},
},
},
secrets: []*corev1.Secret{
testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1, testBcryptSecret2}),
},
run: func(t *testing.T, subject *ClientManager) {
got, err := subject.GetClient(ctx, testName)
require.NoError(t, err)
require.IsType(t, &Client{}, got)
c := got.(*Client)
require.Equal(t, testName, c.GetID())
require.Nil(t, c.GetHashedSecret())
require.Len(t, c.GetRotatedHashes(), 2)
require.Equal(t, testBcryptSecret1, string(c.GetRotatedHashes()[0]))
require.Equal(t, testBcryptSecret2, string(c.GetRotatedHashes()[1]))
require.Equal(t, []string{"http://localhost:80", "https://foobar.com/callback"}, c.GetRedirectURIs())
require.Equal(t, fosite.Arguments{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"}, c.GetGrantTypes())
require.Equal(t, fosite.Arguments{"code"}, c.GetResponseTypes())
require.Equal(t, fosite.Arguments{"openid", "offline_access", "pinniped:request-audience", "username", "groups"}, c.GetScopes())
require.False(t, c.IsPublic())
require.Nil(t, c.GetAudience())
require.Nil(t, c.GetRequestURIs())
require.Nil(t, c.GetJSONWebKeys())
require.Equal(t, "", c.GetJSONWebKeysURI())
require.Equal(t, "", c.GetRequestObjectSigningAlgorithm())
require.Equal(t, "client_secret_basic", c.GetTokenEndpointAuthMethod())
require.Equal(t, "RS256", c.GetTokenEndpointAuthSigningAlgorithm())
require.Equal(t, []fosite.ResponseModeType{"", "query"}, c.GetResponseModes())
},
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
kubeClient := fake.NewSimpleClientset()
secrets := kubeClient.CoreV1().Secrets(testNamespace)
supervisorClient := supervisorfake.NewSimpleClientset()
oidcClientsClient := supervisorClient.ConfigV1alpha1().OIDCClients(testNamespace)
subject := NewClientManager(oidcClientsClient, oidcclientsecretstorage.New(secrets, time.Now))
for _, secret := range test.secrets {
require.NoError(t, kubeClient.Tracker().Add(secret))
}
for _, oidcClient := range test.oidcClients {
require.NoError(t, supervisorClient.Tracker().Add(oidcClient))
}
if test.addKubeReactions != nil {
test.addKubeReactions(kubeClient)
}
if test.addSupervisorReactions != nil {
test.addSupervisorReactions(supervisorClient)
}
test.run(t, subject)
})
}
}
func TestPinnipedCLI(t *testing.T) {
c := PinnipedCLI()
requireEqualsPinnipedCLI(t, PinnipedCLI())
}
func requireEqualsPinnipedCLI(t *testing.T, c *Client) {
require.Equal(t, "pinniped-cli", c.GetID())
require.Nil(t, c.GetHashedSecret())
require.Equal(t, []string{"http://127.0.0.1/callback"}, c.GetRedirectURIs())
+4 -2
View File
@@ -13,6 +13,7 @@ import (
fositepkce "github.com/ory/fosite/handler/pkce"
corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
"go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/typed/config/v1alpha1"
"go.pinniped.dev/internal/fositestorage/accesstoken"
"go.pinniped.dev/internal/fositestorage/authorizationcode"
"go.pinniped.dev/internal/fositestorage/openidconnect"
@@ -20,6 +21,7 @@ import (
"go.pinniped.dev/internal/fositestorage/refreshtoken"
"go.pinniped.dev/internal/fositestoragei"
"go.pinniped.dev/internal/oidc/clientregistry"
"go.pinniped.dev/internal/oidcclientsecretstorage"
)
type KubeStorage struct {
@@ -33,10 +35,10 @@ type KubeStorage struct {
var _ fositestoragei.AllFositeStorage = &KubeStorage{}
func NewKubeStorage(secrets corev1client.SecretInterface, timeoutsConfiguration TimeoutsConfiguration) *KubeStorage {
func NewKubeStorage(secrets corev1client.SecretInterface, oidcClientsClient v1alpha1.OIDCClientInterface, timeoutsConfiguration TimeoutsConfiguration) *KubeStorage {
nowFunc := time.Now
return &KubeStorage{
clientManager: &clientregistry.StaticClientManager{},
clientManager: clientregistry.NewClientManager(oidcClientsClient, oidcclientsecretstorage.New(secrets, nowFunc)),
authorizationCodeStorage: authorizationcode.New(secrets, nowFunc, timeoutsConfiguration.AuthorizationCodeSessionStorageLifetime),
pkceStorage: pkce.New(secrets, nowFunc, timeoutsConfiguration.PKCESessionStorageLifetime),
oidcStorage: openidconnect.New(secrets, nowFunc, timeoutsConfiguration.OIDCSessionStorageLifetime),
@@ -46,6 +46,8 @@ func NewPostHandler(issuerURL string, upstreamIDPs oidc.UpstreamIdentityProvider
}
// Automatically grant the openid, offline_access, pinniped:request-audience and groups scopes, but only if they were requested.
// This is instead of asking the user to approve these scopes. Note that `NewAuthorizeRequest` would have returned
// an error if the client requested a scope that they are not allowed to request, so we don't need to worry about that here.
downstreamsession.GrantScopesIfRequested(authorizeRequester, []string{coreosoidc.ScopeOpenID, coreosoidc.ScopeOfflineAccess, oidc.RequestAudienceScope, oidc.DownstreamGroupsScope})
// Get the username and password form params from the POST body.
@@ -16,6 +16,7 @@ import (
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/client-go/kubernetes/fake"
supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake"
"go.pinniped.dev/internal/authenticators"
"go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/oidc/jwks"
@@ -671,11 +672,12 @@ func TestPostLoginEndpoint(t *testing.T) {
kubeClient := fake.NewSimpleClientset()
secretsClient := kubeClient.CoreV1().Secrets("some-namespace")
oidcClientsClient := supervisorfake.NewSimpleClientset().ConfigV1alpha1().OIDCClients("some-namespace")
// Configure fosite the same way that the production code would.
// Inject this into our test subject at the last second so we get a fresh storage for every test.
timeoutsConfiguration := oidc.DefaultOIDCTimeoutsConfiguration()
kubeOauthStore := oidc.NewKubeStorage(secretsClient, timeoutsConfiguration)
kubeOauthStore := oidc.NewKubeStorage(secretsClient, oidcClientsClient, timeoutsConfiguration)
hmacSecretFunc := func() []byte { return []byte("some secret - must have at least 32 bytes") }
require.GreaterOrEqual(t, len(hmacSecretFunc()), 32, "fosite requires that hmac secrets have at least 32 bytes")
jwksProviderIsUnused := jwks.NewDynamicJWKSProvider()
+12 -1
View File
@@ -5,22 +5,33 @@ package oidc
import (
"context"
"time"
"github.com/ory/fosite"
corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
"go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/typed/config/v1alpha1"
"go.pinniped.dev/internal/constable"
"go.pinniped.dev/internal/fositestoragei"
"go.pinniped.dev/internal/oidc/clientregistry"
"go.pinniped.dev/internal/oidcclientsecretstorage"
)
const errNullStorageNotImplemented = constable.Error("NullStorage does not implement this method. It should not have been called.")
type NullStorage struct {
clientregistry.StaticClientManager
// The authorization endpoint uses NullStorage to avoid saving any data, but it still needs to perform client lookups.
*clientregistry.ClientManager
}
var _ fositestoragei.AllFositeStorage = &NullStorage{}
func NewNullStorage(secrets corev1client.SecretInterface, oidcClientsClient v1alpha1.OIDCClientInterface) *NullStorage {
return &NullStorage{
ClientManager: clientregistry.NewClientManager(oidcClientsClient, oidcclientsecretstorage.New(secrets, time.Now)),
}
}
func (NullStorage) RevokeRefreshToken(_ context.Context, _ string) error {
return errNullStorageNotImplemented
}
@@ -0,0 +1,235 @@
// Copyright 2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package oidcclientvalidator
import (
"fmt"
"strings"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/crypto/bcrypt"
v1 "k8s.io/api/core/v1"
"go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1"
"go.pinniped.dev/internal/oidcclientsecretstorage"
)
const (
clientSecretExists = "ClientSecretExists"
allowedGrantTypesValid = "AllowedGrantTypesValid"
allowedScopesValid = "AllowedScopesValid"
reasonSuccess = "Success"
reasonMissingRequiredValue = "MissingRequiredValue"
reasonNoClientSecretFound = "NoClientSecretFound"
reasonInvalidClientSecretFound = "InvalidClientSecretFound"
authorizationCodeGrantTypeName = "authorization_code"
refreshTokenGrantTypeName = "refresh_token"
tokenExchangeGrantTypeName = "urn:ietf:params:oauth:grant-type:token-exchange" //nolint:gosec // this is not a credential
openidScopeName = oidc.ScopeOpenID
offlineAccessScopeName = oidc.ScopeOfflineAccess
requestAudienceScopeName = "pinniped:request-audience"
usernameScopeName = "username"
groupsScopeName = "groups"
allowedGrantTypesFieldName = "allowedGrantTypes"
allowedScopesFieldName = "allowedScopes"
minimumRequiredBcryptCost = 15
)
// Validate validates the OIDCClient and its corresponding client secret storage Secret.
// When the corresponding client secret storage Secret was not found, pass nil to this function to
// get the validation error for that case. It returns a bool to indicate if the client is valid,
// along with a slice of conditions containing more details, and the list of client secrets in the
// case that the client was valid.
func Validate(oidcClient *v1alpha1.OIDCClient, secret *v1.Secret) (bool, []*v1alpha1.Condition, []string) {
conds := make([]*v1alpha1.Condition, 0, 3)
conds, clientSecrets := validateSecret(secret, conds)
conds = validateAllowedGrantTypes(oidcClient, conds)
conds = validateAllowedScopes(oidcClient, conds)
valid := true
for _, cond := range conds {
if cond.Status != v1alpha1.ConditionTrue {
valid = false
break
}
}
return valid, conds, clientSecrets
}
// validateAllowedScopes checks if allowedScopes is valid on the OIDCClient.
func validateAllowedScopes(oidcClient *v1alpha1.OIDCClient, conditions []*v1alpha1.Condition) []*v1alpha1.Condition {
m := make([]string, 0, 4)
if !allowedScopesContains(oidcClient, openidScopeName) {
m = append(m, fmt.Sprintf("%q must always be included in %q", openidScopeName, allowedScopesFieldName))
}
if allowedGrantTypesContains(oidcClient, refreshTokenGrantTypeName) && !allowedScopesContains(oidcClient, offlineAccessScopeName) {
m = append(m, fmt.Sprintf("%q must be included in %q when %q is included in %q",
offlineAccessScopeName, allowedScopesFieldName, refreshTokenGrantTypeName, allowedGrantTypesFieldName))
}
if allowedScopesContains(oidcClient, requestAudienceScopeName) &&
(!allowedScopesContains(oidcClient, usernameScopeName) || !allowedScopesContains(oidcClient, groupsScopeName)) {
m = append(m, fmt.Sprintf("%q and %q must be included in %q when %q is included in %q",
usernameScopeName, groupsScopeName, allowedScopesFieldName, requestAudienceScopeName, allowedScopesFieldName))
}
if allowedGrantTypesContains(oidcClient, tokenExchangeGrantTypeName) && !allowedScopesContains(oidcClient, requestAudienceScopeName) {
m = append(m, fmt.Sprintf("%q must be included in %q when %q is included in %q",
requestAudienceScopeName, allowedScopesFieldName, tokenExchangeGrantTypeName, allowedGrantTypesFieldName))
}
if len(m) == 0 {
conditions = append(conditions, &v1alpha1.Condition{
Type: allowedScopesValid,
Status: v1alpha1.ConditionTrue,
Reason: reasonSuccess,
Message: fmt.Sprintf("%q is valid", allowedScopesFieldName),
})
} else {
conditions = append(conditions, &v1alpha1.Condition{
Type: allowedScopesValid,
Status: v1alpha1.ConditionFalse,
Reason: reasonMissingRequiredValue,
Message: strings.Join(m, "; "),
})
}
return conditions
}
// validateAllowedGrantTypes checks if allowedGrantTypes is valid on the OIDCClient.
func validateAllowedGrantTypes(oidcClient *v1alpha1.OIDCClient, conditions []*v1alpha1.Condition) []*v1alpha1.Condition {
m := make([]string, 0, 3)
if !allowedGrantTypesContains(oidcClient, authorizationCodeGrantTypeName) {
m = append(m, fmt.Sprintf("%q must always be included in %q",
authorizationCodeGrantTypeName, allowedGrantTypesFieldName))
}
if allowedScopesContains(oidcClient, offlineAccessScopeName) && !allowedGrantTypesContains(oidcClient, refreshTokenGrantTypeName) {
m = append(m, fmt.Sprintf("%q must be included in %q when %q is included in %q",
refreshTokenGrantTypeName, allowedGrantTypesFieldName, offlineAccessScopeName, allowedScopesFieldName))
}
if allowedScopesContains(oidcClient, requestAudienceScopeName) && !allowedGrantTypesContains(oidcClient, tokenExchangeGrantTypeName) {
m = append(m, fmt.Sprintf("%q must be included in %q when %q is included in %q",
tokenExchangeGrantTypeName, allowedGrantTypesFieldName, requestAudienceScopeName, allowedScopesFieldName))
}
if len(m) == 0 {
conditions = append(conditions, &v1alpha1.Condition{
Type: allowedGrantTypesValid,
Status: v1alpha1.ConditionTrue,
Reason: reasonSuccess,
Message: fmt.Sprintf("%q is valid", allowedGrantTypesFieldName),
})
} else {
conditions = append(conditions, &v1alpha1.Condition{
Type: allowedGrantTypesValid,
Status: v1alpha1.ConditionFalse,
Reason: reasonMissingRequiredValue,
Message: strings.Join(m, "; "),
})
}
return conditions
}
// validateSecret checks if the client secret storage Secret is valid and contains at least one client secret.
// It returns the updated conditions slice along with the client secrets found in that case that it is valid.
func validateSecret(secret *v1.Secret, conditions []*v1alpha1.Condition) ([]*v1alpha1.Condition, []string) {
emptyList := []string{}
if secret == nil {
// Invalid: no storage Secret found.
conditions = append(conditions, &v1alpha1.Condition{
Type: clientSecretExists,
Status: v1alpha1.ConditionFalse,
Reason: reasonNoClientSecretFound,
Message: "no client secret found (no Secret storage found)",
})
return conditions, emptyList
}
storedClientSecret, err := oidcclientsecretstorage.ReadFromSecret(secret)
if err != nil {
// Invalid: storage Secret exists but its data could not be parsed.
conditions = append(conditions, &v1alpha1.Condition{
Type: clientSecretExists,
Status: v1alpha1.ConditionFalse,
Reason: reasonNoClientSecretFound,
Message: fmt.Sprintf("error reading client secret storage: %s", err.Error()),
})
return conditions, emptyList
}
// Successfully read the stored client secrets, so check if there are any stored in the list.
storedClientSecretsCount := len(storedClientSecret.SecretHashes)
if storedClientSecretsCount == 0 {
// Invalid: no client secrets stored.
conditions = append(conditions, &v1alpha1.Condition{
Type: clientSecretExists,
Status: v1alpha1.ConditionFalse,
Reason: reasonNoClientSecretFound,
Message: "no client secret found (empty list in storage)",
})
return conditions, emptyList
}
// Check each hashed password's format and bcrypt cost.
bcryptErrs := make([]string, 0, storedClientSecretsCount)
for i, p := range storedClientSecret.SecretHashes {
cost, err := bcrypt.Cost([]byte(p))
if err != nil {
bcryptErrs = append(bcryptErrs, fmt.Sprintf(
"hashed client secret at index %d: %s",
i, err.Error()))
} else if cost < minimumRequiredBcryptCost {
bcryptErrs = append(bcryptErrs, fmt.Sprintf(
"hashed client secret at index %d: bcrypt cost %d is below the required minimum of %d",
i, cost, minimumRequiredBcryptCost))
}
}
if len(bcryptErrs) > 0 {
// Invalid: some stored client secrets were not valid.
conditions = append(conditions, &v1alpha1.Condition{
Type: clientSecretExists,
Status: v1alpha1.ConditionFalse,
Reason: reasonInvalidClientSecretFound,
Message: fmt.Sprintf("%d stored client secrets found, but some were invalid, so none will be used: %s",
storedClientSecretsCount, strings.Join(bcryptErrs, "; ")),
})
return conditions, emptyList
}
// Valid: has at least one client secret stored for this OIDC client, and all stored client secrets are valid.
conditions = append(conditions, &v1alpha1.Condition{
Type: clientSecretExists,
Status: v1alpha1.ConditionTrue,
Reason: reasonSuccess,
Message: fmt.Sprintf("%d client secret(s) found", storedClientSecretsCount),
})
return conditions, storedClientSecret.SecretHashes
}
func allowedGrantTypesContains(haystack *v1alpha1.OIDCClient, needle string) bool {
for _, hay := range haystack.Spec.AllowedGrantTypes {
if hay == v1alpha1.GrantType(needle) {
return true
}
}
return false
}
func allowedScopesContains(haystack *v1alpha1.OIDCClient, needle string) bool {
for _, hay := range haystack.Spec.AllowedScopes {
if hay == v1alpha1.Scope(needle) {
return true
}
}
return false
}
+18 -2
View File
@@ -10,6 +10,7 @@ import (
corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
"go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/typed/config/v1alpha1"
"go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/oidc/auth"
"go.pinniped.dev/internal/oidc/callback"
@@ -39,6 +40,7 @@ type Manager struct {
upstreamIDPs oidc.UpstreamIdentityProvidersLister // in-memory cache of upstream IDPs
secretCache *secret.Cache // in-memory cache of cryptographic material
secretsClient corev1client.SecretInterface
oidcClientsClient v1alpha1.OIDCClientInterface
}
// NewManager returns an empty Manager.
@@ -51,6 +53,7 @@ func NewManager(
upstreamIDPs oidc.UpstreamIdentityProvidersLister,
secretCache *secret.Cache,
secretsClient corev1client.SecretInterface,
oidcClientsClient v1alpha1.OIDCClientInterface,
) *Manager {
return &Manager{
providerHandlers: make(map[string]http.Handler),
@@ -59,6 +62,7 @@ func NewManager(
upstreamIDPs: upstreamIDPs,
secretCache: secretCache,
secretsClient: secretsClient,
oidcClientsClient: oidcClientsClient,
}
}
@@ -93,10 +97,22 @@ func (m *Manager) SetProviders(federationDomains ...*provider.FederationDomainIs
// Use NullStorage for the authorize endpoint because we do not actually want to store anything until
// the upstream callback endpoint is called later.
oauthHelperWithNullStorage := oidc.FositeOauth2Helper(oidc.NullStorage{}, issuer, tokenHMACKeyGetter, nil, timeoutsConfiguration)
oauthHelperWithNullStorage := oidc.FositeOauth2Helper(
oidc.NewNullStorage(m.secretsClient, m.oidcClientsClient),
issuer,
tokenHMACKeyGetter,
nil,
timeoutsConfiguration,
)
// For all the other endpoints, make another oauth helper with exactly the same settings except use real storage.
oauthHelperWithKubeStorage := oidc.FositeOauth2Helper(oidc.NewKubeStorage(m.secretsClient, timeoutsConfiguration), issuer, tokenHMACKeyGetter, m.dynamicJWKSProvider, timeoutsConfiguration)
oauthHelperWithKubeStorage := oidc.FositeOauth2Helper(
oidc.NewKubeStorage(m.secretsClient, m.oidcClientsClient, timeoutsConfiguration),
issuer,
tokenHMACKeyGetter,
m.dynamicJWKSProvider,
timeoutsConfiguration,
)
var upstreamStateEncoder = dynamiccodec.New(
timeoutsConfiguration.UpstreamStateParamLifespan,
@@ -15,18 +15,18 @@ import (
"strings"
"testing"
"go.pinniped.dev/internal/secret"
"github.com/sclevine/spec"
"github.com/stretchr/testify/require"
"gopkg.in/square/go-jose.v2"
"k8s.io/client-go/kubernetes/fake"
supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake"
"go.pinniped.dev/internal/here"
"go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/oidc/discovery"
"go.pinniped.dev/internal/oidc/jwks"
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/secret"
"go.pinniped.dev/internal/testutil"
"go.pinniped.dev/internal/testutil/oidctestutil"
"go.pinniped.dev/pkg/oidcclient/nonce"
@@ -271,6 +271,7 @@ func TestManager(t *testing.T) {
kubeClient = fake.NewSimpleClientset()
secretsClient := kubeClient.CoreV1().Secrets("some-namespace")
oidcClientsClient := supervisorfake.NewSimpleClientset().ConfigV1alpha1().OIDCClients("some-namespace")
cache := secret.Cache{}
cache.SetCSRFCookieEncoderHashKey([]byte("fake-csrf-hash-secret"))
@@ -283,7 +284,7 @@ func TestManager(t *testing.T) {
cache.SetStateEncoderHashKey(issuer2, []byte("some-state-encoder-hash-key-2"))
cache.SetStateEncoderBlockKey(issuer2, []byte("16-bytes-STATE02"))
subject = NewManager(nextHandler, dynamicJWKSProvider, idpLister, &cache, secretsClient)
subject = NewManager(nextHandler, dynamicJWKSProvider, idpLister, &cache, secretsClient, oidcClientsClient)
})
when("given no providers via SetProviders()", func() {
+3 -1
View File
@@ -37,6 +37,7 @@ import (
"k8s.io/client-go/kubernetes/fake"
v1 "k8s.io/client-go/kubernetes/typed/core/v1"
supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake"
"go.pinniped.dev/internal/crud"
"go.pinniped.dev/internal/fositestorage/accesstoken"
"go.pinniped.dev/internal/fositestorage/authorizationcode"
@@ -3068,10 +3069,11 @@ func exchangeAuthcodeForTokens(t *testing.T, test authcodeExchangeInputs, idps p
client := fake.NewSimpleClientset()
secrets = client.CoreV1().Secrets("some-namespace")
oidcClientsClient := supervisorfake.NewSimpleClientset().ConfigV1alpha1().OIDCClients("some-namespace")
var oauthHelper fosite.OAuth2Provider
oauthStore = oidc.NewKubeStorage(secrets, oidc.DefaultOIDCTimeoutsConfiguration())
oauthStore = oidc.NewKubeStorage(secrets, oidcClientsClient, oidc.DefaultOIDCTimeoutsConfiguration())
if test.makeOathHelper != nil {
oauthHelper, authCode, jwtSigningKey = test.makeOathHelper(t, authRequest, oauthStore, test.customSessionData)
} else {
+4 -2
View File
@@ -14,6 +14,8 @@ import (
"github.com/ory/fosite/handler/oauth2"
"github.com/ory/fosite/handler/openid"
"github.com/pkg/errors"
"go.pinniped.dev/internal/oidc/clientregistry"
)
const (
@@ -142,8 +144,8 @@ func (t *TokenExchangeHandler) validateParams(params url.Values) (*stsParams, er
if strings.Contains(result.requestedAudience, ".pinniped.dev") {
return nil, fosite.ErrInvalidRequest.WithHintf("requested audience cannot contain '.pinniped.dev'")
}
if result.requestedAudience == "pinniped-cli" {
return nil, fosite.ErrInvalidRequest.WithHintf("requested audience cannot equal 'pinniped-cli'")
if result.requestedAudience == clientregistry.PinnipedCLIClientID {
return nil, fosite.ErrInvalidRequest.WithHintf("requested audience cannot equal '%s'", clientregistry.PinnipedCLIClientID)
}
return &result, nil
@@ -4,11 +4,14 @@
package oidcclientsecretstorage
import (
"context"
"encoding/base64"
"fmt"
"time"
v1 "k8s.io/api/core/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
@@ -26,6 +29,7 @@ const (
type OIDCClientSecretStorage struct {
storage crud.Storage
secrets corev1client.SecretInterface
}
// StoredClientSecret defines the format of the content of a client's secrets when stored in a Secret
@@ -39,12 +43,27 @@ type StoredClientSecret struct {
}
func New(secrets corev1client.SecretInterface, clock func() time.Time) *OIDCClientSecretStorage {
// TODO make lifetime = 0 mean that it does not get annotated with any garbage collection annotation
return &OIDCClientSecretStorage{storage: crud.New(TypeLabelValue, secrets, clock, 0)}
return &OIDCClientSecretStorage{
storage: crud.New(TypeLabelValue, secrets, clock, 0),
secrets: secrets,
}
}
// TODO expose other methods as needed for get, create, update, etc.
// GetStorageSecret gets the corev1.Secret which is used to store the client secrets for the given client.
// Returns nil,nil when the corev1.Secret was not found, as this is not an error for a client to not have any secrets yet.
func (s *OIDCClientSecretStorage) GetStorageSecret(ctx context.Context, oidcClientUID types.UID) (*corev1.Secret, error) {
secret, err := s.secrets.Get(ctx, s.GetName(oidcClientUID), metav1.GetOptions{})
if errors.IsNotFound(err) {
return nil, nil
}
if err != nil {
return nil, err
}
return secret, nil
}
// GetName returns the name of the Secret which would be used to store data for the given signature.
func (s *OIDCClientSecretStorage) GetName(oidcClientUID types.UID) string {
// Avoid having s.storage.GetName() base64 decode something that wasn't ever encoded by encoding it here.
@@ -53,7 +72,7 @@ func (s *OIDCClientSecretStorage) GetName(oidcClientUID types.UID) string {
}
// ReadFromSecret reads the contents of a Secret as a StoredClientSecret.
func ReadFromSecret(secret *v1.Secret) (*StoredClientSecret, error) {
func ReadFromSecret(secret *corev1.Secret) (*StoredClientSecret, error) {
storedClientSecret := &StoredClientSecret{}
err := crud.FromSecret(TypeLabelValue, secret, storedClientSecret)
if err != nil {
@@ -9,6 +9,8 @@ import (
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"go.pinniped.dev/internal/testutil"
)
func TestGetName(t *testing.T) {
@@ -106,6 +108,31 @@ func TestReadFromSecret(t *testing.T) {
},
wantErr: "secret storage data has incorrect version",
},
{
name: "OIDCClientSecretStorageSecretForUID() test helper generates readable format, to ensure that test helpers are kept up to date",
secret: testutil.OIDCClientSecretStorageSecretForUID(t,
"some-namespace", "some-uid", []string{"first-hash", "second-hash"},
),
wantStored: &StoredClientSecret{
Version: "1",
SecretHashes: []string{"first-hash", "second-hash"},
},
},
{
name: "OIDCClientSecretStorageSecretWithoutName() test helper generates readable format, to ensure that test helpers are kept up to date",
secret: testutil.OIDCClientSecretStorageSecretWithoutName(t,
"some-namespace", []string{"first-hash", "second-hash"},
),
wantStored: &StoredClientSecret{
Version: "1",
SecretHashes: []string{"first-hash", "second-hash"},
},
},
{
name: "OIDCClientSecretStorageSecretForUIDWithWrongVersion() test helper generates readable format, to ensure that test helpers are kept up to date",
secret: testutil.OIDCClientSecretStorageSecretForUIDWithWrongVersion(t, "some-namespace", "some-uid"),
wantErr: "OIDC client secret storage data has wrong version: OIDC client secret storage has version wrong-version instead of 1",
},
}
for _, tt := range tests {
+1
View File
@@ -439,6 +439,7 @@ func runSupervisor(ctx context.Context, podInfo *downward.PodInfo, cfg *supervis
dynamicUpstreamIDPProvider,
&secretCache,
clientWithoutLeaderElection.Kubernetes.CoreV1().Secrets(serverInstallationNamespace), // writes to kube storage are allowed for non-leaders
clientWithoutLeaderElection.PinnipedSupervisor.ConfigV1alpha1().OIDCClients(serverInstallationNamespace),
)
// Get the "real" name of the client secret supervisor API group (i.e., the API group name with the
@@ -0,0 +1,51 @@
// Copyright 2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package testutil
import (
"encoding/base32"
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func secretNameForUID(uid string) string {
// See GetName() in OIDCClientSecretStorage for how the production code determines the Secret name.
// This test helper is intended to choose the same name.
return "pinniped-storage-oidc-client-secret-" +
strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString([]byte(uid)))
}
func OIDCClientSecretStorageSecretWithoutName(t *testing.T, namespace string, hashes []string) *corev1.Secret {
hashesJSON, err := json.Marshal(hashes)
require.NoError(t, err) // this shouldn't really happen since we can always encode a slice of strings
return &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Namespace: namespace,
Labels: map[string]string{"storage.pinniped.dev/type": "oidc-client-secret"},
},
Type: "storage.pinniped.dev/oidc-client-secret",
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"version":"1","hashes":` + string(hashesJSON) + `}`),
"pinniped-storage-version": []byte("1"),
},
}
}
func OIDCClientSecretStorageSecretForUID(t *testing.T, namespace string, oidcClientUID string, hashes []string) *corev1.Secret {
secret := OIDCClientSecretStorageSecretWithoutName(t, namespace, hashes)
secret.Name = secretNameForUID(oidcClientUID)
return secret
}
func OIDCClientSecretStorageSecretForUIDWithWrongVersion(t *testing.T, namespace string, oidcClientUID string) *corev1.Secret {
secret := OIDCClientSecretStorageSecretForUID(t, namespace, oidcClientUID, []string{})
secret.Data["pinniped-storage-data"] = []byte(`{"version":"wrong-version","hashes":[]}`)
return secret
}