2022-01-19 21:20:49 +00:00
// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
2020-12-15 18:26:54 +00:00
// SPDX-License-Identifier: Apache-2.0
package integration
import (
"bufio"
"bytes"
"context"
2021-05-12 16:05:13 +00:00
"encoding/base32"
2020-12-15 18:26:54 +00:00
"encoding/base64"
"errors"
"fmt"
2021-05-11 20:55:46 +00:00
"io"
2020-12-15 18:26:54 +00:00
"io/ioutil"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
2021-01-11 19:58:07 +00:00
"sort"
2020-12-15 18:26:54 +00:00
"strings"
"testing"
"time"
2021-05-13 21:24:10 +00:00
"go.pinniped.dev/pkg/oidcclient/oidctypes"
2021-01-20 17:54:44 +00:00
coreosoidc "github.com/coreos/go-oidc/v3/oidc"
2021-05-11 20:55:46 +00:00
"github.com/creack/pty"
2020-12-15 18:26:54 +00:00
"github.com/stretchr/testify/require"
2021-02-23 01:23:11 +00:00
authorizationv1 "k8s.io/api/authorization/v1"
2021-01-11 19:58:07 +00:00
corev1 "k8s.io/api/core/v1"
2020-12-15 18:26:54 +00:00
rbacv1 "k8s.io/api/rbac/v1"
2021-05-12 16:05:13 +00:00
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2020-12-15 18:26:54 +00:00
2021-02-16 19:00:08 +00:00
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"
2020-12-15 18:26:54 +00:00
"go.pinniped.dev/internal/certauthority"
2021-05-12 16:05:13 +00:00
"go.pinniped.dev/internal/crud"
2021-05-11 20:55:46 +00:00
"go.pinniped.dev/internal/here"
2021-01-11 19:58:07 +00:00
"go.pinniped.dev/internal/oidc"
2020-12-15 18:26:54 +00:00
"go.pinniped.dev/internal/testutil"
2021-01-11 19:58:07 +00:00
"go.pinniped.dev/pkg/oidcclient"
"go.pinniped.dev/pkg/oidcclient/filesession"
2021-06-22 15:23:19 +00:00
"go.pinniped.dev/test/testlib"
"go.pinniped.dev/test/testlib/browsertest"
2020-12-15 18:26:54 +00:00
)
// TestE2EFullIntegration tests a full integration scenario that combines the supervisor, concierge, and CLI.
2021-08-26 23:55:43 +00:00
func TestE2EFullIntegration ( t * testing . T ) { // nolint:gocyclo
2021-06-22 15:23:19 +00:00
env := testlib . IntegrationEnv ( t )
2020-12-16 13:23:54 +00:00
2021-05-12 16:05:13 +00:00
ctx , cancelFunc := context . WithTimeout ( context . Background ( ) , 15 * time . Minute )
2020-12-15 18:26:54 +00:00
defer cancelFunc ( )
// Build pinniped CLI.
2021-06-22 15:23:19 +00:00
pinnipedExe := testlib . PinnipedCLIPath ( t )
2020-12-15 18:26:54 +00:00
tempDir := testutil . TempDir ( t )
// Infer the downstream issuer URL from the callback associated with the upstream test client registration.
2021-04-07 19:56:09 +00:00
issuerURL , err := url . Parse ( env . SupervisorUpstreamOIDC . CallbackURL )
2020-12-15 18:26:54 +00:00
require . NoError ( t , err )
require . True ( t , strings . HasSuffix ( issuerURL . Path , "/callback" ) )
issuerURL . Path = strings . TrimSuffix ( issuerURL . Path , "/callback" )
t . Logf ( "testing with downstream issuer URL %s" , issuerURL . String ( ) )
// Generate a CA bundle with which to serve this provider.
t . Logf ( "generating test CA" )
2021-03-13 00:09:16 +00:00
ca , err := certauthority . New ( "Downstream Test CA" , 1 * time . Hour )
2020-12-15 18:26:54 +00:00
require . NoError ( t , err )
// Save that bundle plus the one that signs the upstream issuer, for test purposes.
testCABundlePath := filepath . Join ( tempDir , "test-ca.pem" )
2021-04-07 19:56:09 +00:00
testCABundlePEM := [ ] byte ( string ( ca . Bundle ( ) ) + "\n" + env . SupervisorUpstreamOIDC . CABundle )
2020-12-15 18:26:54 +00:00
testCABundleBase64 := base64 . StdEncoding . EncodeToString ( testCABundlePEM )
require . NoError ( t , ioutil . WriteFile ( testCABundlePath , testCABundlePEM , 0600 ) )
// Use the CA to issue a TLS server cert.
t . Logf ( "issuing test certificate" )
2021-03-13 00:09:16 +00:00
tlsCert , err := ca . IssueServerCert ( [ ] string { issuerURL . Hostname ( ) } , nil , 1 * time . Hour )
2020-12-15 18:26:54 +00:00
require . NoError ( t , err )
certPEM , keyPEM , err := certauthority . ToPEM ( tlsCert )
require . NoError ( t , err )
// Write the serving cert to a secret.
2021-06-22 15:23:19 +00:00
certSecret := testlib . CreateTestSecret ( t ,
2020-12-15 18:26:54 +00:00
env . SupervisorNamespace ,
"oidc-provider-tls" ,
2021-01-11 19:58:07 +00:00
corev1 . SecretTypeTLS ,
2020-12-15 18:26:54 +00:00
map [ string ] string { "tls.crt" : string ( certPEM ) , "tls.key" : string ( keyPEM ) } ,
)
2020-12-16 22:27:09 +00:00
// Create the downstream FederationDomain and expect it to go into the success status condition.
2021-06-22 15:23:19 +00:00
downstream := testlib . CreateTestFederationDomain ( ctx , t ,
2020-12-15 18:26:54 +00:00
issuerURL . String ( ) ,
certSecret . Name ,
2020-12-16 22:27:09 +00:00
configv1alpha1 . SuccessFederationDomainStatusCondition ,
2020-12-15 18:26:54 +00:00
)
// Create a JWTAuthenticator that will validate the tokens from the downstream issuer.
2021-06-22 15:23:19 +00:00
clusterAudience := "test-cluster-" + testlib . RandHex ( t , 8 )
authenticator := testlib . CreateTestJWTAuthenticator ( ctx , t , authv1alpha . JWTAuthenticatorSpec {
2020-12-15 18:26:54 +00:00
Issuer : downstream . Spec . Issuer ,
Audience : clusterAudience ,
TLS : & authv1alpha . TLSSpec { CertificateAuthorityData : testCABundleBase64 } ,
} )
2021-05-11 20:55:46 +00:00
// Add an OIDC upstream IDP and try using it to authenticate during kubectl commands.
2021-07-09 03:29:15 +00:00
t . Run ( "with Supervisor OIDC upstream IDP and automatic flow" , func ( t * testing . T ) {
2021-07-13 23:20:02 +00:00
// Start a fresh browser driver because we don't want to share cookies between the various tests in this file.
page := browsertest . Open ( t )
2021-05-11 20:55:46 +00:00
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.
2021-06-22 15:23:19 +00:00
testlib . CreateTestClusterRoleBinding ( t ,
2021-05-11 20:55:46 +00:00
rbacv1 . Subject { Kind : rbacv1 . UserKind , APIGroup : rbacv1 . GroupName , Name : expectedUsername } ,
rbacv1 . RoleRef { Kind : "ClusterRole" , APIGroup : rbacv1 . GroupName , Name : "view" } ,
)
2021-06-22 15:23:19 +00:00
testlib . WaitForUserToHaveAccess ( t , expectedUsername , [ ] string { } , & authorizationv1 . ResourceAttributes {
2021-05-11 20:55:46 +00:00
Verb : "get" ,
Group : "" ,
Version : "v1" ,
Resource : "namespaces" ,
} )
// Create upstream OIDC provider and wait for it to become ready.
2021-06-22 15:23:19 +00:00
testlib . CreateTestOIDCIdentityProvider ( t , idpv1alpha1 . OIDCIdentityProviderSpec {
2021-05-11 20:55:46 +00:00
Issuer : env . SupervisorUpstreamOIDC . Issuer ,
TLS : & idpv1alpha1 . TLSSpec {
CertificateAuthorityData : base64 . StdEncoding . EncodeToString ( [ ] byte ( env . SupervisorUpstreamOIDC . CABundle ) ) ,
} ,
AuthorizationConfig : idpv1alpha1 . OIDCAuthorizationConfig {
AdditionalScopes : env . SupervisorUpstreamOIDC . AdditionalScopes ,
} ,
Claims : idpv1alpha1 . OIDCClaims {
Username : env . SupervisorUpstreamOIDC . UsernameClaim ,
Groups : env . SupervisorUpstreamOIDC . GroupsClaim ,
} ,
Client : idpv1alpha1 . OIDCClient {
2021-06-22 15:23:19 +00:00
SecretName : testlib . CreateClientCredsSecret ( t , env . SupervisorUpstreamOIDC . ClientID , env . SupervisorUpstreamOIDC . ClientSecret ) . Name ,
2021-05-11 20:55:46 +00:00
} ,
} , idpv1alpha1 . PhaseReady )
// Use a specific session cache for this test.
sessionCachePath := tempDir + "/oidc-test-sessions.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-ca-bundle" , testCABundlePath ,
"--oidc-session-cache" , sessionCachePath ,
} )
// Run "kubectl get namespaces" which should trigger a browser login via the plugin.
start := time . Now ( )
kubectlCmd := exec . CommandContext ( ctx , "kubectl" , "get" , "namespace" , "--kubeconfig" , kubeconfigPath )
kubectlCmd . Env = append ( os . Environ ( ) , env . ProxyEnv ( ) ... )
stderrPipe , err := kubectlCmd . StderrPipe ( )
require . NoError ( t , err )
stdoutPipe , err := kubectlCmd . StdoutPipe ( )
require . NoError ( t , err )
2020-12-15 18:26:54 +00:00
2021-05-11 20:55:46 +00:00
t . Logf ( "starting kubectl subprocess" )
require . NoError ( t , kubectlCmd . Start ( ) )
t . Cleanup ( func ( ) {
err := kubectlCmd . Wait ( )
t . Logf ( "kubectl subprocess exited with code %d" , kubectlCmd . ProcessState . ExitCode ( ) )
stdout , stdoutErr := ioutil . ReadAll ( stdoutPipe )
if stdoutErr != nil {
stdout = [ ] byte ( "<error reading stdout: " + stdoutErr . Error ( ) + ">" )
}
stderr , stderrErr := ioutil . ReadAll ( stderrPipe )
if stderrErr != nil {
stderr = [ ] byte ( "<error reading stderr: " + stderrErr . Error ( ) + ">" )
}
require . NoErrorf ( t , err , "kubectl process did not exit cleanly, stdout/stderr: %q/%q" , string ( stdout ) , string ( stderr ) )
} )
// Start a background goroutine to read stderr from the CLI and parse out the login URL.
loginURLChan := make ( chan string )
spawnTestGoroutine ( t , func ( ) ( err error ) {
defer func ( ) {
closeErr := stderrPipe . Close ( )
if closeErr == nil || errors . Is ( closeErr , os . ErrClosed ) {
return
}
if err == nil {
err = fmt . Errorf ( "stderr stream closed with error: %w" , closeErr )
}
} ( )
2021-06-22 15:23:19 +00:00
reader := bufio . NewReader ( testlib . NewLoggerReader ( t , "stderr" , stderrPipe ) )
2021-07-08 19:32:44 +00:00
scanner := bufio . NewScanner ( reader )
for scanner . Scan ( ) {
loginURL , err := url . Parse ( strings . TrimSpace ( scanner . Text ( ) ) )
if err == nil && loginURL . Scheme == "https" {
loginURLChan <- loginURL . String ( )
return nil
}
2021-05-11 20:55:46 +00:00
}
2021-07-08 19:32:44 +00:00
return fmt . Errorf ( "expected stderr to contain login URL" )
2021-05-11 20:55:46 +00:00
} )
// Start a background goroutine to read stdout from kubectl and return the result as a string.
kubectlOutputChan := make ( chan string )
spawnTestGoroutine ( t , func ( ) ( err error ) {
defer func ( ) {
closeErr := stdoutPipe . Close ( )
if closeErr == nil || errors . Is ( closeErr , os . ErrClosed ) {
return
}
if err == nil {
err = fmt . Errorf ( "stdout stream closed with error: %w" , closeErr )
}
} ( )
output , err := ioutil . ReadAll ( stdoutPipe )
if err != nil {
return err
}
t . Logf ( "kubectl output:\n%s\n" , output )
kubectlOutputChan <- string ( output )
return nil
} )
// Wait for the CLI to print out the login URL and open the browser to it.
t . Logf ( "waiting for CLI to output login URL" )
var loginURL string
select {
case <- time . After ( 1 * time . Minute ) :
require . Fail ( t , "timed out waiting for login URL" )
case loginURL = <- loginURLChan :
}
t . Logf ( "navigating to login page" )
require . NoError ( t , page . Navigate ( loginURL ) )
2020-12-15 18:26:54 +00:00
2021-05-11 20:55:46 +00:00
// Expect to be redirected to the upstream provider and log in.
browsertest . LoginToUpstream ( t , page , env . SupervisorUpstreamOIDC )
2021-03-03 22:49:33 +00:00
2021-06-30 19:40:16 +00:00
// Expect to be redirected to the downstream callback which is serving the form_post HTML.
t . Logf ( "waiting for response page %s" , downstream . Spec . Issuer )
browsertest . WaitForURL ( t , page , regexp . MustCompile ( regexp . QuoteMeta ( downstream . Spec . Issuer ) ) )
2020-12-15 18:26:54 +00:00
2021-06-30 19:40:16 +00:00
// 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 , page )
2021-05-11 20:55:46 +00:00
2021-07-20 00:08:52 +00:00
// Expect the CLI to output a list of namespaces.
t . Logf ( "waiting for kubectl to output namespace list" )
2021-05-11 20:55:46 +00:00
var kubectlOutput string
select {
case <- time . After ( 10 * time . Second ) :
require . Fail ( t , "timed out waiting for kubectl output" )
case kubectlOutput = <- kubectlOutputChan :
2020-12-16 13:23:54 +00:00
}
2021-07-20 00:08:52 +00:00
requireKubectlGetNamespaceOutput ( t , env , kubectlOutput )
2021-05-11 20:55:46 +00:00
t . Logf ( "first kubectl command took %s" , time . Since ( start ) . String ( ) )
requireUserCanUseKubectlWithoutAuthenticatingAgain ( ctx , t , env ,
downstream ,
kubeconfigPath ,
sessionCachePath ,
pinnipedExe ,
expectedUsername ,
expectedGroups ,
)
2020-12-15 18:26:54 +00:00
} )
2021-08-12 17:00:18 +00:00
t . Run ( "with Supervisor OIDC upstream IDP and manual authcode copy-paste from browser flow" , func ( t * testing . T ) {
2021-07-13 23:20:02 +00:00
// Start a fresh browser driver because we don't want to share cookies between the various tests in this file.
page := browsertest . Open ( t )
2021-07-09 03:29:15 +00:00
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" ,
} )
2022-01-19 21:20:49 +00:00
var additionalScopes [ ] string
// If we're using dex, we will test that we see a warning when the access token
// lifetime is too short (we have it set to 20 minutes) and it's using access token based refresh.
// To ensure that access token refresh happens rather than refresh token, don't ask for the offline_access scope.
// In other environments, test the refresh token based flow.
if len ( env . ToolsNamespace ) == 0 {
additionalScopes = env . SupervisorUpstreamOIDC . AdditionalScopes
} else {
for _ , additionalScope := range env . SupervisorUpstreamOIDC . AdditionalScopes {
if additionalScope != "offline_access" {
additionalScopes = append ( additionalScopes , additionalScope )
}
}
}
2021-07-09 03:29:15 +00:00
// 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 {
2022-01-19 21:20:49 +00:00
AdditionalScopes : additionalScopes ,
2021-07-09 03:29:15 +00:00
} ,
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-manual.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" ,
"--oidc-ca-bundle" , testCABundlePath ,
"--oidc-session-cache" , sessionCachePath ,
} )
// Run "kubectl get namespaces" which should trigger a browser 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 login prompt.
t . Logf ( "waiting for CLI to output login URL and manual prompt" )
2021-07-29 22:49:16 +00:00
output := readFromFileUntilStringIsSeen ( t , ptyFile , "Optionally, paste your authorization code: " )
2021-07-09 03:29:15 +00:00
require . Contains ( t , output , "Log in by visiting this link:" )
2021-07-29 22:49:16 +00:00
require . Contains ( t , output , "Optionally, paste your authorization code: " )
2021-07-09 03:29:15 +00:00
// Find the line with the login URL.
var loginURL string
for _ , line := range strings . Split ( output , "\n" ) {
trimmed := strings . TrimSpace ( line )
if strings . HasPrefix ( trimmed , "https://" ) {
loginURL = trimmed
}
}
require . NotEmptyf ( t , loginURL , "didn't find login URL in output: %s" , output )
t . Logf ( "navigating to login page" )
require . NoError ( t , page . Navigate ( loginURL ) )
// Expect to be redirected to the upstream provider and log in.
browsertest . LoginToUpstream ( t , page , env . SupervisorUpstreamOIDC )
// Expect to be redirected to the downstream callback which is serving the form_post HTML.
t . Logf ( "waiting for response page %s" , downstream . Spec . Issuer )
browsertest . WaitForURL ( t , page , regexp . MustCompile ( regexp . QuoteMeta ( downstream . Spec . Issuer ) ) )
// The response page should have failed to automatically post, and should now be showing the manual instructions.
authCode := formpostExpectManualState ( t , page )
// Enter the auth code in the waiting prompt, followed by a newline.
t . Logf ( "'manually' pasting authorization code %q to waiting prompt" , authCode )
_ , err = ptyFile . WriteString ( authCode + "\n" )
require . NoError ( t , err )
// Read all of the remaining output from the subprocess until EOF.
t . Logf ( "waiting for kubectl to output namespace list" )
2021-08-12 17:00:18 +00:00
// Read all output from the subprocess until EOF.
2021-07-09 03:29:15 +00:00
// Ignore any errors returned because there is always an error on linux.
2021-07-20 00:08:52 +00:00
kubectlOutputBytes , _ := ioutil . ReadAll ( ptyFile )
requireKubectlGetNamespaceOutput ( t , env , string ( kubectlOutputBytes ) )
2022-01-19 21:20:49 +00:00
if len ( env . ToolsNamespace ) > 0 {
require . Contains ( t , string ( kubectlOutputBytes ) , "Access token from identity provider has lifetime of less than 3 hours. Expect frequent prompts to log in." )
}
2021-07-20 00:08:52 +00:00
2021-07-09 03:29:15 +00:00
t . Logf ( "first kubectl command took %s" , time . Since ( start ) . String ( ) )
requireUserCanUseKubectlWithoutAuthenticatingAgain ( ctx , t , env ,
downstream ,
kubeconfigPath ,
sessionCachePath ,
pinnipedExe ,
expectedUsername ,
expectedGroups ,
)
} )
2021-08-12 17:00:18 +00:00
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. ` +
2021-08-16 22:40:34 +00:00
` Resource owner password credentials grant is not allowed for this upstream provider according to its configuration. ` ,
2021-08-12 17:00:18 +00:00
)
} )
2021-07-20 00:08:52 +00:00
// 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 ) {
2021-06-22 15:23:19 +00:00
if len ( env . ToolsNamespace ) == 0 && ! env . HasCapability ( testlib . CanReachInternetLDAPPorts ) {
2021-05-28 23:12:57 +00:00
t . Skip ( "LDAP integration test requires connectivity to an LDAP server" )
}
2021-05-11 20:55:46 +00:00
expectedUsername := env . SupervisorUpstreamLDAP . TestUserMailAttributeValue
2021-05-28 20:27:11 +00:00
expectedGroups := env . SupervisorUpstreamLDAP . TestUserDirectGroupsDNs
2021-05-11 20:55:46 +00:00
2021-07-20 00:08:52 +00:00
setupClusterForEndToEndLDAPTest ( t , expectedUsername , env )
2021-05-11 20:55:46 +00:00
// Use a specific session cache for this test.
sessionCachePath := tempDir + "/ldap-test-sessions.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-session-cache" , sessionCachePath ,
} )
// Run "kubectl get namespaces" which should trigger an LDAP-style login CLI prompt 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 )
2020-12-15 18:26:54 +00:00
2021-05-11 20:55:46 +00:00
// 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 )
2020-12-15 18:26:54 +00:00
2021-05-11 20:55:46 +00:00
// 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 )
2021-08-12 17:00:18 +00:00
// Read all output from the subprocess until EOF.
2021-07-20 00:08:52 +00:00
// 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 ,
)
} )
// Add an LDAP upstream IDP and try using it to authenticate during kubectl commands
// by passing username and password via environment variables, thus avoiding the CLI's username and password prompts.
t . Run ( "with Supervisor LDAP upstream IDP using PINNIPED_USERNAME and PINNIPED_PASSWORD env vars" , func ( t * testing . T ) {
if len ( env . ToolsNamespace ) == 0 && ! env . HasCapability ( testlib . CanReachInternetLDAPPorts ) {
t . Skip ( "LDAP integration test requires connectivity to an LDAP server" )
}
expectedUsername := env . SupervisorUpstreamLDAP . TestUserMailAttributeValue
expectedGroups := env . SupervisorUpstreamLDAP . TestUserDirectGroupsDNs
setupClusterForEndToEndLDAPTest ( t , expectedUsername , env )
// Use a specific session cache for this test.
sessionCachePath := tempDir + "/ldap-test-with-env-vars-sessions.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-session-cache" , sessionCachePath ,
} )
// Set up the username and password env vars to avoid the interactive prompts.
const usernameEnvVar = "PINNIPED_USERNAME"
originalUsername , hadOriginalUsername := os . LookupEnv ( usernameEnvVar )
t . Cleanup ( func ( ) {
if hadOriginalUsername {
require . NoError ( t , os . Setenv ( usernameEnvVar , originalUsername ) )
}
} )
require . NoError ( t , os . Setenv ( usernameEnvVar , expectedUsername ) )
2021-07-27 00:20:49 +00:00
const passwordEnvVar = "PINNIPED_PASSWORD" //nolint:gosec // this is not a credential
2021-07-20 00:08:52 +00:00
originalPassword , hadOriginalPassword := os . LookupEnv ( passwordEnvVar )
t . Cleanup ( func ( ) {
if hadOriginalPassword {
require . NoError ( t , os . Setenv ( passwordEnvVar , originalPassword ) )
}
} )
require . NoError ( t , os . Setenv ( passwordEnvVar , env . SupervisorUpstreamLDAP . TestUserPassword ) )
// Run "kubectl get namespaces" which should run an LDAP-style login without interactive prompts for username and password.
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 )
2021-08-12 17:00:18 +00:00
// Read all output from the subprocess until EOF.
2021-05-13 23:02:24 +00:00
// Ignore any errors returned because there is always an error on linux.
2021-07-20 00:08:52 +00:00
kubectlOutputBytes , _ := ioutil . ReadAll ( ptyFile )
requireKubectlGetNamespaceOutput ( t , env , string ( kubectlOutputBytes ) )
2021-05-11 20:55:46 +00:00
t . Logf ( "first kubectl command took %s" , time . Since ( start ) . String ( ) )
2021-07-20 00:08:52 +00:00
// The next kubectl command should not require auth, so we should be able to run it without these env vars.
require . NoError ( t , os . Unsetenv ( usernameEnvVar ) )
require . NoError ( t , os . Unsetenv ( passwordEnvVar ) )
2021-05-11 20:55:46 +00:00
requireUserCanUseKubectlWithoutAuthenticatingAgain ( ctx , t , env ,
downstream ,
kubeconfigPath ,
sessionCachePath ,
pinnipedExe ,
expectedUsername ,
expectedGroups ,
)
2020-12-15 18:26:54 +00:00
} )
2021-08-26 23:18:05 +00:00
// Add an Active Directory 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 ActiveDirectory upstream IDP using username and password prompts" , func ( t * testing . T ) {
if len ( env . ToolsNamespace ) == 0 && ! env . HasCapability ( testlib . CanReachInternetLDAPPorts ) {
t . Skip ( "Active Directory integration test requires connectivity to an LDAP server" )
}
if env . SupervisorUpstreamActiveDirectory . Host == "" {
t . Skip ( "Active Directory hostname not specified" )
}
expectedUsername := env . SupervisorUpstreamActiveDirectory . TestUserPrincipalNameValue
expectedGroups := env . SupervisorUpstreamActiveDirectory . TestUserIndirectGroupsSAMAccountPlusDomainNames
setupClusterForEndToEndActiveDirectoryTest ( t , expectedUsername , env )
// Use a specific session cache for this test.
sessionCachePath := tempDir + "/ad-test-sessions.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-session-cache" , sessionCachePath ,
} )
// Run "kubectl get namespaces" which should trigger an LDAP-style login CLI prompt 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 . SupervisorUpstreamActiveDirectory . 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 , _ := 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 ,
)
} )
// Add an ActiveDirectory upstream IDP and try using it to authenticate during kubectl commands
// by passing username and password via environment variables, thus avoiding the CLI's username and password prompts.
t . Run ( "with Supervisor ActiveDirectory upstream IDP using PINNIPED_USERNAME and PINNIPED_PASSWORD env vars" , func ( t * testing . T ) {
if len ( env . ToolsNamespace ) == 0 && ! env . HasCapability ( testlib . CanReachInternetLDAPPorts ) {
t . Skip ( "ActiveDirectory integration test requires connectivity to an LDAP server" )
}
if env . SupervisorUpstreamActiveDirectory . Host == "" {
t . Skip ( "Active Directory hostname not specified" )
}
expectedUsername := env . SupervisorUpstreamActiveDirectory . TestUserPrincipalNameValue
expectedGroups := env . SupervisorUpstreamActiveDirectory . TestUserIndirectGroupsSAMAccountPlusDomainNames
setupClusterForEndToEndActiveDirectoryTest ( t , expectedUsername , env )
// Use a specific session cache for this test.
sessionCachePath := tempDir + "/ad-test-with-env-vars-sessions.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-session-cache" , sessionCachePath ,
} )
// Set up the username and password env vars to avoid the interactive prompts.
const usernameEnvVar = "PINNIPED_USERNAME"
originalUsername , hadOriginalUsername := os . LookupEnv ( usernameEnvVar )
t . Cleanup ( func ( ) {
if hadOriginalUsername {
require . NoError ( t , os . Setenv ( usernameEnvVar , originalUsername ) )
}
} )
require . NoError ( t , os . Setenv ( usernameEnvVar , expectedUsername ) )
const passwordEnvVar = "PINNIPED_PASSWORD" //nolint:gosec // this is not a credential
originalPassword , hadOriginalPassword := os . LookupEnv ( passwordEnvVar )
t . Cleanup ( func ( ) {
if hadOriginalPassword {
require . NoError ( t , os . Setenv ( passwordEnvVar , originalPassword ) )
}
} )
require . NoError ( t , os . Setenv ( passwordEnvVar , env . SupervisorUpstreamActiveDirectory . TestUserPassword ) )
// Run "kubectl get namespaces" which should run an LDAP-style login without interactive prompts for username and password.
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 )
// 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 ( ) )
// The next kubectl command should not require auth, so we should be able to run it without these env vars.
require . NoError ( t , os . Unsetenv ( usernameEnvVar ) )
require . NoError ( t , os . Unsetenv ( passwordEnvVar ) )
requireUserCanUseKubectlWithoutAuthenticatingAgain ( ctx , t , env ,
downstream ,
kubeconfigPath ,
sessionCachePath ,
pinnipedExe ,
expectedUsername ,
expectedGroups ,
)
} )
2021-05-11 20:55:46 +00:00
}
2020-12-15 18:26:54 +00:00
2021-07-20 00:08:52 +00:00
func setupClusterForEndToEndLDAPTest ( t * testing . T , username string , env * testlib . TestEnv ) {
// 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 : username } ,
rbacv1 . RoleRef { Kind : "ClusterRole" , APIGroup : rbacv1 . GroupName , Name : "view" } ,
)
testlib . WaitForUserToHaveAccess ( t , username , [ ] string { } , & authorizationv1 . ResourceAttributes {
Verb : "get" ,
Group : "" ,
Version : "v1" ,
Resource : "namespaces" ,
} )
// Put the bind service account's info into a Secret.
bindSecret := testlib . CreateTestSecret ( t , env . SupervisorNamespace , "ldap-service-account" , corev1 . SecretTypeBasicAuth ,
map [ string ] string {
corev1 . BasicAuthUsernameKey : env . SupervisorUpstreamLDAP . BindUsername ,
corev1 . BasicAuthPasswordKey : env . SupervisorUpstreamLDAP . BindPassword ,
} ,
)
// Create upstream LDAP provider and wait for it to become ready.
testlib . CreateTestLDAPIdentityProvider ( t , idpv1alpha1 . LDAPIdentityProviderSpec {
Host : env . SupervisorUpstreamLDAP . Host ,
TLS : & idpv1alpha1 . TLSSpec {
CertificateAuthorityData : base64 . StdEncoding . EncodeToString ( [ ] byte ( env . SupervisorUpstreamLDAP . CABundle ) ) ,
} ,
Bind : idpv1alpha1 . LDAPIdentityProviderBind {
SecretName : bindSecret . Name ,
} ,
UserSearch : idpv1alpha1 . LDAPIdentityProviderUserSearch {
Base : env . SupervisorUpstreamLDAP . UserSearchBase ,
Filter : "" ,
Attributes : idpv1alpha1 . LDAPIdentityProviderUserSearchAttributes {
Username : env . SupervisorUpstreamLDAP . TestUserMailAttributeName ,
UID : env . SupervisorUpstreamLDAP . TestUserUniqueIDAttributeName ,
} ,
} ,
GroupSearch : idpv1alpha1 . LDAPIdentityProviderGroupSearch {
Base : env . SupervisorUpstreamLDAP . GroupSearchBase ,
Filter : "" , // use the default value of "member={}"
Attributes : idpv1alpha1 . LDAPIdentityProviderGroupSearchAttributes {
GroupName : "" , // use the default value of "dn"
} ,
} ,
} , idpv1alpha1 . LDAPPhaseReady )
}
2021-08-26 23:18:05 +00:00
func setupClusterForEndToEndActiveDirectoryTest ( t * testing . T , username string , env * testlib . TestEnv ) {
// 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 : username } ,
rbacv1 . RoleRef { Kind : "ClusterRole" , APIGroup : rbacv1 . GroupName , Name : "view" } ,
)
testlib . WaitForUserToHaveAccess ( t , username , [ ] string { } , & authorizationv1 . ResourceAttributes {
Verb : "get" ,
Group : "" ,
Version : "v1" ,
Resource : "namespaces" ,
} )
// Put the bind service account's info into a Secret.
bindSecret := testlib . CreateTestSecret ( t , env . SupervisorNamespace , "ldap-service-account" , corev1 . SecretTypeBasicAuth ,
map [ string ] string {
corev1 . BasicAuthUsernameKey : env . SupervisorUpstreamActiveDirectory . BindUsername ,
corev1 . BasicAuthPasswordKey : env . SupervisorUpstreamActiveDirectory . BindPassword ,
} ,
)
// Create upstream LDAP provider and wait for it to become ready.
testlib . CreateTestActiveDirectoryIdentityProvider ( t , idpv1alpha1 . ActiveDirectoryIdentityProviderSpec {
Host : env . SupervisorUpstreamActiveDirectory . Host ,
TLS : & idpv1alpha1 . TLSSpec {
CertificateAuthorityData : base64 . StdEncoding . EncodeToString ( [ ] byte ( env . SupervisorUpstreamActiveDirectory . CABundle ) ) ,
} ,
Bind : idpv1alpha1 . ActiveDirectoryIdentityProviderBind {
SecretName : bindSecret . Name ,
} ,
} , idpv1alpha1 . ActiveDirectoryPhaseReady )
}
2021-07-09 03:29:15 +00:00
func readFromFileUntilStringIsSeen ( t * testing . T , f * os . File , until string ) string {
2021-05-11 20:55:46 +00:00
readFromFile := ""
2020-12-15 18:26:54 +00:00
2021-06-22 15:23:19 +00:00
testlib . RequireEventuallyWithoutError ( t , func ( ) ( bool , error ) {
2021-05-11 20:55:46 +00:00
someOutput , foundEOF := readAvailableOutput ( t , f )
readFromFile += someOutput
if strings . Contains ( readFromFile , until ) {
return true , nil // found it! finished.
}
if foundEOF {
return false , fmt . Errorf ( "reached EOF of subcommand's output without seeing expected string %q" , until )
}
return false , nil // keep waiting and reading
} , 1 * time . Minute , 1 * time . Second )
2021-07-09 03:29:15 +00:00
return readFromFile
2021-05-11 20:55:46 +00:00
}
2020-12-15 18:26:54 +00:00
2021-05-11 20:55:46 +00:00
func readAvailableOutput ( t * testing . T , r io . Reader ) ( string , bool ) {
buf := make ( [ ] byte , 1024 )
n , err := r . Read ( buf )
if err != nil {
if err == io . EOF {
return string ( buf [ : n ] ) , true
}
2021-05-12 18:24:00 +00:00
require . NoError ( t , err )
2020-12-15 18:26:54 +00:00
}
2021-05-11 20:55:46 +00:00
return string ( buf [ : n ] ) , false
}
2021-07-20 00:08:52 +00:00
func requireKubectlGetNamespaceOutput ( t * testing . T , env * testlib . TestEnv , kubectlOutput string ) {
t . Log ( "kubectl command output:\n" , kubectlOutput )
require . Greaterf ( t , len ( kubectlOutput ) , 0 , "expected to get some more output from the kubectl subcommand, but did not" )
// Should look generally like a list of namespaces, with one namespace listed per line in a table format.
require . Greaterf ( t , len ( strings . Split ( kubectlOutput , "\n" ) ) , 2 , "expected some namespaces to be returned, got %q" , kubectlOutput )
require . Contains ( t , kubectlOutput , fmt . Sprintf ( "\n%s " , env . ConciergeNamespace ) )
require . Contains ( t , kubectlOutput , fmt . Sprintf ( "\n%s " , env . SupervisorNamespace ) )
2021-07-28 17:40:07 +00:00
if len ( env . ToolsNamespace ) > 0 {
2021-07-20 00:08:52 +00:00
require . Contains ( t , kubectlOutput , fmt . Sprintf ( "\n%s " , env . ToolsNamespace ) )
}
}
2021-05-11 20:55:46 +00:00
func requireUserCanUseKubectlWithoutAuthenticatingAgain (
ctx context . Context ,
t * testing . T ,
2021-06-22 15:23:19 +00:00
env * testlib . TestEnv ,
2021-05-11 20:55:46 +00:00
downstream * configv1alpha1 . FederationDomain ,
kubeconfigPath string ,
sessionCachePath string ,
pinnipedExe string ,
expectedUsername string ,
expectedGroups [ ] string ,
) {
// Run kubectl, which should work without any prompting for authentication.
kubectlCmd := exec . CommandContext ( ctx , "kubectl" , "get" , "namespace" , "--kubeconfig" , kubeconfigPath )
kubectlCmd . Env = append ( os . Environ ( ) , env . ProxyEnv ( ) ... )
startTime := time . Now ( )
kubectlOutput2 , err := kubectlCmd . CombinedOutput ( )
2020-12-15 18:26:54 +00:00
require . NoError ( t , err )
require . Greaterf ( t , len ( bytes . Split ( kubectlOutput2 , [ ] byte ( "\n" ) ) ) , 2 , "expected some namespaces to be returned again" )
2021-05-11 20:55:46 +00:00
t . Logf ( "second kubectl command took %s" , time . Since ( startTime ) . String ( ) )
2021-01-11 19:58:07 +00:00
2021-05-11 20:55:46 +00:00
// Probe our cache for the current ID token as a proxy for a whoami API.
2021-01-11 19:58:07 +00:00
cache := filesession . New ( sessionCachePath , filesession . WithErrorReporter ( func ( err error ) {
require . NoError ( t , err )
} ) )
downstreamScopes := [ ] string { coreosoidc . ScopeOfflineAccess , coreosoidc . ScopeOpenID , "pinniped:request-audience" }
sort . Strings ( downstreamScopes )
token := cache . GetToken ( oidcclient . SessionCacheKey {
Issuer : downstream . Spec . Issuer ,
ClientID : "pinniped-cli" ,
Scopes : downstreamScopes ,
RedirectURI : "http://localhost:0/callback" ,
} )
require . NotNil ( t , token )
2021-05-13 21:24:10 +00:00
requireGCAnnotationsOnSessionStorage ( ctx , t , env . SupervisorNamespace , startTime , token )
2021-01-11 19:58:07 +00:00
idTokenClaims := token . IDToken . Claims
2021-05-11 20:55:46 +00:00
require . Equal ( t , expectedUsername , idTokenClaims [ oidc . DownstreamUsernameClaim ] )
2021-01-15 02:46:56 +00:00
// The groups claim in the file ends up as an []interface{}, so adjust our expectation to match.
2021-05-11 20:55:46 +00:00
expectedGroupsAsEmptyInterfaces := make ( [ ] interface { } , 0 , len ( expectedGroups ) )
for _ , g := range expectedGroups {
expectedGroupsAsEmptyInterfaces = append ( expectedGroupsAsEmptyInterfaces , g )
2021-01-11 19:58:07 +00:00
}
2021-05-17 18:10:26 +00:00
require . ElementsMatch ( t , expectedGroupsAsEmptyInterfaces , idTokenClaims [ oidc . DownstreamGroupsClaim ] )
expectedGroupsPlusAuthenticated := append ( [ ] string { } , expectedGroups ... )
expectedGroupsPlusAuthenticated = append ( expectedGroupsPlusAuthenticated , "system:authenticated" )
2021-05-11 20:55:46 +00:00
// Confirm we are the right user according to Kube by calling the whoami API.
2021-02-19 18:21:10 +00:00
kubectlCmd3 := exec . CommandContext ( ctx , "kubectl" , "create" , "-f" , "-" , "-o" , "yaml" , "--kubeconfig" , kubeconfigPath )
kubectlCmd3 . Env = append ( os . Environ ( ) , env . ProxyEnv ( ) ... )
2021-05-11 20:55:46 +00:00
kubectlCmd3 . Stdin = strings . NewReader ( here . Docf ( `
apiVersion : identity . concierge . % s / v1alpha1
kind : WhoAmIRequest
2021-05-17 18:10:26 +00:00
` , env . APIGroupSuffix ) )
2021-05-11 20:55:46 +00:00
2021-02-19 18:21:10 +00:00
kubectlOutput3 , err := kubectlCmd3 . CombinedOutput ( )
require . NoError ( t , err )
2021-05-11 20:55:46 +00:00
2021-05-17 18:10:26 +00:00
whoAmI := deserializeWhoAmIRequest ( t , string ( kubectlOutput3 ) , env . APIGroupSuffix )
require . Equal ( t , expectedUsername , whoAmI . Status . KubernetesUserInfo . User . Username )
require . ElementsMatch ( t , expectedGroupsPlusAuthenticated , whoAmI . Status . KubernetesUserInfo . User . Groups )
2021-03-04 19:46:18 +00:00
// Validate that `pinniped whoami` returns the correct identity.
assertWhoami (
ctx ,
t ,
true ,
pinnipedExe ,
kubeconfigPath ,
2021-05-11 20:55:46 +00:00
expectedUsername ,
2021-05-11 18:09:37 +00:00
expectedGroupsPlusAuthenticated ,
2021-03-04 19:46:18 +00:00
)
2020-12-15 18:26:54 +00:00
}
2021-05-11 20:55:46 +00:00
2021-05-13 21:24:10 +00:00
func requireGCAnnotationsOnSessionStorage ( ctx context . Context , t * testing . T , supervisorNamespace string , startTime time . Time , token * oidctypes . Token ) {
2021-05-12 16:05:13 +00:00
// check that the access token is new (since it's just been refreshed) and has close to two minutes left.
2021-05-13 21:24:10 +00:00
testutil . RequireTimeInDelta ( t , startTime . Add ( 2 * time . Minute ) , token . AccessToken . Expiry . Time , 15 * time . Second )
2021-05-12 16:05:13 +00:00
2021-06-22 15:23:19 +00:00
kubeClient := testlib . NewKubernetesClientset ( t ) . CoreV1 ( )
2021-05-12 16:05:13 +00:00
// get the access token secret that matches the signature from the cache
accessTokenSignature := strings . Split ( token . AccessToken . Token , "." ) [ 1 ]
accessSecretName := getSecretNameFromSignature ( t , accessTokenSignature , "access-token" )
2021-05-13 21:24:10 +00:00
accessTokenSecret , err := kubeClient . Secrets ( supervisorNamespace ) . Get ( ctx , accessSecretName , metav1 . GetOptions { } )
2021-05-12 16:05:13 +00:00
require . NoError ( t , err )
// Check that the access token garbage-collect-after value is 9 hours from now
accessTokenGCTimeString := accessTokenSecret . Annotations [ "storage.pinniped.dev/garbage-collect-after" ]
accessTokenGCTime , err := time . Parse ( crud . SecretLifetimeAnnotationDateFormat , accessTokenGCTimeString )
require . NoError ( t , err )
require . True ( t , accessTokenGCTime . After ( time . Now ( ) . Add ( 9 * time . Hour ) ) )
// get the refresh token secret that matches the signature from the cache
refreshTokenSignature := strings . Split ( token . RefreshToken . Token , "." ) [ 1 ]
refreshSecretName := getSecretNameFromSignature ( t , refreshTokenSignature , "refresh-token" )
2021-05-13 21:24:10 +00:00
refreshTokenSecret , err := kubeClient . Secrets ( supervisorNamespace ) . Get ( ctx , refreshSecretName , metav1 . GetOptions { } )
2021-05-12 16:05:13 +00:00
require . NoError ( t , err )
// Check that the refresh token garbage-collect-after value is 9 hours
refreshTokenGCTimeString := refreshTokenSecret . Annotations [ "storage.pinniped.dev/garbage-collect-after" ]
refreshTokenGCTime , err := time . Parse ( crud . SecretLifetimeAnnotationDateFormat , refreshTokenGCTimeString )
require . NoError ( t , err )
require . True ( t , refreshTokenGCTime . After ( time . Now ( ) . Add ( 9 * time . Hour ) ) )
// the access token and the refresh token should be garbage collected at essentially the same time
testutil . RequireTimeInDelta ( t , accessTokenGCTime , refreshTokenGCTime , 1 * time . Minute )
2021-05-13 21:24:10 +00:00
}
2021-05-12 16:05:13 +00:00
2021-06-22 15:23:19 +00:00
func runPinnipedGetKubeconfig ( t * testing . T , env * testlib . TestEnv , pinnipedExe string , tempDir string , pinnipedCLICommand [ ] string ) string {
2021-05-11 20:55:46 +00:00
// Run "pinniped get kubeconfig" to get a kubeconfig YAML.
envVarsWithProxy := append ( os . Environ ( ) , env . ProxyEnv ( ) ... )
kubeconfigYAML , stderr := runPinnipedCLI ( t , envVarsWithProxy , pinnipedExe , pinnipedCLICommand ... )
t . Logf ( "stderr output from 'pinniped get kubeconfig':\n%s\n\n" , stderr )
t . Logf ( "test kubeconfig:\n%s\n\n" , kubeconfigYAML )
2021-06-22 15:23:19 +00:00
restConfig := testlib . NewRestConfigFromKubeconfig ( t , kubeconfigYAML )
2021-05-11 20:55:46 +00:00
require . NotNil ( t , restConfig . ExecProvider )
require . Equal ( t , [ ] string { "login" , "oidc" } , restConfig . ExecProvider . Args [ : 2 ] )
kubeconfigPath := filepath . Join ( tempDir , "kubeconfig.yaml" )
require . NoError ( t , ioutil . WriteFile ( kubeconfigPath , [ ] byte ( kubeconfigYAML ) , 0600 ) )
return kubeconfigPath
}
2021-05-12 16:05:13 +00:00
func getSecretNameFromSignature ( t * testing . T , signature string , typeLabel string ) string {
t . Helper ( )
// try to decode base64 signatures to prevent double encoding of binary data
signatureBytes , err := base64 . RawURLEncoding . DecodeString ( signature )
require . NoError ( t , err )
// lower case base32 encoding insures that our secret name is valid per ValidateSecretName in k/k
var b32 = base32 . StdEncoding . WithPadding ( base32 . NoPadding )
signatureAsValidName := strings . ToLower ( b32 . EncodeToString ( signatureBytes ) )
return fmt . Sprintf ( "pinniped-storage-%s-%s" , typeLabel , signatureAsValidName )
}