2179 lines
109 KiB
Go
2179 lines
109 KiB
Go
// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package integration
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
coreosoidc "github.com/coreos/go-oidc/v3/oidc"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/oauth2"
|
|
v1 "k8s.io/api/core/v1"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/utils/strings/slices"
|
|
|
|
configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1"
|
|
idpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1"
|
|
"go.pinniped.dev/internal/certauthority"
|
|
"go.pinniped.dev/internal/oidc"
|
|
"go.pinniped.dev/internal/oidc/oidcclientvalidator"
|
|
"go.pinniped.dev/internal/psession"
|
|
"go.pinniped.dev/internal/testutil"
|
|
"go.pinniped.dev/pkg/oidcclient/nonce"
|
|
"go.pinniped.dev/pkg/oidcclient/pkce"
|
|
"go.pinniped.dev/pkg/oidcclient/state"
|
|
"go.pinniped.dev/test/testlib"
|
|
"go.pinniped.dev/test/testlib/browsertest"
|
|
)
|
|
|
|
func TestSupervisorLogin_Browser(t *testing.T) {
|
|
env := testlib.IntegrationEnv(t)
|
|
|
|
skipNever := func(t *testing.T) {
|
|
// never need to skip this test
|
|
}
|
|
|
|
skipLDAPTests := func(t *testing.T) {
|
|
t.Helper()
|
|
testlib.SkipTestWhenLDAPIsUnavailable(t, env)
|
|
}
|
|
|
|
skipActiveDirectoryTests := func(t *testing.T) {
|
|
t.Helper()
|
|
testlib.SkipTestWhenActiveDirectoryIsUnavailable(t, env)
|
|
}
|
|
|
|
basicOIDCIdentityProviderSpec := func() idpv1alpha1.OIDCIdentityProviderSpec {
|
|
return 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,
|
|
},
|
|
}
|
|
}
|
|
|
|
createActiveDirectoryIdentityProvider := func(t *testing.T, edit func(spec *idpv1alpha1.ActiveDirectoryIdentityProviderSpec)) (*idpv1alpha1.ActiveDirectoryIdentityProvider, *v1.Secret) {
|
|
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,
|
|
},
|
|
)
|
|
|
|
spec := idpv1alpha1.ActiveDirectoryIdentityProviderSpec{
|
|
Host: env.SupervisorUpstreamActiveDirectory.Host,
|
|
TLS: &idpv1alpha1.TLSSpec{
|
|
CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamActiveDirectory.CABundle)),
|
|
},
|
|
Bind: idpv1alpha1.ActiveDirectoryIdentityProviderBind{
|
|
SecretName: secret.Name,
|
|
},
|
|
}
|
|
|
|
if edit != nil {
|
|
edit(&spec)
|
|
}
|
|
|
|
adIDP := testlib.CreateTestActiveDirectoryIdentityProvider(t, spec, idpv1alpha1.ActiveDirectoryPhaseReady)
|
|
|
|
expectedMsg := fmt.Sprintf(
|
|
`successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`,
|
|
spec.Host, env.SupervisorUpstreamActiveDirectory.BindUsername,
|
|
secret.Name, secret.ResourceVersion,
|
|
)
|
|
requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg)
|
|
|
|
return adIDP, secret
|
|
}
|
|
|
|
createLDAPIdentityProvider := func(t *testing.T, edit func(spec *idpv1alpha1.LDAPIdentityProviderSpec)) (*idpv1alpha1.LDAPIdentityProvider, *v1.Secret) {
|
|
t.Helper()
|
|
|
|
secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ldap-service-account", v1.SecretTypeBasicAuth,
|
|
map[string]string{
|
|
v1.BasicAuthUsernameKey: env.SupervisorUpstreamLDAP.BindUsername,
|
|
v1.BasicAuthPasswordKey: env.SupervisorUpstreamLDAP.BindPassword,
|
|
},
|
|
)
|
|
|
|
spec := idpv1alpha1.LDAPIdentityProviderSpec{
|
|
Host: env.SupervisorUpstreamLDAP.Host,
|
|
TLS: &idpv1alpha1.TLSSpec{
|
|
CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.CABundle)),
|
|
},
|
|
Bind: idpv1alpha1.LDAPIdentityProviderBind{
|
|
SecretName: secret.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: "",
|
|
Attributes: idpv1alpha1.LDAPIdentityProviderGroupSearchAttributes{
|
|
GroupName: "dn",
|
|
},
|
|
},
|
|
}
|
|
|
|
if edit != nil {
|
|
edit(&spec)
|
|
}
|
|
|
|
ldapIDP := testlib.CreateTestLDAPIdentityProvider(t, spec, idpv1alpha1.LDAPPhaseReady)
|
|
|
|
expectedMsg := fmt.Sprintf(
|
|
`successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`,
|
|
spec.Host, env.SupervisorUpstreamLDAP.BindUsername,
|
|
secret.Name, secret.ResourceVersion,
|
|
)
|
|
requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg)
|
|
|
|
return ldapIDP, secret
|
|
}
|
|
|
|
// These tests attempt to exercise the entire login and refresh flow of the Supervisor for various cases.
|
|
// They do not use the Pinniped CLI as the client, which allows them to exercise the Supervisor as an
|
|
// OIDC provider in ways that the CLI might not use. Similar tests exist using the CLI in e2e_test.go.
|
|
//
|
|
// Each of these tests perform the following flow:
|
|
// 1. Create a FederationDomain with TLS configured and wait for its JWKS endpoint to be available.
|
|
// 2. Configure an IDP CR.
|
|
// 3. Call the authorization endpoint and log in as a specific user.
|
|
// Note that these tests do not use form_post response type (which is tested by e2e_test.go).
|
|
// 4. Listen on a local callback server for the authorization redirect, and assert that it was success or failure.
|
|
// 5. Call the token endpoint to exchange the authcode.
|
|
// 6. Call the token endpoint to perform the RFC8693 token exchange for the cluster-scoped ID token.
|
|
// 7. Potentially edit the refresh session data or IDP settings before the refresh.
|
|
// 8. Call the token endpoint to perform a refresh, and expect it to succeed.
|
|
// 9. Call the token endpoint again to perform another RFC8693 token exchange for the cluster-scoped ID token,
|
|
// this time using the recently refreshed tokens when submitting the request.
|
|
// 10. Potentially edit the refresh session data or IDP settings again, this time in such a way that the next
|
|
// refresh should fail. If done, then perform one more refresh and expect failure.
|
|
tests := []struct {
|
|
name string
|
|
|
|
// This required function might choose to skip the test case, for example if the LDAP server is not
|
|
// available for an LDAP test.
|
|
maybeSkip func(t *testing.T)
|
|
|
|
// This required function should configure an IDP CR. It should also wait for it to be ready and schedule
|
|
// its cleanup. Return the name of the IDP CR.
|
|
createIDP func(t *testing.T) string
|
|
|
|
// Optionally create an OIDCClient CR for the test to use. Return the client ID and client secret for the
|
|
// test to use. When not set, the test will default to using the "pinniped-cli" static client with no secret.
|
|
// When a client secret is returned, it will be used for authcode exchange, refresh requests, and RFC8693
|
|
// token exchanges for cluster-scoped tokens (client secrets are not needed in authorization requests).
|
|
createOIDCClient func(t *testing.T, callbackURL string) (string, string)
|
|
|
|
// Optionally return the username and password for the test to use when logging in. This username/password
|
|
// will be passed to requestAuthorization(), or empty strings will be passed to indicate that the defaults
|
|
// should be used. If there is any cleanup required, then this function should also schedule that cleanup.
|
|
testUser func(t *testing.T) (string, string)
|
|
|
|
// This required function should call the authorization endpoint using the given URL and also perform whatever
|
|
// interactions are needed to log in as the user.
|
|
requestAuthorization func(t *testing.T, downstreamIssuer, downstreamAuthorizeURL, downstreamCallbackURL, username, password string, httpClient *http.Client)
|
|
|
|
// This string will be used as the requested audience in the RFC8693 token exchange for
|
|
// the cluster-scoped ID token. When it is not specified, a default string will be used.
|
|
requestTokenExchangeAud string
|
|
|
|
// The scopes to request from the authorization endpoint. Defaults will be used when not specified.
|
|
downstreamScopes []string
|
|
|
|
// When we want the localhost callback to have never happened, then the flow will stop there. The login was
|
|
// unable to finish so there is nothing to assert about what should have happened with the callback, and there
|
|
// won't be any error sent to the callback either. This would happen, for example, when the user fails to log
|
|
// in at the LDAP/AD login page, because then they would be redirected back to that page again, instead of
|
|
// getting a callback success/error redirect.
|
|
wantLocalhostCallbackToNeverHappen bool
|
|
|
|
// The expected ID token subject claim value as a regexp, for the original ID token and the refreshed ID token.
|
|
wantDownstreamIDTokenSubjectToMatch string
|
|
// The expected ID token username claim value as a regexp, for the original ID token and the refreshed ID token.
|
|
wantDownstreamIDTokenUsernameToMatch func(username string) string
|
|
// The expected ID token groups claim value, for the original ID token and the refreshed ID token.
|
|
wantDownstreamIDTokenGroups []string
|
|
|
|
// Want the authorization endpoint to redirect to the callback with this error type.
|
|
// The rest of the flow will be skipped since the initial authorization failed.
|
|
wantAuthorizationErrorType string
|
|
// Want the authorization endpoint to redirect to the callback with this error description.
|
|
// Should be used with wantAuthorizationErrorType.
|
|
wantAuthorizationErrorDescription string
|
|
|
|
// Optionally want to the authcode exchange at the token endpoint to fail. The rest of the flow will be
|
|
// skipped since the authcode exchange failed.
|
|
wantAuthcodeExchangeError string
|
|
|
|
// Optionally make all required assertions about the response of the RFC8693 token exchange for
|
|
// the cluster-scoped ID token, given the http response status and response body from the token endpoint.
|
|
// When this is not specified then the appropriate default assertions for a successful exchange are made.
|
|
// Even if this expects failures, the rest of the flow will continue.
|
|
wantTokenExchangeResponse func(t *testing.T, status int, body string)
|
|
|
|
// Optionally edit the refresh session data between the initial login and the first refresh,
|
|
// which is still expected to succeed after these edits.
|
|
editRefreshSessionDataWithoutBreaking func(t *testing.T, sessionData *psession.PinnipedSession, idpName, username string) []string
|
|
// Optionally either revoke the user's session on the upstream provider, or manipulate the user's session
|
|
// data in such a way that it should cause the next upstream refresh attempt to fail.
|
|
breakRefreshSessionData func(t *testing.T, sessionData *psession.PinnipedSession, idpName, username string)
|
|
}{
|
|
{
|
|
name: "oidc with default username and groups claim settings",
|
|
maybeSkip: skipNever,
|
|
createIDP: func(t *testing.T) string {
|
|
return testlib.CreateTestOIDCIdentityProvider(t, basicOIDCIdentityProviderSpec(), idpv1alpha1.PhaseReady).Name
|
|
},
|
|
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC,
|
|
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
|
|
pinnipedSessionData := pinnipedSession.Custom
|
|
pinnipedSessionData.OIDC.UpstreamIssuer = "wrong-issuer"
|
|
},
|
|
// 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: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+" },
|
|
},
|
|
{
|
|
name: "oidc with custom username and groups claim settings",
|
|
maybeSkip: skipNever,
|
|
createIDP: func(t *testing.T) string {
|
|
spec := basicOIDCIdentityProviderSpec()
|
|
spec.Claims = idpv1alpha1.OIDCClaims{
|
|
Username: env.SupervisorUpstreamOIDC.UsernameClaim,
|
|
Groups: env.SupervisorUpstreamOIDC.GroupsClaim,
|
|
}
|
|
spec.AuthorizationConfig = idpv1alpha1.OIDCAuthorizationConfig{
|
|
AdditionalScopes: env.SupervisorUpstreamOIDC.AdditionalScopes,
|
|
}
|
|
return testlib.CreateTestOIDCIdentityProvider(t, spec, idpv1alpha1.PhaseReady).Name
|
|
},
|
|
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC,
|
|
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
|
|
fositeSessionData := pinnipedSession.Fosite
|
|
fositeSessionData.Claims.Extra["username"] = "some-incorrect-username"
|
|
},
|
|
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+",
|
|
wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Username) + "$" },
|
|
wantDownstreamIDTokenGroups: env.SupervisorUpstreamOIDC.ExpectedGroups,
|
|
editRefreshSessionDataWithoutBreaking: func(t *testing.T, sessionData *psession.PinnipedSession, _, _ string) []string {
|
|
// even if we update this group to the wrong thing, we expect that it will return to the correct
|
|
// value after we refresh.
|
|
// However if there are no expected groups then they will not update, so we should skip this.
|
|
if len(env.SupervisorUpstreamOIDC.ExpectedGroups) > 0 {
|
|
sessionData.Fosite.Claims.Extra["groups"] = []string{"some-wrong-group", "some-other-group"}
|
|
}
|
|
return env.SupervisorUpstreamOIDC.ExpectedGroups
|
|
},
|
|
},
|
|
{
|
|
name: "oidc without refresh token",
|
|
maybeSkip: skipNever,
|
|
createIDP: func(t *testing.T) string {
|
|
var additionalScopes []string
|
|
// keep all the scopes except for offline access so we can test the access token based refresh 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)
|
|
}
|
|
}
|
|
}
|
|
spec := basicOIDCIdentityProviderSpec()
|
|
spec.Claims = idpv1alpha1.OIDCClaims{
|
|
Username: env.SupervisorUpstreamOIDC.UsernameClaim,
|
|
Groups: env.SupervisorUpstreamOIDC.GroupsClaim,
|
|
}
|
|
spec.AuthorizationConfig = idpv1alpha1.OIDCAuthorizationConfig{
|
|
AdditionalScopes: additionalScopes,
|
|
}
|
|
return testlib.CreateTestOIDCIdentityProvider(t, spec, idpv1alpha1.PhaseReady).Name
|
|
},
|
|
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC,
|
|
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
|
|
fositeSessionData := pinnipedSession.Fosite
|
|
fositeSessionData.Claims.Extra["username"] = "some-incorrect-username"
|
|
},
|
|
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+",
|
|
wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Username) + "$" },
|
|
wantDownstreamIDTokenGroups: env.SupervisorUpstreamOIDC.ExpectedGroups,
|
|
},
|
|
{
|
|
name: "oidc with CLI password flow",
|
|
maybeSkip: skipNever,
|
|
createIDP: func(t *testing.T) string {
|
|
spec := basicOIDCIdentityProviderSpec()
|
|
spec.AuthorizationConfig = idpv1alpha1.OIDCAuthorizationConfig{
|
|
AllowPasswordGrant: true, // allow the CLI password flow for this OIDCIdentityProvider
|
|
}
|
|
return testlib.CreateTestOIDCIdentityProvider(t, spec, idpv1alpha1.PhaseReady).Name
|
|
},
|
|
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,
|
|
false,
|
|
)
|
|
},
|
|
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
|
|
customSessionData := pinnipedSession.Custom
|
|
require.Equal(t, psession.ProviderTypeOIDC, customSessionData.ProviderType)
|
|
require.NotEmpty(t, customSessionData.OIDC.UpstreamRefreshToken)
|
|
customSessionData.OIDC.UpstreamRefreshToken = "invalid-updated-refresh-token"
|
|
},
|
|
// 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: func(_ string) string { return "^" + 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: skipLDAPTests,
|
|
createIDP: func(t *testing.T) string {
|
|
idp, _ := createLDAPIdentityProvider(t, nil)
|
|
return idp.Name
|
|
},
|
|
requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) {
|
|
requestAuthorizationUsingCLIPasswordFlow(t,
|
|
downstreamAuthorizeURL,
|
|
env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
|
|
env.SupervisorUpstreamLDAP.TestUserPassword, // password to present to server during login
|
|
httpClient,
|
|
false,
|
|
)
|
|
},
|
|
editRefreshSessionDataWithoutBreaking: func(t *testing.T, sessionData *psession.PinnipedSession, _, _ string) []string {
|
|
// even if we update this group to the wrong thing, we expect that it will return to the correct
|
|
// value after we refresh.
|
|
sessionData.Fosite.Claims.Extra["groups"] = []string{"some-wrong-group", "some-other-group"}
|
|
return env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs
|
|
},
|
|
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
|
|
customSessionData := pinnipedSession.Custom
|
|
require.Equal(t, psession.ProviderTypeLDAP, customSessionData.ProviderType)
|
|
require.NotEmpty(t, customSessionData.LDAP.UserDN)
|
|
fositeSessionData := pinnipedSession.Fosite
|
|
fositeSessionData.Claims.Subject = "not-right"
|
|
},
|
|
// the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute
|
|
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(
|
|
"ldaps://"+env.SupervisorUpstreamLDAP.Host+
|
|
"?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+
|
|
"&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)),
|
|
) + "$",
|
|
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
|
|
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
|
|
return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$"
|
|
},
|
|
wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs,
|
|
},
|
|
{
|
|
name: "ldap without requesting groups scope",
|
|
maybeSkip: skipLDAPTests,
|
|
createIDP: func(t *testing.T) string {
|
|
idp, _ := createLDAPIdentityProvider(t, nil)
|
|
return idp.Name
|
|
},
|
|
downstreamScopes: []string{"openid", "pinniped:request-audience", "offline_access"},
|
|
requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) {
|
|
requestAuthorizationUsingCLIPasswordFlow(t,
|
|
downstreamAuthorizeURL,
|
|
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
|
|
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(
|
|
"ldaps://"+env.SupervisorUpstreamLDAP.Host+
|
|
"?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+
|
|
"&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)),
|
|
) + "$",
|
|
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
|
|
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
|
|
return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$"
|
|
},
|
|
wantDownstreamIDTokenGroups: []string{},
|
|
},
|
|
{
|
|
name: "oidc without requesting groups scope",
|
|
maybeSkip: skipNever,
|
|
createIDP: func(t *testing.T) string {
|
|
spec := basicOIDCIdentityProviderSpec()
|
|
spec.Claims = idpv1alpha1.OIDCClaims{
|
|
Username: env.SupervisorUpstreamOIDC.UsernameClaim,
|
|
Groups: env.SupervisorUpstreamOIDC.GroupsClaim,
|
|
}
|
|
spec.AuthorizationConfig = idpv1alpha1.OIDCAuthorizationConfig{
|
|
AdditionalScopes: env.SupervisorUpstreamOIDC.AdditionalScopes,
|
|
}
|
|
return testlib.CreateTestOIDCIdentityProvider(t, spec, idpv1alpha1.PhaseReady).Name
|
|
},
|
|
downstreamScopes: []string{"openid", "pinniped:request-audience", "offline_access"},
|
|
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC,
|
|
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+",
|
|
wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Username) + "$" },
|
|
wantDownstreamIDTokenGroups: nil,
|
|
},
|
|
{
|
|
name: "ldap with browser flow",
|
|
maybeSkip: skipLDAPTests,
|
|
createIDP: func(t *testing.T) string {
|
|
idp, _ := createLDAPIdentityProvider(t, nil)
|
|
return idp.Name
|
|
},
|
|
testUser: func(t *testing.T) (string, string) {
|
|
// return the username and password of the existing user that we want to use for this test
|
|
return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
|
|
env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login
|
|
},
|
|
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAP,
|
|
// the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute
|
|
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(
|
|
"ldaps://"+env.SupervisorUpstreamLDAP.Host+
|
|
"?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+
|
|
"&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)),
|
|
) + "$",
|
|
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
|
|
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
|
|
return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$"
|
|
},
|
|
wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs,
|
|
},
|
|
{
|
|
name: "ldap with browser flow with wrong password",
|
|
maybeSkip: skipLDAPTests,
|
|
createIDP: func(t *testing.T) string {
|
|
idp, _ := createLDAPIdentityProvider(t, nil)
|
|
return idp.Name
|
|
},
|
|
testUser: func(t *testing.T) (string, string) {
|
|
// return the username and password of the existing user that we want to use for this test
|
|
return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
|
|
"this is the wrong password" // password to present to server during login
|
|
},
|
|
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAPWithBadCredentials,
|
|
wantLocalhostCallbackToNeverHappen: true, // we should have been sent back to the login page to retry login
|
|
},
|
|
{
|
|
name: "ldap with browser flow with wrong username",
|
|
maybeSkip: skipLDAPTests,
|
|
createIDP: func(t *testing.T) string {
|
|
idp, _ := createLDAPIdentityProvider(t, nil)
|
|
return idp.Name
|
|
},
|
|
testUser: func(t *testing.T) (string, string) {
|
|
// return the username and password of the existing user that we want to use for this test
|
|
return "this is the wrong username", // username to present to server during login
|
|
env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login
|
|
},
|
|
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAPWithBadCredentials,
|
|
wantLocalhostCallbackToNeverHappen: true, // we should have been sent back to the login page to retry login
|
|
},
|
|
{
|
|
name: "ldap with browser flow with wrong password and then correct password",
|
|
maybeSkip: skipLDAPTests,
|
|
createIDP: func(t *testing.T) string {
|
|
idp, _ := createLDAPIdentityProvider(t, nil)
|
|
return idp.Name
|
|
},
|
|
testUser: func(t *testing.T) (string, string) {
|
|
// return the username and password of the existing user that we want to use for this test
|
|
return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
|
|
env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login
|
|
},
|
|
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAPWithBadCredentialsAndThenGoodCredentials,
|
|
// the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute
|
|
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(
|
|
"ldaps://"+env.SupervisorUpstreamLDAP.Host+
|
|
"?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+
|
|
"&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)),
|
|
) + "$",
|
|
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
|
|
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
|
|
return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$"
|
|
},
|
|
wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs,
|
|
},
|
|
{
|
|
name: "ldap skip group refresh",
|
|
maybeSkip: skipLDAPTests,
|
|
createIDP: func(t *testing.T) string {
|
|
idp, _ := createLDAPIdentityProvider(t, func(spec *idpv1alpha1.LDAPIdentityProviderSpec) {
|
|
spec.GroupSearch.SkipGroupRefresh = true
|
|
})
|
|
return idp.Name
|
|
},
|
|
requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) {
|
|
requestAuthorizationUsingCLIPasswordFlow(t,
|
|
downstreamAuthorizeURL,
|
|
env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
|
|
env.SupervisorUpstreamLDAP.TestUserPassword, // password to present to server during login
|
|
httpClient,
|
|
false,
|
|
)
|
|
},
|
|
editRefreshSessionDataWithoutBreaking: func(t *testing.T, sessionData *psession.PinnipedSession, _, _ string) []string {
|
|
// update the list of groups to the wrong thing and see that they do not get updated because
|
|
// skip group refresh is set
|
|
wrongGroups := []string{"some-wrong-group", "some-other-group"}
|
|
sessionData.Fosite.Claims.Extra["groups"] = wrongGroups
|
|
return wrongGroups
|
|
},
|
|
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
|
|
customSessionData := pinnipedSession.Custom
|
|
require.Equal(t, psession.ProviderTypeLDAP, customSessionData.ProviderType)
|
|
require.NotEmpty(t, customSessionData.LDAP.UserDN)
|
|
fositeSessionData := pinnipedSession.Fosite
|
|
fositeSessionData.Claims.Subject = "not-right"
|
|
},
|
|
// the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute
|
|
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(
|
|
"ldaps://"+env.SupervisorUpstreamLDAP.Host+
|
|
"?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+
|
|
"&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)),
|
|
) + "$",
|
|
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
|
|
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
|
|
return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$"
|
|
},
|
|
wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs,
|
|
},
|
|
{
|
|
name: "ldap with email as username and group search base that doesn't return anything, and using an LDAP provider which supports TLS",
|
|
maybeSkip: func(t *testing.T) {
|
|
skipLDAPTests(t)
|
|
if env.SupervisorUpstreamLDAP.UserSearchBase == env.SupervisorUpstreamLDAP.GroupSearchBase {
|
|
// This test relies on using the user search base as the group search base, to simulate
|
|
// searching for groups and not finding any.
|
|
// If the users and groups are stored in the same place, then we will get groups
|
|
// back, so this test wouldn't make sense.
|
|
t.Skip("must have a different user search base than group search base")
|
|
}
|
|
},
|
|
createIDP: func(t *testing.T) string {
|
|
idp, _ := createLDAPIdentityProvider(t, func(spec *idpv1alpha1.LDAPIdentityProviderSpec) {
|
|
spec.GroupSearch.Base = env.SupervisorUpstreamLDAP.UserSearchBase // groups not stored at the user search base
|
|
})
|
|
return idp.Name
|
|
},
|
|
requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) {
|
|
requestAuthorizationUsingCLIPasswordFlow(t,
|
|
downstreamAuthorizeURL,
|
|
env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
|
|
env.SupervisorUpstreamLDAP.TestUserPassword, // password to present to server during login
|
|
httpClient,
|
|
false,
|
|
)
|
|
},
|
|
editRefreshSessionDataWithoutBreaking: func(t *testing.T, sessionData *psession.PinnipedSession, _, _ string) []string {
|
|
// even if we update this group to the wrong thing, we expect that it will return to the correct
|
|
// value (no groups) after we refresh.
|
|
sessionData.Fosite.Claims.Extra["groups"] = []string{"some-wrong-group", "some-other-group"}
|
|
return []string{}
|
|
},
|
|
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
|
|
customSessionData := pinnipedSession.Custom
|
|
require.Equal(t, psession.ProviderTypeLDAP, customSessionData.ProviderType)
|
|
require.NotEmpty(t, customSessionData.LDAP.UserDN)
|
|
fositeSessionData := pinnipedSession.Fosite
|
|
fositeSessionData.Claims.Subject = "not-right"
|
|
},
|
|
// the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute
|
|
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(
|
|
"ldaps://"+env.SupervisorUpstreamLDAP.Host+
|
|
"?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+
|
|
"&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)),
|
|
) + "$",
|
|
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
|
|
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
|
|
return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$"
|
|
},
|
|
wantDownstreamIDTokenGroups: []string{},
|
|
},
|
|
{
|
|
name: "ldap with CN as username and group names as CNs and using an LDAP provider which only supports StartTLS", // try another variation of configuration options
|
|
maybeSkip: skipLDAPTests,
|
|
createIDP: func(t *testing.T) string {
|
|
idp, _ := createLDAPIdentityProvider(t, func(spec *idpv1alpha1.LDAPIdentityProviderSpec) {
|
|
spec.Host = env.SupervisorUpstreamLDAP.StartTLSOnlyHost
|
|
spec.UserSearch.Filter = "cn={}" // try using a non-default search filter
|
|
spec.UserSearch.Attributes.Username = "dn" // try using the user's DN as the downstream username
|
|
spec.GroupSearch.Attributes.GroupName = "cn"
|
|
})
|
|
return idp.Name
|
|
},
|
|
requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) {
|
|
requestAuthorizationUsingCLIPasswordFlow(t,
|
|
downstreamAuthorizeURL,
|
|
env.SupervisorUpstreamLDAP.TestUserCN, // username to present to server during login
|
|
env.SupervisorUpstreamLDAP.TestUserPassword, // password to present to server during login
|
|
httpClient,
|
|
false,
|
|
)
|
|
},
|
|
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
|
|
customSessionData := pinnipedSession.Custom
|
|
require.Equal(t, psession.ProviderTypeLDAP, customSessionData.ProviderType)
|
|
require.NotEmpty(t, customSessionData.LDAP.UserDN)
|
|
fositeSessionData := pinnipedSession.Fosite
|
|
fositeSessionData.Claims.Extra["username"] = "not-the-same"
|
|
},
|
|
// the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute
|
|
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(
|
|
"ldaps://"+env.SupervisorUpstreamLDAP.StartTLSOnlyHost+
|
|
"?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+
|
|
"&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)),
|
|
) + "$",
|
|
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
|
|
wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserDN) + "$" },
|
|
wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsCNs,
|
|
},
|
|
{
|
|
name: "logging in to ldap with the wrong password fails",
|
|
maybeSkip: skipLDAPTests,
|
|
createIDP: func(t *testing.T) string {
|
|
idp, _ := createLDAPIdentityProvider(t, nil)
|
|
return idp.Name
|
|
},
|
|
requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) {
|
|
requestAuthorizationUsingCLIPasswordFlow(t,
|
|
downstreamAuthorizeURL,
|
|
env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
|
|
"incorrect", // password to present to server during login
|
|
httpClient,
|
|
true,
|
|
)
|
|
},
|
|
wantAuthorizationErrorDescription: "The resource owner or authorization server denied the request. Username/password not accepted by LDAP provider.",
|
|
wantAuthorizationErrorType: "access_denied",
|
|
},
|
|
{
|
|
name: "ldap login still works after updating bind secret",
|
|
maybeSkip: skipLDAPTests,
|
|
createIDP: func(t *testing.T) string {
|
|
t.Helper()
|
|
idp, secret := createLDAPIdentityProvider(t, nil)
|
|
|
|
secret.Annotations = map[string]string{"pinniped.dev/test": "", "another-label": "another-key"}
|
|
// update that secret, which will cause the cache to recheck tls and search base values
|
|
client := testlib.NewKubernetesClientset(t)
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancel()
|
|
updatedSecret, err := client.CoreV1().Secrets(env.SupervisorNamespace).Update(ctx, secret, metav1.UpdateOptions{})
|
|
require.NoError(t, err)
|
|
|
|
expectedMsg := fmt.Sprintf(
|
|
`successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`,
|
|
env.SupervisorUpstreamLDAP.Host, env.SupervisorUpstreamLDAP.BindUsername,
|
|
updatedSecret.Name, updatedSecret.ResourceVersion,
|
|
)
|
|
supervisorClient := testlib.NewSupervisorClientset(t)
|
|
testlib.RequireEventually(t, func(requireEventually *require.Assertions) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
idp, err = supervisorClient.IDPV1alpha1().LDAPIdentityProviders(env.SupervisorNamespace).Get(ctx, idp.Name, metav1.GetOptions{})
|
|
requireEventually.NoError(err)
|
|
requireEventuallySuccessfulLDAPIdentityProviderConditions(t, requireEventually, idp, expectedMsg)
|
|
}, time.Minute, 500*time.Millisecond)
|
|
return idp.Name
|
|
},
|
|
requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) {
|
|
requestAuthorizationUsingCLIPasswordFlow(t,
|
|
downstreamAuthorizeURL,
|
|
env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
|
|
env.SupervisorUpstreamLDAP.TestUserPassword, // password to present to server during login
|
|
httpClient,
|
|
false,
|
|
)
|
|
},
|
|
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
|
|
customSessionData := pinnipedSession.Custom
|
|
require.Equal(t, psession.ProviderTypeLDAP, customSessionData.ProviderType)
|
|
require.NotEmpty(t, customSessionData.LDAP.UserDN)
|
|
customSessionData.LDAP.UserDN = "cn=not-a-user,dc=pinniped,dc=dev"
|
|
},
|
|
// the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute
|
|
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(
|
|
"ldaps://"+env.SupervisorUpstreamLDAP.Host+
|
|
"?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+
|
|
"&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)),
|
|
) + "$",
|
|
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
|
|
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
|
|
return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$"
|
|
},
|
|
wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs,
|
|
},
|
|
{
|
|
name: "ldap login still works after deleting and recreating the bind secret",
|
|
maybeSkip: skipLDAPTests,
|
|
createIDP: func(t *testing.T) string {
|
|
t.Helper()
|
|
idp, secret := createLDAPIdentityProvider(t, nil)
|
|
|
|
// delete, then recreate that secret, which will cause the cache to recheck tls and search base values
|
|
client := testlib.NewKubernetesClientset(t)
|
|
deleteCtx, deleteCancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer deleteCancel()
|
|
err := client.CoreV1().Secrets(env.SupervisorNamespace).Delete(deleteCtx, secret.Name, metav1.DeleteOptions{})
|
|
require.NoError(t, err)
|
|
|
|
// create the secret again
|
|
recreateCtx, recreateCancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer recreateCancel()
|
|
recreatedSecret, err := client.CoreV1().Secrets(env.SupervisorNamespace).Create(recreateCtx, &v1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: secret.Name,
|
|
Namespace: env.SupervisorNamespace,
|
|
},
|
|
Type: v1.SecretTypeBasicAuth,
|
|
StringData: map[string]string{
|
|
v1.BasicAuthUsernameKey: env.SupervisorUpstreamLDAP.BindUsername,
|
|
v1.BasicAuthPasswordKey: env.SupervisorUpstreamLDAP.BindPassword,
|
|
},
|
|
}, metav1.CreateOptions{})
|
|
require.NoError(t, err)
|
|
expectedMsg := fmt.Sprintf(
|
|
`successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`,
|
|
env.SupervisorUpstreamLDAP.Host, env.SupervisorUpstreamLDAP.BindUsername,
|
|
recreatedSecret.Name, recreatedSecret.ResourceVersion,
|
|
)
|
|
supervisorClient := testlib.NewSupervisorClientset(t)
|
|
testlib.RequireEventually(t, func(requireEventually *require.Assertions) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
idp, err = supervisorClient.IDPV1alpha1().LDAPIdentityProviders(env.SupervisorNamespace).Get(ctx, idp.Name, metav1.GetOptions{})
|
|
requireEventually.NoError(err)
|
|
requireEventuallySuccessfulLDAPIdentityProviderConditions(t, requireEventually, idp, expectedMsg)
|
|
}, time.Minute, 500*time.Millisecond)
|
|
return idp.Name
|
|
},
|
|
requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) {
|
|
requestAuthorizationUsingCLIPasswordFlow(t,
|
|
downstreamAuthorizeURL,
|
|
env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
|
|
env.SupervisorUpstreamLDAP.TestUserPassword, // password to present to server during login
|
|
httpClient,
|
|
false,
|
|
)
|
|
},
|
|
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
|
|
customSessionData := pinnipedSession.Custom
|
|
require.Equal(t, psession.ProviderTypeLDAP, customSessionData.ProviderType)
|
|
require.NotEmpty(t, customSessionData.LDAP.UserDN)
|
|
customSessionData.LDAP.UserDN = "cn=not-a-user,dc=pinniped,dc=dev"
|
|
},
|
|
// the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute
|
|
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(
|
|
"ldaps://"+env.SupervisorUpstreamLDAP.Host+
|
|
"?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+
|
|
"&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)),
|
|
) + "$",
|
|
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
|
|
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
|
|
return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$"
|
|
},
|
|
wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs,
|
|
},
|
|
{
|
|
name: "active directory with all default options",
|
|
maybeSkip: skipActiveDirectoryTests,
|
|
createIDP: func(t *testing.T) string {
|
|
idp, _ := createActiveDirectoryIdentityProvider(t, nil)
|
|
return idp.Name
|
|
},
|
|
requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) {
|
|
requestAuthorizationUsingCLIPasswordFlow(t,
|
|
downstreamAuthorizeURL,
|
|
env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue, // username to present to server during login
|
|
env.SupervisorUpstreamActiveDirectory.TestUserPassword, // password to present to server during login
|
|
httpClient,
|
|
false,
|
|
)
|
|
},
|
|
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
|
|
customSessionData := pinnipedSession.Custom
|
|
require.Equal(t, psession.ProviderTypeActiveDirectory, customSessionData.ProviderType)
|
|
require.NotEmpty(t, customSessionData.ActiveDirectory.UserDN)
|
|
fositeSessionData := pinnipedSession.Fosite
|
|
fositeSessionData.Claims.Extra["username"] = "not-the-same"
|
|
},
|
|
// the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute
|
|
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(
|
|
"ldaps://"+env.SupervisorUpstreamActiveDirectory.Host+
|
|
"?base="+url.QueryEscape(env.SupervisorUpstreamActiveDirectory.DefaultNamingContextSearchBase)+
|
|
"&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue,
|
|
) + "$",
|
|
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
|
|
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
|
|
return "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$"
|
|
},
|
|
wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames,
|
|
},
|
|
{
|
|
name: "active directory with custom options",
|
|
maybeSkip: skipActiveDirectoryTests,
|
|
createIDP: func(t *testing.T) string {
|
|
idp, _ := createActiveDirectoryIdentityProvider(t, func(spec *idpv1alpha1.ActiveDirectoryIdentityProviderSpec) {
|
|
spec.UserSearch = idpv1alpha1.ActiveDirectoryIdentityProviderUserSearch{
|
|
Base: env.SupervisorUpstreamActiveDirectory.UserSearchBase,
|
|
Filter: env.SupervisorUpstreamActiveDirectory.TestUserMailAttributeName + "={}",
|
|
Attributes: idpv1alpha1.ActiveDirectoryIdentityProviderUserSearchAttributes{
|
|
Username: env.SupervisorUpstreamActiveDirectory.TestUserMailAttributeName,
|
|
},
|
|
}
|
|
spec.GroupSearch = idpv1alpha1.ActiveDirectoryIdentityProviderGroupSearch{
|
|
Filter: "member={}", // excluding nested groups
|
|
Base: env.SupervisorUpstreamActiveDirectory.GroupSearchBase,
|
|
Attributes: idpv1alpha1.ActiveDirectoryIdentityProviderGroupSearchAttributes{
|
|
GroupName: "dn",
|
|
},
|
|
}
|
|
})
|
|
return idp.Name
|
|
},
|
|
requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) {
|
|
requestAuthorizationUsingCLIPasswordFlow(t,
|
|
downstreamAuthorizeURL,
|
|
env.SupervisorUpstreamActiveDirectory.TestUserMailAttributeValue, // username to present to server during login
|
|
env.SupervisorUpstreamActiveDirectory.TestUserPassword, // password to present to server during login
|
|
httpClient,
|
|
false,
|
|
)
|
|
},
|
|
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
|
|
customSessionData := pinnipedSession.Custom
|
|
require.Equal(t, psession.ProviderTypeActiveDirectory, customSessionData.ProviderType)
|
|
require.NotEmpty(t, customSessionData.ActiveDirectory.UserDN)
|
|
fositeSessionData := pinnipedSession.Fosite
|
|
fositeSessionData.Claims.Subject = "not-right"
|
|
},
|
|
// the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute
|
|
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(
|
|
"ldaps://"+env.SupervisorUpstreamActiveDirectory.Host+
|
|
"?base="+url.QueryEscape(env.SupervisorUpstreamActiveDirectory.UserSearchBase)+
|
|
"&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue,
|
|
) + "$",
|
|
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
|
|
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
|
|
return "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserMailAttributeValue) + "$"
|
|
},
|
|
wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserDirectGroupsDNs,
|
|
},
|
|
{
|
|
name: "active directory login still works after updating bind secret",
|
|
maybeSkip: skipActiveDirectoryTests,
|
|
createIDP: func(t *testing.T) string {
|
|
t.Helper()
|
|
idp, secret := createActiveDirectoryIdentityProvider(t, nil)
|
|
|
|
secret.Annotations = map[string]string{"pinniped.dev/test": "", "another-label": "another-key"}
|
|
// update that secret, which will cause the cache to recheck tls and search base values
|
|
client := testlib.NewKubernetesClientset(t)
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancel()
|
|
updatedSecret, err := client.CoreV1().Secrets(env.SupervisorNamespace).Update(ctx, secret, metav1.UpdateOptions{})
|
|
require.NoError(t, err)
|
|
|
|
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,
|
|
updatedSecret.Name, updatedSecret.ResourceVersion,
|
|
)
|
|
supervisorClient := testlib.NewSupervisorClientset(t)
|
|
testlib.RequireEventually(t, func(requireEventually *require.Assertions) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
idp, err = supervisorClient.IDPV1alpha1().ActiveDirectoryIdentityProviders(env.SupervisorNamespace).Get(ctx, idp.Name, metav1.GetOptions{})
|
|
requireEventually.NoError(err)
|
|
requireEventuallySuccessfulActiveDirectoryIdentityProviderConditions(t, requireEventually, idp, expectedMsg)
|
|
}, time.Minute, 500*time.Millisecond)
|
|
return idp.Name
|
|
},
|
|
requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) {
|
|
requestAuthorizationUsingCLIPasswordFlow(t,
|
|
downstreamAuthorizeURL,
|
|
env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue, // username to present to server during login
|
|
env.SupervisorUpstreamActiveDirectory.TestUserPassword, // password to present to server during login
|
|
httpClient,
|
|
false,
|
|
)
|
|
},
|
|
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
|
|
customSessionData := pinnipedSession.Custom
|
|
require.Equal(t, psession.ProviderTypeActiveDirectory, customSessionData.ProviderType)
|
|
require.NotEmpty(t, customSessionData.ActiveDirectory.UserDN)
|
|
customSessionData.ActiveDirectory.UserDN = "cn=not-a-user,dc=pinniped,dc=dev"
|
|
},
|
|
// the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute
|
|
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(
|
|
"ldaps://"+env.SupervisorUpstreamActiveDirectory.Host+
|
|
"?base="+url.QueryEscape(env.SupervisorUpstreamActiveDirectory.DefaultNamingContextSearchBase)+
|
|
"&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue,
|
|
) + "$",
|
|
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
|
|
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
|
|
return "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$"
|
|
},
|
|
wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames,
|
|
},
|
|
{
|
|
name: "active directory login still works after deleting and recreating bind secret",
|
|
maybeSkip: skipActiveDirectoryTests,
|
|
createIDP: func(t *testing.T) string {
|
|
t.Helper()
|
|
idp, secret := createActiveDirectoryIdentityProvider(t, nil)
|
|
|
|
// delete the secret
|
|
client := testlib.NewKubernetesClientset(t)
|
|
deleteCtx, deleteCancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer deleteCancel()
|
|
err := client.CoreV1().Secrets(env.SupervisorNamespace).Delete(deleteCtx, secret.Name, metav1.DeleteOptions{})
|
|
require.NoError(t, err)
|
|
|
|
// create the secret again
|
|
recreateCtx, recreateCancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer recreateCancel()
|
|
recreatedSecret, err := client.CoreV1().Secrets(env.SupervisorNamespace).Create(recreateCtx, &v1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: secret.Name,
|
|
Namespace: env.SupervisorNamespace,
|
|
},
|
|
Type: v1.SecretTypeBasicAuth,
|
|
StringData: map[string]string{
|
|
v1.BasicAuthUsernameKey: env.SupervisorUpstreamActiveDirectory.BindUsername,
|
|
v1.BasicAuthPasswordKey: env.SupervisorUpstreamActiveDirectory.BindPassword,
|
|
},
|
|
}, metav1.CreateOptions{})
|
|
require.NoError(t, err)
|
|
|
|
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,
|
|
recreatedSecret.Name, recreatedSecret.ResourceVersion,
|
|
)
|
|
supervisorClient := testlib.NewSupervisorClientset(t)
|
|
testlib.RequireEventually(t, func(requireEventually *require.Assertions) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
idp, err = supervisorClient.IDPV1alpha1().ActiveDirectoryIdentityProviders(env.SupervisorNamespace).Get(ctx, idp.Name, metav1.GetOptions{})
|
|
requireEventually.NoError(err)
|
|
requireEventuallySuccessfulActiveDirectoryIdentityProviderConditions(t, requireEventually, idp, expectedMsg)
|
|
}, time.Minute, 500*time.Millisecond)
|
|
return idp.Name
|
|
},
|
|
requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) {
|
|
requestAuthorizationUsingCLIPasswordFlow(t,
|
|
downstreamAuthorizeURL,
|
|
env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue, // username to present to server during login
|
|
env.SupervisorUpstreamActiveDirectory.TestUserPassword, // password to present to server during login
|
|
httpClient,
|
|
false,
|
|
)
|
|
},
|
|
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
|
|
customSessionData := pinnipedSession.Custom
|
|
require.Equal(t, psession.ProviderTypeActiveDirectory, customSessionData.ProviderType)
|
|
require.NotEmpty(t, customSessionData.ActiveDirectory.UserDN)
|
|
customSessionData.ActiveDirectory.UserDN = "cn=not-a-user,dc=pinniped,dc=dev"
|
|
},
|
|
// the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute
|
|
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(
|
|
"ldaps://"+env.SupervisorUpstreamActiveDirectory.Host+
|
|
"?base="+url.QueryEscape(env.SupervisorUpstreamActiveDirectory.DefaultNamingContextSearchBase)+
|
|
"&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue,
|
|
) + "$",
|
|
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
|
|
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
|
|
return "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$"
|
|
},
|
|
wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames,
|
|
},
|
|
{
|
|
name: "active directory login fails after the user password is changed",
|
|
maybeSkip: skipActiveDirectoryTests,
|
|
createIDP: func(t *testing.T) string {
|
|
idp, _ := createActiveDirectoryIdentityProvider(t, nil)
|
|
return idp.Name
|
|
},
|
|
testUser: func(t *testing.T) (string, string) {
|
|
return testlib.CreateFreshADTestUser(t, env)
|
|
},
|
|
requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, testUserName, testUserPassword string, httpClient *http.Client) {
|
|
requestAuthorizationUsingCLIPasswordFlow(t,
|
|
downstreamAuthorizeURL,
|
|
testUserName, // username to present to server during login
|
|
testUserPassword, // password to present to server during login
|
|
httpClient,
|
|
false,
|
|
)
|
|
},
|
|
breakRefreshSessionData: func(t *testing.T, sessionData *psession.PinnipedSession, _, username string) {
|
|
testlib.ChangeADTestUserPassword(t, env, username)
|
|
},
|
|
// we can't know the subject ahead of time because we created a new user and don't know their uid,
|
|
// so skip wantDownstreamIDTokenSubjectToMatch
|
|
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
|
|
wantDownstreamIDTokenUsernameToMatch: func(username string) string {
|
|
return "^" + regexp.QuoteMeta(username+"@"+env.SupervisorUpstreamActiveDirectory.Domain) + "$"
|
|
},
|
|
wantDownstreamIDTokenGroups: []string{}, // none for now.
|
|
},
|
|
{
|
|
name: "active directory login fails after the user is deactivated",
|
|
maybeSkip: skipActiveDirectoryTests,
|
|
createIDP: func(t *testing.T) string {
|
|
idp, _ := createActiveDirectoryIdentityProvider(t, nil)
|
|
return idp.Name
|
|
},
|
|
testUser: func(t *testing.T) (string, string) {
|
|
return testlib.CreateFreshADTestUser(t, env)
|
|
},
|
|
requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, testUserName, testUserPassword string, httpClient *http.Client) {
|
|
requestAuthorizationUsingCLIPasswordFlow(t,
|
|
downstreamAuthorizeURL,
|
|
testUserName, // username to present to server during login
|
|
testUserPassword, // password to present to server during login
|
|
httpClient,
|
|
false,
|
|
)
|
|
},
|
|
breakRefreshSessionData: func(t *testing.T, sessionData *psession.PinnipedSession, _, username string) {
|
|
testlib.DeactivateADTestUser(t, env, username)
|
|
},
|
|
// we can't know the subject ahead of time because we created a new user and don't know their uid,
|
|
// so skip wantDownstreamIDTokenSubjectToMatch
|
|
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
|
|
wantDownstreamIDTokenUsernameToMatch: func(username string) string {
|
|
return "^" + regexp.QuoteMeta(username+"@"+env.SupervisorUpstreamActiveDirectory.Domain) + "$"
|
|
},
|
|
wantDownstreamIDTokenGroups: []string{}, // none for now.
|
|
},
|
|
{
|
|
name: "active directory login fails after the user is locked",
|
|
maybeSkip: skipActiveDirectoryTests,
|
|
createIDP: func(t *testing.T) string {
|
|
idp, _ := createActiveDirectoryIdentityProvider(t, nil)
|
|
return idp.Name
|
|
},
|
|
testUser: func(t *testing.T) (string, string) {
|
|
return testlib.CreateFreshADTestUser(t, env)
|
|
},
|
|
requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, testUserName, testUserPassword string, httpClient *http.Client) {
|
|
requestAuthorizationUsingCLIPasswordFlow(t,
|
|
downstreamAuthorizeURL,
|
|
testUserName, // username to present to server during login
|
|
testUserPassword, // password to present to server during login
|
|
httpClient,
|
|
false,
|
|
)
|
|
},
|
|
breakRefreshSessionData: func(t *testing.T, sessionData *psession.PinnipedSession, _, username string) {
|
|
testlib.LockADTestUser(t, env, username)
|
|
},
|
|
// we can't know the subject ahead of time because we created a new user and don't know their uid,
|
|
// so skip wantDownstreamIDTokenSubjectToMatch
|
|
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
|
|
wantDownstreamIDTokenUsernameToMatch: func(username string) string {
|
|
return "^" + regexp.QuoteMeta(username+"@"+env.SupervisorUpstreamActiveDirectory.Domain) + "$"
|
|
},
|
|
wantDownstreamIDTokenGroups: []string{},
|
|
},
|
|
{
|
|
name: "logging in to active directory with a deactivated user fails",
|
|
maybeSkip: skipActiveDirectoryTests,
|
|
createIDP: func(t *testing.T) string {
|
|
idp, _ := createActiveDirectoryIdentityProvider(t, nil)
|
|
return idp.Name
|
|
},
|
|
requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) {
|
|
requestAuthorizationUsingCLIPasswordFlow(t,
|
|
downstreamAuthorizeURL,
|
|
env.SupervisorUpstreamActiveDirectory.TestDeactivatedUserSAMAccountNameValue, // username to present to server during login
|
|
env.SupervisorUpstreamActiveDirectory.TestDeactivatedUserPassword, // password to present to server during login
|
|
httpClient,
|
|
true,
|
|
)
|
|
},
|
|
breakRefreshSessionData: nil,
|
|
wantAuthorizationErrorDescription: "The resource owner or authorization server denied the request. Username/password not accepted by LDAP provider.",
|
|
wantAuthorizationErrorType: "access_denied",
|
|
},
|
|
{
|
|
name: "ldap refresh fails when username changes from email as username to dn as username",
|
|
maybeSkip: skipLDAPTests,
|
|
createIDP: func(t *testing.T) string {
|
|
idp, _ := createLDAPIdentityProvider(t, nil)
|
|
return idp.Name
|
|
},
|
|
requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) {
|
|
requestAuthorizationUsingCLIPasswordFlow(t,
|
|
downstreamAuthorizeURL,
|
|
env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
|
|
env.SupervisorUpstreamLDAP.TestUserPassword, // password to present to server during login
|
|
httpClient,
|
|
false,
|
|
)
|
|
},
|
|
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, idpName, _ string) {
|
|
// get the idp, update the config.
|
|
client := testlib.NewSupervisorClientset(t)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
defer cancel()
|
|
|
|
upstreams := client.IDPV1alpha1().LDAPIdentityProviders(env.SupervisorNamespace)
|
|
ldapIDP, err := upstreams.Get(ctx, idpName, metav1.GetOptions{})
|
|
require.NoError(t, err)
|
|
ldapIDP.Spec.UserSearch.Attributes.Username = "dn"
|
|
|
|
_, err = upstreams.Update(ctx, ldapIDP, metav1.UpdateOptions{})
|
|
require.NoError(t, err)
|
|
time.Sleep(10 * time.Second) // wait for controllers to pick up the change
|
|
},
|
|
// the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute
|
|
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(
|
|
"ldaps://"+env.SupervisorUpstreamLDAP.Host+
|
|
"?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+
|
|
"&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)),
|
|
) + "$",
|
|
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
|
|
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
|
|
return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$"
|
|
},
|
|
wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs,
|
|
},
|
|
{
|
|
name: "ldap refresh updates groups to be empty after deleting the group search base",
|
|
maybeSkip: skipLDAPTests,
|
|
createIDP: func(t *testing.T) string {
|
|
idp, _ := createLDAPIdentityProvider(t, nil)
|
|
return idp.Name
|
|
},
|
|
requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) {
|
|
requestAuthorizationUsingCLIPasswordFlow(t,
|
|
downstreamAuthorizeURL,
|
|
env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
|
|
env.SupervisorUpstreamLDAP.TestUserPassword, // password to present to server during login
|
|
httpClient,
|
|
false,
|
|
)
|
|
},
|
|
editRefreshSessionDataWithoutBreaking: func(t *testing.T, pinnipedSession *psession.PinnipedSession, idpName, _ string) []string {
|
|
// get the idp, update the config.
|
|
client := testlib.NewSupervisorClientset(t)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
defer cancel()
|
|
|
|
upstreams := client.IDPV1alpha1().LDAPIdentityProviders(env.SupervisorNamespace)
|
|
ldapIDP, err := upstreams.Get(ctx, idpName, metav1.GetOptions{})
|
|
require.NoError(t, err)
|
|
ldapIDP.Spec.GroupSearch.Base = ""
|
|
|
|
_, err = upstreams.Update(ctx, ldapIDP, metav1.UpdateOptions{})
|
|
require.NoError(t, err)
|
|
time.Sleep(10 * time.Second) // wait for controllers to pick up the change
|
|
return []string{}
|
|
},
|
|
// the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute
|
|
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(
|
|
"ldaps://"+env.SupervisorUpstreamLDAP.Host+
|
|
"?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+
|
|
"&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)),
|
|
) + "$",
|
|
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
|
|
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
|
|
return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$"
|
|
},
|
|
wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs,
|
|
},
|
|
{
|
|
name: "disallowed requested audience using reserved substring on token exchange results in token exchange error",
|
|
maybeSkip: skipNever,
|
|
createIDP: func(t *testing.T) string {
|
|
return testlib.CreateTestOIDCIdentityProvider(t, basicOIDCIdentityProviderSpec(), idpv1alpha1.PhaseReady).Name
|
|
},
|
|
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC,
|
|
requestTokenExchangeAud: "contains-disallowed-substring.pinniped.dev-something", // .pinniped.dev substring is not allowed
|
|
// 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: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+" },
|
|
wantTokenExchangeResponse: func(t *testing.T, status int, body string) {
|
|
require.Equal(t, http.StatusBadRequest, status)
|
|
require.Equal(t,
|
|
`{"error":"invalid_request","error_description":"The request is missing a required parameter, `+
|
|
`includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed. `+
|
|
`requested audience cannot contain '.pinniped.dev'"}`,
|
|
body)
|
|
},
|
|
},
|
|
{
|
|
name: "disallowed requested audience using specific reserved name of a dynamic client on token exchange results in token exchange error",
|
|
maybeSkip: skipNever,
|
|
createIDP: func(t *testing.T) string {
|
|
return testlib.CreateTestOIDCIdentityProvider(t, basicOIDCIdentityProviderSpec(), idpv1alpha1.PhaseReady).Name
|
|
},
|
|
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC,
|
|
requestTokenExchangeAud: "client.oauth.pinniped.dev-client-name", // OIDC dynamic client name is not allowed
|
|
// 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: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+" },
|
|
wantTokenExchangeResponse: func(t *testing.T, status int, body string) {
|
|
require.Equal(t, http.StatusBadRequest, status)
|
|
require.Equal(t,
|
|
`{"error":"invalid_request","error_description":"The request is missing a required parameter, `+
|
|
`includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed. `+
|
|
`requested audience cannot contain '.pinniped.dev'"}`,
|
|
body)
|
|
},
|
|
},
|
|
{
|
|
name: "disallowed requested audience pinniped-cli on token exchange results in token exchange error",
|
|
maybeSkip: skipNever,
|
|
createIDP: func(t *testing.T) string {
|
|
return testlib.CreateTestOIDCIdentityProvider(t, basicOIDCIdentityProviderSpec(), idpv1alpha1.PhaseReady).Name
|
|
},
|
|
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC,
|
|
requestTokenExchangeAud: "pinniped-cli", // pinniped-cli is not allowed
|
|
// 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: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+" },
|
|
wantTokenExchangeResponse: func(t *testing.T, status int, body string) {
|
|
require.Equal(t, http.StatusBadRequest, status)
|
|
require.Equal(t,
|
|
`{"error":"invalid_request","error_description":"The request is missing a required parameter, `+
|
|
`includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed. `+
|
|
`requested audience cannot equal 'pinniped-cli'"}`,
|
|
body)
|
|
},
|
|
},
|
|
{
|
|
name: "oidc upstream with downstream dynamic client happy path",
|
|
maybeSkip: skipNever,
|
|
createIDP: func(t *testing.T) string {
|
|
return testlib.CreateTestOIDCIdentityProvider(t, basicOIDCIdentityProviderSpec(), idpv1alpha1.PhaseReady).Name
|
|
},
|
|
createOIDCClient: func(t *testing.T, callbackURL string) (string, string) {
|
|
return testlib.CreateOIDCClient(t, configv1alpha1.OIDCClientSpec{
|
|
AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)},
|
|
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"},
|
|
AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"},
|
|
}, configv1alpha1.PhaseReady)
|
|
},
|
|
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC,
|
|
// 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: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+" },
|
|
},
|
|
{
|
|
name: "ldap upstream with downstream dynamic client happy path",
|
|
maybeSkip: skipLDAPTests,
|
|
createIDP: func(t *testing.T) string {
|
|
idp, _ := createLDAPIdentityProvider(t, nil)
|
|
return idp.Name
|
|
},
|
|
createOIDCClient: func(t *testing.T, callbackURL string) (string, string) {
|
|
return testlib.CreateOIDCClient(t, configv1alpha1.OIDCClientSpec{
|
|
AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)},
|
|
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"},
|
|
AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"},
|
|
}, configv1alpha1.PhaseReady)
|
|
},
|
|
testUser: func(t *testing.T) (string, string) {
|
|
// return the username and password of the existing user that we want to use for this test
|
|
return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
|
|
env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login
|
|
},
|
|
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAP,
|
|
// the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute
|
|
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(
|
|
"ldaps://"+env.SupervisorUpstreamLDAP.Host+
|
|
"?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+
|
|
"&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)),
|
|
) + "$",
|
|
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
|
|
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
|
|
return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$"
|
|
},
|
|
wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs,
|
|
},
|
|
{
|
|
name: "active directory with all default options with downstream dynamic client happy path",
|
|
maybeSkip: skipActiveDirectoryTests,
|
|
createIDP: func(t *testing.T) string {
|
|
idp, _ := createActiveDirectoryIdentityProvider(t, nil)
|
|
return idp.Name
|
|
},
|
|
createOIDCClient: func(t *testing.T, callbackURL string) (string, string) {
|
|
return testlib.CreateOIDCClient(t, configv1alpha1.OIDCClientSpec{
|
|
AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)},
|
|
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"},
|
|
AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"},
|
|
}, configv1alpha1.PhaseReady)
|
|
},
|
|
requestAuthorization: func(t *testing.T, downstreamIssuer, downstreamAuthorizeURL, downstreamCallbackURL, _, _ string, httpClient *http.Client) {
|
|
requestAuthorizationUsingBrowserAuthcodeFlowLDAP(t,
|
|
downstreamIssuer,
|
|
downstreamAuthorizeURL,
|
|
downstreamCallbackURL,
|
|
env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue, // username to present to server during login
|
|
env.SupervisorUpstreamActiveDirectory.TestUserPassword, // password to present to server during login
|
|
httpClient,
|
|
)
|
|
},
|
|
// the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute
|
|
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(
|
|
"ldaps://"+env.SupervisorUpstreamActiveDirectory.Host+
|
|
"?base="+url.QueryEscape(env.SupervisorUpstreamActiveDirectory.DefaultNamingContextSearchBase)+
|
|
"&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue,
|
|
) + "$",
|
|
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
|
|
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
|
|
return "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$"
|
|
},
|
|
wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames,
|
|
},
|
|
{
|
|
name: "ldap upstream with downstream dynamic client, failed client authentication",
|
|
maybeSkip: skipLDAPTests,
|
|
createIDP: func(t *testing.T) string {
|
|
idp, _ := createLDAPIdentityProvider(t, nil)
|
|
return idp.Name
|
|
},
|
|
createOIDCClient: func(t *testing.T, callbackURL string) (string, string) {
|
|
clientID, _ := testlib.CreateOIDCClient(t, configv1alpha1.OIDCClientSpec{
|
|
AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)},
|
|
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"},
|
|
AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"},
|
|
}, configv1alpha1.PhaseReady)
|
|
return clientID, "wrong-client-secret"
|
|
},
|
|
testUser: func(t *testing.T) (string, string) {
|
|
// return the username and password of the existing user that we want to use for this test
|
|
return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
|
|
env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login
|
|
},
|
|
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAP,
|
|
wantAuthcodeExchangeError: "oauth2: cannot fetch token: 401 Unauthorized\n" +
|
|
`Response: {"error":"invalid_client","error_description":"Client authentication failed (e.g., unknown client, no client authentication included, or unsupported authentication method)."}`,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
tt := test
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tt.maybeSkip(t)
|
|
|
|
testSupervisorLogin(
|
|
t,
|
|
tt.createIDP,
|
|
tt.requestAuthorization,
|
|
tt.editRefreshSessionDataWithoutBreaking,
|
|
tt.breakRefreshSessionData,
|
|
tt.testUser,
|
|
tt.createOIDCClient,
|
|
tt.downstreamScopes,
|
|
tt.requestTokenExchangeAud,
|
|
tt.wantLocalhostCallbackToNeverHappen,
|
|
tt.wantDownstreamIDTokenSubjectToMatch,
|
|
tt.wantDownstreamIDTokenUsernameToMatch,
|
|
tt.wantDownstreamIDTokenGroups,
|
|
tt.wantAuthorizationErrorType,
|
|
tt.wantAuthorizationErrorDescription,
|
|
tt.wantAuthcodeExchangeError,
|
|
tt.wantTokenExchangeResponse,
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
func requireSuccessfulLDAPIdentityProviderConditions(t *testing.T, ldapIDP *idpv1alpha1.LDAPIdentityProvider, expectedLDAPConnectionValidMessage string) {
|
|
require.Len(t, ldapIDP.Status.Conditions, 3)
|
|
|
|
conditionsSummary := [][]string{}
|
|
for _, condition := range ldapIDP.Status.Conditions {
|
|
conditionsSummary = append(conditionsSummary, []string{condition.Type, string(condition.Status), condition.Reason})
|
|
t.Logf("Saw LDAPIdentityProvider Status.Condition Type=%s Status=%s Reason=%s Message=%s",
|
|
condition.Type, string(condition.Status), condition.Reason, condition.Message)
|
|
switch condition.Type {
|
|
case "BindSecretValid":
|
|
require.Equal(t, "loaded bind secret", condition.Message)
|
|
case "TLSConfigurationValid":
|
|
require.Equal(t, "loaded TLS configuration", condition.Message)
|
|
case "LDAPConnectionValid":
|
|
require.Equal(t, expectedLDAPConnectionValidMessage, condition.Message)
|
|
}
|
|
}
|
|
|
|
require.ElementsMatch(t, [][]string{
|
|
{"BindSecretValid", "True", "Success"},
|
|
{"TLSConfigurationValid", "True", "Success"},
|
|
{"LDAPConnectionValid", "True", "Success"},
|
|
}, conditionsSummary)
|
|
}
|
|
|
|
func requireSuccessfulActiveDirectoryIdentityProviderConditions(t *testing.T, adIDP *idpv1alpha1.ActiveDirectoryIdentityProvider, expectedActiveDirectoryConnectionValidMessage string) {
|
|
require.Len(t, adIDP.Status.Conditions, 4)
|
|
|
|
conditionsSummary := [][]string{}
|
|
for _, condition := range adIDP.Status.Conditions {
|
|
conditionsSummary = append(conditionsSummary, []string{condition.Type, string(condition.Status), condition.Reason})
|
|
t.Logf("Saw ActiveDirectoryIdentityProvider Status.Condition Type=%s Status=%s Reason=%s Message=%s",
|
|
condition.Type, string(condition.Status), condition.Reason, condition.Message)
|
|
switch condition.Type {
|
|
case "BindSecretValid":
|
|
require.Equal(t, "loaded bind secret", condition.Message)
|
|
case "TLSConfigurationValid":
|
|
require.Equal(t, "loaded TLS configuration", condition.Message)
|
|
case "LDAPConnectionValid":
|
|
require.Equal(t, expectedActiveDirectoryConnectionValidMessage, condition.Message)
|
|
}
|
|
}
|
|
|
|
expectedUserSearchReason := ""
|
|
if adIDP.Spec.UserSearch.Base == "" || adIDP.Spec.GroupSearch.Base == "" {
|
|
expectedUserSearchReason = "Success"
|
|
} else {
|
|
expectedUserSearchReason = "UsingConfigurationFromSpec"
|
|
}
|
|
|
|
require.ElementsMatch(t, [][]string{
|
|
{"BindSecretValid", "True", "Success"},
|
|
{"TLSConfigurationValid", "True", "Success"},
|
|
{"LDAPConnectionValid", "True", "Success"},
|
|
{"SearchBaseFound", "True", expectedUserSearchReason},
|
|
}, conditionsSummary)
|
|
}
|
|
|
|
func requireEventuallySuccessfulLDAPIdentityProviderConditions(t *testing.T, requireEventually *require.Assertions, ldapIDP *idpv1alpha1.LDAPIdentityProvider, expectedLDAPConnectionValidMessage string) {
|
|
t.Helper()
|
|
requireEventually.Len(ldapIDP.Status.Conditions, 3)
|
|
|
|
conditionsSummary := [][]string{}
|
|
for _, condition := range ldapIDP.Status.Conditions {
|
|
conditionsSummary = append(conditionsSummary, []string{condition.Type, string(condition.Status), condition.Reason})
|
|
t.Logf("Saw ActiveDirectoryIdentityProvider Status.Condition Type=%s Status=%s Reason=%s Message=%s",
|
|
condition.Type, string(condition.Status), condition.Reason, condition.Message)
|
|
switch condition.Type {
|
|
case "BindSecretValid":
|
|
requireEventually.Equal("loaded bind secret", condition.Message)
|
|
case "TLSConfigurationValid":
|
|
requireEventually.Equal("loaded TLS configuration", condition.Message)
|
|
case "LDAPConnectionValid":
|
|
requireEventually.Equal(expectedLDAPConnectionValidMessage, condition.Message)
|
|
}
|
|
}
|
|
|
|
requireEventually.ElementsMatch([][]string{
|
|
{"BindSecretValid", "True", "Success"},
|
|
{"TLSConfigurationValid", "True", "Success"},
|
|
{"LDAPConnectionValid", "True", "Success"},
|
|
}, conditionsSummary)
|
|
}
|
|
|
|
func requireEventuallySuccessfulActiveDirectoryIdentityProviderConditions(t *testing.T, requireEventually *require.Assertions, adIDP *idpv1alpha1.ActiveDirectoryIdentityProvider, expectedActiveDirectoryConnectionValidMessage string) {
|
|
t.Helper()
|
|
requireEventually.Len(adIDP.Status.Conditions, 4)
|
|
|
|
conditionsSummary := [][]string{}
|
|
for _, condition := range adIDP.Status.Conditions {
|
|
conditionsSummary = append(conditionsSummary, []string{condition.Type, string(condition.Status), condition.Reason})
|
|
t.Logf("Saw ActiveDirectoryIdentityProvider Status.Condition Type=%s Status=%s Reason=%s Message=%s",
|
|
condition.Type, string(condition.Status), condition.Reason, condition.Message)
|
|
switch condition.Type {
|
|
case "BindSecretValid":
|
|
requireEventually.Equal("loaded bind secret", condition.Message)
|
|
case "TLSConfigurationValid":
|
|
requireEventually.Equal("loaded TLS configuration", condition.Message)
|
|
case "LDAPConnectionValid":
|
|
requireEventually.Equal(expectedActiveDirectoryConnectionValidMessage, condition.Message)
|
|
}
|
|
}
|
|
|
|
expectedUserSearchReason := ""
|
|
if adIDP.Spec.UserSearch.Base == "" || adIDP.Spec.GroupSearch.Base == "" {
|
|
expectedUserSearchReason = "Success"
|
|
} else {
|
|
expectedUserSearchReason = "UsingConfigurationFromSpec"
|
|
}
|
|
|
|
requireEventually.ElementsMatch([][]string{
|
|
{"BindSecretValid", "True", "Success"},
|
|
{"TLSConfigurationValid", "True", "Success"},
|
|
{"LDAPConnectionValid", "True", "Success"},
|
|
{"SearchBaseFound", "True", expectedUserSearchReason},
|
|
}, conditionsSummary)
|
|
}
|
|
|
|
func testSupervisorLogin(
|
|
t *testing.T,
|
|
createIDP func(t *testing.T) string,
|
|
requestAuthorization func(t *testing.T, downstreamIssuer string, downstreamAuthorizeURL string, downstreamCallbackURL string, username string, password string, httpClient *http.Client),
|
|
editRefreshSessionDataWithoutBreaking func(t *testing.T, pinnipedSession *psession.PinnipedSession, idpName string, username string) []string,
|
|
breakRefreshSessionData func(t *testing.T, pinnipedSession *psession.PinnipedSession, idpName string, username string),
|
|
testUser func(t *testing.T) (string, string),
|
|
createOIDCClient func(t *testing.T, callbackURL string) (string, string),
|
|
downstreamScopes []string,
|
|
requestTokenExchangeAud string,
|
|
wantLocalhostCallbackToNeverHappen bool,
|
|
wantDownstreamIDTokenSubjectToMatch string,
|
|
wantDownstreamIDTokenUsernameToMatch func(username string) string,
|
|
wantDownstreamIDTokenGroups []string,
|
|
wantAuthorizationErrorType string,
|
|
wantAuthorizationErrorDescription string,
|
|
wantAuthcodeExchangeError string,
|
|
wantTokenExchangeResponse func(t *testing.T, status int, body string),
|
|
) {
|
|
env := testlib.IntegrationEnv(t)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
defer cancel()
|
|
|
|
// Infer the downstream issuer URL from the callback associated with the upstream test client registration.
|
|
issuerURL, err := url.Parse(env.SupervisorUpstreamOIDC.CallbackURL)
|
|
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")
|
|
ca, err := certauthority.New("Downstream Test CA", 1*time.Hour)
|
|
require.NoError(t, err)
|
|
|
|
// Create an HTTP client that can reach the downstream discovery endpoint using the CA certs.
|
|
httpClient := &http.Client{
|
|
Transport: &http.Transport{
|
|
TLSClientConfig: &tls.Config{RootCAs: ca.Pool()}, //nolint:gosec // not concerned with TLS MinVersion here
|
|
Proxy: func(req *http.Request) (*url.URL, error) {
|
|
if strings.HasPrefix(req.URL.Host, "127.0.0.1") {
|
|
// don't proxy requests to localhost to avoid proxying calls to our local callback listener
|
|
return nil, nil
|
|
}
|
|
if env.Proxy == "" {
|
|
t.Logf("passing request for %s with no proxy", testlib.RedactURLParams(req.URL))
|
|
return nil, nil
|
|
}
|
|
proxyURL, err := url.Parse(env.Proxy)
|
|
require.NoError(t, err)
|
|
t.Logf("passing request for %s through proxy %s", testlib.RedactURLParams(req.URL), proxyURL.String())
|
|
return proxyURL, nil
|
|
},
|
|
},
|
|
// Don't follow redirects automatically.
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
oidcHTTPClientContext := coreosoidc.ClientContext(ctx, httpClient)
|
|
|
|
// Use the CA to issue a TLS server cert.
|
|
t.Logf("issuing test certificate")
|
|
tlsCert, err := ca.IssueServerCert([]string{issuerURL.Hostname()}, nil, 1*time.Hour)
|
|
require.NoError(t, err)
|
|
certPEM, keyPEM, err := certauthority.ToPEM(tlsCert)
|
|
require.NoError(t, err)
|
|
|
|
// Write the serving cert to a secret.
|
|
certSecret := testlib.CreateTestSecret(t,
|
|
env.SupervisorNamespace,
|
|
"oidc-provider-tls",
|
|
v1.SecretTypeTLS,
|
|
map[string]string{"tls.crt": string(certPEM), "tls.key": string(keyPEM)},
|
|
)
|
|
|
|
// Create the downstream FederationDomain and expect it to go into the success status condition.
|
|
downstream := testlib.CreateTestFederationDomain(ctx, t,
|
|
issuerURL.String(),
|
|
certSecret.Name,
|
|
configv1alpha1.SuccessFederationDomainStatusCondition,
|
|
)
|
|
|
|
// Ensure the the JWKS data is created and ready for the new FederationDomain by waiting for
|
|
// the `/jwks.json` endpoint to succeed, because there is no point in proceeding and eventually
|
|
// calling the token endpoint from this test until the JWKS data has been loaded into
|
|
// the server's in-memory JWKS cache for the token endpoint to use.
|
|
requestJWKSEndpoint, err := http.NewRequestWithContext(
|
|
ctx,
|
|
http.MethodGet,
|
|
fmt.Sprintf("%s/jwks.json", issuerURL.String()),
|
|
nil,
|
|
)
|
|
require.NoError(t, err)
|
|
testlib.RequireEventually(t, func(requireEventually *require.Assertions) {
|
|
rsp, err := httpClient.Do(requestJWKSEndpoint)
|
|
requireEventually.NoError(err)
|
|
requireEventually.NoError(rsp.Body.Close())
|
|
requireEventually.Equal(http.StatusOK, rsp.StatusCode)
|
|
}, 30*time.Second, 200*time.Millisecond)
|
|
|
|
// Create upstream IDP and wait for it to become ready.
|
|
idpName := createIDP(t)
|
|
|
|
// Start a callback server on localhost.
|
|
localCallbackServer := startLocalCallbackServer(t)
|
|
|
|
// Optionally create an OIDCClient. Default to using the hardcoded public client that the Supervisor supports.
|
|
clientID, clientSecret := "pinniped-cli", "" //nolint:gosec // empty credential is not a hardcoded credential
|
|
if createOIDCClient != nil {
|
|
clientID, clientSecret = createOIDCClient(t, localCallbackServer.URL)
|
|
}
|
|
|
|
// Optionally override which user to use for the test, or choose zero values to mean use the default for
|
|
// the test's IDP.
|
|
username, password := "", ""
|
|
if testUser != nil {
|
|
username, password = testUser(t)
|
|
}
|
|
|
|
// Perform OIDC discovery for our downstream.
|
|
var discovery *coreosoidc.Provider
|
|
testlib.RequireEventually(t, func(requireEventually *require.Assertions) {
|
|
var err error
|
|
discovery, err = coreosoidc.NewProvider(oidcHTTPClientContext, downstream.Spec.Issuer)
|
|
requireEventually.NoError(err)
|
|
}, 30*time.Second, 200*time.Millisecond)
|
|
|
|
if downstreamScopes == nil {
|
|
downstreamScopes = []string{"openid", "pinniped:request-audience", "offline_access", "groups"}
|
|
}
|
|
|
|
// Create the OAuth2 configuration.
|
|
// Note that this is not using response_type=form_post, so the Supervisor will redirect to the callback endpoint
|
|
// directly, without using the Javascript form_post HTML page to POST back to the callback endpoint. The e2e
|
|
// tests which use the Pinniped CLI are testing the form_post part of the flow, so that is covered elsewhere.
|
|
// When ClientSecret is set here, it will be used for all token endpoint requests, but not for the authorization
|
|
// request, where it is not needed.
|
|
endpoint := discovery.Endpoint()
|
|
if clientSecret != "" {
|
|
// We only support basic auth for dynamic clients, so use basic auth in these tests.
|
|
endpoint.AuthStyle = oauth2.AuthStyleInHeader
|
|
}
|
|
downstreamOAuth2Config := oauth2.Config{
|
|
ClientID: clientID,
|
|
ClientSecret: clientSecret,
|
|
Endpoint: endpoint,
|
|
RedirectURL: localCallbackServer.URL,
|
|
Scopes: downstreamScopes,
|
|
}
|
|
|
|
// Build a valid downstream authorize URL for the supervisor.
|
|
stateParam, err := state.Generate()
|
|
require.NoError(t, err)
|
|
nonceParam, err := nonce.Generate()
|
|
require.NoError(t, err)
|
|
pkceParam, err := pkce.Generate()
|
|
require.NoError(t, err)
|
|
downstreamAuthorizeURL := downstreamOAuth2Config.AuthCodeURL(
|
|
stateParam.String(),
|
|
nonceParam.Param(),
|
|
pkceParam.Challenge(),
|
|
pkceParam.Method(),
|
|
)
|
|
|
|
// Perform parameterized auth code acquisition.
|
|
requestAuthorization(t, downstream.Spec.Issuer, downstreamAuthorizeURL, localCallbackServer.URL, username, password, httpClient)
|
|
|
|
// Expect that our callback handler was invoked.
|
|
callback, err := localCallbackServer.waitForCallback(10 * time.Second)
|
|
if wantLocalhostCallbackToNeverHappen {
|
|
require.Error(t, err)
|
|
// When we want the localhost callback to have never happened, then this is the end of the test. The login was
|
|
// unable to finish so there is nothing to assert about what should have happened with the callback, and there
|
|
// won't be any error sent to the callback either.
|
|
return
|
|
}
|
|
// Else, no error.
|
|
require.NoError(t, err)
|
|
|
|
t.Logf("got callback request: %s", testlib.MaskTokens(callback.URL.String()))
|
|
|
|
if wantAuthorizationErrorType != "" {
|
|
errorDescription := callback.URL.Query().Get("error_description")
|
|
errorType := callback.URL.Query().Get("error")
|
|
require.Equal(t, errorDescription, wantAuthorizationErrorDescription)
|
|
require.Equal(t, errorType, wantAuthorizationErrorType)
|
|
// The authorization has failed, so can't continue the login flow, making this the end of the test case.
|
|
return
|
|
}
|
|
|
|
require.Equal(t, stateParam.String(), callback.URL.Query().Get("state"))
|
|
require.ElementsMatch(t, downstreamScopes, strings.Split(callback.URL.Query().Get("scope"), " "))
|
|
authcode := callback.URL.Query().Get("code")
|
|
require.NotEmpty(t, authcode)
|
|
|
|
// Authcodes should start with the custom prefix "pin_ac_" to make them identifiable as authcodes when seen by a user out of context.
|
|
require.True(t, strings.HasPrefix(authcode, "pin_ac_"), "token %q did not have expected prefix 'pin_ac_'", authcode)
|
|
|
|
// Call the token endpoint to get tokens.
|
|
tokenResponse, err := downstreamOAuth2Config.Exchange(oidcHTTPClientContext, authcode, pkceParam.Verifier())
|
|
if wantAuthcodeExchangeError != "" {
|
|
require.EqualError(t, err, wantAuthcodeExchangeError)
|
|
// The authcode exchange has failed, so can't continue the login flow, making this the end of the test case.
|
|
return
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
expectedIDTokenClaims := []string{"iss", "exp", "sub", "aud", "auth_time", "iat", "jti", "nonce", "rat", "username"}
|
|
if slices.Contains(downstreamScopes, "groups") {
|
|
expectedIDTokenClaims = append(expectedIDTokenClaims, "groups")
|
|
}
|
|
verifyTokenResponse(t,
|
|
tokenResponse, discovery, downstreamOAuth2Config, nonceParam,
|
|
expectedIDTokenClaims, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch(username), wantDownstreamIDTokenGroups)
|
|
|
|
// token exchange on the original token
|
|
if requestTokenExchangeAud == "" {
|
|
requestTokenExchangeAud = "some-cluster-123" // use a default test value
|
|
}
|
|
doTokenExchange(t, requestTokenExchangeAud, &downstreamOAuth2Config, tokenResponse, httpClient, discovery, wantTokenExchangeResponse)
|
|
|
|
refreshedGroups := wantDownstreamIDTokenGroups
|
|
if editRefreshSessionDataWithoutBreaking != nil {
|
|
latestRefreshToken := tokenResponse.RefreshToken
|
|
signatureOfLatestRefreshToken := getFositeDataSignature(t, latestRefreshToken)
|
|
|
|
// First use the latest downstream refresh token to look up the corresponding session in the Supervisor's storage.
|
|
supervisorSecretsClient := testlib.NewKubernetesClientset(t).CoreV1().Secrets(env.SupervisorNamespace)
|
|
supervisorOIDCClientsClient := testlib.NewSupervisorClientset(t).ConfigV1alpha1().OIDCClients(env.SupervisorNamespace)
|
|
oauthStore := oidc.NewKubeStorage(supervisorSecretsClient, supervisorOIDCClientsClient, oidc.DefaultOIDCTimeoutsConfiguration(), oidcclientvalidator.DefaultMinBcryptCost)
|
|
storedRefreshSession, err := oauthStore.GetRefreshTokenSession(ctx, signatureOfLatestRefreshToken, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Next mutate the part of the session that is used during upstream refresh.
|
|
pinnipedSession, ok := storedRefreshSession.GetSession().(*psession.PinnipedSession)
|
|
require.True(t, ok, "should have been able to cast session data to PinnipedSession")
|
|
|
|
refreshedGroups = editRefreshSessionDataWithoutBreaking(t, pinnipedSession, idpName, username)
|
|
|
|
// Then save the mutated Secret back to Kubernetes.
|
|
// There is no update function, so delete and create again at the same name.
|
|
require.NoError(t, oauthStore.DeleteRefreshTokenSession(ctx, signatureOfLatestRefreshToken))
|
|
require.NoError(t, oauthStore.CreateRefreshTokenSession(ctx, signatureOfLatestRefreshToken, storedRefreshSession))
|
|
}
|
|
// 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", "at_hash"}
|
|
if slices.Contains(downstreamScopes, "groups") {
|
|
expectRefreshedIDTokenClaims = append(expectRefreshedIDTokenClaims, "groups")
|
|
}
|
|
verifyTokenResponse(t,
|
|
refreshedTokenResponse, discovery, downstreamOAuth2Config, "",
|
|
expectRefreshedIDTokenClaims, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch(username), refreshedGroups)
|
|
|
|
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, requestTokenExchangeAud, &downstreamOAuth2Config, refreshedTokenResponse, httpClient, discovery, wantTokenExchangeResponse)
|
|
|
|
// Now that we have successfully performed a refresh, let's test what happens when an
|
|
// upstream refresh fails during the next downstream refresh.
|
|
if breakRefreshSessionData != nil {
|
|
latestRefreshToken := refreshedTokenResponse.RefreshToken
|
|
signatureOfLatestRefreshToken := getFositeDataSignature(t, latestRefreshToken)
|
|
|
|
// First use the latest downstream refresh token to look up the corresponding session in the Supervisor's storage.
|
|
supervisorSecretsClient := testlib.NewKubernetesClientset(t).CoreV1().Secrets(env.SupervisorNamespace)
|
|
supervisorOIDCClientsClient := testlib.NewSupervisorClientset(t).ConfigV1alpha1().OIDCClients(env.SupervisorNamespace)
|
|
oauthStore := oidc.NewKubeStorage(supervisorSecretsClient, supervisorOIDCClientsClient, oidc.DefaultOIDCTimeoutsConfiguration(), oidcclientvalidator.DefaultMinBcryptCost)
|
|
storedRefreshSession, err := oauthStore.GetRefreshTokenSession(ctx, signatureOfLatestRefreshToken, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Next mutate the part of the session that is used during upstream refresh.
|
|
pinnipedSession, ok := storedRefreshSession.GetSession().(*psession.PinnipedSession)
|
|
require.True(t, ok, "should have been able to cast session data to PinnipedSession")
|
|
breakRefreshSessionData(t, pinnipedSession, idpName, username)
|
|
|
|
// Then save the mutated Secret back to Kubernetes.
|
|
// There is no update function, so delete and create again at the same name.
|
|
require.NoError(t, oauthStore.DeleteRefreshTokenSession(ctx, signatureOfLatestRefreshToken))
|
|
require.NoError(t, oauthStore.CreateRefreshTokenSession(ctx, signatureOfLatestRefreshToken, storedRefreshSession))
|
|
|
|
// Now try to perform a downstream refresh again, knowing that the corresponding upstream refresh should fail.
|
|
_, err = downstreamOAuth2Config.TokenSource(oidcHTTPClientContext, &oauth2.Token{RefreshToken: latestRefreshToken}).Token()
|
|
// Should have got an error since the upstream refresh should have failed.
|
|
require.Error(t, err)
|
|
require.Regexp(t,
|
|
regexp.QuoteMeta("oauth2: cannot fetch token: 401 Unauthorized\n")+
|
|
regexp.QuoteMeta(`Response: {"error":"error","error_description":"Error during upstream refresh. Upstream refresh failed`)+
|
|
"[^']+",
|
|
err.Error(),
|
|
)
|
|
}
|
|
}
|
|
|
|
// getFositeDataSignature returns the signature of the provided data. The provided data could be an auth code, access
|
|
// token, etc. It is assumed that the code is of the format "data.signature", which is how Fosite generates auth codes
|
|
// and access tokens.
|
|
func getFositeDataSignature(t *testing.T, data string) string {
|
|
split := strings.Split(data, ".")
|
|
require.Len(t, split, 2)
|
|
return split[1]
|
|
}
|
|
|
|
func verifyTokenResponse(
|
|
t *testing.T,
|
|
tokenResponse *oauth2.Token,
|
|
discovery *coreosoidc.Provider,
|
|
downstreamOAuth2Config oauth2.Config,
|
|
nonceParam nonce.Nonce,
|
|
expectedIDTokenClaims []string,
|
|
wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch string, wantDownstreamIDTokenGroups []string,
|
|
) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancel()
|
|
|
|
// Verify the ID Token.
|
|
rawIDToken, ok := tokenResponse.Extra("id_token").(string)
|
|
require.True(t, ok, "expected to get an ID token but did not")
|
|
var verifier = discovery.Verifier(&coreosoidc.Config{ClientID: downstreamOAuth2Config.ClientID})
|
|
idToken, err := verifier.Verify(ctx, rawIDToken)
|
|
require.NoError(t, err)
|
|
|
|
// Check the sub claim of the ID token.
|
|
require.Regexp(t, wantDownstreamIDTokenSubjectToMatch, idToken.Subject)
|
|
|
|
// Check the nonce claim of the ID token.
|
|
require.NoError(t, nonceParam.Validate(idToken))
|
|
|
|
// Check the exp claim of the ID token.
|
|
expectedIDTokenLifetime := oidc.DefaultOIDCTimeoutsConfiguration().IDTokenLifespan
|
|
testutil.RequireTimeInDelta(t, time.Now().UTC().Add(expectedIDTokenLifetime), idToken.Expiry, time.Second*30)
|
|
|
|
// Check the full list of claim names of the ID token.
|
|
idTokenClaims := map[string]interface{}{}
|
|
err = idToken.Claims(&idTokenClaims)
|
|
require.NoError(t, err)
|
|
idTokenClaimNames := []string{}
|
|
for k := range idTokenClaims {
|
|
idTokenClaimNames = append(idTokenClaimNames, k)
|
|
}
|
|
require.ElementsMatch(t, expectedIDTokenClaims, idTokenClaimNames)
|
|
|
|
// Check username claim of the ID token.
|
|
require.Regexp(t, wantDownstreamIDTokenUsernameToMatch, idTokenClaims["username"].(string))
|
|
|
|
// Check the groups claim.
|
|
require.ElementsMatch(t, wantDownstreamIDTokenGroups, idTokenClaims["groups"])
|
|
|
|
// Some light verification of the other tokens that were returned.
|
|
require.NotEmpty(t, tokenResponse.AccessToken)
|
|
require.Equal(t, "bearer", tokenResponse.TokenType)
|
|
require.NotZero(t, tokenResponse.Expiry)
|
|
expectedAccessTokenLifetime := oidc.DefaultOIDCTimeoutsConfiguration().AccessTokenLifespan
|
|
testutil.RequireTimeInDelta(t, time.Now().UTC().Add(expectedAccessTokenLifetime), tokenResponse.Expiry, time.Second*30)
|
|
// Access tokens should start with the custom prefix "pin_at_" to make them identifiable as access tokens when seen by a user out of context.
|
|
require.True(t, strings.HasPrefix(tokenResponse.AccessToken, "pin_at_"), "token %q did not have expected prefix 'pin_at_'", tokenResponse.AccessToken)
|
|
|
|
require.NotEmpty(t, tokenResponse.RefreshToken)
|
|
// Refresh tokens should start with the custom prefix "pin_rt_" to make them identifiable as refresh tokens when seen by a user out of context.
|
|
require.True(t, strings.HasPrefix(tokenResponse.RefreshToken, "pin_rt_"), "token %q did not have expected prefix 'pin_rt_'", tokenResponse.RefreshToken)
|
|
}
|
|
|
|
func requestAuthorizationUsingBrowserAuthcodeFlowOIDC(t *testing.T, _, downstreamAuthorizeURL, downstreamCallbackURL, _, _ string, httpClient *http.Client) {
|
|
t.Helper()
|
|
env := testlib.IntegrationEnv(t)
|
|
|
|
ctx, cancelFunc := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancelFunc()
|
|
|
|
// Make the authorize request once "manually" so we can check its response security headers.
|
|
makeAuthorizationRequestAndRequireSecurityHeaders(ctx, t, downstreamAuthorizeURL, httpClient)
|
|
|
|
// Open the web browser and navigate to the downstream authorize URL.
|
|
page := browsertest.Open(t)
|
|
t.Logf("opening browser to downstream authorize URL %s", testlib.MaskTokens(downstreamAuthorizeURL))
|
|
require.NoError(t, page.Navigate(downstreamAuthorizeURL))
|
|
|
|
// Expect to be redirected to the upstream provider and log in.
|
|
browsertest.LoginToUpstreamOIDC(t, page, env.SupervisorUpstreamOIDC)
|
|
|
|
// Wait for the login to happen and us be redirected back to a localhost callback.
|
|
t.Logf("waiting for redirect to callback")
|
|
callbackURLPattern := regexp.MustCompile(`\A` + regexp.QuoteMeta(downstreamCallbackURL) + `\?.+\z`)
|
|
browsertest.WaitForURL(t, page, callbackURLPattern)
|
|
}
|
|
|
|
func requestAuthorizationUsingBrowserAuthcodeFlowLDAP(t *testing.T, downstreamIssuer, downstreamAuthorizeURL, downstreamCallbackURL, username, password string, httpClient *http.Client) {
|
|
t.Helper()
|
|
|
|
ctx, cancelFunc := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancelFunc()
|
|
|
|
// Make the authorize request once "manually" so we can check its response security headers.
|
|
makeAuthorizationRequestAndRequireSecurityHeaders(ctx, t, downstreamAuthorizeURL, httpClient)
|
|
|
|
// Open the web browser and navigate to the downstream authorize URL.
|
|
page := browsertest.Open(t)
|
|
t.Logf("opening browser to downstream authorize URL %s", testlib.MaskTokens(downstreamAuthorizeURL))
|
|
require.NoError(t, page.Navigate(downstreamAuthorizeURL))
|
|
|
|
// Expect to be redirected to the upstream provider and log in.
|
|
browsertest.LoginToUpstreamLDAP(t, page, downstreamIssuer, username, password)
|
|
|
|
// Wait for the login to happen and us be redirected back to a localhost callback.
|
|
t.Logf("waiting for redirect to callback")
|
|
callbackURLPattern := regexp.MustCompile(`\A` + regexp.QuoteMeta(downstreamCallbackURL) + `\?.+\z`)
|
|
browsertest.WaitForURL(t, page, callbackURLPattern)
|
|
}
|
|
|
|
func requestAuthorizationUsingBrowserAuthcodeFlowLDAPWithBadCredentials(t *testing.T, downstreamIssuer, downstreamAuthorizeURL, _, username, password string, _ *http.Client) {
|
|
t.Helper()
|
|
|
|
// Open the web browser and navigate to the downstream authorize URL.
|
|
page := browsertest.Open(t)
|
|
t.Logf("opening browser to downstream authorize URL %s", testlib.MaskTokens(downstreamAuthorizeURL))
|
|
require.NoError(t, page.Navigate(downstreamAuthorizeURL))
|
|
|
|
// This functions assumes that it has been passed either a bad username or a bad password, and submits the
|
|
// provided credentials. Expect to be redirected to the upstream provider and attempt to log in.
|
|
browsertest.LoginToUpstreamLDAP(t, page, downstreamIssuer, username, password)
|
|
|
|
// After failing login expect to land back on the login page again with an error message.
|
|
browsertest.WaitForUpstreamLDAPLoginPageWithError(t, page, downstreamIssuer)
|
|
}
|
|
|
|
func requestAuthorizationUsingBrowserAuthcodeFlowLDAPWithBadCredentialsAndThenGoodCredentials(t *testing.T, downstreamIssuer, downstreamAuthorizeURL, _, username, password string, _ *http.Client) {
|
|
t.Helper()
|
|
|
|
// Open the web browser and navigate to the downstream authorize URL.
|
|
page := browsertest.Open(t)
|
|
t.Logf("opening browser to downstream authorize URL %s", testlib.MaskTokens(downstreamAuthorizeURL))
|
|
require.NoError(t, page.Navigate(downstreamAuthorizeURL))
|
|
|
|
// Expect to be redirected to the upstream provider and attempt to log in.
|
|
browsertest.LoginToUpstreamLDAP(t, page, downstreamIssuer, username, "this is the wrong password!")
|
|
|
|
// After failing login expect to land back on the login page again with an error message.
|
|
browsertest.WaitForUpstreamLDAPLoginPageWithError(t, page, downstreamIssuer)
|
|
|
|
// Already at the login page, so this time can directly submit it using the provided username and password.
|
|
browsertest.SubmitUpstreamLDAPLoginForm(t, page, username, password)
|
|
}
|
|
|
|
func makeAuthorizationRequestAndRequireSecurityHeaders(ctx context.Context, t *testing.T, downstreamAuthorizeURL string, httpClient *http.Client) {
|
|
authorizeRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, downstreamAuthorizeURL, nil)
|
|
require.NoError(t, err)
|
|
authorizeResp, err := httpClient.Do(authorizeRequest)
|
|
require.NoError(t, err)
|
|
require.NoError(t, authorizeResp.Body.Close())
|
|
expectSecurityHeaders(t, authorizeResp, false)
|
|
}
|
|
|
|
func requestAuthorizationUsingCLIPasswordFlow(t *testing.T, downstreamAuthorizeURL, upstreamUsername, upstreamPassword string, httpClient *http.Client, wantErr bool) {
|
|
t.Helper()
|
|
|
|
ctx, cancelFunc := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancelFunc()
|
|
|
|
authRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, downstreamAuthorizeURL, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Set the custom username/password headers for the LDAP authorize request.
|
|
authRequest.Header.Set("Pinniped-Username", upstreamUsername)
|
|
authRequest.Header.Set("Pinniped-Password", upstreamPassword)
|
|
|
|
// At this point in the test, we've already waited for the LDAPIdentityProvider to be loaded and marked healthy by
|
|
// at least one Supervisor pod, but we can't be sure that _all_ of them have loaded the provider, so we may need
|
|
// to retry this request multiple times until we get the expected 302 status response.
|
|
var authResponse *http.Response
|
|
var responseBody []byte
|
|
testlib.RequireEventuallyWithoutError(t, func() (bool, error) {
|
|
authResponse, err = httpClient.Do(authRequest)
|
|
if err != nil {
|
|
t.Logf("got authorization response with error %v", err)
|
|
return false, nil
|
|
}
|
|
defer func() { _ = authResponse.Body.Close() }()
|
|
responseBody, err = ioutil.ReadAll(authResponse.Body)
|
|
if err != nil {
|
|
return false, nil
|
|
}
|
|
t.Logf("got authorization response with code %d (%d byte body)", authResponse.StatusCode, len(responseBody))
|
|
if authResponse.StatusCode != http.StatusFound {
|
|
return false, nil
|
|
}
|
|
return true, nil
|
|
}, 60*time.Second, 200*time.Millisecond)
|
|
|
|
expectSecurityHeaders(t, authResponse, true)
|
|
|
|
// A successful authorize request results in a redirect to our localhost callback listener with an authcode param.
|
|
require.Equalf(t, http.StatusFound, authResponse.StatusCode, "response body was: %s", string(responseBody))
|
|
redirectLocation := authResponse.Header.Get("Location")
|
|
require.Contains(t, redirectLocation, "127.0.0.1")
|
|
require.Contains(t, redirectLocation, "/callback")
|
|
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)
|
|
require.NoError(t, err)
|
|
|
|
// Our localhost callback listener should have returned 200 OK.
|
|
callbackResponse, err := httpClient.Do(callbackRequest)
|
|
require.NoError(t, err)
|
|
defer callbackResponse.Body.Close()
|
|
require.Equal(t, http.StatusOK, callbackResponse.StatusCode)
|
|
}
|
|
|
|
func startLocalCallbackServer(t *testing.T) *localCallbackServer {
|
|
// Handle the callback by sending the *http.Request object back through a channel.
|
|
callbacks := make(chan *http.Request, 1)
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
callbacks <- r
|
|
}))
|
|
server.URL += "/callback"
|
|
t.Cleanup(server.Close)
|
|
t.Cleanup(func() { close(callbacks) })
|
|
return &localCallbackServer{Server: server, t: t, callbacks: callbacks}
|
|
}
|
|
|
|
type localCallbackServer struct {
|
|
*httptest.Server
|
|
t *testing.T
|
|
callbacks <-chan *http.Request
|
|
}
|
|
|
|
func (s *localCallbackServer) waitForCallback(timeout time.Duration) (*http.Request, error) {
|
|
select {
|
|
case callback := <-s.callbacks:
|
|
return callback, nil
|
|
case <-time.After(timeout):
|
|
return nil, errors.New("timed out waiting for callback request")
|
|
}
|
|
}
|
|
|
|
func doTokenExchange(
|
|
t *testing.T,
|
|
requestTokenExchangeAud string,
|
|
config *oauth2.Config,
|
|
tokenResponse *oauth2.Token,
|
|
httpClient *http.Client,
|
|
provider *coreosoidc.Provider,
|
|
wantTokenExchangeResponse func(t *testing.T, status int, body string),
|
|
) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancel()
|
|
|
|
// Form the HTTP POST request with the parameters specified by RFC8693.
|
|
reqBody := strings.NewReader(url.Values{
|
|
"grant_type": []string{"urn:ietf:params:oauth:grant-type:token-exchange"},
|
|
"audience": []string{requestTokenExchangeAud},
|
|
"client_id": []string{config.ClientID},
|
|
"subject_token": []string{tokenResponse.AccessToken},
|
|
"subject_token_type": []string{"urn:ietf:params:oauth:token-type:access_token"},
|
|
"requested_token_type": []string{"urn:ietf:params:oauth:token-type:jwt"},
|
|
}.Encode())
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, config.Endpoint.TokenURL, reqBody)
|
|
require.NoError(t, err)
|
|
req.Header.Set("content-type", "application/x-www-form-urlencoded")
|
|
if config.ClientSecret != "" {
|
|
// We only support basic auth for dynamic clients, so use basic auth in these tests.
|
|
req.SetBasicAuth(config.ClientID, config.ClientSecret)
|
|
}
|
|
|
|
resp, err := httpClient.Do(req)
|
|
require.NoError(t, err)
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
// If a function was passed, call it, so it can make the desired assertions.
|
|
if wantTokenExchangeResponse != nil {
|
|
body, err := ioutil.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
wantTokenExchangeResponse(t, resp.StatusCode, string(body))
|
|
return // the above call should have made all desired assertions about the response, so return
|
|
}
|
|
|
|
// Else, want a successful response.
|
|
require.Equal(t, resp.StatusCode, http.StatusOK)
|
|
|
|
var respBody struct {
|
|
AccessToken string `json:"access_token"`
|
|
IssuedTokenType string `json:"issued_token_type"`
|
|
TokenType string `json:"token_type"`
|
|
}
|
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&respBody))
|
|
|
|
var clusterVerifier = provider.Verifier(&coreosoidc.Config{ClientID: requestTokenExchangeAud})
|
|
exchangedToken, err := clusterVerifier.Verify(ctx, respBody.AccessToken)
|
|
require.NoError(t, err)
|
|
|
|
var claims map[string]interface{}
|
|
require.NoError(t, exchangedToken.Claims(&claims))
|
|
indentedClaims, err := json.MarshalIndent(claims, " ", " ")
|
|
require.NoError(t, err)
|
|
t.Logf("exchanged token claims:\n%s", string(indentedClaims))
|
|
}
|
|
|
|
func expectSecurityHeaders(t *testing.T, response *http.Response, expectFositeToOverrideSome bool) {
|
|
h := response.Header
|
|
|
|
cspHeader := h.Get("Content-Security-Policy")
|
|
require.Contains(t, cspHeader, "script-src '") // loose assertion
|
|
require.Contains(t, cspHeader, "style-src '") // loose assertion
|
|
require.Contains(t, cspHeader, "img-src data:")
|
|
require.Contains(t, cspHeader, "connect-src *")
|
|
require.Contains(t, cspHeader, "default-src 'none'")
|
|
require.Contains(t, cspHeader, "frame-ancestors 'none'")
|
|
|
|
assert.Equal(t, "DENY", h.Get("X-Frame-Options"))
|
|
assert.Equal(t, "1; mode=block", h.Get("X-XSS-Protection"))
|
|
assert.Equal(t, "nosniff", h.Get("X-Content-Type-Options"))
|
|
assert.Equal(t, "no-referrer", h.Get("Referrer-Policy"))
|
|
assert.Equal(t, "off", h.Get("X-DNS-Prefetch-Control"))
|
|
if expectFositeToOverrideSome {
|
|
assert.Equal(t, "no-store", h.Get("Cache-Control"))
|
|
} else {
|
|
assert.Equal(t, "no-cache,no-store,max-age=0,must-revalidate", h.Get("Cache-Control"))
|
|
}
|
|
assert.Equal(t, "no-cache", h.Get("Pragma"))
|
|
assert.Equal(t, "0", h.Get("Expires"))
|
|
}
|