Merge pull request #249 from vmware-tanzu/token-endpoint
OIDC token endpoint supports authcode flow
This commit is contained in:
commit
6a90a10123
@ -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
|
||||
}
|
||||
|
@ -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{}))
|
||||
|
@ -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{
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
114
internal/fositestorage/accesstoken/accesstoken.go
Normal file
114
internal/fositestorage/accesstoken/accesstoken.go
Normal 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{},
|
||||
},
|
||||
}
|
||||
}
|
282
internal/fositestorage/accesstoken/accesstoken_test.go
Normal file
282
internal/fositestorage/accesstoken/accesstoken_test.go
Normal 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"])
|
||||
}
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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{
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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{
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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{
|
||||
|
114
internal/fositestorage/refreshtoken/refreshtoken.go
Normal file
114
internal/fositestorage/refreshtoken/refreshtoken.go
Normal 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{},
|
||||
},
|
||||
}
|
||||
}
|
282
internal/fositestorage/refreshtoken/refreshtoken_test.go
Normal file
282
internal/fositestorage/refreshtoken/refreshtoken_test.go
Normal 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"])
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
72
internal/oidc/dynamic_open_id_connect_ecdsa_strategy.go
Normal file
72
internal/oidc/dynamic_open_id_connect_ecdsa_strategy.go
Normal 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)
|
||||
}
|
136
internal/oidc/dynamic_open_id_connect_ecdsa_strategy_test.go
Normal file
136
internal/oidc/dynamic_open_id_connect_ecdsa_strategy_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -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]
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -30,6 +30,7 @@ func TestNullStorage_GetClient(t *testing.T) {
|
||||
GrantTypes: []string{"authorization_code"},
|
||||
Scopes: []string{"openid", "profile", "email"},
|
||||
},
|
||||
TokenEndpointAuthMethod: "none",
|
||||
},
|
||||
client,
|
||||
)
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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() {
|
||||
|
41
internal/oidc/token/token_handler.go
Normal file
41
internal/oidc/token/token_handler.go
Normal 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
|
||||
})
|
||||
}
|
991
internal/oidc/token/token_handler_test.go
Normal file
991
internal/oidc/token/token_handler_test.go
Normal 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
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
15
internal/testutil/crypto.go
Normal file
15
internal/testutil/crypto.go
Normal 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[:])
|
||||
}
|
@ -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)
|
||||
|
||||
|
@ -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 {
|
||||
|
Loading…
Reference in New Issue
Block a user