Merge pull request #1928 from vmware-tanzu/jtc/add-idp-type-discovery

Add `pinniped_supported_identity_provider_types` to the IDP discovery endpoint
This commit is contained in:
Ryan Richard
2024-05-16 13:06:38 -07:00
committed by GitHub
23 changed files with 1926 additions and 495 deletions

View File

@@ -1,4 +1,4 @@
// Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
// Copyright 2021-2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package v1alpha1
@@ -54,7 +54,8 @@ type OIDCDiscoveryResponseIDPEndpoint struct {
// IDPDiscoveryResponse is the response of a FederationDomain's identity provider discovery endpoint.
type IDPDiscoveryResponse struct {
PinnipedIDPs []PinnipedIDP `json:"pinniped_identity_providers"`
PinnipedIDPs []PinnipedIDP `json:"pinniped_identity_providers"`
PinnipedSupportedIDPTypes []PinnipedSupportedIDPType `json:"pinniped_supported_identity_provider_types"`
}
// PinnipedIDP describes a single identity provider as included in the response of a FederationDomain's
@@ -64,3 +65,8 @@ type PinnipedIDP struct {
Type IDPType `json:"type"`
Flows []IDPFlow `json:"flows,omitempty"`
}
// PinnipedSupportedIDPType describes a single identity provider type.
type PinnipedSupportedIDPType struct {
Type IDPType `json:"type"`
}

View File

@@ -908,7 +908,7 @@ func TestGetKubeconfig(t *testing.T) {
idpsDiscoveryResponse: here.Docf(`{
"pinniped_identity_providers": [
{"name": "some-ldap-idp", "type": "ldap"},
{"name": "some-oidc-idp", "type": "oidc"}
{"name": "some-oidc-idp", "type": "oidc", "flows": ["flow1", "flow2"]}
]
}`),
wantLogs: func(issuerCABundle string, issuerURL string) []string {
@@ -927,7 +927,7 @@ func TestGetKubeconfig(t *testing.T) {
wantStderr: func(issuerCABundle string, issuerURL string) testutil.RequireErrorStringFunc {
return testutil.WantExactErrorString(`Error: multiple Supervisor upstream identity providers were found, ` +
`so the --upstream-identity-provider-name/--upstream-identity-provider-type flags must be specified. ` +
`Found these upstreams: [{"name":"some-ldap-idp","type":"ldap"},{"name":"some-oidc-idp","type":"oidc"}]` + "\n")
`Found these upstreams: [{"name":"some-ldap-idp","type":"ldap"},{"name":"some-oidc-idp","type":"oidc","flows":["flow1","flow2"]}]` + "\n")
},
},
{

View File

@@ -12,7 +12,6 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
@@ -59,9 +58,10 @@ func init() {
}
type oidcLoginCommandDeps struct {
lookupEnv func(string) (string, bool)
login func(string, string, ...oidcclient.Option) (*oidctypes.Token, error)
exchangeToken func(context.Context, *conciergeclient.Client, string) (*clientauthv1beta1.ExecCredential, error)
lookupEnv func(string) (string, bool)
login func(string, string, ...oidcclient.Option) (*oidctypes.Token, error)
exchangeToken func(context.Context, *conciergeclient.Client, string) (*clientauthv1beta1.ExecCredential, error)
optionsFactory OIDCClientOptions
}
func oidcLoginCommandRealDeps() oidcLoginCommandDeps {
@@ -71,6 +71,7 @@ func oidcLoginCommandRealDeps() oidcLoginCommandDeps {
exchangeToken: func(ctx context.Context, client *conciergeclient.Client, token string) (*clientauthv1beta1.ExecCredential, error) {
return client.ExchangeToken(ctx, token)
},
optionsFactory: &clientOptions{},
}
}
@@ -175,39 +176,37 @@ func runOIDCLogin(cmd *cobra.Command, deps oidcLoginCommandDeps, flags oidcLogin
// Initialize the login handler.
opts := []oidcclient.Option{
oidcclient.WithContext(cmd.Context()),
oidcclient.WithLogger(plog.Logr()), //nolint:staticcheck // old code with lots of log statements
oidcclient.WithScopes(flags.scopes),
oidcclient.WithSessionCache(sessionCache),
deps.optionsFactory.WithContext(cmd.Context()),
deps.optionsFactory.WithLogger(plog.Logr()), //nolint:staticcheck // old code with lots of log statements
deps.optionsFactory.WithScopes(flags.scopes),
deps.optionsFactory.WithSessionCache(sessionCache),
}
skipPrintLoginURL, _ := deps.lookupEnv(skipPrintLoginURLEnvVarName)
if skipPrintLoginURL == envVarTruthyValue {
opts = append(opts, oidcclient.WithSkipPrintLoginURL())
opts = append(opts, deps.optionsFactory.WithSkipPrintLoginURL())
}
if flags.listenPort != 0 {
opts = append(opts, oidcclient.WithListenPort(flags.listenPort))
opts = append(opts, deps.optionsFactory.WithListenPort(flags.listenPort))
}
if flags.requestAudience != "" {
opts = append(opts, oidcclient.WithRequestAudience(flags.requestAudience))
opts = append(opts, deps.optionsFactory.WithRequestAudience(flags.requestAudience))
}
if flags.upstreamIdentityProviderName != "" {
opts = append(opts, oidcclient.WithUpstreamIdentityProvider(
opts = append(opts, deps.optionsFactory.WithUpstreamIdentityProvider(
flags.upstreamIdentityProviderName, flags.upstreamIdentityProviderType))
}
flowOpts, err := flowOptions(
idpdiscoveryv1alpha1.IDPType(flags.upstreamIdentityProviderType),
idpdiscoveryv1alpha1.IDPFlow(flags.upstreamIdentityProviderFlow),
deps,
)
if err != nil {
return err
requestedFlow, flowSource := idpdiscoveryv1alpha1.IDPFlow(flags.upstreamIdentityProviderFlow), "--upstream-identity-provider-flow"
if flowOverride, hasFlowOverride := deps.lookupEnv(upstreamIdentityProviderFlowEnvVarName); hasFlowOverride {
requestedFlow, flowSource = idpdiscoveryv1alpha1.IDPFlow(flowOverride), upstreamIdentityProviderFlowEnvVarName
}
if requestedFlow != "" {
opts = append(opts, deps.optionsFactory.WithLoginFlow(requestedFlow, flowSource))
}
opts = append(opts, flowOpts...)
var concierge *conciergeclient.Client
if flags.conciergeEnabled {
@@ -225,12 +224,12 @@ func runOIDCLogin(cmd *cobra.Command, deps oidcLoginCommandDeps, flags oidcLogin
// --skip-browser skips opening the browser.
if flags.skipBrowser {
opts = append(opts, oidcclient.WithSkipBrowserOpen())
opts = append(opts, deps.optionsFactory.WithSkipBrowserOpen())
}
// --skip-listen skips starting the localhost callback listener.
if flags.skipListen {
opts = append(opts, oidcclient.WithSkipListen())
opts = append(opts, deps.optionsFactory.WithSkipListen())
}
if len(flags.caBundlePaths) > 0 || len(flags.caBundleData) > 0 {
@@ -238,7 +237,7 @@ func runOIDCLogin(cmd *cobra.Command, deps oidcLoginCommandDeps, flags oidcLogin
if err != nil {
return err
}
opts = append(opts, oidcclient.WithClient(client))
opts = append(opts, deps.optionsFactory.WithClient(client))
}
// Look up cached credentials based on a hash of all the CLI arguments and the cluster info.
cacheKey := struct {
@@ -288,60 +287,6 @@ func runOIDCLogin(cmd *cobra.Command, deps oidcLoginCommandDeps, flags oidcLogin
return json.NewEncoder(cmd.OutOrStdout()).Encode(cred)
}
func flowOptions(
requestedIDPType idpdiscoveryv1alpha1.IDPType,
requestedFlow idpdiscoveryv1alpha1.IDPFlow,
deps oidcLoginCommandDeps,
) ([]oidcclient.Option, error) {
useCLIFlow := []oidcclient.Option{oidcclient.WithCLISendingCredentials()}
// If the env var is set to override the --upstream-identity-provider-type flag, then override it.
flowOverride, hasFlowOverride := deps.lookupEnv(upstreamIdentityProviderFlowEnvVarName)
flowSource := "--upstream-identity-provider-flow"
if hasFlowOverride {
requestedFlow = idpdiscoveryv1alpha1.IDPFlow(flowOverride)
flowSource = upstreamIdentityProviderFlowEnvVarName
}
switch requestedIDPType {
case idpdiscoveryv1alpha1.IDPTypeOIDC:
switch requestedFlow {
case idpdiscoveryv1alpha1.IDPFlowCLIPassword:
return useCLIFlow, nil
case idpdiscoveryv1alpha1.IDPFlowBrowserAuthcode, "":
return nil, nil // browser authcode flow is the default Option, so don't need to return an Option here
default:
return nil, fmt.Errorf(
"%s value not recognized for identity provider type %q: %s (supported values: %s)",
flowSource, requestedIDPType, requestedFlow,
strings.Join([]string{idpdiscoveryv1alpha1.IDPFlowBrowserAuthcode.String(), idpdiscoveryv1alpha1.IDPFlowCLIPassword.String()}, ", "))
}
case idpdiscoveryv1alpha1.IDPTypeLDAP, idpdiscoveryv1alpha1.IDPTypeActiveDirectory:
switch requestedFlow {
case idpdiscoveryv1alpha1.IDPFlowCLIPassword, "":
return useCLIFlow, nil
case idpdiscoveryv1alpha1.IDPFlowBrowserAuthcode:
return nil, nil // browser authcode flow is the default Option, so don't need to return an Option here
default:
return nil, fmt.Errorf(
"%s value not recognized for identity provider type %q: %s (supported values: %s)",
flowSource, requestedIDPType, requestedFlow,
strings.Join([]string{idpdiscoveryv1alpha1.IDPFlowCLIPassword.String(), idpdiscoveryv1alpha1.IDPFlowBrowserAuthcode.String()}, ", "))
}
default:
// Surprisingly cobra does not support this kind of flag validation. See https://github.com/spf13/pflag/issues/236
return nil, fmt.Errorf(
"--upstream-identity-provider-type value not recognized: %s (supported values: %s)",
requestedIDPType,
strings.Join([]string{
idpdiscoveryv1alpha1.IDPTypeOIDC.String(),
idpdiscoveryv1alpha1.IDPTypeLDAP.String(),
idpdiscoveryv1alpha1.IDPTypeActiveDirectory.String(),
}, ", "),
)
}
}
func makeClient(caBundlePaths []string, caBundleData []string) (*http.Client, error) {
pool := x509.NewCertPool()
for _, p := range caBundlePaths {

View File

@@ -14,12 +14,16 @@ import (
"time"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
clientauthv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1"
clocktesting "k8s.io/utils/clock/testing"
idpdiscoveryv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1"
oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc"
"go.pinniped.dev/internal/certauthority"
"go.pinniped.dev/internal/here"
"go.pinniped.dev/internal/mocks/mockoidcclientoptions"
"go.pinniped.dev/internal/plog"
"go.pinniped.dev/internal/testutil"
"go.pinniped.dev/pkg/conciergeclient"
@@ -42,6 +46,13 @@ func TestLoginOIDCCommand(t *testing.T) {
require.NoError(t, err)
nowStr := now.Local().Format(time.RFC1123)
defaultWantedOptions := func(f *mockoidcclientoptions.MockOIDCClientOptions) {
f.EXPECT().WithContext(gomock.Any())
f.EXPECT().WithLogger(gomock.Any())
f.EXPECT().WithScopes([]string{oidcapi.ScopeOfflineAccess, oidcapi.ScopeOpenID, oidcapi.ScopeRequestAudience, oidcapi.ScopeUsername, oidcapi.ScopeGroups})
f.EXPECT().WithSessionCache(gomock.Any())
}
tests := []struct {
name string
args []string
@@ -51,6 +62,7 @@ func TestLoginOIDCCommand(t *testing.T) {
wantError bool
wantStdout string
wantStderr string
wantOptions func(f *mockoidcclientoptions.MockOIDCClientOptions)
wantOptionsCount int
wantLogs []string
}{
@@ -109,7 +121,8 @@ func TestLoginOIDCCommand(t *testing.T) {
"--issuer", "test-issuer",
"--enable-concierge",
},
wantError: true,
wantOptions: defaultWantedOptions,
wantError: true,
wantStderr: here.Doc(`
Error: invalid Concierge parameters: endpoint must not be empty
`),
@@ -121,7 +134,8 @@ func TestLoginOIDCCommand(t *testing.T) {
"--issuer", "test-issuer",
"--ca-bundle", "./does/not/exist",
},
wantError: true,
wantOptions: defaultWantedOptions,
wantError: true,
wantStderr: here.Doc(`
Error: could not read --ca-bundle: open ./does/not/exist: no such file or directory
`),
@@ -133,7 +147,8 @@ func TestLoginOIDCCommand(t *testing.T) {
"--issuer", "test-issuer",
"--ca-bundle-data", "invalid-base64",
},
wantError: true,
wantOptions: defaultWantedOptions,
wantError: true,
wantStderr: here.Doc(`
Error: could not read --ca-bundle-data: illegal base64 data at input byte 7
`),
@@ -148,34 +163,12 @@ func TestLoginOIDCCommand(t *testing.T) {
"--concierge-authenticator-name", "test-authenticator",
"--concierge-endpoint", "https://127.0.0.1:1234/",
},
wantError: true,
wantOptions: defaultWantedOptions,
wantError: true,
wantStderr: here.Doc(`
Error: invalid Concierge parameters: invalid API group suffix: a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g. 'example.com', regex used for validation is '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*')
`),
},
{
name: "invalid upstream type is an error",
args: []string{
"--issuer", "test-issuer",
"--upstream-identity-provider-type", "invalid",
},
wantError: true,
wantStderr: here.Doc(`
Error: --upstream-identity-provider-type value not recognized: invalid (supported values: oidc, ldap, activedirectory)
`),
},
{
name: "invalid upstream type when flow override env var is used is still an error",
args: []string{
"--issuer", "test-issuer",
"--upstream-identity-provider-type", "invalid",
},
env: map[string]string{"PINNIPED_UPSTREAM_IDENTITY_PROVIDER_FLOW": "browser_authcode"},
wantError: true,
wantStderr: here.Doc(`
Error: --upstream-identity-provider-type value not recognized: invalid (supported values: oidc, ldap, activedirectory)
`),
},
{
name: "oidc upstream type with default flow is allowed",
args: []string{
@@ -184,6 +177,7 @@ func TestLoginOIDCCommand(t *testing.T) {
"--upstream-identity-provider-type", "oidc",
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
},
wantOptions: defaultWantedOptions,
wantOptionsCount: 4,
wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{"interactive":false},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n",
},
@@ -195,269 +189,45 @@ func TestLoginOIDCCommand(t *testing.T) {
"--upstream-identity-provider-type", "oidc",
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
},
env: map[string]string{"PINNIPED_SKIP_PRINT_LOGIN_URL": "true"},
env: map[string]string{"PINNIPED_SKIP_PRINT_LOGIN_URL": "true"},
wantOptions: func(f *mockoidcclientoptions.MockOIDCClientOptions) {
defaultWantedOptions(f)
f.EXPECT().WithSkipPrintLoginURL()
},
wantOptionsCount: 5,
wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{"interactive":false},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n",
},
{
name: "oidc upstream type with CLI flow is allowed",
name: "--upstream-identity-provider-flow adds an option",
args: []string{
"--issuer", "test-issuer",
"--client-id", "test-client-id",
"--upstream-identity-provider-type", "oidc",
"--upstream-identity-provider-flow", "cli_password",
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
},
wantOptionsCount: 5,
wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{"interactive":false},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n",
},
{
name: "oidc upstream type with browser flow is allowed",
args: []string{
"--issuer", "test-issuer",
"--client-id", "test-client-id",
"--upstream-identity-provider-type", "oidc",
"--upstream-identity-provider-flow", "browser_authcode",
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
},
wantOptionsCount: 4,
wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{"interactive":false},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n",
},
{
name: "oidc upstream type with CLI flow in flow override env var is allowed",
args: []string{
"--issuer", "test-issuer",
"--client-id", "test-client-id",
"--upstream-identity-provider-type", "oidc",
"--upstream-identity-provider-flow", "browser_authcode",
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
},
env: map[string]string{"PINNIPED_UPSTREAM_IDENTITY_PROVIDER_FLOW": "cli_password"},
wantOptionsCount: 5,
wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{"interactive":false},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n",
},
{
name: "oidc upstream type with with browser flow in flow override env var is allowed",
args: []string{
"--issuer", "test-issuer",
"--client-id", "test-client-id",
"--upstream-identity-provider-type", "oidc",
"--upstream-identity-provider-flow", "cli_password",
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
},
env: map[string]string{"PINNIPED_UPSTREAM_IDENTITY_PROVIDER_FLOW": "browser_authcode"},
wantOptionsCount: 4,
wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{"interactive":false},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n",
},
{
name: "oidc upstream type with unsupported flow is an error",
args: []string{
"--issuer", "test-issuer",
"--client-id", "test-client-id",
"--upstream-identity-provider-type", "oidc",
"--upstream-identity-provider-flow", "foobar",
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
},
wantError: true,
wantStderr: here.Doc(`
Error: --upstream-identity-provider-flow value not recognized for identity provider type "oidc": foobar (supported values: browser_authcode, cli_password)
`),
},
{
name: "oidc upstream type with unsupported flow in flow override env var is an error",
args: []string{
"--issuer", "test-issuer",
"--client-id", "test-client-id",
"--upstream-identity-provider-type", "oidc",
"--upstream-identity-provider-flow", "browser_authcode",
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
},
env: map[string]string{"PINNIPED_UPSTREAM_IDENTITY_PROVIDER_FLOW": "foo"},
wantError: true,
wantStderr: here.Doc(`
Error: PINNIPED_UPSTREAM_IDENTITY_PROVIDER_FLOW value not recognized for identity provider type "oidc": foo (supported values: browser_authcode, cli_password)
`),
},
{
name: "ldap upstream type with default flow is allowed",
args: []string{
"--issuer", "test-issuer",
"--client-id", "test-client-id",
"--upstream-identity-provider-type", "ldap",
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
wantOptions: func(f *mockoidcclientoptions.MockOIDCClientOptions) {
defaultWantedOptions(f)
f.EXPECT().WithLoginFlow(idpdiscoveryv1alpha1.IDPFlowCLIPassword, "--upstream-identity-provider-flow")
},
wantOptionsCount: 5,
wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{"interactive":false},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n",
},
{
name: "activedirectory upstream type with default flow is allowed",
name: "PINNIPED_UPSTREAM_IDENTITY_PROVIDER_FLOW adds an option that overrides --upstream-identity-provider-flow",
args: []string{
"--issuer", "test-issuer",
"--client-id", "test-client-id",
"--upstream-identity-provider-type", "activedirectory",
"--upstream-identity-provider-flow", "ignored-value-from-param",
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
},
env: map[string]string{"PINNIPED_UPSTREAM_IDENTITY_PROVIDER_FLOW": "actual-value-from-env"},
wantOptions: func(f *mockoidcclientoptions.MockOIDCClientOptions) {
defaultWantedOptions(f)
f.EXPECT().WithLoginFlow(idpdiscoveryv1alpha1.IDPFlow("actual-value-from-env"), "PINNIPED_UPSTREAM_IDENTITY_PROVIDER_FLOW")
},
wantOptionsCount: 5,
wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{"interactive":false},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n",
},
{
name: "ldap upstream type with CLI flow is allowed",
args: []string{
"--issuer", "test-issuer",
"--client-id", "test-client-id",
"--upstream-identity-provider-type", "ldap",
"--upstream-identity-provider-flow", "cli_password",
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
},
wantOptionsCount: 5,
wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{"interactive":false},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n",
},
{
name: "ldap upstream type with browser_authcode flow is allowed",
args: []string{
"--issuer", "test-issuer",
"--client-id", "test-client-id",
"--upstream-identity-provider-type", "ldap",
"--upstream-identity-provider-flow", "browser_authcode",
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
},
wantOptionsCount: 4,
wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{"interactive":false},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n",
},
{
name: "ldap upstream type with CLI flow in flow override env var is allowed",
args: []string{
"--issuer", "test-issuer",
"--client-id", "test-client-id",
"--upstream-identity-provider-type", "ldap",
"--upstream-identity-provider-flow", "browser_authcode",
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
},
env: map[string]string{"PINNIPED_UPSTREAM_IDENTITY_PROVIDER_FLOW": "cli_password"},
wantOptionsCount: 5,
wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{"interactive":false},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n",
},
{
name: "ldap upstream type with browser_authcode flow in flow override env var is allowed",
args: []string{
"--issuer", "test-issuer",
"--client-id", "test-client-id",
"--upstream-identity-provider-type", "ldap",
"--upstream-identity-provider-flow", "cli_password",
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
},
env: map[string]string{"PINNIPED_UPSTREAM_IDENTITY_PROVIDER_FLOW": "browser_authcode"},
wantOptionsCount: 4,
wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{"interactive":false},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n",
},
{
name: "ldap upstream type with unsupported flow is an error",
args: []string{
"--issuer", "test-issuer",
"--client-id", "test-client-id",
"--upstream-identity-provider-type", "ldap",
"--upstream-identity-provider-flow", "foo",
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
},
wantError: true,
wantStderr: here.Doc(`
Error: --upstream-identity-provider-flow value not recognized for identity provider type "ldap": foo (supported values: cli_password, browser_authcode)
`),
},
{
name: "ldap upstream type with unsupported flow in flow override env var is an error",
args: []string{
"--issuer", "test-issuer",
"--client-id", "test-client-id",
"--upstream-identity-provider-type", "ldap",
"--upstream-identity-provider-flow", "browser_authcode",
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
},
env: map[string]string{"PINNIPED_UPSTREAM_IDENTITY_PROVIDER_FLOW": "foo"},
wantError: true,
wantStderr: here.Doc(`
Error: PINNIPED_UPSTREAM_IDENTITY_PROVIDER_FLOW value not recognized for identity provider type "ldap": foo (supported values: cli_password, browser_authcode)
`),
},
{
name: "active directory upstream type with CLI flow is allowed",
args: []string{
"--issuer", "test-issuer",
"--client-id", "test-client-id",
"--upstream-identity-provider-type", "activedirectory",
"--upstream-identity-provider-flow", "cli_password",
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
},
wantOptionsCount: 5,
wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{"interactive":false},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n",
},
{
name: "active directory upstream type with browser_authcode is allowed",
args: []string{
"--issuer", "test-issuer",
"--client-id", "test-client-id",
"--upstream-identity-provider-type", "activedirectory",
"--upstream-identity-provider-flow", "browser_authcode",
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
},
wantOptionsCount: 4,
wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{"interactive":false},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n",
},
{
name: "active directory upstream type with CLI flow in flow override env var is allowed",
args: []string{
"--issuer", "test-issuer",
"--client-id", "test-client-id",
"--upstream-identity-provider-type", "activedirectory",
"--upstream-identity-provider-flow", "browser_authcode",
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
},
env: map[string]string{"PINNIPED_UPSTREAM_IDENTITY_PROVIDER_FLOW": "cli_password"},
wantOptionsCount: 5,
wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{"interactive":false},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n",
},
{
name: "active directory upstream type with browser_authcode in flow override env var is allowed",
args: []string{
"--issuer", "test-issuer",
"--client-id", "test-client-id",
"--upstream-identity-provider-type", "activedirectory",
"--upstream-identity-provider-flow", "cli_password",
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
},
env: map[string]string{"PINNIPED_UPSTREAM_IDENTITY_PROVIDER_FLOW": "browser_authcode"},
wantOptionsCount: 4,
wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{"interactive":false},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n",
},
{
name: "active directory upstream type with unsupported flow is an error",
args: []string{
"--issuer", "test-issuer",
"--client-id", "test-client-id",
"--upstream-identity-provider-type", "activedirectory",
"--upstream-identity-provider-flow", "foo",
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
},
wantError: true,
wantStderr: here.Doc(`
Error: --upstream-identity-provider-flow value not recognized for identity provider type "activedirectory": foo (supported values: cli_password, browser_authcode)
`),
},
{
name: "active directory upstream type with unsupported flow in flow override env var is an error",
args: []string{
"--issuer", "test-issuer",
"--client-id", "test-client-id",
"--upstream-identity-provider-type", "activedirectory",
"--upstream-identity-provider-flow", "browser_authcode",
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
},
env: map[string]string{"PINNIPED_UPSTREAM_IDENTITY_PROVIDER_FLOW": "foo"},
wantError: true,
wantStderr: here.Doc(`
Error: PINNIPED_UPSTREAM_IDENTITY_PROVIDER_FLOW value not recognized for identity provider type "activedirectory": foo (supported values: cli_password, browser_authcode)
`),
},
{
name: "login error",
args: []string{
@@ -466,6 +236,7 @@ func TestLoginOIDCCommand(t *testing.T) {
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
},
loginErr: fmt.Errorf("some login error"),
wantOptions: defaultWantedOptions,
wantOptionsCount: 4,
wantError: true,
wantStderr: here.Doc(`
@@ -484,6 +255,7 @@ func TestLoginOIDCCommand(t *testing.T) {
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
},
conciergeErr: fmt.Errorf("some concierge error"),
wantOptions: defaultWantedOptions,
wantOptionsCount: 4,
wantError: true,
wantStderr: here.Doc(`
@@ -498,11 +270,12 @@ func TestLoginOIDCCommand(t *testing.T) {
"--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution
},
env: map[string]string{"PINNIPED_DEBUG": "true"},
wantOptions: defaultWantedOptions,
wantOptionsCount: 4,
wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{"interactive":false},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n",
wantLogs: []string{
nowStr + ` pinniped-login cmd/login_oidc.go:260 Performing OIDC login {"issuer": "test-issuer", "client id": "test-client-id"}`,
nowStr + ` pinniped-login cmd/login_oidc.go:280 No concierge configured, skipping token credential exchange`,
nowStr + ` pinniped-login cmd/login_oidc.go:259 Performing OIDC login {"issuer": "test-issuer", "client id": "test-client-id"}`,
nowStr + ` pinniped-login cmd/login_oidc.go:279 No concierge configured, skipping token credential exchange`,
},
},
{
@@ -526,15 +299,30 @@ func TestLoginOIDCCommand(t *testing.T) {
"--credential-cache", t.TempDir() + "/credentials.yaml", // must specify --credential-cache or else the cache file on disk causes test pollution
"--upstream-identity-provider-name", "some-upstream-name",
"--upstream-identity-provider-type", "ldap",
"--upstream-identity-provider-flow", "some-flow-type",
},
env: map[string]string{"PINNIPED_DEBUG": "true", "PINNIPED_SKIP_PRINT_LOGIN_URL": "true"},
wantOptions: func(f *mockoidcclientoptions.MockOIDCClientOptions) {
f.EXPECT().WithContext(gomock.Any())
f.EXPECT().WithLogger(gomock.Any())
f.EXPECT().WithScopes([]string{oidcapi.ScopeOfflineAccess, oidcapi.ScopeOpenID, oidcapi.ScopeRequestAudience, oidcapi.ScopeUsername, oidcapi.ScopeGroups})
f.EXPECT().WithSessionCache(gomock.Any())
f.EXPECT().WithListenPort(uint16(1234))
f.EXPECT().WithSkipBrowserOpen()
f.EXPECT().WithSkipListen()
f.EXPECT().WithSkipPrintLoginURL()
f.EXPECT().WithClient(gomock.Any())
f.EXPECT().WithRequestAudience("cluster-1234")
f.EXPECT().WithLoginFlow(idpdiscoveryv1alpha1.IDPFlow("some-flow-type"), "--upstream-identity-provider-flow")
f.EXPECT().WithUpstreamIdentityProvider("some-upstream-name", "ldap")
},
env: map[string]string{"PINNIPED_DEBUG": "true", "PINNIPED_SKIP_PRINT_LOGIN_URL": "true"},
wantOptionsCount: 12,
wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{"interactive":false},"status":{"token":"exchanged-token"}}` + "\n",
wantLogs: []string{
nowStr + ` pinniped-login cmd/login_oidc.go:260 Performing OIDC login {"issuer": "test-issuer", "client id": "test-client-id"}`,
nowStr + ` pinniped-login cmd/login_oidc.go:270 Exchanging token for cluster credential {"endpoint": "https://127.0.0.1:1234/", "authenticator type": "webhook", "authenticator name": "test-authenticator"}`,
nowStr + ` pinniped-login cmd/login_oidc.go:278 Successfully exchanged token for cluster credential.`,
nowStr + ` pinniped-login cmd/login_oidc.go:285 caching cluster credential for future use.`,
nowStr + ` pinniped-login cmd/login_oidc.go:259 Performing OIDC login {"issuer": "test-issuer", "client id": "test-client-id"}`,
nowStr + ` pinniped-login cmd/login_oidc.go:269 Exchanging token for cluster credential {"endpoint": "https://127.0.0.1:1234/", "authenticator type": "webhook", "authenticator name": "test-authenticator"}`,
nowStr + ` pinniped-login cmd/login_oidc.go:277 Successfully exchanged token for cluster credential.`,
nowStr + ` pinniped-login cmd/login_oidc.go:284 caching cluster credential for future use.`,
},
},
}
@@ -543,6 +331,13 @@ func TestLoginOIDCCommand(t *testing.T) {
var buf bytes.Buffer
ctx := plog.AddZapOverridesToContext(context.Background(), t, &buf, nil, clocktesting.NewFakeClock(now))
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
optionsFactory := mockoidcclientoptions.NewMockOIDCClientOptions(ctrl)
if tt.wantOptions != nil {
tt.wantOptions(optionsFactory)
}
var gotOptions []oidcclient.Option
cmd := oidcLoginCommand(oidcLoginCommandDeps{
lookupEnv: func(s string) (string, bool) {
@@ -578,6 +373,7 @@ func TestLoginOIDCCommand(t *testing.T) {
},
}, nil
},
optionsFactory: optionsFactory,
})
require.NotNil(t, cmd)

View File

@@ -0,0 +1,85 @@
// Copyright 2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package cmd
import (
"context"
"net/http"
"github.com/go-logr/logr"
"go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1"
"go.pinniped.dev/pkg/oidcclient"
)
// OIDCClientOptions is an interface that wraps the creation of Options for the purpose of making them
// more friendly to unit tests. Because the Option type refers to a private struct type, it is hard
// to create mocks for them in tests of other packages. This provides a seam that can be mocked.
type OIDCClientOptions interface {
WithContext(ctx context.Context) oidcclient.Option
WithLogger(logger logr.Logger) oidcclient.Option
WithListenPort(port uint16) oidcclient.Option
WithSkipBrowserOpen() oidcclient.Option
WithSkipListen() oidcclient.Option
WithSkipPrintLoginURL() oidcclient.Option
WithSessionCache(cache oidcclient.SessionCache) oidcclient.Option
WithClient(httpClient *http.Client) oidcclient.Option
WithScopes(scopes []string) oidcclient.Option
WithRequestAudience(audience string) oidcclient.Option
WithLoginFlow(loginFlow v1alpha1.IDPFlow, flowSource string) oidcclient.Option
WithUpstreamIdentityProvider(upstreamName, upstreamType string) oidcclient.Option
}
// clientOptions implements OIDCClientOptions for production use.
type clientOptions struct{}
var _ OIDCClientOptions = (*clientOptions)(nil)
func (o *clientOptions) WithContext(ctx context.Context) oidcclient.Option {
return oidcclient.WithContext(ctx)
}
func (o *clientOptions) WithLogger(logger logr.Logger) oidcclient.Option {
return oidcclient.WithLogger(logger)
}
func (o *clientOptions) WithListenPort(port uint16) oidcclient.Option {
return oidcclient.WithListenPort(port)
}
func (o *clientOptions) WithSkipBrowserOpen() oidcclient.Option {
return oidcclient.WithSkipBrowserOpen()
}
func (o *clientOptions) WithSkipListen() oidcclient.Option {
return oidcclient.WithSkipListen()
}
func (o *clientOptions) WithSkipPrintLoginURL() oidcclient.Option {
return oidcclient.WithSkipPrintLoginURL()
}
func (o *clientOptions) WithSessionCache(cache oidcclient.SessionCache) oidcclient.Option {
return oidcclient.WithSessionCache(cache)
}
func (o *clientOptions) WithClient(httpClient *http.Client) oidcclient.Option {
return oidcclient.WithClient(httpClient)
}
func (o *clientOptions) WithScopes(scopes []string) oidcclient.Option {
return oidcclient.WithScopes(scopes)
}
func (o *clientOptions) WithRequestAudience(audience string) oidcclient.Option {
return oidcclient.WithRequestAudience(audience)
}
func (o *clientOptions) WithLoginFlow(loginFlow v1alpha1.IDPFlow, flowSource string) oidcclient.Option {
return oidcclient.WithLoginFlow(loginFlow, flowSource)
}
func (o *clientOptions) WithUpstreamIdentityProvider(upstreamName, upstreamType string) oidcclient.Option {
return oidcclient.WithUpstreamIdentityProvider(upstreamName, upstreamType)
}

View File

@@ -1,4 +1,4 @@
// Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
// Copyright 2021-2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package v1alpha1
@@ -54,7 +54,8 @@ type OIDCDiscoveryResponseIDPEndpoint struct {
// IDPDiscoveryResponse is the response of a FederationDomain's identity provider discovery endpoint.
type IDPDiscoveryResponse struct {
PinnipedIDPs []PinnipedIDP `json:"pinniped_identity_providers"`
PinnipedIDPs []PinnipedIDP `json:"pinniped_identity_providers"`
PinnipedSupportedIDPTypes []PinnipedSupportedIDPType `json:"pinniped_supported_identity_provider_types"`
}
// PinnipedIDP describes a single identity provider as included in the response of a FederationDomain's
@@ -64,3 +65,8 @@ type PinnipedIDP struct {
Type IDPType `json:"type"`
Flows []IDPFlow `json:"flows,omitempty"`
}
// PinnipedSupportedIDPType describes a single identity provider type.
type PinnipedSupportedIDPType struct {
Type IDPType `json:"type"`
}

View File

@@ -1,4 +1,4 @@
// Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
// Copyright 2021-2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package v1alpha1
@@ -54,7 +54,8 @@ type OIDCDiscoveryResponseIDPEndpoint struct {
// IDPDiscoveryResponse is the response of a FederationDomain's identity provider discovery endpoint.
type IDPDiscoveryResponse struct {
PinnipedIDPs []PinnipedIDP `json:"pinniped_identity_providers"`
PinnipedIDPs []PinnipedIDP `json:"pinniped_identity_providers"`
PinnipedSupportedIDPTypes []PinnipedSupportedIDPType `json:"pinniped_supported_identity_provider_types"`
}
// PinnipedIDP describes a single identity provider as included in the response of a FederationDomain's
@@ -64,3 +65,8 @@ type PinnipedIDP struct {
Type IDPType `json:"type"`
Flows []IDPFlow `json:"flows,omitempty"`
}
// PinnipedSupportedIDPType describes a single identity provider type.
type PinnipedSupportedIDPType struct {
Type IDPType `json:"type"`
}

View File

@@ -1,4 +1,4 @@
// Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
// Copyright 2021-2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package v1alpha1
@@ -54,7 +54,8 @@ type OIDCDiscoveryResponseIDPEndpoint struct {
// IDPDiscoveryResponse is the response of a FederationDomain's identity provider discovery endpoint.
type IDPDiscoveryResponse struct {
PinnipedIDPs []PinnipedIDP `json:"pinniped_identity_providers"`
PinnipedIDPs []PinnipedIDP `json:"pinniped_identity_providers"`
PinnipedSupportedIDPTypes []PinnipedSupportedIDPType `json:"pinniped_supported_identity_provider_types"`
}
// PinnipedIDP describes a single identity provider as included in the response of a FederationDomain's
@@ -64,3 +65,8 @@ type PinnipedIDP struct {
Type IDPType `json:"type"`
Flows []IDPFlow `json:"flows,omitempty"`
}
// PinnipedSupportedIDPType describes a single identity provider type.
type PinnipedSupportedIDPType struct {
Type IDPType `json:"type"`
}

View File

@@ -1,4 +1,4 @@
// Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
// Copyright 2021-2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package v1alpha1
@@ -54,7 +54,8 @@ type OIDCDiscoveryResponseIDPEndpoint struct {
// IDPDiscoveryResponse is the response of a FederationDomain's identity provider discovery endpoint.
type IDPDiscoveryResponse struct {
PinnipedIDPs []PinnipedIDP `json:"pinniped_identity_providers"`
PinnipedIDPs []PinnipedIDP `json:"pinniped_identity_providers"`
PinnipedSupportedIDPTypes []PinnipedSupportedIDPType `json:"pinniped_supported_identity_provider_types"`
}
// PinnipedIDP describes a single identity provider as included in the response of a FederationDomain's
@@ -64,3 +65,8 @@ type PinnipedIDP struct {
Type IDPType `json:"type"`
Flows []IDPFlow `json:"flows,omitempty"`
}
// PinnipedSupportedIDPType describes a single identity provider type.
type PinnipedSupportedIDPType struct {
Type IDPType `json:"type"`
}

View File

@@ -1,4 +1,4 @@
// Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
// Copyright 2021-2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package v1alpha1
@@ -54,7 +54,8 @@ type OIDCDiscoveryResponseIDPEndpoint struct {
// IDPDiscoveryResponse is the response of a FederationDomain's identity provider discovery endpoint.
type IDPDiscoveryResponse struct {
PinnipedIDPs []PinnipedIDP `json:"pinniped_identity_providers"`
PinnipedIDPs []PinnipedIDP `json:"pinniped_identity_providers"`
PinnipedSupportedIDPTypes []PinnipedSupportedIDPType `json:"pinniped_supported_identity_provider_types"`
}
// PinnipedIDP describes a single identity provider as included in the response of a FederationDomain's
@@ -64,3 +65,8 @@ type PinnipedIDP struct {
Type IDPType `json:"type"`
Flows []IDPFlow `json:"flows,omitempty"`
}
// PinnipedSupportedIDPType describes a single identity provider type.
type PinnipedSupportedIDPType struct {
Type IDPType `json:"type"`
}

View File

@@ -1,4 +1,4 @@
// Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
// Copyright 2021-2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package v1alpha1
@@ -54,7 +54,8 @@ type OIDCDiscoveryResponseIDPEndpoint struct {
// IDPDiscoveryResponse is the response of a FederationDomain's identity provider discovery endpoint.
type IDPDiscoveryResponse struct {
PinnipedIDPs []PinnipedIDP `json:"pinniped_identity_providers"`
PinnipedIDPs []PinnipedIDP `json:"pinniped_identity_providers"`
PinnipedSupportedIDPTypes []PinnipedSupportedIDPType `json:"pinniped_supported_identity_provider_types"`
}
// PinnipedIDP describes a single identity provider as included in the response of a FederationDomain's
@@ -64,3 +65,8 @@ type PinnipedIDP struct {
Type IDPType `json:"type"`
Flows []IDPFlow `json:"flows,omitempty"`
}
// PinnipedSupportedIDPType describes a single identity provider type.
type PinnipedSupportedIDPType struct {
Type IDPType `json:"type"`
}

View File

@@ -1,4 +1,4 @@
// Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
// Copyright 2021-2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package v1alpha1
@@ -54,7 +54,8 @@ type OIDCDiscoveryResponseIDPEndpoint struct {
// IDPDiscoveryResponse is the response of a FederationDomain's identity provider discovery endpoint.
type IDPDiscoveryResponse struct {
PinnipedIDPs []PinnipedIDP `json:"pinniped_identity_providers"`
PinnipedIDPs []PinnipedIDP `json:"pinniped_identity_providers"`
PinnipedSupportedIDPTypes []PinnipedSupportedIDPType `json:"pinniped_supported_identity_provider_types"`
}
// PinnipedIDP describes a single identity provider as included in the response of a FederationDomain's
@@ -64,3 +65,8 @@ type PinnipedIDP struct {
Type IDPType `json:"type"`
Flows []IDPFlow `json:"flows,omitempty"`
}
// PinnipedSupportedIDPType describes a single identity provider type.
type PinnipedSupportedIDPType struct {
Type IDPType `json:"type"`
}

View File

@@ -1,4 +1,4 @@
// Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
// Copyright 2021-2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package v1alpha1
@@ -54,7 +54,8 @@ type OIDCDiscoveryResponseIDPEndpoint struct {
// IDPDiscoveryResponse is the response of a FederationDomain's identity provider discovery endpoint.
type IDPDiscoveryResponse struct {
PinnipedIDPs []PinnipedIDP `json:"pinniped_identity_providers"`
PinnipedIDPs []PinnipedIDP `json:"pinniped_identity_providers"`
PinnipedSupportedIDPTypes []PinnipedSupportedIDPType `json:"pinniped_supported_identity_provider_types"`
}
// PinnipedIDP describes a single identity provider as included in the response of a FederationDomain's
@@ -64,3 +65,8 @@ type PinnipedIDP struct {
Type IDPType `json:"type"`
Flows []IDPFlow `json:"flows,omitempty"`
}
// PinnipedSupportedIDPType describes a single identity provider type.
type PinnipedSupportedIDPType struct {
Type IDPType `json:"type"`
}

View File

@@ -4,6 +4,11 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: go.pinniped.dev/internal/dynamiccert (interfaces: Private)
//
// Generated by this command:
//
// mockgen -destination=mockdynamiccert.go -package=mocks -copyright_file=../../../../hack/header.txt -mock_names Private=MockDynamicCertPrivate go.pinniped.dev/internal/dynamiccert Private
//
// Package mocks is a generated GoMock package.
package mocks
@@ -46,7 +51,7 @@ func (m *MockDynamicCertPrivate) AddListener(arg0 dynamiccertificates.Listener)
}
// AddListener indicates an expected call of AddListener.
func (mr *MockDynamicCertPrivateMockRecorder) AddListener(arg0 interface{}) *gomock.Call {
func (mr *MockDynamicCertPrivateMockRecorder) AddListener(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddListener", reflect.TypeOf((*MockDynamicCertPrivate)(nil).AddListener), arg0)
}
@@ -87,7 +92,7 @@ func (m *MockDynamicCertPrivate) Run(arg0 context.Context, arg1 int) {
}
// Run indicates an expected call of Run.
func (mr *MockDynamicCertPrivateMockRecorder) Run(arg0, arg1 interface{}) *gomock.Call {
func (mr *MockDynamicCertPrivateMockRecorder) Run(arg0, arg1 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Run", reflect.TypeOf((*MockDynamicCertPrivate)(nil).Run), arg0, arg1)
}
@@ -101,7 +106,7 @@ func (m *MockDynamicCertPrivate) RunOnce(arg0 context.Context) error {
}
// RunOnce indicates an expected call of RunOnce.
func (mr *MockDynamicCertPrivateMockRecorder) RunOnce(arg0 interface{}) *gomock.Call {
func (mr *MockDynamicCertPrivateMockRecorder) RunOnce(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RunOnce", reflect.TypeOf((*MockDynamicCertPrivate)(nil).RunOnce), arg0)
}
@@ -115,7 +120,7 @@ func (m *MockDynamicCertPrivate) SetCertKeyContent(arg0, arg1 []byte) error {
}
// SetCertKeyContent indicates an expected call of SetCertKeyContent.
func (mr *MockDynamicCertPrivateMockRecorder) SetCertKeyContent(arg0, arg1 interface{}) *gomock.Call {
func (mr *MockDynamicCertPrivateMockRecorder) SetCertKeyContent(arg0, arg1 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetCertKeyContent", reflect.TypeOf((*MockDynamicCertPrivate)(nil).SetCertKeyContent), arg0, arg1)
}

View File

@@ -4,6 +4,11 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: go.pinniped.dev/internal/controller/kubecertagent (interfaces: PodCommandExecutor)
//
// Generated by this command:
//
// mockgen -destination=mockpodcommandexecutor.go -package=mocks -copyright_file=../../../../hack/header.txt go.pinniped.dev/internal/controller/kubecertagent PodCommandExecutor
//
// Package mocks is a generated GoMock package.
package mocks
@@ -41,7 +46,7 @@ func (m *MockPodCommandExecutor) EXPECT() *MockPodCommandExecutorMockRecorder {
// Exec mocks base method.
func (m *MockPodCommandExecutor) Exec(arg0 context.Context, arg1, arg2, arg3 string, arg4 ...string) (string, error) {
m.ctrl.T.Helper()
varargs := []interface{}{arg0, arg1, arg2, arg3}
varargs := []any{arg0, arg1, arg2, arg3}
for _, a := range arg4 {
varargs = append(varargs, a)
}
@@ -52,8 +57,8 @@ func (m *MockPodCommandExecutor) Exec(arg0 context.Context, arg1, arg2, arg3 str
}
// Exec indicates an expected call of Exec.
func (mr *MockPodCommandExecutorMockRecorder) Exec(arg0, arg1, arg2, arg3 interface{}, arg4 ...interface{}) *gomock.Call {
func (mr *MockPodCommandExecutorMockRecorder) Exec(arg0, arg1, arg2, arg3 any, arg4 ...any) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]interface{}{arg0, arg1, arg2, arg3}, arg4...)
varargs := append([]any{arg0, arg1, arg2, arg3}, arg4...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Exec", reflect.TypeOf((*MockPodCommandExecutor)(nil).Exec), varargs...)
}

View File

@@ -37,15 +37,23 @@ func NewHandler(upstreamIDPs federationdomainproviders.FederationDomainIdentityP
}
func responseAsJSON(upstreamIDPs federationdomainproviders.FederationDomainIdentityProvidersListerI) ([]byte, error) {
r := v1alpha1.IDPDiscoveryResponse{PinnipedIDPs: []v1alpha1.PinnipedIDP{}}
r := v1alpha1.IDPDiscoveryResponse{
PinnipedSupportedIDPTypes: []v1alpha1.PinnipedSupportedIDPType{
{Type: v1alpha1.IDPTypeActiveDirectory},
{Type: v1alpha1.IDPTypeLDAP},
{Type: v1alpha1.IDPTypeOIDC},
},
}
upstreams := upstreamIDPs.GetIdentityProviders()
r.PinnipedIDPs = make([]v1alpha1.PinnipedIDP, len(upstreams))
// The cache of IDPs could change at any time, so always recalculate the list.
for _, federationDomainIdentityProvider := range upstreamIDPs.GetIdentityProviders() {
r.PinnipedIDPs = append(r.PinnipedIDPs, v1alpha1.PinnipedIDP{
for i, federationDomainIdentityProvider := range upstreams {
r.PinnipedIDPs[i] = v1alpha1.PinnipedIDP{
Name: federationDomainIdentityProvider.GetDisplayName(),
Type: federationDomainIdentityProvider.GetIDPDiscoveryType(),
Flows: federationDomainIdentityProvider.GetIDPDiscoveryFlows(),
})
}
}
// Nobody like an API that changes the results unnecessarily. :)

View File

@@ -20,8 +20,9 @@ func TestIDPDiscovery(t *testing.T) {
tests := []struct {
name string
method string
path string
method string
path string
idpLister *testidplister.TestFederationDomainIdentityProvidersListerFinder
wantStatus int
wantContentType string
@@ -30,9 +31,19 @@ func TestIDPDiscovery(t *testing.T) {
wantBodyString string
}{
{
name: "happy path",
method: http.MethodGet,
path: "/some/path" + oidc.WellKnownEndpointPath,
name: "happy path",
method: http.MethodGet,
path: "/some/path" + oidc.WellKnownEndpointPath,
idpLister: testidplister.NewUpstreamIDPListerBuilder().
WithOIDC(oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder().WithName("z-some-oidc-idp").WithAllowPasswordGrant(true).Build()).
WithOIDC(oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder().WithName("x-some-oidc-idp").Build()).
WithLDAP(oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().WithName("a-some-ldap-idp").Build()).
WithOIDC(oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder().WithName("a-some-oidc-idp").Build()).
WithLDAP(oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().WithName("z-some-ldap-idp").Build()).
WithLDAP(oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().WithName("x-some-ldap-idp").Build()).
WithActiveDirectory(oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().WithName("z-some-ad-idp").Build()).
WithActiveDirectory(oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().WithName("y-some-ad-idp").Build()).
BuildFederationDomainIdentityProvidersListerFinder(),
wantStatus: http.StatusOK,
wantContentType: "application/json",
wantFirstResponseBodyJSON: here.Doc(`{
@@ -45,6 +56,11 @@ func TestIDPDiscovery(t *testing.T) {
{"name": "z-some-ad-idp", "type": "activedirectory", "flows": ["cli_password", "browser_authcode"]},
{"name": "z-some-ldap-idp", "type": "ldap", "flows": ["cli_password", "browser_authcode"]},
{"name": "z-some-oidc-idp", "type": "oidc", "flows": ["browser_authcode", "cli_password"]}
],
"pinniped_supported_identity_provider_types": [
{"type": "activedirectory"},
{"type": "ldap"},
{"type": "oidc"}
]
}`),
wantSecondResponseBodyJSON: here.Doc(`{
@@ -55,13 +71,53 @@ func TestIDPDiscovery(t *testing.T) {
{"name": "some-other-ldap-idp-2", "type": "ldap", "flows": ["cli_password", "browser_authcode"]},
{"name": "some-other-oidc-idp-1", "type": "oidc", "flows": ["browser_authcode", "cli_password"]},
{"name": "some-other-oidc-idp-2", "type": "oidc", "flows": ["browser_authcode"]}
],
"pinniped_supported_identity_provider_types": [
{"type": "activedirectory"},
{"type": "ldap"},
{"type": "oidc"}
]
}`),
},
{
name: "bad method",
method: http.MethodPost,
path: oidc.WellKnownEndpointPath,
name: "no starting IDPs still returns supported IDP types",
method: http.MethodGet,
path: "/some/path" + oidc.WellKnownEndpointPath,
idpLister: testidplister.NewUpstreamIDPListerBuilder().
BuildFederationDomainIdentityProvidersListerFinder(),
wantStatus: http.StatusOK,
wantContentType: "application/json",
wantFirstResponseBodyJSON: here.Doc(`{
"pinniped_identity_providers": [],
"pinniped_supported_identity_provider_types": [
{"type": "activedirectory"},
{"type": "ldap"},
{"type": "oidc"}
]
}`),
wantSecondResponseBodyJSON: here.Doc(`{
"pinniped_identity_providers": [
{"name": "some-other-ad-idp-1", "type": "activedirectory", "flows": ["cli_password", "browser_authcode"]},
{"name": "some-other-ad-idp-2", "type": "activedirectory", "flows": ["cli_password", "browser_authcode"]},
{"name": "some-other-ldap-idp-1", "type": "ldap", "flows": ["cli_password", "browser_authcode"]},
{"name": "some-other-ldap-idp-2", "type": "ldap", "flows": ["cli_password", "browser_authcode"]},
{"name": "some-other-oidc-idp-1", "type": "oidc", "flows": ["browser_authcode", "cli_password"]},
{"name": "some-other-oidc-idp-2", "type": "oidc", "flows": ["browser_authcode"]}
],
"pinniped_supported_identity_provider_types": [
{"type": "activedirectory"},
{"type": "ldap"},
{"type": "oidc"}
]
}`),
},
{
name: "bad method",
method: http.MethodPost,
path: oidc.WellKnownEndpointPath,
idpLister: testidplister.NewUpstreamIDPListerBuilder().
WithOIDC(oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder().WithName("some-oidc-idp").Build()).
BuildFederationDomainIdentityProvidersListerFinder(),
wantStatus: http.StatusMethodNotAllowed,
wantContentType: "text/plain; charset=utf-8",
wantBodyString: "Method not allowed (try GET)\n",
@@ -69,18 +125,8 @@ func TestIDPDiscovery(t *testing.T) {
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
idpLister := testidplister.NewUpstreamIDPListerBuilder().
WithOIDC(oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder().WithName("z-some-oidc-idp").WithAllowPasswordGrant(true).Build()).
WithOIDC(oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder().WithName("x-some-oidc-idp").Build()).
WithLDAP(oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().WithName("a-some-ldap-idp").Build()).
WithOIDC(oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder().WithName("a-some-oidc-idp").Build()).
WithLDAP(oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().WithName("z-some-ldap-idp").Build()).
WithLDAP(oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().WithName("x-some-ldap-idp").Build()).
WithActiveDirectory(oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().WithName("z-some-ad-idp").Build()).
WithActiveDirectory(oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().WithName("y-some-ad-idp").Build()).
BuildFederationDomainIdentityProvidersListerFinder()
handler := NewHandler(idpLister)
require.NotNil(t, test.idpLister)
handler := NewHandler(test.idpLister)
req := httptest.NewRequest(test.method, test.path, nil)
rsp := httptest.NewRecorder()
handler.ServeHTTP(rsp, req)
@@ -98,15 +144,15 @@ func TestIDPDiscovery(t *testing.T) {
}
// Change the list of IDPs in the cache.
idpLister.SetLDAPIdentityProviders([]*oidctestutil.TestUpstreamLDAPIdentityProvider{
test.idpLister.SetLDAPIdentityProviders([]*oidctestutil.TestUpstreamLDAPIdentityProvider{
oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().WithName("some-other-ldap-idp-1").Build(),
oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().WithName("some-other-ldap-idp-2").Build(),
})
idpLister.SetOIDCIdentityProviders([]*oidctestutil.TestUpstreamOIDCIdentityProvider{
test.idpLister.SetOIDCIdentityProviders([]*oidctestutil.TestUpstreamOIDCIdentityProvider{
oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder().WithName("some-other-oidc-idp-1").WithAllowPasswordGrant(true).Build(),
oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder().WithName("some-other-oidc-idp-2").Build(),
})
idpLister.SetActiveDirectoryIdentityProviders([]*oidctestutil.TestUpstreamLDAPIdentityProvider{
test.idpLister.SetActiveDirectoryIdentityProviders([]*oidctestutil.TestUpstreamLDAPIdentityProvider{
oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().WithName("some-other-ad-idp-2").Build(),
oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().WithName("some-other-ad-idp-1").Build(),
})

View File

@@ -118,8 +118,18 @@ func TestManager(t *testing.T) {
r.Equal(http.StatusOK, recorder.Code, "unexpected response:", recorder)
responseBody, err := io.ReadAll(recorder.Body)
r.NoError(err)
r.Equal(
fmt.Sprintf(`{"pinniped_identity_providers":[%s]}`+"\n", strings.Join(expectedIDPJSONList, ",")),
expectedResponse := here.Docf(`{
"pinniped_identity_providers": [%s],
"pinniped_supported_identity_provider_types": [
{"type":"activedirectory"},
{"type":"ldap"},
{"type":"oidc"}
]
}`, strings.Join(expectedIDPJSONList, ","))
r.JSONEq(
expectedResponse,
string(responseBody),
)
}

View File

@@ -0,0 +1,6 @@
// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package mockoidcclientoptions
//go:generate go run -v go.uber.org/mock/mockgen -destination=mockoidcclientoptions.go -package=mockoidcclientoptions -copyright_file=../../../hack/header.txt go.pinniped.dev/cmd/pinniped/cmd OIDCClientOptions

View File

@@ -0,0 +1,216 @@
// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
//
// Code generated by MockGen. DO NOT EDIT.
// Source: go.pinniped.dev/cmd/pinniped/cmd (interfaces: OIDCClientOptions)
//
// Generated by this command:
//
// mockgen -destination=mockoidcclientoptions.go -package=mockoidcclientoptions -copyright_file=../../../hack/header.txt go.pinniped.dev/cmd/pinniped/cmd OIDCClientOptions
//
// Package mockoidcclientoptions is a generated GoMock package.
package mockoidcclientoptions
import (
context "context"
http "net/http"
reflect "reflect"
logr "github.com/go-logr/logr"
v1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1"
oidcclient "go.pinniped.dev/pkg/oidcclient"
gomock "go.uber.org/mock/gomock"
)
// MockOIDCClientOptions is a mock of OIDCClientOptions interface.
type MockOIDCClientOptions struct {
ctrl *gomock.Controller
recorder *MockOIDCClientOptionsMockRecorder
}
// MockOIDCClientOptionsMockRecorder is the mock recorder for MockOIDCClientOptions.
type MockOIDCClientOptionsMockRecorder struct {
mock *MockOIDCClientOptions
}
// NewMockOIDCClientOptions creates a new mock instance.
func NewMockOIDCClientOptions(ctrl *gomock.Controller) *MockOIDCClientOptions {
mock := &MockOIDCClientOptions{ctrl: ctrl}
mock.recorder = &MockOIDCClientOptionsMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockOIDCClientOptions) EXPECT() *MockOIDCClientOptionsMockRecorder {
return m.recorder
}
// WithClient mocks base method.
func (m *MockOIDCClientOptions) WithClient(arg0 *http.Client) oidcclient.Option {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "WithClient", arg0)
ret0, _ := ret[0].(oidcclient.Option)
return ret0
}
// WithClient indicates an expected call of WithClient.
func (mr *MockOIDCClientOptionsMockRecorder) WithClient(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WithClient", reflect.TypeOf((*MockOIDCClientOptions)(nil).WithClient), arg0)
}
// WithContext mocks base method.
func (m *MockOIDCClientOptions) WithContext(arg0 context.Context) oidcclient.Option {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "WithContext", arg0)
ret0, _ := ret[0].(oidcclient.Option)
return ret0
}
// WithContext indicates an expected call of WithContext.
func (mr *MockOIDCClientOptionsMockRecorder) WithContext(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WithContext", reflect.TypeOf((*MockOIDCClientOptions)(nil).WithContext), arg0)
}
// WithListenPort mocks base method.
func (m *MockOIDCClientOptions) WithListenPort(arg0 uint16) oidcclient.Option {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "WithListenPort", arg0)
ret0, _ := ret[0].(oidcclient.Option)
return ret0
}
// WithListenPort indicates an expected call of WithListenPort.
func (mr *MockOIDCClientOptionsMockRecorder) WithListenPort(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WithListenPort", reflect.TypeOf((*MockOIDCClientOptions)(nil).WithListenPort), arg0)
}
// WithLogger mocks base method.
func (m *MockOIDCClientOptions) WithLogger(arg0 logr.Logger) oidcclient.Option {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "WithLogger", arg0)
ret0, _ := ret[0].(oidcclient.Option)
return ret0
}
// WithLogger indicates an expected call of WithLogger.
func (mr *MockOIDCClientOptionsMockRecorder) WithLogger(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WithLogger", reflect.TypeOf((*MockOIDCClientOptions)(nil).WithLogger), arg0)
}
// WithLoginFlow mocks base method.
func (m *MockOIDCClientOptions) WithLoginFlow(arg0 v1alpha1.IDPFlow, arg1 string) oidcclient.Option {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "WithLoginFlow", arg0, arg1)
ret0, _ := ret[0].(oidcclient.Option)
return ret0
}
// WithLoginFlow indicates an expected call of WithLoginFlow.
func (mr *MockOIDCClientOptionsMockRecorder) WithLoginFlow(arg0, arg1 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WithLoginFlow", reflect.TypeOf((*MockOIDCClientOptions)(nil).WithLoginFlow), arg0, arg1)
}
// WithRequestAudience mocks base method.
func (m *MockOIDCClientOptions) WithRequestAudience(arg0 string) oidcclient.Option {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "WithRequestAudience", arg0)
ret0, _ := ret[0].(oidcclient.Option)
return ret0
}
// WithRequestAudience indicates an expected call of WithRequestAudience.
func (mr *MockOIDCClientOptionsMockRecorder) WithRequestAudience(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WithRequestAudience", reflect.TypeOf((*MockOIDCClientOptions)(nil).WithRequestAudience), arg0)
}
// WithScopes mocks base method.
func (m *MockOIDCClientOptions) WithScopes(arg0 []string) oidcclient.Option {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "WithScopes", arg0)
ret0, _ := ret[0].(oidcclient.Option)
return ret0
}
// WithScopes indicates an expected call of WithScopes.
func (mr *MockOIDCClientOptionsMockRecorder) WithScopes(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WithScopes", reflect.TypeOf((*MockOIDCClientOptions)(nil).WithScopes), arg0)
}
// WithSessionCache mocks base method.
func (m *MockOIDCClientOptions) WithSessionCache(arg0 oidcclient.SessionCache) oidcclient.Option {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "WithSessionCache", arg0)
ret0, _ := ret[0].(oidcclient.Option)
return ret0
}
// WithSessionCache indicates an expected call of WithSessionCache.
func (mr *MockOIDCClientOptionsMockRecorder) WithSessionCache(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WithSessionCache", reflect.TypeOf((*MockOIDCClientOptions)(nil).WithSessionCache), arg0)
}
// WithSkipBrowserOpen mocks base method.
func (m *MockOIDCClientOptions) WithSkipBrowserOpen() oidcclient.Option {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "WithSkipBrowserOpen")
ret0, _ := ret[0].(oidcclient.Option)
return ret0
}
// WithSkipBrowserOpen indicates an expected call of WithSkipBrowserOpen.
func (mr *MockOIDCClientOptionsMockRecorder) WithSkipBrowserOpen() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WithSkipBrowserOpen", reflect.TypeOf((*MockOIDCClientOptions)(nil).WithSkipBrowserOpen))
}
// WithSkipListen mocks base method.
func (m *MockOIDCClientOptions) WithSkipListen() oidcclient.Option {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "WithSkipListen")
ret0, _ := ret[0].(oidcclient.Option)
return ret0
}
// WithSkipListen indicates an expected call of WithSkipListen.
func (mr *MockOIDCClientOptionsMockRecorder) WithSkipListen() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WithSkipListen", reflect.TypeOf((*MockOIDCClientOptions)(nil).WithSkipListen))
}
// WithSkipPrintLoginURL mocks base method.
func (m *MockOIDCClientOptions) WithSkipPrintLoginURL() oidcclient.Option {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "WithSkipPrintLoginURL")
ret0, _ := ret[0].(oidcclient.Option)
return ret0
}
// WithSkipPrintLoginURL indicates an expected call of WithSkipPrintLoginURL.
func (mr *MockOIDCClientOptionsMockRecorder) WithSkipPrintLoginURL() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WithSkipPrintLoginURL", reflect.TypeOf((*MockOIDCClientOptions)(nil).WithSkipPrintLoginURL))
}
// WithUpstreamIdentityProvider mocks base method.
func (m *MockOIDCClientOptions) WithUpstreamIdentityProvider(arg0, arg1 string) oidcclient.Option {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "WithUpstreamIdentityProvider", arg0, arg1)
ret0, _ := ret[0].(oidcclient.Option)
return ret0
}
// WithUpstreamIdentityProvider indicates an expected call of WithUpstreamIdentityProvider.
func (mr *MockOIDCClientOptionsMockRecorder) WithUpstreamIdentityProvider(arg0, arg1 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WithUpstreamIdentityProvider", reflect.TypeOf((*MockOIDCClientOptions)(nil).WithUpstreamIdentityProvider), arg0, arg1)
}

View File

@@ -16,6 +16,7 @@ import (
"net/http"
"net/url"
"os"
"slices"
"sort"
"strings"
"sync"
@@ -27,8 +28,8 @@ import (
"golang.org/x/oauth2"
"golang.org/x/term"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/utils/strings/slices"
idpdiscoveryv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1"
oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc"
"go.pinniped.dev/internal/federationdomain/upstreamprovider"
"go.pinniped.dev/internal/httputil/httperr"
@@ -88,8 +89,9 @@ type handlerState struct {
// Tracking the usage of some other functional options.
upstreamIdentityProviderName string
upstreamIdentityProviderType string
upstreamIdentityProviderType idpdiscoveryv1alpha1.IDPType
cliToSendCredentials bool
loginFlow idpdiscoveryv1alpha1.IDPFlow
skipBrowser bool
skipPrintLoginURL bool
requestedAudience string
@@ -101,6 +103,7 @@ type handlerState struct {
// Generated parameters of a login flow.
provider *coreosoidc.Provider
idpDiscovery *idpdiscoveryv1alpha1.IDPDiscoveryResponse
oauth2Config *oauth2.Config
useFormPost bool
state state.State
@@ -243,6 +246,9 @@ func WithRequestAudience(audience string) Option {
// and by OIDCIdentityProviders which optionally enable the resource owner password credentials grant flow.
// This should never be used with non-Supervisor issuers because it will send the user's password to the authorization
// endpoint as a custom header, which would be ignored but could potentially get logged somewhere by the issuer.
//
// Deprecated: this option will be removed in a future version of Pinniped. See the WithLoginFlow() option instead.
// If this option is used along with the WithLoginFlow() option, it will cause an error.
func WithCLISendingCredentials() Option {
return func(h *handlerState) error {
h.cliToSendCredentials = true
@@ -250,6 +256,38 @@ func WithCLISendingCredentials() Option {
}
}
// WithLoginFlow chooses the login flow.
// When the argument is equal to idpdiscoveryv1alpha1.IDPFlowCLIPassword, it causes the login flow to use CLI-based
// prompts for username and password and causes the call to the Issuer's authorize endpoint to be made directly (no web
// browser) with the username and password on custom HTTP headers. This is only intended to be used when the issuer is a
// Pinniped Supervisor and the upstream identity provider type supports this style of authentication. Currently, this is
// supported by LDAPIdentityProviders, ActiveDirectoryIdentityProviders, and by OIDCIdentityProviders which optionally
// enable the resource owner password credentials grant flow. This should never be used with non-Supervisor issuers
// because it will send the user's password to the authorization endpoint as a custom header, which would be ignored but
// could potentially get logged somewhere by the issuer.
// When the argument is equal to idpdiscoveryv1alpha1.IDPFlowBrowserAuthcode, it will attempt to open a web browser
// and perform the OIDC authcode flow.
// When not used, the default when the issuer is a Pinniped Supervisor will be determined automatically,
// and the default for non-Supervisor issuers will be the browser authcode flow.
func WithLoginFlow(loginFlow idpdiscoveryv1alpha1.IDPFlow, flowSource string) Option {
return func(h *handlerState) error {
switch loginFlow {
case idpdiscoveryv1alpha1.IDPFlowCLIPassword,
idpdiscoveryv1alpha1.IDPFlowBrowserAuthcode:
default:
return fmt.Errorf(
"WithLoginFlow error: loginFlow '%s' from '%s' must be '%s' or '%s'",
loginFlow,
flowSource,
idpdiscoveryv1alpha1.IDPFlowCLIPassword,
idpdiscoveryv1alpha1.IDPFlowBrowserAuthcode,
)
}
h.loginFlow = loginFlow
return nil
}
}
// WithUpstreamIdentityProvider causes the specified name and type to be sent as custom query parameters to the
// issuer's authorize endpoint. This is only intended to be used when the issuer is a Pinniped Supervisor, in which
// case it provides a mechanism to choose among several upstream identity providers.
@@ -257,7 +295,10 @@ func WithCLISendingCredentials() Option {
func WithUpstreamIdentityProvider(upstreamName, upstreamType string) Option {
return func(h *handlerState) error {
h.upstreamIdentityProviderName = upstreamName
h.upstreamIdentityProviderType = upstreamType
// Do not perform validation on this cast.
// If possible, dynamic validation against a Pinniped Supervisor's supported IDP types will be performed.
h.upstreamIdentityProviderType = idpdiscoveryv1alpha1.IDPType(upstreamType)
return nil
}
}
@@ -304,6 +345,13 @@ func Login(issuer string, clientID string, opts ...Option) (*oidctypes.Token, er
}
}
if h.cliToSendCredentials {
if h.loginFlow != "" {
return nil, fmt.Errorf("do not use deprecated option WithCLISendingCredentials when using option WithLoginFlow")
}
h.loginFlow = idpdiscoveryv1alpha1.IDPFlowCLIPassword
}
// Copy the configured HTTP client to set a request timeout (the Go default client has no timeout configured).
httpClientWithTimeout := *h.httpClient
httpClientWithTimeout.Timeout = httpRequestTimeout
@@ -426,19 +474,24 @@ func (h *handlerState) baseLogin() (*oidctypes.Token, error) {
h.pkce.Challenge(),
h.pkce.Method(),
}
if h.upstreamIdentityProviderName != "" {
authorizeOptions = append(authorizeOptions,
oauth2.SetAuthURLParam(oidcapi.AuthorizeUpstreamIDPNameParamName, h.upstreamIdentityProviderName),
)
authorizeOptions = append(authorizeOptions,
oauth2.SetAuthURLParam(oidcapi.AuthorizeUpstreamIDPTypeParamName, h.upstreamIdentityProviderType),
)
loginFlow, pinnipedSupervisorOptions, err := h.maybePerformPinnipedSupervisorValidations()
if err != nil {
return nil, err
}
h.loginFlow = loginFlow
authorizeOptions = append(authorizeOptions, pinnipedSupervisorOptions...)
// Preserve the legacy behavior where browser-based auth is preferred
authFunc := h.webBrowserBasedAuth
// Choose the appropriate authorization and authcode exchange strategy.
var authFunc = h.webBrowserBasedAuth
if h.cliToSendCredentials {
// Use a switch so that lint will make sure we have full coverage.
switch h.loginFlow {
case idpdiscoveryv1alpha1.IDPFlowCLIPassword:
authFunc = h.cliBasedAuth
case idpdiscoveryv1alpha1.IDPFlowBrowserAuthcode:
// NOOP
}
// Perform the authorize request and authcode exchange to get back OIDC tokens.
@@ -452,6 +505,108 @@ func (h *handlerState) baseLogin() (*oidctypes.Token, error) {
return token, err
}
// maybePerformPinnipedSupervisorValidations will return the flow and some authorization options.
// When the IDP name is unset, it will assume that the server is not a Pinniped Supervisor, and will return immediately.
// Otherwise, when the flow is unset, it will infer the flow from the server, or when the flow is set, it will return that flow unchanged.
// It will also perform additional validations if the issuer is a Pinniped Supervisor.
func (h *handlerState) maybePerformPinnipedSupervisorValidations() (idpdiscoveryv1alpha1.IDPFlow, []oauth2.AuthCodeOption, error) {
loginFlow := h.loginFlow
if h.upstreamIdentityProviderName == "" {
return loginFlow, nil, nil
}
if h.idpDiscovery == nil {
return "", nil, fmt.Errorf("upstream identity provider name %q was specified, but OIDC issuer %q does not "+
"offer Pinniped-style IDP discovery, so it does not appear to be a Pinniped Supervisor; "+
"specifying an upstream identity provider name is only meant to be used with Pinniped Supervisors",
h.upstreamIdentityProviderName, h.issuer)
}
// Legacy Pinniped Supervisors do not provide this information. Only run this validation when the information was provided.
if len(h.idpDiscovery.PinnipedSupportedIDPTypes) > 0 {
var supportedIDPTypes []idpdiscoveryv1alpha1.IDPType
for _, idpType := range h.idpDiscovery.PinnipedSupportedIDPTypes {
supportedIDPTypes = append(supportedIDPTypes, idpType.Type)
}
// Sort by name for repeatability
slices.Sort(supportedIDPTypes)
if !slices.Contains(supportedIDPTypes, h.upstreamIdentityProviderType) {
convertIDPListToQuotedStringList := func() []string {
var temp []string
for _, idpType := range supportedIDPTypes {
temp = append(temp, fmt.Sprintf("%q", idpType))
}
return temp
}
return "", nil, fmt.Errorf("unable to find upstream identity provider with type %q, this Pinniped Supervisor supports IDP types [%s]",
h.upstreamIdentityProviderType,
strings.Join(convertIDPListToQuotedStringList(), ", "))
}
}
// Find the IDP from discovery by the specified name, type, and maybe flow.
foundIDPIndex := slices.IndexFunc(h.idpDiscovery.PinnipedIDPs, func(idp idpdiscoveryv1alpha1.PinnipedIDP) bool {
return idp.Name == h.upstreamIdentityProviderName &&
idp.Type == h.upstreamIdentityProviderType &&
(loginFlow == "" || slices.Contains(idp.Flows, loginFlow))
})
// If the IDP was not found...
if foundIDPIndex < 0 {
pinnipedIDPsString, err := json.Marshal(h.idpDiscovery.PinnipedIDPs)
if err != nil {
// This should never happen. Not unit tested.
return "", nil, fmt.Errorf("error marshalling IDP discovery response: %w", err)
}
if loginFlow == "" {
return "", nil, fmt.Errorf(
"unable to find upstream identity provider with name %q and type %q. Found these providers: %s",
h.upstreamIdentityProviderName,
h.upstreamIdentityProviderType,
pinnipedIDPsString,
)
}
return "", nil, fmt.Errorf(
"unable to find upstream identity provider with name %q and type %q and flow %q. Found these providers: %s",
h.upstreamIdentityProviderName,
h.upstreamIdentityProviderType,
loginFlow,
pinnipedIDPsString,
)
}
// If the caller has not requested a specific flow, but has requested a specific IDP, infer the authentication flow
// from the found IDP's discovery information.
if loginFlow == "" {
foundIDP := h.idpDiscovery.PinnipedIDPs[foundIDPIndex]
if len(foundIDP.Flows) == 0 {
// Note that this should not really happen because the Supervisor's IDP discovery endpoint has always listed flows.
return "", nil, fmt.Errorf("unable to infer flow for upstream identity provider with name %q and type %q "+
"because there were no flows discovered for that provider",
h.upstreamIdentityProviderName,
h.upstreamIdentityProviderType,
)
}
// The order of the flows returned by the server indicates the server's flow preference,
// so always use the first flow for that IDP from the discovery response.
loginFlow = foundIDP.Flows[0]
}
var authorizeOptions []oauth2.AuthCodeOption
authorizeOptions = append(authorizeOptions,
oauth2.SetAuthURLParam(oidcapi.AuthorizeUpstreamIDPNameParamName, h.upstreamIdentityProviderName),
)
authorizeOptions = append(authorizeOptions,
oauth2.SetAuthURLParam(oidcapi.AuthorizeUpstreamIDPTypeParamName, string(h.upstreamIdentityProviderType)),
)
return loginFlow, authorizeOptions, nil
}
// Make a direct call to the authorize endpoint, including the user's username and password on custom http headers,
// and parse the authcode from the response. Exchange the authcode for tokens. Return the tokens or an error.
func (h *handlerState) cliBasedAuth(authorizeOptions *[]oauth2.AuthCodeOption) (*oidctypes.Token, error) {
@@ -759,7 +914,7 @@ func promptForSecret(promptLabel string, out io.Writer) (string, error) {
}
func (h *handlerState) initOIDCDiscovery() error {
// Make this method idempotent so it can be called in multiple cases with no extra network requests.
// Make this method idempotent, so it can be called in multiple cases with no extra network requests.
if h.provider != nil {
return nil
}
@@ -803,6 +958,60 @@ func (h *handlerState) initOIDCDiscovery() error {
return fmt.Errorf("could not decode response_modes_supported in OIDC discovery from %q: %w", h.issuer, err)
}
h.useFormPost = slices.Contains(discoveryClaims.ResponseModesSupported, "form_post")
return h.maybePerformPinnipedSupervisorIDPDiscovery()
}
func (h *handlerState) maybePerformPinnipedSupervisorIDPDiscovery() error {
// If this OIDC IDP is a Pinniped Supervisor, it will have a reference to the IDP discovery document.
// Go to that document and retrieve the IDPs.
var pinnipedSupervisorClaims idpdiscoveryv1alpha1.OIDCDiscoveryResponse
if err := h.provider.Claims(&pinnipedSupervisorClaims); err != nil {
return fmt.Errorf("could not decode the Pinniped IDP discovery document URL in OIDC discovery from %q: %w", h.issuer, err)
}
// This is not an error - it just means that this issuer is not a Pinniped Supervisor.
// Note that this package can be used with OIDC IDPs other than Pinniped Supervisor.
if pinnipedSupervisorClaims.SupervisorDiscovery.PinnipedIDPsEndpoint == "" {
return nil
}
// This check confirms that the issuer is hosting the IDP discovery document, which would always be the case for
// Pinniped Supervisor. Since there are checks above to confirm that the issuer uses HTTPS, IDP discovery will
// always use HTTPS.
if !strings.HasPrefix(pinnipedSupervisorClaims.SupervisorDiscovery.PinnipedIDPsEndpoint, h.issuer) {
return fmt.Errorf("the Pinniped IDP discovery document must always be hosted by the issuer: %q", h.issuer)
}
idpDiscoveryCtx, idpDiscoveryCtxCancelFunc := context.WithTimeout(h.ctx, httpRequestTimeout)
defer idpDiscoveryCtxCancelFunc()
idpDiscoveryReq, err := http.NewRequestWithContext(idpDiscoveryCtx, http.MethodGet, pinnipedSupervisorClaims.SupervisorDiscovery.PinnipedIDPsEndpoint, nil)
if err != nil { // untested
return fmt.Errorf("could not build IDP Discovery request: %w", err)
}
idpDiscoveryRes, err := h.httpClient.Do(idpDiscoveryReq)
if err != nil {
return fmt.Errorf("IDP Discovery response error: %w", err)
}
defer func() {
_ = idpDiscoveryRes.Body.Close() // We can't do anything if this fails to close
}()
if idpDiscoveryRes.StatusCode != http.StatusOK {
return fmt.Errorf("unable to fetch IDP discovery data from issuer: unexpected http response status: %s", idpDiscoveryRes.Status)
}
rawBody, err := io.ReadAll(idpDiscoveryRes.Body)
if err != nil { // untested
return fmt.Errorf("unable to fetch IDP discovery data from issuer: could not read response body: %w", err)
}
var body idpdiscoveryv1alpha1.IDPDiscoveryResponse
err = json.Unmarshal(rawBody, &body)
if err != nil {
return fmt.Errorf("unable to fetch the Pinniped IDP discovery document: could not parse response JSON: %w", err)
}
h.idpDiscovery = &body
return nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,7 @@ import (
"path/filepath"
"regexp"
"runtime"
"slices"
"sort"
"strings"
"sync/atomic"
@@ -36,6 +37,7 @@ import (
authv1alpha "go.pinniped.dev/generated/latest/apis/concierge/authentication/v1alpha1"
configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1"
idpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1"
supervisorclient "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/typed/config/v1alpha1"
"go.pinniped.dev/internal/certauthority"
"go.pinniped.dev/internal/crud"
"go.pinniped.dev/internal/here"
@@ -701,27 +703,15 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
ptyFile, err := pty.Start(kubectlCmd)
require.NoError(t, err)
// Wait for the subprocess to print the username prompt, then type the user's username.
readFromFileUntilStringIsSeen(t, ptyFile, "Username: ")
_, err = ptyFile.WriteString(env.SupervisorUpstreamOIDC.Username + "\n")
require.NoError(t, err)
// Wait for the subprocess to print the password prompt, then type the user's password.
readFromFileUntilStringIsSeen(t, ptyFile, "Password: ")
_, err = ptyFile.WriteString(env.SupervisorUpstreamOIDC.Password + "\n")
require.NoError(t, err)
// Read all output from the subprocess until EOF.
// Ignore any errors returned because there is always an error on linux.
kubectlOutputBytes, _ := io.ReadAll(ptyFile)
kubectlOutput := string(kubectlOutputBytes)
// The output should look like an authentication failure, because the OIDCIdentityProvider disallows password grants.
// The output should fail IDP discovery validation, because the OIDCIdentityProvider disallows password grants.
t.Log("kubectl command output (expecting a login failed error):\n", kubectlOutput)
require.Contains(t, kubectlOutput,
`Error: could not complete Pinniped login: login failed with code "access_denied": `+
`The resource owner or authorization server denied the request. `+
`Resource owner password credentials grant is not allowed for this upstream provider according to its configuration.`,
fmt.Sprintf(`could not complete Pinniped login: unable to find upstream identity provider with name "%[1]s" and type "oidc" and flow "cli_password". Found these providers: [{"name":"%[1]s","type":"oidc","flows":["browser_authcode"]}]`, oidcIdentityProvider.Name),
)
})
@@ -1293,6 +1283,10 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
gotFederationDomain, err := federationDomainsClient.Get(testCtx, federationDomain.Name, metav1.GetOptions{})
require.NoError(t, err)
t.Cleanup(func() {
removeFederationDomainIdentityProviders(t, federationDomainsClient, federationDomain.Name)
})
ldapIDPDisplayName := "My LDAP IDP 💾"
oidcIDPDisplayName := "My OIDC IDP 🚀"
@@ -1547,6 +1541,211 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
`Reason: configured identity policy rejected this authentication: only special LDAP users allowed.`)
require.Contains(t, string(kubectlOutputBytes), "pinniped failed with exit code 1")
})
t.Run("with OIDC and LDAP, verify that 'pinniped login oidc' will infer the login flow from IDP discovery", func(t *testing.T) {
testlib.SkipTestWhenLDAPIsUnavailable(t, env)
testCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
t.Cleanup(cancel)
tempDir := t.TempDir() // per-test tmp dir to avoid sharing files between tests
// Start a fresh browser driver because we don't want to share cookies between the various tests in this file.
browser := browsertest.OpenBrowser(t)
expectedDownstreamLDAPUsername := env.SupervisorUpstreamLDAP.TestUserMailAttributeValue
expectedDownstreamOIDCUsername := env.SupervisorUpstreamOIDC.Username
expectedDownstreamLDAPGroups := env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs
expectedDownstreamOIDCGroups := env.SupervisorUpstreamOIDC.ExpectedGroups
createdLDAPProvider := setupClusterForEndToEndLDAPTest(t, expectedDownstreamLDAPUsername, env)
// Having one IDP should put the FederationDomain into a ready state.
testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, configv1alpha1.FederationDomainPhaseReady)
testlib.WaitForJWTAuthenticatorStatusPhase(testCtx, t, authenticator.Name, authv1alpha.JWTAuthenticatorPhaseReady)
// Create a ClusterRoleBinding to give our test user from the upstream read-only access to the cluster.
testlib.CreateTestClusterRoleBinding(t,
rbacv1.Subject{Kind: rbacv1.UserKind, APIGroup: rbacv1.GroupName, Name: expectedDownstreamOIDCUsername},
rbacv1.RoleRef{Kind: "ClusterRole", APIGroup: rbacv1.GroupName, Name: "view"},
)
testlib.WaitForUserToHaveAccess(t, expectedDownstreamOIDCUsername, []string{}, &authorizationv1.ResourceAttributes{
Verb: "get",
Group: "",
Version: "v1",
Resource: "namespaces",
})
// Create upstream OIDC provider and wait for it to become ready.
createdOIDCProvider := testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{
Issuer: env.SupervisorUpstreamOIDC.Issuer,
TLS: &idpv1alpha1.TLSSpec{
CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)),
},
AuthorizationConfig: idpv1alpha1.OIDCAuthorizationConfig{
AdditionalScopes: env.SupervisorUpstreamOIDC.AdditionalScopes,
AllowPasswordGrant: true, // We specifically want this OIDC to support both 'cli_password' and 'browser_authcode'
},
Claims: idpv1alpha1.OIDCClaims{
Username: env.SupervisorUpstreamOIDC.UsernameClaim,
Groups: env.SupervisorUpstreamOIDC.GroupsClaim,
},
Client: idpv1alpha1.OIDCClient{
SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name,
},
}, idpv1alpha1.PhaseReady)
// Having a second IDP should put the FederationDomain back into an error state until we tell it which one to use.
testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, configv1alpha1.FederationDomainPhaseError)
testlib.WaitForJWTAuthenticatorStatusPhase(testCtx, t, authenticator.Name, authv1alpha.JWTAuthenticatorPhaseReady)
// Update the FederationDomain to use the two IDPs.
federationDomainsClient := testlib.NewSupervisorClientset(t).ConfigV1alpha1().FederationDomains(env.SupervisorNamespace)
gotFederationDomain, err := federationDomainsClient.Get(testCtx, federationDomain.Name, metav1.GetOptions{})
require.NoError(t, err)
t.Cleanup(func() {
removeFederationDomainIdentityProviders(t, federationDomainsClient, federationDomain.Name)
})
ldapIDPDisplayName := "My LDAP IDP 💾"
oidcIDPDisplayName := "My OIDC IDP 🚀"
gotFederationDomain.Spec.IdentityProviders = []configv1alpha1.FederationDomainIdentityProvider{
{
DisplayName: ldapIDPDisplayName,
ObjectRef: corev1.TypedLocalObjectReference{
APIGroup: ptr.To("idp.supervisor." + env.APIGroupSuffix),
Kind: "LDAPIdentityProvider",
Name: createdLDAPProvider.Name,
},
},
{
DisplayName: oidcIDPDisplayName,
ObjectRef: corev1.TypedLocalObjectReference{
APIGroup: ptr.To("idp.supervisor." + env.APIGroupSuffix),
Kind: "OIDCIdentityProvider",
Name: createdOIDCProvider.Name,
},
},
}
_, err = federationDomainsClient.Update(testCtx, gotFederationDomain, metav1.UpdateOptions{})
require.NoError(t, err)
// The FederationDomain should be valid after the above update.
testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, configv1alpha1.FederationDomainPhaseReady)
testlib.WaitForJWTAuthenticatorStatusPhase(testCtx, t, authenticator.Name, authv1alpha.JWTAuthenticatorPhaseReady)
// Use a specific session cache for this test.
sessionCachePath := tempDir + "/test-sessions.yaml"
credentialCachePath := tempDir + "/test-credentials.yaml"
// We want to be sure that "pinniped login oidc" will infer "cli_password" when we override the flow type
// with PINNIPED_UPSTREAM_IDENTITY_PROVIDER_FLOW="". So we'll set this to be the non-default login flow.
nonDefaultLDAPLoginFlow := "browser_authcode"
ldapKubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{
"get", "kubeconfig",
"--concierge-api-group-suffix", env.APIGroupSuffix,
"--concierge-authenticator-type", "jwt",
"--concierge-authenticator-name", authenticator.Name,
"--oidc-session-cache", sessionCachePath,
"--credential-cache", credentialCachePath,
"--upstream-identity-provider-name", ldapIDPDisplayName,
"--upstream-identity-provider-flow", nonDefaultLDAPLoginFlow,
// use default for --oidc-scopes, which is to request all relevant scopes
})
// We want to be sure that "pinniped login oidc" will infer "browser_authcode" when we override the flow type
// with PINNIPED_UPSTREAM_IDENTITY_PROVIDER_FLOW="". So we'll set this to be the non-default login flow.
nonDefaultOIDCLoginFlow := "cli_password"
oidcKubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{
"get", "kubeconfig",
"--concierge-api-group-suffix", env.APIGroupSuffix,
"--concierge-authenticator-type", "jwt",
"--concierge-authenticator-name", authenticator.Name,
"--oidc-skip-browser",
"--oidc-session-cache", sessionCachePath,
"--credential-cache", credentialCachePath,
"--upstream-identity-provider-name", oidcIDPDisplayName,
"--upstream-identity-provider-flow", nonDefaultOIDCLoginFlow,
// use default for --oidc-scopes, which is to request all relevant scopes
})
// Run "kubectl get namespaces" which should trigger an LDAP-style login CLI prompt via the plugin for the LDAP IDP.
t.Log("starting LDAP auth via kubectl")
start := time.Now()
kubectlCmd := exec.CommandContext(testCtx, "kubectl", "get", "namespace", "--kubeconfig", ldapKubeconfigPath)
kubectlCmd.Env = slices.Concat(os.Environ(), env.ProxyEnv(), []string{"PINNIPED_UPSTREAM_IDENTITY_PROVIDER_FLOW="})
ptyFile, err := pty.Start(kubectlCmd)
require.NoError(t, err)
// Wait for the subprocess to print the username prompt, then type the user's username.
readFromFileUntilStringIsSeen(t, ptyFile, "Username: ")
_, err = ptyFile.WriteString(expectedDownstreamLDAPUsername + "\n")
require.NoError(t, err)
// Wait for the subprocess to print the password prompt, then type the user's password.
readFromFileUntilStringIsSeen(t, ptyFile, "Password: ")
_, err = ptyFile.WriteString(env.SupervisorUpstreamLDAP.TestUserPassword + "\n")
require.NoError(t, err)
// Read all output from the subprocess until EOF.
// Ignore any errors returned because there is always an error on linux.
kubectlOutputBytes, _ := io.ReadAll(ptyFile)
requireKubectlGetNamespaceOutput(t, env, string(kubectlOutputBytes))
t.Logf("first kubectl command took %s", time.Since(start).String())
requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, federationDomain, ldapIDPDisplayName, ldapKubeconfigPath,
sessionCachePath, pinnipedExe, expectedDownstreamLDAPUsername, expectedDownstreamLDAPGroups, allScopes)
// Run "kubectl get namespaces" which should trigger a browser login via the plugin for the OIDC IDP.
t.Log("starting OIDC auth via kubectl")
kubectlCmd = exec.CommandContext(testCtx, "kubectl", "get", "namespace", "--kubeconfig", oidcKubeconfigPath, "-v", "6")
kubectlCmd.Env = slices.Concat(os.Environ(), env.ProxyEnv(), []string{"PINNIPED_UPSTREAM_IDENTITY_PROVIDER_FLOW="})
// Run the kubectl command, wait for the Pinniped CLI to print the authorization URL, and open it in the browser.
kubectlOutputChan := startKubectlAndOpenAuthorizationURLInBrowser(testCtx, t, kubectlCmd, browser)
// Confirm that we got to the upstream IDP's login page, fill out the form, and submit the form.
browsertest.LoginToUpstreamOIDC(t, browser, env.SupervisorUpstreamOIDC)
// Expect to be redirected to the downstream callback which is serving the form_post HTML.
t.Logf("waiting for response page %s", federationDomain.Spec.Issuer)
browser.WaitForURL(t, regexp.MustCompile(regexp.QuoteMeta(federationDomain.Spec.Issuer)))
// The response page should have done the background fetch() and POST'ed to the CLI's callback.
// It should now be in the "success" state.
formpostExpectSuccessState(t, browser)
requireKubectlGetNamespaceOutput(t, env, waitForKubectlOutput(t, kubectlOutputChan))
// The user is now logged in to the cluster as two different identities simultaneously, and can switch
// back and forth by switching kubeconfigs, without needing to auth again.
requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, federationDomain, oidcIDPDisplayName, oidcKubeconfigPath,
sessionCachePath, pinnipedExe, expectedDownstreamOIDCUsername, expectedDownstreamOIDCGroups, allScopes)
requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, federationDomain, ldapIDPDisplayName, ldapKubeconfigPath,
sessionCachePath, pinnipedExe, expectedDownstreamLDAPUsername, expectedDownstreamLDAPGroups, allScopes)
requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, federationDomain, oidcIDPDisplayName, oidcKubeconfigPath,
sessionCachePath, pinnipedExe, expectedDownstreamOIDCUsername, expectedDownstreamOIDCGroups, allScopes)
})
}
func removeFederationDomainIdentityProviders(t *testing.T, federationDomainsClient supervisorclient.FederationDomainInterface, federationDomainName string) {
t.Helper()
cleanupContext, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
gotFederationDomain, err := federationDomainsClient.Get(cleanupContext, federationDomainName, metav1.GetOptions{})
require.NoError(t, err)
// remove the FederationDomain's identity providers
gotFederationDomain.Spec.IdentityProviders = nil
_, err = federationDomainsClient.Update(cleanupContext, gotFederationDomain, metav1.UpdateOptions{})
require.NoError(t, err)
}
func startKubectlAndOpenAuthorizationURLInBrowser(testCtx context.Context, t *testing.T, kubectlCmd *exec.Cmd, b *browsertest.Browser) chan string {