694 lines
33 KiB
Go
694 lines
33 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"
|
||
|
"k8s.io/apiserver/pkg/authentication/user"
|
||
|
"k8s.io/client-go/kubernetes/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"}
|
||
|
happyDownstreamScopesGranted := []string{"openid"}
|
||
|
|
||
|
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&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
|
||
|
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
|
||
|
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 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&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: "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) {
|
||
|
kubeClient := fake.NewSimpleClientset()
|
||
|
secretsClient := kubeClient.CoreV1().Secrets("some-namespace")
|
||
|
|
||
|
// 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()
|
||
|
kubeOauthStore := oidc.NewKubeStorage(secretsClient, timeoutsConfiguration)
|
||
|
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)
|
||
|
require.Equal(t, test.wantBodyString, rsp.Body.String())
|
||
|
|
||
|
actualLocation := rsp.Header().Get("Location")
|
||
|
|
||
|
switch {
|
||
|
case tt.wantRedirectLocationRegexp != "":
|
||
|
require.Len(t, rsp.Header().Values("Location"), 1)
|
||
|
oidctestutil.RequireAuthCodeRegexpMatch(
|
||
|
t,
|
||
|
actualLocation,
|
||
|
test.wantRedirectLocationRegexp,
|
||
|
kubeClient,
|
||
|
secretsClient,
|
||
|
kubeOauthStore,
|
||
|
test.wantDownstreamGrantedScopes,
|
||
|
test.wantDownstreamIDTokenSubject,
|
||
|
test.wantDownstreamIDTokenUsername,
|
||
|
test.wantDownstreamIDTokenGroups,
|
||
|
test.wantDownstreamRequestedScopes,
|
||
|
test.wantDownstreamPKCEChallenge,
|
||
|
test.wantDownstreamPKCEChallengeMethod,
|
||
|
test.wantDownstreamNonce,
|
||
|
downstreamClientID,
|
||
|
test.wantDownstreamRedirectURI,
|
||
|
test.wantDownstreamCustomSessionData,
|
||
|
)
|
||
|
case tt.wantRedirectToLoginPageError != "":
|
||
|
expectedLocation := downstreamIssuer + oidc.PinnipedLoginPath +
|
||
|
"?err=" + tt.wantRedirectToLoginPageError + "&state=" + happyEncodedUpstreamState
|
||
|
require.Equal(t, expectedLocation, actualLocation)
|
||
|
require.Len(t, kubeClient.Actions(), test.wantUnnecessaryStoredRecords)
|
||
|
case tt.wantRedirectLocationString != "":
|
||
|
require.Equal(t, tt.wantRedirectLocationString, actualLocation)
|
||
|
require.Len(t, kubeClient.Actions(), test.wantUnnecessaryStoredRecords)
|
||
|
default:
|
||
|
require.Failf(t, "test should have expected a redirect",
|
||
|
"actual location was %q", actualLocation)
|
||
|
}
|
||
|
})
|
||
|
}
|
||
|
}
|