34509e7430
- Enhance the token exchange to check that the same client is used compared to the client used during the original authorization and token requests, and also check that the client has the token-exchange grant type allowed in its configuration. - Reduce the minimum required bcrypt cost for OIDCClient secrets because 15 is too slow for real-life use, especially considering that every login and every refresh flow will require two client auths. - In unit tests, use bcrypt hashes with a cost of 4, because bcrypt slows down by 13x when run with the race detector, and we run our tests with the race detector enabled, causing the tests to be unacceptably slow. The production code uses a higher minimum cost. - Centralize all pre-computed bcrypt hashes used by unit tests to a single place. Also extract some other useful test helpers for unit tests related to OIDCClients. - Add tons of unit tests for the token endpoint related to dynamic clients for authcode exchanges, token exchanges, and refreshes.
223 lines
8.2 KiB
Go
223 lines
8.2 KiB
Go
// Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
// Package clientregistry defines Pinniped's OAuth2/OIDC clients.
|
|
package clientregistry
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/coreos/go-oidc/v3/oidc"
|
|
"github.com/ory/fosite"
|
|
"k8s.io/apimachinery/pkg/api/errors"
|
|
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
|
|
configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1"
|
|
supervisorclient "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/typed/config/v1alpha1"
|
|
"go.pinniped.dev/internal/oidc/oidcclientvalidator"
|
|
"go.pinniped.dev/internal/oidcclientsecretstorage"
|
|
"go.pinniped.dev/internal/plog"
|
|
)
|
|
|
|
// PinnipedCLIClientID is the client ID of the statically defined public OIDC client which is used by the CLI.
|
|
const PinnipedCLIClientID = "pinniped-cli"
|
|
|
|
// Client represents a Pinniped OAuth/OIDC client. It can be the static pinniped-cli client
|
|
// or a dynamic client defined by an OIDCClient CR.
|
|
type Client struct {
|
|
fosite.DefaultOpenIDConnectClient
|
|
}
|
|
|
|
// Client implements the base, OIDC, and response_mode client interfaces of Fosite.
|
|
var (
|
|
_ fosite.Client = (*Client)(nil)
|
|
_ fosite.OpenIDConnectClient = (*Client)(nil)
|
|
_ fosite.ResponseModeClient = (*Client)(nil)
|
|
)
|
|
|
|
func (c Client) GetResponseModes() []fosite.ResponseModeType {
|
|
if c.ID == PinnipedCLIClientID {
|
|
// The pinniped-cli client supports "" (unspecified), "query", and "form_post" response modes.
|
|
return []fosite.ResponseModeType{fosite.ResponseModeDefault, fosite.ResponseModeQuery, fosite.ResponseModeFormPost}
|
|
}
|
|
// For now, all other clients support only "" (unspecified) and "query" response modes.
|
|
return []fosite.ResponseModeType{fosite.ResponseModeDefault, fosite.ResponseModeQuery}
|
|
}
|
|
|
|
// ClientManager is a fosite.ClientManager with a statically-defined client and with dynamically-defined clients.
|
|
type ClientManager struct {
|
|
oidcClientsClient supervisorclient.OIDCClientInterface
|
|
storage *oidcclientsecretstorage.OIDCClientSecretStorage
|
|
minBcryptCost int
|
|
}
|
|
|
|
var _ fosite.ClientManager = (*ClientManager)(nil)
|
|
|
|
func NewClientManager(
|
|
oidcClientsClient supervisorclient.OIDCClientInterface,
|
|
storage *oidcclientsecretstorage.OIDCClientSecretStorage,
|
|
minBcryptCost int,
|
|
) *ClientManager {
|
|
return &ClientManager{
|
|
oidcClientsClient: oidcClientsClient,
|
|
storage: storage,
|
|
minBcryptCost: minBcryptCost,
|
|
}
|
|
}
|
|
|
|
// GetClient returns the client specified by the given ID.
|
|
//
|
|
// It returns a fosite.ErrNotFound if an unknown client is specified.
|
|
// Other errors returned are plain errors, because fosite will wrap them into a new ErrInvalidClient error and
|
|
// use the plain error's text as that error's debug message (see client_authentication.go in fosite).
|
|
func (m *ClientManager) GetClient(ctx context.Context, id string) (fosite.Client, error) {
|
|
if id == PinnipedCLIClientID {
|
|
// Return the static client. No lookups needed.
|
|
return PinnipedCLI(), nil
|
|
}
|
|
|
|
// Try to look up an OIDCClient with the given client ID (which will be the Name of the OIDCClient).
|
|
oidcClient, err := m.oidcClientsClient.Get(ctx, id, v1.GetOptions{})
|
|
if errors.IsNotFound(err) {
|
|
return nil, fosite.ErrNotFound.WithDescription("no such client")
|
|
}
|
|
if err != nil {
|
|
// Log the error so an admin can see why the lookup failed at the time of the request.
|
|
plog.Error("OIDC client lookup GetClient() failed to get OIDCClient", err, "clientID", id)
|
|
return nil, fmt.Errorf("failed to get client %q", id)
|
|
}
|
|
|
|
// Try to find the corresponding client secret storage Secret.
|
|
storageSecret, err := m.storage.GetStorageSecret(ctx, oidcClient.UID)
|
|
if err != nil {
|
|
// Log the error so an admin can see why the lookup failed at the time of the request.
|
|
plog.Error("OIDC client lookup GetClient() failed to get storage secret for OIDCClient", err, "clientID", id)
|
|
return nil, fmt.Errorf("failed to get storage secret for client %q", id)
|
|
}
|
|
|
|
// Check if the OIDCClient and its corresponding Secret are valid.
|
|
valid, conditions, clientSecrets := oidcclientvalidator.Validate(oidcClient, storageSecret, m.minBcryptCost)
|
|
if !valid {
|
|
// Log the conditions so an admin can see exactly what was invalid at the time of the request.
|
|
plog.Debug("OIDC client lookup GetClient() found an invalid client", "clientID", id, "conditions", conditions)
|
|
return nil, fmt.Errorf("client %q exists but is invalid or not ready", id)
|
|
}
|
|
|
|
// Everything is valid, so return the client. Note that it has at least one client secret to be considered valid.
|
|
return oidcClientCRToFositeClient(oidcClient, clientSecrets), nil
|
|
}
|
|
|
|
// ClientAssertionJWTValid returns an error if the JTI is
|
|
// known or the DB check failed and nil if the JTI is not known.
|
|
//
|
|
// This functionality is not supported by the ClientManager.
|
|
func (*ClientManager) ClientAssertionJWTValid(ctx context.Context, jti string) error {
|
|
return fmt.Errorf("not implemented")
|
|
}
|
|
|
|
// SetClientAssertionJWT marks a JTI as known for the given
|
|
// expiry time. Before inserting the new JTI, it will clean
|
|
// up any existing JTIs that have expired as those tokens can
|
|
// not be replayed due to the expiry.
|
|
//
|
|
// This functionality is not supported by the ClientManager.
|
|
func (*ClientManager) SetClientAssertionJWT(ctx context.Context, jti string, exp time.Time) error {
|
|
return fmt.Errorf("not implemented")
|
|
}
|
|
|
|
// PinnipedCLI returns the static Client corresponding to the Pinniped CLI.
|
|
func PinnipedCLI() *Client {
|
|
return &Client{
|
|
DefaultOpenIDConnectClient: fosite.DefaultOpenIDConnectClient{
|
|
DefaultClient: &fosite.DefaultClient{
|
|
ID: PinnipedCLIClientID,
|
|
Secret: nil,
|
|
RedirectURIs: []string{"http://127.0.0.1/callback"},
|
|
GrantTypes: fosite.Arguments{
|
|
"authorization_code",
|
|
"refresh_token",
|
|
"urn:ietf:params:oauth:grant-type:token-exchange",
|
|
},
|
|
ResponseTypes: []string{"code"},
|
|
Scopes: fosite.Arguments{
|
|
oidc.ScopeOpenID,
|
|
oidc.ScopeOfflineAccess,
|
|
"profile",
|
|
"email",
|
|
"pinniped:request-audience",
|
|
"groups",
|
|
},
|
|
Audience: nil,
|
|
Public: true,
|
|
},
|
|
RequestURIs: nil,
|
|
JSONWebKeys: nil,
|
|
JSONWebKeysURI: "",
|
|
RequestObjectSigningAlgorithm: "",
|
|
TokenEndpointAuthSigningAlgorithm: oidc.RS256,
|
|
TokenEndpointAuthMethod: "none",
|
|
},
|
|
}
|
|
}
|
|
|
|
func oidcClientCRToFositeClient(oidcClient *configv1alpha1.OIDCClient, clientSecrets []string) *Client {
|
|
return &Client{
|
|
DefaultOpenIDConnectClient: fosite.DefaultOpenIDConnectClient{
|
|
DefaultClient: &fosite.DefaultClient{
|
|
ID: oidcClient.Name,
|
|
// We set RotatedSecrets, but we don't need to also set Secret because the client_authentication.go code
|
|
// will always call the hasher on the empty Secret first, and the bcrypt hasher will always fail very
|
|
// quickly (ErrHashTooShort error), and then client_authentication.go will move on to using the
|
|
// RotatedSecrets instead.
|
|
RotatedSecrets: stringSliceToByteSlices(clientSecrets),
|
|
RedirectURIs: redirectURIsToStrings(oidcClient.Spec.AllowedRedirectURIs),
|
|
GrantTypes: grantTypesToArguments(oidcClient.Spec.AllowedGrantTypes),
|
|
ResponseTypes: []string{"code"},
|
|
Scopes: scopesToArguments(oidcClient.Spec.AllowedScopes),
|
|
Audience: nil,
|
|
Public: false,
|
|
},
|
|
RequestURIs: nil,
|
|
JSONWebKeys: nil,
|
|
JSONWebKeysURI: "",
|
|
RequestObjectSigningAlgorithm: "",
|
|
TokenEndpointAuthSigningAlgorithm: oidc.RS256,
|
|
TokenEndpointAuthMethod: "client_secret_basic",
|
|
},
|
|
}
|
|
}
|
|
|
|
func scopesToArguments(scopes []configv1alpha1.Scope) fosite.Arguments {
|
|
a := make(fosite.Arguments, len(scopes))
|
|
for i, scope := range scopes {
|
|
a[i] = string(scope)
|
|
}
|
|
return a
|
|
}
|
|
|
|
func grantTypesToArguments(grantTypes []configv1alpha1.GrantType) fosite.Arguments {
|
|
a := make(fosite.Arguments, len(grantTypes))
|
|
for i, grantType := range grantTypes {
|
|
a[i] = string(grantType)
|
|
}
|
|
return a
|
|
}
|
|
|
|
func redirectURIsToStrings(uris []configv1alpha1.RedirectURI) []string {
|
|
s := make([]string, len(uris))
|
|
for i, uri := range uris {
|
|
s[i] = string(uri)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func stringSliceToByteSlices(s []string) [][]byte {
|
|
b := make([][]byte, len(s))
|
|
for i, str := range s {
|
|
b[i] = []byte(str)
|
|
}
|
|
return b
|
|
}
|