Merge branch 'main' of github.com:vmware-tanzu/pinniped into credentialissuer-spec-api

This commit is contained in:
Matt Moyer 2021-05-27 15:13:36 -05:00
commit af2af567be
No known key found for this signature in database
GPG Key ID: EAE88AD172C5AE2D
11 changed files with 100 additions and 37 deletions

View File

@ -15,6 +15,7 @@ import (
"github.com/ory/fosite/token/jwt"
"github.com/pkg/errors"
"golang.org/x/oauth2"
"k8s.io/apiserver/pkg/authentication/authenticator"
"go.pinniped.dev/internal/httputil/httperr"
"go.pinniped.dev/internal/httputil/securityheader"
@ -108,11 +109,10 @@ func handleAuthRequestForLDAPUpstream(
return nil
}
subject := fmt.Sprintf("%s?%s=%s", ldapUpstream.GetURL(), oidc.IDTokenSubjectClaim, authenticateResponse.User.GetUID())
now := time.Now().UTC()
openIDSession := &openid.DefaultSession{
Claims: &jwt.IDTokenClaims{
Subject: subject,
Subject: downstreamSubjectFromUpstreamLDAP(ldapUpstream, authenticateResponse),
RequestedAt: now,
AuthTime: now,
},
@ -359,3 +359,11 @@ func addCSRFSetCookieHeader(w http.ResponseWriter, csrfValue csrftoken.CSRFToken
return nil
}
func downstreamSubjectFromUpstreamLDAP(ldapUpstream provider.UpstreamLDAPIdentityProviderI, authenticateResponse *authenticator.Response) string {
ldapURL := *ldapUpstream.GetURL()
q := ldapURL.Query()
q.Set(oidc.IDTokenSubjectClaim, authenticateResponse.User.GetUID())
ldapURL.RawQuery = q.Encode()
return ldapURL.String()
}

View File

@ -44,7 +44,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
downstreamPKCEChallengeMethod = "S256"
happyState = "8b-state"
downstreamClientID = "pinniped-cli"
upstreamLDAPURL = "ldaps://some-ldap-host:123"
upstreamLDAPURL = "ldaps://some-ldap-host:123?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev"
htmlContentType = "text/html; charset=utf-8"
)
@ -158,9 +158,12 @@ func TestAuthorizationEndpoint(t *testing.T) {
happyLDAPUID := "some-ldap-uid"
happyLDAPGroups := []string{"group1", "group2", "group3"}
parsedUpstreamLDAPURL, err := url.Parse(upstreamLDAPURL)
require.NoError(t, err)
upstreamLDAPIdentityProvider := oidctestutil.TestUpstreamLDAPIdentityProvider{
Name: "some-ldap-idp",
URL: upstreamLDAPURL,
URL: parsedUpstreamLDAPURL,
AuthenticateFunc: func(ctx context.Context, username, password string) (*authenticator.Response, bool, error) {
if username == "" || password == "" {
return nil, false, fmt.Errorf("should not have passed empty username or password to the authenticator")
@ -384,7 +387,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantContentType: htmlContentType,
wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp,
wantBodyStringWithLocationInHref: false,
wantDownstreamIDTokenSubject: upstreamLDAPURL + "?sub=" + happyLDAPUID,
wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID,
wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator,
wantDownstreamIDTokenGroups: happyLDAPGroups,
wantDownstreamRequestedScopes: happyDownstreamScopesRequested,
@ -443,7 +446,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantContentType: htmlContentType,
wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp,
wantBodyStringWithLocationInHref: false,
wantDownstreamIDTokenSubject: upstreamLDAPURL + "?sub=" + happyLDAPUID,
wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID,
wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator,
wantDownstreamIDTokenGroups: happyLDAPGroups,
wantDownstreamRequestedScopes: happyDownstreamScopesRequested,
@ -525,7 +528,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantContentType: htmlContentType,
wantRedirectLocationRegexp: downstreamRedirectURIWithDifferentPort + `\?code=([^&]+)&scope=openid&state=` + happyState,
wantBodyStringWithLocationInHref: false,
wantDownstreamIDTokenSubject: upstreamLDAPURL + "?sub=" + happyLDAPUID,
wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID,
wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator,
wantDownstreamIDTokenGroups: happyLDAPGroups,
wantDownstreamRequestedScopes: happyDownstreamScopesRequested,
@ -941,7 +944,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantContentType: htmlContentType,
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=&state=` + happyState, // no scopes granted
wantBodyStringWithLocationInHref: false,
wantDownstreamIDTokenSubject: upstreamLDAPURL + "?sub=" + happyLDAPUID,
wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID,
wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator,
wantDownstreamIDTokenGroups: happyLDAPGroups,
wantDownstreamRequestedScopes: []string{"email"}, // only email was requested

View File

@ -228,7 +228,7 @@ func getSubjectAndUsernameFromUpstreamIDToken(
return "", "", httperr.New(http.StatusUnprocessableEntity, "subject claim in upstream ID token has invalid format")
}
subject := fmt.Sprintf("%s?%s=%s", upstreamIssuerAsString, oidc.IDTokenSubjectClaim, upstreamSubject)
subject := downstreamSubjectFromUpstreamOIDC(upstreamIssuerAsString, upstreamSubject)
usernameClaimName := upstreamIDPConfig.GetUsernameClaim()
if usernameClaimName == "" {
@ -282,6 +282,10 @@ func getSubjectAndUsernameFromUpstreamIDToken(
return subject, username, nil
}
func downstreamSubjectFromUpstreamOIDC(upstreamIssuerAsString string, upstreamSubject string) string {
return fmt.Sprintf("%s?%s=%s", upstreamIssuerAsString, oidc.IDTokenSubjectClaim, url.QueryEscape(upstreamSubject))
}
func getGroupsFromUpstreamIDToken(
upstreamIDPConfig provider.UpstreamOIDCIdentityProviderI,
idTokenClaims map[string]interface{},

View File

@ -28,9 +28,10 @@ import (
const (
happyUpstreamIDPName = "upstream-idp-name"
upstreamIssuer = "https://my-upstream-issuer.com"
upstreamSubject = "abc123-some-guid"
upstreamUsername = "test-pinniped-username"
upstreamIssuer = "https://my-upstream-issuer.com"
upstreamSubject = "abc123-some guid" // has a space character which should get escaped in URL
queryEscapedUpstreamSubject = "abc123-some+guid"
upstreamUsername = "test-pinniped-username"
upstreamUsernameClaim = "the-user-claim"
upstreamGroupsClaim = "the-groups-claim"
@ -141,7 +142,7 @@ func TestCallbackEndpoint(t *testing.T) {
wantStatus: http.StatusFound,
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantBody: "",
wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + upstreamSubject,
wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + queryEscapedUpstreamSubject,
wantDownstreamIDTokenUsername: upstreamUsername,
wantDownstreamIDTokenGroups: upstreamGroupMembership,
wantDownstreamRequestedScopes: happyDownstreamScopesRequested,
@ -160,8 +161,8 @@ func TestCallbackEndpoint(t *testing.T) {
wantStatus: http.StatusFound,
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantBody: "",
wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + upstreamSubject,
wantDownstreamIDTokenUsername: upstreamIssuer + "?sub=" + upstreamSubject,
wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + queryEscapedUpstreamSubject,
wantDownstreamIDTokenUsername: upstreamIssuer + "?sub=" + queryEscapedUpstreamSubject,
wantDownstreamIDTokenGroups: []string{},
wantDownstreamRequestedScopes: happyDownstreamScopesRequested,
wantDownstreamGrantedScopes: happyDownstreamScopesGranted,
@ -180,7 +181,7 @@ func TestCallbackEndpoint(t *testing.T) {
wantStatus: http.StatusFound,
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantBody: "",
wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + upstreamSubject,
wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + queryEscapedUpstreamSubject,
wantDownstreamIDTokenUsername: "joe@whitehouse.gov",
wantDownstreamIDTokenGroups: upstreamGroupMembership,
wantDownstreamRequestedScopes: happyDownstreamScopesRequested,
@ -201,7 +202,7 @@ func TestCallbackEndpoint(t *testing.T) {
wantStatus: http.StatusFound,
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantBody: "",
wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + upstreamSubject,
wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + queryEscapedUpstreamSubject,
wantDownstreamIDTokenUsername: "joe@whitehouse.gov",
wantDownstreamIDTokenGroups: upstreamGroupMembership,
wantDownstreamRequestedScopes: happyDownstreamScopesRequested,
@ -223,7 +224,7 @@ func TestCallbackEndpoint(t *testing.T) {
wantStatus: http.StatusFound, // succeed despite `email_verified=false` because we're not using the email claim for anything
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantBody: "",
wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + upstreamSubject,
wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + queryEscapedUpstreamSubject,
wantDownstreamIDTokenUsername: "joe",
wantDownstreamIDTokenGroups: upstreamGroupMembership,
wantDownstreamRequestedScopes: happyDownstreamScopesRequested,
@ -268,7 +269,7 @@ func TestCallbackEndpoint(t *testing.T) {
wantStatus: http.StatusFound,
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantBody: "",
wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + upstreamSubject,
wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + queryEscapedUpstreamSubject,
wantDownstreamIDTokenUsername: upstreamSubject,
wantDownstreamIDTokenGroups: upstreamGroupMembership,
wantDownstreamRequestedScopes: happyDownstreamScopesRequested,
@ -287,7 +288,7 @@ func TestCallbackEndpoint(t *testing.T) {
wantStatus: http.StatusFound,
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantBody: "",
wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + upstreamSubject,
wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + queryEscapedUpstreamSubject,
wantDownstreamIDTokenUsername: upstreamUsername,
wantDownstreamIDTokenGroups: []string{"notAnArrayGroup1 notAnArrayGroup2"},
wantDownstreamRequestedScopes: happyDownstreamScopesRequested,
@ -306,7 +307,7 @@ func TestCallbackEndpoint(t *testing.T) {
wantStatus: http.StatusFound,
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantBody: "",
wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + upstreamSubject,
wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + queryEscapedUpstreamSubject,
wantDownstreamIDTokenUsername: upstreamUsername,
wantDownstreamIDTokenGroups: []string{"group1", "group2"},
wantDownstreamRequestedScopes: happyDownstreamScopesRequested,
@ -445,7 +446,7 @@ func TestCallbackEndpoint(t *testing.T) {
wantStatus: http.StatusFound,
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=&state=` + happyDownstreamState,
wantDownstreamIDTokenUsername: upstreamUsername,
wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + upstreamSubject,
wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + queryEscapedUpstreamSubject,
wantDownstreamRequestedScopes: []string{"profile", "email"},
wantDownstreamIDTokenGroups: upstreamGroupMembership,
wantDownstreamNonce: downstreamNonce,
@ -467,7 +468,7 @@ func TestCallbackEndpoint(t *testing.T) {
wantStatus: http.StatusFound,
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access&state=` + happyDownstreamState,
wantDownstreamIDTokenUsername: upstreamUsername,
wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + upstreamSubject,
wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + queryEscapedUpstreamSubject,
wantDownstreamRequestedScopes: []string{"openid", "offline_access"},
wantDownstreamGrantedScopes: []string{"openid", "offline_access"},
wantDownstreamIDTokenGroups: upstreamGroupMembership,
@ -548,7 +549,7 @@ func TestCallbackEndpoint(t *testing.T) {
wantStatus: http.StatusFound,
wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp,
wantBody: "",
wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + upstreamSubject,
wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + queryEscapedUpstreamSubject,
wantDownstreamIDTokenUsername: upstreamUsername,
wantDownstreamRequestedScopes: happyDownstreamScopesRequested,
wantDownstreamGrantedScopes: happyDownstreamScopesGranted,

View File

@ -56,7 +56,7 @@ type UpstreamLDAPIdentityProviderI interface {
// Return a URL which uniquely identifies this LDAP provider, e.g. "ldaps://host.example.com:1234".
// This URL is not used for connecting to the provider, but rather is used for creating a globally unique user
// identifier by being combined with the user's UID, since user UIDs are only unique within one provider.
GetURL() string
GetURL() *url.URL
// A method for performing user authentication against the upstream LDAP provider.
authenticators.UserAuthenticator

View File

@ -51,10 +51,12 @@ type ExchangeAuthcodeAndValidateTokenArgs struct {
type TestUpstreamLDAPIdentityProvider struct {
Name string
URL string
URL *url.URL
AuthenticateFunc func(ctx context.Context, username, password string) (*authenticator.Response, bool, error)
}
var _ provider.UpstreamLDAPIdentityProviderI = &TestUpstreamLDAPIdentityProvider{}
func (u *TestUpstreamLDAPIdentityProvider) GetName() string {
return u.Name
}
@ -63,7 +65,7 @@ func (u *TestUpstreamLDAPIdentityProvider) AuthenticateUser(ctx context.Context,
return u.AuthenticateFunc(ctx, username, password)
}
func (u *TestUpstreamLDAPIdentityProvider) GetURL() string {
func (u *TestUpstreamLDAPIdentityProvider) GetURL() *url.URL {
return u.URL
}

View File

@ -11,17 +11,19 @@ import (
"errors"
"fmt"
"net"
"net/url"
"sort"
"strings"
"time"
"k8s.io/utils/trace"
"github.com/go-ldap/ldap/v3"
"k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/utils/trace"
"go.pinniped.dev/internal/authenticators"
"go.pinniped.dev/internal/endpointaddr"
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/plog"
)
@ -138,6 +140,9 @@ type Provider struct {
c ProviderConfig
}
var _ provider.UpstreamLDAPIdentityProviderI = &Provider{}
var _ authenticators.UserAuthenticator = &Provider{}
// Create a Provider. The config is not a pointer to ensure that a copy of the config is created,
// making the resulting Provider use an effectively read-only configuration.
func New(config ProviderConfig) *Provider {
@ -249,11 +254,15 @@ func (p *Provider) GetName() string {
return p.c.Name
}
// Return a URL which uniquely identifies this LDAP provider, e.g. "ldaps://host.example.com:1234".
// Return a URL which uniquely identifies this LDAP provider, e.g. "ldaps://host.example.com:1234?base=user-search-base".
// This URL is not used for connecting to the provider, but rather is used for creating a globally unique user
// identifier by being combined with the user's UID, since user UIDs are only unique within one provider.
func (p *Provider) GetURL() string {
return fmt.Sprintf("%s://%s", ldapsScheme, p.c.Host)
func (p *Provider) GetURL() *url.URL {
u := &url.URL{Scheme: ldapsScheme, Host: p.c.Host}
q := u.Query()
q.Set("base", p.c.UserSearch.Base)
u.RawQuery = q.Encode()
return u
}
// TestConnection provides a method for testing the connection and bind settings. It performs a dial and bind

View File

@ -1115,8 +1115,19 @@ func TestGetConfig(t *testing.T) {
}
func TestGetURL(t *testing.T) {
require.Equal(t, "ldaps://ldap.example.com:1234", New(ProviderConfig{Host: "ldap.example.com:1234"}).GetURL())
require.Equal(t, "ldaps://ldap.example.com", New(ProviderConfig{Host: "ldap.example.com"}).GetURL())
require.Equal(t,
"ldaps://ldap.example.com:1234?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev",
New(ProviderConfig{
Host: "ldap.example.com:1234",
UserSearch: UserSearchConfig{Base: "ou=users,dc=pinniped,dc=dev"},
}).GetURL().String())
require.Equal(t,
"ldaps://ldap.example.com?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev",
New(ProviderConfig{
Host: "ldap.example.com",
UserSearch: UserSearchConfig{Base: "ou=users,dc=pinniped,dc=dev"},
}).GetURL().String())
}
// Testing of host parsing, TLS negotiation, and CA bundle, etc. for the production code's dialer.

View File

@ -41,7 +41,7 @@ ldap.ldif: |
objectClass: shadowAccount
cn: pinny
sn: Seal
givenName: Pinny
givenName: Pinny the 🦭
mail: pinny.ldap@example.com
userPassword: (@= data.values.pinny_ldap_password @)
uid: pinny

View File

@ -167,6 +167,31 @@ func TestLDAPSearch(t *testing.T) {
User: &user.DefaultInfo{Name: "Seal", UID: "1000", Groups: []string{"ball-game-players", "seals"}}, // note that the final answer has case preserved from the entry
},
},
{
name: "when the UsernameAttribute or UIDAttribute are attributes whose value contains UTF-8 data",
username: "pinny",
password: pinnyPassword,
provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) {
p.UserSearch.Filter = "cn={}"
p.UserSearch.UsernameAttribute = "givenName"
p.UserSearch.UIDAttribute = "givenName"
})),
wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{Name: "Pinny the 🦭", UID: "Pinny the 🦭", Groups: []string{"ball-game-players", "seals"}},
},
},
{
name: "when the search filter is searching on an attribute whose value contains UTF-8 data",
username: "Pinny the 🦭",
password: pinnyPassword,
provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) {
p.UserSearch.Filter = "givenName={}"
p.UserSearch.UsernameAttribute = "cn"
})),
wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{Name: "pinny", UID: "1000", Groups: []string{"ball-game-players", "seals"}},
},
},
{
name: "when the UsernameAttribute is dn and there is no user search filter provided",
username: "cn=pinny,ou=users,dc=pinniped,dc=dev",

View File

@ -119,7 +119,7 @@ func TestSupervisorLogin(t *testing.T) {
},
// 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 + "?sub=" + env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue,
"ldaps://" + env.SupervisorUpstreamLDAP.Host + "?base=" + url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase) + "&sub=" + env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue,
),
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
wantDownstreamIDTokenUsernameToMatch: regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue),
@ -176,7 +176,7 @@ func TestSupervisorLogin(t *testing.T) {
},
// 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 + "?sub=" + env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue,
"ldaps://" + env.SupervisorUpstreamLDAP.StartTLSOnlyHost + "?base=" + url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase) + "&sub=" + env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue,
),
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
wantDownstreamIDTokenUsernameToMatch: regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserDN),