34509e7430
- Enhance the token exchange to check that the same client is used compared to the client used during the original authorization and token requests, and also check that the client has the token-exchange grant type allowed in its configuration. - Reduce the minimum required bcrypt cost for OIDCClient secrets because 15 is too slow for real-life use, especially considering that every login and every refresh flow will require two client auths. - In unit tests, use bcrypt hashes with a cost of 4, because bcrypt slows down by 13x when run with the race detector, and we run our tests with the race detector enabled, causing the tests to be unacceptably slow. The production code uses a higher minimum cost. - Centralize all pre-computed bcrypt hashes used by unit tests to a single place. Also extract some other useful test helpers for unit tests related to OIDCClients. - Add tons of unit tests for the token endpoint related to dynamic clients for authcode exchanges, token exchanges, and refreshes.
784 lines
37 KiB
Go
784 lines
37 KiB
Go
// Copyright 2022 the Pinniped contributors. All Rights Reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package login
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"k8s.io/apiserver/pkg/authentication/user"
|
|
"k8s.io/client-go/kubernetes/fake"
|
|
|
|
supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake"
|
|
"go.pinniped.dev/internal/authenticators"
|
|
"go.pinniped.dev/internal/oidc"
|
|
"go.pinniped.dev/internal/oidc/jwks"
|
|
"go.pinniped.dev/internal/psession"
|
|
"go.pinniped.dev/internal/testutil"
|
|
"go.pinniped.dev/internal/testutil/oidctestutil"
|
|
)
|
|
|
|
func TestPostLoginEndpoint(t *testing.T) {
|
|
const (
|
|
htmlContentType = "text/html; charset=utf-8"
|
|
|
|
happyDownstreamCSRF = "test-csrf"
|
|
happyDownstreamPKCE = "test-pkce"
|
|
happyDownstreamNonce = "test-nonce"
|
|
happyDownstreamStateVersion = "2"
|
|
happyEncodedUpstreamState = "fake-encoded-state-param-value"
|
|
|
|
downstreamIssuer = "https://my-downstream-issuer.com/path"
|
|
downstreamRedirectURI = "http://127.0.0.1/callback"
|
|
downstreamClientID = "pinniped-cli"
|
|
happyDownstreamState = "8b-state"
|
|
downstreamNonce = "some-nonce-value"
|
|
downstreamPKCEChallenge = "some-challenge"
|
|
downstreamPKCEChallengeMethod = "S256"
|
|
|
|
ldapUpstreamName = "some-ldap-idp"
|
|
ldapUpstreamType = "ldap"
|
|
ldapUpstreamResourceUID = "ldap-resource-uid"
|
|
activeDirectoryUpstreamName = "some-active-directory-idp"
|
|
activeDirectoryUpstreamType = "activedirectory"
|
|
activeDirectoryUpstreamResourceUID = "active-directory-resource-uid"
|
|
upstreamLDAPURL = "ldaps://some-ldap-host:123?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev"
|
|
|
|
userParam = "username"
|
|
passParam = "password"
|
|
badUserPassErrParamValue = "login_error"
|
|
internalErrParamValue = "internal_error"
|
|
)
|
|
|
|
var (
|
|
fositeMissingCodeChallengeErrorQuery = map[string]string{
|
|
"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. Clients must include a code_challenge when performing the authorize code flow, but it is missing.",
|
|
"state": happyDownstreamState,
|
|
}
|
|
|
|
fositeInvalidCodeChallengeErrorQuery = map[string]string{
|
|
"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. The code_challenge_method is not supported, use S256 instead.",
|
|
"state": happyDownstreamState,
|
|
}
|
|
|
|
fositeMissingCodeChallengeMethodErrorQuery = map[string]string{
|
|
"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. Clients must use code_challenge_method=S256, plain is not allowed.",
|
|
"state": happyDownstreamState,
|
|
}
|
|
|
|
fositePromptHasNoneAndOtherValueErrorQuery = map[string]string{
|
|
"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. Parameter 'prompt' was set to 'none', but contains other values as well which is not allowed.",
|
|
"state": happyDownstreamState,
|
|
}
|
|
)
|
|
|
|
happyDownstreamScopesRequested := []string{"openid", "groups"}
|
|
happyDownstreamScopesGranted := []string{"openid", "groups"}
|
|
|
|
happyDownstreamRequestParamsQuery := url.Values{
|
|
"response_type": []string{"code"},
|
|
"scope": []string{strings.Join(happyDownstreamScopesRequested, " ")},
|
|
"client_id": []string{downstreamClientID},
|
|
"state": []string{happyDownstreamState},
|
|
"nonce": []string{downstreamNonce},
|
|
"code_challenge": []string{downstreamPKCEChallenge},
|
|
"code_challenge_method": []string{downstreamPKCEChallengeMethod},
|
|
"redirect_uri": []string{downstreamRedirectURI},
|
|
}
|
|
happyDownstreamRequestParams := happyDownstreamRequestParamsQuery.Encode()
|
|
|
|
copyOfHappyDownstreamRequestParamsQuery := func() url.Values {
|
|
params := url.Values{}
|
|
for k, v := range happyDownstreamRequestParamsQuery {
|
|
params[k] = make([]string, len(v))
|
|
copy(params[k], v)
|
|
}
|
|
return params
|
|
}
|
|
|
|
happyLDAPDecodedState := &oidc.UpstreamStateParamData{
|
|
AuthParams: happyDownstreamRequestParams,
|
|
UpstreamName: ldapUpstreamName,
|
|
UpstreamType: ldapUpstreamType,
|
|
Nonce: happyDownstreamNonce,
|
|
CSRFToken: happyDownstreamCSRF,
|
|
PKCECode: happyDownstreamPKCE,
|
|
FormatVersion: happyDownstreamStateVersion,
|
|
}
|
|
|
|
modifyHappyLDAPDecodedState := func(edit func(*oidc.UpstreamStateParamData)) *oidc.UpstreamStateParamData {
|
|
copyOfHappyLDAPDecodedState := *happyLDAPDecodedState
|
|
edit(©OfHappyLDAPDecodedState)
|
|
return ©OfHappyLDAPDecodedState
|
|
}
|
|
|
|
happyActiveDirectoryDecodedState := &oidc.UpstreamStateParamData{
|
|
AuthParams: happyDownstreamRequestParams,
|
|
UpstreamName: activeDirectoryUpstreamName,
|
|
UpstreamType: activeDirectoryUpstreamType,
|
|
Nonce: happyDownstreamNonce,
|
|
CSRFToken: happyDownstreamCSRF,
|
|
PKCECode: happyDownstreamPKCE,
|
|
FormatVersion: happyDownstreamStateVersion,
|
|
}
|
|
|
|
happyLDAPUsername := "some-ldap-user"
|
|
happyLDAPUsernameFromAuthenticator := "some-mapped-ldap-username"
|
|
happyLDAPPassword := "some-ldap-password" //nolint:gosec
|
|
happyLDAPUID := "some-ldap-uid"
|
|
happyLDAPUserDN := "cn=foo,dn=bar"
|
|
happyLDAPGroups := []string{"group1", "group2", "group3"}
|
|
happyLDAPExtraRefreshAttribute := "some-refresh-attribute"
|
|
happyLDAPExtraRefreshValue := "some-refresh-attribute-value"
|
|
|
|
parsedUpstreamLDAPURL, err := url.Parse(upstreamLDAPURL)
|
|
require.NoError(t, err)
|
|
|
|
ldapAuthenticateFunc := func(ctx context.Context, username, password string) (*authenticators.Response, bool, error) {
|
|
if username == "" || password == "" {
|
|
return nil, false, fmt.Errorf("should not have passed empty username or password to the authenticator")
|
|
}
|
|
if username == happyLDAPUsername && password == happyLDAPPassword {
|
|
return &authenticators.Response{
|
|
User: &user.DefaultInfo{
|
|
Name: happyLDAPUsernameFromAuthenticator,
|
|
UID: happyLDAPUID,
|
|
Groups: happyLDAPGroups,
|
|
},
|
|
DN: happyLDAPUserDN,
|
|
ExtraRefreshAttributes: map[string]string{
|
|
happyLDAPExtraRefreshAttribute: happyLDAPExtraRefreshValue,
|
|
},
|
|
}, true, nil
|
|
}
|
|
return nil, false, nil
|
|
}
|
|
|
|
upstreamLDAPIdentityProvider := oidctestutil.TestUpstreamLDAPIdentityProvider{
|
|
Name: ldapUpstreamName,
|
|
ResourceUID: ldapUpstreamResourceUID,
|
|
URL: parsedUpstreamLDAPURL,
|
|
AuthenticateFunc: ldapAuthenticateFunc,
|
|
}
|
|
|
|
upstreamActiveDirectoryIdentityProvider := oidctestutil.TestUpstreamLDAPIdentityProvider{
|
|
Name: activeDirectoryUpstreamName,
|
|
ResourceUID: activeDirectoryUpstreamResourceUID,
|
|
URL: parsedUpstreamLDAPURL,
|
|
AuthenticateFunc: ldapAuthenticateFunc,
|
|
}
|
|
|
|
erroringUpstreamLDAPIdentityProvider := oidctestutil.TestUpstreamLDAPIdentityProvider{
|
|
Name: ldapUpstreamName,
|
|
ResourceUID: ldapUpstreamResourceUID,
|
|
AuthenticateFunc: func(ctx context.Context, username, password string) (*authenticators.Response, bool, error) {
|
|
return nil, false, fmt.Errorf("some ldap upstream auth error")
|
|
},
|
|
}
|
|
|
|
expectedHappyActiveDirectoryUpstreamCustomSession := &psession.CustomSessionData{
|
|
ProviderUID: activeDirectoryUpstreamResourceUID,
|
|
ProviderName: activeDirectoryUpstreamName,
|
|
ProviderType: psession.ProviderTypeActiveDirectory,
|
|
OIDC: nil,
|
|
LDAP: nil,
|
|
ActiveDirectory: &psession.ActiveDirectorySessionData{
|
|
UserDN: happyLDAPUserDN,
|
|
ExtraRefreshAttributes: map[string]string{happyLDAPExtraRefreshAttribute: happyLDAPExtraRefreshValue},
|
|
},
|
|
}
|
|
|
|
expectedHappyLDAPUpstreamCustomSession := &psession.CustomSessionData{
|
|
ProviderUID: ldapUpstreamResourceUID,
|
|
ProviderName: ldapUpstreamName,
|
|
ProviderType: psession.ProviderTypeLDAP,
|
|
OIDC: nil,
|
|
LDAP: &psession.LDAPSessionData{
|
|
UserDN: happyLDAPUserDN,
|
|
ExtraRefreshAttributes: map[string]string{happyLDAPExtraRefreshAttribute: happyLDAPExtraRefreshValue},
|
|
},
|
|
ActiveDirectory: nil,
|
|
}
|
|
|
|
// Note that fosite puts the granted scopes as a param in the redirect URI even though the spec doesn't seem to require it
|
|
happyAuthcodeDownstreamRedirectLocationRegexp := downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+groups&state=` + happyDownstreamState
|
|
|
|
happyUsernamePasswordFormParams := url.Values{userParam: []string{happyLDAPUsername}, passParam: []string{happyLDAPPassword}}
|
|
|
|
encodeQuery := func(query map[string]string) string {
|
|
values := url.Values{}
|
|
for k, v := range query {
|
|
values[k] = []string{v}
|
|
}
|
|
return values.Encode()
|
|
}
|
|
|
|
urlWithQuery := func(baseURL string, query map[string]string) string {
|
|
urlToReturn := fmt.Sprintf("%s?%s", baseURL, encodeQuery(query))
|
|
_, err := url.Parse(urlToReturn)
|
|
require.NoError(t, err, "urlWithQuery helper was used to create an illegal URL")
|
|
return urlToReturn
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
idps *oidctestutil.UpstreamIDPListerBuilder
|
|
kubeResources func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset)
|
|
decodedState *oidc.UpstreamStateParamData
|
|
formParams url.Values
|
|
reqURIQuery url.Values
|
|
|
|
wantStatus int
|
|
wantContentType string
|
|
wantBodyString string
|
|
wantErr string
|
|
|
|
// Assertion that the response should be a redirect to the login page with an error param.
|
|
wantRedirectToLoginPageError string
|
|
|
|
// Assertions for when an authcode should be returned, i.e. the request was authenticated by an
|
|
// upstream LDAP or AD provider.
|
|
wantRedirectLocationRegexp string // for loose matching
|
|
wantRedirectLocationString string // for exact matching instead
|
|
wantBodyFormResponseRegexp string // for form_post html page matching instead
|
|
wantDownstreamRedirectURI string
|
|
wantDownstreamGrantedScopes []string
|
|
wantDownstreamIDTokenSubject string
|
|
wantDownstreamIDTokenUsername string
|
|
wantDownstreamIDTokenGroups []string
|
|
wantDownstreamRequestedScopes []string
|
|
wantDownstreamPKCEChallenge string
|
|
wantDownstreamPKCEChallengeMethod string
|
|
wantDownstreamNonce string
|
|
wantDownstreamCustomSessionData *psession.CustomSessionData
|
|
|
|
// Authorization requests for either a successful OIDC upstream or for an error with any upstream
|
|
// should never use Kube storage. There is only one exception to this rule, which is that certain
|
|
// OIDC validations are checked in fosite after the OAuth authcode (and sometimes the OIDC session)
|
|
// is stored, so it is possible with an LDAP upstream to store objects and then return an error to
|
|
// the client anyway (which makes the stored objects useless, but oh well).
|
|
wantUnnecessaryStoredRecords int
|
|
}{
|
|
{
|
|
name: "happy LDAP login",
|
|
idps: oidctestutil.NewUpstreamIDPListerBuilder().
|
|
WithLDAP(&upstreamLDAPIdentityProvider). // should pick this one
|
|
WithActiveDirectory(&erroringUpstreamLDAPIdentityProvider),
|
|
decodedState: happyLDAPDecodedState,
|
|
formParams: happyUsernamePasswordFormParams,
|
|
wantStatus: http.StatusSeeOther,
|
|
wantContentType: htmlContentType,
|
|
wantBodyString: "",
|
|
wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp,
|
|
wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID,
|
|
wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator,
|
|
wantDownstreamIDTokenGroups: happyLDAPGroups,
|
|
wantDownstreamRequestedScopes: happyDownstreamScopesRequested,
|
|
wantDownstreamRedirectURI: downstreamRedirectURI,
|
|
wantDownstreamGrantedScopes: happyDownstreamScopesGranted,
|
|
wantDownstreamNonce: downstreamNonce,
|
|
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
|
|
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
|
|
wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession,
|
|
},
|
|
{
|
|
name: "happy AD login",
|
|
idps: oidctestutil.NewUpstreamIDPListerBuilder().
|
|
WithLDAP(&erroringUpstreamLDAPIdentityProvider).
|
|
WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), // should pick this one
|
|
decodedState: happyActiveDirectoryDecodedState,
|
|
formParams: happyUsernamePasswordFormParams,
|
|
wantStatus: http.StatusSeeOther,
|
|
wantContentType: htmlContentType,
|
|
wantBodyString: "",
|
|
wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp,
|
|
wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID,
|
|
wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator,
|
|
wantDownstreamIDTokenGroups: happyLDAPGroups,
|
|
wantDownstreamRequestedScopes: happyDownstreamScopesRequested,
|
|
wantDownstreamRedirectURI: downstreamRedirectURI,
|
|
wantDownstreamGrantedScopes: happyDownstreamScopesGranted,
|
|
wantDownstreamNonce: downstreamNonce,
|
|
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
|
|
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
|
|
wantDownstreamCustomSessionData: expectedHappyActiveDirectoryUpstreamCustomSession,
|
|
},
|
|
{
|
|
name: "happy LDAP login when downstream response_mode=form_post returns 200 with HTML+JS form",
|
|
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
|
|
decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) {
|
|
query := copyOfHappyDownstreamRequestParamsQuery()
|
|
query["response_mode"] = []string{"form_post"}
|
|
data.AuthParams = query.Encode()
|
|
}),
|
|
formParams: happyUsernamePasswordFormParams,
|
|
wantStatus: http.StatusOK,
|
|
wantContentType: htmlContentType,
|
|
wantBodyFormResponseRegexp: `(?s)<html.*<script>.*To finish logging in, paste this authorization code` +
|
|
`.*<form>.*<code id="manual-auth-code">(.+)</code>.*</html>`, // "(?s)" means match "." across newlines
|
|
wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID,
|
|
wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator,
|
|
wantDownstreamIDTokenGroups: happyLDAPGroups,
|
|
wantDownstreamRequestedScopes: happyDownstreamScopesRequested,
|
|
wantDownstreamRedirectURI: downstreamRedirectURI,
|
|
wantDownstreamGrantedScopes: happyDownstreamScopesGranted,
|
|
wantDownstreamNonce: downstreamNonce,
|
|
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
|
|
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
|
|
wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession,
|
|
},
|
|
{
|
|
name: "happy LDAP login when downstream redirect uri matches what is configured for client except for the port number",
|
|
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
|
|
decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) {
|
|
query := copyOfHappyDownstreamRequestParamsQuery()
|
|
query["redirect_uri"] = []string{"http://127.0.0.1:4242/callback"}
|
|
data.AuthParams = query.Encode()
|
|
}),
|
|
formParams: happyUsernamePasswordFormParams,
|
|
wantStatus: http.StatusSeeOther,
|
|
wantContentType: htmlContentType,
|
|
wantBodyString: "",
|
|
wantRedirectLocationRegexp: "http://127.0.0.1:4242/callback" + `\?code=([^&]+)&scope=openid\+groups&state=` + happyDownstreamState,
|
|
wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID,
|
|
wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator,
|
|
wantDownstreamIDTokenGroups: happyLDAPGroups,
|
|
wantDownstreamRequestedScopes: happyDownstreamScopesRequested,
|
|
wantDownstreamRedirectURI: "http://127.0.0.1:4242/callback",
|
|
wantDownstreamGrantedScopes: happyDownstreamScopesGranted,
|
|
wantDownstreamNonce: downstreamNonce,
|
|
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
|
|
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
|
|
wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession,
|
|
},
|
|
{
|
|
name: "happy LDAP login when there are additional allowed downstream requested scopes",
|
|
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
|
|
decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) {
|
|
query := copyOfHappyDownstreamRequestParamsQuery()
|
|
query["scope"] = []string{"openid offline_access pinniped:request-audience"}
|
|
data.AuthParams = query.Encode()
|
|
}),
|
|
formParams: happyUsernamePasswordFormParams,
|
|
wantStatus: http.StatusSeeOther,
|
|
wantContentType: htmlContentType,
|
|
wantBodyString: "",
|
|
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+pinniped%3Arequest-audience&state=` + happyDownstreamState,
|
|
wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID,
|
|
wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator,
|
|
wantDownstreamIDTokenGroups: happyLDAPGroups,
|
|
wantDownstreamRequestedScopes: []string{"openid", "offline_access", "pinniped:request-audience"},
|
|
wantDownstreamRedirectURI: downstreamRedirectURI,
|
|
wantDownstreamGrantedScopes: []string{"openid", "offline_access", "pinniped:request-audience"},
|
|
wantDownstreamNonce: downstreamNonce,
|
|
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
|
|
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
|
|
wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession,
|
|
},
|
|
{
|
|
name: "happy LDAP when downstream OIDC validations are skipped because the openid scope was not requested",
|
|
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
|
|
decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) {
|
|
query := copyOfHappyDownstreamRequestParamsQuery()
|
|
query["scope"] = []string{"email"}
|
|
// The following prompt value is illegal when openid is requested, but note that openid is not requested.
|
|
query["prompt"] = []string{"none login"}
|
|
data.AuthParams = query.Encode()
|
|
}),
|
|
formParams: happyUsernamePasswordFormParams,
|
|
wantStatus: http.StatusSeeOther,
|
|
wantContentType: htmlContentType,
|
|
wantBodyString: "",
|
|
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=&state=` + happyDownstreamState, // no scopes granted
|
|
wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID,
|
|
wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator,
|
|
wantDownstreamIDTokenGroups: happyLDAPGroups,
|
|
wantDownstreamRequestedScopes: []string{"email"}, // only email was requested
|
|
wantDownstreamRedirectURI: downstreamRedirectURI,
|
|
wantDownstreamGrantedScopes: []string{}, // no scopes granted
|
|
wantDownstreamNonce: downstreamNonce,
|
|
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
|
|
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
|
|
wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession,
|
|
},
|
|
{
|
|
name: "happy LDAP login when groups scope is not requested",
|
|
idps: oidctestutil.NewUpstreamIDPListerBuilder().
|
|
WithLDAP(&upstreamLDAPIdentityProvider). // should pick this one
|
|
WithActiveDirectory(&erroringUpstreamLDAPIdentityProvider),
|
|
decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) {
|
|
query := copyOfHappyDownstreamRequestParamsQuery()
|
|
query["scope"] = []string{"openid"}
|
|
data.AuthParams = query.Encode()
|
|
}),
|
|
formParams: happyUsernamePasswordFormParams,
|
|
wantStatus: http.StatusSeeOther,
|
|
wantContentType: htmlContentType,
|
|
wantBodyString: "",
|
|
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid&state=` + happyDownstreamState,
|
|
wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID,
|
|
wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator,
|
|
wantDownstreamRequestedScopes: []string{"openid"},
|
|
wantDownstreamRedirectURI: downstreamRedirectURI,
|
|
wantDownstreamGrantedScopes: []string{"openid"},
|
|
wantDownstreamNonce: downstreamNonce,
|
|
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
|
|
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
|
|
wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession,
|
|
},
|
|
{
|
|
name: "bad username LDAP login",
|
|
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
|
|
decodedState: happyLDAPDecodedState,
|
|
formParams: url.Values{userParam: []string{"wrong!"}, passParam: []string{happyLDAPPassword}},
|
|
wantStatus: http.StatusSeeOther,
|
|
wantContentType: htmlContentType,
|
|
wantBodyString: "",
|
|
wantRedirectToLoginPageError: badUserPassErrParamValue,
|
|
},
|
|
{
|
|
name: "bad password LDAP login",
|
|
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
|
|
decodedState: happyLDAPDecodedState,
|
|
formParams: url.Values{userParam: []string{happyLDAPUsername}, passParam: []string{"wrong!"}},
|
|
wantStatus: http.StatusSeeOther,
|
|
wantContentType: htmlContentType,
|
|
wantBodyString: "",
|
|
wantRedirectToLoginPageError: badUserPassErrParamValue,
|
|
},
|
|
{
|
|
name: "blank username LDAP login",
|
|
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
|
|
decodedState: happyLDAPDecodedState,
|
|
formParams: url.Values{userParam: []string{""}, passParam: []string{happyLDAPPassword}},
|
|
wantStatus: http.StatusSeeOther,
|
|
wantContentType: htmlContentType,
|
|
wantBodyString: "",
|
|
wantRedirectToLoginPageError: badUserPassErrParamValue,
|
|
},
|
|
{
|
|
name: "blank password LDAP login",
|
|
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
|
|
decodedState: happyLDAPDecodedState,
|
|
formParams: url.Values{userParam: []string{happyLDAPUsername}, passParam: []string{""}},
|
|
wantStatus: http.StatusSeeOther,
|
|
wantContentType: htmlContentType,
|
|
wantBodyString: "",
|
|
wantRedirectToLoginPageError: badUserPassErrParamValue,
|
|
},
|
|
{
|
|
name: "username and password sent as URI query params should be ignored since they are expected in form post body",
|
|
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
|
|
decodedState: happyLDAPDecodedState,
|
|
reqURIQuery: happyUsernamePasswordFormParams,
|
|
wantStatus: http.StatusSeeOther,
|
|
wantContentType: htmlContentType,
|
|
wantBodyString: "",
|
|
wantRedirectToLoginPageError: badUserPassErrParamValue,
|
|
},
|
|
{
|
|
name: "error during upstream LDAP authentication",
|
|
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&erroringUpstreamLDAPIdentityProvider),
|
|
decodedState: happyLDAPDecodedState,
|
|
formParams: happyUsernamePasswordFormParams,
|
|
wantStatus: http.StatusSeeOther,
|
|
wantContentType: htmlContentType,
|
|
wantBodyString: "",
|
|
wantRedirectToLoginPageError: internalErrParamValue,
|
|
},
|
|
{
|
|
name: "downstream redirect uri does not match what is configured for client",
|
|
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
|
|
decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) {
|
|
query := copyOfHappyDownstreamRequestParamsQuery()
|
|
query["redirect_uri"] = []string{"http://127.0.0.1/wrong_callback"}
|
|
data.AuthParams = query.Encode()
|
|
}),
|
|
formParams: happyUsernamePasswordFormParams,
|
|
wantErr: "error using state downstream auth params",
|
|
},
|
|
{
|
|
name: "downstream client does not exist",
|
|
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
|
|
decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) {
|
|
query := copyOfHappyDownstreamRequestParamsQuery()
|
|
query["client_id"] = []string{"wrong_client_id"}
|
|
data.AuthParams = query.Encode()
|
|
}),
|
|
formParams: happyUsernamePasswordFormParams,
|
|
wantErr: "error using state downstream auth params",
|
|
},
|
|
{
|
|
name: "downstream client is missing",
|
|
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
|
|
decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) {
|
|
query := copyOfHappyDownstreamRequestParamsQuery()
|
|
delete(query, "client_id")
|
|
data.AuthParams = query.Encode()
|
|
}),
|
|
formParams: happyUsernamePasswordFormParams,
|
|
wantErr: "error using state downstream auth params",
|
|
},
|
|
{
|
|
name: "response type is unsupported",
|
|
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
|
|
decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) {
|
|
query := copyOfHappyDownstreamRequestParamsQuery()
|
|
query["response_type"] = []string{"unsupported"}
|
|
data.AuthParams = query.Encode()
|
|
}),
|
|
formParams: happyUsernamePasswordFormParams,
|
|
wantErr: "error using state downstream auth params",
|
|
},
|
|
{
|
|
name: "response type is missing",
|
|
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
|
|
decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) {
|
|
query := copyOfHappyDownstreamRequestParamsQuery()
|
|
delete(query, "response_type")
|
|
data.AuthParams = query.Encode()
|
|
}),
|
|
formParams: happyUsernamePasswordFormParams,
|
|
wantErr: "error using state downstream auth params",
|
|
},
|
|
{
|
|
name: "PKCE code_challenge is missing",
|
|
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
|
|
decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) {
|
|
query := copyOfHappyDownstreamRequestParamsQuery()
|
|
delete(query, "code_challenge")
|
|
data.AuthParams = query.Encode()
|
|
}),
|
|
formParams: happyUsernamePasswordFormParams,
|
|
wantStatus: http.StatusSeeOther,
|
|
wantContentType: htmlContentType,
|
|
wantBodyString: "",
|
|
wantRedirectLocationString: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeErrorQuery),
|
|
wantUnnecessaryStoredRecords: 2, // fosite already stored the authcode and oidc session before it noticed the error
|
|
},
|
|
{
|
|
name: "PKCE code_challenge_method is invalid",
|
|
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
|
|
decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) {
|
|
query := copyOfHappyDownstreamRequestParamsQuery()
|
|
query["code_challenge_method"] = []string{"this-is-not-a-valid-pkce-alg"}
|
|
data.AuthParams = query.Encode()
|
|
}),
|
|
formParams: happyUsernamePasswordFormParams,
|
|
wantStatus: http.StatusSeeOther,
|
|
wantContentType: htmlContentType,
|
|
wantBodyString: "",
|
|
wantRedirectLocationString: urlWithQuery(downstreamRedirectURI, fositeInvalidCodeChallengeErrorQuery),
|
|
wantUnnecessaryStoredRecords: 2, // fosite already stored the authcode and oidc session before it noticed the error
|
|
},
|
|
{
|
|
name: "PKCE code_challenge_method is `plain`",
|
|
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
|
|
decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) {
|
|
query := copyOfHappyDownstreamRequestParamsQuery()
|
|
query["code_challenge_method"] = []string{"plain"} // plain is not allowed
|
|
data.AuthParams = query.Encode()
|
|
}),
|
|
formParams: happyUsernamePasswordFormParams,
|
|
wantStatus: http.StatusSeeOther,
|
|
wantContentType: htmlContentType,
|
|
wantBodyString: "",
|
|
wantRedirectLocationString: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeMethodErrorQuery),
|
|
wantUnnecessaryStoredRecords: 2, // fosite already stored the authcode and oidc session before it noticed the error
|
|
},
|
|
{
|
|
name: "PKCE code_challenge_method is missing",
|
|
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
|
|
decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) {
|
|
query := copyOfHappyDownstreamRequestParamsQuery()
|
|
delete(query, "code_challenge_method")
|
|
data.AuthParams = query.Encode()
|
|
}),
|
|
formParams: happyUsernamePasswordFormParams,
|
|
wantStatus: http.StatusSeeOther,
|
|
wantContentType: htmlContentType,
|
|
wantBodyString: "",
|
|
wantRedirectLocationString: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeMethodErrorQuery),
|
|
wantUnnecessaryStoredRecords: 2, // fosite already stored the authcode and oidc session before it noticed the error
|
|
},
|
|
{
|
|
name: "prompt param is not allowed to have none and another legal value at the same time",
|
|
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
|
|
decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) {
|
|
query := copyOfHappyDownstreamRequestParamsQuery()
|
|
query["prompt"] = []string{"none login"}
|
|
data.AuthParams = query.Encode()
|
|
}),
|
|
formParams: happyUsernamePasswordFormParams,
|
|
wantStatus: http.StatusSeeOther,
|
|
wantContentType: htmlContentType,
|
|
wantBodyString: "",
|
|
wantRedirectLocationString: urlWithQuery(downstreamRedirectURI, fositePromptHasNoneAndOtherValueErrorQuery),
|
|
wantUnnecessaryStoredRecords: 1, // fosite already stored the authcode before it noticed the error
|
|
},
|
|
{
|
|
name: "downstream state does not have enough entropy",
|
|
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
|
|
decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) {
|
|
query := copyOfHappyDownstreamRequestParamsQuery()
|
|
query["state"] = []string{"short"}
|
|
data.AuthParams = query.Encode()
|
|
}),
|
|
formParams: happyUsernamePasswordFormParams,
|
|
wantErr: "error using state downstream auth params",
|
|
},
|
|
{
|
|
name: "downstream scopes do not match what is configured for client",
|
|
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
|
|
decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) {
|
|
query := copyOfHappyDownstreamRequestParamsQuery()
|
|
query["scope"] = []string{"openid offline_access pinniped:request-audience scope_not_allowed"}
|
|
data.AuthParams = query.Encode()
|
|
}),
|
|
formParams: happyUsernamePasswordFormParams,
|
|
wantErr: "error using state downstream auth params",
|
|
},
|
|
{
|
|
name: "no upstream providers are configured or provider cannot be found by name",
|
|
idps: oidctestutil.NewUpstreamIDPListerBuilder(), // empty
|
|
decodedState: happyLDAPDecodedState,
|
|
formParams: happyUsernamePasswordFormParams,
|
|
wantErr: "error finding upstream provider: provider not found",
|
|
},
|
|
{
|
|
name: "upstream provider cannot be found by name and type",
|
|
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
|
|
decodedState: happyActiveDirectoryDecodedState, // correct upstream IDP name, but wrong upstream IDP type
|
|
formParams: happyUsernamePasswordFormParams,
|
|
wantErr: "error finding upstream provider: provider not found",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
tt := test
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
kubeClient := fake.NewSimpleClientset()
|
|
supervisorClient := supervisorfake.NewSimpleClientset()
|
|
secretsClient := kubeClient.CoreV1().Secrets("some-namespace")
|
|
oidcClientsClient := supervisorClient.ConfigV1alpha1().OIDCClients("some-namespace")
|
|
|
|
if test.kubeResources != nil {
|
|
test.kubeResources(t, supervisorClient, kubeClient)
|
|
}
|
|
|
|
// Configure fosite the same way that the production code would.
|
|
// Inject this into our test subject at the last second so we get a fresh storage for every test.
|
|
timeoutsConfiguration := oidc.DefaultOIDCTimeoutsConfiguration()
|
|
// Use lower minimum required bcrypt cost than we would use in production to keep unit the tests fast.
|
|
kubeOauthStore := oidc.NewKubeStorage(secretsClient, oidcClientsClient, timeoutsConfiguration, bcrypt.MinCost)
|
|
hmacSecretFunc := func() []byte { return []byte("some secret - must have at least 32 bytes") }
|
|
require.GreaterOrEqual(t, len(hmacSecretFunc()), 32, "fosite requires that hmac secrets have at least 32 bytes")
|
|
jwksProviderIsUnused := jwks.NewDynamicJWKSProvider()
|
|
oauthHelper := oidc.FositeOauth2Helper(kubeOauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/ignored", strings.NewReader(tt.formParams.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
if tt.reqURIQuery != nil {
|
|
req.URL.RawQuery = tt.reqURIQuery.Encode()
|
|
}
|
|
|
|
rsp := httptest.NewRecorder()
|
|
|
|
subject := NewPostHandler(downstreamIssuer, tt.idps.Build(), oauthHelper)
|
|
|
|
err := subject(rsp, req, happyEncodedUpstreamState, tt.decodedState)
|
|
if tt.wantErr != "" {
|
|
require.EqualError(t, err, tt.wantErr)
|
|
require.Empty(t, kubeClient.Actions())
|
|
return // the http response doesn't matter when the function returns an error, because the caller should handle the error
|
|
}
|
|
// Otherwise, expect no error.
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, tt.wantStatus, rsp.Code)
|
|
testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), tt.wantContentType)
|
|
|
|
actualLocation := rsp.Header().Get("Location")
|
|
|
|
switch {
|
|
case tt.wantRedirectLocationRegexp != "":
|
|
// Expecting a success redirect to the client.
|
|
require.Equal(t, tt.wantBodyString, rsp.Body.String())
|
|
require.Len(t, rsp.Header().Values("Location"), 1)
|
|
oidctestutil.RequireAuthCodeRegexpMatch(
|
|
t,
|
|
actualLocation,
|
|
tt.wantRedirectLocationRegexp,
|
|
kubeClient,
|
|
secretsClient,
|
|
kubeOauthStore,
|
|
tt.wantDownstreamGrantedScopes,
|
|
tt.wantDownstreamIDTokenSubject,
|
|
tt.wantDownstreamIDTokenUsername,
|
|
tt.wantDownstreamIDTokenGroups,
|
|
tt.wantDownstreamRequestedScopes,
|
|
tt.wantDownstreamPKCEChallenge,
|
|
tt.wantDownstreamPKCEChallengeMethod,
|
|
tt.wantDownstreamNonce,
|
|
downstreamClientID,
|
|
tt.wantDownstreamRedirectURI,
|
|
tt.wantDownstreamCustomSessionData,
|
|
)
|
|
case tt.wantRedirectToLoginPageError != "":
|
|
// Expecting an error redirect to the login UI page.
|
|
require.Equal(t, tt.wantBodyString, rsp.Body.String())
|
|
expectedLocation := downstreamIssuer + oidc.PinnipedLoginPath +
|
|
"?err=" + tt.wantRedirectToLoginPageError + "&state=" + happyEncodedUpstreamState
|
|
require.Equal(t, expectedLocation, actualLocation)
|
|
require.Len(t, kubeClient.Actions(), tt.wantUnnecessaryStoredRecords)
|
|
case tt.wantRedirectLocationString != "":
|
|
// Expecting an error redirect to the client.
|
|
require.Equal(t, tt.wantBodyString, rsp.Body.String())
|
|
require.Equal(t, tt.wantRedirectLocationString, actualLocation)
|
|
require.Len(t, kubeClient.Actions(), tt.wantUnnecessaryStoredRecords)
|
|
case tt.wantBodyFormResponseRegexp != "":
|
|
// Expecting the body of the response to be a html page with a form (for "response_mode=form_post").
|
|
_, hasLocationHeader := rsp.Header()["Location"]
|
|
require.False(t, hasLocationHeader)
|
|
oidctestutil.RequireAuthCodeRegexpMatch(
|
|
t,
|
|
rsp.Body.String(),
|
|
tt.wantBodyFormResponseRegexp,
|
|
kubeClient,
|
|
secretsClient,
|
|
kubeOauthStore,
|
|
tt.wantDownstreamGrantedScopes,
|
|
tt.wantDownstreamIDTokenSubject,
|
|
tt.wantDownstreamIDTokenUsername,
|
|
tt.wantDownstreamIDTokenGroups,
|
|
tt.wantDownstreamRequestedScopes,
|
|
tt.wantDownstreamPKCEChallenge,
|
|
tt.wantDownstreamPKCEChallengeMethod,
|
|
tt.wantDownstreamNonce,
|
|
downstreamClientID,
|
|
tt.wantDownstreamRedirectURI,
|
|
tt.wantDownstreamCustomSessionData,
|
|
)
|
|
default:
|
|
require.Failf(t, "test should have expected a redirect or form body",
|
|
"actual location was %q", actualLocation)
|
|
}
|
|
})
|
|
}
|
|
}
|