mirror of
https://github.com/vmware-tanzu/pinniped.git
synced 2026-01-05 13:07:14 +00:00
reorganize federation domain packages to be more intuitive
Co-authored-by: Benjamin A. Petersen <ben@benjaminapetersen.me>
This commit is contained in:
203
internal/federationdomain/endpointsmanager/manager.go
Normal file
203
internal/federationdomain/endpointsmanager/manager.go
Normal file
@@ -0,0 +1,203 @@
|
||||
// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package endpointsmanager
|
||||
|
||||
import (
|
||||
"go.pinniped.dev/internal/httputil/requestutil"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
|
||||
|
||||
"go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/typed/config/v1alpha1"
|
||||
"go.pinniped.dev/internal/federationdomain/csrftoken"
|
||||
"go.pinniped.dev/internal/federationdomain/dynamiccodec"
|
||||
"go.pinniped.dev/internal/federationdomain/endpoints/auth"
|
||||
"go.pinniped.dev/internal/federationdomain/endpoints/callback"
|
||||
"go.pinniped.dev/internal/federationdomain/endpoints/discovery"
|
||||
"go.pinniped.dev/internal/federationdomain/endpoints/idpdiscovery"
|
||||
"go.pinniped.dev/internal/federationdomain/endpoints/jwks"
|
||||
"go.pinniped.dev/internal/federationdomain/endpoints/login"
|
||||
"go.pinniped.dev/internal/federationdomain/endpoints/token"
|
||||
"go.pinniped.dev/internal/federationdomain/federationdomainproviders"
|
||||
"go.pinniped.dev/internal/federationdomain/idplister"
|
||||
"go.pinniped.dev/internal/federationdomain/oidc"
|
||||
"go.pinniped.dev/internal/federationdomain/oidcclientvalidator"
|
||||
"go.pinniped.dev/internal/federationdomain/storage"
|
||||
"go.pinniped.dev/internal/plog"
|
||||
"go.pinniped.dev/internal/secret"
|
||||
"go.pinniped.dev/pkg/oidcclient/nonce"
|
||||
"go.pinniped.dev/pkg/oidcclient/pkce"
|
||||
)
|
||||
|
||||
// Manager can manage multiple active OIDC providers. It acts as a request router for them.
|
||||
//
|
||||
// It is thread-safe.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
providers []*federationdomainproviders.FederationDomainIssuer
|
||||
providerHandlers map[string]http.Handler // map of all routes for all providers
|
||||
nextHandler http.Handler // the next handler in a chain, called when this manager didn't know how to handle a request
|
||||
dynamicJWKSProvider jwks.DynamicJWKSProvider // in-memory cache of per-issuer JWKS data
|
||||
upstreamIDPs idplister.UpstreamIdentityProvidersLister // in-memory cache of upstream IDPs
|
||||
secretCache *secret.Cache // in-memory cache of cryptographic material
|
||||
secretsClient corev1client.SecretInterface
|
||||
oidcClientsClient v1alpha1.OIDCClientInterface
|
||||
}
|
||||
|
||||
// NewManager returns an empty Manager.
|
||||
// nextHandler will be invoked for any requests that could not be handled by this manager's providers.
|
||||
// dynamicJWKSProvider will be used as an in-memory cache for per-issuer JWKS data.
|
||||
// upstreamIDPs will be used as an in-memory cache of currently configured upstream IDPs.
|
||||
func NewManager(
|
||||
nextHandler http.Handler,
|
||||
dynamicJWKSProvider jwks.DynamicJWKSProvider,
|
||||
upstreamIDPs idplister.UpstreamIdentityProvidersLister,
|
||||
secretCache *secret.Cache,
|
||||
secretsClient corev1client.SecretInterface,
|
||||
oidcClientsClient v1alpha1.OIDCClientInterface,
|
||||
) *Manager {
|
||||
return &Manager{
|
||||
providerHandlers: make(map[string]http.Handler),
|
||||
nextHandler: nextHandler,
|
||||
dynamicJWKSProvider: dynamicJWKSProvider,
|
||||
upstreamIDPs: upstreamIDPs,
|
||||
secretCache: secretCache,
|
||||
secretsClient: secretsClient,
|
||||
oidcClientsClient: oidcClientsClient,
|
||||
}
|
||||
}
|
||||
|
||||
// SetFederationDomains adds or updates all the given providerHandlers using each provider's issuer string
|
||||
// as the name of the provider to decide if it is an add or update operation.
|
||||
//
|
||||
// It also removes any providerHandlers that were previously added but were not passed in to
|
||||
// the current invocation.
|
||||
//
|
||||
// This method assumes that all of the FederationDomainIssuer arguments have already been validated
|
||||
// by someone else before they are passed to this method.
|
||||
func (m *Manager) SetFederationDomains(federationDomains ...*federationdomainproviders.FederationDomainIssuer) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.providers = federationDomains
|
||||
m.providerHandlers = make(map[string]http.Handler)
|
||||
|
||||
csrfCookieEncoder := dynamiccodec.New(
|
||||
oidc.CSRFCookieLifespan,
|
||||
m.secretCache.GetCSRFCookieEncoderHashKey,
|
||||
func() []byte { return nil },
|
||||
)
|
||||
|
||||
for _, incomingFederationDomain := range federationDomains {
|
||||
issuerURL := incomingFederationDomain.Issuer()
|
||||
issuerHostWithPath := strings.ToLower(incomingFederationDomain.IssuerHost()) + "/" + incomingFederationDomain.IssuerPath()
|
||||
|
||||
tokenHMACKeyGetter := wrapGetter(incomingFederationDomain.Issuer(), m.secretCache.GetTokenHMACKey)
|
||||
|
||||
timeoutsConfiguration := oidc.DefaultOIDCTimeoutsConfiguration()
|
||||
|
||||
// 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(
|
||||
storage.NewNullStorage(m.secretsClient, m.oidcClientsClient, oidcclientvalidator.DefaultMinBcryptCost),
|
||||
issuerURL,
|
||||
tokenHMACKeyGetter,
|
||||
nil,
|
||||
timeoutsConfiguration,
|
||||
)
|
||||
|
||||
// For all the other endpoints, make another oauth helper with exactly the same settings except use real storage.
|
||||
oauthHelperWithKubeStorage := oidc.FositeOauth2Helper(
|
||||
storage.NewKubeStorage(m.secretsClient, m.oidcClientsClient, timeoutsConfiguration, oidcclientvalidator.DefaultMinBcryptCost),
|
||||
issuerURL,
|
||||
tokenHMACKeyGetter,
|
||||
m.dynamicJWKSProvider,
|
||||
timeoutsConfiguration,
|
||||
)
|
||||
|
||||
upstreamStateEncoder := dynamiccodec.New(
|
||||
timeoutsConfiguration.UpstreamStateParamLifespan,
|
||||
wrapGetter(incomingFederationDomain.Issuer(), m.secretCache.GetStateEncoderHashKey),
|
||||
wrapGetter(incomingFederationDomain.Issuer(), m.secretCache.GetStateEncoderBlockKey),
|
||||
)
|
||||
|
||||
idpLister := federationdomainproviders.NewFederationDomainIdentityProvidersListerFinder(incomingFederationDomain, m.upstreamIDPs)
|
||||
|
||||
m.providerHandlers[(issuerHostWithPath + oidc.WellKnownEndpointPath)] = discovery.NewHandler(issuerURL)
|
||||
|
||||
m.providerHandlers[(issuerHostWithPath + oidc.JWKSEndpointPath)] = jwks.NewHandler(issuerURL, m.dynamicJWKSProvider)
|
||||
|
||||
m.providerHandlers[(issuerHostWithPath + oidc.PinnipedIDPsPathV1Alpha1)] = idpdiscovery.NewHandler(idpLister)
|
||||
|
||||
m.providerHandlers[(issuerHostWithPath + oidc.AuthorizationEndpointPath)] = auth.NewHandler(
|
||||
issuerURL,
|
||||
idpLister,
|
||||
oauthHelperWithNullStorage,
|
||||
oauthHelperWithKubeStorage,
|
||||
csrftoken.Generate,
|
||||
pkce.Generate,
|
||||
nonce.Generate,
|
||||
upstreamStateEncoder,
|
||||
csrfCookieEncoder,
|
||||
)
|
||||
|
||||
m.providerHandlers[(issuerHostWithPath + oidc.CallbackEndpointPath)] = callback.NewHandler(
|
||||
idpLister,
|
||||
oauthHelperWithKubeStorage,
|
||||
upstreamStateEncoder,
|
||||
csrfCookieEncoder,
|
||||
issuerURL+oidc.CallbackEndpointPath,
|
||||
)
|
||||
|
||||
m.providerHandlers[(issuerHostWithPath + oidc.TokenEndpointPath)] = token.NewHandler(
|
||||
idpLister,
|
||||
oauthHelperWithKubeStorage,
|
||||
)
|
||||
|
||||
m.providerHandlers[(issuerHostWithPath + oidc.PinnipedLoginPath)] = login.NewHandler(
|
||||
upstreamStateEncoder,
|
||||
csrfCookieEncoder,
|
||||
login.NewGetHandler(incomingFederationDomain.IssuerPath()+oidc.PinnipedLoginPath),
|
||||
login.NewPostHandler(issuerURL, idpLister, oauthHelperWithKubeStorage),
|
||||
)
|
||||
|
||||
plog.Debug("oidc provider manager added or updated issuer", "issuer", issuerURL)
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP implements the http.Handler interface.
|
||||
func (m *Manager) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
|
||||
requestHandler := m.findHandler(req)
|
||||
|
||||
// Using Info level so the user can safely configure a production Supervisor to show this message if they choose.
|
||||
plog.Info("received incoming request",
|
||||
"proto", req.Proto,
|
||||
"method", req.Method,
|
||||
"host", req.Host,
|
||||
"requestSNIServerName", requestutil.SNIServerName(req),
|
||||
"path", req.URL.Path,
|
||||
"remoteAddr", req.RemoteAddr,
|
||||
"foundFederationDomainRequestHandler", requestHandler != nil,
|
||||
)
|
||||
|
||||
if requestHandler == nil {
|
||||
requestHandler = m.nextHandler // couldn't find an issuer to handle the request
|
||||
}
|
||||
requestHandler.ServeHTTP(resp, req)
|
||||
}
|
||||
|
||||
func (m *Manager) findHandler(req *http.Request) http.Handler {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
return m.providerHandlers[strings.ToLower(req.Host)+"/"+req.URL.Path]
|
||||
}
|
||||
|
||||
func wrapGetter(issuer string, getter func(string) []byte) func() []byte {
|
||||
return func() []byte {
|
||||
return getter(issuer)
|
||||
}
|
||||
}
|
||||
459
internal/federationdomain/endpointsmanager/manager_test.go
Normal file
459
internal/federationdomain/endpointsmanager/manager_test.go
Normal file
@@ -0,0 +1,459 @@
|
||||
// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package endpointsmanager
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sclevine/spec"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/square/go-jose.v2"
|
||||
"k8s.io/client-go/kubernetes/fake"
|
||||
|
||||
supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake"
|
||||
"go.pinniped.dev/internal/federationdomain/endpoints/discovery"
|
||||
"go.pinniped.dev/internal/federationdomain/endpoints/jwks"
|
||||
"go.pinniped.dev/internal/federationdomain/federationdomainproviders"
|
||||
"go.pinniped.dev/internal/federationdomain/oidc"
|
||||
"go.pinniped.dev/internal/here"
|
||||
"go.pinniped.dev/internal/idtransform"
|
||||
"go.pinniped.dev/internal/secret"
|
||||
"go.pinniped.dev/internal/testutil"
|
||||
"go.pinniped.dev/internal/testutil/oidctestutil"
|
||||
)
|
||||
|
||||
func TestManager(t *testing.T) {
|
||||
spec.Run(t, "ServeHTTP", func(t *testing.T, when spec.G, it spec.S) {
|
||||
var (
|
||||
r *require.Assertions
|
||||
subject *Manager
|
||||
nextHandler http.HandlerFunc
|
||||
fallbackHandlerWasCalled bool
|
||||
dynamicJWKSProvider jwks.DynamicJWKSProvider
|
||||
federationDomainIDPs []*federationdomainproviders.FederationDomainIdentityProvider
|
||||
kubeClient *fake.Clientset
|
||||
)
|
||||
|
||||
const (
|
||||
issuer1 = "https://example.com/some/path"
|
||||
issuer1DifferentCaseHostname = "https://eXamPle.coM/some/path"
|
||||
issuer1KeyID = "issuer1-key"
|
||||
issuer2 = "https://example.com/some/path/more/deeply/nested/path" // note that this is a sub-path of the other issuer url
|
||||
issuer2DifferentCaseHostname = "https://exAmPlE.Com/some/path/more/deeply/nested/path"
|
||||
issuer2KeyID = "issuer2-key"
|
||||
upstreamIDPAuthorizationURL = "https://test-upstream.com/auth"
|
||||
upstreamIDPName = "test-idp"
|
||||
upstreamResourceUID = "test-resource-uid"
|
||||
upstreamIDPType = "oidc"
|
||||
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"
|
||||
)
|
||||
|
||||
var (
|
||||
upstreamIDPFlows = []string{"browser_authcode"}
|
||||
)
|
||||
|
||||
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, expectedIssuer string) {
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
subject.ServeHTTP(recorder, newGetRequest(requestIssuer+oidc.WellKnownEndpointPath+requestURLSuffix))
|
||||
|
||||
r.False(fallbackHandlerWasCalled)
|
||||
|
||||
// Minimal check to ensure that the right discovery endpoint was called
|
||||
r.Equal(http.StatusOK, recorder.Code)
|
||||
responseBody, err := io.ReadAll(recorder.Body)
|
||||
r.NoError(err)
|
||||
parsedDiscoveryResult := discovery.Metadata{}
|
||||
err = json.Unmarshal(responseBody, &parsedDiscoveryResult)
|
||||
r.NoError(err)
|
||||
r.Equal(expectedIssuer, parsedDiscoveryResult.Issuer)
|
||||
r.Equal(parsedDiscoveryResult.SupervisorDiscovery.PinnipedIDPsEndpoint, expectedIssuer+oidc.PinnipedIDPsPathV1Alpha1)
|
||||
}
|
||||
|
||||
requirePinnipedIDPsDiscoveryRequestToBeHandled := func(requestIssuer, requestURLSuffix, expectedIDPName, expectedIDPType string, expectedFlows []string) {
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
subject.ServeHTTP(recorder, newGetRequest(requestIssuer+oidc.PinnipedIDPsPathV1Alpha1+requestURLSuffix))
|
||||
|
||||
r.False(fallbackHandlerWasCalled)
|
||||
|
||||
expectedFlowsJSON, err := json.Marshal(expectedFlows)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Minimal check to ensure that the right IDP discovery endpoint was called
|
||||
r.Equal(http.StatusOK, recorder.Code)
|
||||
responseBody, err := io.ReadAll(recorder.Body)
|
||||
r.NoError(err)
|
||||
r.Equal(
|
||||
fmt.Sprintf(`{"pinniped_identity_providers":[{"name":"%s","type":"%s","flows":%s}]}`+"\n", expectedIDPName, expectedIDPType, expectedFlowsJSON),
|
||||
string(responseBody),
|
||||
)
|
||||
}
|
||||
|
||||
requireAuthorizationRequestToBeHandled := func(requestIssuer, requestURLSuffix, expectedRedirectLocationPrefix string) (string, string) {
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
subject.ServeHTTP(recorder, newGetRequest(requestIssuer+oidc.AuthorizationEndpointPath+requestURLSuffix))
|
||||
|
||||
r.False(fallbackHandlerWasCalled)
|
||||
|
||||
// Minimal check to ensure that the right endpoint was called
|
||||
r.Equal(http.StatusSeeOther, recorder.Code)
|
||||
actualLocation := recorder.Header().Get("Location")
|
||||
r.True(
|
||||
strings.HasPrefix(actualLocation, expectedRedirectLocationPrefix),
|
||||
"actual location %s did not start with expected prefix %s",
|
||||
actualLocation, expectedRedirectLocationPrefix,
|
||||
)
|
||||
|
||||
parsedLocation, err := url.Parse(actualLocation)
|
||||
r.NoError(err)
|
||||
redirectStateParam := parsedLocation.Query().Get("state")
|
||||
r.NotEmpty(redirectStateParam)
|
||||
|
||||
cookies := recorder.Result().Cookies() //nolint:bodyclose
|
||||
r.Len(cookies, 1)
|
||||
csrfCookie := cookies[0]
|
||||
r.Equal("__Host-pinniped-csrf", csrfCookie.Name)
|
||||
r.NotEmpty(csrfCookie.Value)
|
||||
|
||||
// Return the important parts of the response so we can use them in our next request to the callback endpoint
|
||||
return csrfCookie.Value, redirectStateParam
|
||||
}
|
||||
|
||||
requireCallbackRequestToBeHandled := func(requestIssuer, requestURLSuffix, csrfCookieValue string) string {
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
numberOfKubeActionsBeforeThisRequest := len(kubeClient.Actions())
|
||||
|
||||
getRequest := newGetRequest(requestIssuer + oidc.CallbackEndpointPath + requestURLSuffix)
|
||||
getRequest.AddCookie(&http.Cookie{
|
||||
Name: "__Host-pinniped-csrf",
|
||||
Value: csrfCookieValue,
|
||||
})
|
||||
subject.ServeHTTP(recorder, getRequest)
|
||||
|
||||
r.False(fallbackHandlerWasCalled)
|
||||
|
||||
// Check just enough of the response to ensure that we wired up the callback endpoint correctly.
|
||||
// The endpoint's own unit tests cover everything else.
|
||||
r.Equal(http.StatusSeeOther, recorder.Code)
|
||||
actualLocation := recorder.Header().Get("Location")
|
||||
r.True(
|
||||
strings.HasPrefix(actualLocation, downstreamRedirectURL),
|
||||
"actual location %s did not start with expected prefix %s",
|
||||
actualLocation, downstreamRedirectURL,
|
||||
)
|
||||
parsedLocation, err := url.Parse(actualLocation)
|
||||
r.NoError(err)
|
||||
actualLocationQueryParams := parsedLocation.Query()
|
||||
r.Contains(actualLocationQueryParams, "code")
|
||||
r.Equal("openid username groups", actualLocationQueryParams.Get("scope"))
|
||||
r.Equal("some-state-value-with-enough-bytes-to-exceed-min-allowed", actualLocationQueryParams.Get("state"))
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
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+9,
|
||||
"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))
|
||||
|
||||
r.False(fallbackHandlerWasCalled)
|
||||
|
||||
// Minimal check to ensure that the right JWKS endpoint was called
|
||||
r.Equal(http.StatusOK, recorder.Code)
|
||||
responseBody, err := io.ReadAll(recorder.Body)
|
||||
r.NoError(err)
|
||||
parsedJWKSResult := jose.JSONWebKeySet{}
|
||||
err = json.Unmarshal(responseBody, &parsedJWKSResult)
|
||||
r.NoError(err)
|
||||
r.Equal(expectedJWKKeyID, parsedJWKSResult.Keys[0].KeyID)
|
||||
|
||||
return &parsedJWKSResult
|
||||
}
|
||||
|
||||
it.Before(func() {
|
||||
r = require.New(t)
|
||||
nextHandler = func(http.ResponseWriter, *http.Request) {
|
||||
fallbackHandlerWasCalled = true
|
||||
}
|
||||
dynamicJWKSProvider = jwks.NewDynamicJWKSProvider()
|
||||
|
||||
parsedUpstreamIDPAuthorizationURL, err := url.Parse(upstreamIDPAuthorizationURL)
|
||||
r.NoError(err)
|
||||
|
||||
federationDomainIDPs = []*federationdomainproviders.FederationDomainIdentityProvider{
|
||||
{
|
||||
DisplayName: upstreamIDPName,
|
||||
UID: upstreamResourceUID,
|
||||
Transforms: idtransform.NewTransformationPipeline(),
|
||||
},
|
||||
}
|
||||
|
||||
idpLister := oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder().
|
||||
WithName(upstreamIDPName).
|
||||
WithClientID("test-client-id").
|
||||
WithResourceUID(upstreamResourceUID).
|
||||
WithAuthorizationURL(*parsedUpstreamIDPAuthorizationURL).
|
||||
WithScopes([]string{"test-scope"}).
|
||||
WithIDTokenClaim("iss", "https://some-issuer.com").
|
||||
WithIDTokenClaim("sub", "some-subject").
|
||||
WithIDTokenClaim("username", "test-username").
|
||||
WithIDTokenClaim("groups", "test-group1").
|
||||
WithRefreshToken("some-opaque-token").
|
||||
WithoutAccessToken().
|
||||
Build(),
|
||||
).BuildDynamicUpstreamIDPProvider()
|
||||
|
||||
kubeClient = fake.NewSimpleClientset()
|
||||
secretsClient := kubeClient.CoreV1().Secrets("some-namespace")
|
||||
oidcClientsClient := supervisorfake.NewSimpleClientset().ConfigV1alpha1().OIDCClients("some-namespace")
|
||||
|
||||
cache := secret.Cache{}
|
||||
cache.SetCSRFCookieEncoderHashKey([]byte("fake-csrf-hash-secret"))
|
||||
|
||||
cache.SetTokenHMACKey(issuer1, []byte("some secret 1 - must have at least 32 bytes"))
|
||||
cache.SetStateEncoderHashKey(issuer1, []byte("some-state-encoder-hash-key-1"))
|
||||
cache.SetStateEncoderBlockKey(issuer1, []byte("16-bytes-STATE01"))
|
||||
|
||||
cache.SetTokenHMACKey(issuer2, []byte("some secret 2 - must have at least 32 bytes"))
|
||||
cache.SetStateEncoderHashKey(issuer2, []byte("some-state-encoder-hash-key-2"))
|
||||
cache.SetStateEncoderBlockKey(issuer2, []byte("16-bytes-STATE02"))
|
||||
|
||||
subject = NewManager(nextHandler, dynamicJWKSProvider, idpLister, &cache, secretsClient, oidcClientsClient)
|
||||
})
|
||||
|
||||
when("given no providers via SetFederationDomains()", func() {
|
||||
it("sends all requests to the nextHandler", func() {
|
||||
r.False(fallbackHandlerWasCalled)
|
||||
subject.ServeHTTP(httptest.NewRecorder(), newGetRequest("/anything"))
|
||||
r.True(fallbackHandlerWasCalled)
|
||||
})
|
||||
})
|
||||
|
||||
newTestJWK := func(keyID string) *jose.JSONWebKey {
|
||||
testJWKSJSONString := here.Docf(`
|
||||
{
|
||||
"use": "sig",
|
||||
"kty": "EC",
|
||||
"kid": "%s",
|
||||
"crv": "P-256",
|
||||
"alg": "ES256",
|
||||
"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
|
||||
}
|
||||
|
||||
requireRoutesMatchingRequestsToAppropriateProvider := func() {
|
||||
requireDiscoveryRequestToBeHandled(issuer1, "", issuer1)
|
||||
requireDiscoveryRequestToBeHandled(issuer2, "", issuer2)
|
||||
requireDiscoveryRequestToBeHandled(issuer2, "?some=query", issuer2)
|
||||
|
||||
// Hostnames are case-insensitive, so test that we can handle that.
|
||||
requireDiscoveryRequestToBeHandled(issuer1DifferentCaseHostname, "", issuer1)
|
||||
requireDiscoveryRequestToBeHandled(issuer2DifferentCaseHostname, "", issuer2)
|
||||
requireDiscoveryRequestToBeHandled(issuer2DifferentCaseHostname, "?some=query", issuer2)
|
||||
|
||||
requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer1, "", upstreamIDPName, upstreamIDPType, upstreamIDPFlows)
|
||||
requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer2, "", upstreamIDPName, upstreamIDPType, upstreamIDPFlows)
|
||||
requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer2, "?some=query", upstreamIDPName, upstreamIDPType, upstreamIDPFlows)
|
||||
|
||||
// Hostnames are case-insensitive, so test that we can handle that.
|
||||
requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer1DifferentCaseHostname, "", upstreamIDPName, upstreamIDPType, upstreamIDPFlows)
|
||||
requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer2DifferentCaseHostname, "", upstreamIDPName, upstreamIDPType, upstreamIDPFlows)
|
||||
requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer2DifferentCaseHostname, "?some=query", upstreamIDPName, upstreamIDPType, upstreamIDPFlows)
|
||||
|
||||
issuer1JWKS := requireJWKSRequestToBeHandled(issuer1, "", issuer1KeyID)
|
||||
issuer2JWKS := requireJWKSRequestToBeHandled(issuer2, "", issuer2KeyID)
|
||||
requireJWKSRequestToBeHandled(issuer2, "?some=query", issuer2KeyID)
|
||||
|
||||
// Hostnames are case-insensitive, so test that we can handle that.
|
||||
requireJWKSRequestToBeHandled(issuer1DifferentCaseHostname, "", issuer1KeyID)
|
||||
requireJWKSRequestToBeHandled(issuer2DifferentCaseHostname, "", issuer2KeyID)
|
||||
requireJWKSRequestToBeHandled(issuer2DifferentCaseHostname, "?some=query", issuer2KeyID)
|
||||
|
||||
authRequestParams := "?" + url.Values{
|
||||
"pinniped_idp_name": []string{upstreamIDPName},
|
||||
"response_type": []string{"code"},
|
||||
"scope": []string{"openid profile email username groups"},
|
||||
"client_id": []string{downstreamClientID},
|
||||
"state": []string{"some-state-value-with-enough-bytes-to-exceed-min-allowed"},
|
||||
"nonce": []string{"some-nonce-value-with-enough-bytes-to-exceed-min-allowed"},
|
||||
"code_challenge": []string{testutil.SHA256(downstreamPKCECodeVerifier)},
|
||||
"code_challenge_method": []string{"S256"},
|
||||
"redirect_uri": []string{downstreamRedirectURL},
|
||||
}.Encode()
|
||||
|
||||
requireAuthorizationRequestToBeHandled(issuer1, authRequestParams, upstreamIDPAuthorizationURL)
|
||||
requireAuthorizationRequestToBeHandled(issuer2, authRequestParams, upstreamIDPAuthorizationURL)
|
||||
|
||||
// Hostnames are case-insensitive, so test that we can handle that.
|
||||
csrfCookieValue1, upstreamStateParam1 :=
|
||||
requireAuthorizationRequestToBeHandled(issuer1DifferentCaseHostname, authRequestParams, upstreamIDPAuthorizationURL)
|
||||
csrfCookieValue2, upstreamStateParam2 :=
|
||||
requireAuthorizationRequestToBeHandled(issuer2DifferentCaseHostname, authRequestParams, upstreamIDPAuthorizationURL)
|
||||
|
||||
callbackRequestParams1 := "?" + url.Values{
|
||||
"code": []string{"some-fake-code"},
|
||||
"state": []string{upstreamStateParam1},
|
||||
}.Encode()
|
||||
callbackRequestParams2 := "?" + url.Values{
|
||||
"code": []string{"some-fake-code"},
|
||||
"state": []string{upstreamStateParam2},
|
||||
}.Encode()
|
||||
|
||||
downstreamAuthCode1 := requireCallbackRequestToBeHandled(issuer1, callbackRequestParams1, csrfCookieValue1)
|
||||
downstreamAuthCode2 := requireCallbackRequestToBeHandled(issuer2, callbackRequestParams2, csrfCookieValue2)
|
||||
|
||||
// Hostnames are case-insensitive, so test that we can handle that.
|
||||
downstreamAuthCode3 := requireCallbackRequestToBeHandled(issuer1DifferentCaseHostname, callbackRequestParams1, csrfCookieValue1)
|
||||
downstreamAuthCode4 := requireCallbackRequestToBeHandled(issuer2DifferentCaseHostname, callbackRequestParams2, csrfCookieValue2)
|
||||
|
||||
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 SetFederationDomains()", func() {
|
||||
it.Before(func() {
|
||||
fd1, err := federationdomainproviders.NewFederationDomainIssuer(issuer1, federationDomainIDPs)
|
||||
r.NoError(err)
|
||||
fd2, err := federationdomainproviders.NewFederationDomainIssuer(issuer2, federationDomainIDPs)
|
||||
r.NoError(err)
|
||||
subject.SetFederationDomains(fd1, fd2)
|
||||
|
||||
jwksMap := 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(jwksMap, activeJWK)
|
||||
})
|
||||
|
||||
it("sends all non-matching host requests to the nextHandler", func() {
|
||||
r.False(fallbackHandlerWasCalled)
|
||||
wrongHostURL := strings.ReplaceAll(issuer1+oidc.WellKnownEndpointPath, "example.com", "wrong-host.com")
|
||||
subject.ServeHTTP(httptest.NewRecorder(), newGetRequest(wrongHostURL))
|
||||
r.True(fallbackHandlerWasCalled)
|
||||
})
|
||||
|
||||
it("sends all non-matching path requests to the nextHandler", func() {
|
||||
r.False(fallbackHandlerWasCalled)
|
||||
subject.ServeHTTP(httptest.NewRecorder(), newGetRequest("https://example.com/path-does-not-match-any-provider"))
|
||||
r.True(fallbackHandlerWasCalled)
|
||||
})
|
||||
|
||||
it("sends requests which match the issuer prefix but do not match any of that provider's known paths to the nextHandler", func() {
|
||||
r.False(fallbackHandlerWasCalled)
|
||||
subject.ServeHTTP(httptest.NewRecorder(), newGetRequest(issuer1+"/unhandled-sub-path"))
|
||||
r.True(fallbackHandlerWasCalled)
|
||||
})
|
||||
|
||||
it("routes matching requests to the appropriate provider", func() {
|
||||
requireRoutesMatchingRequestsToAppropriateProvider()
|
||||
})
|
||||
})
|
||||
|
||||
when("given the same valid providers as arguments to SetFederationDomains() in reverse order", func() {
|
||||
it.Before(func() {
|
||||
fd1, err := federationdomainproviders.NewFederationDomainIssuer(issuer1, federationDomainIDPs)
|
||||
r.NoError(err)
|
||||
fd2, err := federationdomainproviders.NewFederationDomainIssuer(issuer2, federationDomainIDPs)
|
||||
r.NoError(err)
|
||||
subject.SetFederationDomains(fd2, fd1)
|
||||
|
||||
jwksMap := 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(jwksMap, activeJWK)
|
||||
})
|
||||
|
||||
it("still routes matching requests to the appropriate provider", func() {
|
||||
requireRoutesMatchingRequestsToAppropriateProvider()
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user