Add fosite kube storage for access and refresh tokens

Also switched the token_handler_test.go to use kube storage.

Signed-off-by: Aram Price <pricear@vmware.com>
This commit is contained in:
Ryan Richard 2020-12-04 14:31:06 -08:00 committed by Aram Price
parent 8d5f4a93ed
commit ac83633888
13 changed files with 1192 additions and 82 deletions

View File

@ -14,6 +14,7 @@ import (
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
corev1client "k8s.io/client-go/kubernetes/typed/core/v1" corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
"go.pinniped.dev/internal/constable" "go.pinniped.dev/internal/constable"
@ -34,10 +35,11 @@ const (
) )
type Storage interface { 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) 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) Update(ctx context.Context, signature, resourceVersion string, data JSON) (newResourceVersion string, err error)
Delete(ctx context.Context, signature string) 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 type JSON interface{} // document that we need valid JSON types
@ -58,8 +60,8 @@ type secretsStorage struct {
secrets corev1client.SecretInterface secrets corev1client.SecretInterface
} }
func (s *secretsStorage) Create(ctx context.Context, signature string, data JSON) (string, error) { func (s *secretsStorage) Create(ctx context.Context, signature string, data JSON, additionalLabels map[string]string) (string, error) {
secret, err := s.toSecret(signature, "", data) secret, err := s.toSecret(signature, "", data, additionalLabels)
if err != nil { if err != nil {
return "", err return "", err
} }
@ -98,7 +100,7 @@ func (s *secretsStorage) validateSecret(secret *corev1.Secret) error {
} }
func (s *secretsStorage) Update(ctx context.Context, signature, resourceVersion string, data JSON) (string, 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 { if err != nil {
return "", err return "", err
} }
@ -116,6 +118,28 @@ func (s *secretsStorage) Delete(ctx context.Context, signature string) error {
return nil 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 //nolint: gochecknoglobals
var b32 = base32.StdEncoding.WithPadding(base32.NoPadding) var b32 = base32.StdEncoding.WithPadding(base32.NoPadding)
@ -127,18 +151,24 @@ func (s *secretsStorage) getName(signature string) string {
return fmt.Sprintf(secretNameFormat, s.resource, signatureAsValidName) 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) buf, err := json.Marshal(data)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to encode secret data for %s: %w", s.getName(signature), err) 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{ return &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
Name: s.getName(signature), Name: s.getName(signature),
ResourceVersion: resourceVersion, ResourceVersion: resourceVersion,
Labels: map[string]string{ Labels: labels,
secretLabelKey: s.resource, // make it easier to find this stuff via kubectl
},
OwnerReferences: nil, OwnerReferences: nil,
}, },
Data: map[string][]byte{ Data: map[string][]byte{

View File

@ -6,6 +6,7 @@ package crud
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"testing" "testing"
"github.com/ory/fosite/compose" "github.com/ory/fosite/compose"
@ -87,6 +88,7 @@ func TestStorage(t *testing.T) {
wantSecrets: nil, wantSecrets: nil,
wantErr: `failed to delete tokens for signature not-a-token: secrets "pinniped-storage-tokens-t2fx427lnci6s" not found`, 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", name: "create and get",
resource: "access-tokens", 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 require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is
data := &testJSON{Data: "create-and-get"} 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.Empty(t, rv1) // fake client does not set this
require.NoError(t, err) require.NoError(t, err)
@ -145,6 +147,68 @@ func TestStorage(t *testing.T) {
}, },
wantErr: "", 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": "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": "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",
},
},
wantErr: "",
},
{ {
name: "get existing", name: "get existing",
resource: "pandas-are-best", resource: "pandas-are-best",
@ -325,6 +389,207 @@ func TestStorage(t *testing.T) {
wantSecrets: nil, wantSecrets: nil,
wantErr: "", 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": "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": "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": "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": "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=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": "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": "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": "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=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": "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")
if !found || requiresExactMatch != "seals" {
// this list action did not use label selector storage.pinniped.dev=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=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", name: "invalid exiting secret type",
resource: "candies", resource: "candies",
@ -582,10 +847,13 @@ func checkSecretActionNames(t *testing.T, actions []coretesting.Action) {
t.Helper() t.Helper()
for _, action := range actions { for _, action := range actions {
_, ok := action.(coretesting.ListActionImpl)
if !ok { // list action don't have names, so skip these assertions for list actions
name := getName(t, action) name := getName(t, action)
assertValidName(t, name) assertValidName(t, name)
} }
} }
}
func checkSecretListNames(t *testing.T, secrets []corev1.Secret) { func checkSecretListNames(t *testing.T, secrets []corev1.Secret) {
t.Helper() t.Helper()

View File

@ -0,0 +1,112 @@
// 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 (
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("access-token", secrets)}
}
func (a *accessTokenStorage) RevokeAccessToken(ctx context.Context, requestID string) error {
return a.storage.DeleteByLabel(ctx, fositestorage.StorageRequestIDLabelName, requestID)
}
func (a *accessTokenStorage) CreateAccessTokenSession(ctx context.Context, signature string, requester fosite.Requester) error {
request, err := fositestorage.ValidateAndExtractAuthorizeRequest(requester)
if err != nil {
return err
}
_, err = a.storage.Create(
ctx,
signature,
&session{Request: request, Version: accessTokenStorageVersion},
map[string]string{fositestorage.StorageRequestIDLabelName: requester.GetID()},
)
return err
}
func (a *accessTokenStorage) GetAccessTokenSession(ctx context.Context, signature string, _ fosite.Session) (fosite.Requester, error) {
session, _, err := a.getSession(ctx, signature)
if err != nil {
return nil, err
}
return session.Request, err
}
func (a *accessTokenStorage) DeleteAccessTokenSession(ctx context.Context, signature string) error {
return a.storage.Delete(ctx, signature)
}
func (a *accessTokenStorage) getSession(ctx context.Context, signature string) (*session, string, error) {
session := newValidEmptyAccessTokenSession()
rv, err := a.storage.Get(ctx, signature, session)
if errors.IsNotFound(err) {
return nil, "", fosite.ErrNotFound.WithCause(err).WithDebug(err.Error())
}
if err != nil {
return nil, "", fmt.Errorf("failed to get access token session for %s: %w", signature, err)
}
if version := session.Version; version != accessTokenStorageVersion {
return nil, "", fmt.Errorf("%w: access token session for %s has version %s instead of %s",
ErrInvalidAccessTokenRequestVersion, signature, version, accessTokenStorageVersion)
}
if session.Request.ID == "" {
return nil, "", fmt.Errorf("malformed access token session for %s: %w", signature, ErrInvalidAccessTokenRequestData)
}
return session, rv, nil
}
func newValidEmptyAccessTokenSession() *session {
return &session{
Request: &fosite.Request{
Client: &fosite.DefaultOpenIDConnectClient{},
Session: &openid.DefaultSession{},
},
}
}

View File

@ -0,0 +1,282 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package accesstoken
import (
"context"
"net/url"
"testing"
"time"
"github.com/ory/fosite"
"github.com/ory/fosite/handler/openid"
"github.com/pkg/errors"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/kubernetes/fake"
coretesting "k8s.io/client-go/testing"
)
const namespace = "test-ns"
var secretsGVR = schema.GroupVersionResource{
Group: "",
Version: "v1",
Resource: "secrets",
}
func TestAccessTokenStorage(t *testing.T) {
ctx := context.Background()
wantActions := []coretesting.Action{
coretesting.NewCreateAction(secretsGVR, namespace, &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "pinniped-storage-access-token-pwu5zs7lekbhnln2w4",
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev": "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": "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=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": "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": "access-token",
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"1"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/access-token",
}
_, err := secrets.Create(ctx, secret, metav1.CreateOptions{})
require.NoError(t, err)
_, err = storage.GetAccessTokenSession(ctx, "fancy-signature", nil)
require.EqualError(t, err, "malformed access token session for fancy-signature: access token request data must be present")
}
func TestCreateWithNilRequester(t *testing.T) {
ctx := context.Background()
client := fake.NewSimpleClientset()
secrets := client.CoreV1().Secrets(namespace)
storage := New(secrets)
err := storage.CreateAccessTokenSession(ctx, "signature-doesnt-matter", nil)
require.EqualError(t, err, "requester must be of type fosite.Request")
}
func TestCreateWithWrongRequesterDataTypes(t *testing.T) {
ctx := context.Background()
client := fake.NewSimpleClientset()
secrets := client.CoreV1().Secrets(namespace)
storage := New(secrets)
request := &fosite.Request{
Session: nil,
Client: &fosite.DefaultOpenIDConnectClient{},
}
err := storage.CreateAccessTokenSession(ctx, "signature-doesnt-matter", request)
require.EqualError(t, err, "requester's session must be of type openid.DefaultSession")
request = &fosite.Request{
Session: &openid.DefaultSession{},
Client: nil,
}
err = storage.CreateAccessTokenSession(ctx, "signature-doesnt-matter", request)
require.EqualError(t, err, "requester's client must be of type fosite.DefaultOpenIDConnectClient")
}
func TestCreateWithoutRequesterID(t *testing.T) {
ctx := context.Background()
client := fake.NewSimpleClientset()
secrets := client.CoreV1().Secrets(namespace)
storage := New(secrets)
request := &fosite.Request{
ID: "", // empty ID
Session: &openid.DefaultSession{},
Client: &fosite.DefaultOpenIDConnectClient{},
}
err := storage.CreateAccessTokenSession(ctx, "signature-doesnt-matter", request)
require.NoError(t, err)
// the blank ID was filled in with an auto-generated ID
require.NotEmpty(t, request.ID)
require.Len(t, client.Actions(), 1)
actualAction := client.Actions()[0].(coretesting.CreateActionImpl)
actualSecret := actualAction.GetObject().(*corev1.Secret)
// The generated secret was labeled with that auto-generated request ID
require.Equal(t, request.ID, actualSecret.Labels["storage.pinniped.dev/request-id"])
}

View File

@ -64,7 +64,7 @@ func (a *authorizeCodeStorage) CreateAuthorizeCodeSession(ctx context.Context, s
// of the consent authorization request. It is used to identify the session. // of the consent authorization request. It is used to identify the session.
// signature for lookup in the DB // 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 return err
} }

View File

@ -14,6 +14,7 @@ const (
ErrInvalidRequestType = constable.Error("requester must be of type fosite.Request") ErrInvalidRequestType = constable.Error("requester must be of type fosite.Request")
ErrInvalidClientType = constable.Error("requester's client must be of type fosite.DefaultOpenIDConnectClient") ErrInvalidClientType = constable.Error("requester's client must be of type fosite.DefaultOpenIDConnectClient")
ErrInvalidSessionType = constable.Error("requester's session must be of type openid.DefaultSession") 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) { func ValidateAndExtractAuthorizeRequest(requester fosite.Requester) (*fosite.Request, error) {

View File

@ -52,7 +52,7 @@ func (a *openIDConnectRequestStorage) CreateOpenIDConnectSession(ctx context.Con
return err 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 return err
} }

View File

@ -46,7 +46,7 @@ func (a *pkceStorage) CreatePKCERequestSession(ctx context.Context, signature st
return err 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 return err
} }

View File

@ -0,0 +1,112 @@
// 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 (
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("refresh-token", secrets)}
}
func (a *refreshTokenStorage) RevokeRefreshToken(ctx context.Context, requestID string) error {
return a.storage.DeleteByLabel(ctx, fositestorage.StorageRequestIDLabelName, requestID)
}
func (a *refreshTokenStorage) CreateRefreshTokenSession(ctx context.Context, signature string, requester fosite.Requester) error {
request, err := fositestorage.ValidateAndExtractAuthorizeRequest(requester)
if err != nil {
return err
}
_, err = a.storage.Create(
ctx,
signature,
&session{Request: request, Version: refreshTokenStorageVersion},
map[string]string{fositestorage.StorageRequestIDLabelName: requester.GetID()},
)
return err
}
func (a *refreshTokenStorage) GetRefreshTokenSession(ctx context.Context, signature string, _ fosite.Session) (fosite.Requester, error) {
session, _, err := a.getSession(ctx, signature)
if err != nil {
return nil, err
}
return session.Request, err
}
func (a *refreshTokenStorage) DeleteRefreshTokenSession(ctx context.Context, signature string) error {
return a.storage.Delete(ctx, signature)
}
func (a *refreshTokenStorage) getSession(ctx context.Context, signature string) (*session, string, error) {
session := newValidEmptyRefreshTokenSession()
rv, err := a.storage.Get(ctx, signature, session)
if errors.IsNotFound(err) {
return nil, "", fosite.ErrNotFound.WithCause(err).WithDebug(err.Error())
}
if err != nil {
return nil, "", fmt.Errorf("failed to get refresh token session for %s: %w", signature, err)
}
if version := session.Version; version != refreshTokenStorageVersion {
return nil, "", fmt.Errorf("%w: refresh token session for %s has version %s instead of %s",
ErrInvalidRefreshTokenRequestVersion, signature, version, refreshTokenStorageVersion)
}
if session.Request.ID == "" {
return nil, "", fmt.Errorf("malformed refresh token session for %s: %w", signature, ErrInvalidRefreshTokenRequestData)
}
return session, rv, nil
}
func newValidEmptyRefreshTokenSession() *session {
return &session{
Request: &fosite.Request{
Client: &fosite.DefaultOpenIDConnectClient{},
Session: &openid.DefaultSession{},
},
}
}

View File

@ -0,0 +1,282 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package refreshtoken
import (
"context"
"net/url"
"testing"
"time"
"github.com/ory/fosite"
"github.com/ory/fosite/handler/openid"
"github.com/pkg/errors"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/kubernetes/fake"
coretesting "k8s.io/client-go/testing"
)
const namespace = "test-ns"
var secretsGVR = schema.GroupVersionResource{
Group: "",
Version: "v1",
Resource: "secrets",
}
func TestRefreshTokenStorage(t *testing.T) {
ctx := context.Background()
wantActions := []coretesting.Action{
coretesting.NewCreateAction(secretsGVR, namespace, &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "pinniped-storage-refresh-token-pwu5zs7lekbhnln2w4",
ResourceVersion: "",
Labels: map[string]string{
"storage.pinniped.dev": "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": "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=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": "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": "refresh-token",
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"1"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/refresh-token",
}
_, err := secrets.Create(ctx, secret, metav1.CreateOptions{})
require.NoError(t, err)
_, err = storage.GetRefreshTokenSession(ctx, "fancy-signature", nil)
require.EqualError(t, err, "malformed refresh token session for fancy-signature: refresh token request data must be present")
}
func TestCreateWithNilRequester(t *testing.T) {
ctx := context.Background()
client := fake.NewSimpleClientset()
secrets := client.CoreV1().Secrets(namespace)
storage := New(secrets)
err := storage.CreateRefreshTokenSession(ctx, "signature-doesnt-matter", nil)
require.EqualError(t, err, "requester must be of type fosite.Request")
}
func TestCreateWithWrongRequesterDataTypes(t *testing.T) {
ctx := context.Background()
client := fake.NewSimpleClientset()
secrets := client.CoreV1().Secrets(namespace)
storage := New(secrets)
request := &fosite.Request{
Session: nil,
Client: &fosite.DefaultOpenIDConnectClient{},
}
err := storage.CreateRefreshTokenSession(ctx, "signature-doesnt-matter", request)
require.EqualError(t, err, "requester's session must be of type openid.DefaultSession")
request = &fosite.Request{
Session: &openid.DefaultSession{},
Client: nil,
}
err = storage.CreateRefreshTokenSession(ctx, "signature-doesnt-matter", request)
require.EqualError(t, err, "requester's client must be of type fosite.DefaultOpenIDConnectClient")
}
func TestCreateWithoutRequesterID(t *testing.T) {
ctx := context.Background()
client := fake.NewSimpleClientset()
secrets := client.CoreV1().Secrets(namespace)
storage := New(secrets)
request := &fosite.Request{
ID: "", // empty ID
Session: &openid.DefaultSession{},
Client: &fosite.DefaultOpenIDConnectClient{},
}
err := storage.CreateRefreshTokenSession(ctx, "signature-doesnt-matter", request)
require.NoError(t, err)
// the blank ID was filled in with an auto-generated ID
require.NotEmpty(t, request.ID)
require.Len(t, client.Actions(), 1)
actualAction := client.Actions()[0].(coretesting.CreateActionImpl)
actualSecret := actualAction.GetObject().(*corev1.Secret)
// The generated secret was labeled with that auto-generated request ID
require.Equal(t, request.ID, actualSecret.Labels["storage.pinniped.dev/request-id"])
}

View File

@ -14,9 +14,11 @@ import (
corev1client "k8s.io/client-go/kubernetes/typed/core/v1" corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
"go.pinniped.dev/internal/constable" "go.pinniped.dev/internal/constable"
"go.pinniped.dev/internal/fositestorage/accesstoken"
"go.pinniped.dev/internal/fositestorage/authorizationcode" "go.pinniped.dev/internal/fositestorage/authorizationcode"
"go.pinniped.dev/internal/fositestorage/openidconnect" "go.pinniped.dev/internal/fositestorage/openidconnect"
"go.pinniped.dev/internal/fositestorage/pkce" "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.") 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 authorizationCodeStorage oauth2.AuthorizeCodeStorage
pkceStorage fositepkce.PKCERequestStorage pkceStorage fositepkce.PKCERequestStorage
oidcStorage openid.OpenIDConnectRequestStorage oidcStorage openid.OpenIDConnectRequestStorage
accessTokenStorage accesstoken.RevocationStorage
refreshTokenStorage refreshtoken.RevocationStorage
} }
func NewKubeStorage(secrets corev1client.SecretInterface) *KubeStorage { func NewKubeStorage(secrets corev1client.SecretInterface) *KubeStorage {
@ -32,39 +36,41 @@ func NewKubeStorage(secrets corev1client.SecretInterface) *KubeStorage {
authorizationCodeStorage: authorizationcode.New(secrets), authorizationCodeStorage: authorizationcode.New(secrets),
pkceStorage: pkce.New(secrets), pkceStorage: pkce.New(secrets),
oidcStorage: openidconnect.New(secrets), oidcStorage: openidconnect.New(secrets),
accessTokenStorage: accesstoken.New(secrets),
refreshTokenStorage: refreshtoken.New(secrets),
} }
} }
func (KubeStorage) RevokeRefreshToken(_ context.Context, _ string) error { func (k KubeStorage) RevokeRefreshToken(ctx context.Context, requestID string) error {
return errKubeStorageNotImplemented return k.refreshTokenStorage.RevokeRefreshToken(ctx, requestID)
} }
func (KubeStorage) RevokeAccessToken(_ context.Context, _ string) error { func (k KubeStorage) RevokeAccessToken(ctx context.Context, requestID string) error {
return errKubeStorageNotImplemented return k.accessTokenStorage.RevokeAccessToken(ctx, requestID)
} }
func (KubeStorage) CreateRefreshTokenSession(_ context.Context, _ string, _ fosite.Requester) (err error) { func (k KubeStorage) CreateRefreshTokenSession(ctx context.Context, signature string, request fosite.Requester) (err error) {
return nil return k.refreshTokenStorage.CreateRefreshTokenSession(ctx, signature, request)
} }
func (KubeStorage) GetRefreshTokenSession(_ context.Context, _ string, _ fosite.Session) (request fosite.Requester, err error) { func (k KubeStorage) GetRefreshTokenSession(ctx context.Context, signature string, session fosite.Session) (request fosite.Requester, err error) {
return nil, errKubeStorageNotImplemented return k.refreshTokenStorage.GetRefreshTokenSession(ctx, signature, session)
} }
func (KubeStorage) DeleteRefreshTokenSession(_ context.Context, _ string) (err error) { func (k KubeStorage) DeleteRefreshTokenSession(ctx context.Context, signature string) (err error) {
return errKubeStorageNotImplemented return k.refreshTokenStorage.DeleteRefreshTokenSession(ctx, signature)
} }
func (KubeStorage) CreateAccessTokenSession(_ context.Context, _ string, _ fosite.Requester) (err error) { func (k KubeStorage) CreateAccessTokenSession(ctx context.Context, signature string, requester fosite.Requester) (err error) {
return nil return k.accessTokenStorage.CreateAccessTokenSession(ctx, signature, requester)
} }
func (KubeStorage) GetAccessTokenSession(_ context.Context, _ string, _ fosite.Session) (request fosite.Requester, err error) { func (k KubeStorage) GetAccessTokenSession(ctx context.Context, signature string, session fosite.Session) (request fosite.Requester, err error) {
return nil, errKubeStorageNotImplemented return k.accessTokenStorage.GetAccessTokenSession(ctx, signature, session)
} }
func (KubeStorage) DeleteAccessTokenSession(_ context.Context, _ string) (err error) { func (k KubeStorage) DeleteAccessTokenSession(ctx context.Context, signature string) (err error) {
return errKubeStorageNotImplemented return k.accessTokenStorage.DeleteAccessTokenSession(ctx, signature)
} }
func (k KubeStorage) CreateOpenIDConnectSession(ctx context.Context, authcode string, requester fosite.Requester) error { func (k KubeStorage) CreateOpenIDConnectSession(ctx context.Context, authcode string, requester fosite.Requester) error {

View File

@ -190,7 +190,7 @@ func TestManager(t *testing.T) {
oidctestutil.VerifyECDSAIDToken(t, jwkIssuer, downstreamClientID, privateKey, idToken) oidctestutil.VerifyECDSAIDToken(t, jwkIssuer, downstreamClientID, privateKey, idToken)
// Make sure that we wired up the callback endpoint to use kube storage for fosite sessions. // Make sure that we wired up the callback endpoint to use kube storage for fosite sessions.
r.Equal(len(kubeClient.Actions()), numberOfKubeActionsBeforeThisRequest+7, r.Equal(len(kubeClient.Actions()), numberOfKubeActionsBeforeThisRequest+8,
"did not perform any kube actions during the callback request, but should have") "did not perform any kube actions during the callback request, but should have")
} }

View File

@ -8,8 +8,6 @@ import (
"crypto/ecdsa" "crypto/ecdsa"
"crypto/elliptic" "crypto/elliptic"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json" "encoding/json"
"io" "io"
"io/ioutil" "io/ioutil"
@ -24,10 +22,11 @@ import (
"github.com/ory/fosite/handler/oauth2" "github.com/ory/fosite/handler/oauth2"
"github.com/ory/fosite/handler/openid" "github.com/ory/fosite/handler/openid"
"github.com/ory/fosite/handler/pkce" "github.com/ory/fosite/handler/pkce"
"github.com/ory/fosite/storage"
"github.com/ory/fosite/token/jwt" "github.com/ory/fosite/token/jwt"
"github.com/pkg/errors"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gopkg.in/square/go-jose.v2" "gopkg.in/square/go-jose.v2"
"k8s.io/client-go/kubernetes/fake"
"go.pinniped.dev/internal/here" "go.pinniped.dev/internal/here"
"go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc"
@ -55,8 +54,8 @@ const (
) )
var ( var (
goodAuthTime = time.Date(1, 2, 3, 4, 5, 6, 7, time.Local) goodAuthTime = time.Date(1, 2, 3, 4, 5, 6, 7, time.UTC)
goodRequestedAtTime = time.Date(7, 6, 5, 4, 3, 2, 1, time.Local) goodRequestedAtTime = time.Date(7, 6, 5, 4, 3, 2, 1, time.UTC)
fositeInvalidMethodErrorBody = func(actual string) string { fositeInvalidMethodErrorBody = func(actual string) string {
return here.Docf(` return here.Docf(`
@ -384,15 +383,19 @@ func TestTokenEndpoint(t *testing.T) {
test.authRequest(authRequest) test.authRequest(authRequest)
} }
oauthStore := storage.NewMemoryStore() client := fake.NewSimpleClientset()
// Add the Pinniped CLI client. secrets := client.CoreV1().Secrets("some-namespace")
oauthStore.Clients[goodClient] = oidc.PinnipedCLIOIDCClient()
oauthStore := oidc.NewKubeStorage(secrets)
oauthHelper, authCode, jwtSigningKey := makeHappyOauthHelper(t, authRequest, oauthStore) oauthHelper, authCode, jwtSigningKey := makeHappyOauthHelper(t, authRequest, oauthStore)
if test.storage != nil { if test.storage != nil {
test.storage(t, oauthStore, authCode) test.storage(t, oauthStore, authCode)
} }
subject := NewHandler(oauthHelper) subject := NewHandler(oauthHelper)
// TODO add assertions about how many of each storage type exist at this point as a pre-condition
req := httptest.NewRequest("POST", "/path/shouldn't/matter", happyBody(authCode).ReadCloser()) req := httptest.NewRequest("POST", "/path/shouldn't/matter", happyBody(authCode).ReadCloser())
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if test.request != nil { if test.request != nil {
@ -421,6 +424,8 @@ func TestTokenEndpoint(t *testing.T) {
if wantOpenidScope { if wantOpenidScope {
requireValidIDToken(t, m, jwtSigningKey) requireValidIDToken(t, m, jwtSigningKey)
} }
// TODO add assertions about how many of each storage type are remaining at this point
} else { } else {
require.JSONEq(t, test.wantExactBody, rsp.Body.String()) require.JSONEq(t, test.wantExactBody, rsp.Body.String())
} }
@ -429,12 +434,16 @@ func TestTokenEndpoint(t *testing.T) {
t.Run("auth code is used twice", func(t *testing.T) { t.Run("auth code is used twice", func(t *testing.T) {
authRequest := deepCopyRequestForm(happyAuthRequest) authRequest := deepCopyRequestForm(happyAuthRequest)
oauthStore := storage.NewMemoryStore()
// Add the Pinniped CLI client. client := fake.NewSimpleClientset()
oauthStore.Clients[goodClient] = oidc.PinnipedCLIOIDCClient() secrets := client.CoreV1().Secrets("some-namespace")
oauthStore := oidc.NewKubeStorage(secrets)
oauthHelper, authCode, jwtSigningKey := makeHappyOauthHelper(t, authRequest, oauthStore) oauthHelper, authCode, jwtSigningKey := makeHappyOauthHelper(t, authRequest, oauthStore)
subject := NewHandler(oauthHelper) subject := NewHandler(oauthHelper)
// TODO add assertions about how many of each storage type exist at this point as a pre-condition
req := httptest.NewRequest("POST", "/path/shouldn't/matter", happyBody(authCode).ReadCloser()) req := httptest.NewRequest("POST", "/path/shouldn't/matter", happyBody(authCode).ReadCloser())
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@ -460,6 +469,8 @@ func TestTokenEndpoint(t *testing.T) {
requireValidOIDCStorage(t, m, code, oauthStore, wantOpenidScope) requireValidOIDCStorage(t, m, code, oauthStore, wantOpenidScope)
requireValidIDToken(t, m, jwtSigningKey) requireValidIDToken(t, m, jwtSigningKey)
// TODO add assertions about how many of each storage type are remaining at this point
// Second call - should be unsuccessful since auth code was already used. // 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 // Fosite will also revoke the access token as is recommended by the OIDC spec. Currently, we don't
@ -476,6 +487,8 @@ func TestTokenEndpoint(t *testing.T) {
requireInvalidAccessTokenStorage(t, m, oauthStore) requireInvalidAccessTokenStorage(t, m, oauthStore)
requireInvalidPKCEStorage(t, code, oauthStore) requireInvalidPKCEStorage(t, code, oauthStore)
requireValidOIDCStorage(t, m, code, oauthStore, wantOpenidScope) requireValidOIDCStorage(t, m, code, oauthStore, wantOpenidScope)
// TODO add assertions about how many of each storage type are remaining at this point
}) })
} }
@ -590,16 +603,6 @@ func generateJWTSigningKeyAndJWKSProvider(t *testing.T, issuer string) (*ecdsa.P
return key, jwksProvider return key, jwksProvider
} }
func hashAccessToken(accessToken string) string {
// See https://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken.
// "Access Token hash value. Its value is the base64url encoding of the left-most half of
// the hash of the octets of the ASCII representation of the access_token value, where the
// hash algorithm used is the hash algorithm used in the alg Header Parameter of the ID
// Token's JOSE Header."
b := sha256.Sum256([]byte(accessToken))
return base64.RawURLEncoding.EncodeToString(b[:len(b)/2])
}
func requireInvalidAuthCodeStorage( func requireInvalidAuthCodeStorage(
t *testing.T, t *testing.T,
code string, code string,
@ -609,7 +612,7 @@ func requireInvalidAuthCodeStorage(
// Make sure we have invalidated this auth code. // Make sure we have invalidated this auth code.
_, err := storage.GetAuthorizeCodeSession(context.Background(), getFositeDataSignature(t, code), nil) _, err := storage.GetAuthorizeCodeSession(context.Background(), getFositeDataSignature(t, code), nil)
require.Equal(t, fosite.ErrInvalidatedAuthorizeCode, err) require.True(t, errors.Is(err, fosite.ErrInvalidatedAuthorizeCode))
} }
func requireValidAccessTokenStorage( func requireValidAccessTokenStorage(
@ -656,8 +659,8 @@ func requireValidAccessTokenStorage(
t, t,
authRequest, authRequest,
authRequest.Sanitize([]string{}).GetRequestForm(), authRequest.Sanitize([]string{}).GetRequestForm(),
hashAccessToken(accessTokenString),
wantGrantedOpenidScope, wantGrantedOpenidScope,
true,
) )
} }
@ -674,7 +677,7 @@ func requireInvalidAccessTokenStorage(
accessTokenString, ok := accessToken.(string) accessTokenString, ok := accessToken.(string)
require.Truef(t, ok, "wanted access_token to be a string, but got %T", accessToken) require.Truef(t, ok, "wanted access_token to be a string, but got %T", accessToken)
_, err := storage.GetAccessTokenSession(context.Background(), getFositeDataSignature(t, accessTokenString), nil) _, err := storage.GetAccessTokenSession(context.Background(), getFositeDataSignature(t, accessTokenString), nil)
require.Equal(t, fosite.ErrNotFound, err) require.True(t, errors.Is(err, fosite.ErrNotFound))
} }
func requireInvalidPKCEStorage( func requireInvalidPKCEStorage(
@ -687,7 +690,7 @@ func requireInvalidPKCEStorage(
// Make sure the PKCE session has been deleted. Note that Fosite stores PKCE codes using the auth code signature // Make sure the PKCE session has been deleted. Note that Fosite stores PKCE codes using the auth code signature
// as a key. // as a key.
_, err := storage.GetPKCERequestSession(context.Background(), getFositeDataSignature(t, code), nil) _, err := storage.GetPKCERequestSession(context.Background(), getFositeDataSignature(t, code), nil)
require.Equal(t, fosite.ErrNotFound, err) require.True(t, errors.Is(err, fosite.ErrNotFound))
} }
func requireValidOIDCStorage( func requireValidOIDCStorage(
@ -709,16 +712,18 @@ func requireValidOIDCStorage(
require.True(t, ok) require.True(t, ok)
accessTokenString, ok := accessToken.(string) accessTokenString, ok := accessToken.(string)
require.Truef(t, ok, "wanted access_token to be a string, but got %T", accessToken) require.Truef(t, ok, "wanted access_token to be a string, but got %T", accessToken)
require.NotEmpty(t, accessTokenString)
requireValidAuthRequest( requireValidAuthRequest(
t, t,
authRequest, authRequest,
authRequest.Sanitize([]string{"nonce"}).GetRequestForm(), authRequest.Sanitize([]string{"nonce"}).GetRequestForm(),
hashAccessToken(accessTokenString),
true, true,
false,
) )
} else { } else {
_, err := storage.GetOpenIDConnectSession(context.Background(), code, nil) _, err := storage.GetOpenIDConnectSession(context.Background(), code, nil)
require.Equal(t, fosite.ErrNotFound, err) require.True(t, errors.Is(err, fosite.ErrNotFound))
} }
} }
@ -726,8 +731,8 @@ func requireValidAuthRequest(
t *testing.T, t *testing.T,
authRequest fosite.Requester, authRequest fosite.Requester,
wantRequestForm url.Values, wantRequestForm url.Values,
wantAccessTokenHash string,
wantGrantedOpenidScope bool, wantGrantedOpenidScope bool,
wantAccessTokenExpiresAt bool,
) { ) {
t.Helper() t.Helper()
@ -755,24 +760,28 @@ func requireValidAuthRequest(
if wantGrantedOpenidScope { if wantGrantedOpenidScope {
claims := session.Claims claims := session.Claims
require.Empty(t, claims.JTI) // When claims.JTI is empty, Fosite will generate a UUID for this field. require.Empty(t, claims.JTI) // When claims.JTI is empty, Fosite will generate a UUID for this field.
require.Equal(t, goodIssuer, claims.Issuer)
require.Equal(t, goodSubject, claims.Subject) require.Equal(t, goodSubject, claims.Subject)
require.Equal(t, []string{goodClient}, claims.Audience)
require.Equal(t, goodNonce, claims.Nonce)
testutil.RequireTimeInDelta(
t,
time.Now().UTC().Add(idTokenExpirationSeconds*time.Second),
claims.ExpiresAt,
timeComparisonFudgeSeconds*time.Second,
)
testutil.RequireTimeInDelta(t, time.Now().UTC(), claims.IssuedAt, timeComparisonFudgeSeconds*time.Second)
require.Equal(t, wantAccessTokenHash, claims.AccessTokenHash)
// We are in charge of setting these fields. For the purpose of testing, we ensure that the // We are in charge of setting these fields. For the purpose of testing, we ensure that the
// sentinel test value is set correctly. // sentinel test value is set correctly.
require.Equal(t, goodRequestedAtTime, claims.RequestedAt) require.Equal(t, goodRequestedAtTime, claims.RequestedAt)
require.Equal(t, goodAuthTime, claims.AuthTime) 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. // 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.AuthenticationContextClassReference)
require.Empty(t, claims.AuthenticationMethodsReference) require.Empty(t, claims.AuthenticationMethodsReference)
@ -793,7 +802,10 @@ func requireValidAuthRequest(
authCodeExpiresAt, authCodeExpiresAt,
timeComparisonFudgeSeconds*time.Second, timeComparisonFudgeSeconds*time.Second,
) )
// OpenID Connect sessions do not store access token expiration information.
accessTokenExpiresAt, ok := session.ExpiresAt[fosite.AccessToken] accessTokenExpiresAt, ok := session.ExpiresAt[fosite.AccessToken]
if wantAccessTokenExpiresAt {
require.True(t, ok, "expected session to hold expiration time for access token") require.True(t, ok, "expected session to hold expiration time for access token")
testutil.RequireTimeInDelta( testutil.RequireTimeInDelta(
t, t,
@ -801,6 +813,9 @@ func requireValidAuthRequest(
accessTokenExpiresAt, accessTokenExpiresAt,
timeComparisonFudgeSeconds*time.Second, 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. // Assert that the session's username and subject are correct.
require.Equal(t, goodUsername, session.Username) require.Equal(t, goodUsername, session.Username)
@ -830,7 +845,10 @@ func requireValidIDToken(t *testing.T, body map[string]interface{}, jwtSigningKe
RequestedAt int64 `json:"rat"` RequestedAt int64 `json:"rat"`
AuthTime int64 `json:"auth_time"` AuthTime int64 `json:"auth_time"`
} }
idTokenFields := []string{"sub", "aud", "iss", "jti", "nonce", "auth_time", "at_hash", "exp", "iat", "rat"}
// 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 // make sure that these are the only fields in the token
var m map[string]interface{} var m map[string]interface{}
@ -846,7 +864,6 @@ func requireValidIDToken(t *testing.T, body map[string]interface{}, jwtSigningKe
require.Equal(t, goodIssuer, claims.Issuer) require.Equal(t, goodIssuer, claims.Issuer)
require.NotEmpty(t, claims.JTI) require.NotEmpty(t, claims.JTI)
require.Equal(t, goodNonce, claims.Nonce) require.Equal(t, goodNonce, claims.Nonce)
require.NotEmpty(t, claims.AccessTokenHash)
expiresAt := time.Unix(claims.ExpiresAt, 0) expiresAt := time.Unix(claims.ExpiresAt, 0)
issuedAt := time.Unix(claims.IssuedAt, 0) issuedAt := time.Unix(claims.IssuedAt, 0)