require groups scope to get groups back from supervisor

Signed-off-by: Margo Crawford <margaretc@vmware.com>
This commit is contained in:
Margo Crawford 2022-06-15 08:00:17 -07:00
parent 268e1108d1
commit 4d0c2e16f4
13 changed files with 172 additions and 48 deletions

View File

@ -146,7 +146,7 @@ func handleAuthRequestForLDAPUpstreamCLIFlow(
username = authenticateResponse.User.GetName() username = authenticateResponse.User.GetName()
groups := authenticateResponse.User.GetGroups() groups := authenticateResponse.User.GetGroups()
customSessionData := downstreamsession.MakeDownstreamLDAPOrADCustomSessionData(ldapUpstream, idpType, authenticateResponse) customSessionData := downstreamsession.MakeDownstreamLDAPOrADCustomSessionData(ldapUpstream, idpType, authenticateResponse)
openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups, customSessionData) openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups, authorizeRequester.GetGrantedScopes(), customSessionData)
oidc.PerformAuthcodeRedirect(r, w, oauthHelper, authorizeRequester, openIDSession, true) oidc.PerformAuthcodeRedirect(r, w, oauthHelper, authorizeRequester, openIDSession, true)
return nil return nil
@ -243,7 +243,7 @@ func handleAuthRequestForOIDCUpstreamPasswordGrant(
return nil return nil
} }
openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups, customSessionData) openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups, authorizeRequester.GetGrantedScopes(), customSessionData)
oidc.PerformAuthcodeRedirect(r, w, oauthHelper, authorizeRequester, openIDSession, true) oidc.PerformAuthcodeRedirect(r, w, oauthHelper, authorizeRequester, openIDSession, true)
@ -334,7 +334,7 @@ func newAuthorizeRequest(r *http.Request, w http.ResponseWriter, oauthHelper fos
// Grant the openid scope (for now) if they asked for it so that `NewAuthorizeResponse` will perform its OIDC validations. // Grant the openid scope (for now) if they asked for it so that `NewAuthorizeResponse` will perform its OIDC validations.
// There don't seem to be any validations inside `NewAuthorizeResponse` related to the offline_access scope // There don't seem to be any validations inside `NewAuthorizeResponse` related to the offline_access scope
// at this time, however we will temporarily grant the scope just in case that changes in a future release of fosite. // at this time, however we will temporarily grant the scope just in case that changes in a future release of fosite.
downstreamsession.GrantScopesIfRequested(authorizeRequester) downstreamsession.GrantScopesIfRequested(authorizeRequester, []string{coreosoidc.ScopeOpenID, coreosoidc.ScopeOfflineAccess, oidc.RequestAudienceScope, oidc.DownstreamGroupsScope})
return authorizeRequester, true return authorizeRequester, true
} }

View File

@ -375,8 +375,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
return urlToReturn return urlToReturn
} }
happyDownstreamScopesRequested := []string{"openid", "profile", "email"} happyDownstreamScopesRequested := []string{"openid", "profile", "email", "groups"}
happyDownstreamScopesGranted := []string{"openid"} happyDownstreamScopesGranted := []string{"openid", "groups"}
happyGetRequestQueryMap := map[string]string{ happyGetRequestQueryMap := map[string]string{
"response_type": "code", "response_type": "code",
@ -495,7 +495,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
} }
// Note that fosite puts the granted scopes as a param in the redirect URI even though the spec doesn't seem to require it // 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=` + happyState happyAuthcodeDownstreamRedirectLocationRegexp := downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+groups&state=` + happyState
incomingCookieCSRFValue := "csrf-value-from-cookie" incomingCookieCSRFValue := "csrf-value-from-cookie"
encodedIncomingCookieCSRFValue, err := happyCookieEncoder.Encode("csrf", incomingCookieCSRFValue) encodedIncomingCookieCSRFValue, err := happyCookieEncoder.Encode("csrf", incomingCookieCSRFValue)
@ -957,7 +957,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation,
wantStatus: http.StatusFound, wantStatus: http.StatusFound,
wantContentType: htmlContentType, wantContentType: htmlContentType,
wantRedirectLocationRegexp: downstreamRedirectURIWithDifferentPort + `\?code=([^&]+)&scope=openid&state=` + happyState, wantRedirectLocationRegexp: downstreamRedirectURIWithDifferentPort + `\?code=([^&]+)&scope=openid\+groups&state=` + happyState,
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenUsername: oidcUpstreamUsername,
wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership,
@ -980,7 +980,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
customPasswordHeader: pointer.StringPtr(happyLDAPPassword), customPasswordHeader: pointer.StringPtr(happyLDAPPassword),
wantStatus: http.StatusFound, wantStatus: http.StatusFound,
wantContentType: htmlContentType, wantContentType: htmlContentType,
wantRedirectLocationRegexp: downstreamRedirectURIWithDifferentPort + `\?code=([^&]+)&scope=openid&state=` + happyState, wantRedirectLocationRegexp: downstreamRedirectURIWithDifferentPort + `\?code=([^&]+)&scope=openid\+groups&state=` + happyState,
wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID, wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID,
wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator,
wantDownstreamIDTokenGroups: happyLDAPGroups, wantDownstreamIDTokenGroups: happyLDAPGroups,

View File

@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
coreosoidc "github.com/coreos/go-oidc/v3/oidc"
"github.com/ory/fosite" "github.com/ory/fosite"
"go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/httputil/httperr"
@ -52,7 +53,7 @@ func NewHandler(
} }
// Automatically grant the openid, offline_access, and pinniped:request-audience scopes, but only if they were requested. // Automatically grant the openid, offline_access, and pinniped:request-audience scopes, but only if they were requested.
downstreamsession.GrantScopesIfRequested(authorizeRequester) downstreamsession.GrantScopesIfRequested(authorizeRequester, []string{coreosoidc.ScopeOpenID, coreosoidc.ScopeOfflineAccess, oidc.RequestAudienceScope, oidc.DownstreamGroupsScope})
token, err := upstreamIDPConfig.ExchangeAuthcodeAndValidateTokens( token, err := upstreamIDPConfig.ExchangeAuthcodeAndValidateTokens(
r.Context(), r.Context(),
@ -76,7 +77,7 @@ func NewHandler(
return httperr.Wrap(http.StatusUnprocessableEntity, err.Error(), err) return httperr.Wrap(http.StatusUnprocessableEntity, err.Error(), err)
} }
openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups, customSessionData) openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups, authorizeRequester.GetGrantedScopes(), customSessionData)
authorizeResponder, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, openIDSession) authorizeResponder, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, openIDSession)
if err != nil { if err != nil {

View File

@ -62,8 +62,8 @@ const (
var ( var (
oidcUpstreamGroupMembership = []string{"test-pinniped-group-0", "test-pinniped-group-1"} oidcUpstreamGroupMembership = []string{"test-pinniped-group-0", "test-pinniped-group-1"}
happyDownstreamScopesRequested = []string{"openid"} happyDownstreamScopesRequested = []string{"openid", "groups"}
happyDownstreamScopesGranted = []string{"openid"} happyDownstreamScopesGranted = []string{"openid", "groups"}
happyDownstreamRequestParamsQuery = url.Values{ happyDownstreamRequestParamsQuery = url.Values{
"response_type": []string{"code"}, "response_type": []string{"code"},
@ -133,7 +133,7 @@ func TestCallbackEndpoint(t *testing.T) {
} }
// Note that fosite puts the granted scopes as a param in the redirect URI even though the spec doesn't seem to require it // Note that fosite puts the granted scopes as a param in the redirect URI even though the spec doesn't seem to require it
happyDownstreamRedirectLocationRegexp := downstreamRedirectURI + `\?code=([^&]+)&scope=openid&state=` + happyDownstreamState happyDownstreamRedirectLocationRegexp := downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+groups&state=` + happyDownstreamState
tests := []struct { tests := []struct {
name string name string
@ -236,6 +236,38 @@ func TestCallbackEndpoint(t *testing.T) {
args: happyExchangeAndValidateTokensArgs, args: happyExchangeAndValidateTokensArgs,
}, },
}, },
{
name: "form_post happy path with no groups scope requested",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()),
method: http.MethodGet,
path: newRequestPath().WithState(
happyUpstreamStateParam().WithAuthorizeRequestParams(
shallowCopyAndModifyQuery(
happyDownstreamRequestParamsQuery,
map[string]string{
"response_mode": "form_post",
"scope": "openid",
},
).Encode(),
).Build(t, happyStateCodec),
).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusOK,
wantContentType: "text/html;charset=UTF-8",
wantBodyFormResponseRegexp: `<code id="manual-auth-code">(.+)</code>`,
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
wantDownstreamIDTokenUsername: oidcUpstreamUsername,
wantDownstreamRequestedScopes: []string{"openid"},
wantDownstreamGrantedScopes: []string{"openid"},
wantDownstreamNonce: downstreamNonce,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantDownstreamCustomSessionData: happyDownstreamCustomSessionData,
wantAuthcodeExchangeCall: &expectedAuthcodeExchange{
performedByUpstreamName: happyUpstreamIDPName,
args: happyExchangeAndValidateTokensArgs,
},
},
{ {
name: "GET with authcode exchange that returns an access token but no refresh token but has a short token lifetime which is stored as a warning in the session", name: "GET with authcode exchange that returns an access token but no refresh token but has a short token lifetime which is stored as a warning in the session",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().WithEmptyRefreshToken().WithAccessToken(oidcUpstreamAccessToken, metav1.NewTime(time.Now().Add(1*time.Hour))).WithUserInfoURL().Build()), idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().WithEmptyRefreshToken().WithAccessToken(oidcUpstreamAccessToken, metav1.NewTime(time.Now().Add(1*time.Hour))).WithUserInfoURL().Build()),
@ -683,6 +715,33 @@ func TestCallbackEndpoint(t *testing.T) {
name: "state's downstream auth params does not contain openid scope", name: "state's downstream auth params does not contain openid scope",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()),
method: http.MethodGet, method: http.MethodGet,
path: newRequestPath().
WithState(
happyUpstreamStateParam().
WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, map[string]string{"scope": "profile email groups"}).Encode()).
Build(t, happyStateCodec),
).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusSeeOther,
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=groups&state=` + happyDownstreamState,
wantDownstreamIDTokenUsername: oidcUpstreamUsername,
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
wantDownstreamRequestedScopes: []string{"profile", "email", "groups"},
wantDownstreamGrantedScopes: []string{"groups"},
wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership,
wantDownstreamNonce: downstreamNonce,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantDownstreamCustomSessionData: happyDownstreamCustomSessionData,
wantAuthcodeExchangeCall: &expectedAuthcodeExchange{
performedByUpstreamName: happyUpstreamIDPName,
args: happyExchangeAndValidateTokensArgs,
},
},
{
name: "state's downstream auth params does not contain openid or groups scope",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()),
method: http.MethodGet,
path: newRequestPath(). path: newRequestPath().
WithState( WithState(
happyUpstreamStateParam(). happyUpstreamStateParam().
@ -695,7 +754,7 @@ func TestCallbackEndpoint(t *testing.T) {
wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenUsername: oidcUpstreamUsername,
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
wantDownstreamRequestedScopes: []string{"profile", "email"}, wantDownstreamRequestedScopes: []string{"profile", "email"},
wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamGrantedScopes: []string{},
wantDownstreamNonce: downstreamNonce, wantDownstreamNonce: downstreamNonce,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
@ -712,16 +771,16 @@ func TestCallbackEndpoint(t *testing.T) {
path: newRequestPath(). path: newRequestPath().
WithState( WithState(
happyUpstreamStateParam(). happyUpstreamStateParam().
WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, map[string]string{"scope": "openid offline_access"}).Encode()). WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, map[string]string{"scope": "openid offline_access groups"}).Encode()).
Build(t, happyStateCodec), Build(t, happyStateCodec),
).String(), ).String(),
csrfCookie: happyCSRFCookie, csrfCookie: happyCSRFCookie,
wantStatus: http.StatusSeeOther, wantStatus: http.StatusSeeOther,
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access&state=` + happyDownstreamState, wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+groups&state=` + happyDownstreamState,
wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenUsername: oidcUpstreamUsername,
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
wantDownstreamRequestedScopes: []string{"openid", "offline_access"}, wantDownstreamRequestedScopes: []string{"openid", "offline_access", "groups"},
wantDownstreamGrantedScopes: []string{"openid", "offline_access"}, wantDownstreamGrantedScopes: []string{"openid", "offline_access", "groups"},
wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership,
wantDownstreamNonce: downstreamNonce, wantDownstreamNonce: downstreamNonce,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallenge: downstreamPKCEChallenge,

View File

@ -1,4 +1,4 @@
// Copyright 2021 the Pinniped contributors. All Rights Reserved. // Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// Package clientregistry defines Pinniped's OAuth2/OIDC clients. // Package clientregistry defines Pinniped's OAuth2/OIDC clients.
@ -85,6 +85,7 @@ func PinnipedCLI() *Client {
"profile", "profile",
"email", "email",
"pinniped:request-audience", "pinniped:request-audience",
"groups",
}, },
Audience: nil, Audience: nil,
Public: true, Public: true,

View File

@ -1,4 +1,4 @@
// Copyright 2021 the Pinniped contributors. All Rights Reserved. // Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package clientregistry package clientregistry
@ -50,7 +50,7 @@ func TestPinnipedCLI(t *testing.T) {
require.Equal(t, []string{"http://127.0.0.1/callback"}, c.GetRedirectURIs()) require.Equal(t, []string{"http://127.0.0.1/callback"}, c.GetRedirectURIs())
require.Equal(t, fosite.Arguments{"authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:token-exchange"}, c.GetGrantTypes()) require.Equal(t, fosite.Arguments{"authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:token-exchange"}, c.GetGrantTypes())
require.Equal(t, fosite.Arguments{"code"}, c.GetResponseTypes()) require.Equal(t, fosite.Arguments{"code"}, c.GetResponseTypes())
require.Equal(t, fosite.Arguments{oidc.ScopeOpenID, oidc.ScopeOfflineAccess, "profile", "email", "pinniped:request-audience"}, c.GetScopes()) require.Equal(t, fosite.Arguments{oidc.ScopeOpenID, oidc.ScopeOfflineAccess, "profile", "email", "pinniped:request-audience", "groups"}, c.GetScopes())
require.True(t, c.IsPublic()) require.True(t, c.IsPublic())
require.Nil(t, c.GetAudience()) require.Nil(t, c.GetAudience())
require.Nil(t, c.GetRequestURIs()) require.Nil(t, c.GetRequestURIs())
@ -82,7 +82,8 @@ func TestPinnipedCLI(t *testing.T) {
"offline_access", "offline_access",
"profile", "profile",
"email", "email",
"pinniped:request-audience" "pinniped:request-audience",
"groups"
], ],
"audience": null, "audience": null,
"public": true, "public": true,

View File

@ -10,7 +10,8 @@ import (
"net/url" "net/url"
"time" "time"
coreosoidc "github.com/coreos/go-oidc/v3/oidc" "k8s.io/utils/strings/slices"
"github.com/ory/fosite" "github.com/ory/fosite"
"github.com/ory/fosite/handler/openid" "github.com/ory/fosite/handler/openid"
"github.com/ory/fosite/token/jwt" "github.com/ory/fosite/token/jwt"
@ -40,7 +41,7 @@ const (
) )
// MakeDownstreamSession creates a downstream OIDC session. // MakeDownstreamSession creates a downstream OIDC session.
func MakeDownstreamSession(subject string, username string, groups []string, custom *psession.CustomSessionData) *psession.PinnipedSession { func MakeDownstreamSession(subject string, username string, groups []string, grantedScopes []string, custom *psession.CustomSessionData) *psession.PinnipedSession {
now := time.Now().UTC() now := time.Now().UTC()
openIDSession := &psession.PinnipedSession{ openIDSession := &psession.PinnipedSession{
Fosite: &openid.DefaultSession{ Fosite: &openid.DefaultSession{
@ -57,7 +58,9 @@ func MakeDownstreamSession(subject string, username string, groups []string, cus
} }
openIDSession.IDTokenClaims().Extra = map[string]interface{}{ openIDSession.IDTokenClaims().Extra = map[string]interface{}{
oidc.DownstreamUsernameClaim: username, oidc.DownstreamUsernameClaim: username,
oidc.DownstreamGroupsClaim: groups, }
if slices.Contains(grantedScopes, oidc.DownstreamGroupsScope) {
openIDSession.IDTokenClaims().Extra[oidc.DownstreamGroupsClaim] = groups
} }
return openIDSession return openIDSession
} }
@ -147,10 +150,10 @@ func MakeDownstreamOIDCCustomSessionData(oidcUpstream provider.UpstreamOIDCIdent
} }
// GrantScopesIfRequested auto-grants the scopes for which we do not require end-user approval, if they were requested. // GrantScopesIfRequested auto-grants the scopes for which we do not require end-user approval, if they were requested.
func GrantScopesIfRequested(authorizeRequester fosite.AuthorizeRequester) { func GrantScopesIfRequested(authorizeRequester fosite.AuthorizeRequester, scopes []string) {
oidc.GrantScopeIfRequested(authorizeRequester, coreosoidc.ScopeOpenID) for _, scope := range scopes {
oidc.GrantScopeIfRequested(authorizeRequester, coreosoidc.ScopeOfflineAccess) oidc.GrantScopeIfRequested(authorizeRequester, scope)
oidc.GrantScopeIfRequested(authorizeRequester, "pinniped:request-audience") }
} }
// GetDownstreamIdentityFromUpstreamIDToken returns the mapped subject, username, and group names, in that order. // GetDownstreamIdentityFromUpstreamIDToken returns the mapped subject, username, and group names, in that order.

View File

@ -7,6 +7,8 @@ import (
"net/http" "net/http"
"net/url" "net/url"
coreosoidc "github.com/coreos/go-oidc/v3/oidc"
"github.com/ory/fosite" "github.com/ory/fosite"
"go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/httputil/httperr"
@ -44,8 +46,8 @@ func NewPostHandler(issuerURL string, upstreamIDPs oidc.UpstreamIdentityProvider
return httperr.New(http.StatusBadRequest, "error using state downstream auth params") return httperr.New(http.StatusBadRequest, "error using state downstream auth params")
} }
// Automatically grant the openid, offline_access, and pinniped:request-audience scopes, but only if they were requested. // Automatically grant the openid, offline_access, pinniped:request-audience and groups scopes, but only if they were requested.
downstreamsession.GrantScopesIfRequested(authorizeRequester) downstreamsession.GrantScopesIfRequested(authorizeRequester, []string{coreosoidc.ScopeOpenID, coreosoidc.ScopeOfflineAccess, oidc.RequestAudienceScope, oidc.DownstreamGroupsScope})
// Get the username and password form params from the POST body. // Get the username and password form params from the POST body.
username := r.PostFormValue(usernameParamName) username := r.PostFormValue(usernameParamName)
@ -80,7 +82,7 @@ func NewPostHandler(issuerURL string, upstreamIDPs oidc.UpstreamIdentityProvider
username = authenticateResponse.User.GetName() username = authenticateResponse.User.GetName()
groups := authenticateResponse.User.GetGroups() groups := authenticateResponse.User.GetGroups()
customSessionData := downstreamsession.MakeDownstreamLDAPOrADCustomSessionData(ldapUpstream, idpType, authenticateResponse) customSessionData := downstreamsession.MakeDownstreamLDAPOrADCustomSessionData(ldapUpstream, idpType, authenticateResponse)
openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups, customSessionData) openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups, authorizeRequester.GetGrantedScopes(), customSessionData)
oidc.PerformAuthcodeRedirect(r, w, oauthHelper, authorizeRequester, openIDSession, false) oidc.PerformAuthcodeRedirect(r, w, oauthHelper, authorizeRequester, openIDSession, false)
return nil return nil

View File

@ -82,8 +82,8 @@ func TestPostLoginEndpoint(t *testing.T) {
} }
) )
happyDownstreamScopesRequested := []string{"openid"} happyDownstreamScopesRequested := []string{"openid", "groups"}
happyDownstreamScopesGranted := []string{"openid"} happyDownstreamScopesGranted := []string{"openid", "groups"}
happyDownstreamRequestParamsQuery := url.Values{ happyDownstreamRequestParamsQuery := url.Values{
"response_type": []string{"code"}, "response_type": []string{"code"},
@ -211,7 +211,7 @@ func TestPostLoginEndpoint(t *testing.T) {
} }
// Note that fosite puts the granted scopes as a param in the redirect URI even though the spec doesn't seem to require it // 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 happyAuthcodeDownstreamRedirectLocationRegexp := downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+groups&state=` + happyDownstreamState
happyUsernamePasswordFormParams := url.Values{userParam: []string{happyLDAPUsername}, passParam: []string{happyLDAPPassword}} happyUsernamePasswordFormParams := url.Values{userParam: []string{happyLDAPUsername}, passParam: []string{happyLDAPPassword}}
@ -348,7 +348,7 @@ func TestPostLoginEndpoint(t *testing.T) {
wantStatus: http.StatusSeeOther, wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType, wantContentType: htmlContentType,
wantBodyString: "", wantBodyString: "",
wantRedirectLocationRegexp: "http://127.0.0.1:4242/callback" + `\?code=([^&]+)&scope=openid&state=` + happyDownstreamState, wantRedirectLocationRegexp: "http://127.0.0.1:4242/callback" + `\?code=([^&]+)&scope=openid\+groups&state=` + happyDownstreamState,
wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID, wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID,
wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator,
wantDownstreamIDTokenGroups: happyLDAPGroups, wantDownstreamIDTokenGroups: happyLDAPGroups,
@ -410,6 +410,31 @@ func TestPostLoginEndpoint(t *testing.T) {
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, 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", name: "bad username LDAP login",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),

View File

@ -76,6 +76,14 @@ const (
// information. // information.
DownstreamGroupsClaim = "groups" DownstreamGroupsClaim = "groups"
// DownstreamGroupsScope is a custom scope that determines whether the
// groups claim will be returned in ID tokens.
DownstreamGroupsScope = "groups"
// RequestAudienceScope is a custom scope that determines whether a RFC8693 token
// exchange is allowed to request a different audience.
RequestAudienceScope = "pinniped:request-audience"
// CSRFCookieLifespan is the length of time that the CSRF cookie is valid. After this time, the // CSRFCookieLifespan is the length of time that the CSRF cookie is valid. After this time, the
// Supervisor's authorization endpoint should give the browser a new CSRF cookie. We set it to // Supervisor's authorization endpoint should give the browser a new CSRF cookie. We set it to
// a week so that it is unlikely to expire during a login. // a week so that it is unlikely to expire during a login.

View File

@ -14,6 +14,8 @@ import (
"testing" "testing"
"time" "time"
"k8s.io/utils/strings/slices"
coreosoidc "github.com/coreos/go-oidc/v3/oidc" coreosoidc "github.com/coreos/go-oidc/v3/oidc"
"github.com/gorilla/securecookie" "github.com/gorilla/securecookie"
"github.com/ory/fosite" "github.com/ory/fosite"
@ -1063,10 +1065,16 @@ func validateAuthcodeStorage(
// Check the user's identity, which are put into the downstream ID token's subject, username and groups claims. // Check the user's identity, which are put into the downstream ID token's subject, username and groups claims.
require.Equal(t, wantDownstreamIDTokenSubject, actualClaims.Subject) require.Equal(t, wantDownstreamIDTokenSubject, actualClaims.Subject)
require.Equal(t, wantDownstreamIDTokenUsername, actualClaims.Extra["username"]) require.Equal(t, wantDownstreamIDTokenUsername, actualClaims.Extra["username"])
if slices.Contains(wantDownstreamGrantedScopes, "groups") {
require.Len(t, actualClaims.Extra, 2) require.Len(t, actualClaims.Extra, 2)
actualDownstreamIDTokenGroups := actualClaims.Extra["groups"] actualDownstreamIDTokenGroups := actualClaims.Extra["groups"]
require.NotNil(t, actualDownstreamIDTokenGroups) require.NotNil(t, actualDownstreamIDTokenGroups)
require.ElementsMatch(t, wantDownstreamIDTokenGroups, actualDownstreamIDTokenGroups) require.ElementsMatch(t, wantDownstreamIDTokenGroups, actualDownstreamIDTokenGroups)
} else {
require.Len(t, actualClaims.Extra, 1)
actualDownstreamIDTokenGroups := actualClaims.Extra["groups"]
require.Nil(t, actualDownstreamIDTokenGroups)
}
// Check the rest of the downstream ID token's claims. Fosite wants us to set these (in UTC time). // Check the rest of the downstream ID token's claims. Fosite wants us to set these (in UTC time).
testutil.RequireTimeInDelta(t, time.Now().UTC(), actualClaims.RequestedAt, timeComparisonFudgeFactor) testutil.RequireTimeInDelta(t, time.Now().UTC(), actualClaims.RequestedAt, timeComparisonFudgeFactor)

View File

@ -170,6 +170,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
"--oidc-skip-browser", "--oidc-skip-browser",
"--oidc-ca-bundle", testCABundlePath, "--oidc-ca-bundle", testCABundlePath,
"--oidc-session-cache", sessionCachePath, "--oidc-session-cache", sessionCachePath,
"--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups",
}) })
// Run "kubectl get namespaces" which should trigger a browser login via the plugin. // Run "kubectl get namespaces" which should trigger a browser login via the plugin.
@ -256,6 +257,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
"--oidc-skip-listen", "--oidc-skip-listen",
"--oidc-ca-bundle", testCABundlePath, "--oidc-ca-bundle", testCABundlePath,
"--oidc-session-cache", sessionCachePath, "--oidc-session-cache", sessionCachePath,
"--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups",
}) })
// Run "kubectl get namespaces" which should trigger a browser login via the plugin. // Run "kubectl get namespaces" which should trigger a browser login via the plugin.
@ -381,6 +383,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
"--oidc-skip-listen", "--oidc-skip-listen",
"--oidc-ca-bundle", testCABundlePath, "--oidc-ca-bundle", testCABundlePath,
"--oidc-session-cache", sessionCachePath, "--oidc-session-cache", sessionCachePath,
"--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups",
}) })
// Run "kubectl get namespaces" which should trigger a browser login via the plugin. // Run "kubectl get namespaces" which should trigger a browser login via the plugin.
@ -514,6 +517,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
"--upstream-identity-provider-flow", "cli_password", // create a kubeconfig configured to use the cli_password flow "--upstream-identity-provider-flow", "cli_password", // create a kubeconfig configured to use the cli_password flow
"--oidc-ca-bundle", testCABundlePath, "--oidc-ca-bundle", testCABundlePath,
"--oidc-session-cache", sessionCachePath, "--oidc-session-cache", sessionCachePath,
"--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups",
}) })
// Run "kubectl get namespaces" which should trigger a browser-less CLI prompt login via the plugin. // Run "kubectl get namespaces" which should trigger a browser-less CLI prompt login via the plugin.
@ -594,6 +598,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
"--upstream-identity-provider-flow", "cli_password", "--upstream-identity-provider-flow", "cli_password",
"--oidc-ca-bundle", testCABundlePath, "--oidc-ca-bundle", testCABundlePath,
"--oidc-session-cache", sessionCachePath, "--oidc-session-cache", sessionCachePath,
"--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups",
}) })
// Run "kubectl get --raw /healthz" which should trigger a browser-less CLI prompt login via the plugin. // Run "kubectl get --raw /healthz" which should trigger a browser-less CLI prompt login via the plugin.
@ -655,6 +660,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
"--concierge-authenticator-type", "jwt", "--concierge-authenticator-type", "jwt",
"--concierge-authenticator-name", authenticator.Name, "--concierge-authenticator-name", authenticator.Name,
"--oidc-session-cache", sessionCachePath, "--oidc-session-cache", sessionCachePath,
"--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups",
}) })
// Run "kubectl get namespaces" which should trigger an LDAP-style login CLI prompt via the plugin. // Run "kubectl get namespaces" which should trigger an LDAP-style login CLI prompt via the plugin.
@ -715,6 +721,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
"--concierge-authenticator-type", "jwt", "--concierge-authenticator-type", "jwt",
"--concierge-authenticator-name", authenticator.Name, "--concierge-authenticator-name", authenticator.Name,
"--oidc-session-cache", sessionCachePath, "--oidc-session-cache", sessionCachePath,
"--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups",
}) })
// Set up the username and password env vars to avoid the interactive prompts. // Set up the username and password env vars to avoid the interactive prompts.
@ -787,6 +794,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
"--concierge-authenticator-type", "jwt", "--concierge-authenticator-type", "jwt",
"--concierge-authenticator-name", authenticator.Name, "--concierge-authenticator-name", authenticator.Name,
"--oidc-session-cache", sessionCachePath, "--oidc-session-cache", sessionCachePath,
"--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups",
}) })
// Run "kubectl get namespaces" which should trigger an LDAP-style login CLI prompt via the plugin. // Run "kubectl get namespaces" which should trigger an LDAP-style login CLI prompt via the plugin.
@ -847,6 +855,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
"--concierge-authenticator-type", "jwt", "--concierge-authenticator-type", "jwt",
"--concierge-authenticator-name", authenticator.Name, "--concierge-authenticator-name", authenticator.Name,
"--oidc-session-cache", sessionCachePath, "--oidc-session-cache", sessionCachePath,
"--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups",
}) })
// Set up the username and password env vars to avoid the interactive prompts. // Set up the username and password env vars to avoid the interactive prompts.
@ -924,6 +933,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
"--oidc-ca-bundle", testCABundlePath, "--oidc-ca-bundle", testCABundlePath,
"--upstream-identity-provider-flow", "browser_authcode", "--upstream-identity-provider-flow", "browser_authcode",
"--oidc-session-cache", sessionCachePath, "--oidc-session-cache", sessionCachePath,
"--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups",
}) })
// Run "kubectl get namespaces" which should trigger a browser login via the plugin. // Run "kubectl get namespaces" which should trigger a browser login via the plugin.
@ -980,6 +990,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
"--oidc-ca-bundle", testCABundlePath, "--oidc-ca-bundle", testCABundlePath,
"--upstream-identity-provider-flow", "browser_authcode", "--upstream-identity-provider-flow", "browser_authcode",
"--oidc-session-cache", sessionCachePath, "--oidc-session-cache", sessionCachePath,
"--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups",
}) })
// Run "kubectl get namespaces" which should trigger a browser login via the plugin. // Run "kubectl get namespaces" which should trigger a browser login via the plugin.
@ -1036,6 +1047,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
"--oidc-ca-bundle", testCABundlePath, "--oidc-ca-bundle", testCABundlePath,
"--upstream-identity-provider-flow", "cli_password", // put cli_password in the kubeconfig, so we can override it with the env var "--upstream-identity-provider-flow", "cli_password", // put cli_password in the kubeconfig, so we can override it with the env var
"--oidc-session-cache", sessionCachePath, "--oidc-session-cache", sessionCachePath,
"--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups",
}) })
// Override the --upstream-identity-provider-flow flag from the kubeconfig using the env var. // Override the --upstream-identity-provider-flow flag from the kubeconfig using the env var.
@ -1311,7 +1323,7 @@ func requireUserCanUseKubectlWithoutAuthenticatingAgain(
require.NoError(t, err) require.NoError(t, err)
})) }))
downstreamScopes := []string{coreosoidc.ScopeOfflineAccess, coreosoidc.ScopeOpenID, "pinniped:request-audience"} downstreamScopes := []string{coreosoidc.ScopeOfflineAccess, coreosoidc.ScopeOpenID, "pinniped:request-audience", "groups"}
sort.Strings(downstreamScopes) sort.Strings(downstreamScopes)
token := cache.GetToken(oidcclient.SessionCacheKey{ token := cache.GetToken(oidcclient.SessionCacheKey{
Issuer: downstream.Spec.Issuer, Issuer: downstream.Spec.Issuer,
@ -1326,12 +1338,16 @@ func requireUserCanUseKubectlWithoutAuthenticatingAgain(
idTokenClaims := token.IDToken.Claims idTokenClaims := token.IDToken.Claims
require.Equal(t, expectedUsername, idTokenClaims[oidc.DownstreamUsernameClaim]) require.Equal(t, expectedUsername, idTokenClaims[oidc.DownstreamUsernameClaim])
if expectedGroups == nil {
require.Nil(t, idTokenClaims[oidc.DownstreamGroupsClaim])
} else {
// The groups claim in the file ends up as an []interface{}, so adjust our expectation to match. // The groups claim in the file ends up as an []interface{}, so adjust our expectation to match.
expectedGroupsAsEmptyInterfaces := make([]interface{}, 0, len(expectedGroups)) expectedGroupsAsEmptyInterfaces := make([]interface{}, 0, len(expectedGroups))
for _, g := range expectedGroups { for _, g := range expectedGroups {
expectedGroupsAsEmptyInterfaces = append(expectedGroupsAsEmptyInterfaces, g) expectedGroupsAsEmptyInterfaces = append(expectedGroupsAsEmptyInterfaces, g)
} }
require.ElementsMatch(t, expectedGroupsAsEmptyInterfaces, idTokenClaims[oidc.DownstreamGroupsClaim]) require.ElementsMatch(t, expectedGroupsAsEmptyInterfaces, idTokenClaims[oidc.DownstreamGroupsClaim])
}
expectedGroupsPlusAuthenticated := append([]string{}, expectedGroups...) expectedGroupsPlusAuthenticated := append([]string{}, expectedGroups...)
expectedGroupsPlusAuthenticated = append(expectedGroupsPlusAuthenticated, "system:authenticated") expectedGroupsPlusAuthenticated = append(expectedGroupsPlusAuthenticated, "system:authenticated")

View File

@ -1381,7 +1381,7 @@ func testSupervisorLogin(
ClientID: "pinniped-cli", ClientID: "pinniped-cli",
Endpoint: discovery.Endpoint(), Endpoint: discovery.Endpoint(),
RedirectURL: localCallbackServer.URL, RedirectURL: localCallbackServer.URL,
Scopes: []string{"openid", "pinniped:request-audience", "offline_access"}, Scopes: []string{"openid", "pinniped:request-audience", "offline_access", "groups"},
} }
// Build a valid downstream authorize URL for the supervisor. // Build a valid downstream authorize URL for the supervisor.
@ -1416,7 +1416,7 @@ func testSupervisorLogin(
t.Logf("got callback request: %s", testlib.MaskTokens(callback.URL.String())) t.Logf("got callback request: %s", testlib.MaskTokens(callback.URL.String()))
if wantErrorType == "" { if wantErrorType == "" {
require.Equal(t, stateParam.String(), callback.URL.Query().Get("state")) require.Equal(t, stateParam.String(), callback.URL.Query().Get("state"))
require.ElementsMatch(t, []string{"openid", "pinniped:request-audience", "offline_access"}, strings.Split(callback.URL.Query().Get("scope"), " ")) require.ElementsMatch(t, []string{"openid", "pinniped:request-audience", "offline_access", "groups"}, strings.Split(callback.URL.Query().Get("scope"), " "))
authcode := callback.URL.Query().Get("code") authcode := callback.URL.Query().Get("code")
require.NotEmpty(t, authcode) require.NotEmpty(t, authcode)