Merge pull request #249 from vmware-tanzu/token-endpoint

OIDC token endpoint supports authcode flow
This commit is contained in:
Ryan Richard 2020-12-07 15:08:07 -08:00 committed by GitHub
commit 6a90a10123
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 2822 additions and 198 deletions

View File

@ -24,7 +24,10 @@ type jwksObserverController struct {
}
type IssuerToJWKSMapSetter interface {
SetIssuerToJWKSMap(issuerToJWKSMap map[string]*jose.JSONWebKeySet)
SetIssuerToJWKSMap(
issuerToJWKSMap map[string]*jose.JSONWebKeySet,
issuerToActiveJWKMap map[string]*jose.JSONWebKey,
)
}
// Returns a controller which watches all of the OIDCProviders and their corresponding Secrets
@ -70,6 +73,7 @@ func (c *jwksObserverController) Sync(ctx controllerlib.Context) error {
// Rebuild the whole map on any change to any Secret or OIDCProvider, because either can have changes that
// can cause the map to need to be updated.
issuerToJWKSMap := map[string]*jose.JSONWebKeySet{}
issuerToActiveJWKMap := map[string]*jose.JSONWebKey{}
for _, provider := range allProviders {
secretRef := provider.Status.JWKSSecret
@ -78,17 +82,33 @@ func (c *jwksObserverController) Sync(ctx controllerlib.Context) error {
plog.Debug("jwksObserverController Sync could not find JWKS secret", "namespace", ns, "secretName", secretRef.Name)
continue
}
jwkFromSecret := jose.JSONWebKeySet{}
err = json.Unmarshal(jwksSecret.Data[jwksKey], &jwkFromSecret)
jwksFromSecret := jose.JSONWebKeySet{}
err = json.Unmarshal(jwksSecret.Data[jwksKey], &jwksFromSecret)
if err != nil {
plog.Debug("jwksObserverController Sync found a JWKS secret with Data in an unexpected format", "namespace", ns, "secretName", secretRef.Name)
continue
}
issuerToJWKSMap[provider.Spec.Issuer] = &jwkFromSecret
activeJWKFromSecret := jose.JSONWebKey{}
err = json.Unmarshal(jwksSecret.Data[activeJWKKey], &activeJWKFromSecret)
if err != nil {
plog.Debug("jwksObserverController Sync found an active JWK secret with Data in an unexpected format", "namespace", ns, "secretName", secretRef.Name)
continue
}
issuerToJWKSMap[provider.Spec.Issuer] = &jwksFromSecret
issuerToActiveJWKMap[provider.Spec.Issuer] = &activeJWKFromSecret
}
plog.Debug("jwksObserverController Sync updated the JWKS cache", "issuerCount", len(issuerToJWKSMap))
c.issuerToJWKSSetter.SetIssuerToJWKSMap(issuerToJWKSMap)
plog.Debug(
"jwksObserverController Sync updated the JWKS cache",
"issuerJWKSCount",
len(issuerToJWKSMap),
"issuerActiveJWKCount",
len(issuerToActiveJWKMap),
)
c.issuerToJWKSSetter.SetIssuerToJWKSMap(issuerToJWKSMap, issuerToActiveJWKMap)
return nil
}

View File

@ -96,13 +96,18 @@ func TestJWKSObserverControllerInformerFilters(t *testing.T) {
}
type fakeIssuerToJWKSMapSetter struct {
setIssuerToJWKSMapWasCalled bool
issuerToJWKSMapReceived map[string]*jose.JSONWebKeySet
setIssuerToJWKSMapWasCalled bool
issuerToJWKSMapReceived map[string]*jose.JSONWebKeySet
issuerToActiveJWKMapReceived map[string]*jose.JSONWebKey
}
func (f *fakeIssuerToJWKSMapSetter) SetIssuerToJWKSMap(issuerToJWKSMap map[string]*jose.JSONWebKeySet) {
func (f *fakeIssuerToJWKSMapSetter) SetIssuerToJWKSMap(
issuerToJWKSMap map[string]*jose.JSONWebKeySet,
issuerToActiveJWKMap map[string]*jose.JSONWebKey,
) {
f.setIssuerToJWKSMapWasCalled = true
f.issuerToJWKSMapReceived = issuerToJWKSMap
f.issuerToActiveJWKMapReceived = issuerToActiveJWKMap
}
func TestJWKSObserverControllerSync(t *testing.T) {
@ -181,6 +186,7 @@ func TestJWKSObserverControllerSync(t *testing.T) {
r.True(issuerToJWKSSetter.setIssuerToJWKSMapWasCalled)
r.Empty(issuerToJWKSSetter.issuerToJWKSMapReceived)
r.Empty(issuerToJWKSSetter.issuerToActiveJWKMapReceived)
})
})
@ -212,10 +218,30 @@ func TestJWKSObserverControllerSync(t *testing.T) {
Namespace: installedInNamespace,
},
Spec: v1alpha1.OIDCProviderSpec{Issuer: "https://bad-secret-issuer.com"},
Status: v1alpha1.OIDCProviderStatus{
JWKSSecret: corev1.LocalObjectReference{Name: "bad-secret-name"},
},
}
oidcProviderWithBadJWKSSecret := &v1alpha1.OIDCProvider{
ObjectMeta: metav1.ObjectMeta{
Name: "bad-jwks-secret-oidcprovider",
Namespace: installedInNamespace,
},
Spec: v1alpha1.OIDCProviderSpec{Issuer: "https://bad-jwks-secret-issuer.com"},
Status: v1alpha1.OIDCProviderStatus{
JWKSSecret: corev1.LocalObjectReference{Name: "bad-jwks-secret-name"},
},
}
oidcProviderWithBadActiveJWKSecret := &v1alpha1.OIDCProvider{
ObjectMeta: metav1.ObjectMeta{
Name: "bad-active-jwk-secret-oidcprovider",
Namespace: installedInNamespace,
},
Spec: v1alpha1.OIDCProviderSpec{Issuer: "https://bad-active-jwk-secret-issuer.com"},
Status: v1alpha1.OIDCProviderStatus{
JWKSSecret: corev1.LocalObjectReference{Name: "bad-active-jwk-secret-name"},
},
}
oidcProviderWithGoodSecret1 := &v1alpha1.OIDCProvider{
ObjectMeta: metav1.ObjectMeta{
Name: "good-secret-oidcprovider1",
@ -260,24 +286,48 @@ func TestJWKSObserverControllerSync(t *testing.T) {
"jwks": []byte(`{"keys": [` + expectedJWK2 + `]}`),
},
}
badSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "bad-secret-name",
Namespace: installedInNamespace,
},
Data: map[string][]byte{"junk": nil},
}
badJWKSSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "bad-jwks-secret-name",
Namespace: installedInNamespace,
},
Data: map[string][]byte{"junk": nil},
Data: map[string][]byte{
"activeJWK": []byte(expectedJWK2),
"jwks": []byte("bad"),
},
}
badActiveJWKSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "bad-active-jwk-secret-name",
Namespace: installedInNamespace,
},
Data: map[string][]byte{
"activeJWK": []byte("bad"),
"jwks": []byte(`{"keys": [` + expectedJWK2 + `]}`),
},
}
r.NoError(pinnipedInformerClient.Tracker().Add(oidcProviderWithoutSecret1))
r.NoError(pinnipedInformerClient.Tracker().Add(oidcProviderWithoutSecret2))
r.NoError(pinnipedInformerClient.Tracker().Add(oidcProviderWithBadSecret))
r.NoError(pinnipedInformerClient.Tracker().Add(oidcProviderWithBadJWKSSecret))
r.NoError(pinnipedInformerClient.Tracker().Add(oidcProviderWithBadActiveJWKSecret))
r.NoError(pinnipedInformerClient.Tracker().Add(oidcProviderWithGoodSecret1))
r.NoError(pinnipedInformerClient.Tracker().Add(oidcProviderWithGoodSecret2))
r.NoError(kubeInformerClient.Tracker().Add(goodJWKSSecret1))
r.NoError(kubeInformerClient.Tracker().Add(goodJWKSSecret2))
r.NoError(kubeInformerClient.Tracker().Add(badSecret))
r.NoError(kubeInformerClient.Tracker().Add(badJWKSSecret))
r.NoError(kubeInformerClient.Tracker().Add(badActiveJWKSecret))
})
requireJWKJSON := func(expectedJWKJSON string, actualJWKS *jose.JSONWebKeySet) {
requireJWKSJSON := func(expectedJWKJSON string, actualJWKS *jose.JSONWebKeySet) {
r.NotNil(actualJWKS)
r.Len(actualJWKS.Keys, 1)
actualJWK := actualJWKS.Keys[0]
@ -286,16 +336,26 @@ func TestJWKSObserverControllerSync(t *testing.T) {
r.JSONEq(expectedJWKJSON, string(actualJWKJSON))
}
requireJWKJSON := func(expectedJWKJSON string, actualJWK *jose.JSONWebKey) {
r.NotNil(actualJWK)
actualJWKJSON, err := json.Marshal(actualJWK)
r.NoError(err)
r.JSONEq(expectedJWKJSON, string(actualJWKJSON))
}
it("updates the issuerToJWKSSetter's map to include only the issuers that had valid JWKS", func() {
startInformersAndController()
r.NoError(controllerlib.TestSync(t, subject, *syncContext))
r.True(issuerToJWKSSetter.setIssuerToJWKSMapWasCalled)
r.Len(issuerToJWKSSetter.issuerToJWKSMapReceived, 2)
r.Len(issuerToJWKSSetter.issuerToActiveJWKMapReceived, 2)
// the actual JWK should match the one from the test fixture that was put into the secret
requireJWKJSON(expectedJWK1, issuerToJWKSSetter.issuerToJWKSMapReceived["https://issuer-with-good-secret1.com"])
requireJWKJSON(expectedJWK2, issuerToJWKSSetter.issuerToJWKSMapReceived["https://issuer-with-good-secret2.com"])
requireJWKSJSON(expectedJWK1, issuerToJWKSSetter.issuerToJWKSMapReceived["https://issuer-with-good-secret1.com"])
requireJWKJSON(expectedJWK1, issuerToJWKSSetter.issuerToActiveJWKMapReceived["https://issuer-with-good-secret1.com"])
requireJWKSJSON(expectedJWK2, issuerToJWKSSetter.issuerToJWKSMapReceived["https://issuer-with-good-secret2.com"])
requireJWKJSON(expectedJWK2, issuerToJWKSSetter.issuerToActiveJWKMapReceived["https://issuer-with-good-secret2.com"])
})
})
}, spec.Parallel(), spec.Report(report.Terminal{}))

View File

@ -14,6 +14,7 @@ import (
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
"go.pinniped.dev/internal/constable"
@ -21,8 +22,9 @@ import (
//nolint:gosec // ignore lint warnings that these are credentials
const (
SecretLabelKey = "storage.pinniped.dev/type"
secretNameFormat = "pinniped-storage-%s-%s"
secretLabelKey = "storage.pinniped.dev"
secretTypeFormat = "storage.pinniped.dev/%s"
secretVersion = "1"
secretDataKey = "pinniped-storage-data"
@ -34,10 +36,11 @@ const (
)
type Storage interface {
Create(ctx context.Context, signature string, data JSON) (resourceVersion string, err error)
Create(ctx context.Context, signature string, data JSON, additionalLabels map[string]string) (resourceVersion string, err error)
Get(ctx context.Context, signature string, data JSON) (resourceVersion string, err error)
Update(ctx context.Context, signature, resourceVersion string, data JSON) (newResourceVersion string, err error)
Delete(ctx context.Context, signature string) error
DeleteByLabel(ctx context.Context, labelName string, labelValue string) error
}
type JSON interface{} // document that we need valid JSON types
@ -58,8 +61,8 @@ type secretsStorage struct {
secrets corev1client.SecretInterface
}
func (s *secretsStorage) Create(ctx context.Context, signature string, data JSON) (string, error) {
secret, err := s.toSecret(signature, "", data)
func (s *secretsStorage) Create(ctx context.Context, signature string, data JSON, additionalLabels map[string]string) (string, error) {
secret, err := s.toSecret(signature, "", data, additionalLabels)
if err != nil {
return "", err
}
@ -88,7 +91,7 @@ func (s *secretsStorage) validateSecret(secret *corev1.Secret) error {
if secret.Type != s.secretType {
return fmt.Errorf("%w: %s must equal %s", ErrSecretTypeMismatch, secret.Type, s.secretType)
}
if labelResource := secret.Labels[secretLabelKey]; labelResource != s.resource {
if labelResource := secret.Labels[SecretLabelKey]; labelResource != s.resource {
return fmt.Errorf("%w: %s must equal %s", ErrSecretLabelMismatch, labelResource, s.resource)
}
if !bytes.Equal(secret.Data[secretVersionKey], s.secretVersion) {
@ -98,7 +101,7 @@ func (s *secretsStorage) validateSecret(secret *corev1.Secret) error {
}
func (s *secretsStorage) Update(ctx context.Context, signature, resourceVersion string, data JSON) (string, error) {
secret, err := s.toSecret(signature, resourceVersion, data)
secret, err := s.toSecret(signature, resourceVersion, data, nil)
if err != nil {
return "", err
}
@ -116,6 +119,28 @@ func (s *secretsStorage) Delete(ctx context.Context, signature string) error {
return nil
}
func (s *secretsStorage) DeleteByLabel(ctx context.Context, labelName string, labelValue string) error {
list, err := s.secrets.List(ctx, metav1.ListOptions{
LabelSelector: labels.Set{
SecretLabelKey: s.resource,
labelName: labelValue,
}.String(),
})
if err != nil {
//nolint:err113 // there's nothing wrong with this error
return fmt.Errorf(`failed to list secrets for resource "%s" matching label "%s=%s": %w`, s.resource, labelName, labelValue, err)
}
// TODO try to delete all of the items and consolidate all of the errors and return them all
for _, secret := range list.Items {
err = s.secrets.Delete(ctx, secret.Name, metav1.DeleteOptions{})
if err != nil {
//nolint:err113 // there's nothing wrong with this error
return fmt.Errorf(`failed to delete secrets for resource "%s" matching label "%s=%s" with name %s: %w`, s.resource, labelName, labelValue, secret.Name, err)
}
}
return nil
}
//nolint: gochecknoglobals
var b32 = base32.StdEncoding.WithPadding(base32.NoPadding)
@ -127,18 +152,24 @@ func (s *secretsStorage) getName(signature string) string {
return fmt.Sprintf(secretNameFormat, s.resource, signatureAsValidName)
}
func (s *secretsStorage) toSecret(signature, resourceVersion string, data JSON) (*corev1.Secret, error) {
func (s *secretsStorage) toSecret(signature, resourceVersion string, data JSON, additionalLabels map[string]string) (*corev1.Secret, error) {
buf, err := json.Marshal(data)
if err != nil {
return nil, fmt.Errorf("failed to encode secret data for %s: %w", s.getName(signature), err)
}
labels := map[string]string{
SecretLabelKey: s.resource, // make it easier to find this stuff via kubectl
}
for labelName, labelValue := range additionalLabels {
labels[labelName] = labelValue
}
return &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: s.getName(signature),
ResourceVersion: resourceVersion,
Labels: map[string]string{
secretLabelKey: s.resource, // make it easier to find this stuff via kubectl
},
Labels: labels,
OwnerReferences: nil,
},
Data: map[string][]byte{

View File

@ -6,6 +6,7 @@ package crud
import (
"context"
"errors"
"fmt"
"testing"
"github.com/ory/fosite/compose"
@ -87,6 +88,7 @@ func TestStorage(t *testing.T) {
wantSecrets: nil,
wantErr: `failed to delete tokens for signature not-a-token: secrets "pinniped-storage-tokens-t2fx427lnci6s" not found`,
},
// TODO make a delete non-existent test for DeleteByLabel
{
name: "create and get",
resource: "access-tokens",
@ -97,7 +99,7 @@ func TestStorage(t *testing.T) {
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)
rv1, err := storage.Create(ctx, signature, data, nil)
require.Empty(t, rv1) // fake client does not set this
require.NoError(t, err)
@ -115,7 +117,7 @@ func TestStorage(t *testing.T) {
Name: "pinniped-storage-access-tokens-i6mhp4azwdxshgsy3s2mvedxpxuh3nudh3ot3m4xamlugj4e6qoq",
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev": "access-tokens",
"storage.pinniped.dev/type": "access-tokens",
},
},
Data: map[string][]byte{
@ -133,7 +135,69 @@ func TestStorage(t *testing.T) {
Namespace: namespace,
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev": "access-tokens",
"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: "",
},
{
name: "create and get with additional labels",
resource: "access-tokens",
mocks: nil,
run: func(t *testing.T, storage Storage) 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, map[string]string{"label1": "value1", "label2": "value2"})
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: "",
Labels: map[string]string{
"storage.pinniped.dev/type": "access-tokens",
"label1": "value1",
"label2": "value2",
},
},
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: "",
Labels: map[string]string{
"storage.pinniped.dev/type": "access-tokens",
"label1": "value1",
"label2": "value2",
},
},
Data: map[string][]byte{
@ -155,7 +219,7 @@ func TestStorage(t *testing.T) {
Namespace: namespace,
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev": "pandas-are-best",
"storage.pinniped.dev/type": "pandas-are-best",
},
},
Data: map[string][]byte{
@ -190,7 +254,7 @@ func TestStorage(t *testing.T) {
Namespace: namespace,
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev": "pandas-are-best",
"storage.pinniped.dev/type": "pandas-are-best",
},
},
Data: map[string][]byte{
@ -212,7 +276,7 @@ func TestStorage(t *testing.T) {
Namespace: namespace,
ResourceVersion: "35",
Labels: map[string]string{
"storage.pinniped.dev": "stores",
"storage.pinniped.dev/type": "stores",
},
},
Data: map[string][]byte{
@ -261,7 +325,7 @@ func TestStorage(t *testing.T) {
Name: "pinniped-storage-stores-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba",
ResourceVersion: "35", // update at initial RV
Labels: map[string]string{
"storage.pinniped.dev": "stores",
"storage.pinniped.dev/type": "stores",
},
},
Data: map[string][]byte{
@ -279,7 +343,7 @@ func TestStorage(t *testing.T) {
Namespace: namespace,
ResourceVersion: "45", // final list at new RV
Labels: map[string]string{
"storage.pinniped.dev": "stores",
"storage.pinniped.dev/type": "stores",
},
},
Data: map[string][]byte{
@ -301,7 +365,7 @@ func TestStorage(t *testing.T) {
Namespace: namespace,
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev": "seals",
"storage.pinniped.dev/type": "seals",
},
},
Data: map[string][]byte{
@ -325,6 +389,207 @@ func TestStorage(t *testing.T) {
wantSecrets: nil,
wantErr: "",
},
{
name: "delete existing by label",
resource: "seals",
mocks: func(t *testing.T, mock mocker) {
require.NoError(t, mock.Tracker().Add(&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "pinniped-storage-seals-lvzgyywdc2dhjdbgf5jvzfyphosigvhnsh6qlse3blumogoqhqhq",
Namespace: namespace,
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev/type": "seals",
"additionalLabel": "matching-value",
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"Data":"sad-seal"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/seals",
}))
require.NoError(t, mock.Tracker().Add(&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "pinniped-storage-seals-abcdywdc2dhjdbgf5jvzfyphosigvhnsh6qlse3blumogoqhqhq",
Namespace: namespace,
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev/type": "seals",
"additionalLabel": "matching-value",
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"Data":"happy-seal"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/seals",
}))
require.NoError(t, mock.Tracker().Add(&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "pinniped-storage-seals-12345wdc2dhjdbgf5jvzfyphosigvhnsh6qlse3blumogoqhqhq",
Namespace: namespace,
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev/type": "seals", // same type as above
"additionalLabel": "non-matching-value", // different value for the same label
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"Data":"sad-seal2"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/seals",
}))
require.NoError(t, mock.Tracker().Add(&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "pinniped-storage-seals-54321wdc2dhjdbgf5jvzfyphosigvhnsh6qlse3blumogoqhqhq",
Namespace: namespace,
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev/type": "walruses", // different type from above
"additionalLabel": "matching-value", // same value for the same label as above
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"Data":"sad-seal3"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/walruses",
}))
},
run: func(t *testing.T, storage Storage) error {
return storage.DeleteByLabel(ctx, "additionalLabel", "matching-value")
},
wantActions: []coretesting.Action{
coretesting.NewListAction(secretsGVR, schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"}, namespace, metav1.ListOptions{
LabelSelector: "storage.pinniped.dev/type=seals,additionalLabel=matching-value",
}),
coretesting.NewDeleteAction(secretsGVR, namespace, "pinniped-storage-seals-abcdywdc2dhjdbgf5jvzfyphosigvhnsh6qlse3blumogoqhqhq"),
coretesting.NewDeleteAction(secretsGVR, namespace, "pinniped-storage-seals-lvzgyywdc2dhjdbgf5jvzfyphosigvhnsh6qlse3blumogoqhqhq"),
},
wantSecrets: []corev1.Secret{
// the secret of the same type whose label did not match is not deleted
{
ObjectMeta: metav1.ObjectMeta{
Name: "pinniped-storage-seals-12345wdc2dhjdbgf5jvzfyphosigvhnsh6qlse3blumogoqhqhq",
Namespace: namespace,
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev/type": "seals", // same type as above
"additionalLabel": "non-matching-value", // different value for the same label
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"Data":"sad-seal2"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/seals",
},
// the secrets of other types are not deleted, even if they have a matching label
{
ObjectMeta: metav1.ObjectMeta{
Name: "pinniped-storage-seals-54321wdc2dhjdbgf5jvzfyphosigvhnsh6qlse3blumogoqhqhq",
Namespace: namespace,
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev/type": "walruses", // different type from above
"additionalLabel": "matching-value", // same value for the same label as above
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"Data":"sad-seal3"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/walruses",
},
},
wantErr: "",
},
{
name: "when there is an error performing the delete while deleting by label",
resource: "seals",
mocks: func(t *testing.T, mock mocker) {
require.NoError(t, mock.Tracker().Add(&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "pinniped-storage-seals-lvzgyywdc2dhjdbgf5jvzfyphosigvhnsh6qlse3blumogoqhqhq",
Namespace: namespace,
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev/type": "seals",
"additionalLabel": "matching-value",
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"Data":"sad-seal"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/seals",
}))
mock.PrependReactor("delete", "secrets", func(action coretesting.Action) (handled bool, ret runtime.Object, err error) {
return true, nil, fmt.Errorf("some delete error")
})
},
run: func(t *testing.T, storage Storage) error {
return storage.DeleteByLabel(ctx, "additionalLabel", "matching-value")
},
wantActions: []coretesting.Action{
coretesting.NewListAction(secretsGVR, schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"}, namespace, metav1.ListOptions{
LabelSelector: "storage.pinniped.dev/type=seals,additionalLabel=matching-value",
}),
coretesting.NewDeleteAction(secretsGVR, namespace, "pinniped-storage-seals-lvzgyywdc2dhjdbgf5jvzfyphosigvhnsh6qlse3blumogoqhqhq"),
},
wantSecrets: []corev1.Secret{
{
ObjectMeta: metav1.ObjectMeta{
Name: "pinniped-storage-seals-lvzgyywdc2dhjdbgf5jvzfyphosigvhnsh6qlse3blumogoqhqhq",
Namespace: namespace,
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev/type": "seals",
"additionalLabel": "matching-value",
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"Data":"sad-seal"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/seals",
},
},
wantErr: `failed to delete secrets for resource "seals" matching label "additionalLabel=matching-value" with name pinniped-storage-seals-lvzgyywdc2dhjdbgf5jvzfyphosigvhnsh6qlse3blumogoqhqhq: some delete error`,
},
{
name: "when there is an error listing secrets during a delete by label operation",
resource: "seals",
mocks: func(t *testing.T, mock mocker) {
mock.PrependReactor("list", "secrets", func(action coretesting.Action) (handled bool, ret runtime.Object, err error) {
listAction := action.(coretesting.ListActionImpl)
labelRestrictions := listAction.GetListRestrictions().Labels
requiresExactMatch, found := labelRestrictions.RequiresExactMatch("additionalLabel")
if !found || requiresExactMatch != "matching-value" {
// this list action did not use label selector additionalLabel=matching-value, so allow it to proceed without intervention
return false, nil, nil
}
requiresExactMatch, found = labelRestrictions.RequiresExactMatch("storage.pinniped.dev/type")
if !found || requiresExactMatch != "seals" {
// this list action did not use label selector storage.pinniped.dev/type=seals, so allow it to proceed without intervention
return false, nil, nil
}
// this list action was the one that did use the expected label selectors so cause it to error
return true, nil, fmt.Errorf("some listing error")
})
},
run: func(t *testing.T, storage Storage) error {
return storage.DeleteByLabel(ctx, "additionalLabel", "matching-value")
},
wantActions: []coretesting.Action{
coretesting.NewListAction(secretsGVR, schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"}, namespace, metav1.ListOptions{
LabelSelector: "storage.pinniped.dev/type=seals,additionalLabel=matching-value",
}),
},
wantErr: `failed to list secrets for resource "seals" matching label "additionalLabel=matching-value": some listing error`,
},
{
name: "invalid exiting secret type",
resource: "candies",
@ -335,7 +600,7 @@ func TestStorage(t *testing.T) {
Namespace: namespace,
ResourceVersion: "55",
Labels: map[string]string{
"storage.pinniped.dev": "candies",
"storage.pinniped.dev/type": "candies",
},
},
Data: map[string][]byte{
@ -370,7 +635,7 @@ func TestStorage(t *testing.T) {
Namespace: namespace,
ResourceVersion: "55",
Labels: map[string]string{
"storage.pinniped.dev": "candies",
"storage.pinniped.dev/type": "candies",
},
},
Data: map[string][]byte{
@ -392,7 +657,7 @@ func TestStorage(t *testing.T) {
Namespace: namespace,
ResourceVersion: "55",
Labels: map[string]string{
"storage.pinniped.dev": "candies-are-bad",
"storage.pinniped.dev/type": "candies-are-bad",
},
},
Data: map[string][]byte{
@ -427,7 +692,7 @@ func TestStorage(t *testing.T) {
Namespace: namespace,
ResourceVersion: "55",
Labels: map[string]string{
"storage.pinniped.dev": "candies-are-bad",
"storage.pinniped.dev/type": "candies-are-bad",
},
},
Data: map[string][]byte{
@ -449,7 +714,7 @@ func TestStorage(t *testing.T) {
Namespace: namespace,
ResourceVersion: "55",
Labels: map[string]string{
"storage.pinniped.dev": "candies",
"storage.pinniped.dev/type": "candies",
},
},
Data: map[string][]byte{
@ -484,7 +749,7 @@ func TestStorage(t *testing.T) {
Namespace: namespace,
ResourceVersion: "55",
Labels: map[string]string{
"storage.pinniped.dev": "candies",
"storage.pinniped.dev/type": "candies",
},
},
Data: map[string][]byte{
@ -506,7 +771,7 @@ func TestStorage(t *testing.T) {
Namespace: namespace,
ResourceVersion: "55",
Labels: map[string]string{
"storage.pinniped.dev": "candies",
"storage.pinniped.dev/type": "candies",
},
},
Data: map[string][]byte{
@ -540,7 +805,7 @@ func TestStorage(t *testing.T) {
Namespace: namespace,
ResourceVersion: "55",
Labels: map[string]string{
"storage.pinniped.dev": "candies",
"storage.pinniped.dev/type": "candies",
},
},
Data: map[string][]byte{
@ -582,8 +847,11 @@ func checkSecretActionNames(t *testing.T, actions []coretesting.Action) {
t.Helper()
for _, action := range actions {
name := getName(t, action)
assertValidName(t, name)
_, ok := action.(coretesting.ListActionImpl)
if !ok { // list action don't have names, so skip these assertions for list actions
name := getName(t, action)
assertValidName(t, name)
}
}
}

View File

@ -0,0 +1,114 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package accesstoken
import (
"context"
"fmt"
"github.com/ory/fosite"
"github.com/ory/fosite/handler/oauth2"
"github.com/ory/fosite/handler/openid"
"k8s.io/apimachinery/pkg/api/errors"
corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
"go.pinniped.dev/internal/constable"
"go.pinniped.dev/internal/crud"
"go.pinniped.dev/internal/fositestorage"
)
const (
TypeLabelValue = "access-token"
ErrInvalidAccessTokenRequestVersion = constable.Error("access token request data has wrong version")
ErrInvalidAccessTokenRequestData = constable.Error("access token request data must be present")
accessTokenStorageVersion = "1"
)
type RevocationStorage interface {
oauth2.AccessTokenStorage
RevokeAccessToken(ctx context.Context, requestID string) error
}
var _ RevocationStorage = &accessTokenStorage{}
type accessTokenStorage struct {
storage crud.Storage
}
type session struct {
Request *fosite.Request `json:"request"`
Version string `json:"version"`
}
func New(secrets corev1client.SecretInterface) RevocationStorage {
return &accessTokenStorage{storage: crud.New(TypeLabelValue, secrets)}
}
func (a *accessTokenStorage) RevokeAccessToken(ctx context.Context, requestID string) error {
return a.storage.DeleteByLabel(ctx, fositestorage.StorageRequestIDLabelName, requestID)
}
func (a *accessTokenStorage) CreateAccessTokenSession(ctx context.Context, signature string, requester fosite.Requester) error {
request, err := fositestorage.ValidateAndExtractAuthorizeRequest(requester)
if err != nil {
return err
}
_, err = a.storage.Create(
ctx,
signature,
&session{Request: request, Version: accessTokenStorageVersion},
map[string]string{fositestorage.StorageRequestIDLabelName: requester.GetID()},
)
return err
}
func (a *accessTokenStorage) GetAccessTokenSession(ctx context.Context, signature string, _ fosite.Session) (fosite.Requester, error) {
session, _, err := a.getSession(ctx, signature)
if err != nil {
return nil, err
}
return session.Request, err
}
func (a *accessTokenStorage) DeleteAccessTokenSession(ctx context.Context, signature string) error {
return a.storage.Delete(ctx, signature)
}
func (a *accessTokenStorage) getSession(ctx context.Context, signature string) (*session, string, error) {
session := newValidEmptyAccessTokenSession()
rv, err := a.storage.Get(ctx, signature, session)
if errors.IsNotFound(err) {
return nil, "", fosite.ErrNotFound.WithCause(err).WithDebug(err.Error())
}
if err != nil {
return nil, "", fmt.Errorf("failed to get access token session for %s: %w", signature, err)
}
if version := session.Version; version != accessTokenStorageVersion {
return nil, "", fmt.Errorf("%w: access token session for %s has version %s instead of %s",
ErrInvalidAccessTokenRequestVersion, signature, version, accessTokenStorageVersion)
}
if session.Request.ID == "" {
return nil, "", fmt.Errorf("malformed access token session for %s: %w", signature, ErrInvalidAccessTokenRequestData)
}
return session, rv, nil
}
func newValidEmptyAccessTokenSession() *session {
return &session{
Request: &fosite.Request{
Client: &fosite.DefaultOpenIDConnectClient{},
Session: &openid.DefaultSession{},
},
}
}

View File

@ -0,0 +1,282 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package accesstoken
import (
"context"
"net/url"
"testing"
"time"
"github.com/ory/fosite"
"github.com/ory/fosite/handler/openid"
"github.com/pkg/errors"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/kubernetes/fake"
coretesting "k8s.io/client-go/testing"
)
const namespace = "test-ns"
var secretsGVR = schema.GroupVersionResource{
Group: "",
Version: "v1",
Resource: "secrets",
}
func TestAccessTokenStorage(t *testing.T) {
ctx := context.Background()
wantActions := []coretesting.Action{
coretesting.NewCreateAction(secretsGVR, namespace, &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "pinniped-storage-access-token-pwu5zs7lekbhnln2w4",
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev/type": "access-token",
"storage.pinniped.dev/request-id": "abcd-1",
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"requestedAudience":null,"grantedAudience":null},"version":"1"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/access-token",
}),
coretesting.NewGetAction(secretsGVR, namespace, "pinniped-storage-access-token-pwu5zs7lekbhnln2w4"),
coretesting.NewDeleteAction(secretsGVR, namespace, "pinniped-storage-access-token-pwu5zs7lekbhnln2w4"),
}
client := fake.NewSimpleClientset()
secrets := client.CoreV1().Secrets(namespace)
storage := New(secrets)
request := &fosite.Request{
ID: "abcd-1",
RequestedAt: time.Time{},
Client: &fosite.DefaultOpenIDConnectClient{
DefaultClient: &fosite.DefaultClient{
ID: "pinny",
Secret: nil,
RedirectURIs: nil,
GrantTypes: nil,
ResponseTypes: nil,
Scopes: nil,
Audience: nil,
Public: true,
},
JSONWebKeysURI: "where",
JSONWebKeys: nil,
TokenEndpointAuthMethod: "something",
RequestURIs: nil,
RequestObjectSigningAlgorithm: "",
TokenEndpointAuthSigningAlgorithm: "",
},
RequestedScope: nil,
GrantedScope: nil,
Form: url.Values{"key": []string{"val"}},
Session: &openid.DefaultSession{
Claims: nil,
Headers: nil,
ExpiresAt: nil,
Username: "snorlax",
Subject: "panda",
},
RequestedAudience: nil,
GrantedAudience: nil,
}
err := storage.CreateAccessTokenSession(ctx, "fancy-signature", request)
require.NoError(t, err)
newRequest, err := storage.GetAccessTokenSession(ctx, "fancy-signature", nil)
require.NoError(t, err)
require.Equal(t, request, newRequest)
err = storage.DeleteAccessTokenSession(ctx, "fancy-signature")
require.NoError(t, err)
require.Equal(t, wantActions, client.Actions())
}
func TestAccessTokenStorageRevocation(t *testing.T) {
ctx := context.Background()
wantActions := []coretesting.Action{
coretesting.NewCreateAction(secretsGVR, namespace, &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "pinniped-storage-access-token-pwu5zs7lekbhnln2w4",
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev/type": "access-token",
"storage.pinniped.dev/request-id": "abcd-1",
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"requestedAudience":null,"grantedAudience":null},"version":"1"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/access-token",
}),
coretesting.NewListAction(secretsGVR, schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"}, namespace, metav1.ListOptions{
LabelSelector: "storage.pinniped.dev/type=access-token,storage.pinniped.dev/request-id=abcd-1",
}),
coretesting.NewDeleteAction(secretsGVR, namespace, "pinniped-storage-access-token-pwu5zs7lekbhnln2w4"),
}
client := fake.NewSimpleClientset()
secrets := client.CoreV1().Secrets(namespace)
storage := New(secrets)
request := &fosite.Request{
ID: "abcd-1",
RequestedAt: time.Time{},
Client: &fosite.DefaultOpenIDConnectClient{
DefaultClient: &fosite.DefaultClient{
ID: "pinny",
Public: true,
},
JSONWebKeysURI: "where",
TokenEndpointAuthMethod: "something",
},
Form: url.Values{"key": []string{"val"}},
Session: &openid.DefaultSession{
Username: "snorlax",
Subject: "panda",
},
}
err := storage.CreateAccessTokenSession(ctx, "fancy-signature", request)
require.NoError(t, err)
// Revoke the request ID of the session that we just created
err = storage.RevokeAccessToken(ctx, "abcd-1")
require.NoError(t, err)
require.Equal(t, wantActions, client.Actions())
}
func TestGetNotFound(t *testing.T) {
ctx := context.Background()
client := fake.NewSimpleClientset()
secrets := client.CoreV1().Secrets(namespace)
storage := New(secrets)
_, notFoundErr := storage.GetAccessTokenSession(ctx, "non-existent-signature", nil)
require.EqualError(t, notFoundErr, "not_found")
require.True(t, errors.Is(notFoundErr, fosite.ErrNotFound))
}
func TestWrongVersion(t *testing.T) {
ctx := context.Background()
client := fake.NewSimpleClientset()
secrets := client.CoreV1().Secrets(namespace)
storage := New(secrets)
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "pinniped-storage-access-token-pwu5zs7lekbhnln2w4",
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev/type": "access-token",
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"requestedAudience":null,"grantedAudience":null},"version":"not-the-right-version"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/access-token",
}
_, err := secrets.Create(ctx, secret, metav1.CreateOptions{})
require.NoError(t, err)
_, err = storage.GetAccessTokenSession(ctx, "fancy-signature", nil)
require.EqualError(t, err, "access token request data has wrong version: access token session for fancy-signature has version not-the-right-version instead of 1")
}
func TestNilSessionRequest(t *testing.T) {
ctx := context.Background()
client := fake.NewSimpleClientset()
secrets := client.CoreV1().Secrets(namespace)
storage := New(secrets)
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "pinniped-storage-access-token-pwu5zs7lekbhnln2w4",
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev/type": "access-token",
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"1"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/access-token",
}
_, err := secrets.Create(ctx, secret, metav1.CreateOptions{})
require.NoError(t, err)
_, err = storage.GetAccessTokenSession(ctx, "fancy-signature", nil)
require.EqualError(t, err, "malformed access token session for fancy-signature: access token request data must be present")
}
func TestCreateWithNilRequester(t *testing.T) {
ctx := context.Background()
client := fake.NewSimpleClientset()
secrets := client.CoreV1().Secrets(namespace)
storage := New(secrets)
err := storage.CreateAccessTokenSession(ctx, "signature-doesnt-matter", nil)
require.EqualError(t, err, "requester must be of type fosite.Request")
}
func TestCreateWithWrongRequesterDataTypes(t *testing.T) {
ctx := context.Background()
client := fake.NewSimpleClientset()
secrets := client.CoreV1().Secrets(namespace)
storage := New(secrets)
request := &fosite.Request{
Session: nil,
Client: &fosite.DefaultOpenIDConnectClient{},
}
err := storage.CreateAccessTokenSession(ctx, "signature-doesnt-matter", request)
require.EqualError(t, err, "requester's session must be of type openid.DefaultSession")
request = &fosite.Request{
Session: &openid.DefaultSession{},
Client: nil,
}
err = storage.CreateAccessTokenSession(ctx, "signature-doesnt-matter", request)
require.EqualError(t, err, "requester's client must be of type fosite.DefaultOpenIDConnectClient")
}
func TestCreateWithoutRequesterID(t *testing.T) {
ctx := context.Background()
client := fake.NewSimpleClientset()
secrets := client.CoreV1().Secrets(namespace)
storage := New(secrets)
request := &fosite.Request{
ID: "", // empty ID
Session: &openid.DefaultSession{},
Client: &fosite.DefaultOpenIDConnectClient{},
}
err := storage.CreateAccessTokenSession(ctx, "signature-doesnt-matter", request)
require.NoError(t, err)
// the blank ID was filled in with an auto-generated ID
require.NotEmpty(t, request.ID)
require.Len(t, client.Actions(), 1)
actualAction := client.Actions()[0].(coretesting.CreateActionImpl)
actualSecret := actualAction.GetObject().(*corev1.Secret)
// The generated secret was labeled with that auto-generated request ID
require.Equal(t, request.ID, actualSecret.Labels["storage.pinniped.dev/request-id"])
}

View File

@ -20,6 +20,8 @@ import (
)
const (
TypeLabelValue = "authcode"
ErrInvalidAuthorizeRequestData = constable.Error("authorization request data must be present")
ErrInvalidAuthorizeRequestVersion = constable.Error("authorization request data has wrong version")
@ -39,7 +41,7 @@ type AuthorizeCodeSession struct {
}
func New(secrets corev1client.SecretInterface) oauth2.AuthorizeCodeStorage {
return &authorizeCodeStorage{storage: crud.New("authcode", secrets)}
return &authorizeCodeStorage{storage: crud.New(TypeLabelValue, secrets)}
}
func (a *authorizeCodeStorage) CreateAuthorizeCodeSession(ctx context.Context, signature string, requester fosite.Requester) error {
@ -64,7 +66,7 @@ func (a *authorizeCodeStorage) CreateAuthorizeCodeSession(ctx context.Context, s
// of the consent authorization request. It is used to identify the session.
// signature for lookup in the DB
_, err = a.storage.Create(ctx, signature, &AuthorizeCodeSession{Active: true, Request: request, Version: authorizeCodeStorageVersion})
_, err = a.storage.Create(ctx, signature, &AuthorizeCodeSession{Active: true, Request: request, Version: authorizeCodeStorageVersion}, nil)
return err
}

View File

@ -49,7 +49,7 @@ func TestAuthorizationCodeStorage(t *testing.T) {
Name: "pinniped-storage-authcode-pwu5zs7lekbhnln2w4",
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev": "authcode",
"storage.pinniped.dev/type": "authcode",
},
},
Data: map[string][]byte{
@ -65,7 +65,7 @@ func TestAuthorizationCodeStorage(t *testing.T) {
Name: "pinniped-storage-authcode-pwu5zs7lekbhnln2w4",
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev": "authcode",
"storage.pinniped.dev/type": "authcode",
},
},
Data: map[string][]byte{
@ -189,7 +189,7 @@ func TestWrongVersion(t *testing.T) {
Name: "pinniped-storage-authcode-pwu5zs7lekbhnln2w4",
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev": "authcode",
"storage.pinniped.dev/type": "authcode",
},
},
Data: map[string][]byte{
@ -217,7 +217,7 @@ func TestNilSessionRequest(t *testing.T) {
Name: "pinniped-storage-authcode-pwu5zs7lekbhnln2w4",
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev": "authcode",
"storage.pinniped.dev/type": "authcode",
},
},
Data: map[string][]byte{

View File

@ -11,9 +11,10 @@ import (
)
const (
ErrInvalidRequestType = constable.Error("requester must be of type fosite.Request")
ErrInvalidClientType = constable.Error("requester's client must be of type fosite.DefaultOpenIDConnectClient")
ErrInvalidSessionType = constable.Error("requester's session must be of type openid.DefaultSession")
ErrInvalidRequestType = constable.Error("requester must be of type fosite.Request")
ErrInvalidClientType = constable.Error("requester's client must be of type fosite.DefaultOpenIDConnectClient")
ErrInvalidSessionType = constable.Error("requester's session must be of type openid.DefaultSession")
StorageRequestIDLabelName = "storage.pinniped.dev/request-id" //nolint:gosec // this is not a credential
)
func ValidateAndExtractAuthorizeRequest(requester fosite.Requester) (*fosite.Request, error) {

View File

@ -19,6 +19,8 @@ import (
)
const (
TypeLabelValue = "oidc"
ErrInvalidOIDCRequestVersion = constable.Error("oidc request data has wrong version")
ErrInvalidOIDCRequestData = constable.Error("oidc request data must be present")
ErrMalformedAuthorizationCode = constable.Error("malformed authorization code")
@ -38,7 +40,7 @@ type session struct {
}
func New(secrets corev1client.SecretInterface) openid.OpenIDConnectRequestStorage {
return &openIDConnectRequestStorage{storage: crud.New("oidc", secrets)}
return &openIDConnectRequestStorage{storage: crud.New(TypeLabelValue, secrets)}
}
func (a *openIDConnectRequestStorage) CreateOpenIDConnectSession(ctx context.Context, authcode string, requester fosite.Requester) error {
@ -52,7 +54,7 @@ func (a *openIDConnectRequestStorage) CreateOpenIDConnectSession(ctx context.Con
return err
}
_, err = a.storage.Create(ctx, signature, &session{Request: request, Version: oidcStorageVersion})
_, err = a.storage.Create(ctx, signature, &session{Request: request, Version: oidcStorageVersion}, nil)
return err
}

View File

@ -36,7 +36,7 @@ func TestOpenIdConnectStorage(t *testing.T) {
Name: "pinniped-storage-oidc-pwu5zs7lekbhnln2w4",
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev": "oidc",
"storage.pinniped.dev/type": "oidc",
},
},
Data: map[string][]byte{
@ -122,7 +122,7 @@ func TestWrongVersion(t *testing.T) {
Name: "pinniped-storage-oidc-pwu5zs7lekbhnln2w4",
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev": "oidc",
"storage.pinniped.dev/type": "oidc",
},
},
Data: map[string][]byte{
@ -150,7 +150,7 @@ func TestNilSessionRequest(t *testing.T) {
Name: "pinniped-storage-oidc-pwu5zs7lekbhnln2w4",
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev": "oidc",
"storage.pinniped.dev/type": "oidc",
},
},
Data: map[string][]byte{

View File

@ -19,6 +19,8 @@ import (
)
const (
TypeLabelValue = "pkce"
ErrInvalidPKCERequestVersion = constable.Error("pkce request data has wrong version")
ErrInvalidPKCERequestData = constable.Error("pkce request data must be present")
@ -37,7 +39,7 @@ type session struct {
}
func New(secrets corev1client.SecretInterface) pkce.PKCERequestStorage {
return &pkceStorage{storage: crud.New("pkce", secrets)}
return &pkceStorage{storage: crud.New(TypeLabelValue, secrets)}
}
func (a *pkceStorage) CreatePKCERequestSession(ctx context.Context, signature string, requester fosite.Requester) error {
@ -46,7 +48,7 @@ func (a *pkceStorage) CreatePKCERequestSession(ctx context.Context, signature st
return err
}
_, err = a.storage.Create(ctx, signature, &session{Request: request, Version: pkceStorageVersion})
_, err = a.storage.Create(ctx, signature, &session{Request: request, Version: pkceStorageVersion}, nil)
return err
}

View File

@ -36,7 +36,7 @@ func TestPKCEStorage(t *testing.T) {
Name: "pinniped-storage-pkce-pwu5zs7lekbhnln2w4",
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev": "pkce",
"storage.pinniped.dev/type": "pkce",
},
},
Data: map[string][]byte{
@ -122,7 +122,7 @@ func TestWrongVersion(t *testing.T) {
Name: "pinniped-storage-pkce-pwu5zs7lekbhnln2w4",
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev": "pkce",
"storage.pinniped.dev/type": "pkce",
},
},
Data: map[string][]byte{
@ -150,7 +150,7 @@ func TestNilSessionRequest(t *testing.T) {
Name: "pinniped-storage-pkce-pwu5zs7lekbhnln2w4",
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev": "pkce",
"storage.pinniped.dev/type": "pkce",
},
},
Data: map[string][]byte{

View File

@ -0,0 +1,114 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package refreshtoken
import (
"context"
"fmt"
"github.com/ory/fosite"
"github.com/ory/fosite/handler/oauth2"
"github.com/ory/fosite/handler/openid"
"k8s.io/apimachinery/pkg/api/errors"
corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
"go.pinniped.dev/internal/constable"
"go.pinniped.dev/internal/crud"
"go.pinniped.dev/internal/fositestorage"
)
const (
TypeLabelValue = "refresh-token"
ErrInvalidRefreshTokenRequestVersion = constable.Error("refresh token request data has wrong version")
ErrInvalidRefreshTokenRequestData = constable.Error("refresh token request data must be present")
refreshTokenStorageVersion = "1"
)
type RevocationStorage interface {
oauth2.RefreshTokenStorage
RevokeRefreshToken(ctx context.Context, requestID string) error
}
var _ RevocationStorage = &refreshTokenStorage{}
type refreshTokenStorage struct {
storage crud.Storage
}
type session struct {
Request *fosite.Request `json:"request"`
Version string `json:"version"`
}
func New(secrets corev1client.SecretInterface) RevocationStorage {
return &refreshTokenStorage{storage: crud.New(TypeLabelValue, secrets)}
}
func (a *refreshTokenStorage) RevokeRefreshToken(ctx context.Context, requestID string) error {
return a.storage.DeleteByLabel(ctx, fositestorage.StorageRequestIDLabelName, requestID)
}
func (a *refreshTokenStorage) CreateRefreshTokenSession(ctx context.Context, signature string, requester fosite.Requester) error {
request, err := fositestorage.ValidateAndExtractAuthorizeRequest(requester)
if err != nil {
return err
}
_, err = a.storage.Create(
ctx,
signature,
&session{Request: request, Version: refreshTokenStorageVersion},
map[string]string{fositestorage.StorageRequestIDLabelName: requester.GetID()},
)
return err
}
func (a *refreshTokenStorage) GetRefreshTokenSession(ctx context.Context, signature string, _ fosite.Session) (fosite.Requester, error) {
session, _, err := a.getSession(ctx, signature)
if err != nil {
return nil, err
}
return session.Request, err
}
func (a *refreshTokenStorage) DeleteRefreshTokenSession(ctx context.Context, signature string) error {
return a.storage.Delete(ctx, signature)
}
func (a *refreshTokenStorage) getSession(ctx context.Context, signature string) (*session, string, error) {
session := newValidEmptyRefreshTokenSession()
rv, err := a.storage.Get(ctx, signature, session)
if errors.IsNotFound(err) {
return nil, "", fosite.ErrNotFound.WithCause(err).WithDebug(err.Error())
}
if err != nil {
return nil, "", fmt.Errorf("failed to get refresh token session for %s: %w", signature, err)
}
if version := session.Version; version != refreshTokenStorageVersion {
return nil, "", fmt.Errorf("%w: refresh token session for %s has version %s instead of %s",
ErrInvalidRefreshTokenRequestVersion, signature, version, refreshTokenStorageVersion)
}
if session.Request.ID == "" {
return nil, "", fmt.Errorf("malformed refresh token session for %s: %w", signature, ErrInvalidRefreshTokenRequestData)
}
return session, rv, nil
}
func newValidEmptyRefreshTokenSession() *session {
return &session{
Request: &fosite.Request{
Client: &fosite.DefaultOpenIDConnectClient{},
Session: &openid.DefaultSession{},
},
}
}

View File

@ -0,0 +1,282 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package refreshtoken
import (
"context"
"net/url"
"testing"
"time"
"github.com/ory/fosite"
"github.com/ory/fosite/handler/openid"
"github.com/pkg/errors"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/kubernetes/fake"
coretesting "k8s.io/client-go/testing"
)
const namespace = "test-ns"
var secretsGVR = schema.GroupVersionResource{
Group: "",
Version: "v1",
Resource: "secrets",
}
func TestRefreshTokenStorage(t *testing.T) {
ctx := context.Background()
wantActions := []coretesting.Action{
coretesting.NewCreateAction(secretsGVR, namespace, &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "pinniped-storage-refresh-token-pwu5zs7lekbhnln2w4",
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev/type": "refresh-token",
"storage.pinniped.dev/request-id": "abcd-1",
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"requestedAudience":null,"grantedAudience":null},"version":"1"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/refresh-token",
}),
coretesting.NewGetAction(secretsGVR, namespace, "pinniped-storage-refresh-token-pwu5zs7lekbhnln2w4"),
coretesting.NewDeleteAction(secretsGVR, namespace, "pinniped-storage-refresh-token-pwu5zs7lekbhnln2w4"),
}
client := fake.NewSimpleClientset()
secrets := client.CoreV1().Secrets(namespace)
storage := New(secrets)
request := &fosite.Request{
ID: "abcd-1",
RequestedAt: time.Time{},
Client: &fosite.DefaultOpenIDConnectClient{
DefaultClient: &fosite.DefaultClient{
ID: "pinny",
Secret: nil,
RedirectURIs: nil,
GrantTypes: nil,
ResponseTypes: nil,
Scopes: nil,
Audience: nil,
Public: true,
},
JSONWebKeysURI: "where",
JSONWebKeys: nil,
TokenEndpointAuthMethod: "something",
RequestURIs: nil,
RequestObjectSigningAlgorithm: "",
TokenEndpointAuthSigningAlgorithm: "",
},
RequestedScope: nil,
GrantedScope: nil,
Form: url.Values{"key": []string{"val"}},
Session: &openid.DefaultSession{
Claims: nil,
Headers: nil,
ExpiresAt: nil,
Username: "snorlax",
Subject: "panda",
},
RequestedAudience: nil,
GrantedAudience: nil,
}
err := storage.CreateRefreshTokenSession(ctx, "fancy-signature", request)
require.NoError(t, err)
newRequest, err := storage.GetRefreshTokenSession(ctx, "fancy-signature", nil)
require.NoError(t, err)
require.Equal(t, request, newRequest)
err = storage.DeleteRefreshTokenSession(ctx, "fancy-signature")
require.NoError(t, err)
require.Equal(t, wantActions, client.Actions())
}
func TestRefreshTokenStorageRevocation(t *testing.T) {
ctx := context.Background()
wantActions := []coretesting.Action{
coretesting.NewCreateAction(secretsGVR, namespace, &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "pinniped-storage-refresh-token-pwu5zs7lekbhnln2w4",
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev/type": "refresh-token",
"storage.pinniped.dev/request-id": "abcd-1",
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"requestedAudience":null,"grantedAudience":null},"version":"1"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/refresh-token",
}),
coretesting.NewListAction(secretsGVR, schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"}, namespace, metav1.ListOptions{
LabelSelector: "storage.pinniped.dev/type=refresh-token,storage.pinniped.dev/request-id=abcd-1",
}),
coretesting.NewDeleteAction(secretsGVR, namespace, "pinniped-storage-refresh-token-pwu5zs7lekbhnln2w4"),
}
client := fake.NewSimpleClientset()
secrets := client.CoreV1().Secrets(namespace)
storage := New(secrets)
request := &fosite.Request{
ID: "abcd-1",
RequestedAt: time.Time{},
Client: &fosite.DefaultOpenIDConnectClient{
DefaultClient: &fosite.DefaultClient{
ID: "pinny",
Public: true,
},
JSONWebKeysURI: "where",
TokenEndpointAuthMethod: "something",
},
Form: url.Values{"key": []string{"val"}},
Session: &openid.DefaultSession{
Username: "snorlax",
Subject: "panda",
},
}
err := storage.CreateRefreshTokenSession(ctx, "fancy-signature", request)
require.NoError(t, err)
// Revoke the request ID of the session that we just created
err = storage.RevokeRefreshToken(ctx, "abcd-1")
require.NoError(t, err)
require.Equal(t, wantActions, client.Actions())
}
func TestGetNotFound(t *testing.T) {
ctx := context.Background()
client := fake.NewSimpleClientset()
secrets := client.CoreV1().Secrets(namespace)
storage := New(secrets)
_, notFoundErr := storage.GetRefreshTokenSession(ctx, "non-existent-signature", nil)
require.EqualError(t, notFoundErr, "not_found")
require.True(t, errors.Is(notFoundErr, fosite.ErrNotFound))
}
func TestWrongVersion(t *testing.T) {
ctx := context.Background()
client := fake.NewSimpleClientset()
secrets := client.CoreV1().Secrets(namespace)
storage := New(secrets)
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "pinniped-storage-refresh-token-pwu5zs7lekbhnln2w4",
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev/type": "refresh-token",
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"requestedAudience":null,"grantedAudience":null},"version":"not-the-right-version"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/refresh-token",
}
_, err := secrets.Create(ctx, secret, metav1.CreateOptions{})
require.NoError(t, err)
_, err = storage.GetRefreshTokenSession(ctx, "fancy-signature", nil)
require.EqualError(t, err, "refresh token request data has wrong version: refresh token session for fancy-signature has version not-the-right-version instead of 1")
}
func TestNilSessionRequest(t *testing.T) {
ctx := context.Background()
client := fake.NewSimpleClientset()
secrets := client.CoreV1().Secrets(namespace)
storage := New(secrets)
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "pinniped-storage-refresh-token-pwu5zs7lekbhnln2w4",
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev/type": "refresh-token",
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"1"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/refresh-token",
}
_, err := secrets.Create(ctx, secret, metav1.CreateOptions{})
require.NoError(t, err)
_, err = storage.GetRefreshTokenSession(ctx, "fancy-signature", nil)
require.EqualError(t, err, "malformed refresh token session for fancy-signature: refresh token request data must be present")
}
func TestCreateWithNilRequester(t *testing.T) {
ctx := context.Background()
client := fake.NewSimpleClientset()
secrets := client.CoreV1().Secrets(namespace)
storage := New(secrets)
err := storage.CreateRefreshTokenSession(ctx, "signature-doesnt-matter", nil)
require.EqualError(t, err, "requester must be of type fosite.Request")
}
func TestCreateWithWrongRequesterDataTypes(t *testing.T) {
ctx := context.Background()
client := fake.NewSimpleClientset()
secrets := client.CoreV1().Secrets(namespace)
storage := New(secrets)
request := &fosite.Request{
Session: nil,
Client: &fosite.DefaultOpenIDConnectClient{},
}
err := storage.CreateRefreshTokenSession(ctx, "signature-doesnt-matter", request)
require.EqualError(t, err, "requester's session must be of type openid.DefaultSession")
request = &fosite.Request{
Session: &openid.DefaultSession{},
Client: nil,
}
err = storage.CreateRefreshTokenSession(ctx, "signature-doesnt-matter", request)
require.EqualError(t, err, "requester's client must be of type fosite.DefaultOpenIDConnectClient")
}
func TestCreateWithoutRequesterID(t *testing.T) {
ctx := context.Background()
client := fake.NewSimpleClientset()
secrets := client.CoreV1().Secrets(namespace)
storage := New(secrets)
request := &fosite.Request{
ID: "", // empty ID
Session: &openid.DefaultSession{},
Client: &fosite.DefaultOpenIDConnectClient{},
}
err := storage.CreateRefreshTokenSession(ctx, "signature-doesnt-matter", request)
require.NoError(t, err)
// the blank ID was filled in with an auto-generated ID
require.NotEmpty(t, request.ID)
require.Len(t, client.Actions(), 1)
actualAction := client.Actions()[0].(coretesting.CreateActionImpl)
actualSecret := actualAction.GetObject().(*corev1.Secret)
// The generated secret was labeled with that auto-generated request ID
require.Equal(t, request.ID, actualSecret.Labels["storage.pinniped.dev/request-id"])
}

View File

@ -45,7 +45,7 @@ func NewHandler(
authorizeRequester, err := oauthHelper.NewAuthorizeRequest(r.Context(), r)
if err != nil {
plog.Info("authorize request error", fositeErrorForLog(err)...)
plog.Info("authorize request error", oidc.FositeErrorForLog(err)...)
oauthHelper.WriteAuthorizeError(w, authorizeRequester, err)
return nil
}
@ -69,7 +69,7 @@ func NewHandler(
},
})
if err != nil {
plog.Info("authorize response error", fositeErrorForLog(err)...)
plog.Info("authorize response error", oidc.FositeErrorForLog(err)...)
oauthHelper.WriteAuthorizeError(w, authorizeRequester, err)
return nil
}
@ -232,15 +232,3 @@ func addCSRFSetCookieHeader(w http.ResponseWriter, csrfValue csrftoken.CSRFToken
return nil
}
func fositeErrorForLog(err error) []interface{} {
rfc6749Error := fosite.ErrorToRFC6749Error(err)
keysAndValues := make([]interface{}, 0)
keysAndValues = append(keysAndValues, "name")
keysAndValues = append(keysAndValues, rfc6749Error.Name)
keysAndValues = append(keysAndValues, "status")
keysAndValues = append(keysAndValues, rfc6749Error.Status())
keysAndValues = append(keysAndValues, "description")
keysAndValues = append(keysAndValues, rfc6749Error.Description)
return keysAndValues
}

View File

@ -6,7 +6,6 @@ package auth
import (
"fmt"
"html"
"mime"
"net/http"
"net/http/httptest"
"net/url"
@ -20,8 +19,10 @@ import (
"go.pinniped.dev/internal/here"
"go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/oidc/csrftoken"
"go.pinniped.dev/internal/oidc/jwks"
"go.pinniped.dev/internal/oidc/oidctestutil"
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/testutil"
"go.pinniped.dev/pkg/oidcclient/nonce"
"go.pinniped.dev/pkg/oidcclient/pkce"
)
@ -125,7 +126,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
oauthStore := oidc.NullStorage{}
hmacSecret := []byte("some secret - must have at least 32 bytes")
require.GreaterOrEqual(t, len(hmacSecret), 32, "fosite requires that hmac secrets have at least 32 bytes")
oauthHelper := oidc.FositeOauth2Helper(oauthStore, downstreamIssuer, hmacSecret)
jwksProviderIsUnused := jwks.NewDynamicJWKSProvider()
oauthHelper := oidc.FositeOauth2Helper(oauthStore, downstreamIssuer, hmacSecret, jwksProviderIsUnused)
happyCSRF := "test-csrf"
happyPKCE := "test-pkce"
@ -723,7 +725,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
t.Logf("response body: %q", rsp.Body.String())
require.Equal(t, test.wantStatus, rsp.Code)
requireEqualContentType(t, rsp.Header().Get("Content-Type"), test.wantContentType)
testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), test.wantContentType)
actualLocation := rsp.Header().Get("Location")
if test.wantLocationHeader != "" {
@ -824,22 +826,6 @@ func (*errorReturningEncoder) Encode(_ string, _ interface{}) (string, error) {
return "", fmt.Errorf("some encoding error")
}
func requireEqualContentType(t *testing.T, actual string, expected string) {
t.Helper()
if expected == "" {
require.Empty(t, actual)
return
}
actualContentType, actualContentTypeParams, err := mime.ParseMediaType(expected)
require.NoError(t, err)
expectedContentType, expectedContentTypeParams, err := mime.ParseMediaType(expected)
require.NoError(t, err)
require.Equal(t, actualContentType, expectedContentType)
require.Equal(t, actualContentTypeParams, expectedContentTypeParams)
}
func requireEqualDecodedStateParams(t *testing.T, actualURL string, expectedURL string, stateParamDecoder oidc.Codec) {
t.Helper()
actualLocationURL, err := url.Parse(actualURL)

View File

@ -18,17 +18,20 @@ import (
"github.com/ory/fosite"
"github.com/ory/fosite/handler/openid"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/client-go/kubernetes/fake"
kubetesting "k8s.io/client-go/testing"
"go.pinniped.dev/internal/crud"
"go.pinniped.dev/internal/fositestorage/authorizationcode"
"go.pinniped.dev/internal/fositestorage/openidconnect"
"go.pinniped.dev/internal/fositestorage/pkce"
"go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/oidc/jwks"
"go.pinniped.dev/internal/oidc/oidctestutil"
"go.pinniped.dev/internal/testutil"
"go.pinniped.dev/pkg/oidcclient/nonce"
"go.pinniped.dev/pkg/oidcclient/oidctypes"
"go.pinniped.dev/pkg/oidcclient/pkce"
oidcpkce "go.pinniped.dev/pkg/oidcclient/pkce"
)
const (
@ -105,7 +108,7 @@ func TestCallbackEndpoint(t *testing.T) {
happyExchangeAndValidateTokensArgs := &oidctestutil.ExchangeAuthcodeAndValidateTokenArgs{
Authcode: happyUpstreamAuthcode,
PKCECodeVerifier: pkce.Code(happyDownstreamPKCE),
PKCECodeVerifier: oidcpkce.Code(happyDownstreamPKCE),
ExpectedIDTokenNonce: nonce.Nonce(happyDownstreamNonce),
RedirectURI: happyUpstreamRedirectURI,
}
@ -433,7 +436,8 @@ func TestCallbackEndpoint(t *testing.T) {
oauthStore := oidc.NewKubeStorage(secrets)
hmacSecret := []byte("some secret - must have at least 32 bytes")
require.GreaterOrEqual(t, len(hmacSecret), 32, "fosite requires that hmac secrets have at least 32 bytes")
oauthHelper := oidc.FositeOauth2Helper(oauthStore, downstreamIssuer, hmacSecret)
jwksProviderIsUnused := jwks.NewDynamicJWKSProvider()
oauthHelper := oidc.FositeOauth2Helper(oauthStore, downstreamIssuer, hmacSecret, jwksProviderIsUnused)
idpListGetter := oidctestutil.NewIDPListGetter(&test.idp)
subject := NewHandler(idpListGetter, oauthHelper, happyStateCodec, happyCookieCodec, happyUpstreamRedirectURI)
@ -482,18 +486,8 @@ func TestCallbackEndpoint(t *testing.T) {
}
require.Len(t, client.Actions(), expectedNumberOfCreatedSecrets)
actualSecretNames := []string{}
for i := range client.Actions() {
actualAction := client.Actions()[i].(kubetesting.CreateActionImpl)
require.Equal(t, "create", actualAction.GetVerb())
require.Equal(t, schema.GroupVersionResource{Group: "", Version: "v1", Resource: "secrets"}, actualAction.GetResource())
actualSecret := actualAction.GetObject().(*corev1.Secret)
require.Empty(t, actualSecret.Namespace) // because the secrets client is already scoped to a namespace
actualSecretNames = append(actualSecretNames, actualSecret.Name)
}
// One authcode should have been stored.
requireAnyStringHasPrefix(t, actualSecretNames, "pinniped-storage-authcode-")
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: authorizationcode.TypeLabelValue}, 1)
storedRequestFromAuthcode, storedSessionFromAuthcode := validateAuthcodeStorage(
t,
@ -506,7 +500,7 @@ func TestCallbackEndpoint(t *testing.T) {
)
// One PKCE should have been stored.
requireAnyStringHasPrefix(t, actualSecretNames, "pinniped-storage-pkce-")
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: pkce.TypeLabelValue}, 1)
validatePKCEStorage(
t,
@ -520,7 +514,7 @@ func TestCallbackEndpoint(t *testing.T) {
// One IDSession should have been stored, if the downstream actually requested the "openid" scope
if test.wantGrantedOpenidScope {
requireAnyStringHasPrefix(t, actualSecretNames, "pinniped-storage-oidc")
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: openidconnect.TypeLabelValue}, 1)
validateIDSessionStorage(
t,
@ -682,7 +676,7 @@ func (u *upstreamOIDCIdentityProviderBuilder) Build() oidctestutil.TestUpstreamO
UsernameClaim: u.usernameClaim,
GroupsClaim: u.groupsClaim,
Scopes: []string{"scope1", "scope2"},
ExchangeAuthcodeAndValidateTokensFunc: func(ctx context.Context, authcode string, pkceCodeVerifier pkce.Code, expectedIDTokenNonce nonce.Nonce) (*oidctypes.Token, error) {
ExchangeAuthcodeAndValidateTokensFunc: func(ctx context.Context, authcode string, pkceCodeVerifier oidcpkce.Code, expectedIDTokenNonce nonce.Nonce) (*oidctypes.Token, error) {
if u.authcodeExchangeErr != nil {
return nil, u.authcodeExchangeErr
}
@ -848,15 +842,3 @@ func castStoredAuthorizeRequest(t *testing.T, storedAuthorizeRequest fosite.Requ
return storedRequest, storedSession
}
func requireAnyStringHasPrefix(t *testing.T, stringList []string, prefix string) {
t.Helper()
containsPrefix := false
for i := range stringList {
if strings.HasPrefix(stringList[i], prefix) {
containsPrefix = true
}
}
require.Truef(t, containsPrefix, "list %v did not contain any strings with prefix %s", stringList, prefix)
}

View File

@ -31,10 +31,9 @@ type Metadata struct {
// vvv Optional vvv
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
TokenEndpointAuthSigningAlgoValuesSupported []string `json:"token_endpoint_auth_signing_alg_values_supported"`
ScopesSupported []string `json:"scopes_supported"`
ClaimsSupported []string `json:"claims_supported"`
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
ScopesSupported []string `json:"scopes_supported"`
ClaimsSupported []string `json:"claims_supported"`
// ^^^ Optional ^^^
}
@ -56,11 +55,10 @@ func NewHandler(issuerURL string) http.Handler {
JWKSURI: issuerURL + oidc.JWKSEndpointPath,
ResponseTypesSupported: []string{"code"},
SubjectTypesSupported: []string{"public"},
IDTokenSigningAlgValuesSupported: []string{"RS256"},
IDTokenSigningAlgValuesSupported: []string{"ES256"},
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic"},
TokenEndpointAuthSigningAlgoValuesSupported: []string{"RS256"},
ScopesSupported: []string{"openid", "offline"},
ClaimsSupported: []string{"groups"},
ScopesSupported: []string{"openid", "offline"},
ClaimsSupported: []string{"groups"},
}
if err := json.NewEncoder(w).Encode(&oidcConfig); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@ -41,11 +41,10 @@ func TestDiscovery(t *testing.T) {
JWKSURI: "https://some-issuer.com/some/path/jwks.json",
ResponseTypesSupported: []string{"code"},
SubjectTypesSupported: []string{"public"},
IDTokenSigningAlgValuesSupported: []string{"RS256"},
IDTokenSigningAlgValuesSupported: []string{"ES256"},
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic"},
TokenEndpointAuthSigningAlgoValuesSupported: []string{"RS256"},
ScopesSupported: []string{"openid", "offline"},
ClaimsSupported: []string{"groups"},
ScopesSupported: []string{"openid", "offline"},
ClaimsSupported: []string{"groups"},
},
},
{

View File

@ -0,0 +1,72 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package oidc
import (
"context"
"crypto/ecdsa"
"reflect"
"go.pinniped.dev/internal/constable"
"go.pinniped.dev/internal/plog"
"github.com/ory/fosite"
"github.com/ory/fosite/compose"
"github.com/ory/fosite/handler/openid"
"go.pinniped.dev/internal/oidc/jwks"
)
// dynamicOpenIDConnectECDSAStrategy is an openid.OpenIDConnectTokenStrategy that can dynamically
// load a signing key to issue ID tokens. We want this dynamic capability since our controllers for
// loading OIDCProvider's and signing keys run in parallel, and thus the signing key might not be
// ready when an OIDCProvider is otherwise ready.
//
// If we ever update OIDCProvider's to hold their signing key, we might not need this type, since we
// could have an invariant that routes to an OIDCProvider's endpoints are only wired up if an
// OIDCProvider has a valid signing key.
type dynamicOpenIDConnectECDSAStrategy struct {
fositeConfig *compose.Config
jwksProvider jwks.DynamicJWKSProvider
}
var _ openid.OpenIDConnectTokenStrategy = &dynamicOpenIDConnectECDSAStrategy{}
func newDynamicOpenIDConnectECDSAStrategy(
fositeConfig *compose.Config,
jwksProvider jwks.DynamicJWKSProvider,
) *dynamicOpenIDConnectECDSAStrategy {
return &dynamicOpenIDConnectECDSAStrategy{
fositeConfig: fositeConfig,
jwksProvider: jwksProvider,
}
}
func (s *dynamicOpenIDConnectECDSAStrategy) GenerateIDToken(
ctx context.Context,
requester fosite.Requester,
) (string, error) {
_, activeJwk := s.jwksProvider.GetJWKS(s.fositeConfig.IDTokenIssuer)
if activeJwk == nil {
plog.Debug("no JWK found for issuer", "issuer", s.fositeConfig.IDTokenIssuer)
return "", fosite.ErrTemporarilyUnavailable.WithCause(constable.Error("no JWK found for issuer"))
}
key, ok := activeJwk.Key.(*ecdsa.PrivateKey)
if !ok {
actualType := "nil"
if t := reflect.TypeOf(activeJwk.Key); t != nil {
actualType = t.String()
}
plog.Debug(
"JWK must be of type ecdsa",
"issuer",
s.fositeConfig.IDTokenIssuer,
"actualType",
actualType,
)
return "", fosite.ErrServerError.WithCause(constable.Error("JWK must be of type ecdsa"))
}
return compose.NewOpenIDConnectECDSAStrategy(s.fositeConfig, key).GenerateIDToken(ctx, requester)
}

View File

@ -0,0 +1,136 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package oidc
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"errors"
"net/url"
"testing"
"github.com/ory/fosite"
"github.com/ory/fosite/compose"
"github.com/ory/fosite/handler/openid"
"github.com/ory/fosite/token/jwt"
"github.com/stretchr/testify/require"
"gopkg.in/square/go-jose.v2"
"go.pinniped.dev/internal/oidc/jwks"
"go.pinniped.dev/internal/oidc/oidctestutil"
)
func TestDynamicOpenIDConnectECDSAStrategy(t *testing.T) {
const (
goodIssuer = "https://some-good-issuer.com"
clientID = "some-client-id"
goodSubject = "some-subject"
goodUsername = "some-username"
goodNonce = "some-nonce-that-is-at-least-32-characters-to-meet-entropy-requirements"
)
ecPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
rsaPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
tests := []struct {
name string
issuer string
jwksProvider func(jwks.DynamicJWKSProvider)
wantErrorType *fosite.RFC6749Error
wantErrorCause string
wantSigningJWK *jose.JSONWebKey
}{
{
name: "jwks provider does contain signing key for issuer",
issuer: goodIssuer,
jwksProvider: func(provider jwks.DynamicJWKSProvider) {
provider.SetIssuerToJWKSMap(
nil,
map[string]*jose.JSONWebKey{
goodIssuer: {
Key: ecPrivateKey,
},
},
)
},
wantSigningJWK: &jose.JSONWebKey{
Key: ecPrivateKey,
},
},
{
name: "jwks provider does not contain signing key for issuer",
issuer: goodIssuer,
wantErrorType: fosite.ErrTemporarilyUnavailable,
wantErrorCause: "no JWK found for issuer",
},
{
name: "jwks provider contains signing key of wrong type for issuer",
issuer: goodIssuer,
jwksProvider: func(provider jwks.DynamicJWKSProvider) {
provider.SetIssuerToJWKSMap(
nil,
map[string]*jose.JSONWebKey{
goodIssuer: {
Key: rsaPrivateKey,
},
},
)
},
wantErrorType: fosite.ErrServerError,
wantErrorCause: "JWK must be of type ecdsa",
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
jwksProvider := jwks.NewDynamicJWKSProvider()
if test.jwksProvider != nil {
test.jwksProvider(jwksProvider)
}
s := newDynamicOpenIDConnectECDSAStrategy(
&compose.Config{IDTokenIssuer: test.issuer},
jwksProvider,
)
requester := &fosite.Request{
Client: &fosite.DefaultClient{
ID: clientID,
},
Session: &openid.DefaultSession{
Claims: &jwt.IDTokenClaims{
Subject: goodSubject,
},
Subject: goodSubject,
Username: goodUsername,
},
Form: url.Values{
"nonce": {goodNonce},
},
}
idToken, err := s.GenerateIDToken(context.Background(), requester)
if test.wantErrorType != nil {
require.True(t, errors.Is(err, test.wantErrorType))
require.EqualError(t, err.(*fosite.RFC6749Error).Cause(), test.wantErrorCause)
} else {
require.NoError(t, err)
privateKey, ok := test.wantSigningJWK.Key.(*ecdsa.PrivateKey)
require.True(t, ok, "wanted private key to be *ecdsa.PrivateKey, but was %T", test.wantSigningJWK)
// Perform a light validation on the token to make sure 1) we passed through the correct
// signing key and 2) we forwarded the fosite.Requester correctly. Token generation is
// tested more expansively in the token endpoint.
token := oidctestutil.VerifyECDSAIDToken(t, goodIssuer, clientID, privateKey, idToken)
require.Equal(t, goodSubject, token.Subject)
require.Equal(t, goodNonce, token.Nonce)
}
})
}
}

View File

@ -10,29 +10,38 @@ import (
)
type DynamicJWKSProvider interface {
SetIssuerToJWKSMap(issuerToJWKSMap map[string]*jose.JSONWebKeySet)
GetJWKS(issuerName string) *jose.JSONWebKeySet
SetIssuerToJWKSMap(
issuerToJWKSMap map[string]*jose.JSONWebKeySet,
issuerToActiveJWKMap map[string]*jose.JSONWebKey,
)
GetJWKS(issuerName string) (jwks *jose.JSONWebKeySet, activeJWK *jose.JSONWebKey)
}
type dynamicJWKSProvider struct {
issuerToJWKSMap map[string]*jose.JSONWebKeySet
mutex sync.RWMutex
issuerToJWKSMap map[string]*jose.JSONWebKeySet
issuerToActiveJWKMap map[string]*jose.JSONWebKey
mutex sync.RWMutex
}
func NewDynamicJWKSProvider() DynamicJWKSProvider {
return &dynamicJWKSProvider{
issuerToJWKSMap: map[string]*jose.JSONWebKeySet{},
issuerToJWKSMap: map[string]*jose.JSONWebKeySet{},
issuerToActiveJWKMap: map[string]*jose.JSONWebKey{},
}
}
func (p *dynamicJWKSProvider) SetIssuerToJWKSMap(issuerToJWKSMap map[string]*jose.JSONWebKeySet) {
func (p *dynamicJWKSProvider) SetIssuerToJWKSMap(
issuerToJWKSMap map[string]*jose.JSONWebKeySet,
issuerToActiveJWKMap map[string]*jose.JSONWebKey,
) {
p.mutex.Lock() // acquire a write lock
defer p.mutex.Unlock()
p.issuerToJWKSMap = issuerToJWKSMap
p.issuerToActiveJWKMap = issuerToActiveJWKMap
}
func (p *dynamicJWKSProvider) GetJWKS(issuerName string) *jose.JSONWebKeySet {
func (p *dynamicJWKSProvider) GetJWKS(issuerName string) (*jose.JSONWebKeySet, *jose.JSONWebKey) {
p.mutex.RLock() // acquire a read lock
defer p.mutex.RUnlock()
return p.issuerToJWKSMap[issuerName]
return p.issuerToJWKSMap[issuerName], p.issuerToActiveJWKMap[issuerName]
}

View File

@ -19,7 +19,7 @@ func NewHandler(issuerName string, provider DynamicJWKSProvider) http.Handler {
return
}
jwks := provider.GetJWKS(issuerName)
jwks, _ := provider.GetJWKS(issuerName)
if jwks == nil {
http.Error(w, "JWKS not found for requested issuer", http.StatusNotFound)

View File

@ -105,8 +105,12 @@ func newDynamicJWKSProvider(t *testing.T, issuer string, jwksJSON string) Dynami
var keySet jose.JSONWebKeySet
err := json.Unmarshal([]byte(jwksJSON), &keySet)
require.NoError(t, err)
jwksProvider.SetIssuerToJWKSMap(map[string]*jose.JSONWebKeySet{
issuerToJWKSMap := map[string]*jose.JSONWebKeySet{
issuer: &keySet,
})
}
issuerToActiveJWKMap := map[string]*jose.JSONWebKey{
issuer: &keySet.Keys[0],
}
jwksProvider.SetIssuerToJWKSMap(issuerToJWKSMap, issuerToActiveJWKMap)
return jwksProvider
}

View File

@ -14,9 +14,11 @@ import (
corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
"go.pinniped.dev/internal/constable"
"go.pinniped.dev/internal/fositestorage/accesstoken"
"go.pinniped.dev/internal/fositestorage/authorizationcode"
"go.pinniped.dev/internal/fositestorage/openidconnect"
"go.pinniped.dev/internal/fositestorage/pkce"
"go.pinniped.dev/internal/fositestorage/refreshtoken"
)
const errKubeStorageNotImplemented = constable.Error("KubeStorage does not implement this method. It should not have been called.")
@ -25,6 +27,8 @@ type KubeStorage struct {
authorizationCodeStorage oauth2.AuthorizeCodeStorage
pkceStorage fositepkce.PKCERequestStorage
oidcStorage openid.OpenIDConnectRequestStorage
accessTokenStorage accesstoken.RevocationStorage
refreshTokenStorage refreshtoken.RevocationStorage
}
func NewKubeStorage(secrets corev1client.SecretInterface) *KubeStorage {
@ -32,39 +36,41 @@ func NewKubeStorage(secrets corev1client.SecretInterface) *KubeStorage {
authorizationCodeStorage: authorizationcode.New(secrets),
pkceStorage: pkce.New(secrets),
oidcStorage: openidconnect.New(secrets),
accessTokenStorage: accesstoken.New(secrets),
refreshTokenStorage: refreshtoken.New(secrets),
}
}
func (KubeStorage) RevokeRefreshToken(_ context.Context, _ string) error {
return errKubeStorageNotImplemented
func (k KubeStorage) RevokeRefreshToken(ctx context.Context, requestID string) error {
return k.refreshTokenStorage.RevokeRefreshToken(ctx, requestID)
}
func (KubeStorage) RevokeAccessToken(_ context.Context, _ string) error {
return errKubeStorageNotImplemented
func (k KubeStorage) RevokeAccessToken(ctx context.Context, requestID string) error {
return k.accessTokenStorage.RevokeAccessToken(ctx, requestID)
}
func (KubeStorage) CreateRefreshTokenSession(_ context.Context, _ string, _ fosite.Requester) (err error) {
return nil
func (k KubeStorage) CreateRefreshTokenSession(ctx context.Context, signature string, request fosite.Requester) (err error) {
return k.refreshTokenStorage.CreateRefreshTokenSession(ctx, signature, request)
}
func (KubeStorage) GetRefreshTokenSession(_ context.Context, _ string, _ fosite.Session) (request fosite.Requester, err error) {
return nil, errKubeStorageNotImplemented
func (k KubeStorage) GetRefreshTokenSession(ctx context.Context, signature string, session fosite.Session) (request fosite.Requester, err error) {
return k.refreshTokenStorage.GetRefreshTokenSession(ctx, signature, session)
}
func (KubeStorage) DeleteRefreshTokenSession(_ context.Context, _ string) (err error) {
return errKubeStorageNotImplemented
func (k KubeStorage) DeleteRefreshTokenSession(ctx context.Context, signature string) (err error) {
return k.refreshTokenStorage.DeleteRefreshTokenSession(ctx, signature)
}
func (KubeStorage) CreateAccessTokenSession(_ context.Context, _ string, _ fosite.Requester) (err error) {
return nil
func (k KubeStorage) CreateAccessTokenSession(ctx context.Context, signature string, requester fosite.Requester) (err error) {
return k.accessTokenStorage.CreateAccessTokenSession(ctx, signature, requester)
}
func (KubeStorage) GetAccessTokenSession(_ context.Context, _ string, _ fosite.Session) (request fosite.Requester, err error) {
return nil, errKubeStorageNotImplemented
func (k KubeStorage) GetAccessTokenSession(ctx context.Context, signature string, session fosite.Session) (request fosite.Requester, err error) {
return k.accessTokenStorage.GetAccessTokenSession(ctx, signature, session)
}
func (KubeStorage) DeleteAccessTokenSession(_ context.Context, _ string) (err error) {
return errKubeStorageNotImplemented
func (k KubeStorage) DeleteAccessTokenSession(ctx context.Context, signature string) (err error) {
return k.accessTokenStorage.DeleteAccessTokenSession(ctx, signature)
}
func (k KubeStorage) CreateOpenIDConnectSession(ctx context.Context, authcode string, requester fosite.Requester) error {

View File

@ -30,6 +30,7 @@ func TestNullStorage_GetClient(t *testing.T) {
GrantTypes: []string{"authorization_code"},
Scopes: []string{"openid", "profile", "email"},
},
TokenEndpointAuthMethod: "none",
},
client,
)

View File

@ -11,6 +11,7 @@ import (
"github.com/ory/fosite/compose"
"go.pinniped.dev/internal/oidc/csrftoken"
"go.pinniped.dev/internal/oidc/jwks"
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/pkg/oidcclient/nonce"
"go.pinniped.dev/pkg/oidcclient/pkce"
@ -85,10 +86,16 @@ func PinnipedCLIOIDCClient() *fosite.DefaultOpenIDConnectClient {
GrantTypes: []string{"authorization_code"},
Scopes: []string{"openid", "profile", "email"},
},
TokenEndpointAuthMethod: "none",
}
}
func FositeOauth2Helper(oauthStore interface{}, issuer string, hmacSecretOfLengthAtLeast32 []byte) fosite.OAuth2Provider {
func FositeOauth2Helper(
oauthStore interface{},
issuer string,
hmacSecretOfLengthAtLeast32 []byte,
jwksProvider jwks.DynamicJWKSProvider,
) fosite.OAuth2Provider {
oauthConfig := &compose.Config{
AuthorizeCodeLifespan: 3 * time.Minute, // seems more than long enough to exchange a code
@ -98,7 +105,6 @@ func FositeOauth2Helper(oauthStore interface{}, issuer string, hmacSecretOfLengt
RefreshTokenLifespan: 16 * time.Hour, // long enough for a single workday
IDTokenIssuer: issuer,
TokenURL: "", // TODO set once we have this endpoint written
ScopeStrategy: fosite.ExactScopeStrategy, // be careful and only support exact string matching for scopes
AudienceMatchingStrategy: nil, // I believe the default is fine
@ -114,7 +120,8 @@ func FositeOauth2Helper(oauthStore interface{}, issuer string, hmacSecretOfLengt
oauthStore,
&compose.CommonStrategy{
// Note that Fosite requires the HMAC secret to be at least 32 bytes.
CoreStrategy: compose.NewOAuth2HMACStrategy(oauthConfig, hmacSecretOfLengthAtLeast32, nil),
CoreStrategy: compose.NewOAuth2HMACStrategy(oauthConfig, hmacSecretOfLengthAtLeast32, nil),
OpenIDConnectTokenStrategy: newDynamicOpenIDConnectECDSAStrategy(oauthConfig, jwksProvider),
},
nil, // hasher, defaults to using BCrypt when nil. Used for hashing client secrets.
compose.OAuth2AuthorizeExplicitFactory,
@ -125,6 +132,27 @@ func FositeOauth2Helper(oauthStore interface{}, issuer string, hmacSecretOfLengt
)
}
// FositeErrorForLog generates a list of information about the provided Fosite error that can be
// passed to a plog function (e.g., plog.Info()).
//
// Sample usage:
// err := someFositeLibraryFunction()
// if err != nil {
// plog.Info("some error", FositeErrorForLog(err)...)
// ...
// }
func FositeErrorForLog(err error) []interface{} {
rfc6749Error := fosite.ErrorToRFC6749Error(err)
keysAndValues := make([]interface{}, 0)
keysAndValues = append(keysAndValues, "name")
keysAndValues = append(keysAndValues, rfc6749Error.Name)
keysAndValues = append(keysAndValues, "status")
keysAndValues = append(keysAndValues, rfc6749Error.Status())
keysAndValues = append(keysAndValues, "description")
keysAndValues = append(keysAndValues, rfc6749Error.Description)
return keysAndValues
}
type IDPListGetter interface {
GetIDPList() []provider.UpstreamOIDCIdentityProviderI
}

View File

@ -5,9 +5,16 @@ package oidctestutil
import (
"context"
"crypto"
"crypto/ecdsa"
"fmt"
"net/url"
"testing"
coreosoidc "github.com/coreos/go-oidc"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
"gopkg.in/square/go-jose.v2"
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/pkg/oidcclient/nonce"
@ -127,3 +134,41 @@ type ExpectedUpstreamStateParamFormat struct {
K string `json:"k"`
V string `json:"v"`
}
type staticKeySet struct {
publicKey crypto.PublicKey
}
func newStaticKeySet(publicKey crypto.PublicKey) coreosoidc.KeySet {
return &staticKeySet{publicKey}
}
func (s *staticKeySet) VerifySignature(ctx context.Context, jwt string) ([]byte, error) {
jws, err := jose.ParseSigned(jwt)
if err != nil {
return nil, fmt.Errorf("oidc: malformed jwt: %w", err)
}
return jws.Verify(s.publicKey)
}
// VerifyECDSAIDToken verifies that the provided idToken was issued via the provided jwtSigningKey.
// It also performs some light validation on the claims, i.e., it makes sure the provided idToken
// has the provided issuer and clientID.
//
// Further validation can be done via callers via the returned coreosoidc.IDToken.
func VerifyECDSAIDToken(
t *testing.T,
issuer, clientID string,
jwtSigningKey *ecdsa.PrivateKey,
idToken string,
) *coreosoidc.IDToken {
t.Helper()
keySet := newStaticKeySet(jwtSigningKey.Public())
verifyConfig := coreosoidc.Config{ClientID: clientID, SupportedSigningAlgs: []string{coreosoidc.ES256}}
verifier := coreosoidc.NewVerifier(issuer, keySet, &verifyConfig)
token, err := verifier.Verify(context.Background(), idToken)
require.NoError(t, err)
return token
}

View File

@ -18,6 +18,7 @@ import (
"go.pinniped.dev/internal/oidc/discovery"
"go.pinniped.dev/internal/oidc/jwks"
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/oidc/token"
"go.pinniped.dev/internal/plog"
"go.pinniped.dev/pkg/oidcclient/nonce"
"go.pinniped.dev/pkg/oidcclient/pkce"
@ -78,10 +79,10 @@ func (m *Manager) SetProviders(oidcProviders ...*provider.OIDCProvider) {
// 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, fositeHMACSecretForThisProvider)
oauthHelperWithNullStorage := oidc.FositeOauth2Helper(oidc.NullStorage{}, issuer, fositeHMACSecretForThisProvider, nil)
// 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), issuer, fositeHMACSecretForThisProvider)
oauthHelperWithKubeStorage := oidc.FositeOauth2Helper(oidc.NewKubeStorage(m.secretsClient), issuer, fositeHMACSecretForThisProvider, m.dynamicJWKSProvider)
// TODO use different codecs for the state and the cookie, because:
// 1. we would like to state to have an embedded expiration date while the cookie does not need that
@ -115,6 +116,10 @@ func (m *Manager) SetProviders(oidcProviders ...*provider.OIDCProvider) {
issuer+oidc.CallbackEndpointPath,
)
m.providerHandlers[(issuerHostWithPath + oidc.TokenEndpointPath)] = token.NewHandler(
oauthHelperWithKubeStorage,
)
plog.Debug("oidc provider manager added or updated issuer", "issuer", issuer)
}
}

View File

@ -5,6 +5,7 @@ package manager
import (
"context"
"crypto/ecdsa"
"encoding/json"
"io/ioutil"
"net/http"
@ -24,6 +25,7 @@ import (
"go.pinniped.dev/internal/oidc/jwks"
"go.pinniped.dev/internal/oidc/oidctestutil"
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/testutil"
"go.pinniped.dev/pkg/oidcclient/nonce"
"go.pinniped.dev/pkg/oidcclient/oidctypes"
"go.pinniped.dev/pkg/oidcclient/pkce"
@ -48,13 +50,22 @@ func TestManager(t *testing.T) {
issuer2DifferentCaseHostname = "https://exAmPlE.Com/some/path/more/deeply/nested/path"
issuer2KeyID = "issuer2-key"
upstreamIDPAuthorizationURL = "https://test-upstream.com/auth"
downstreamClientID = "pinniped-cli"
downstreamRedirectURL = "http://127.0.0.1:12345/callback"
downstreamPKCECodeVerifier = "some-pkce-verifier-that-must-be-at-least-43-characters-to-meet-entropy-requirements"
)
newGetRequest := func(url string) *http.Request {
return httptest.NewRequest(http.MethodGet, url, nil)
}
newPostRequest := func(url, body string) *http.Request {
req := httptest.NewRequest(http.MethodPost, url, strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return req
}
requireDiscoveryRequestToBeHandled := func(requestIssuer, requestURLSuffix, expectedIssuerInResponse string) {
recorder := httptest.NewRecorder()
@ -106,7 +117,7 @@ func TestManager(t *testing.T) {
return csrfCookieValue, redirectStateParam
}
requireCallbackRequestToBeHandled := func(requestIssuer, requestURLSuffix, csrfCookieValue string) {
requireCallbackRequestToBeHandled := func(requestIssuer, requestURLSuffix, csrfCookieValue string) string {
recorder := httptest.NewRecorder()
numberOfKubeActionsBeforeThisRequest := len(kubeClient.Actions())
@ -139,9 +150,51 @@ func TestManager(t *testing.T) {
// Make sure that we wired up the callback endpoint to use kube storage for fosite sessions.
r.Equal(len(kubeClient.Actions()), numberOfKubeActionsBeforeThisRequest+3,
"did not perform any kube actions during the callback request, but should have")
// Return the important parts of the response so we can use them in our next request to the token endpoint.
return actualLocationQueryParams.Get("code")
}
requireJWKSRequestToBeHandled := func(requestIssuer, requestURLSuffix, expectedJWKKeyID string) {
requireTokenRequestToBeHandled := func(requestIssuer, authCode string, jwks *jose.JSONWebKeySet, jwkIssuer string) {
recorder := httptest.NewRecorder()
numberOfKubeActionsBeforeThisRequest := len(kubeClient.Actions())
tokenRequestBody := url.Values{
"code": []string{authCode},
"client_id": []string{downstreamClientID},
"redirect_uri": []string{downstreamRedirectURL},
"code_verifier": []string{downstreamPKCECodeVerifier},
"grant_type": []string{"authorization_code"},
}.Encode()
subject.ServeHTTP(recorder, newPostRequest(requestIssuer+oidc.TokenEndpointPath, tokenRequestBody))
r.False(fallbackHandlerWasCalled)
// Minimal check to ensure that the right endpoint was called
var body map[string]interface{}
r.Equal(http.StatusOK, recorder.Code)
r.NoError(json.Unmarshal(recorder.Body.Bytes(), &body))
r.Contains(body, "id_token")
r.Contains(body, "access_token")
// Validate ID token is signed by the correct JWK to make sure we wired the token endpoint
// signing key correctly.
idToken, ok := body["id_token"].(string)
r.True(ok, "wanted id_token type to be string, but was %T", body["id_token"])
r.GreaterOrEqual(len(jwks.Keys), 1)
privateKey, ok := jwks.Keys[0].Key.(*ecdsa.PrivateKey)
r.True(ok, "wanted private key to be *ecdsa.PrivateKey, but was %T", jwks.Keys[0].Key)
oidctestutil.VerifyECDSAIDToken(t, jwkIssuer, downstreamClientID, privateKey, idToken)
// Make sure that we wired up the callback endpoint to use kube storage for fosite sessions.
r.Equal(len(kubeClient.Actions()), numberOfKubeActionsBeforeThisRequest+8,
"did not perform any kube actions during the callback request, but should have")
}
requireJWKSRequestToBeHandled := func(requestIssuer, requestURLSuffix, expectedJWKKeyID string) *jose.JSONWebKeySet {
recorder := httptest.NewRecorder()
subject.ServeHTTP(recorder, newGetRequest(requestIssuer+oidc.JWKSEndpointPath+requestURLSuffix))
@ -156,6 +209,8 @@ func TestManager(t *testing.T) {
err = json.Unmarshal(responseBody, &parsedJWKSResult)
r.NoError(err)
r.Equal(expectedJWKKeyID, parsedJWKSResult.Keys[0].KeyID)
return &parsedJWKSResult
}
it.Before(func() {
@ -200,7 +255,7 @@ func TestManager(t *testing.T) {
})
})
newTestJWK := func(keyID string) jose.JSONWebKey {
newTestJWK := func(keyID string) *jose.JSONWebKey {
testJWKSJSONString := here.Docf(`
{
"use": "sig",
@ -208,13 +263,14 @@ func TestManager(t *testing.T) {
"kid": "%s",
"crv": "P-256",
"alg": "ES256",
"x": "awmmj6CIMhSoJyfsqH7sekbTeY72GGPLEy16tPWVz2U",
"y": "FcMh06uXLaq9b2MOixlLVidUkycO1u7IHOkrTi7N0aw"
"x": "9c_oMKjd_ruVIy4pA5y9quT1E-Fampx0w270OtPx5Ng",
"y": "-Y-9nfrtJdFPl-9kzXv55-Fq9Oo2AWDg5zZBs9P-Vds",
"d": "LXdNChAEtGKETBzYXiL_G0wESXceBuajE_EBQbcRuGg"
}
`, keyID)
k := jose.JSONWebKey{}
r.NoError(json.Unmarshal([]byte(testJWKSJSONString), &k))
return k
return &k
}
requireRoutesMatchingRequestsToAppropriateProvider := func() {
@ -227,8 +283,8 @@ func TestManager(t *testing.T) {
requireDiscoveryRequestToBeHandled(issuer2DifferentCaseHostname, "", issuer2)
requireDiscoveryRequestToBeHandled(issuer2DifferentCaseHostname, "?some=query", issuer2)
requireJWKSRequestToBeHandled(issuer1, "", issuer1KeyID)
requireJWKSRequestToBeHandled(issuer2, "", issuer2KeyID)
issuer1JWKS := requireJWKSRequestToBeHandled(issuer1, "", issuer1KeyID)
issuer2JWKS := requireJWKSRequestToBeHandled(issuer2, "", issuer2KeyID)
requireJWKSRequestToBeHandled(issuer2, "?some=query", issuer2KeyID)
// Hostnames are case-insensitive, so test that we can handle that.
@ -239,10 +295,10 @@ func TestManager(t *testing.T) {
authRequestParams := "?" + url.Values{
"response_type": []string{"code"},
"scope": []string{"openid profile email"},
"client_id": []string{"pinniped-cli"},
"client_id": []string{downstreamClientID},
"state": []string{"some-state-value-that-is-32-byte"},
"nonce": []string{"some-nonce-value"},
"code_challenge": []string{"some-challenge"},
"nonce": []string{"some-nonce-value-that-is-at-least-32-bytes"},
"code_challenge": []string{testutil.SHA256(downstreamPKCECodeVerifier)},
"code_challenge_method": []string{"S256"},
"redirect_uri": []string{downstreamRedirectURL},
}.Encode()
@ -260,12 +316,19 @@ func TestManager(t *testing.T) {
"state": []string{upstreamStateParam},
}.Encode()
requireCallbackRequestToBeHandled(issuer1, callbackRequestParams, csrfCookieValue)
requireCallbackRequestToBeHandled(issuer2, callbackRequestParams, csrfCookieValue)
downstreamAuthCode1 := requireCallbackRequestToBeHandled(issuer1, callbackRequestParams, csrfCookieValue)
downstreamAuthCode2 := requireCallbackRequestToBeHandled(issuer2, callbackRequestParams, csrfCookieValue)
// // Hostnames are case-insensitive, so test that we can handle that.
requireCallbackRequestToBeHandled(issuer1DifferentCaseHostname, callbackRequestParams, csrfCookieValue)
requireCallbackRequestToBeHandled(issuer2DifferentCaseHostname, callbackRequestParams, csrfCookieValue)
// Hostnames are case-insensitive, so test that we can handle that.
downstreamAuthCode3 := requireCallbackRequestToBeHandled(issuer1DifferentCaseHostname, callbackRequestParams, csrfCookieValue)
downstreamAuthCode4 := requireCallbackRequestToBeHandled(issuer2DifferentCaseHostname, callbackRequestParams, csrfCookieValue)
requireTokenRequestToBeHandled(issuer1, downstreamAuthCode1, issuer1JWKS, issuer1)
requireTokenRequestToBeHandled(issuer2, downstreamAuthCode2, issuer2JWKS, issuer2)
// Hostnames are case-insensitive, so test that we can handle that.
requireTokenRequestToBeHandled(issuer1DifferentCaseHostname, downstreamAuthCode3, issuer1JWKS, issuer1)
requireTokenRequestToBeHandled(issuer2DifferentCaseHostname, downstreamAuthCode4, issuer2JWKS, issuer2)
}
when("given some valid providers via SetProviders()", func() {
@ -276,10 +339,15 @@ func TestManager(t *testing.T) {
r.NoError(err)
subject.SetProviders(p1, p2)
dynamicJWKSProvider.SetIssuerToJWKSMap(map[string]*jose.JSONWebKeySet{
issuer1: {Keys: []jose.JSONWebKey{newTestJWK(issuer1KeyID)}},
issuer2: {Keys: []jose.JSONWebKey{newTestJWK(issuer2KeyID)}},
})
jwks := map[string]*jose.JSONWebKeySet{
issuer1: {Keys: []jose.JSONWebKey{*newTestJWK(issuer1KeyID)}},
issuer2: {Keys: []jose.JSONWebKey{*newTestJWK(issuer2KeyID)}},
}
activeJWK := map[string]*jose.JSONWebKey{
issuer1: newTestJWK(issuer1KeyID),
issuer2: newTestJWK(issuer2KeyID),
}
dynamicJWKSProvider.SetIssuerToJWKSMap(jwks, activeJWK)
})
it("sends all non-matching host requests to the nextHandler", func() {
@ -314,10 +382,15 @@ func TestManager(t *testing.T) {
r.NoError(err)
subject.SetProviders(p2, p1)
dynamicJWKSProvider.SetIssuerToJWKSMap(map[string]*jose.JSONWebKeySet{
issuer1: {Keys: []jose.JSONWebKey{newTestJWK(issuer1KeyID)}},
issuer2: {Keys: []jose.JSONWebKey{newTestJWK(issuer2KeyID)}},
})
jwks := map[string]*jose.JSONWebKeySet{
issuer1: {Keys: []jose.JSONWebKey{*newTestJWK(issuer1KeyID)}},
issuer2: {Keys: []jose.JSONWebKey{*newTestJWK(issuer2KeyID)}},
}
activeJWK := map[string]*jose.JSONWebKey{
issuer1: newTestJWK(issuer1KeyID),
issuer2: newTestJWK(issuer2KeyID),
}
dynamicJWKSProvider.SetIssuerToJWKSMap(jwks, activeJWK)
})
it("still routes matching requests to the appropriate provider", func() {

View File

@ -0,0 +1,41 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
// Package token provides a handler for the OIDC token endpoint.
package token
import (
"net/http"
"github.com/ory/fosite"
"github.com/ory/fosite/handler/openid"
"go.pinniped.dev/internal/httputil/httperr"
"go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/plog"
)
func NewHandler(
oauthHelper fosite.OAuth2Provider,
) http.Handler {
return httperr.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
var session openid.DefaultSession
accessRequest, err := oauthHelper.NewAccessRequest(r.Context(), r, &session)
if err != nil {
plog.Info("token request error", oidc.FositeErrorForLog(err)...)
oauthHelper.WriteAccessError(w, accessRequest, err)
return nil
}
accessResponse, err := oauthHelper.NewAccessResponse(r.Context(), accessRequest)
if err != nil {
plog.Info("token response error", oidc.FositeErrorForLog(err)...)
oauthHelper.WriteAccessError(w, accessRequest, err)
return nil
}
oauthHelper.WriteAccessResponse(w, accessRequest, accessResponse)
return nil
})
}

View File

@ -0,0 +1,991 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package token
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/json"
"io"
"io/ioutil"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/ory/fosite"
"github.com/ory/fosite/handler/oauth2"
"github.com/ory/fosite/handler/openid"
"github.com/ory/fosite/handler/pkce"
"github.com/ory/fosite/token/jwt"
"github.com/pkg/errors"
"github.com/stretchr/testify/require"
"gopkg.in/square/go-jose.v2"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/client-go/kubernetes/fake"
"go.pinniped.dev/internal/crud"
"go.pinniped.dev/internal/fositestorage/accesstoken"
"go.pinniped.dev/internal/fositestorage/authorizationcode"
"go.pinniped.dev/internal/fositestorage/openidconnect"
storagepkce "go.pinniped.dev/internal/fositestorage/pkce"
"go.pinniped.dev/internal/fositestorage/refreshtoken"
"go.pinniped.dev/internal/here"
"go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/oidc/jwks"
"go.pinniped.dev/internal/oidc/oidctestutil"
"go.pinniped.dev/internal/testutil"
)
const (
goodIssuer = "https://some-issuer.com"
goodClient = "pinniped-cli"
goodRedirectURI = "http://127.0.0.1/callback"
goodPKCECodeVerifier = "some-pkce-verifier-that-must-be-at-least-43-characters-to-meet-entropy-requirements"
goodNonce = "some-nonce-that-is-at-least-32-characters-to-meet-entropy-requirements"
goodSubject = "some-subject"
goodUsername = "some-username"
hmacSecret = "this needs to be at least 32 characters to meet entropy requirements"
authCodeExpirationSeconds = 3 * 60 // Current, we set our auth code expiration to 3 minutes
accessTokenExpirationSeconds = 5 * 60 // Currently, we set our access token expiration to 5 minutes
idTokenExpirationSeconds = 5 * 60 // Currently, we set our ID token expiration to 5 minutes
timeComparisonFudgeSeconds = 15
)
var (
goodAuthTime = time.Date(1, 2, 3, 4, 5, 6, 7, time.UTC)
goodRequestedAtTime = time.Date(7, 6, 5, 4, 3, 2, 1, time.UTC)
fositeInvalidMethodErrorBody = func(actual string) string {
return here.Docf(`
{
"error": "invalid_request",
"error_verbose": "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed",
"error_description": "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed\n\nHTTP method is \"%s\", expected \"POST\".",
"error_hint": "HTTP method is \"%s\", expected \"POST\".",
"status_code": 400
}
`, actual, actual)
}
fositeMissingGrantTypeErrorBody = here.Docf(`
{
"error": "invalid_request",
"error_verbose": "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed",
"error_description": "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed\n\nRequest parameter \"grant_type\"\" is missing",
"error_hint": "Request parameter \"grant_type\"\" is missing",
"status_code": 400
}
`)
fositeEmptyPayloadErrorBody = here.Doc(`
{
"error": "invalid_request",
"error_verbose": "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed",
"error_description": "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed\n\nThe POST body can not be empty.",
"error_hint": "The POST body can not be empty.",
"status_code": 400
}
`)
fositeInvalidPayloadErrorBody = here.Doc(`
{
"error": "invalid_request",
"error_verbose": "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed",
"error_description": "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed\n\nUnable to parse HTTP body, make sure to send a properly formatted form request body.",
"error_hint": "Unable to parse HTTP body, make sure to send a properly formatted form request body.",
"status_code": 400
}
`)
fositeInvalidRequestErrorBody = here.Doc(`
{
"error": "invalid_request",
"error_verbose": "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed",
"error_description": "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed\n\nMake sure that the various parameters are correct, be aware of case sensitivity and trim your parameters. Make sure that the client you are using has exactly whitelisted the redirect_uri you specified.",
"error_hint": "Make sure that the various parameters are correct, be aware of case sensitivity and trim your parameters. Make sure that the client you are using has exactly whitelisted the redirect_uri you specified.",
"status_code": 400
}
`)
fositeMissingClientErrorBody = here.Doc(`
{
"error": "invalid_request",
"error_verbose": "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed",
"error_description": "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed\n\nClient credentials missing or malformed in both HTTP Authorization header and HTTP POST body.",
"error_hint": "Client credentials missing or malformed in both HTTP Authorization header and HTTP POST body.",
"status_code": 400
}
`)
fositeInvalidClientErrorBody = here.Doc(`
{
"error": "invalid_client",
"error_verbose": "Client authentication failed (e.g., unknown client, no client authentication included, or unsupported authentication method)",
"error_description": "Client authentication failed (e.g., unknown client, no client authentication included, or unsupported authentication method)",
"status_code": 401
}
`)
fositeInvalidAuthCodeErrorBody = here.Doc(`
{
"error": "invalid_grant",
"error_verbose": "The provided authorization grant (e.g., authorization code, resource owner credentials) or refresh token is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client",
"error_description": "The provided authorization grant (e.g., authorization code, resource owner credentials) or refresh token is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client",
"status_code": 400
}
`)
fositeReusedAuthCodeErrorBody = here.Doc(`
{
"error": "invalid_grant",
"error_verbose": "The provided authorization grant (e.g., authorization code, resource owner credentials) or refresh token is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client",
"error_description": "The provided authorization grant (e.g., authorization code, resource owner credentials) or refresh token is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client\n\nThe authorization code has already been used.",
"error_hint": "The authorization code has already been used.",
"status_code": 400
}
`)
fositeInvalidRedirectURIErrorBody = here.Doc(`
{
"error": "invalid_grant",
"error_verbose": "The provided authorization grant (e.g., authorization code, resource owner credentials) or refresh token is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client",
"error_description": "The provided authorization grant (e.g., authorization code, resource owner credentials) or refresh token is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client\n\nThe \"redirect_uri\" from this request does not match the one from the authorize request.",
"error_hint": "The \"redirect_uri\" from this request does not match the one from the authorize request.",
"status_code": 400
}
`)
fositeMissingPKCEVerifierErrorBody = here.Doc(`
{
"error": "invalid_grant",
"error_verbose": "The provided authorization grant (e.g., authorization code, resource owner credentials) or refresh token is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client",
"error_description": "The provided authorization grant (e.g., authorization code, resource owner credentials) or refresh token is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client\n\nThe PKCE code verifier must be at least 43 characters.",
"error_hint": "The PKCE code verifier must be at least 43 characters.",
"status_code": 400
}
`)
fositeWrongPKCEVerifierErrorBody = here.Doc(`
{
"error": "invalid_grant",
"error_verbose": "The provided authorization grant (e.g., authorization code, resource owner credentials) or refresh token is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client",
"error_description": "The provided authorization grant (e.g., authorization code, resource owner credentials) or refresh token is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client\n\nThe PKCE code challenge did not match the code verifier.",
"error_hint": "The PKCE code challenge did not match the code verifier.",
"status_code": 400
}
`)
fositeTemporarilyUnavailableErrorBody = here.Doc(`
{
"error": "temporarily_unavailable",
"error_description": "The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server",
"error_verbose": "The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server",
"status_code": 503
}
`)
)
func TestTokenEndpoint(t *testing.T) {
happyAuthRequest := &http.Request{
Form: url.Values{
"response_type": {"code"},
"scope": {"openid profile email"},
"client_id": {goodClient},
"state": {"some-state-value-that-is-32-byte"},
"nonce": {goodNonce},
"code_challenge": {testutil.SHA256(goodPKCECodeVerifier)},
"code_challenge_method": {"S256"},
"redirect_uri": {goodRedirectURI},
},
}
tests := []struct {
name string
authRequest func(authRequest *http.Request)
storage func(
t *testing.T,
s interface {
oauth2.TokenRevocationStorage
oauth2.CoreStorage
openid.OpenIDConnectRequestStorage
pkce.PKCERequestStorage
fosite.ClientManager
},
authCode string,
)
request func(r *http.Request, authCode string)
makeOathHelper func(
t *testing.T,
authRequest *http.Request,
store interface {
oauth2.TokenRevocationStorage
oauth2.CoreStorage
openid.OpenIDConnectRequestStorage
pkce.PKCERequestStorage
fosite.ClientManager
},
) (fosite.OAuth2Provider, string, *ecdsa.PrivateKey)
wantStatus int
wantBodyFields []string
wantExactBody string
}{
// happy path
{
name: "request is valid and tokens are issued",
wantStatus: http.StatusOK,
wantBodyFields: []string{"id_token", "access_token", "token_type", "scope", "expires_in"},
},
{
name: "openid scope was not requested from authorize endpoint",
authRequest: func(authRequest *http.Request) {
authRequest.Form.Set("scope", "profile email")
},
wantStatus: http.StatusOK,
wantBodyFields: []string{"access_token", "token_type", "scope", "expires_in"},
},
// sad path
{
name: "GET method is wrong",
request: func(r *http.Request, authCode string) { r.Method = http.MethodGet },
wantStatus: http.StatusBadRequest,
wantExactBody: fositeInvalidMethodErrorBody("GET"),
},
{
name: "PUT method is wrong",
request: func(r *http.Request, authCode string) { r.Method = http.MethodPut },
wantStatus: http.StatusBadRequest,
wantExactBody: fositeInvalidMethodErrorBody("PUT"),
},
{
name: "PATCH method is wrong",
request: func(r *http.Request, authCode string) { r.Method = http.MethodPatch },
wantStatus: http.StatusBadRequest,
wantExactBody: fositeInvalidMethodErrorBody("PATCH"),
},
{
name: "DELETE method is wrong",
request: func(r *http.Request, authCode string) { r.Method = http.MethodDelete },
wantStatus: http.StatusBadRequest,
wantExactBody: fositeInvalidMethodErrorBody("DELETE"),
},
{
name: "content type is invalid",
request: func(r *http.Request, authCode string) { r.Header.Set("Content-Type", "text/plain") },
wantStatus: http.StatusBadRequest,
wantExactBody: fositeEmptyPayloadErrorBody,
},
{
name: "payload is not valid form serialization",
request: func(r *http.Request, authCode string) {
r.Body = ioutil.NopCloser(strings.NewReader("this newline character is not allowed in a form serialization: \n"))
},
wantStatus: http.StatusBadRequest,
wantExactBody: fositeMissingGrantTypeErrorBody,
},
{
name: "payload is empty",
request: func(r *http.Request, authCode string) { r.Body = nil },
wantStatus: http.StatusBadRequest,
wantExactBody: fositeInvalidPayloadErrorBody,
},
{
name: "grant type is missing in request",
request: func(r *http.Request, authCode string) {
r.Body = happyBody(authCode).WithGrantType("").ReadCloser()
},
wantStatus: http.StatusBadRequest,
wantExactBody: fositeMissingGrantTypeErrorBody,
},
{
name: "grant type is not authorization_code",
request: func(r *http.Request, authCode string) {
r.Body = happyBody(authCode).WithGrantType("bogus").ReadCloser()
},
wantStatus: http.StatusBadRequest,
wantExactBody: fositeInvalidRequestErrorBody,
},
{
name: "client id is missing in request",
request: func(r *http.Request, authCode string) {
r.Body = happyBody(authCode).WithClientID("").ReadCloser()
},
wantStatus: http.StatusBadRequest,
wantExactBody: fositeMissingClientErrorBody,
},
{
name: "client id is wrong",
request: func(r *http.Request, authCode string) {
r.Body = happyBody(authCode).WithClientID("bogus").ReadCloser()
},
wantStatus: http.StatusUnauthorized,
wantExactBody: fositeInvalidClientErrorBody,
},
{
name: "auth code is missing in request",
request: func(r *http.Request, authCode string) {
r.Body = happyBody(authCode).WithAuthCode("").ReadCloser()
},
wantStatus: http.StatusBadRequest,
wantExactBody: fositeInvalidAuthCodeErrorBody,
},
{
name: "auth code has never been valid",
request: func(r *http.Request, authCode string) {
r.Body = happyBody(authCode).WithAuthCode("bogus").ReadCloser()
},
wantStatus: http.StatusBadRequest,
wantExactBody: fositeInvalidAuthCodeErrorBody,
},
{
name: "auth code is invalidated",
storage: func(
t *testing.T,
s interface {
oauth2.TokenRevocationStorage
oauth2.CoreStorage
openid.OpenIDConnectRequestStorage
pkce.PKCERequestStorage
fosite.ClientManager
},
authCode string,
) {
err := s.InvalidateAuthorizeCodeSession(context.Background(), getFositeDataSignature(t, authCode))
require.NoError(t, err)
},
wantStatus: http.StatusBadRequest,
wantExactBody: fositeReusedAuthCodeErrorBody,
},
{
name: "redirect uri is missing in request",
request: func(r *http.Request, authCode string) {
r.Body = happyBody(authCode).WithRedirectURI("").ReadCloser()
},
wantStatus: http.StatusBadRequest,
wantExactBody: fositeInvalidRedirectURIErrorBody,
},
{
name: "redirect uri is wrong",
request: func(r *http.Request, authCode string) {
r.Body = happyBody(authCode).WithRedirectURI("bogus").ReadCloser()
},
wantStatus: http.StatusBadRequest,
wantExactBody: fositeInvalidRedirectURIErrorBody,
},
{
name: "pkce is missing in request",
request: func(r *http.Request, authCode string) {
r.Body = happyBody(authCode).WithPKCE("").ReadCloser()
},
wantStatus: http.StatusBadRequest,
wantExactBody: fositeMissingPKCEVerifierErrorBody,
},
{
name: "pkce is wrong",
request: func(r *http.Request, authCode string) {
r.Body = happyBody(authCode).WithPKCE(
"bogus-verifier-that-is-at-least-43-characters-for-the-sake-of-entropy",
).ReadCloser()
},
wantStatus: http.StatusBadRequest,
wantExactBody: fositeWrongPKCEVerifierErrorBody,
},
{
name: "private signing key for JWTs has not yet been provided by the controller who is responsible for dynamically providing it",
makeOathHelper: makeOauthHelperWithNilPrivateJWTSigningKey,
wantStatus: http.StatusServiceUnavailable,
wantExactBody: fositeTemporarilyUnavailableErrorBody,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
authRequest := deepCopyRequestForm(happyAuthRequest)
if test.authRequest != nil {
test.authRequest(authRequest)
}
client := fake.NewSimpleClientset()
secrets := client.CoreV1().Secrets("some-namespace")
var oauthHelper fosite.OAuth2Provider
var authCode string
var jwtSigningKey *ecdsa.PrivateKey
oauthStore := oidc.NewKubeStorage(secrets)
if test.makeOathHelper != nil {
oauthHelper, authCode, jwtSigningKey = test.makeOathHelper(t, authRequest, oauthStore)
} else {
oauthHelper, authCode, jwtSigningKey = makeHappyOauthHelper(t, authRequest, oauthStore)
}
if test.storage != nil {
test.storage(t, oauthStore, authCode)
}
subject := NewHandler(oauthHelper)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: authorizationcode.TypeLabelValue}, 1)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: storagepkce.TypeLabelValue}, 1)
if strings.Contains(authRequest.Form.Get("scope"), "openid") {
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: openidconnect.TypeLabelValue}, 1)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{}, 3)
} else {
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{}, 2)
}
req := httptest.NewRequest("POST", "/path/shouldn't/matter", happyBody(authCode).ReadCloser())
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if test.request != nil {
test.request(req, authCode)
}
rsp := httptest.NewRecorder()
subject.ServeHTTP(rsp, req)
t.Logf("response: %#v", rsp)
t.Logf("response body: %q", rsp.Body.String())
require.Equal(t, test.wantStatus, rsp.Code)
testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), "application/json")
if test.wantBodyFields != nil {
var m map[string]interface{}
require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &m))
require.ElementsMatch(t, test.wantBodyFields, getMapKeys(m))
code := req.PostForm.Get("code")
wantOpenidScope := contains(test.wantBodyFields, "id_token")
requireInvalidAuthCodeStorage(t, code, oauthStore)
requireValidAccessTokenStorage(t, m, oauthStore, wantOpenidScope)
requireInvalidPKCEStorage(t, code, oauthStore)
requireValidOIDCStorage(t, m, code, oauthStore, wantOpenidScope)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: authorizationcode.TypeLabelValue}, 1)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: accesstoken.TypeLabelValue}, 1)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: refreshtoken.TypeLabelValue}, 0)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: storagepkce.TypeLabelValue}, 0)
if wantOpenidScope {
requireValidIDToken(t, m, jwtSigningKey)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: openidconnect.TypeLabelValue}, 1)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{}, 3)
} else {
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{}, 2)
}
} else {
require.JSONEq(t, test.wantExactBody, rsp.Body.String())
}
})
}
t.Run("auth code is used twice", func(t *testing.T) {
authRequest := deepCopyRequestForm(happyAuthRequest)
client := fake.NewSimpleClientset()
secrets := client.CoreV1().Secrets("some-namespace")
oauthStore := oidc.NewKubeStorage(secrets)
oauthHelper, authCode, jwtSigningKey := makeHappyOauthHelper(t, authRequest, oauthStore)
subject := NewHandler(oauthHelper)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: authorizationcode.TypeLabelValue}, 1)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: storagepkce.TypeLabelValue}, 1)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: openidconnect.TypeLabelValue}, 1)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{}, 3)
req := httptest.NewRequest("POST", "/path/shouldn't/matter", happyBody(authCode).ReadCloser())
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// First call - should be successful.
rsp0 := httptest.NewRecorder()
subject.ServeHTTP(rsp0, req)
t.Logf("response 0: %#v", rsp0)
t.Logf("response 0 body: %q", rsp0.Body.String())
testutil.RequireEqualContentType(t, rsp0.Header().Get("Content-Type"), "application/json")
require.Equal(t, http.StatusOK, rsp0.Code)
var m map[string]interface{}
require.NoError(t, json.Unmarshal(rsp0.Body.Bytes(), &m))
wantBodyFields := []string{"id_token", "access_token", "token_type", "expires_in", "scope"}
require.ElementsMatch(t, wantBodyFields, getMapKeys(m))
code := req.PostForm.Get("code")
wantOpenidScope := true
requireInvalidAuthCodeStorage(t, code, oauthStore)
requireValidAccessTokenStorage(t, m, oauthStore, wantOpenidScope)
requireInvalidPKCEStorage(t, code, oauthStore)
requireValidOIDCStorage(t, m, code, oauthStore, wantOpenidScope)
requireValidIDToken(t, m, jwtSigningKey)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: authorizationcode.TypeLabelValue}, 1)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: accesstoken.TypeLabelValue}, 1)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: refreshtoken.TypeLabelValue}, 0)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: storagepkce.TypeLabelValue}, 0)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: openidconnect.TypeLabelValue}, 1)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{}, 3)
// Second call - should be unsuccessful since auth code was already used.
//
// Fosite will also revoke the access token as is recommended by the OIDC spec. Currently, we don't
// delete the OIDC storage...but we probably should.
rsp1 := httptest.NewRecorder()
subject.ServeHTTP(rsp1, req)
t.Logf("response 1: %#v", rsp1)
t.Logf("response 1 body: %q", rsp1.Body.String())
require.Equal(t, http.StatusBadRequest, rsp1.Code)
testutil.RequireEqualContentType(t, rsp1.Header().Get("Content-Type"), "application/json")
require.JSONEq(t, fositeReusedAuthCodeErrorBody, rsp1.Body.String())
requireInvalidAuthCodeStorage(t, code, oauthStore)
requireInvalidAccessTokenStorage(t, m, oauthStore)
requireInvalidPKCEStorage(t, code, oauthStore)
requireValidOIDCStorage(t, m, code, oauthStore, wantOpenidScope)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: authorizationcode.TypeLabelValue}, 1)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: accesstoken.TypeLabelValue}, 0)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: refreshtoken.TypeLabelValue}, 0)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: storagepkce.TypeLabelValue}, 0)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: openidconnect.TypeLabelValue}, 1)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{}, 2)
})
}
type body url.Values
func happyBody(happyAuthCode string) body {
return map[string][]string{
"grant_type": {"authorization_code"},
"code": {happyAuthCode},
"redirect_uri": {goodRedirectURI},
"code_verifier": {goodPKCECodeVerifier},
"client_id": {goodClient},
}
}
func (b body) WithGrantType(grantType string) body {
return b.with("grant_type", grantType)
}
func (b body) WithClientID(clientID string) body {
return b.with("client_id", clientID)
}
func (b body) WithAuthCode(code string) body {
return b.with("code", code)
}
func (b body) WithRedirectURI(redirectURI string) body {
return b.with("redirect_uri", redirectURI)
}
func (b body) WithPKCE(verifier string) body {
return b.with("code_verifier", verifier)
}
func (b body) ReadCloser() io.ReadCloser {
return ioutil.NopCloser(strings.NewReader(url.Values(b).Encode()))
}
func (b body) with(param, value string) body {
if value == "" {
url.Values(b).Del(param)
} else {
url.Values(b).Set(param, value)
}
return b
}
// getFositeDataSignature returns the signature of the provided data. The provided data could be an auth code, access
// token, etc. It is assumed that the code is of the format "data.signature", which is how Fosite generates auth codes
// and access tokens.
func getFositeDataSignature(t *testing.T, data string) string {
split := strings.Split(data, ".")
require.Len(t, split, 2)
return split[1]
}
func makeHappyOauthHelper(
t *testing.T,
authRequest *http.Request,
store interface {
oauth2.TokenRevocationStorage
oauth2.CoreStorage
openid.OpenIDConnectRequestStorage
pkce.PKCERequestStorage
fosite.ClientManager
},
) (fosite.OAuth2Provider, string, *ecdsa.PrivateKey) {
t.Helper()
jwtSigningKey, jwkProvider := generateJWTSigningKeyAndJWKSProvider(t, goodIssuer)
oauthHelper := oidc.FositeOauth2Helper(store, goodIssuer, []byte(hmacSecret), jwkProvider)
authResponder := simulateAuthEndpointHavingAlreadyRun(t, authRequest, oauthHelper)
return oauthHelper, authResponder.GetCode(), jwtSigningKey
}
func makeOauthHelperWithNilPrivateJWTSigningKey(
t *testing.T,
authRequest *http.Request,
store interface {
oauth2.TokenRevocationStorage
oauth2.CoreStorage
openid.OpenIDConnectRequestStorage
pkce.PKCERequestStorage
fosite.ClientManager
},
) (fosite.OAuth2Provider, string, *ecdsa.PrivateKey) {
t.Helper()
jwkProvider := jwks.NewDynamicJWKSProvider() // empty provider which contains no signing key for this issuer
oauthHelper := oidc.FositeOauth2Helper(store, goodIssuer, []byte(hmacSecret), jwkProvider)
authResponder := simulateAuthEndpointHavingAlreadyRun(t, authRequest, oauthHelper)
return oauthHelper, authResponder.GetCode(), nil
}
func simulateAuthEndpointHavingAlreadyRun(t *testing.T, authRequest *http.Request, oauthHelper fosite.OAuth2Provider) fosite.AuthorizeResponder {
// Simulate the auth endpoint running so Fosite code will fill the store with realistic values.
//
// We only set the fields in the session that Fosite wants us to set.
ctx := context.Background()
session := &openid.DefaultSession{
Claims: &jwt.IDTokenClaims{
Subject: goodSubject,
AuthTime: goodAuthTime,
RequestedAt: goodRequestedAtTime,
},
Subject: goodSubject,
Username: goodUsername,
}
authRequester, err := oauthHelper.NewAuthorizeRequest(ctx, authRequest)
require.NoError(t, err)
if strings.Contains(authRequest.Form.Get("scope"), "openid") {
authRequester.GrantScope("openid")
}
authResponder, err := oauthHelper.NewAuthorizeResponse(ctx, authRequester, session)
require.NoError(t, err)
return authResponder
}
func generateJWTSigningKeyAndJWKSProvider(t *testing.T, issuer string) (*ecdsa.PrivateKey, jwks.DynamicJWKSProvider) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
jwksProvider := jwks.NewDynamicJWKSProvider()
jwksProvider.SetIssuerToJWKSMap(
nil, // public JWKS unused
map[string]*jose.JSONWebKey{
issuer: {Key: key},
},
)
return key, jwksProvider
}
func requireInvalidAuthCodeStorage(
t *testing.T,
code string,
storage oauth2.CoreStorage,
) {
t.Helper()
// Make sure we have invalidated this auth code.
_, err := storage.GetAuthorizeCodeSession(context.Background(), getFositeDataSignature(t, code), nil)
require.True(t, errors.Is(err, fosite.ErrInvalidatedAuthorizeCode))
}
func requireValidAccessTokenStorage(
t *testing.T,
body map[string]interface{},
storage oauth2.CoreStorage,
wantGrantedOpenidScope bool,
) {
t.Helper()
// Get the access token, and make sure we can use it to perform a lookup on the storage.
accessToken, ok := body["access_token"]
require.True(t, ok)
accessTokenString, ok := accessToken.(string)
require.Truef(t, ok, "wanted access_token to be a string, but got %T", accessToken)
authRequest, err := storage.GetAccessTokenSession(context.Background(), getFositeDataSignature(t, accessTokenString), nil)
require.NoError(t, err)
// Make sure the other body fields are valid.
tokenType, ok := body["token_type"]
require.True(t, ok)
tokenTypeString, ok := tokenType.(string)
require.Truef(t, ok, "wanted token_type to be a string, but got %T", tokenType)
require.Equal(t, "bearer", tokenTypeString)
expiresIn, ok := body["expires_in"]
require.True(t, ok)
expiresInNumber, ok := expiresIn.(float64) // Go unmarshals JSON numbers to float64, see `go doc encoding/json`
require.Truef(t, ok, "wanted expires_in to be an float64, but got %T", expiresIn)
require.InDelta(t, accessTokenExpirationSeconds, expiresInNumber, timeComparisonFudgeSeconds)
scopes, ok := body["scope"]
require.True(t, ok)
scopesString, ok := scopes.(string)
require.Truef(t, ok, "wanted scopes to be an string, but got %T", scopes)
wantScopes := ""
if wantGrantedOpenidScope {
wantScopes += "openid"
}
require.Equal(t, wantScopes, scopesString)
// Fosite stores access tokens without any of the original request form pararmeters.
requireValidAuthRequest(
t,
authRequest,
authRequest.Sanitize([]string{}).GetRequestForm(),
wantGrantedOpenidScope,
true,
)
}
func requireInvalidAccessTokenStorage(
t *testing.T,
body map[string]interface{},
storage oauth2.CoreStorage,
) {
t.Helper()
// Get the access token, and make sure we can use it to perform a lookup on the storage.
accessToken, ok := body["access_token"]
require.True(t, ok)
accessTokenString, ok := accessToken.(string)
require.Truef(t, ok, "wanted access_token to be a string, but got %T", accessToken)
_, err := storage.GetAccessTokenSession(context.Background(), getFositeDataSignature(t, accessTokenString), nil)
require.True(t, errors.Is(err, fosite.ErrNotFound))
}
func requireInvalidPKCEStorage(
t *testing.T,
code string,
storage pkce.PKCERequestStorage,
) {
t.Helper()
// Make sure the PKCE session has been deleted. Note that Fosite stores PKCE codes using the auth code signature
// as a key.
_, err := storage.GetPKCERequestSession(context.Background(), getFositeDataSignature(t, code), nil)
require.True(t, errors.Is(err, fosite.ErrNotFound))
}
func requireValidOIDCStorage(
t *testing.T,
body map[string]interface{},
code string,
storage openid.OpenIDConnectRequestStorage,
wantGrantedOpenidScope bool,
) {
t.Helper()
if wantGrantedOpenidScope {
// Make sure the OIDC session is still there. Note that Fosite stores OIDC sessions using the full auth code as a key.
authRequest, err := storage.GetOpenIDConnectSession(context.Background(), code, nil)
require.NoError(t, err)
// Fosite stores OIDC sessions with only the nonce in the original request form.
accessToken, ok := body["access_token"]
require.True(t, ok)
accessTokenString, ok := accessToken.(string)
require.Truef(t, ok, "wanted access_token to be a string, but got %T", accessToken)
require.NotEmpty(t, accessTokenString)
requireValidAuthRequest(
t,
authRequest,
authRequest.Sanitize([]string{"nonce"}).GetRequestForm(),
true,
false,
)
} else {
_, err := storage.GetOpenIDConnectSession(context.Background(), code, nil)
require.True(t, errors.Is(err, fosite.ErrNotFound))
}
}
func requireValidAuthRequest(
t *testing.T,
authRequest fosite.Requester,
wantRequestForm url.Values,
wantGrantedOpenidScope bool,
wantAccessTokenExpiresAt bool,
) {
t.Helper()
// Assert that the getters on the authRequest return what we think they should.
wantRequestedScopes := []string{"profile", "email"}
wantGrantedScopes := []string{}
if wantGrantedOpenidScope {
wantRequestedScopes = append([]string{"openid"}, wantRequestedScopes...)
wantGrantedScopes = append([]string{"openid"}, wantGrantedScopes...)
}
require.NotEmpty(t, authRequest.GetID())
testutil.RequireTimeInDelta(t, authRequest.GetRequestedAt(), time.Now().UTC(), timeComparisonFudgeSeconds*time.Second)
require.Equal(t, goodClient, authRequest.GetClient().GetID())
require.Equal(t, fosite.Arguments(wantRequestedScopes), authRequest.GetRequestedScopes())
require.Equal(t, fosite.Arguments(wantGrantedScopes), authRequest.GetGrantedScopes())
require.Empty(t, authRequest.GetRequestedAudience())
require.Empty(t, authRequest.GetGrantedAudience())
require.Equal(t, wantRequestForm, authRequest.GetRequestForm()) // Fosite stores access token request without form
// Cast session to the type we think it should be.
session, ok := authRequest.GetSession().(*openid.DefaultSession)
require.Truef(t, ok, "could not cast %T to %T", authRequest.GetSession(), &openid.DefaultSession{})
// Assert that the session claims are what we think they should be, but only if we are doing OIDC.
if wantGrantedOpenidScope {
claims := session.Claims
require.Empty(t, claims.JTI) // When claims.JTI is empty, Fosite will generate a UUID for this field.
require.Equal(t, goodSubject, claims.Subject)
// We are in charge of setting these fields. For the purpose of testing, we ensure that the
// sentinel test value is set correctly.
require.Equal(t, goodRequestedAtTime, claims.RequestedAt)
require.Equal(t, goodAuthTime, claims.AuthTime)
// These fields will all be given good defaults by fosite at runtime and we only need to use them
// if we want to override the default behaviors. We currently don't need to override these defaults,
// so they do not end up being stored. Fosite sets its defaults at runtime in openid.DefaultSession's
// GenerateIDToken() method.
require.Empty(t, claims.Issuer)
require.Empty(t, claims.Audience)
require.Empty(t, claims.Nonce)
require.Zero(t, claims.ExpiresAt)
require.Zero(t, claims.IssuedAt)
// Fosite unconditionally overwrites claims.AccessTokenHash at runtime in openid.OpenIDConnectExplicitHandler's
// PopulateTokenEndpointResponse() method, just before it calls the same GenerateIDToken() mentioned above,
// so it does not end up saved in storage.
require.Empty(t, claims.AccessTokenHash)
// At this time, we don't use any of these optional (per the OIDC spec) fields.
require.Empty(t, claims.AuthenticationContextClassReference)
require.Empty(t, claims.AuthenticationMethodsReference)
require.Empty(t, claims.CodeHash)
require.Empty(t, claims.Extra)
}
// Assert that the session headers are what we think they should be.
headers := session.Headers
require.Empty(t, headers)
// Assert that the token expirations are what we think they should be.
authCodeExpiresAt, ok := session.ExpiresAt[fosite.AuthorizeCode]
require.True(t, ok, "expected session to hold expiration time for auth code")
testutil.RequireTimeInDelta(
t,
time.Now().UTC().Add(authCodeExpirationSeconds*time.Second),
authCodeExpiresAt,
timeComparisonFudgeSeconds*time.Second,
)
// OpenID Connect sessions do not store access token expiration information.
accessTokenExpiresAt, ok := session.ExpiresAt[fosite.AccessToken]
if wantAccessTokenExpiresAt {
require.True(t, ok, "expected session to hold expiration time for access token")
testutil.RequireTimeInDelta(
t,
time.Now().UTC().Add(accessTokenExpirationSeconds*time.Second),
accessTokenExpiresAt,
timeComparisonFudgeSeconds*time.Second,
)
} else {
require.False(t, ok, "expected session to not hold expiration time for access token, but it did")
}
// Assert that the session's username and subject are correct.
require.Equal(t, goodUsername, session.Username)
require.Equal(t, goodSubject, session.Subject)
}
func requireValidIDToken(t *testing.T, body map[string]interface{}, jwtSigningKey *ecdsa.PrivateKey) {
t.Helper()
idToken, ok := body["id_token"]
require.Truef(t, ok, "body did not contain 'id_token': %s", body)
idTokenString, ok := idToken.(string)
require.Truef(t, ok, "wanted id_token to be a string, but got %T", idToken)
// The go-oidc library will validate the signature and the client claim in the ID token.
token := oidctestutil.VerifyECDSAIDToken(t, goodIssuer, goodClient, jwtSigningKey, idTokenString)
var claims struct {
Subject string `json:"sub"`
Audience []string `json:"aud"`
Issuer string `json:"iss"`
JTI string `json:"jti"`
Nonce string `json:"nonce"`
AccessTokenHash string `json:"at_hash"`
ExpiresAt int64 `json:"exp"`
IssuedAt int64 `json:"iat"`
RequestedAt int64 `json:"rat"`
AuthTime int64 `json:"auth_time"`
}
// Note that there is a bug in fosite which prevents the `at_hash` claim from appearing in this ID token.
// We can add a workaround for this later.
idTokenFields := []string{"sub", "aud", "iss", "jti", "nonce", "auth_time", "exp", "iat", "rat"}
// make sure that these are the only fields in the token
var m map[string]interface{}
require.NoError(t, token.Claims(&m))
require.ElementsMatch(t, idTokenFields, getMapKeys(m))
// verify each of the claims
err := token.Claims(&claims)
require.NoError(t, err)
require.Equal(t, goodSubject, claims.Subject)
require.Len(t, claims.Audience, 1)
require.Equal(t, goodClient, claims.Audience[0])
require.Equal(t, goodIssuer, claims.Issuer)
require.NotEmpty(t, claims.JTI)
require.Equal(t, goodNonce, claims.Nonce)
expiresAt := time.Unix(claims.ExpiresAt, 0)
issuedAt := time.Unix(claims.IssuedAt, 0)
requestedAt := time.Unix(claims.RequestedAt, 0)
authTime := time.Unix(claims.AuthTime, 0)
testutil.RequireTimeInDelta(t, time.Now().UTC().Add(idTokenExpirationSeconds*time.Second), expiresAt, timeComparisonFudgeSeconds*time.Second)
testutil.RequireTimeInDelta(t, time.Now().UTC(), issuedAt, timeComparisonFudgeSeconds*time.Second)
testutil.RequireTimeInDelta(t, goodRequestedAtTime, requestedAt, timeComparisonFudgeSeconds*time.Second)
testutil.RequireTimeInDelta(t, goodAuthTime, authTime, timeComparisonFudgeSeconds*time.Second)
}
func deepCopyRequestForm(r *http.Request) *http.Request {
copied := url.Values{}
for k, v := range r.Form {
copied[k] = v
}
return &http.Request{Form: copied}
}
func getMapKeys(m map[string]interface{}) []string {
keys := make([]string, 0)
for key := range m {
keys = append(keys, key)
}
return keys
}
func contains(haystack []string, needle string) bool {
for _, hay := range haystack {
if hay == needle {
return true
}
}
return false
}

View File

@ -4,10 +4,15 @@
package testutil
import (
"context"
"mime"
"testing"
"time"
"github.com/stretchr/testify/require"
v12 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
v1 "k8s.io/client-go/kubernetes/typed/core/v1"
)
func RequireTimeInDelta(t *testing.T, t1 time.Time, t2 time.Time, delta time.Duration) {
@ -22,3 +27,28 @@ func RequireTimeInDelta(t *testing.T, t1 time.Time, t2 time.Time, delta time.Dur
t1.Sub(t2).String(),
)
}
func RequireEqualContentType(t *testing.T, actual string, expected string) {
t.Helper()
if expected == "" {
require.Empty(t, actual)
return
}
actualContentType, actualContentTypeParams, err := mime.ParseMediaType(expected)
require.NoError(t, err)
expectedContentType, expectedContentTypeParams, err := mime.ParseMediaType(expected)
require.NoError(t, err)
require.Equal(t, actualContentType, expectedContentType)
require.Equal(t, actualContentTypeParams, expectedContentTypeParams)
}
func RequireNumberOfSecretsMatchingLabelSelector(t *testing.T, secrets v1.SecretInterface, labelSet labels.Set, expectedNumberOfSecrets int) {
t.Helper()
storedAuthcodeSecrets, err := secrets.List(context.Background(), v12.ListOptions{
LabelSelector: labelSet.String(),
})
require.NoError(t, err)
require.Len(t, storedAuthcodeSecrets.Items, expectedNumberOfSecrets)
}

View File

@ -0,0 +1,15 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package testutil
import (
"crypto/sha256"
"encoding/base64"
)
// SHA256 returns the base64 URL encoding of the SHA256 sum of the provided string.
func SHA256(s string) string {
b := sha256.Sum256([]byte(s))
return base64.RawURLEncoding.EncodeToString(b[:])
}

View File

@ -472,13 +472,12 @@ func requireWellKnownEndpointIsWorking(t *testing.T, supervisorScheme, superviso
"authorization_endpoint": "%s/oauth2/authorize",
"token_endpoint": "%s/oauth2/token",
"token_endpoint_auth_methods_supported": ["client_secret_basic"],
"token_endpoint_auth_signing_alg_values_supported": ["RS256"],
"jwks_uri": "%s/jwks.json",
"scopes_supported": ["openid", "offline"],
"response_types_supported": ["code"],
"claims_supported": ["groups"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"]
"id_token_signing_alg_values_supported": ["ES256"]
}`)
expectedJSON := fmt.Sprintf(expectedResultTemplate, issuerName, issuerName, issuerName, issuerName)

View File

@ -24,6 +24,7 @@ import (
configv1alpha1 "go.pinniped.dev/generated/1.19/apis/supervisor/config/v1alpha1"
idpv1alpha1 "go.pinniped.dev/generated/1.19/apis/supervisor/idp/v1alpha1"
"go.pinniped.dev/internal/certauthority"
"go.pinniped.dev/internal/testutil"
"go.pinniped.dev/pkg/oidcclient/nonce"
"go.pinniped.dev/pkg/oidcclient/pkce"
"go.pinniped.dev/pkg/oidcclient/state"
@ -67,6 +68,7 @@ func TestSupervisorLogin(t *testing.T) {
return proxyURL, nil
},
}}
oidcHTTPClientContext := oidc.ClientContext(ctx, httpClient)
// Use the CA to issue a TLS server cert.
t.Logf("issuing test certificate")
@ -109,7 +111,7 @@ func TestSupervisorLogin(t *testing.T) {
// Perform OIDC discovery for our downstream.
var discovery *oidc.Provider
assert.Eventually(t, func() bool {
discovery, err = oidc.NewProvider(oidc.ClientContext(ctx, httpClient), downstream.Spec.Issuer)
discovery, err = oidc.NewProvider(oidcHTTPClientContext, downstream.Spec.Issuer)
return err == nil
}, 30*time.Second, 200*time.Millisecond)
require.NoError(t, err)
@ -158,7 +160,43 @@ func TestSupervisorLogin(t *testing.T) {
t.Logf("got callback request: %s", library.MaskTokens(callback.URL.String()))
require.Equal(t, stateParam.String(), callback.URL.Query().Get("state"))
require.Equal(t, "openid", callback.URL.Query().Get("scope"))
require.NotEmpty(t, callback.URL.Query().Get("code"))
authcode := callback.URL.Query().Get("code")
require.NotEmpty(t, authcode)
// Call the token endpoint to get tokens.
tokenResponse, err := downstreamOAuth2Config.Exchange(oidcHTTPClientContext, authcode, pkceParam.Verifier())
require.NoError(t, err)
// Verify the ID Token.
rawIDToken, ok := tokenResponse.Extra("id_token").(string)
require.True(t, ok, "expected to get an ID token but did not")
var verifier = discovery.Verifier(&oidc.Config{ClientID: downstreamOAuth2Config.ClientID})
idToken, err := verifier.Verify(ctx, rawIDToken)
require.NoError(t, err)
// Check the claims of the ID token.
expectedSubjectPrefix := env.SupervisorTestUpstream.Issuer + "?sub="
require.True(t, strings.HasPrefix(idToken.Subject, expectedSubjectPrefix))
require.Greater(t, len(idToken.Subject), len(expectedSubjectPrefix),
"the ID token Subject should include the upstream user ID after the upstream issuer name")
require.NoError(t, nonceParam.Validate(idToken))
testutil.RequireTimeInDelta(t, time.Now().UTC().Add(time.Minute*5), idToken.Expiry, time.Second*30)
idTokenClaims := map[string]interface{}{}
err = idToken.Claims(&idTokenClaims)
require.NoError(t, err)
idTokenClaimNames := []string{}
for k := range idTokenClaims {
idTokenClaimNames = append(idTokenClaimNames, k)
}
require.ElementsMatch(t, []string{"iss", "exp", "sub", "aud", "auth_time", "iat", "jti", "nonce", "rat"}, idTokenClaimNames)
// Some light verification of the other tokens that were returned.
require.NotEmpty(t, tokenResponse.AccessToken)
require.Equal(t, "bearer", tokenResponse.TokenType)
require.NotZero(t, tokenResponse.Expiry)
testutil.RequireTimeInDelta(t, time.Now().UTC().Add(time.Minute*5), tokenResponse.Expiry, time.Second*30)
require.Empty(t, tokenResponse.RefreshToken) // for now, until the next user story :)
}
func startLocalCallbackServer(t *testing.T) *localCallbackServer {