mirror of
https://github.com/vmware-tanzu/pinniped.git
synced 2026-09-19 06:31:47 +00:00
Optionally allow OIDC password grant for CLI-based login experience
- Add `AllowPasswordGrant` boolean field to OIDCIdentityProvider's spec
- The oidc upstream watcher controller copies the value of
`AllowPasswordGrant` into the configuration of the cached provider
- Add password grant to the UpstreamOIDCIdentityProviderI interface
which is implemented by the cached provider instance for use in the
authorization endpoint
- Enhance the IDP discovery endpoint to return the supported "flows"
for each IDP ("cli_password" and/or "browser_authcode")
- Enhance `pinniped get kubeconfig` to help the user choose the desired
flow for the selected IDP, and to write the flow into the resulting
kubeconfg
- Enhance `pinniped login oidc` to have a flow flag to tell it which
client-side flow it should use for auth (CLI-based or browser-based)
- In the Dex config, allow the resource owner password grant, which Dex
implements to also return ID tokens, for use in integration tests
- Enhance the authorize endpoint to perform password grant when
requested by the incoming headers. This commit does not include unit
tests for the enhancements to the authorize endpoint, which will come
in the next commit
- Extract some shared helpers from the callback endpoint to share the
code with the authorize endpoint
- Add new integration tests
This commit is contained in:
@@ -17,6 +17,8 @@ web:
|
||||
tlsKey: /var/certs/dex-key.pem
|
||||
oauth2:
|
||||
skipApprovalScreen: true
|
||||
#! Allow the resource owner password grant, which Dex implements to also return ID tokens.
|
||||
passwordConnector: local
|
||||
staticClients:
|
||||
- id: pinniped-cli
|
||||
name: 'Pinniped CLI'
|
||||
|
||||
@@ -271,7 +271,7 @@ func TestE2EFullIntegration(t *testing.T) {
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("with Supervisor OIDC upstream IDP and manual flow", func(t *testing.T) {
|
||||
t.Run("with Supervisor OIDC upstream IDP and manual authcode copy-paste from browser flow", func(t *testing.T) {
|
||||
// Start a fresh browser driver because we don't want to share cookies between the various tests in this file.
|
||||
page := browsertest.Open(t)
|
||||
|
||||
@@ -365,7 +365,7 @@ func TestE2EFullIntegration(t *testing.T) {
|
||||
|
||||
// Read all of the remaining output from the subprocess until EOF.
|
||||
t.Logf("waiting for kubectl to output namespace list")
|
||||
// Read all of the output from the subprocess until EOF.
|
||||
// Read all output from the subprocess until EOF.
|
||||
// Ignore any errors returned because there is always an error on linux.
|
||||
kubectlOutputBytes, _ := ioutil.ReadAll(ptyFile)
|
||||
requireKubectlGetNamespaceOutput(t, env, string(kubectlOutputBytes))
|
||||
@@ -382,6 +382,159 @@ func TestE2EFullIntegration(t *testing.T) {
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("with Supervisor OIDC upstream IDP and CLI password flow without web browser", func(t *testing.T) {
|
||||
expectedUsername := env.SupervisorUpstreamOIDC.Username
|
||||
expectedGroups := env.SupervisorUpstreamOIDC.ExpectedGroups
|
||||
|
||||
// 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: expectedUsername},
|
||||
rbacv1.RoleRef{Kind: "ClusterRole", APIGroup: rbacv1.GroupName, Name: "view"},
|
||||
)
|
||||
testlib.WaitForUserToHaveAccess(t, expectedUsername, []string{}, &authorizationv1.ResourceAttributes{
|
||||
Verb: "get",
|
||||
Group: "",
|
||||
Version: "v1",
|
||||
Resource: "namespaces",
|
||||
})
|
||||
|
||||
// Create upstream OIDC provider and wait for it to become ready.
|
||||
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, // allow the CLI password flow for this OIDCIdentityProvider
|
||||
},
|
||||
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)
|
||||
|
||||
// Use a specific session cache for this test.
|
||||
sessionCachePath := tempDir + "/oidc-test-sessions-password-grant.yaml"
|
||||
kubeconfigPath := 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-skip-listen",
|
||||
"--upstream-identity-provider-flow", "cli_password", // create a kubeconfig configured to use the cli_password flow
|
||||
"--oidc-ca-bundle", testCABundlePath,
|
||||
"--oidc-session-cache", sessionCachePath,
|
||||
})
|
||||
|
||||
// Run "kubectl get namespaces" which should trigger a browser-less CLI prompt login via the plugin.
|
||||
start := time.Now()
|
||||
kubectlCmd := exec.CommandContext(ctx, "kubectl", "get", "namespace", "--kubeconfig", kubeconfigPath)
|
||||
kubectlCmd.Env = append(os.Environ(), env.ProxyEnv()...)
|
||||
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(expectedUsername + "\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, _ := ioutil.ReadAll(ptyFile)
|
||||
requireKubectlGetNamespaceOutput(t, env, string(kubectlOutputBytes))
|
||||
|
||||
t.Logf("first kubectl command took %s", time.Since(start).String())
|
||||
|
||||
requireUserCanUseKubectlWithoutAuthenticatingAgain(ctx, t, env,
|
||||
downstream,
|
||||
kubeconfigPath,
|
||||
sessionCachePath,
|
||||
pinnipedExe,
|
||||
expectedUsername,
|
||||
expectedGroups,
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("with Supervisor OIDC upstream IDP and CLI password flow when OIDCIdentityProvider disallows it", func(t *testing.T) {
|
||||
// Create upstream OIDC provider and wait for it to become ready.
|
||||
oidcIdentityProvider := 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: false, // disallow the CLI password flow for this OIDCIdentityProvider!
|
||||
},
|
||||
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)
|
||||
|
||||
// Use a specific session cache for this test.
|
||||
sessionCachePath := tempDir + "/oidc-test-sessions-password-grant-negative-test.yaml"
|
||||
kubeconfigPath := 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-skip-listen",
|
||||
// Create a kubeconfig configured to use the cli_password flow. By specifying all
|
||||
// available --upstream-identity-provider-* options the CLI should skip IDP discovery
|
||||
// and use the provided values without validating them. "cli_password" will not show
|
||||
// up in the list of available flows for this IDP in the discovery response.
|
||||
"--upstream-identity-provider-name", oidcIdentityProvider.Name,
|
||||
"--upstream-identity-provider-type", "oidc",
|
||||
"--upstream-identity-provider-flow", "cli_password",
|
||||
"--oidc-ca-bundle", testCABundlePath,
|
||||
"--oidc-session-cache", sessionCachePath,
|
||||
})
|
||||
|
||||
// Run "kubectl get namespaces" which should trigger a browser-less CLI prompt login via the plugin.
|
||||
kubectlCmd := exec.CommandContext(ctx, "kubectl", "get", "namespace", "--kubeconfig", kubeconfigPath)
|
||||
kubectlCmd.Env = append(os.Environ(), env.ProxyEnv()...)
|
||||
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, _ := ioutil.ReadAll(ptyFile)
|
||||
kubectlOutput := string(kubectlOutputBytes)
|
||||
|
||||
// The output should look like an authentication failure, 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 grant is not allowed for this upstream provider according to its configuration`,
|
||||
)
|
||||
})
|
||||
|
||||
// Add an LDAP upstream IDP and try using it to authenticate during kubectl commands
|
||||
// by interacting with the CLI's username and password prompts.
|
||||
t.Run("with Supervisor LDAP upstream IDP using username and password prompts", func(t *testing.T) {
|
||||
@@ -422,7 +575,7 @@ func TestE2EFullIntegration(t *testing.T) {
|
||||
_, err = ptyFile.WriteString(env.SupervisorUpstreamLDAP.TestUserPassword + "\n")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Read all of the output from the subprocess until EOF.
|
||||
// Read all output from the subprocess until EOF.
|
||||
// Ignore any errors returned because there is always an error on linux.
|
||||
kubectlOutputBytes, _ := ioutil.ReadAll(ptyFile)
|
||||
requireKubectlGetNamespaceOutput(t, env, string(kubectlOutputBytes))
|
||||
@@ -487,7 +640,7 @@ func TestE2EFullIntegration(t *testing.T) {
|
||||
ptyFile, err := pty.Start(kubectlCmd)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Read all of the output from the subprocess until EOF.
|
||||
// Read all output from the subprocess until EOF.
|
||||
// Ignore any errors returned because there is always an error on linux.
|
||||
kubectlOutputBytes, _ := ioutil.ReadAll(ptyFile)
|
||||
requireKubectlGetNamespaceOutput(t, env, string(kubectlOutputBytes))
|
||||
|
||||
@@ -65,7 +65,7 @@ func TestSupervisorLogin(t *testing.T) {
|
||||
},
|
||||
}, idpv1alpha1.PhaseReady)
|
||||
},
|
||||
requestAuthorization: requestAuthorizationUsingOIDCIdentityProvider,
|
||||
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlow,
|
||||
// the ID token Subject should include the upstream user ID after the upstream issuer name
|
||||
wantDownstreamIDTokenSubjectToMatch: regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+",
|
||||
// the ID token Username should include the upstream user ID after the upstream issuer name
|
||||
@@ -95,11 +95,44 @@ func TestSupervisorLogin(t *testing.T) {
|
||||
},
|
||||
}, idpv1alpha1.PhaseReady)
|
||||
},
|
||||
requestAuthorization: requestAuthorizationUsingOIDCIdentityProvider,
|
||||
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlow,
|
||||
wantDownstreamIDTokenSubjectToMatch: regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+",
|
||||
wantDownstreamIDTokenUsernameToMatch: regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Username),
|
||||
wantDownstreamIDTokenGroups: env.SupervisorUpstreamOIDC.ExpectedGroups,
|
||||
},
|
||||
{
|
||||
name: "oidc with CLI password flow",
|
||||
maybeSkip: func(t *testing.T) {
|
||||
// never need to skip this test
|
||||
},
|
||||
createIDP: func(t *testing.T) {
|
||||
t.Helper()
|
||||
testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{
|
||||
Issuer: env.SupervisorUpstreamOIDC.Issuer,
|
||||
TLS: &idpv1alpha1.TLSSpec{
|
||||
CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)),
|
||||
},
|
||||
Client: idpv1alpha1.OIDCClient{
|
||||
SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name,
|
||||
},
|
||||
AuthorizationConfig: idpv1alpha1.OIDCAuthorizationConfig{
|
||||
AllowPasswordGrant: true, // allow the CLI password flow for this OIDCIdentityProvider
|
||||
},
|
||||
}, idpv1alpha1.PhaseReady)
|
||||
},
|
||||
requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) {
|
||||
requestAuthorizationUsingCLIPasswordFlow(t,
|
||||
downstreamAuthorizeURL,
|
||||
env.SupervisorUpstreamOIDC.Username, // username to present to server during login
|
||||
env.SupervisorUpstreamOIDC.Password, // password to present to server during login
|
||||
httpClient,
|
||||
)
|
||||
},
|
||||
// the ID token Subject should include the upstream user ID after the upstream issuer name
|
||||
wantDownstreamIDTokenSubjectToMatch: regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+",
|
||||
// the ID token Username should include the upstream user ID after the upstream issuer name
|
||||
wantDownstreamIDTokenUsernameToMatch: regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+",
|
||||
},
|
||||
{
|
||||
name: "ldap with email as username and groups names as DNs and using an LDAP provider which supports TLS",
|
||||
maybeSkip: func(t *testing.T) {
|
||||
@@ -148,7 +181,7 @@ func TestSupervisorLogin(t *testing.T) {
|
||||
requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg)
|
||||
},
|
||||
requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) {
|
||||
requestAuthorizationUsingLDAPIdentityProvider(t,
|
||||
requestAuthorizationUsingCLIPasswordFlow(t,
|
||||
downstreamAuthorizeURL,
|
||||
env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
|
||||
env.SupervisorUpstreamLDAP.TestUserPassword, // password to present to server during login
|
||||
@@ -213,7 +246,7 @@ func TestSupervisorLogin(t *testing.T) {
|
||||
requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg)
|
||||
},
|
||||
requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) {
|
||||
requestAuthorizationUsingLDAPIdentityProvider(t,
|
||||
requestAuthorizationUsingCLIPasswordFlow(t,
|
||||
downstreamAuthorizeURL,
|
||||
env.SupervisorUpstreamLDAP.TestUserCN, // username to present to server during login
|
||||
env.SupervisorUpstreamLDAP.TestUserPassword, // password to present to server during login
|
||||
@@ -495,7 +528,7 @@ func verifyTokenResponse(
|
||||
require.NotEmpty(t, tokenResponse.RefreshToken)
|
||||
}
|
||||
|
||||
func requestAuthorizationUsingOIDCIdentityProvider(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL string, httpClient *http.Client) {
|
||||
func requestAuthorizationUsingBrowserAuthcodeFlow(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL string, httpClient *http.Client) {
|
||||
t.Helper()
|
||||
env := testlib.IntegrationEnv(t)
|
||||
|
||||
@@ -524,7 +557,7 @@ func requestAuthorizationUsingOIDCIdentityProvider(t *testing.T, downstreamAutho
|
||||
browsertest.WaitForURL(t, page, callbackURLPattern)
|
||||
}
|
||||
|
||||
func requestAuthorizationUsingLDAPIdentityProvider(t *testing.T, downstreamAuthorizeURL, upstreamUsername, upstreamPassword string, httpClient *http.Client) {
|
||||
func requestAuthorizationUsingCLIPasswordFlow(t *testing.T, downstreamAuthorizeURL, upstreamUsername, upstreamPassword string, httpClient *http.Client) {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancelFunc := context.WithTimeout(context.Background(), time.Minute)
|
||||
|
||||
Reference in New Issue
Block a user