diff --git a/test/integration/supervisor_login_test.go b/test/integration/supervisor_login_test.go index 2da945b2..d820879f 100644 --- a/test/integration/supervisor_login_test.go +++ b/test/integration/supervisor_login_test.go @@ -47,6 +47,8 @@ func TestSupervisorLogin(t *testing.T) { wantDownstreamIDTokenSubjectToMatch string wantDownstreamIDTokenUsernameToMatch string wantDownstreamIDTokenGroups []string + wantErrorDescription string + wantErrorType string }{ { name: "oidc with default username and groups claim settings", @@ -153,6 +155,7 @@ func TestSupervisorLogin(t *testing.T) { env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login env.SupervisorUpstreamLDAP.TestUserPassword, // password to present to server during login httpClient, + false, ) }, // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute @@ -218,6 +221,7 @@ func TestSupervisorLogin(t *testing.T) { env.SupervisorUpstreamLDAP.TestUserCN, // username to present to server during login env.SupervisorUpstreamLDAP.TestUserPassword, // password to present to server during login httpClient, + false, ) }, // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute @@ -271,6 +275,7 @@ func TestSupervisorLogin(t *testing.T) { env.SupervisorUpstreamActiveDirectory.TestUserSAMAccountNameValue, // username to present to server during login env.SupervisorUpstreamActiveDirectory.TestUserPassword, // password to present to server during login httpClient, + false, ) }, // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute @@ -283,6 +288,53 @@ func TestSupervisorLogin(t *testing.T) { wantDownstreamIDTokenUsernameToMatch: regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserSAMAccountNameValue), wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountNames, }, + { + name: "logging in to activedirectory with a deactivated user fails", + maybeSkip: func(t *testing.T) { + t.Helper() + if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) { + t.Skip("LDAP integration test requires connectivity to an LDAP server") + } + if env.SupervisorUpstreamActiveDirectory.Host == "" { + t.Skip("Active Directory hostname not specified") + } + }, + createIDP: func(t *testing.T) { + t.Helper() + secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ad-service-account", v1.SecretTypeBasicAuth, + map[string]string{ + v1.BasicAuthUsernameKey: env.SupervisorUpstreamActiveDirectory.BindUsername, + v1.BasicAuthPasswordKey: env.SupervisorUpstreamActiveDirectory.BindPassword, + }, + ) + adIDP := testlib.CreateTestActiveDirectoryIdentityProvider(t, idpv1alpha1.ActiveDirectoryIdentityProviderSpec{ + Host: env.SupervisorUpstreamActiveDirectory.Host, + TLS: &idpv1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamActiveDirectory.CABundle)), + }, + Bind: idpv1alpha1.ActiveDirectoryIdentityProviderBind{ + SecretName: secret.Name, + }, + }, idpv1alpha1.ActiveDirectoryPhaseReady) + expectedMsg := fmt.Sprintf( + `successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`, + env.SupervisorUpstreamActiveDirectory.Host, env.SupervisorUpstreamActiveDirectory.BindUsername, + secret.Name, secret.ResourceVersion, + ) + requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg) + }, + requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { + requestAuthorizationUsingLDAPIdentityProvider(t, + downstreamAuthorizeURL, + env.SupervisorUpstreamActiveDirectory.TestDeactivatedUserSAMAccountNameValue, // username to present to server during login + env.SupervisorUpstreamActiveDirectory.TestDeactivatedUserPassword, // password to present to server during login + httpClient, + true, + ) + }, + wantErrorDescription: "The resource owner or authorization server denied the request. Username/password not accepted by LDAP provider.", + wantErrorType: "access_denied", + }, } for _, test := range tests { tt := test @@ -295,6 +347,7 @@ func TestSupervisorLogin(t *testing.T) { tt.wantDownstreamIDTokenSubjectToMatch, tt.wantDownstreamIDTokenUsernameToMatch, tt.wantDownstreamIDTokenGroups, + tt.wantErrorDescription, tt.wantErrorType, ) }) } @@ -355,6 +408,7 @@ func testSupervisorLogin( createIDP func(t *testing.T), requestAuthorization func(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL string, httpClient *http.Client), wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch string, wantDownstreamIDTokenGroups []string, + wantErrorDescription string, wantErrorType string, ) { env := testlib.IntegrationEnv(t) @@ -482,40 +536,47 @@ func testSupervisorLogin( // Expect that our callback handler was invoked. callback := localCallbackServer.waitForCallback(10 * time.Second) t.Logf("got callback request: %s", testlib.MaskTokens(callback.URL.String())) - require.Equal(t, stateParam.String(), callback.URL.Query().Get("state")) - require.ElementsMatch(t, []string{"openid", "pinniped:request-audience", "offline_access"}, strings.Split(callback.URL.Query().Get("scope"), " ")) - authcode := callback.URL.Query().Get("code") - require.NotEmpty(t, authcode) + if wantErrorType == "" { + require.Equal(t, stateParam.String(), callback.URL.Query().Get("state")) + require.ElementsMatch(t, []string{"openid", "pinniped:request-audience", "offline_access"}, strings.Split(callback.URL.Query().Get("scope"), " ")) + authcode := callback.URL.Query().Get("code") + require.NotEmpty(t, authcode) - // Call the token endpoint to get tokens. - tokenResponse, err := downstreamOAuth2Config.Exchange(oidcHTTPClientContext, authcode, pkceParam.Verifier()) - require.NoError(t, err) + // Call the token endpoint to get tokens. + tokenResponse, err := downstreamOAuth2Config.Exchange(oidcHTTPClientContext, authcode, pkceParam.Verifier()) + require.NoError(t, err) - expectedIDTokenClaims := []string{"iss", "exp", "sub", "aud", "auth_time", "iat", "jti", "nonce", "rat", "username", "groups"} - verifyTokenResponse(t, - tokenResponse, discovery, downstreamOAuth2Config, nonceParam, - expectedIDTokenClaims, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch, wantDownstreamIDTokenGroups) + expectedIDTokenClaims := []string{"iss", "exp", "sub", "aud", "auth_time", "iat", "jti", "nonce", "rat", "username", "groups"} + verifyTokenResponse(t, + tokenResponse, discovery, downstreamOAuth2Config, nonceParam, + expectedIDTokenClaims, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch, wantDownstreamIDTokenGroups) - // token exchange on the original token - doTokenExchange(t, &downstreamOAuth2Config, tokenResponse, httpClient, discovery) + // token exchange on the original token + doTokenExchange(t, &downstreamOAuth2Config, tokenResponse, httpClient, discovery) - // Use the refresh token to get new tokens - refreshSource := downstreamOAuth2Config.TokenSource(oidcHTTPClientContext, &oauth2.Token{RefreshToken: tokenResponse.RefreshToken}) - refreshedTokenResponse, err := refreshSource.Token() - require.NoError(t, err) + // Use the refresh token to get new tokens + refreshSource := downstreamOAuth2Config.TokenSource(oidcHTTPClientContext, &oauth2.Token{RefreshToken: tokenResponse.RefreshToken}) + refreshedTokenResponse, err := refreshSource.Token() + require.NoError(t, err) - // When refreshing, expect to get an "at_hash" claim, but no "nonce" claim. - expectRefreshedIDTokenClaims := []string{"iss", "exp", "sub", "aud", "auth_time", "iat", "jti", "rat", "username", "groups", "at_hash"} - verifyTokenResponse(t, - refreshedTokenResponse, discovery, downstreamOAuth2Config, "", - expectRefreshedIDTokenClaims, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch, wantDownstreamIDTokenGroups) + // When refreshing, expect to get an "at_hash" claim, but no "nonce" claim. + expectRefreshedIDTokenClaims := []string{"iss", "exp", "sub", "aud", "auth_time", "iat", "jti", "rat", "username", "groups", "at_hash"} + verifyTokenResponse(t, + refreshedTokenResponse, discovery, downstreamOAuth2Config, "", + expectRefreshedIDTokenClaims, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch, wantDownstreamIDTokenGroups) - require.NotEqual(t, tokenResponse.AccessToken, refreshedTokenResponse.AccessToken) - require.NotEqual(t, tokenResponse.RefreshToken, refreshedTokenResponse.RefreshToken) - require.NotEqual(t, tokenResponse.Extra("id_token"), refreshedTokenResponse.Extra("id_token")) + require.NotEqual(t, tokenResponse.AccessToken, refreshedTokenResponse.AccessToken) + require.NotEqual(t, tokenResponse.RefreshToken, refreshedTokenResponse.RefreshToken) + require.NotEqual(t, tokenResponse.Extra("id_token"), refreshedTokenResponse.Extra("id_token")) - // token exchange on the refreshed token - doTokenExchange(t, &downstreamOAuth2Config, refreshedTokenResponse, httpClient, discovery) + // token exchange on the refreshed token + doTokenExchange(t, &downstreamOAuth2Config, refreshedTokenResponse, httpClient, discovery) + } else { + errorDescription := callback.URL.Query().Get("error_description") + errorType := callback.URL.Query().Get("error") + require.Equal(t, errorDescription, wantErrorDescription) + require.Equal(t, errorType, wantErrorType) + } } func verifyTokenResponse( @@ -602,7 +663,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 requestAuthorizationUsingLDAPIdentityProvider(t *testing.T, downstreamAuthorizeURL, upstreamUsername, upstreamPassword string, httpClient *http.Client, wantErr bool) { t.Helper() ctx, cancelFunc := context.WithTimeout(context.Background(), time.Minute) @@ -645,7 +706,11 @@ func requestAuthorizationUsingLDAPIdentityProvider(t *testing.T, downstreamAutho redirectLocation := authResponse.Header.Get("Location") require.Contains(t, redirectLocation, "127.0.0.1") require.Contains(t, redirectLocation, "/callback") - require.Contains(t, redirectLocation, "code=") + if wantErr { + require.Contains(t, redirectLocation, "error_description") + } else { + require.Contains(t, redirectLocation, "code=") + } // Follow the redirect. callbackRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, redirectLocation, nil) diff --git a/test/testlib/env.go b/test/testlib/env.go index ee83e1c7..875964ef 100644 --- a/test/testlib/env.go +++ b/test/testlib/env.go @@ -82,24 +82,26 @@ type TestOIDCUpstream struct { } type TestLDAPUpstream struct { - Host string `json:"host"` - StartTLSOnlyHost string `json:"startTLSOnlyHost"` - CABundle string `json:"caBundle"` - BindUsername string `json:"bindUsername"` - BindPassword string `json:"bindPassword"` - UserSearchBase string `json:"userSearchBase"` - GroupSearchBase string `json:"groupSearchBase"` - TestUserDN string `json:"testUserDN"` - TestUserCN string `json:"testUserCN"` - TestUserPassword string `json:"testUserPassword"` - TestUserMailAttributeName string `json:"testUserMailAttributeName"` - TestUserMailAttributeValue string `json:"testUserMailAttributeValue"` - TestUserUniqueIDAttributeName string `json:"testUserUniqueIDAttributeName"` - TestUserUniqueIDAttributeValue string `json:"testUserUniqueIDAttributeValue"` - TestUserDirectGroupsCNs []string `json:"testUserDirectGroupsCNs"` - TestUserDirectGroupsDNs []string `json:"testUserDirectGroupsDNs"` //nolint:golint // this is "distinguished names", not "DNS" - TestUserSAMAccountNameValue string `json:"testUserSAMAccountNameValue"` - TestUserIndirectGroupsSAMAccountNames []string `json:"TestUserIndirectGroupsSAMAccountNames"` + Host string `json:"host"` + StartTLSOnlyHost string `json:"startTLSOnlyHost"` + CABundle string `json:"caBundle"` + BindUsername string `json:"bindUsername"` + BindPassword string `json:"bindPassword"` + UserSearchBase string `json:"userSearchBase"` + GroupSearchBase string `json:"groupSearchBase"` + TestUserDN string `json:"testUserDN"` + TestUserCN string `json:"testUserCN"` + TestUserPassword string `json:"testUserPassword"` + TestUserMailAttributeName string `json:"testUserMailAttributeName"` + TestUserMailAttributeValue string `json:"testUserMailAttributeValue"` + TestUserUniqueIDAttributeName string `json:"testUserUniqueIDAttributeName"` + TestUserUniqueIDAttributeValue string `json:"testUserUniqueIDAttributeValue"` + TestUserDirectGroupsCNs []string `json:"testUserDirectGroupsCNs"` + TestUserDirectGroupsDNs []string `json:"testUserDirectGroupsDNs"` //nolint:golint // this is "distinguished names", not "DNS" + TestUserSAMAccountNameValue string `json:"testUserSAMAccountNameValue"` + TestUserIndirectGroupsSAMAccountNames []string `json:"TestUserIndirectGroupsSAMAccountNames"` + TestDeactivatedUserSAMAccountNameValue string `json:"TestDeactivatedUserSAMAccountNameValue"` + TestDeactivatedUserPassword string `json:"TestDeactivatedUserPassword"` } // ProxyEnv returns a set of environment variable strings (e.g., to combine with os.Environ()) which set up the configured test HTTP proxy. @@ -271,17 +273,19 @@ func loadEnvVars(t *testing.T, result *TestEnv) { } result.SupervisorUpstreamActiveDirectory = TestLDAPUpstream{ - Host: wantEnv("PINNIPED_TEST_AD_HOST", ""), - CABundle: base64Decoded(t, os.Getenv("PINNIPED_TEST_AD_LDAPS_CA_BUNDLE")), - BindUsername: wantEnv("PINNIPED_TEST_AD_BIND_ACCOUNT_USERNAME", ""), - BindPassword: wantEnv("PINNIPED_TEST_AD_BIND_ACCOUNT_PASSWORD", ""), - TestUserPassword: wantEnv("PINNIPED_TEST_AD_USER_PASSWORD", ""), - TestUserUniqueIDAttributeName: wantEnv("PINNIPED_TEST_AD_USER_UNIQUE_ID_ATTRIBUTE_NAME", ""), - TestUserUniqueIDAttributeValue: wantEnv("PINNIPED_TEST_AD_USER_UNIQUE_ID_ATTRIBUTE_VALUE", ""), - TestUserSAMAccountNameValue: wantEnv("PINNIPED_TEST_AD_USERNAME_ATTRIBUTE_VALUE", ""), - TestUserDirectGroupsDNs: filterEmpty(strings.Split(wantEnv("PINNIPED_TEST_AD_USER_EXPECTED_GROUPS_DN", ""), ";")), - TestUserDirectGroupsCNs: filterEmpty(strings.Split(wantEnv("PINNIPED_TEST_AD_USER_EXPECTED_GROUPS_CN", ""), ";")), - TestUserIndirectGroupsSAMAccountNames: filterEmpty(strings.Split(wantEnv("PINNIPED_TEST_AD_USER_EXPECTED_GROUPS_SAMACCOUNTNAME", ""), ";")), + Host: wantEnv("PINNIPED_TEST_AD_HOST", ""), + CABundle: base64Decoded(t, os.Getenv("PINNIPED_TEST_AD_LDAPS_CA_BUNDLE")), + BindUsername: wantEnv("PINNIPED_TEST_AD_BIND_ACCOUNT_USERNAME", ""), + BindPassword: wantEnv("PINNIPED_TEST_AD_BIND_ACCOUNT_PASSWORD", ""), + TestUserPassword: wantEnv("PINNIPED_TEST_AD_USER_PASSWORD", ""), + TestUserUniqueIDAttributeName: wantEnv("PINNIPED_TEST_AD_USER_UNIQUE_ID_ATTRIBUTE_NAME", ""), + TestUserUniqueIDAttributeValue: wantEnv("PINNIPED_TEST_AD_USER_UNIQUE_ID_ATTRIBUTE_VALUE", ""), + TestUserSAMAccountNameValue: wantEnv("PINNIPED_TEST_AD_USERNAME_ATTRIBUTE_VALUE", ""), + TestUserDirectGroupsDNs: filterEmpty(strings.Split(wantEnv("PINNIPED_TEST_AD_USER_EXPECTED_GROUPS_DN", ""), ";")), + TestUserDirectGroupsCNs: filterEmpty(strings.Split(wantEnv("PINNIPED_TEST_AD_USER_EXPECTED_GROUPS_CN", ""), ";")), + TestUserIndirectGroupsSAMAccountNames: filterEmpty(strings.Split(wantEnv("PINNIPED_TEST_AD_USER_EXPECTED_GROUPS_SAMACCOUNTNAME", ""), ";")), + TestDeactivatedUserSAMAccountNameValue: wantEnv("PINNIPED_TEST_DEACTIVATED_AD_USER_SAMACCOUNTNAME", ""), + TestDeactivatedUserPassword: wantEnv("PINNIPED_TEST_DEACTIVATED_AD_USER_PASSWORD", ""), } sort.Strings(result.SupervisorUpstreamLDAP.TestUserDirectGroupsCNs)