From 22fbced863d3f9d6d032efac1b16eafc2d8691ee Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Mon, 8 Aug 2022 16:29:22 -0700 Subject: [PATCH] Create username scope, required for clients to get username in ID token - For backwards compatibility with older Pinniped CLIs, the pinniped-cli client does not need to request the username or groups scopes for them to be granted. For dynamic clients, the usual OAuth2 rules apply: the client must be allowed to request the scopes according to its configuration, and the client must actually request the scopes in the authorization request. - If the username scope was not granted, then there will be no username in the ID token, and the cluster-scoped token exchange will fail since there would be no username in the resulting cluster-scoped ID token. - The OIDC well-known discovery endpoint lists the username and groups scopes in the scopes_supported list, and lists the username and groups claims in the claims_supported list. - Add username and groups scopes to the default list of scopes put into kubeconfig files by "pinniped get kubeconfig" CLI command, and the default list of scopes used by "pinniped login oidc" when no list of scopes is specified in the kubeconfig file - The warning header about group memberships changing during upstream refresh will only be sent to the pinniped-cli client, since it is only intended for kubectl and it could leak the username to the client (which may not have the username scope granted) through the warning message text. - Add the user's username to the session storage as a new field, so that during upstream refresh we can compare the original username from the initial authorization to the refreshed username, even in the case when the username scope was not granted (and therefore the username is not stored in the ID token claims of the session storage) - Bump the Supervisor session storage format version from 2 to 3 due to the username field being added to the session struct - Extract commonly used string constants related to OIDC flows to api package. - Change some import names to make them consistent: - Always import github.com/coreos/go-oidc/v3/oidc as "coreosoidc" - Always import go.pinniped.dev/generated/latest/apis/supervisor/oidc as "oidcapi" - Always import go.pinniped.dev/internal/oidc as "oidc" --- .../oidc/types_supervisor_oidc.go.tmpl | 65 +- cmd/pinniped/cmd/kubeconfig.go | 9 +- cmd/pinniped/cmd/kubeconfig_test.go | 40 +- cmd/pinniped/cmd/login_oidc.go | 6 +- cmd/pinniped/cmd/login_oidc_test.go | 2 +- .../supervisor/oidc/types_supervisor_oidc.go | 65 +- .../supervisor/oidc/types_supervisor_oidc.go | 65 +- .../supervisor/oidc/types_supervisor_oidc.go | 65 +- .../supervisor/oidc/types_supervisor_oidc.go | 65 +- .../supervisor/oidc/types_supervisor_oidc.go | 65 +- .../supervisor/oidc/types_supervisor_oidc.go | 65 +- .../supervisor/oidc/types_supervisor_oidc.go | 65 +- .../supervisor/oidc/types_supervisor_oidc.go | 65 +- .../supervisor/oidc/types_supervisor_oidc.go | 65 +- .../jwtcachefiller/jwtcachefiller.go | 5 +- .../oidcclientwatcher/oidc_client_watcher.go | 3 +- .../oidc_upstream_watcher.go | 19 +- .../supervisorstorage/garbage_collector.go | 5 +- .../garbage_collector_test.go | 45 +- .../fositestorage/accesstoken/accesstoken.go | 5 +- .../accesstoken/accesstoken_test.go | 19 +- .../authorizationcode/authorizationcode.go | 45 +- .../authorizationcode_test.go | 21 +- .../openidconnect/openidconnect.go | 5 +- .../openidconnect/openidconnect_test.go | 6 +- internal/fositestorage/pkce/pkce.go | 5 +- internal/fositestorage/pkce/pkce_test.go | 6 +- .../refreshtoken/refreshtoken.go | 3 +- .../refreshtoken/refreshtoken_test.go | 21 +- internal/oidc/auth/auth_handler.go | 42 +- internal/oidc/auth/auth_handler_test.go | 59 +- internal/oidc/callback/callback_handler.go | 7 +- .../oidc/callback/callback_handler_test.go | 308 +++- .../oidc/clientregistry/clientregistry.go | 41 +- .../clientregistry/clientregistry_test.go | 3 +- internal/oidc/discovery/discovery_handler.go | 5 +- .../oidc/discovery/discovery_handler_test.go | 4 +- .../downstreamsession/downstream_session.go | 58 +- internal/oidc/login/post_login_handler.go | 7 +- .../oidc/login/post_login_handler_test.go | 173 ++- internal/oidc/oidc.go | 38 +- .../oidcclientvalidator.go | 42 +- .../oidc/provider/manager/manager_test.go | 4 +- internal/oidc/token/token_handler.go | 77 +- internal/oidc/token/token_handler_test.go | 1256 +++++++++-------- internal/oidc/token_exchange.go | 49 +- internal/psession/pinniped_session.go | 5 + internal/testutil/oidcclient.go | 58 +- .../testutil/oidctestutil/oidctestutil.go | 14 +- internal/testutil/psession.go | 1 + internal/upstreamldap/upstreamldap.go | 6 +- internal/upstreamoidc/upstreamoidc.go | 12 +- pkg/oidcclient/login.go | 30 +- pkg/oidcclient/nonce/nonce.go | 10 +- .../docs/reference/code-walkthrough.md | 6 +- test/integration/e2e_test.go | 128 +- test/integration/supervisor_discovery_test.go | 4 +- test/integration/supervisor_login_test.go | 332 ++++- test/integration/supervisor_warnings_test.go | 5 +- 59 files changed, 2576 insertions(+), 1128 deletions(-) diff --git a/apis/supervisor/oidc/types_supervisor_oidc.go.tmpl b/apis/supervisor/oidc/types_supervisor_oidc.go.tmpl index b35aafcb..cb6fe627 100644 --- a/apis/supervisor/oidc/types_supervisor_oidc.go.tmpl +++ b/apis/supervisor/oidc/types_supervisor_oidc.go.tmpl @@ -15,11 +15,68 @@ const ( // or an LDAPIdentityProvider. AuthorizePasswordHeaderName = "Pinniped-Password" //nolint:gosec // this is not a credential - // AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select which - // identity provider should be used for authentication by sending the name of the desired identity provider. + // AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select + // which identity provider should be used for authentication by sending the name of the desired identity provider. AuthorizeUpstreamIDPNameParamName = "pinniped_idp_name" - // AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select which - // identity provider should be used for authentication by sending the type of the desired identity provider. + // AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select + // which identity provider should be used for authentication by sending the type of the desired identity provider. AuthorizeUpstreamIDPTypeParamName = "pinniped_idp_type" + + // IDTokenClaimIssuer is name of the issuer claim defined by the OIDC spec. + IDTokenClaimIssuer = "iss" + + // IDTokenClaimSubject is name of the subject claim defined by the OIDC spec. + IDTokenClaimSubject = "sub" + + // IDTokenClaimAuthorizedParty is name of the authorized party claim defined by the OIDC spec. + IDTokenClaimAuthorizedParty = "azp" + + // IDTokenClaimUsername is the name of a custom claim in the downstream ID token whose value will contain the user's + // username which was mapped from the upstream identity provider. + IDTokenClaimUsername = "username" + + // IDTokenClaimGroups is the name of a custom claim in the downstream ID token whose value will contain the user's + // group names which were mapped from the upstream identity provider. + IDTokenClaimGroups = "groups" + + // GrantTypeAuthorizationCode is the name of the grant type for authorization code flows defined by the OIDC spec. + GrantTypeAuthorizationCode = "authorization_code" + + // GrantTypeRefreshToken is the name of the grant type for refresh flow defined by the OIDC spec. + GrantTypeRefreshToken = "refresh_token" + + // GrantTypeTokenExchange is the name of a custom grant type for RFC8693 token exchanges. + GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" //nolint:gosec // this is not a credential + + // ScopeOpenID is name of the openid scope defined by the OIDC spec. + ScopeOpenID = "openid" + + // ScopeOfflineAccess is name of the offline access scope defined by the OIDC spec, used for requesting refresh + // tokens. + ScopeOfflineAccess = "offline_access" + + // ScopeEmail is name of the email scope defined by the OIDC spec. + ScopeEmail = "email" + + // ScopeProfile is name of the profile scope defined by the OIDC spec. + ScopeProfile = "profile" + + // ScopeUsername is the name of a custom scope that determines whether the username claim will be returned inside + // ID tokens. + ScopeUsername = "username" + + // ScopeGroups is the name of a custom scope that determines whether the groups claim will be returned inside + // ID tokens. + ScopeGroups = "groups" + + // ScopeRequestAudience is the name of a custom scope that determines whether a RFC8693 token exchange is allowed to + // be used to request a different audience. + ScopeRequestAudience = "pinniped:request-audience" + + // ClientIDPinnipedCLI is the client ID of the statically defined public OIDC client which is used by the CLI. + ClientIDPinnipedCLI = "pinniped-cli" + + // ClientIDRequiredOIDCClientPrefix is the required prefix for the metadata.name of OIDCClient CRs. + ClientIDRequiredOIDCClientPrefix = "client.oauth.pinniped.dev-" ) diff --git a/cmd/pinniped/cmd/kubeconfig.go b/cmd/pinniped/cmd/kubeconfig.go index 1e59f481..e03834d3 100644 --- a/cmd/pinniped/cmd/kubeconfig.go +++ b/cmd/pinniped/cmd/kubeconfig.go @@ -17,7 +17,7 @@ import ( "strings" "time" - "github.com/coreos/go-oidc/v3/oidc" + coreosoidc "github.com/coreos/go-oidc/v3/oidc" "github.com/spf13/cobra" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" @@ -28,6 +28,7 @@ import ( conciergev1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/authentication/v1alpha1" configv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/config/v1alpha1" idpdiscoveryv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1" + oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc" conciergeclientset "go.pinniped.dev/generated/latest/client/concierge/clientset/versioned" "go.pinniped.dev/internal/groupsuffix" "go.pinniped.dev/internal/net/phttp" @@ -126,9 +127,9 @@ func kubeconfigCommand(deps kubeconfigDeps) *cobra.Command { f.Var(&flags.concierge.mode, "concierge-mode", "Concierge mode of operation") f.StringVar(&flags.oidc.issuer, "oidc-issuer", "", "OpenID Connect issuer URL (default: autodiscover)") - f.StringVar(&flags.oidc.clientID, "oidc-client-id", "pinniped-cli", "OpenID Connect client ID (default: autodiscover)") + f.StringVar(&flags.oidc.clientID, "oidc-client-id", oidcapi.ClientIDPinnipedCLI, "OpenID Connect client ID (default: autodiscover)") f.Uint16Var(&flags.oidc.listenPort, "oidc-listen-port", 0, "TCP port for localhost listener (authorization code flow only)") - f.StringSliceVar(&flags.oidc.scopes, "oidc-scopes", []string{oidc.ScopeOfflineAccess, oidc.ScopeOpenID, "pinniped:request-audience"}, "OpenID Connect scopes to request during login") + f.StringSliceVar(&flags.oidc.scopes, "oidc-scopes", []string{oidcapi.ScopeOfflineAccess, oidcapi.ScopeOpenID, oidcapi.ScopeRequestAudience, oidcapi.ScopeUsername, oidcapi.ScopeGroups}, "OpenID Connect scopes to request during login") f.BoolVar(&flags.oidc.skipBrowser, "oidc-skip-browser", false, "During OpenID Connect login, skip opening the browser (just print the URL)") f.BoolVar(&flags.oidc.skipListen, "oidc-skip-listen", false, "During OpenID Connect login, skip starting a localhost callback listener (manual copy/paste flow only)") f.StringVar(&flags.oidc.sessionCachePath, "oidc-session-cache", "", "Path to OpenID Connect session cache file") @@ -784,7 +785,7 @@ func newDiscoveryHTTPClient(caBundleFlag caBundleFlag) (*http.Client, error) { } func discoverIDPsDiscoveryEndpointURL(ctx context.Context, issuer string, httpClient *http.Client) (string, error) { - discoveredProvider, err := oidc.NewProvider(oidc.ClientContext(ctx, httpClient), issuer) + discoveredProvider, err := coreosoidc.NewProvider(coreosoidc.ClientContext(ctx, httpClient), issuer) if err != nil { return "", fmt.Errorf("while fetching OIDC discovery data from issuer: %w", err) } diff --git a/cmd/pinniped/cmd/kubeconfig_test.go b/cmd/pinniped/cmd/kubeconfig_test.go index 9c3ee5e0..b6c6428f 100644 --- a/cmd/pinniped/cmd/kubeconfig_test.go +++ b/cmd/pinniped/cmd/kubeconfig_test.go @@ -142,7 +142,7 @@ func TestGetKubeconfig(t *testing.T) { --oidc-issuer string OpenID Connect issuer URL (default: autodiscover) --oidc-listen-port uint16 TCP port for localhost listener (authorization code flow only) --oidc-request-audience string Request a token with an alternate audience using RFC8693 token exchange - --oidc-scopes strings OpenID Connect scopes to request during login (default [offline_access,openid,pinniped:request-audience]) + --oidc-scopes strings OpenID Connect scopes to request during login (default [offline_access,openid,pinniped:request-audience,username,groups]) --oidc-session-cache string Path to OpenID Connect session cache file --oidc-skip-browser During OpenID Connect login, skip opening the browser (just print the URL) -o, --output string Output file path (default: stdout) @@ -1290,7 +1290,7 @@ func TestGetKubeconfig(t *testing.T) { - oidc - --issuer=%s - --client-id=pinniped-cli - - --scopes=offline_access,openid,pinniped:request-audience + - --scopes=offline_access,openid,pinniped:request-audience,username,groups - --ca-bundle-data=%s - --upstream-identity-provider-name=some-ldap-idp - --upstream-identity-provider-type=ldap @@ -1496,7 +1496,7 @@ func TestGetKubeconfig(t *testing.T) { - --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== - --issuer=%s - --client-id=pinniped-cli - - --scopes=offline_access,openid,pinniped:request-audience + - --scopes=offline_access,openid,pinniped:request-audience,username,groups - --ca-bundle-data=%s - --request-audience=test-audience command: '.../path/to/pinniped' @@ -1577,7 +1577,7 @@ func TestGetKubeconfig(t *testing.T) { - --credential-cache=/path/to/cache/dir/credentials.yaml - --issuer=%s - --client-id=pinniped-cli - - --scopes=offline_access,openid,pinniped:request-audience + - --scopes=offline_access,openid,pinniped:request-audience,username,groups - --skip-browser - --skip-listen - --listen-port=1234 @@ -1695,7 +1695,7 @@ func TestGetKubeconfig(t *testing.T) { - --concierge-ca-bundle-data=%s - --issuer=%s - --client-id=pinniped-cli - - --scopes=offline_access,openid,pinniped:request-audience + - --scopes=offline_access,openid,pinniped:request-audience,username,groups - --ca-bundle-data=%s - --request-audience=test-audience command: '.../path/to/pinniped' @@ -1804,7 +1804,7 @@ func TestGetKubeconfig(t *testing.T) { - --concierge-ca-bundle-data=dGVzdC1jb25jaWVyZ2UtY2E= - --issuer=%s - --client-id=pinniped-cli - - --scopes=offline_access,openid,pinniped:request-audience + - --scopes=offline_access,openid,pinniped:request-audience,username,groups - --ca-bundle-data=%s - --request-audience=test-audience command: '.../path/to/pinniped' @@ -1881,7 +1881,7 @@ func TestGetKubeconfig(t *testing.T) { - --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== - --issuer=%s - --client-id=pinniped-cli - - --scopes=offline_access,openid,pinniped:request-audience + - --scopes=offline_access,openid,pinniped:request-audience,username,groups - --ca-bundle-data=%s - --request-audience=test-audience - --upstream-identity-provider-name=some-ldap-idp @@ -1960,7 +1960,7 @@ func TestGetKubeconfig(t *testing.T) { - --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== - --issuer=%s - --client-id=pinniped-cli - - --scopes=offline_access,openid,pinniped:request-audience + - --scopes=offline_access,openid,pinniped:request-audience,username,groups - --ca-bundle-data=%s - --request-audience=test-audience - --upstream-identity-provider-name=some-oidc-idp @@ -2037,7 +2037,7 @@ func TestGetKubeconfig(t *testing.T) { - --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== - --issuer=%s - --client-id=pinniped-cli - - --scopes=offline_access,openid,pinniped:request-audience + - --scopes=offline_access,openid,pinniped:request-audience,username,groups - --ca-bundle-data=%s - --request-audience=test-audience command: '.../path/to/pinniped' @@ -2110,7 +2110,7 @@ func TestGetKubeconfig(t *testing.T) { - --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== - --issuer=%s - --client-id=pinniped-cli - - --scopes=offline_access,openid,pinniped:request-audience + - --scopes=offline_access,openid,pinniped:request-audience,username,groups - --ca-bundle-data=%s - --request-audience=test-audience command: '.../path/to/pinniped' @@ -2190,7 +2190,7 @@ func TestGetKubeconfig(t *testing.T) { - --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== - --issuer=%s - --client-id=pinniped-cli - - --scopes=offline_access,openid,pinniped:request-audience + - --scopes=offline_access,openid,pinniped:request-audience,username,groups - --ca-bundle-data=%s - --request-audience=test-audience command: '.../path/to/pinniped' @@ -2265,7 +2265,7 @@ func TestGetKubeconfig(t *testing.T) { - --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== - --issuer=%s - --client-id=pinniped-cli - - --scopes=offline_access,openid,pinniped:request-audience + - --scopes=offline_access,openid,pinniped:request-audience,username,groups - --ca-bundle-data=%s - --request-audience=test-audience - --upstream-identity-provider-name=some-oidc-idp @@ -2348,7 +2348,7 @@ func TestGetKubeconfig(t *testing.T) { - --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== - --issuer=%s - --client-id=pinniped-cli - - --scopes=offline_access,openid,pinniped:request-audience + - --scopes=offline_access,openid,pinniped:request-audience,username,groups - --ca-bundle-data=%s - --request-audience=test-audience - --upstream-identity-provider-name=some-oidc-idp @@ -2408,7 +2408,7 @@ func TestGetKubeconfig(t *testing.T) { - oidc - --issuer=%s - --client-id=pinniped-cli - - --scopes=offline_access,openid,pinniped:request-audience + - --scopes=offline_access,openid,pinniped:request-audience,username,groups - --ca-bundle-data=%s - --upstream-identity-provider-name=some-ldap-idp - --upstream-identity-provider-type=ldap @@ -2469,7 +2469,7 @@ func TestGetKubeconfig(t *testing.T) { - oidc - --issuer=%s - --client-id=pinniped-cli - - --scopes=offline_access,openid,pinniped:request-audience + - --scopes=offline_access,openid,pinniped:request-audience,username,groups - --ca-bundle-data=%s - --upstream-identity-provider-name=some-ldap-idp - --upstream-identity-provider-type=ldap @@ -2530,7 +2530,7 @@ func TestGetKubeconfig(t *testing.T) { - oidc - --issuer=%s - --client-id=pinniped-cli - - --scopes=offline_access,openid,pinniped:request-audience + - --scopes=offline_access,openid,pinniped:request-audience,username,groups - --ca-bundle-data=%s - --upstream-identity-provider-name=some-ldap-idp - --upstream-identity-provider-type=ldap @@ -2592,7 +2592,7 @@ func TestGetKubeconfig(t *testing.T) { - oidc - --issuer=%s - --client-id=pinniped-cli - - --scopes=offline_access,openid,pinniped:request-audience + - --scopes=offline_access,openid,pinniped:request-audience,username,groups - --ca-bundle-data=%s - --upstream-identity-provider-name=some-ldap-idp - --upstream-identity-provider-type=ldap @@ -2654,7 +2654,7 @@ func TestGetKubeconfig(t *testing.T) { - oidc - --issuer=%s - --client-id=pinniped-cli - - --scopes=offline_access,openid,pinniped:request-audience + - --scopes=offline_access,openid,pinniped:request-audience,username,groups - --ca-bundle-data=%s - --upstream-identity-provider-name=some-ldap-idp - --upstream-identity-provider-type=ldap @@ -2715,7 +2715,7 @@ func TestGetKubeconfig(t *testing.T) { - oidc - --issuer=%s - --client-id=pinniped-cli - - --scopes=offline_access,openid,pinniped:request-audience + - --scopes=offline_access,openid,pinniped:request-audience,username,groups - --ca-bundle-data=%s - --upstream-identity-provider-name=some-ldap-idp - --upstream-identity-provider-type=ldap @@ -2775,7 +2775,7 @@ func TestGetKubeconfig(t *testing.T) { - oidc - --issuer=%s - --client-id=pinniped-cli - - --scopes=offline_access,openid,pinniped:request-audience + - --scopes=offline_access,openid,pinniped:request-audience,username,groups - --ca-bundle-data=%s - --upstream-identity-provider-name=some-ldap-idp - --upstream-identity-provider-type=ldap diff --git a/cmd/pinniped/cmd/login_oidc.go b/cmd/pinniped/cmd/login_oidc.go index b31f8dd6..c792c916 100644 --- a/cmd/pinniped/cmd/login_oidc.go +++ b/cmd/pinniped/cmd/login_oidc.go @@ -16,12 +16,12 @@ import ( "strings" "time" - "github.com/coreos/go-oidc/v3/oidc" "github.com/spf13/cobra" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" clientauthv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" idpdiscoveryv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1" + oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc" "go.pinniped.dev/internal/execcredcache" "go.pinniped.dev/internal/groupsuffix" "go.pinniped.dev/internal/net/phttp" @@ -98,9 +98,9 @@ func oidcLoginCommand(deps oidcLoginCommandDeps) *cobra.Command { conciergeNamespace string // unused now ) cmd.Flags().StringVar(&flags.issuer, "issuer", "", "OpenID Connect issuer URL") - cmd.Flags().StringVar(&flags.clientID, "client-id", "pinniped-cli", "OpenID Connect client ID") + cmd.Flags().StringVar(&flags.clientID, "client-id", oidcapi.ClientIDPinnipedCLI, "OpenID Connect client ID") cmd.Flags().Uint16Var(&flags.listenPort, "listen-port", 0, "TCP port for localhost listener (authorization code flow only)") - cmd.Flags().StringSliceVar(&flags.scopes, "scopes", []string{oidc.ScopeOfflineAccess, oidc.ScopeOpenID, "pinniped:request-audience"}, "OIDC scopes to request during login") + cmd.Flags().StringSliceVar(&flags.scopes, "scopes", []string{oidcapi.ScopeOfflineAccess, oidcapi.ScopeOpenID, oidcapi.ScopeRequestAudience, oidcapi.ScopeUsername, oidcapi.ScopeGroups}, "OIDC scopes to request during login") cmd.Flags().BoolVar(&flags.skipBrowser, "skip-browser", false, "Skip opening the browser (just print the URL)") cmd.Flags().BoolVar(&flags.skipListen, "skip-listen", false, "Skip starting a localhost callback listener (manual copy/paste flow only)") cmd.Flags().StringVar(&flags.sessionCachePath, "session-cache", filepath.Join(mustGetConfigDir(), "sessions.yaml"), "Path to session cache file") diff --git a/cmd/pinniped/cmd/login_oidc_test.go b/cmd/pinniped/cmd/login_oidc_test.go index 2e4fbd45..4d59861a 100644 --- a/cmd/pinniped/cmd/login_oidc_test.go +++ b/cmd/pinniped/cmd/login_oidc_test.go @@ -80,7 +80,7 @@ func TestLoginOIDCCommand(t *testing.T) { --issuer string OpenID Connect issuer URL --listen-port uint16 TCP port for localhost listener (authorization code flow only) --request-audience string Request a token with an alternate audience using RFC8693 token exchange - --scopes strings OIDC scopes to request during login (default [offline_access,openid,pinniped:request-audience]) + --scopes strings OIDC scopes to request during login (default [offline_access,openid,pinniped:request-audience,username,groups]) --session-cache string Path to session cache file (default "` + cfgDir + `/sessions.yaml") --skip-browser Skip opening the browser (just print the URL) --upstream-identity-provider-flow string The type of client flow to use with the upstream identity provider during login with a Supervisor (e.g. 'browser_authcode', 'cli_password') diff --git a/generated/1.17/apis/supervisor/oidc/types_supervisor_oidc.go b/generated/1.17/apis/supervisor/oidc/types_supervisor_oidc.go index b35aafcb..cb6fe627 100644 --- a/generated/1.17/apis/supervisor/oidc/types_supervisor_oidc.go +++ b/generated/1.17/apis/supervisor/oidc/types_supervisor_oidc.go @@ -15,11 +15,68 @@ const ( // or an LDAPIdentityProvider. AuthorizePasswordHeaderName = "Pinniped-Password" //nolint:gosec // this is not a credential - // AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select which - // identity provider should be used for authentication by sending the name of the desired identity provider. + // AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select + // which identity provider should be used for authentication by sending the name of the desired identity provider. AuthorizeUpstreamIDPNameParamName = "pinniped_idp_name" - // AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select which - // identity provider should be used for authentication by sending the type of the desired identity provider. + // AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select + // which identity provider should be used for authentication by sending the type of the desired identity provider. AuthorizeUpstreamIDPTypeParamName = "pinniped_idp_type" + + // IDTokenClaimIssuer is name of the issuer claim defined by the OIDC spec. + IDTokenClaimIssuer = "iss" + + // IDTokenClaimSubject is name of the subject claim defined by the OIDC spec. + IDTokenClaimSubject = "sub" + + // IDTokenClaimAuthorizedParty is name of the authorized party claim defined by the OIDC spec. + IDTokenClaimAuthorizedParty = "azp" + + // IDTokenClaimUsername is the name of a custom claim in the downstream ID token whose value will contain the user's + // username which was mapped from the upstream identity provider. + IDTokenClaimUsername = "username" + + // IDTokenClaimGroups is the name of a custom claim in the downstream ID token whose value will contain the user's + // group names which were mapped from the upstream identity provider. + IDTokenClaimGroups = "groups" + + // GrantTypeAuthorizationCode is the name of the grant type for authorization code flows defined by the OIDC spec. + GrantTypeAuthorizationCode = "authorization_code" + + // GrantTypeRefreshToken is the name of the grant type for refresh flow defined by the OIDC spec. + GrantTypeRefreshToken = "refresh_token" + + // GrantTypeTokenExchange is the name of a custom grant type for RFC8693 token exchanges. + GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" //nolint:gosec // this is not a credential + + // ScopeOpenID is name of the openid scope defined by the OIDC spec. + ScopeOpenID = "openid" + + // ScopeOfflineAccess is name of the offline access scope defined by the OIDC spec, used for requesting refresh + // tokens. + ScopeOfflineAccess = "offline_access" + + // ScopeEmail is name of the email scope defined by the OIDC spec. + ScopeEmail = "email" + + // ScopeProfile is name of the profile scope defined by the OIDC spec. + ScopeProfile = "profile" + + // ScopeUsername is the name of a custom scope that determines whether the username claim will be returned inside + // ID tokens. + ScopeUsername = "username" + + // ScopeGroups is the name of a custom scope that determines whether the groups claim will be returned inside + // ID tokens. + ScopeGroups = "groups" + + // ScopeRequestAudience is the name of a custom scope that determines whether a RFC8693 token exchange is allowed to + // be used to request a different audience. + ScopeRequestAudience = "pinniped:request-audience" + + // ClientIDPinnipedCLI is the client ID of the statically defined public OIDC client which is used by the CLI. + ClientIDPinnipedCLI = "pinniped-cli" + + // ClientIDRequiredOIDCClientPrefix is the required prefix for the metadata.name of OIDCClient CRs. + ClientIDRequiredOIDCClientPrefix = "client.oauth.pinniped.dev-" ) diff --git a/generated/1.18/apis/supervisor/oidc/types_supervisor_oidc.go b/generated/1.18/apis/supervisor/oidc/types_supervisor_oidc.go index b35aafcb..cb6fe627 100644 --- a/generated/1.18/apis/supervisor/oidc/types_supervisor_oidc.go +++ b/generated/1.18/apis/supervisor/oidc/types_supervisor_oidc.go @@ -15,11 +15,68 @@ const ( // or an LDAPIdentityProvider. AuthorizePasswordHeaderName = "Pinniped-Password" //nolint:gosec // this is not a credential - // AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select which - // identity provider should be used for authentication by sending the name of the desired identity provider. + // AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select + // which identity provider should be used for authentication by sending the name of the desired identity provider. AuthorizeUpstreamIDPNameParamName = "pinniped_idp_name" - // AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select which - // identity provider should be used for authentication by sending the type of the desired identity provider. + // AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select + // which identity provider should be used for authentication by sending the type of the desired identity provider. AuthorizeUpstreamIDPTypeParamName = "pinniped_idp_type" + + // IDTokenClaimIssuer is name of the issuer claim defined by the OIDC spec. + IDTokenClaimIssuer = "iss" + + // IDTokenClaimSubject is name of the subject claim defined by the OIDC spec. + IDTokenClaimSubject = "sub" + + // IDTokenClaimAuthorizedParty is name of the authorized party claim defined by the OIDC spec. + IDTokenClaimAuthorizedParty = "azp" + + // IDTokenClaimUsername is the name of a custom claim in the downstream ID token whose value will contain the user's + // username which was mapped from the upstream identity provider. + IDTokenClaimUsername = "username" + + // IDTokenClaimGroups is the name of a custom claim in the downstream ID token whose value will contain the user's + // group names which were mapped from the upstream identity provider. + IDTokenClaimGroups = "groups" + + // GrantTypeAuthorizationCode is the name of the grant type for authorization code flows defined by the OIDC spec. + GrantTypeAuthorizationCode = "authorization_code" + + // GrantTypeRefreshToken is the name of the grant type for refresh flow defined by the OIDC spec. + GrantTypeRefreshToken = "refresh_token" + + // GrantTypeTokenExchange is the name of a custom grant type for RFC8693 token exchanges. + GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" //nolint:gosec // this is not a credential + + // ScopeOpenID is name of the openid scope defined by the OIDC spec. + ScopeOpenID = "openid" + + // ScopeOfflineAccess is name of the offline access scope defined by the OIDC spec, used for requesting refresh + // tokens. + ScopeOfflineAccess = "offline_access" + + // ScopeEmail is name of the email scope defined by the OIDC spec. + ScopeEmail = "email" + + // ScopeProfile is name of the profile scope defined by the OIDC spec. + ScopeProfile = "profile" + + // ScopeUsername is the name of a custom scope that determines whether the username claim will be returned inside + // ID tokens. + ScopeUsername = "username" + + // ScopeGroups is the name of a custom scope that determines whether the groups claim will be returned inside + // ID tokens. + ScopeGroups = "groups" + + // ScopeRequestAudience is the name of a custom scope that determines whether a RFC8693 token exchange is allowed to + // be used to request a different audience. + ScopeRequestAudience = "pinniped:request-audience" + + // ClientIDPinnipedCLI is the client ID of the statically defined public OIDC client which is used by the CLI. + ClientIDPinnipedCLI = "pinniped-cli" + + // ClientIDRequiredOIDCClientPrefix is the required prefix for the metadata.name of OIDCClient CRs. + ClientIDRequiredOIDCClientPrefix = "client.oauth.pinniped.dev-" ) diff --git a/generated/1.19/apis/supervisor/oidc/types_supervisor_oidc.go b/generated/1.19/apis/supervisor/oidc/types_supervisor_oidc.go index b35aafcb..cb6fe627 100644 --- a/generated/1.19/apis/supervisor/oidc/types_supervisor_oidc.go +++ b/generated/1.19/apis/supervisor/oidc/types_supervisor_oidc.go @@ -15,11 +15,68 @@ const ( // or an LDAPIdentityProvider. AuthorizePasswordHeaderName = "Pinniped-Password" //nolint:gosec // this is not a credential - // AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select which - // identity provider should be used for authentication by sending the name of the desired identity provider. + // AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select + // which identity provider should be used for authentication by sending the name of the desired identity provider. AuthorizeUpstreamIDPNameParamName = "pinniped_idp_name" - // AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select which - // identity provider should be used for authentication by sending the type of the desired identity provider. + // AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select + // which identity provider should be used for authentication by sending the type of the desired identity provider. AuthorizeUpstreamIDPTypeParamName = "pinniped_idp_type" + + // IDTokenClaimIssuer is name of the issuer claim defined by the OIDC spec. + IDTokenClaimIssuer = "iss" + + // IDTokenClaimSubject is name of the subject claim defined by the OIDC spec. + IDTokenClaimSubject = "sub" + + // IDTokenClaimAuthorizedParty is name of the authorized party claim defined by the OIDC spec. + IDTokenClaimAuthorizedParty = "azp" + + // IDTokenClaimUsername is the name of a custom claim in the downstream ID token whose value will contain the user's + // username which was mapped from the upstream identity provider. + IDTokenClaimUsername = "username" + + // IDTokenClaimGroups is the name of a custom claim in the downstream ID token whose value will contain the user's + // group names which were mapped from the upstream identity provider. + IDTokenClaimGroups = "groups" + + // GrantTypeAuthorizationCode is the name of the grant type for authorization code flows defined by the OIDC spec. + GrantTypeAuthorizationCode = "authorization_code" + + // GrantTypeRefreshToken is the name of the grant type for refresh flow defined by the OIDC spec. + GrantTypeRefreshToken = "refresh_token" + + // GrantTypeTokenExchange is the name of a custom grant type for RFC8693 token exchanges. + GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" //nolint:gosec // this is not a credential + + // ScopeOpenID is name of the openid scope defined by the OIDC spec. + ScopeOpenID = "openid" + + // ScopeOfflineAccess is name of the offline access scope defined by the OIDC spec, used for requesting refresh + // tokens. + ScopeOfflineAccess = "offline_access" + + // ScopeEmail is name of the email scope defined by the OIDC spec. + ScopeEmail = "email" + + // ScopeProfile is name of the profile scope defined by the OIDC spec. + ScopeProfile = "profile" + + // ScopeUsername is the name of a custom scope that determines whether the username claim will be returned inside + // ID tokens. + ScopeUsername = "username" + + // ScopeGroups is the name of a custom scope that determines whether the groups claim will be returned inside + // ID tokens. + ScopeGroups = "groups" + + // ScopeRequestAudience is the name of a custom scope that determines whether a RFC8693 token exchange is allowed to + // be used to request a different audience. + ScopeRequestAudience = "pinniped:request-audience" + + // ClientIDPinnipedCLI is the client ID of the statically defined public OIDC client which is used by the CLI. + ClientIDPinnipedCLI = "pinniped-cli" + + // ClientIDRequiredOIDCClientPrefix is the required prefix for the metadata.name of OIDCClient CRs. + ClientIDRequiredOIDCClientPrefix = "client.oauth.pinniped.dev-" ) diff --git a/generated/1.20/apis/supervisor/oidc/types_supervisor_oidc.go b/generated/1.20/apis/supervisor/oidc/types_supervisor_oidc.go index b35aafcb..cb6fe627 100644 --- a/generated/1.20/apis/supervisor/oidc/types_supervisor_oidc.go +++ b/generated/1.20/apis/supervisor/oidc/types_supervisor_oidc.go @@ -15,11 +15,68 @@ const ( // or an LDAPIdentityProvider. AuthorizePasswordHeaderName = "Pinniped-Password" //nolint:gosec // this is not a credential - // AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select which - // identity provider should be used for authentication by sending the name of the desired identity provider. + // AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select + // which identity provider should be used for authentication by sending the name of the desired identity provider. AuthorizeUpstreamIDPNameParamName = "pinniped_idp_name" - // AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select which - // identity provider should be used for authentication by sending the type of the desired identity provider. + // AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select + // which identity provider should be used for authentication by sending the type of the desired identity provider. AuthorizeUpstreamIDPTypeParamName = "pinniped_idp_type" + + // IDTokenClaimIssuer is name of the issuer claim defined by the OIDC spec. + IDTokenClaimIssuer = "iss" + + // IDTokenClaimSubject is name of the subject claim defined by the OIDC spec. + IDTokenClaimSubject = "sub" + + // IDTokenClaimAuthorizedParty is name of the authorized party claim defined by the OIDC spec. + IDTokenClaimAuthorizedParty = "azp" + + // IDTokenClaimUsername is the name of a custom claim in the downstream ID token whose value will contain the user's + // username which was mapped from the upstream identity provider. + IDTokenClaimUsername = "username" + + // IDTokenClaimGroups is the name of a custom claim in the downstream ID token whose value will contain the user's + // group names which were mapped from the upstream identity provider. + IDTokenClaimGroups = "groups" + + // GrantTypeAuthorizationCode is the name of the grant type for authorization code flows defined by the OIDC spec. + GrantTypeAuthorizationCode = "authorization_code" + + // GrantTypeRefreshToken is the name of the grant type for refresh flow defined by the OIDC spec. + GrantTypeRefreshToken = "refresh_token" + + // GrantTypeTokenExchange is the name of a custom grant type for RFC8693 token exchanges. + GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" //nolint:gosec // this is not a credential + + // ScopeOpenID is name of the openid scope defined by the OIDC spec. + ScopeOpenID = "openid" + + // ScopeOfflineAccess is name of the offline access scope defined by the OIDC spec, used for requesting refresh + // tokens. + ScopeOfflineAccess = "offline_access" + + // ScopeEmail is name of the email scope defined by the OIDC spec. + ScopeEmail = "email" + + // ScopeProfile is name of the profile scope defined by the OIDC spec. + ScopeProfile = "profile" + + // ScopeUsername is the name of a custom scope that determines whether the username claim will be returned inside + // ID tokens. + ScopeUsername = "username" + + // ScopeGroups is the name of a custom scope that determines whether the groups claim will be returned inside + // ID tokens. + ScopeGroups = "groups" + + // ScopeRequestAudience is the name of a custom scope that determines whether a RFC8693 token exchange is allowed to + // be used to request a different audience. + ScopeRequestAudience = "pinniped:request-audience" + + // ClientIDPinnipedCLI is the client ID of the statically defined public OIDC client which is used by the CLI. + ClientIDPinnipedCLI = "pinniped-cli" + + // ClientIDRequiredOIDCClientPrefix is the required prefix for the metadata.name of OIDCClient CRs. + ClientIDRequiredOIDCClientPrefix = "client.oauth.pinniped.dev-" ) diff --git a/generated/1.21/apis/supervisor/oidc/types_supervisor_oidc.go b/generated/1.21/apis/supervisor/oidc/types_supervisor_oidc.go index b35aafcb..cb6fe627 100644 --- a/generated/1.21/apis/supervisor/oidc/types_supervisor_oidc.go +++ b/generated/1.21/apis/supervisor/oidc/types_supervisor_oidc.go @@ -15,11 +15,68 @@ const ( // or an LDAPIdentityProvider. AuthorizePasswordHeaderName = "Pinniped-Password" //nolint:gosec // this is not a credential - // AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select which - // identity provider should be used for authentication by sending the name of the desired identity provider. + // AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select + // which identity provider should be used for authentication by sending the name of the desired identity provider. AuthorizeUpstreamIDPNameParamName = "pinniped_idp_name" - // AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select which - // identity provider should be used for authentication by sending the type of the desired identity provider. + // AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select + // which identity provider should be used for authentication by sending the type of the desired identity provider. AuthorizeUpstreamIDPTypeParamName = "pinniped_idp_type" + + // IDTokenClaimIssuer is name of the issuer claim defined by the OIDC spec. + IDTokenClaimIssuer = "iss" + + // IDTokenClaimSubject is name of the subject claim defined by the OIDC spec. + IDTokenClaimSubject = "sub" + + // IDTokenClaimAuthorizedParty is name of the authorized party claim defined by the OIDC spec. + IDTokenClaimAuthorizedParty = "azp" + + // IDTokenClaimUsername is the name of a custom claim in the downstream ID token whose value will contain the user's + // username which was mapped from the upstream identity provider. + IDTokenClaimUsername = "username" + + // IDTokenClaimGroups is the name of a custom claim in the downstream ID token whose value will contain the user's + // group names which were mapped from the upstream identity provider. + IDTokenClaimGroups = "groups" + + // GrantTypeAuthorizationCode is the name of the grant type for authorization code flows defined by the OIDC spec. + GrantTypeAuthorizationCode = "authorization_code" + + // GrantTypeRefreshToken is the name of the grant type for refresh flow defined by the OIDC spec. + GrantTypeRefreshToken = "refresh_token" + + // GrantTypeTokenExchange is the name of a custom grant type for RFC8693 token exchanges. + GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" //nolint:gosec // this is not a credential + + // ScopeOpenID is name of the openid scope defined by the OIDC spec. + ScopeOpenID = "openid" + + // ScopeOfflineAccess is name of the offline access scope defined by the OIDC spec, used for requesting refresh + // tokens. + ScopeOfflineAccess = "offline_access" + + // ScopeEmail is name of the email scope defined by the OIDC spec. + ScopeEmail = "email" + + // ScopeProfile is name of the profile scope defined by the OIDC spec. + ScopeProfile = "profile" + + // ScopeUsername is the name of a custom scope that determines whether the username claim will be returned inside + // ID tokens. + ScopeUsername = "username" + + // ScopeGroups is the name of a custom scope that determines whether the groups claim will be returned inside + // ID tokens. + ScopeGroups = "groups" + + // ScopeRequestAudience is the name of a custom scope that determines whether a RFC8693 token exchange is allowed to + // be used to request a different audience. + ScopeRequestAudience = "pinniped:request-audience" + + // ClientIDPinnipedCLI is the client ID of the statically defined public OIDC client which is used by the CLI. + ClientIDPinnipedCLI = "pinniped-cli" + + // ClientIDRequiredOIDCClientPrefix is the required prefix for the metadata.name of OIDCClient CRs. + ClientIDRequiredOIDCClientPrefix = "client.oauth.pinniped.dev-" ) diff --git a/generated/1.22/apis/supervisor/oidc/types_supervisor_oidc.go b/generated/1.22/apis/supervisor/oidc/types_supervisor_oidc.go index b35aafcb..cb6fe627 100644 --- a/generated/1.22/apis/supervisor/oidc/types_supervisor_oidc.go +++ b/generated/1.22/apis/supervisor/oidc/types_supervisor_oidc.go @@ -15,11 +15,68 @@ const ( // or an LDAPIdentityProvider. AuthorizePasswordHeaderName = "Pinniped-Password" //nolint:gosec // this is not a credential - // AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select which - // identity provider should be used for authentication by sending the name of the desired identity provider. + // AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select + // which identity provider should be used for authentication by sending the name of the desired identity provider. AuthorizeUpstreamIDPNameParamName = "pinniped_idp_name" - // AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select which - // identity provider should be used for authentication by sending the type of the desired identity provider. + // AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select + // which identity provider should be used for authentication by sending the type of the desired identity provider. AuthorizeUpstreamIDPTypeParamName = "pinniped_idp_type" + + // IDTokenClaimIssuer is name of the issuer claim defined by the OIDC spec. + IDTokenClaimIssuer = "iss" + + // IDTokenClaimSubject is name of the subject claim defined by the OIDC spec. + IDTokenClaimSubject = "sub" + + // IDTokenClaimAuthorizedParty is name of the authorized party claim defined by the OIDC spec. + IDTokenClaimAuthorizedParty = "azp" + + // IDTokenClaimUsername is the name of a custom claim in the downstream ID token whose value will contain the user's + // username which was mapped from the upstream identity provider. + IDTokenClaimUsername = "username" + + // IDTokenClaimGroups is the name of a custom claim in the downstream ID token whose value will contain the user's + // group names which were mapped from the upstream identity provider. + IDTokenClaimGroups = "groups" + + // GrantTypeAuthorizationCode is the name of the grant type for authorization code flows defined by the OIDC spec. + GrantTypeAuthorizationCode = "authorization_code" + + // GrantTypeRefreshToken is the name of the grant type for refresh flow defined by the OIDC spec. + GrantTypeRefreshToken = "refresh_token" + + // GrantTypeTokenExchange is the name of a custom grant type for RFC8693 token exchanges. + GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" //nolint:gosec // this is not a credential + + // ScopeOpenID is name of the openid scope defined by the OIDC spec. + ScopeOpenID = "openid" + + // ScopeOfflineAccess is name of the offline access scope defined by the OIDC spec, used for requesting refresh + // tokens. + ScopeOfflineAccess = "offline_access" + + // ScopeEmail is name of the email scope defined by the OIDC spec. + ScopeEmail = "email" + + // ScopeProfile is name of the profile scope defined by the OIDC spec. + ScopeProfile = "profile" + + // ScopeUsername is the name of a custom scope that determines whether the username claim will be returned inside + // ID tokens. + ScopeUsername = "username" + + // ScopeGroups is the name of a custom scope that determines whether the groups claim will be returned inside + // ID tokens. + ScopeGroups = "groups" + + // ScopeRequestAudience is the name of a custom scope that determines whether a RFC8693 token exchange is allowed to + // be used to request a different audience. + ScopeRequestAudience = "pinniped:request-audience" + + // ClientIDPinnipedCLI is the client ID of the statically defined public OIDC client which is used by the CLI. + ClientIDPinnipedCLI = "pinniped-cli" + + // ClientIDRequiredOIDCClientPrefix is the required prefix for the metadata.name of OIDCClient CRs. + ClientIDRequiredOIDCClientPrefix = "client.oauth.pinniped.dev-" ) diff --git a/generated/1.23/apis/supervisor/oidc/types_supervisor_oidc.go b/generated/1.23/apis/supervisor/oidc/types_supervisor_oidc.go index b35aafcb..cb6fe627 100644 --- a/generated/1.23/apis/supervisor/oidc/types_supervisor_oidc.go +++ b/generated/1.23/apis/supervisor/oidc/types_supervisor_oidc.go @@ -15,11 +15,68 @@ const ( // or an LDAPIdentityProvider. AuthorizePasswordHeaderName = "Pinniped-Password" //nolint:gosec // this is not a credential - // AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select which - // identity provider should be used for authentication by sending the name of the desired identity provider. + // AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select + // which identity provider should be used for authentication by sending the name of the desired identity provider. AuthorizeUpstreamIDPNameParamName = "pinniped_idp_name" - // AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select which - // identity provider should be used for authentication by sending the type of the desired identity provider. + // AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select + // which identity provider should be used for authentication by sending the type of the desired identity provider. AuthorizeUpstreamIDPTypeParamName = "pinniped_idp_type" + + // IDTokenClaimIssuer is name of the issuer claim defined by the OIDC spec. + IDTokenClaimIssuer = "iss" + + // IDTokenClaimSubject is name of the subject claim defined by the OIDC spec. + IDTokenClaimSubject = "sub" + + // IDTokenClaimAuthorizedParty is name of the authorized party claim defined by the OIDC spec. + IDTokenClaimAuthorizedParty = "azp" + + // IDTokenClaimUsername is the name of a custom claim in the downstream ID token whose value will contain the user's + // username which was mapped from the upstream identity provider. + IDTokenClaimUsername = "username" + + // IDTokenClaimGroups is the name of a custom claim in the downstream ID token whose value will contain the user's + // group names which were mapped from the upstream identity provider. + IDTokenClaimGroups = "groups" + + // GrantTypeAuthorizationCode is the name of the grant type for authorization code flows defined by the OIDC spec. + GrantTypeAuthorizationCode = "authorization_code" + + // GrantTypeRefreshToken is the name of the grant type for refresh flow defined by the OIDC spec. + GrantTypeRefreshToken = "refresh_token" + + // GrantTypeTokenExchange is the name of a custom grant type for RFC8693 token exchanges. + GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" //nolint:gosec // this is not a credential + + // ScopeOpenID is name of the openid scope defined by the OIDC spec. + ScopeOpenID = "openid" + + // ScopeOfflineAccess is name of the offline access scope defined by the OIDC spec, used for requesting refresh + // tokens. + ScopeOfflineAccess = "offline_access" + + // ScopeEmail is name of the email scope defined by the OIDC spec. + ScopeEmail = "email" + + // ScopeProfile is name of the profile scope defined by the OIDC spec. + ScopeProfile = "profile" + + // ScopeUsername is the name of a custom scope that determines whether the username claim will be returned inside + // ID tokens. + ScopeUsername = "username" + + // ScopeGroups is the name of a custom scope that determines whether the groups claim will be returned inside + // ID tokens. + ScopeGroups = "groups" + + // ScopeRequestAudience is the name of a custom scope that determines whether a RFC8693 token exchange is allowed to + // be used to request a different audience. + ScopeRequestAudience = "pinniped:request-audience" + + // ClientIDPinnipedCLI is the client ID of the statically defined public OIDC client which is used by the CLI. + ClientIDPinnipedCLI = "pinniped-cli" + + // ClientIDRequiredOIDCClientPrefix is the required prefix for the metadata.name of OIDCClient CRs. + ClientIDRequiredOIDCClientPrefix = "client.oauth.pinniped.dev-" ) diff --git a/generated/1.24/apis/supervisor/oidc/types_supervisor_oidc.go b/generated/1.24/apis/supervisor/oidc/types_supervisor_oidc.go index b35aafcb..cb6fe627 100644 --- a/generated/1.24/apis/supervisor/oidc/types_supervisor_oidc.go +++ b/generated/1.24/apis/supervisor/oidc/types_supervisor_oidc.go @@ -15,11 +15,68 @@ const ( // or an LDAPIdentityProvider. AuthorizePasswordHeaderName = "Pinniped-Password" //nolint:gosec // this is not a credential - // AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select which - // identity provider should be used for authentication by sending the name of the desired identity provider. + // AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select + // which identity provider should be used for authentication by sending the name of the desired identity provider. AuthorizeUpstreamIDPNameParamName = "pinniped_idp_name" - // AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select which - // identity provider should be used for authentication by sending the type of the desired identity provider. + // AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select + // which identity provider should be used for authentication by sending the type of the desired identity provider. AuthorizeUpstreamIDPTypeParamName = "pinniped_idp_type" + + // IDTokenClaimIssuer is name of the issuer claim defined by the OIDC spec. + IDTokenClaimIssuer = "iss" + + // IDTokenClaimSubject is name of the subject claim defined by the OIDC spec. + IDTokenClaimSubject = "sub" + + // IDTokenClaimAuthorizedParty is name of the authorized party claim defined by the OIDC spec. + IDTokenClaimAuthorizedParty = "azp" + + // IDTokenClaimUsername is the name of a custom claim in the downstream ID token whose value will contain the user's + // username which was mapped from the upstream identity provider. + IDTokenClaimUsername = "username" + + // IDTokenClaimGroups is the name of a custom claim in the downstream ID token whose value will contain the user's + // group names which were mapped from the upstream identity provider. + IDTokenClaimGroups = "groups" + + // GrantTypeAuthorizationCode is the name of the grant type for authorization code flows defined by the OIDC spec. + GrantTypeAuthorizationCode = "authorization_code" + + // GrantTypeRefreshToken is the name of the grant type for refresh flow defined by the OIDC spec. + GrantTypeRefreshToken = "refresh_token" + + // GrantTypeTokenExchange is the name of a custom grant type for RFC8693 token exchanges. + GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" //nolint:gosec // this is not a credential + + // ScopeOpenID is name of the openid scope defined by the OIDC spec. + ScopeOpenID = "openid" + + // ScopeOfflineAccess is name of the offline access scope defined by the OIDC spec, used for requesting refresh + // tokens. + ScopeOfflineAccess = "offline_access" + + // ScopeEmail is name of the email scope defined by the OIDC spec. + ScopeEmail = "email" + + // ScopeProfile is name of the profile scope defined by the OIDC spec. + ScopeProfile = "profile" + + // ScopeUsername is the name of a custom scope that determines whether the username claim will be returned inside + // ID tokens. + ScopeUsername = "username" + + // ScopeGroups is the name of a custom scope that determines whether the groups claim will be returned inside + // ID tokens. + ScopeGroups = "groups" + + // ScopeRequestAudience is the name of a custom scope that determines whether a RFC8693 token exchange is allowed to + // be used to request a different audience. + ScopeRequestAudience = "pinniped:request-audience" + + // ClientIDPinnipedCLI is the client ID of the statically defined public OIDC client which is used by the CLI. + ClientIDPinnipedCLI = "pinniped-cli" + + // ClientIDRequiredOIDCClientPrefix is the required prefix for the metadata.name of OIDCClient CRs. + ClientIDRequiredOIDCClientPrefix = "client.oauth.pinniped.dev-" ) diff --git a/generated/latest/apis/supervisor/oidc/types_supervisor_oidc.go b/generated/latest/apis/supervisor/oidc/types_supervisor_oidc.go index b35aafcb..cb6fe627 100644 --- a/generated/latest/apis/supervisor/oidc/types_supervisor_oidc.go +++ b/generated/latest/apis/supervisor/oidc/types_supervisor_oidc.go @@ -15,11 +15,68 @@ const ( // or an LDAPIdentityProvider. AuthorizePasswordHeaderName = "Pinniped-Password" //nolint:gosec // this is not a credential - // AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select which - // identity provider should be used for authentication by sending the name of the desired identity provider. + // AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select + // which identity provider should be used for authentication by sending the name of the desired identity provider. AuthorizeUpstreamIDPNameParamName = "pinniped_idp_name" - // AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select which - // identity provider should be used for authentication by sending the type of the desired identity provider. + // AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select + // which identity provider should be used for authentication by sending the type of the desired identity provider. AuthorizeUpstreamIDPTypeParamName = "pinniped_idp_type" + + // IDTokenClaimIssuer is name of the issuer claim defined by the OIDC spec. + IDTokenClaimIssuer = "iss" + + // IDTokenClaimSubject is name of the subject claim defined by the OIDC spec. + IDTokenClaimSubject = "sub" + + // IDTokenClaimAuthorizedParty is name of the authorized party claim defined by the OIDC spec. + IDTokenClaimAuthorizedParty = "azp" + + // IDTokenClaimUsername is the name of a custom claim in the downstream ID token whose value will contain the user's + // username which was mapped from the upstream identity provider. + IDTokenClaimUsername = "username" + + // IDTokenClaimGroups is the name of a custom claim in the downstream ID token whose value will contain the user's + // group names which were mapped from the upstream identity provider. + IDTokenClaimGroups = "groups" + + // GrantTypeAuthorizationCode is the name of the grant type for authorization code flows defined by the OIDC spec. + GrantTypeAuthorizationCode = "authorization_code" + + // GrantTypeRefreshToken is the name of the grant type for refresh flow defined by the OIDC spec. + GrantTypeRefreshToken = "refresh_token" + + // GrantTypeTokenExchange is the name of a custom grant type for RFC8693 token exchanges. + GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" //nolint:gosec // this is not a credential + + // ScopeOpenID is name of the openid scope defined by the OIDC spec. + ScopeOpenID = "openid" + + // ScopeOfflineAccess is name of the offline access scope defined by the OIDC spec, used for requesting refresh + // tokens. + ScopeOfflineAccess = "offline_access" + + // ScopeEmail is name of the email scope defined by the OIDC spec. + ScopeEmail = "email" + + // ScopeProfile is name of the profile scope defined by the OIDC spec. + ScopeProfile = "profile" + + // ScopeUsername is the name of a custom scope that determines whether the username claim will be returned inside + // ID tokens. + ScopeUsername = "username" + + // ScopeGroups is the name of a custom scope that determines whether the groups claim will be returned inside + // ID tokens. + ScopeGroups = "groups" + + // ScopeRequestAudience is the name of a custom scope that determines whether a RFC8693 token exchange is allowed to + // be used to request a different audience. + ScopeRequestAudience = "pinniped:request-audience" + + // ClientIDPinnipedCLI is the client ID of the statically defined public OIDC client which is used by the CLI. + ClientIDPinnipedCLI = "pinniped-cli" + + // ClientIDRequiredOIDCClientPrefix is the required prefix for the metadata.name of OIDCClient CRs. + ClientIDRequiredOIDCClientPrefix = "client.oauth.pinniped.dev-" ) diff --git a/internal/controller/authenticator/jwtcachefiller/jwtcachefiller.go b/internal/controller/authenticator/jwtcachefiller/jwtcachefiller.go index c73a351d..ad71a100 100644 --- a/internal/controller/authenticator/jwtcachefiller/jwtcachefiller.go +++ b/internal/controller/authenticator/jwtcachefiller/jwtcachefiller.go @@ -21,6 +21,7 @@ import ( "k8s.io/klog/v2" auth1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/authentication/v1alpha1" + oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc" authinformers "go.pinniped.dev/generated/latest/client/concierge/informers/externalversions/authentication/v1alpha1" pinnipedcontroller "go.pinniped.dev/internal/controller" pinnipedauthenticator "go.pinniped.dev/internal/controller/authenticator" @@ -32,8 +33,8 @@ import ( // These default values come from the way that the Supervisor issues and signs tokens. We make these // the defaults for a JWTAuthenticator so that they can easily integrate with the Supervisor. const ( - defaultUsernameClaim = "username" - defaultGroupsClaim = "groups" + defaultUsernameClaim = oidcapi.IDTokenClaimUsername + defaultGroupsClaim = oidcapi.IDTokenClaimGroups ) // defaultSupportedSigningAlgos returns the default signing algos that this JWTAuthenticator diff --git a/internal/controller/supervisorconfig/oidcclientwatcher/oidc_client_watcher.go b/internal/controller/supervisorconfig/oidcclientwatcher/oidc_client_watcher.go index 041e5c94..69e513c6 100644 --- a/internal/controller/supervisorconfig/oidcclientwatcher/oidc_client_watcher.go +++ b/internal/controller/supervisorconfig/oidcclientwatcher/oidc_client_watcher.go @@ -15,6 +15,7 @@ import ( corev1informers "k8s.io/client-go/informers/core/v1" "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" + oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc" pinnipedclientset "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned" configInformers "go.pinniped.dev/generated/latest/client/supervisor/informers/externalversions/config/v1alpha1" pinnipedcontroller "go.pinniped.dev/internal/controller" @@ -27,7 +28,7 @@ import ( const ( secretTypeToObserve = "storage.pinniped.dev/oidc-client-secret" //nolint:gosec // this is not a credential - oidcClientPrefixToObserve = "client.oauth.pinniped.dev-" //nolint:gosec // this is not a credential + oidcClientPrefixToObserve = oidcapi.ClientIDRequiredOIDCClientPrefix ) type oidcClientWatcherController struct { diff --git a/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher.go b/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher.go index 599d7400..c9f22602 100644 --- a/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher.go +++ b/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher.go @@ -14,7 +14,7 @@ import ( "strings" "time" - "github.com/coreos/go-oidc/v3/oidc" + coreosoidc "github.com/coreos/go-oidc/v3/oidc" "github.com/go-logr/logr" "golang.org/x/oauth2" corev1 "k8s.io/api/core/v1" @@ -26,6 +26,7 @@ import ( corev1informers "k8s.io/client-go/informers/core/v1" "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" + oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc" pinnipedclientset "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned" idpinformers "go.pinniped.dev/generated/latest/client/supervisor/informers/externalversions/idp/v1alpha1" "go.pinniped.dev/internal/constable" @@ -97,11 +98,11 @@ type UpstreamOIDCIdentityProviderICache interface { type lruValidatorCache struct{ cache *cache.Expiring } type lruValidatorCacheEntry struct { - provider *oidc.Provider + provider *coreosoidc.Provider client *http.Client } -func (c *lruValidatorCache) getProvider(spec *v1alpha1.OIDCIdentityProviderSpec) (*oidc.Provider, *http.Client) { +func (c *lruValidatorCache) getProvider(spec *v1alpha1.OIDCIdentityProviderSpec) (*coreosoidc.Provider, *http.Client) { if result, ok := c.cache.Get(c.cacheKey(spec)); ok { entry := result.(*lruValidatorCacheEntry) return entry.provider, entry.client @@ -109,7 +110,7 @@ func (c *lruValidatorCache) getProvider(spec *v1alpha1.OIDCIdentityProviderSpec) return nil, nil } -func (c *lruValidatorCache) putProvider(spec *v1alpha1.OIDCIdentityProviderSpec, provider *oidc.Provider, client *http.Client) { +func (c *lruValidatorCache) putProvider(spec *v1alpha1.OIDCIdentityProviderSpec, provider *coreosoidc.Provider, client *http.Client) { c.cache.Set(c.cacheKey(spec), &lruValidatorCacheEntry{provider: provider, client: client}, oidcValidatorCacheTTL) } @@ -129,8 +130,8 @@ type oidcWatcherController struct { oidcIdentityProviderInformer idpinformers.OIDCIdentityProviderInformer secretInformer corev1informers.SecretInformer validatorCache interface { - getProvider(*v1alpha1.OIDCIdentityProviderSpec) (*oidc.Provider, *http.Client) - putProvider(*v1alpha1.OIDCIdentityProviderSpec, *oidc.Provider, *http.Client) + getProvider(*v1alpha1.OIDCIdentityProviderSpec) (*coreosoidc.Provider, *http.Client) + putProvider(*v1alpha1.OIDCIdentityProviderSpec, *coreosoidc.Provider, *http.Client) } } @@ -329,7 +330,7 @@ func (c *oidcWatcherController) validateIssuer(ctx context.Context, upstream *v1 return issuerURLCondition } - discoveredProvider, err = oidc.NewProvider(oidc.ClientContext(ctx, httpClient), upstream.Spec.Issuer) + discoveredProvider, err = coreosoidc.NewProvider(coreosoidc.ClientContext(ctx, httpClient), upstream.Spec.Issuer) if err != nil { c.log.V(plog.KlogLevelTrace).WithValues( "namespace", upstream.Namespace, @@ -457,12 +458,12 @@ func defaultClientShortTimeout(rootCAs *x509.CertPool) *http.Client { func computeScopes(additionalScopes []string) []string { // If none are set then provide a reasonable default which only tries to use scopes defined in the OIDC spec. if len(additionalScopes) == 0 { - return []string{"openid", "offline_access", "email", "profile"} + return []string{oidcapi.ScopeOpenID, oidcapi.ScopeOfflineAccess, oidcapi.ScopeEmail, oidcapi.ScopeProfile} } // Otherwise, first compute the unique set of scopes, including "openid" (de-duplicate). set := sets.NewString() - set.Insert("openid") + set.Insert(oidcapi.ScopeOpenID) for _, s := range additionalScopes { set.Insert(s) } diff --git a/internal/controller/supervisorstorage/garbage_collector.go b/internal/controller/supervisorstorage/garbage_collector.go index 1c38a1d0..c11aa8c3 100644 --- a/internal/controller/supervisorstorage/garbage_collector.go +++ b/internal/controller/supervisorstorage/garbage_collector.go @@ -9,7 +9,6 @@ import ( "fmt" "time" - coreosoidc "github.com/coreos/go-oidc/v3/oidc" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" @@ -17,8 +16,8 @@ import ( "k8s.io/client-go/kubernetes" "k8s.io/utils/clock" clocktesting "k8s.io/utils/clock/testing" - "k8s.io/utils/strings/slices" + oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc" pinnipedcontroller "go.pinniped.dev/internal/controller" "go.pinniped.dev/internal/controllerlib" "go.pinniped.dev/internal/crud" @@ -204,7 +203,7 @@ func (c *garbageCollectorController) maybeRevokeUpstreamOIDCToken(ctx context.Co return err } pinnipedSession := accessTokenSession.Request.Session.(*psession.PinnipedSession) - if slices.Contains(accessTokenSession.Request.GetGrantedScopes(), coreosoidc.ScopeOfflineAccess) { + if accessTokenSession.Request.GetGrantedScopes().Has(oidcapi.ScopeOfflineAccess) { return nil } return c.tryRevokeUpstreamOIDCToken(ctx, pinnipedSession.Custom, secret) diff --git a/internal/controller/supervisorstorage/garbage_collector_test.go b/internal/controller/supervisorstorage/garbage_collector_test.go index 5ddc6953..e6bbe7b3 100644 --- a/internal/controller/supervisorstorage/garbage_collector_test.go +++ b/internal/controller/supervisorstorage/garbage_collector_test.go @@ -263,13 +263,14 @@ func TestGarbageCollectorControllerSync(t *testing.T) { when("there are valid, expired authcode secrets which contain upstream refresh tokens", func() { it.Before(func() { activeOIDCAuthcodeSession := &authorizationcode.Session{ - Version: "2", + Version: "3", Active: true, Request: &fosite.Request{ ID: "request-id-1", Client: &clientregistry.Client{}, Session: &psession.PinnipedSession{ Custom: &psession.CustomSessionData{ + Username: "should be ignored by garbage collector", ProviderUID: "upstream-oidc-provider-uid", ProviderName: "upstream-oidc-provider-name", ProviderType: psession.ProviderTypeOIDC, @@ -307,13 +308,14 @@ func TestGarbageCollectorControllerSync(t *testing.T) { r.NoError(kubeClient.Tracker().Add(activeOIDCAuthcodeSessionSecret)) inactiveOIDCAuthcodeSession := &authorizationcode.Session{ - Version: "2", + Version: "3", Active: false, Request: &fosite.Request{ ID: "request-id-2", Client: &clientregistry.Client{}, Session: &psession.PinnipedSession{ Custom: &psession.CustomSessionData{ + Username: "should be ignored by garbage collector", ProviderUID: "upstream-oidc-provider-uid", ProviderName: "upstream-oidc-provider-name", ProviderType: psession.ProviderTypeOIDC, @@ -385,13 +387,14 @@ func TestGarbageCollectorControllerSync(t *testing.T) { when("there are valid, expired authcode secrets which contain upstream access tokens", func() { it.Before(func() { activeOIDCAuthcodeSession := &authorizationcode.Session{ - Version: "2", + Version: "3", Active: true, Request: &fosite.Request{ ID: "request-id-1", Client: &clientregistry.Client{}, Session: &psession.PinnipedSession{ Custom: &psession.CustomSessionData{ + Username: "should be ignored by garbage collector", ProviderUID: "upstream-oidc-provider-uid", ProviderName: "upstream-oidc-provider-name", ProviderType: psession.ProviderTypeOIDC, @@ -429,13 +432,14 @@ func TestGarbageCollectorControllerSync(t *testing.T) { r.NoError(kubeClient.Tracker().Add(activeOIDCAuthcodeSessionSecret)) inactiveOIDCAuthcodeSession := &authorizationcode.Session{ - Version: "2", + Version: "3", Active: false, Request: &fosite.Request{ ID: "request-id-2", Client: &clientregistry.Client{}, Session: &psession.PinnipedSession{ Custom: &psession.CustomSessionData{ + Username: "should be ignored by garbage collector", ProviderUID: "upstream-oidc-provider-uid", ProviderName: "upstream-oidc-provider-name", ProviderType: psession.ProviderTypeOIDC, @@ -507,13 +511,14 @@ func TestGarbageCollectorControllerSync(t *testing.T) { when("there is an invalid, expired authcode secret", func() { it.Before(func() { invalidOIDCAuthcodeSession := &authorizationcode.Session{ - Version: "2", + Version: "3", Active: true, Request: &fosite.Request{ ID: "", // it is invalid for there to be a missing request ID Client: &clientregistry.Client{}, Session: &psession.PinnipedSession{ Custom: &psession.CustomSessionData{ + Username: "should be ignored by garbage collector", ProviderUID: "upstream-oidc-provider-uid", ProviderName: "upstream-oidc-provider-name", ProviderType: psession.ProviderTypeOIDC, @@ -575,13 +580,14 @@ func TestGarbageCollectorControllerSync(t *testing.T) { when("there is a valid, expired authcode secret but its upstream name does not match any existing upstream", func() { it.Before(func() { wrongProviderNameOIDCAuthcodeSession := &authorizationcode.Session{ - Version: "2", + Version: "3", Active: true, Request: &fosite.Request{ ID: "request-id-1", Client: &clientregistry.Client{}, Session: &psession.PinnipedSession{ Custom: &psession.CustomSessionData{ + Username: "should be ignored by garbage collector", ProviderUID: "upstream-oidc-provider-uid", ProviderName: "upstream-oidc-provider-name-will-not-match", ProviderType: psession.ProviderTypeOIDC, @@ -645,13 +651,14 @@ func TestGarbageCollectorControllerSync(t *testing.T) { when("there is a valid, expired authcode secret but its upstream UID does not match any existing upstream", func() { it.Before(func() { wrongProviderNameOIDCAuthcodeSession := &authorizationcode.Session{ - Version: "2", + Version: "3", Active: true, Request: &fosite.Request{ ID: "request-id-1", Client: &clientregistry.Client{}, Session: &psession.PinnipedSession{ Custom: &psession.CustomSessionData{ + Username: "should be ignored by garbage collector", ProviderUID: "upstream-oidc-provider-uid-will-not-match", ProviderName: "upstream-oidc-provider-name", ProviderType: psession.ProviderTypeOIDC, @@ -715,13 +722,14 @@ func TestGarbageCollectorControllerSync(t *testing.T) { when("there is a valid, recently expired authcode secret but the upstream revocation fails", func() { it.Before(func() { activeOIDCAuthcodeSession := &authorizationcode.Session{ - Version: "2", + Version: "3", Active: true, Request: &fosite.Request{ ID: "request-id-1", Client: &clientregistry.Client{}, Session: &psession.PinnipedSession{ Custom: &psession.CustomSessionData{ + Username: "should be ignored by garbage collector", ProviderUID: "upstream-oidc-provider-uid", ProviderName: "upstream-oidc-provider-name", ProviderType: psession.ProviderTypeOIDC, @@ -819,13 +827,14 @@ func TestGarbageCollectorControllerSync(t *testing.T) { when("there is a valid, long-since expired authcode secret but the upstream revocation fails", func() { it.Before(func() { activeOIDCAuthcodeSession := &authorizationcode.Session{ - Version: "2", + Version: "3", Active: true, Request: &fosite.Request{ ID: "request-id-1", Client: &clientregistry.Client{}, Session: &psession.PinnipedSession{ Custom: &psession.CustomSessionData{ + Username: "should be ignored by garbage collector", ProviderUID: "upstream-oidc-provider-uid", ProviderName: "upstream-oidc-provider-name", ProviderType: psession.ProviderTypeOIDC, @@ -897,13 +906,14 @@ func TestGarbageCollectorControllerSync(t *testing.T) { when("there are valid, expired access token secrets which contain upstream refresh tokens", func() { it.Before(func() { offlineAccessGrantedOIDCAccessTokenSession := &accesstoken.Session{ - Version: "2", + Version: "3", Request: &fosite.Request{ GrantedScope: fosite.Arguments{"scope1", "scope2", "offline_access"}, ID: "request-id-1", Client: &clientregistry.Client{}, Session: &psession.PinnipedSession{ Custom: &psession.CustomSessionData{ + Username: "should be ignored by garbage collector", ProviderUID: "upstream-oidc-provider-uid", ProviderName: "upstream-oidc-provider-name", ProviderType: psession.ProviderTypeOIDC, @@ -941,13 +951,14 @@ func TestGarbageCollectorControllerSync(t *testing.T) { r.NoError(kubeClient.Tracker().Add(offlineAccessGrantedOIDCAccessTokenSessionSecret)) offlineAccessNotGrantedOIDCAccessTokenSession := &accesstoken.Session{ - Version: "2", + Version: "3", Request: &fosite.Request{ GrantedScope: fosite.Arguments{"scope1", "scope2"}, ID: "request-id-2", Client: &clientregistry.Client{}, Session: &psession.PinnipedSession{ Custom: &psession.CustomSessionData{ + Username: "should be ignored by garbage collector", ProviderUID: "upstream-oidc-provider-uid", ProviderName: "upstream-oidc-provider-name", ProviderType: psession.ProviderTypeOIDC, @@ -1019,13 +1030,14 @@ func TestGarbageCollectorControllerSync(t *testing.T) { when("there are valid, expired access token secrets which contain upstream access tokens", func() { it.Before(func() { offlineAccessGrantedOIDCAccessTokenSession := &accesstoken.Session{ - Version: "2", + Version: "3", Request: &fosite.Request{ GrantedScope: fosite.Arguments{"scope1", "scope2", "offline_access"}, ID: "request-id-1", Client: &clientregistry.Client{}, Session: &psession.PinnipedSession{ Custom: &psession.CustomSessionData{ + Username: "should be ignored by garbage collector", ProviderUID: "upstream-oidc-provider-uid", ProviderName: "upstream-oidc-provider-name", ProviderType: psession.ProviderTypeOIDC, @@ -1063,13 +1075,14 @@ func TestGarbageCollectorControllerSync(t *testing.T) { r.NoError(kubeClient.Tracker().Add(offlineAccessGrantedOIDCAccessTokenSessionSecret)) offlineAccessNotGrantedOIDCAccessTokenSession := &accesstoken.Session{ - Version: "2", + Version: "3", Request: &fosite.Request{ GrantedScope: fosite.Arguments{"scope1", "scope2"}, ID: "request-id-2", Client: &clientregistry.Client{}, Session: &psession.PinnipedSession{ Custom: &psession.CustomSessionData{ + Username: "should be ignored by garbage collector", ProviderUID: "upstream-oidc-provider-uid", ProviderName: "upstream-oidc-provider-name", ProviderType: psession.ProviderTypeOIDC, @@ -1141,12 +1154,13 @@ func TestGarbageCollectorControllerSync(t *testing.T) { when("there are valid, expired refresh secrets which contain upstream refresh tokens", func() { it.Before(func() { oidcRefreshSession := &refreshtoken.Session{ - Version: "2", + Version: "3", Request: &fosite.Request{ ID: "request-id-1", Client: &clientregistry.Client{}, Session: &psession.PinnipedSession{ Custom: &psession.CustomSessionData{ + Username: "should be ignored by garbage collector", ProviderUID: "upstream-oidc-provider-uid", ProviderName: "upstream-oidc-provider-name", ProviderType: psession.ProviderTypeOIDC, @@ -1217,12 +1231,13 @@ func TestGarbageCollectorControllerSync(t *testing.T) { when("there are valid, expired refresh secrets which contain upstream access tokens", func() { it.Before(func() { oidcRefreshSession := &refreshtoken.Session{ - Version: "2", + Version: "3", Request: &fosite.Request{ ID: "request-id-1", Client: &clientregistry.Client{}, Session: &psession.PinnipedSession{ Custom: &psession.CustomSessionData{ + Username: "should be ignored by garbage collector", ProviderUID: "upstream-oidc-provider-uid", ProviderName: "upstream-oidc-provider-name", ProviderType: psession.ProviderTypeOIDC, diff --git a/internal/fositestorage/accesstoken/accesstoken.go b/internal/fositestorage/accesstoken/accesstoken.go index 792b76e7..606b75d2 100644 --- a/internal/fositestorage/accesstoken/accesstoken.go +++ b/internal/fositestorage/accesstoken/accesstoken.go @@ -1,4 +1,4 @@ -// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package accesstoken @@ -29,7 +29,8 @@ const ( // Version 1 was the initial release of storage. // Version 2 is when we switched to storing psession.PinnipedSession inside the fosite request. - accessTokenStorageVersion = "2" + // Version 3 is when we added the Username field to the psession.CustomSessionData. + accessTokenStorageVersion = "3" ) type RevocationStorage interface { diff --git a/internal/fositestorage/accesstoken/accesstoken_test.go b/internal/fositestorage/accesstoken/accesstoken_test.go index ddf30727..a0f818a0 100644 --- a/internal/fositestorage/accesstoken/accesstoken_test.go +++ b/internal/fositestorage/accesstoken/accesstoken_test.go @@ -53,7 +53,7 @@ func TestAccessTokenStorage(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"3"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/access-token", @@ -122,7 +122,7 @@ func TestAccessTokenStorageRevocation(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"3"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/access-token", @@ -195,7 +195,7 @@ func TestWrongVersion(t *testing.T) { _, err = storage.GetAccessTokenSession(ctx, "fancy-signature", nil) - require.EqualError(t, err, "access token request data has wrong version: access token session for fancy-signature has version not-the-right-version instead of 2") + require.EqualError(t, err, "access token request data has wrong version: access token session for fancy-signature has version not-the-right-version instead of 3") } func TestNilSessionRequest(t *testing.T) { @@ -213,7 +213,7 @@ func TestNilSessionRequest(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"2"}`), + "pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"3"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/access-token", @@ -297,13 +297,13 @@ func TestReadFromSecret(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token"}}}},"version":"2","active": true}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token"}}}},"version":"3","active": true}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/access-token", }, wantSession: &Session{ - Version: "2", + Version: "3", Request: &fosite.Request{ ID: "abcd-1", Client: &clientregistry.Client{}, @@ -313,6 +313,7 @@ func TestReadFromSecret(t *testing.T) { Subject: "panda", }, Custom: &psession.CustomSessionData{ + Username: "fake-username", ProviderUID: "fake-provider-uid", ProviderName: "fake-provider-name", ProviderType: "fake-provider-type", @@ -335,7 +336,7 @@ func TestReadFromSecret(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1"},"version":"2","active": true}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1"},"version":"3","active": true}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/not-access-token", @@ -358,7 +359,7 @@ func TestReadFromSecret(t *testing.T) { }, Type: "storage.pinniped.dev/access-token", }, - wantErr: "access token request data has wrong version: access token session has version wrong-version-here instead of 2", + wantErr: "access token request data has wrong version: access token session has version wrong-version-here instead of 3", }, { name: "missing request", @@ -371,7 +372,7 @@ func TestReadFromSecret(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"version":"2","active": true}`), + "pinniped-storage-data": []byte(`{"version":"3","active": true}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/access-token", diff --git a/internal/fositestorage/authorizationcode/authorizationcode.go b/internal/fositestorage/authorizationcode/authorizationcode.go index ecfad7be..e1e3a45a 100644 --- a/internal/fositestorage/authorizationcode/authorizationcode.go +++ b/internal/fositestorage/authorizationcode/authorizationcode.go @@ -30,7 +30,8 @@ const ( // Version 1 was the initial release of storage. // Version 2 is when we switched to storing psession.PinnipedSession inside the fosite request. - authorizeCodeStorageVersion = "2" + // Version 3 is when we added the Username field to the psession.CustomSessionData. + authorizeCodeStorageVersion = "3" ) var _ oauth2.AuthorizeCodeStorage = &authorizeCodeStorage{} @@ -366,45 +367,43 @@ const ExpectedAuthorizeCodeSessionJSONFromFuzzing = `{ "Subject": "\u0026¥潝邎Ȗ莅ŝǔ盕戙鵮碡ʯiŬŽ" }, "custom": { - "providerUID": "Ĝ眧Ĭ", - "providerName": "ʼn2ƋŢ觛ǂ焺nŐǛ", - "providerType": "ɥ闣ʬ橳(ý綃ʃʚƟ覣k眐4", + "username": "Ĝ眧Ĭ", + "providerUID": "ʼn2ƋŢ觛ǂ焺nŐǛ", + "providerName": "ɥ闣ʬ橳(ý綃ʃʚƟ覣k眐4", + "providerType": "ȣ掘ʃƸ澺淗a紽ǒ|鰽", "warnings": [ - "掘ʃƸ澺淗a紽ǒ|鰽ŋ猊", - "毇妬\u003e6鉢緋uƴŤȱʀļÂ?" + "t毇妬\u003e6鉢緋uƴŤȱʀļÂ", + "虝27就伒犘c钡ɏȫ齁š" ], "oidc": { - "upstreamRefreshToken": "\u003cƬb", - "upstreamAccessToken": "犘c钡ɏȫ", - "upstreamSubject": "鬌", - "upstreamIssuer": "%OpKȱ藚ɏ¬Ê蒭堜" + "upstreamRefreshToken": "OpKȱ藚ɏ¬Ê蒭堜]ȗ韚ʫ繕ȫ碰+ʫ", + "upstreamAccessToken": "k9帴", + "upstreamSubject": "磊ůď逳鞪?3)藵睋邔\u0026Ű惫蜀Ģ", + "upstreamIssuer": "4İ" }, "ldap": { - "userDN": "ȗ韚ʫ繕ȫ碰+", + "userDN": "×", "extraRefreshAttributes": { - "+î艔垎0": "ĝ", - "4İ": "墀jMʥ", - "k9帴": "磊ůď逳鞪?3)藵睋邔\u0026Ű惫蜀Ģ" + "ʥ笿0D": "s" } }, "activedirectory": { - "userDN": "%Ä摱ìÓȐĨf跞@)¿,ɭS隑i", + "userDN": "ĝ", "extraRefreshAttributes": { - " 皦pSǬŝ社Vƅȭǝ*擦28Dž": "vư", - "艱iYn面@yȝƋ鬯犦獢9c5¤.岵": "浛a齙\\蹼偦歛" + "IȽ齤士bEǎ": "跞@)¿,ɭS隑ip偶宾儮猷V麹", + "ȝƋ鬯犦獢9c5¤.岵": "浛a齙\\蹼偦歛" } } } }, "requestedAudience": [ - "置b", - "筫MN\u0026錝D肁Ŷɽ蔒PR}Ųʓl{" + " 皦pSǬŝ社Vƅȭǝ*擦28Dž", + "vư" ], "grantedAudience": [ - "jÃ轘屔挝", - "Œų崓ļ憽-蹐È_¸]fś", - "ɵʮGɃɫ囤1+,Ȳ" + "置b", + "筫MN\u0026錝D肁Ŷɽ蔒PR}Ųʓl{" ] }, - "version": "2" + "version": "3" }` diff --git a/internal/fositestorage/authorizationcode/authorizationcode_test.go b/internal/fositestorage/authorizationcode/authorizationcode_test.go index dd007317..9e2fbe4a 100644 --- a/internal/fositestorage/authorizationcode/authorizationcode_test.go +++ b/internal/fositestorage/authorizationcode/authorizationcode_test.go @@ -65,7 +65,7 @@ func TestAuthorizationCodeStorage(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"active":true,"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`), + "pinniped-storage-data": []byte(`{"active":true,"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"3"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/authcode", @@ -84,7 +84,7 @@ func TestAuthorizationCodeStorage(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"active":false,"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`), + "pinniped-storage-data": []byte(`{"active":false,"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"3"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/authcode", @@ -202,7 +202,7 @@ func TestWrongVersion(t *testing.T) { _, err = storage.GetAuthorizeCodeSession(ctx, "fancy-signature", nil) - require.EqualError(t, err, "authorization request data has wrong version: authorization code session for fancy-signature has version not-the-right-version instead of 2") + require.EqualError(t, err, "authorization request data has wrong version: authorization code session for fancy-signature has version not-the-right-version instead of 3") } func TestNilSessionRequest(t *testing.T) { @@ -217,7 +217,7 @@ func TestNilSessionRequest(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value", "version":"2", "active": true}`), + "pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value", "version":"3", "active": true}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/authcode", @@ -384,7 +384,7 @@ func TestFuzzAndJSONNewValidEmptyAuthorizeCodeSession(t *testing.T) { // set these to match CreateAuthorizeCodeSession so that .JSONEq works validSession.Active = true - validSession.Version = "2" + validSession.Version = "3" validSessionJSONBytes, err := json.MarshalIndent(validSession, "", "\t") require.NoError(t, err) @@ -419,13 +419,13 @@ func TestReadFromSecret(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token"}}}},"version":"2","active": true}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token"}}}},"version":"3","active": true}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/authcode", }, wantSession: &Session{ - Version: "2", + Version: "3", Active: true, Request: &fosite.Request{ ID: "abcd-1", @@ -436,6 +436,7 @@ func TestReadFromSecret(t *testing.T) { Subject: "panda", }, Custom: &psession.CustomSessionData{ + Username: "fake-username", ProviderUID: "fake-provider-uid", ProviderName: "fake-provider-name", ProviderType: "fake-provider-type", @@ -458,7 +459,7 @@ func TestReadFromSecret(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1"},"version":"2","active": true}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1"},"version":"3","active": true}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/not-authcode", @@ -481,7 +482,7 @@ func TestReadFromSecret(t *testing.T) { }, Type: "storage.pinniped.dev/authcode", }, - wantErr: "authorization request data has wrong version: authorization code session has version wrong-version-here instead of 2", + wantErr: "authorization request data has wrong version: authorization code session has version wrong-version-here instead of 3", }, { name: "missing request", @@ -494,7 +495,7 @@ func TestReadFromSecret(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"version":"2","active": true}`), + "pinniped-storage-data": []byte(`{"version":"3","active": true}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/authcode", diff --git a/internal/fositestorage/openidconnect/openidconnect.go b/internal/fositestorage/openidconnect/openidconnect.go index 81699410..605ac523 100644 --- a/internal/fositestorage/openidconnect/openidconnect.go +++ b/internal/fositestorage/openidconnect/openidconnect.go @@ -1,4 +1,4 @@ -// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package openidconnect @@ -30,7 +30,8 @@ const ( // Version 1 was the initial release of storage. // Version 2 is when we switched to storing psession.PinnipedSession inside the fosite request. - oidcStorageVersion = "2" + // Version 3 is when we added the Username field to the psession.CustomSessionData. + oidcStorageVersion = "3" ) var _ openid.OpenIDConnectRequestStorage = &openIDConnectRequestStorage{} diff --git a/internal/fositestorage/openidconnect/openidconnect_test.go b/internal/fositestorage/openidconnect/openidconnect_test.go index 10979e9c..cbfd16ea 100644 --- a/internal/fositestorage/openidconnect/openidconnect_test.go +++ b/internal/fositestorage/openidconnect/openidconnect_test.go @@ -52,7 +52,7 @@ func TestOpenIdConnectStorage(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"3"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/oidc", @@ -137,7 +137,7 @@ func TestWrongVersion(t *testing.T) { _, err = storage.GetOpenIDConnectSession(ctx, "fancy-code.fancy-signature", nil) - require.EqualError(t, err, "oidc request data has wrong version: oidc session for fancy-signature has version not-the-right-version instead of 2") + require.EqualError(t, err, "oidc request data has wrong version: oidc session for fancy-signature has version not-the-right-version instead of 3") } func TestNilSessionRequest(t *testing.T) { @@ -152,7 +152,7 @@ func TestNilSessionRequest(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"2"}`), + "pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"3"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/oidc", diff --git a/internal/fositestorage/pkce/pkce.go b/internal/fositestorage/pkce/pkce.go index cbe566bd..f84b01da 100644 --- a/internal/fositestorage/pkce/pkce.go +++ b/internal/fositestorage/pkce/pkce.go @@ -1,4 +1,4 @@ -// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package pkce @@ -28,7 +28,8 @@ const ( // Version 1 was the initial release of storage. // Version 2 is when we switched to storing psession.PinnipedSession inside the fosite request. - pkceStorageVersion = "2" + // Version 3 is when we added the Username field to the psession.CustomSessionData. + pkceStorageVersion = "3" ) var _ pkce.PKCERequestStorage = &pkceStorage{} diff --git a/internal/fositestorage/pkce/pkce_test.go b/internal/fositestorage/pkce/pkce_test.go index 06e3db6b..47e6cef0 100644 --- a/internal/fositestorage/pkce/pkce_test.go +++ b/internal/fositestorage/pkce/pkce_test.go @@ -52,7 +52,7 @@ func TestPKCEStorage(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"3"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/pkce", @@ -140,7 +140,7 @@ func TestWrongVersion(t *testing.T) { _, err = storage.GetPKCERequestSession(ctx, "fancy-signature", nil) - require.EqualError(t, err, "pkce request data has wrong version: pkce session for fancy-signature has version not-the-right-version instead of 2") + require.EqualError(t, err, "pkce request data has wrong version: pkce session for fancy-signature has version not-the-right-version instead of 3") } func TestNilSessionRequest(t *testing.T) { @@ -158,7 +158,7 @@ func TestNilSessionRequest(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"2"}`), + "pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"3"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/pkce", diff --git a/internal/fositestorage/refreshtoken/refreshtoken.go b/internal/fositestorage/refreshtoken/refreshtoken.go index a2a2fe89..7f1147fb 100644 --- a/internal/fositestorage/refreshtoken/refreshtoken.go +++ b/internal/fositestorage/refreshtoken/refreshtoken.go @@ -29,7 +29,8 @@ const ( // Version 1 was the initial release of storage. // Version 2 is when we switched to storing psession.PinnipedSession inside the fosite request. - refreshTokenStorageVersion = "2" + // Version 3 is when we added the Username field to the psession.CustomSessionData. + refreshTokenStorageVersion = "3" ) type RevocationStorage interface { diff --git a/internal/fositestorage/refreshtoken/refreshtoken_test.go b/internal/fositestorage/refreshtoken/refreshtoken_test.go index 15ad5e78..54fc24a4 100644 --- a/internal/fositestorage/refreshtoken/refreshtoken_test.go +++ b/internal/fositestorage/refreshtoken/refreshtoken_test.go @@ -52,7 +52,7 @@ func TestRefreshTokenStorage(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"3"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/refresh-token", @@ -122,7 +122,7 @@ func TestRefreshTokenStorageRevocation(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"3"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/refresh-token", @@ -177,7 +177,7 @@ func TestRefreshTokenStorageRevokeRefreshTokenMaybeGracePeriod(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"3"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/refresh-token", @@ -251,7 +251,7 @@ func TestWrongVersion(t *testing.T) { _, err = storage.GetRefreshTokenSession(ctx, "fancy-signature", nil) - require.EqualError(t, err, "refresh token request data has wrong version: refresh token session for fancy-signature has version not-the-right-version instead of 2") + require.EqualError(t, err, "refresh token request data has wrong version: refresh token session for fancy-signature has version not-the-right-version instead of 3") } func TestNilSessionRequest(t *testing.T) { @@ -269,7 +269,7 @@ func TestNilSessionRequest(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"2"}`), + "pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"3"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/refresh-token", @@ -353,13 +353,13 @@ func TestReadFromSecret(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token"}}}},"version":"2","active": true}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token"}}}},"version":"3","active": true}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/refresh-token", }, wantSession: &Session{ - Version: "2", + Version: "3", Request: &fosite.Request{ ID: "abcd-1", Client: &clientregistry.Client{}, @@ -369,6 +369,7 @@ func TestReadFromSecret(t *testing.T) { Subject: "panda", }, Custom: &psession.CustomSessionData{ + Username: "fake-username", ProviderUID: "fake-provider-uid", ProviderName: "fake-provider-name", ProviderType: "fake-provider-type", @@ -391,7 +392,7 @@ func TestReadFromSecret(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1"},"version":"2","active": true}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1"},"version":"3","active": true}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/not-refresh-token", @@ -414,7 +415,7 @@ func TestReadFromSecret(t *testing.T) { }, Type: "storage.pinniped.dev/refresh-token", }, - wantErr: "refresh token request data has wrong version: refresh token session has version wrong-version-here instead of 2", + wantErr: "refresh token request data has wrong version: refresh token session has version wrong-version-here instead of 3", }, { name: "missing request", @@ -427,7 +428,7 @@ func TestReadFromSecret(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"version":"2","active": true}`), + "pinniped-storage-data": []byte(`{"version":"3","active": true}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/refresh-token", diff --git a/internal/oidc/auth/auth_handler.go b/internal/oidc/auth/auth_handler.go index 370f8baa..bf7e1764 100644 --- a/internal/oidc/auth/auth_handler.go +++ b/internal/oidc/auth/auth_handler.go @@ -10,17 +10,15 @@ import ( "net/url" "time" - coreosoidc "github.com/coreos/go-oidc/v3/oidc" "github.com/ory/fosite" "github.com/ory/fosite/handler/openid" "github.com/ory/fosite/token/jwt" "golang.org/x/oauth2" - supervisoroidc "go.pinniped.dev/generated/latest/apis/supervisor/oidc" + oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc" "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/httputil/securityheader" "go.pinniped.dev/internal/oidc" - "go.pinniped.dev/internal/oidc/clientregistry" "go.pinniped.dev/internal/oidc/csrftoken" "go.pinniped.dev/internal/oidc/downstreamsession" "go.pinniped.dev/internal/oidc/login" @@ -56,12 +54,12 @@ func NewHandler( return httperr.Newf(http.StatusMethodNotAllowed, "%s (try GET or POST)", r.Method) } - // Note that the client might have used supervisoroidc.AuthorizeUpstreamIDPNameParamName and - // supervisoroidc.AuthorizeUpstreamIDPTypeParamName query params to request a certain upstream IDP. + // Note that the client might have used oidcapi.AuthorizeUpstreamIDPNameParamName and + // oidcapi.AuthorizeUpstreamIDPTypeParamName query params to request a certain upstream IDP. // The Pinniped CLI has been sending these params since v0.9.0. // Currently, these are ignored because the Supervisor does not yet support logins when multiple IDPs // are configured. However, these params should be honored in the future when choosing an upstream - // here, e.g. by calling supervisoroidc.FindUpstreamIDPByNameAndType() when the params are present. + // here, e.g. by calling oidcapi.FindUpstreamIDPByNameAndType() when the params are present. oidcUpstream, ldapUpstream, idpType, err := chooseUpstreamIDP(idpLister) if err != nil { plog.WarningErr("authorize upstream config", err) @@ -69,8 +67,8 @@ func NewHandler( } if idpType == psession.ProviderTypeOIDC { - if len(r.Header.Values(supervisoroidc.AuthorizeUsernameHeaderName)) > 0 || - len(r.Header.Values(supervisoroidc.AuthorizePasswordHeaderName)) > 0 { + if len(r.Header.Values(oidcapi.AuthorizeUsernameHeaderName)) > 0 || + len(r.Header.Values(oidcapi.AuthorizePasswordHeaderName)) > 0 { // The client set a username header, so they are trying to log in with a username/password. return handleAuthRequestForOIDCUpstreamPasswordGrant(r, w, oauthHelperWithStorage, oidcUpstream) } @@ -85,8 +83,8 @@ func NewHandler( } // We know it's an AD/LDAP upstream. - if len(r.Header.Values(supervisoroidc.AuthorizeUsernameHeaderName)) > 0 || - len(r.Header.Values(supervisoroidc.AuthorizePasswordHeaderName)) > 0 { + if len(r.Header.Values(oidcapi.AuthorizeUsernameHeaderName)) > 0 || + len(r.Header.Values(oidcapi.AuthorizePasswordHeaderName)) > 0 { // The client set a username header, so they are trying to log in with a username/password. return handleAuthRequestForLDAPUpstreamCLIFlow(r, w, oauthHelperWithStorage, @@ -150,7 +148,7 @@ func handleAuthRequestForLDAPUpstreamCLIFlow( subject := downstreamsession.DownstreamSubjectFromUpstreamLDAP(ldapUpstream, authenticateResponse) username = authenticateResponse.User.GetName() groups := authenticateResponse.User.GetGroups() - customSessionData := downstreamsession.MakeDownstreamLDAPOrADCustomSessionData(ldapUpstream, idpType, authenticateResponse) + customSessionData := downstreamsession.MakeDownstreamLDAPOrADCustomSessionData(ldapUpstream, idpType, authenticateResponse, username) openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups, authorizeRequester.GetGrantedScopes(), customSessionData) oidc.PerformAuthcodeRedirect(r, w, oauthHelper, authorizeRequester, openIDSession, true) @@ -244,7 +242,7 @@ func handleAuthRequestForOIDCUpstreamPasswordGrant( return nil } - customSessionData, err := downstreamsession.MakeDownstreamOIDCCustomSessionData(oidcUpstream, token) + customSessionData, err := downstreamsession.MakeDownstreamOIDCCustomSessionData(oidcUpstream, token, username) if err != nil { oidc.WriteAuthorizeError(w, oauthHelper, authorizeRequester, fosite.ErrAccessDenied.WithHintf("Reason: %s.", err.Error()), true, @@ -322,7 +320,7 @@ func handleAuthRequestForOIDCUpstreamBrowserFlow( } func requireStaticClientForUsernameAndPasswordHeaders(w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, authorizeRequester fosite.AuthorizeRequester) bool { - isStaticClient := authorizeRequester.GetClient().GetID() == clientregistry.PinnipedCLIClientID + isStaticClient := authorizeRequester.GetClient().GetID() == oidcapi.ClientIDPinnipedCLI if !isStaticClient { oidc.WriteAuthorizeError(w, oauthHelper, authorizeRequester, fosite.ErrAccessDenied.WithHintf("This client is not allowed to submit username or password headers to this endpoint."), true) @@ -331,8 +329,8 @@ func requireStaticClientForUsernameAndPasswordHeaders(w http.ResponseWriter, oau } func requireNonEmptyUsernameAndPasswordHeaders(r *http.Request, w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, authorizeRequester fosite.AuthorizeRequester) (string, string, bool) { - username := r.Header.Get(supervisoroidc.AuthorizeUsernameHeaderName) - password := r.Header.Get(supervisoroidc.AuthorizePasswordHeaderName) + username := r.Header.Get(oidcapi.AuthorizeUsernameHeaderName) + password := r.Header.Get(oidcapi.AuthorizePasswordHeaderName) if username == "" || password == "" { oidc.WriteAuthorizeError(w, oauthHelper, authorizeRequester, fosite.ErrAccessDenied.WithHintf("Missing or blank username or password."), true) @@ -348,13 +346,13 @@ func newAuthorizeRequest(r *http.Request, w http.ResponseWriter, oauthHelper fos return nil, false } - // Automatically grant the openid, offline_access, pinniped:request-audience, and groups scopes, but only if they were requested. + // Automatically grant certain scopes, but only if they were requested. // 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 // at this time, however we will temporarily grant the scope just in case that changes in a future release of fosite. // This is instead of asking the user to approve these scopes. Note that `NewAuthorizeRequest` would have returned // an error if the client requested a scope that they are not allowed to request, so we don't need to worry about that here. - downstreamsession.GrantScopesIfRequested(authorizeRequester, []string{coreosoidc.ScopeOpenID, coreosoidc.ScopeOfflineAccess, oidc.RequestAudienceScope, oidc.DownstreamGroupsScope}) + downstreamsession.AutoApproveScopes(authorizeRequester) return authorizeRequester, true } @@ -487,7 +485,7 @@ func handleBrowserFlowAuthRequest( } promptParam := r.Form.Get(promptParamName) - if promptParam == promptParamNone && oidc.ScopeWasRequested(authorizeRequester, coreosoidc.ScopeOpenID) { + if promptParam == promptParamNone && oidc.ScopeWasRequested(authorizeRequester, oidcapi.ScopeOpenID) { oidc.WriteAuthorizeError(w, oauthHelper, authorizeRequester, fosite.ErrLoginRequired, false) return nil, nil // already wrote the error response, don't return error } @@ -538,8 +536,8 @@ func upstreamStateParam( encoder oidc.Encoder, ) (string, error) { stateParamData := oidc.UpstreamStateParamData{ - // The auth params might have included supervisoroidc.AuthorizeUpstreamIDPNameParamName and - // supervisoroidc.AuthorizeUpstreamIDPTypeParamName, but those can be ignored by other handlers + // The auth params might have included oidcapi.AuthorizeUpstreamIDPNameParamName and + // oidcapi.AuthorizeUpstreamIDPTypeParamName, but those can be ignored by other handlers // that are reading from the encoded upstream state param being built here. // The UpstreamName and UpstreamType struct fields can be used instead. // Remove those params here to avoid potential confusion about which should be used later. @@ -565,8 +563,8 @@ func removeCustomIDPParams(params url.Values) url.Values { p[k] = v } // Remove the unnecessary params. - delete(p, supervisoroidc.AuthorizeUpstreamIDPNameParamName) - delete(p, supervisoroidc.AuthorizeUpstreamIDPTypeParamName) + delete(p, oidcapi.AuthorizeUpstreamIDPNameParamName) + delete(p, oidcapi.AuthorizeUpstreamIDPTypeParamName) return p } diff --git a/internal/oidc/auth/auth_handler_test.go b/internal/oidc/auth/auth_handler_test.go index 6f8e7598..6d19b9c7 100644 --- a/internal/oidc/auth/auth_handler_test.go +++ b/internal/oidc/auth/auth_handler_test.go @@ -34,6 +34,7 @@ import ( "go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc/csrftoken" "go.pinniped.dev/internal/oidc/jwks" + "go.pinniped.dev/internal/oidc/oidcclientvalidator" "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/testutil" @@ -391,8 +392,8 @@ func TestAuthorizationEndpoint(t *testing.T) { return urlToReturn } - happyDownstreamScopesRequested := []string{"openid", "profile", "email", "groups"} - happyDownstreamScopesGranted := []string{"openid", "groups"} + happyDownstreamScopesRequested := []string{"openid", "profile", "email", "username", "groups"} + happyDownstreamScopesGranted := []string{"openid", "username", "groups"} happyGetRequestQueryMap := map[string]string{ "response_type": "code", @@ -465,6 +466,7 @@ func TestAuthorizationEndpoint(t *testing.T) { } expectedHappyActiveDirectoryUpstreamCustomSession := &psession.CustomSessionData{ + Username: happyLDAPUsernameFromAuthenticator, ProviderUID: activeDirectoryUpstreamResourceUID, ProviderName: activeDirectoryUpstreamName, ProviderType: psession.ProviderTypeActiveDirectory, @@ -477,6 +479,7 @@ func TestAuthorizationEndpoint(t *testing.T) { } expectedHappyLDAPUpstreamCustomSession := &psession.CustomSessionData{ + Username: happyLDAPUsernameFromAuthenticator, ProviderUID: ldapUpstreamResourceUID, ProviderName: ldapUpstreamName, ProviderType: psession.ProviderTypeLDAP, @@ -489,6 +492,7 @@ func TestAuthorizationEndpoint(t *testing.T) { } expectedHappyOIDCPasswordGrantCustomSession := &psession.CustomSessionData{ + Username: oidcUpstreamUsername, ProviderUID: oidcPasswordGrantUpstreamResourceUID, ProviderName: oidcPasswordGrantUpstreamName, ProviderType: psession.ProviderTypeOIDC, @@ -499,7 +503,16 @@ func TestAuthorizationEndpoint(t *testing.T) { }, } + expectedHappyOIDCPasswordGrantCustomSessionWithUsername := func(wantUsername string) *psession.CustomSessionData { + copyOfCustomSession := *expectedHappyOIDCPasswordGrantCustomSession + copyOfOIDC := *(expectedHappyOIDCPasswordGrantCustomSession.OIDC) + copyOfCustomSession.OIDC = ©OfOIDC + copyOfCustomSession.Username = wantUsername + return ©OfCustomSession + } + expectedHappyOIDCPasswordGrantCustomSessionWithAccessToken := &psession.CustomSessionData{ + Username: oidcUpstreamUsername, ProviderUID: oidcPasswordGrantUpstreamResourceUID, ProviderName: oidcPasswordGrantUpstreamName, ProviderType: psession.ProviderTypeOIDC, @@ -512,13 +525,14 @@ func TestAuthorizationEndpoint(t *testing.T) { addFullyCapableDynamicClientAndSecretToKubeResources := func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) { oidcClient, secret := testutil.FullyCapableOIDCClientAndStorageSecret(t, - "some-namespace", dynamicClientID, dynamicClientUID, downstreamRedirectURI, []string{testutil.HashedPassword1AtGoMinCost}) + "some-namespace", dynamicClientID, dynamicClientUID, downstreamRedirectURI, + []string{testutil.HashedPassword1AtGoMinCost}, oidcclientvalidator.Validate) require.NoError(t, supervisorClient.Tracker().Add(oidcClient)) require.NoError(t, kubeClient.Tracker().Add(secret)) } // Note that fosite puts the granted scopes as a param in the redirect URI even though the spec doesn't seem to require it - happyAuthcodeDownstreamRedirectLocationRegexp := downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+groups&state=` + happyState + happyAuthcodeDownstreamRedirectLocationRegexp := downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+username\+groups&state=` + happyState incomingCookieCSRFValue := "csrf-value-from-cookie" encodedIncomingCookieCSRFValue, err := happyCookieEncoder.Encode("csrf", incomingCookieCSRFValue) @@ -528,6 +542,7 @@ func TestAuthorizationEndpoint(t *testing.T) { name string idps *oidctestutil.UpstreamIDPListerBuilder + kubeResources func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) generateCSRF func() (csrftoken.CSRFToken, error) generatePKCE func() (pkce.Code, error) generateNonce func() (nonce.Nonce, error) @@ -540,7 +555,6 @@ func TestAuthorizationEndpoint(t *testing.T) { csrfCookie string customUsernameHeader *string // nil means do not send header, empty means send header with empty value customPasswordHeader *string // nil means do not send header, empty means send header with empty value - kubeResources func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) wantStatus int wantContentType string @@ -1122,7 +1136,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, wantContentType: htmlContentType, - wantRedirectLocationRegexp: downstreamRedirectURIWithDifferentPort + `\?code=([^&]+)&scope=openid\+groups&state=` + happyState, + wantRedirectLocationRegexp: downstreamRedirectURIWithDifferentPort + `\?code=([^&]+)&scope=openid\+username\+groups&state=` + happyState, wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, @@ -1145,7 +1159,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customPasswordHeader: pointer.StringPtr(happyLDAPPassword), wantStatus: http.StatusFound, wantContentType: htmlContentType, - wantRedirectLocationRegexp: downstreamRedirectURIWithDifferentPort + `\?code=([^&]+)&scope=openid\+groups&state=` + happyState, + wantRedirectLocationRegexp: downstreamRedirectURIWithDifferentPort + `\?code=([^&]+)&scope=openid\+username\+groups&state=` + happyState, wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID, wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, wantDownstreamIDTokenGroups: happyLDAPGroups, @@ -1219,6 +1233,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: &psession.CustomSessionData{ + Username: oidcUpstreamUsername, ProviderUID: oidcPasswordGrantUpstreamResourceUID, ProviderName: oidcPasswordGrantUpstreamName, ProviderType: psession.ProviderTypeOIDC, @@ -2373,13 +2388,13 @@ func TestAuthorizationEndpoint(t *testing.T) { wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, wantContentType: htmlContentType, - wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=&state=` + happyState, // no scopes granted + wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=username\+groups&state=` + happyState, // username and groups scopes were not requested, but are granted anyway for backwards compatibility wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, - wantDownstreamIDTokenUsername: oidcUpstreamUsername, - wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, - wantDownstreamRequestedScopes: []string{"email"}, // only email was requested + wantDownstreamIDTokenUsername: oidcUpstreamUsername, // username scope was not requested, but is granted anyway for backwards compatibility + wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, // groups scope was not requested, but is granted anyway for backwards compatibility + wantDownstreamRequestedScopes: []string{"email"}, // only email was requested wantDownstreamRedirectURI: downstreamRedirectURI, - wantDownstreamGrantedScopes: []string{}, // no scopes granted + wantDownstreamGrantedScopes: []string{"username", "groups"}, // username and groups scopes were not requested, but are granted anyway for backwards compatibility wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, @@ -2395,13 +2410,13 @@ func TestAuthorizationEndpoint(t *testing.T) { customPasswordHeader: pointer.StringPtr(happyLDAPPassword), wantStatus: http.StatusFound, wantContentType: htmlContentType, - wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=&state=` + happyState, // no scopes granted + wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=username\+groups&state=` + happyState, // username and groups scopes were not requested, but are granted anyway for backwards compatibility wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID, - wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, - wantDownstreamIDTokenGroups: happyLDAPGroups, - wantDownstreamRequestedScopes: []string{"email"}, // only email was requested + wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, // username scope was not requested, but is granted anyway for backwards compatibility + wantDownstreamIDTokenGroups: happyLDAPGroups, // groups scope was not requested, but is granted anyway for backwards compatibility + wantDownstreamRequestedScopes: []string{"email"}, // only email was requested wantDownstreamRedirectURI: downstreamRedirectURI, - wantDownstreamGrantedScopes: []string{}, // no scopes granted + wantDownstreamGrantedScopes: []string{"username", "groups"}, // username and groups scopes were not requested, but are granted anyway for backwards compatibility wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, @@ -2429,7 +2444,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSession, + wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSessionWithUsername(oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped), }, { name: "OIDC upstream password grant: upstream IDP configures username claim as special claim `email` and `email_verified` upstream claim is missing", @@ -2455,7 +2470,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSession, + wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSessionWithUsername("joe@whitehouse.gov"), }, { name: "OIDC upstream password grant: upstream IDP configures username claim as special claim `email` and `email_verified` upstream claim is present with true value", @@ -2482,7 +2497,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSession, + wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSessionWithUsername("joe@whitehouse.gov"), }, { name: "OIDC upstream password grant: upstream IDP configures username claim as anything other than special claim `email` and `email_verified` upstream claim is present with false value", @@ -2510,7 +2525,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSession, + wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSessionWithUsername("joe"), }, { name: "OIDC upstream password grant: upstream IDP configures username claim as special claim `email` and `email_verified` upstream claim is present with illegal value", @@ -2570,7 +2585,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSession, + wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSessionWithUsername(oidcUpstreamSubject), }, { name: "OIDC upstream password grant: upstream IDP's configured groups claim in the ID token has a non-array value", diff --git a/internal/oidc/callback/callback_handler.go b/internal/oidc/callback/callback_handler.go index 88b94392..f3a37b9d 100644 --- a/internal/oidc/callback/callback_handler.go +++ b/internal/oidc/callback/callback_handler.go @@ -8,7 +8,6 @@ import ( "net/http" "net/url" - coreosoidc "github.com/coreos/go-oidc/v3/oidc" "github.com/ory/fosite" "go.pinniped.dev/internal/httputil/httperr" @@ -53,10 +52,10 @@ func NewHandler( return httperr.New(http.StatusBadRequest, "error using state downstream auth params") } - // Automatically grant the openid, offline_access, pinniped:request-audience, and groups scopes, but only if they were requested. + // Automatically grant certain scopes, but only if they were requested. // This is instead of asking the user to approve these scopes. Note that `NewAuthorizeRequest` would have returned // an error if the client requested a scope that they are not allowed to request, so we don't need to worry about that here. - downstreamsession.GrantScopesIfRequested(authorizeRequester, []string{coreosoidc.ScopeOpenID, coreosoidc.ScopeOfflineAccess, oidc.RequestAudienceScope, oidc.DownstreamGroupsScope}) + downstreamsession.AutoApproveScopes(authorizeRequester) token, err := upstreamIDPConfig.ExchangeAuthcodeAndValidateTokens( r.Context(), @@ -75,7 +74,7 @@ func NewHandler( return httperr.Wrap(http.StatusUnprocessableEntity, err.Error(), err) } - customSessionData, err := downstreamsession.MakeDownstreamOIDCCustomSessionData(upstreamIDPConfig, token) + customSessionData, err := downstreamsession.MakeDownstreamOIDCCustomSessionData(upstreamIDPConfig, token, username) if err != nil { return httperr.Wrap(http.StatusUnprocessableEntity, err.Error(), err) } diff --git a/internal/oidc/callback/callback_handler_test.go b/internal/oidc/callback/callback_handler_test.go index 57dcfcd5..44794ea5 100644 --- a/internal/oidc/callback/callback_handler_test.go +++ b/internal/oidc/callback/callback_handler_test.go @@ -19,9 +19,11 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes/fake" + configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake" "go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc/jwks" + "go.pinniped.dev/internal/oidc/oidcclientvalidator" "go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil/oidctestutil" @@ -66,8 +68,8 @@ const ( var ( oidcUpstreamGroupMembership = []string{"test-pinniped-group-0", "test-pinniped-group-1"} - happyDownstreamScopesRequested = []string{"openid", "groups"} - happyDownstreamScopesGranted = []string{"openid", "groups"} + happyDownstreamScopesRequested = []string{"openid", "username", "groups"} + happyDownstreamScopesGranted = []string{"openid", "username", "groups"} happyDownstreamRequestParamsQuery = url.Values{ "response_type": []string{"code"}, @@ -81,11 +83,13 @@ var ( } happyDownstreamRequestParams = happyDownstreamRequestParamsQuery.Encode() - happyDownstreamRequestParamsForDynamicClient = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, + happyDownstreamRequestParamsQueryForDynamicClient = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, map[string]string{"client_id": downstreamDynamicClientID}, - ).Encode() + ) + happyDownstreamRequestParamsForDynamicClient = happyDownstreamRequestParamsQueryForDynamicClient.Encode() happyDownstreamCustomSessionData = &psession.CustomSessionData{ + Username: oidcUpstreamUsername, ProviderUID: happyUpstreamIDPResourceUID, ProviderName: happyUpstreamIDPName, ProviderType: psession.ProviderTypeOIDC, @@ -95,7 +99,15 @@ var ( UpstreamSubject: oidcUpstreamSubject, }, } + happyDownstreamCustomSessionDataWithUsername = func(wantUsername string) *psession.CustomSessionData { + copyOfCustomSession := *happyDownstreamCustomSessionData + copyOfOIDC := *(happyDownstreamCustomSessionData.OIDC) + copyOfCustomSession.OIDC = ©OfOIDC + copyOfCustomSession.Username = wantUsername + return ©OfCustomSession + } happyDownstreamAccessTokenCustomSessionData = &psession.CustomSessionData{ + Username: oidcUpstreamUsername, ProviderUID: happyUpstreamIDPResourceUID, ProviderName: happyUpstreamIDPName, ProviderType: psession.ProviderTypeOIDC, @@ -143,11 +155,12 @@ 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 - happyDownstreamRedirectLocationRegexp := downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+groups&state=` + happyDownstreamState + happyDownstreamRedirectLocationRegexp := downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+username\+groups&state=` + happyDownstreamState addFullyCapableDynamicClientAndSecretToKubeResources := func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) { oidcClient, secret := testutil.FullyCapableOIDCClientAndStorageSecret(t, - "some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID, downstreamRedirectURI, []string{testutil.HashedPassword1AtGoMinCost}) + "some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID, downstreamRedirectURI, + []string{testutil.HashedPassword1AtGoMinCost}, oidcclientvalidator.Validate) require.NoError(t, supervisorClient.Tracker().Add(oidcClient)) require.NoError(t, kubeClient.Tracker().Add(secret)) } @@ -284,7 +297,7 @@ func TestCallbackEndpoint(t *testing.T) { }, }, { - name: "form_post happy path with no groups scope requested", + name: "form_post happy path without username or groups scopes requested", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), method: http.MethodGet, path: newRequestPath().WithState( @@ -298,14 +311,16 @@ func TestCallbackEndpoint(t *testing.T) { ).Encode(), ).Build(t, happyStateCodec), ).String(), - csrfCookie: happyCSRFCookie, - wantStatus: http.StatusOK, - wantContentType: "text/html;charset=UTF-8", - wantBodyFormResponseRegexp: `(.+)`, - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, - wantDownstreamIDTokenUsername: oidcUpstreamUsername, - wantDownstreamRequestedScopes: []string{"openid"}, - wantDownstreamGrantedScopes: []string{"openid"}, + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusOK, + wantContentType: "text/html;charset=UTF-8", + wantBodyFormResponseRegexp: `(.+)`, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: oidcUpstreamUsername, + wantDownstreamRequestedScopes: []string{"openid"}, + wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, + // username and groups scopes were not requested but are granted anyway for the pinniped-cli client for backwards compatibility + wantDownstreamGrantedScopes: []string{"openid", "username", "groups"}, wantDownstreamNonce: downstreamNonce, wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, @@ -335,6 +350,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: &psession.CustomSessionData{ + Username: oidcUpstreamUsername, ProviderUID: happyUpstreamIDPResourceUID, ProviderName: happyUpstreamIDPName, ProviderType: psession.ProviderTypeOIDC, @@ -370,7 +386,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsername(oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped), wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, @@ -396,7 +412,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsername("joe@whitehouse.gov"), wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, @@ -424,7 +440,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsername("joe@whitehouse.gov"), wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, @@ -453,7 +469,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsername("joe"), wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, @@ -584,7 +600,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsername(oidcUpstreamSubject), wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, @@ -642,6 +658,152 @@ func TestCallbackEndpoint(t *testing.T) { args: happyExchangeAndValidateTokensArgs, }, }, + { + name: "using dynamic client which is allowed to request username scope, but does not actually request username scope in authorize request, does not get username in ID token", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + method: http.MethodGet, + path: newRequestPath().WithState( + happyUpstreamStateParamForDynamicClient(). + WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQueryForDynamicClient, + map[string]string{"scope": "openid groups offline_access"}).Encode()). + Build(t, happyStateCodec), + ).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusSeeOther, + wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+groups&state=` + happyDownstreamState, + wantBody: "", + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: "", // username scope was not requested + wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, + wantDownstreamRequestedScopes: []string{"openid", "groups", "offline_access"}, + wantDownstreamGrantedScopes: []string{"openid", "groups", "offline_access"}, + wantDownstreamNonce: downstreamNonce, + wantDownstreamClientID: downstreamDynamicClientID, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, + }, + { + name: "using dynamic client which is allowed to request groups scope, but does not actually request groups scope in authorize request, does not get groups in ID token", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + method: http.MethodGet, + path: newRequestPath().WithState( + happyUpstreamStateParamForDynamicClient(). + WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQueryForDynamicClient, + map[string]string{"scope": "openid username offline_access"}).Encode()). + Build(t, happyStateCodec), + ).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusSeeOther, + wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+username&state=` + happyDownstreamState, + wantBody: "", + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: oidcUpstreamUsername, + wantDownstreamIDTokenGroups: nil, // groups scope was not requested + wantDownstreamRequestedScopes: []string{"openid", "username", "offline_access"}, + wantDownstreamGrantedScopes: []string{"openid", "username", "offline_access"}, + wantDownstreamNonce: downstreamNonce, + wantDownstreamClientID: downstreamDynamicClientID, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, + }, + { + name: "using dynamic client which is not allowed to request username scope, and does not actually request username scope in authorize request, does not get username in ID token", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + kubeResources: func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) { + oidcClient, secret := testutil.OIDCClientAndStorageSecret(t, + "some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID, + []configv1alpha1.GrantType{"authorization_code", "refresh_token"}, // token exchange not allowed (required to exclude username scope) + []configv1alpha1.Scope{"openid", "offline_access", "groups"}, // username not allowed + downstreamRedirectURI, []string{testutil.HashedPassword1AtGoMinCost}, oidcclientvalidator.Validate) + require.NoError(t, supervisorClient.Tracker().Add(oidcClient)) + require.NoError(t, kubeClient.Tracker().Add(secret)) + }, + method: http.MethodGet, + path: newRequestPath().WithState( + happyUpstreamStateParam().WithAuthorizeRequestParams( + shallowCopyAndModifyQuery( + happyDownstreamRequestParamsQuery, + map[string]string{ + "client_id": downstreamDynamicClientID, + "scope": "openid offline_access groups", + }, + ).Encode(), + ).Build(t, happyStateCodec), + ).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusSeeOther, + wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+groups&state=` + happyDownstreamState, + wantBody: "", + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: "", // username scope was not requested + wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, + wantDownstreamRequestedScopes: []string{"openid", "groups", "offline_access"}, + wantDownstreamGrantedScopes: []string{"openid", "groups", "offline_access"}, + wantDownstreamNonce: downstreamNonce, + wantDownstreamClientID: downstreamDynamicClientID, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, + }, + { + name: "using dynamic client which is not allowed to request groups scope, and does not actually request groups scope in authorize request, does not get groups in ID token", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + kubeResources: func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) { + oidcClient, secret := testutil.OIDCClientAndStorageSecret(t, + "some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID, + []configv1alpha1.GrantType{"authorization_code", "refresh_token"}, // token exchange not allowed (required to exclude groups scope) + []configv1alpha1.Scope{"openid", "offline_access", "username"}, // groups not allowed + downstreamRedirectURI, []string{testutil.HashedPassword1AtGoMinCost}, oidcclientvalidator.Validate) + require.NoError(t, supervisorClient.Tracker().Add(oidcClient)) + require.NoError(t, kubeClient.Tracker().Add(secret)) + }, + method: http.MethodGet, + path: newRequestPath().WithState( + happyUpstreamStateParam().WithAuthorizeRequestParams( + shallowCopyAndModifyQuery( + happyDownstreamRequestParamsQuery, + map[string]string{ + "client_id": downstreamDynamicClientID, + "scope": "openid offline_access username", + }, + ).Encode(), + ).Build(t, happyStateCodec), + ).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusSeeOther, + wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+username&state=` + happyDownstreamState, + wantBody: "", + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: oidcUpstreamUsername, + wantDownstreamIDTokenGroups: nil, // groups scope was not requested + wantDownstreamRequestedScopes: []string{"openid", "username", "offline_access"}, + wantDownstreamGrantedScopes: []string{"openid", "username", "offline_access"}, + wantDownstreamNonce: downstreamNonce, + wantDownstreamClientID: downstreamDynamicClientID, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, + }, // Pre-upstream-exchange verification { @@ -718,7 +880,8 @@ func TestCallbackEndpoint(t *testing.T) { method: http.MethodGet, path: newRequestPath().WithState( happyUpstreamStateParam(). - WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, map[string]string{"prompt": "none login"}).Encode()). + WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, + map[string]string{"prompt": "none login"}).Encode()). Build(t, happyStateCodec), ).String(), csrfCookie: happyCSRFCookie, @@ -759,7 +922,8 @@ func TestCallbackEndpoint(t *testing.T) { method: http.MethodGet, path: newRequestPath().WithState( happyUpstreamStateParam(). - WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, map[string]string{"client_id": ""}).Encode()). + WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, + map[string]string{"client_id": ""}).Encode()). Build(t, happyStateCodec), ).String(), csrfCookie: happyCSRFCookie, @@ -773,7 +937,8 @@ func TestCallbackEndpoint(t *testing.T) { method: http.MethodGet, path: newRequestPath().WithState( happyUpstreamStateParam(). - WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, map[string]string{"client_id": "bogus"}).Encode()). + WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, + map[string]string{"client_id": "bogus"}).Encode()). Build(t, happyStateCodec), ).String(), csrfCookie: happyCSRFCookie, @@ -803,6 +968,64 @@ func TestCallbackEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBody: "Bad Request: error using state downstream auth params\n", }, + { + name: "using dynamic client which is not allowed to request username scope in authorize request but requests it anyway", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + kubeResources: func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) { + oidcClient, secret := testutil.OIDCClientAndStorageSecret(t, + "some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID, + []configv1alpha1.GrantType{"authorization_code", "refresh_token"}, // token exchange not allowed (required to exclude username scope) + []configv1alpha1.Scope{"openid", "offline_access", "groups"}, // username not allowed + downstreamRedirectURI, []string{testutil.HashedPassword1AtGoMinCost}, oidcclientvalidator.Validate) + require.NoError(t, supervisorClient.Tracker().Add(oidcClient)) + require.NoError(t, kubeClient.Tracker().Add(secret)) + }, + method: http.MethodGet, + path: newRequestPath().WithState( + happyUpstreamStateParam().WithAuthorizeRequestParams( + shallowCopyAndModifyQuery( + happyDownstreamRequestParamsQuery, + map[string]string{ + "client_id": downstreamDynamicClientID, + "scope": "openid username", + }, + ).Encode(), + ).Build(t, happyStateCodec), + ).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusBadRequest, + wantContentType: htmlContentType, + wantBody: "Bad Request: error using state downstream auth params\n", + }, + { + name: "using dynamic client which is not allowed to request groups scope in authorize request but requests it anyway", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + kubeResources: func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) { + oidcClient, secret := testutil.OIDCClientAndStorageSecret(t, + "some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID, + []configv1alpha1.GrantType{"authorization_code", "refresh_token"}, // token exchange not allowed (required to exclude groups scope) + []configv1alpha1.Scope{"openid", "offline_access", "username"}, // groups not allowed + downstreamRedirectURI, []string{testutil.HashedPassword1AtGoMinCost}, oidcclientvalidator.Validate) + require.NoError(t, supervisorClient.Tracker().Add(oidcClient)) + require.NoError(t, kubeClient.Tracker().Add(secret)) + }, + method: http.MethodGet, + path: newRequestPath().WithState( + happyUpstreamStateParam().WithAuthorizeRequestParams( + shallowCopyAndModifyQuery( + happyDownstreamRequestParamsQuery, + map[string]string{ + "client_id": downstreamDynamicClientID, + "scope": "openid groups", + }, + ).Encode(), + ).Build(t, happyStateCodec), + ).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusBadRequest, + wantContentType: htmlContentType, + wantBody: "Bad Request: error using state downstream auth params\n", + }, { name: "state's downstream auth params does not contain openid scope", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), @@ -810,16 +1033,17 @@ func TestCallbackEndpoint(t *testing.T) { path: newRequestPath(). WithState( happyUpstreamStateParam(). - WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, map[string]string{"scope": "profile email groups"}).Encode()). + WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, + map[string]string{"scope": "profile username email groups"}).Encode()). Build(t, happyStateCodec), ).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusSeeOther, - wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=groups&state=` + happyDownstreamState, + wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=username\+groups&state=` + happyDownstreamState, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, - wantDownstreamRequestedScopes: []string{"profile", "email", "groups"}, - wantDownstreamGrantedScopes: []string{"groups"}, + wantDownstreamRequestedScopes: []string{"profile", "email", "username", "groups"}, + wantDownstreamGrantedScopes: []string{"username", "groups"}, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamNonce: downstreamNonce, wantDownstreamClientID: downstreamPinnipedClientID, @@ -832,22 +1056,25 @@ func TestCallbackEndpoint(t *testing.T) { }, }, { - name: "state's downstream auth params does not contain openid or groups scope", + name: "state's downstream auth params does not contain openid, username, or groups scope", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), method: http.MethodGet, path: newRequestPath(). WithState( happyUpstreamStateParam(). - WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, map[string]string{"scope": "profile email"}).Encode()). + WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, + map[string]string{"scope": "profile email"}).Encode()). Build(t, happyStateCodec), ).String(), - csrfCookie: happyCSRFCookie, - wantStatus: http.StatusSeeOther, - wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=&state=` + happyDownstreamState, - wantDownstreamIDTokenUsername: oidcUpstreamUsername, - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, - wantDownstreamRequestedScopes: []string{"profile", "email"}, - wantDownstreamGrantedScopes: []string{}, + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusSeeOther, + wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=username\+groups&state=` + happyDownstreamState, + wantDownstreamIDTokenUsername: oidcUpstreamUsername, + wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamRequestedScopes: []string{"profile", "email"}, + // username and groups scopes were not requested but are granted anyway for the pinniped-cli client for backwards compatibility + wantDownstreamGrantedScopes: []string{"username", "groups"}, wantDownstreamNonce: downstreamNonce, wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, @@ -865,16 +1092,17 @@ func TestCallbackEndpoint(t *testing.T) { path: newRequestPath(). WithState( happyUpstreamStateParam(). - WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, map[string]string{"scope": "openid offline_access groups"}).Encode()). + WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, + map[string]string{"scope": "openid offline_access username groups"}).Encode()). Build(t, happyStateCodec), ).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusSeeOther, - wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+groups&state=` + happyDownstreamState, + wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+username\+groups&state=` + happyDownstreamState, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, - wantDownstreamRequestedScopes: []string{"openid", "offline_access", "groups"}, - wantDownstreamGrantedScopes: []string{"openid", "offline_access", "groups"}, + wantDownstreamRequestedScopes: []string{"openid", "offline_access", "username", "groups"}, + wantDownstreamGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamNonce: downstreamNonce, wantDownstreamClientID: downstreamPinnipedClientID, diff --git a/internal/oidc/clientregistry/clientregistry.go b/internal/oidc/clientregistry/clientregistry.go index 90451784..e1d87abb 100644 --- a/internal/oidc/clientregistry/clientregistry.go +++ b/internal/oidc/clientregistry/clientregistry.go @@ -10,25 +10,19 @@ import ( "strings" "time" - "github.com/coreos/go-oidc/v3/oidc" + coreosoidc "github.com/coreos/go-oidc/v3/oidc" "github.com/ory/fosite" "k8s.io/apimachinery/pkg/api/errors" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" + oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc" supervisorclient "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/typed/config/v1alpha1" "go.pinniped.dev/internal/oidc/oidcclientvalidator" "go.pinniped.dev/internal/oidcclientsecretstorage" "go.pinniped.dev/internal/plog" ) -const ( - // PinnipedCLIClientID is the client ID of the statically defined public OIDC client which is used by the CLI. - PinnipedCLIClientID = "pinniped-cli" - - requiredOIDCClientPrefix = "client.oauth.pinniped.dev-" -) - // Client represents a Pinniped OAuth/OIDC client. It can be the static pinniped-cli client // or a dynamic client defined by an OIDCClient CR. type Client struct { @@ -43,7 +37,7 @@ var ( ) func (c *Client) GetResponseModes() []fosite.ResponseModeType { - if c.ID == PinnipedCLIClientID { + if c.ID == oidcapi.ClientIDPinnipedCLI { // The pinniped-cli client supports "" (unspecified), "query", and "form_post" response modes. return []fosite.ResponseModeType{fosite.ResponseModeDefault, fosite.ResponseModeQuery, fosite.ResponseModeFormPost} } @@ -78,12 +72,12 @@ func NewClientManager( // Other errors returned are plain errors, because fosite will wrap them into a new ErrInvalidClient error and // use the plain error's text as that error's debug message (see client_authentication.go in fosite). func (m *ClientManager) GetClient(ctx context.Context, id string) (fosite.Client, error) { - if id == PinnipedCLIClientID { + if id == oidcapi.ClientIDPinnipedCLI { // Return the static client. No lookups needed. return PinnipedCLI(), nil } - if !strings.HasPrefix(id, requiredOIDCClientPrefix) { + if !strings.HasPrefix(id, oidcapi.ClientIDRequiredOIDCClientPrefix) { // It shouldn't really be possible to find this OIDCClient because the OIDCClient CRD validates the name prefix // upon create, but just in case, don't even try to lookup clients which lack the required name prefix. return nil, fosite.ErrNotFound.WithDescription("no such client") @@ -143,22 +137,23 @@ func PinnipedCLI() *Client { return &Client{ DefaultOpenIDConnectClient: fosite.DefaultOpenIDConnectClient{ DefaultClient: &fosite.DefaultClient{ - ID: PinnipedCLIClientID, + ID: oidcapi.ClientIDPinnipedCLI, Secret: nil, RedirectURIs: []string{"http://127.0.0.1/callback"}, GrantTypes: fosite.Arguments{ - "authorization_code", - "refresh_token", - "urn:ietf:params:oauth:grant-type:token-exchange", + oidcapi.GrantTypeAuthorizationCode, + oidcapi.GrantTypeRefreshToken, + oidcapi.GrantTypeTokenExchange, }, ResponseTypes: []string{"code"}, Scopes: fosite.Arguments{ - oidc.ScopeOpenID, - oidc.ScopeOfflineAccess, - "profile", - "email", - "pinniped:request-audience", - "groups", + oidcapi.ScopeOpenID, + oidcapi.ScopeOfflineAccess, + oidcapi.ScopeProfile, + oidcapi.ScopeEmail, + oidcapi.ScopeRequestAudience, + oidcapi.ScopeUsername, + oidcapi.ScopeGroups, }, Audience: nil, Public: true, @@ -167,7 +162,7 @@ func PinnipedCLI() *Client { JSONWebKeys: nil, JSONWebKeysURI: "", RequestObjectSigningAlgorithm: "", - TokenEndpointAuthSigningAlgorithm: oidc.RS256, + TokenEndpointAuthSigningAlgorithm: coreosoidc.RS256, TokenEndpointAuthMethod: "none", }, } @@ -194,7 +189,7 @@ func oidcClientCRToFositeClient(oidcClient *configv1alpha1.OIDCClient, clientSec JSONWebKeys: nil, JSONWebKeysURI: "", RequestObjectSigningAlgorithm: "", - TokenEndpointAuthSigningAlgorithm: oidc.RS256, + TokenEndpointAuthSigningAlgorithm: coreosoidc.RS256, TokenEndpointAuthMethod: "client_secret_basic", }, } diff --git a/internal/oidc/clientregistry/clientregistry_test.go b/internal/oidc/clientregistry/clientregistry_test.go index b0b2e01e..5367d511 100644 --- a/internal/oidc/clientregistry/clientregistry_test.go +++ b/internal/oidc/clientregistry/clientregistry_test.go @@ -269,7 +269,7 @@ func requireEqualsPinnipedCLI(t *testing.T, c *Client) { 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{"code"}, c.GetResponseTypes()) - require.Equal(t, fosite.Arguments{oidc.ScopeOpenID, oidc.ScopeOfflineAccess, "profile", "email", "pinniped:request-audience", "groups"}, c.GetScopes()) + require.Equal(t, fosite.Arguments{oidc.ScopeOpenID, oidc.ScopeOfflineAccess, "profile", "email", "pinniped:request-audience", "username", "groups"}, c.GetScopes()) require.True(t, c.IsPublic()) require.Nil(t, c.GetAudience()) require.Nil(t, c.GetRequestURIs()) @@ -302,6 +302,7 @@ func requireEqualsPinnipedCLI(t *testing.T, c *Client) { "profile", "email", "pinniped:request-audience", + "username", "groups" ], "audience": null, diff --git a/internal/oidc/discovery/discovery_handler.go b/internal/oidc/discovery/discovery_handler.go index f1fb9f82..7c4b9e53 100644 --- a/internal/oidc/discovery/discovery_handler.go +++ b/internal/oidc/discovery/discovery_handler.go @@ -10,6 +10,7 @@ import ( "net/http" "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1" + oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc" "go.pinniped.dev/internal/oidc" ) @@ -68,8 +69,8 @@ func NewHandler(issuerURL string) http.Handler { IDTokenSigningAlgValuesSupported: []string{"ES256"}, TokenEndpointAuthMethodsSupported: []string{"client_secret_basic"}, CodeChallengeMethodsSupported: []string{"S256"}, - ScopesSupported: []string{"openid", "offline"}, - ClaimsSupported: []string{"groups"}, + ScopesSupported: []string{oidcapi.ScopeOpenID, oidcapi.ScopeOfflineAccess, oidcapi.ScopeRequestAudience, oidcapi.ScopeUsername, oidcapi.ScopeGroups}, + ClaimsSupported: []string{oidcapi.IDTokenClaimUsername, oidcapi.IDTokenClaimGroups}, } var b bytes.Buffer diff --git a/internal/oidc/discovery/discovery_handler_test.go b/internal/oidc/discovery/discovery_handler_test.go index f8d8303f..94592e7c 100644 --- a/internal/oidc/discovery/discovery_handler_test.go +++ b/internal/oidc/discovery/discovery_handler_test.go @@ -45,9 +45,9 @@ func TestDiscovery(t *testing.T) { "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["ES256"], "token_endpoint_auth_methods_supported": ["client_secret_basic"], - "scopes_supported": ["openid", "offline"], + "scopes_supported": ["openid", "offline_access", "pinniped:request-audience", "username", "groups"], "code_challenge_methods_supported": ["S256"], - "claims_supported": ["groups"], + "claims_supported": ["username", "groups"], "discovery.supervisor.pinniped.dev/v1alpha1": { "pinniped_identity_providers_endpoint": "https://some-issuer.com/some/path/v1alpha1/pinniped_identity_providers" } diff --git a/internal/oidc/downstreamsession/downstream_session.go b/internal/oidc/downstreamsession/downstream_session.go index fbb0ca52..cec13d1e 100644 --- a/internal/oidc/downstreamsession/downstream_session.go +++ b/internal/oidc/downstreamsession/downstream_session.go @@ -16,6 +16,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/strings/slices" + oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc" "go.pinniped.dev/internal/authenticators" "go.pinniped.dev/internal/constable" "go.pinniped.dev/internal/oidc" @@ -27,7 +28,7 @@ import ( const ( // The name of the email claim from https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims - emailClaimName = "email" + emailClaimName = oidcapi.ScopeEmail // The name of the email_verified claim from https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims emailVerifiedClaimName = "email_verified" @@ -55,11 +56,12 @@ func MakeDownstreamSession(subject string, username string, groups []string, gra if groups == nil { groups = []string{} } - openIDSession.IDTokenClaims().Extra = map[string]interface{}{ - oidc.DownstreamUsernameClaim: username, + openIDSession.IDTokenClaims().Extra = map[string]interface{}{} + if slices.Contains(grantedScopes, oidcapi.ScopeUsername) { + openIDSession.IDTokenClaims().Extra[oidcapi.IDTokenClaimUsername] = username } - if slices.Contains(grantedScopes, oidc.DownstreamGroupsScope) { - openIDSession.IDTokenClaims().Extra[oidc.DownstreamGroupsClaim] = groups + if slices.Contains(grantedScopes, oidcapi.ScopeGroups) { + openIDSession.IDTokenClaims().Extra[oidcapi.IDTokenClaimGroups] = groups } return openIDSession } @@ -68,8 +70,10 @@ func MakeDownstreamLDAPOrADCustomSessionData( ldapUpstream provider.UpstreamLDAPIdentityProviderI, idpType psession.ProviderType, authenticateResponse *authenticators.Response, + username string, ) *psession.CustomSessionData { customSessionData := &psession.CustomSessionData{ + Username: username, ProviderUID: ldapUpstream.GetResourceUID(), ProviderName: ldapUpstream.GetName(), ProviderType: idpType, @@ -92,17 +96,22 @@ func MakeDownstreamLDAPOrADCustomSessionData( return customSessionData } -func MakeDownstreamOIDCCustomSessionData(oidcUpstream provider.UpstreamOIDCIdentityProviderI, token *oidctypes.Token) (*psession.CustomSessionData, error) { - upstreamSubject, err := ExtractStringClaimValue(oidc.IDTokenSubjectClaim, oidcUpstream.GetName(), token.IDToken.Claims) +func MakeDownstreamOIDCCustomSessionData( + oidcUpstream provider.UpstreamOIDCIdentityProviderI, + token *oidctypes.Token, + username string, +) (*psession.CustomSessionData, error) { + upstreamSubject, err := ExtractStringClaimValue(oidcapi.IDTokenClaimSubject, oidcUpstream.GetName(), token.IDToken.Claims) if err != nil { return nil, err } - upstreamIssuer, err := ExtractStringClaimValue(oidc.IDTokenIssuerClaim, oidcUpstream.GetName(), token.IDToken.Claims) + upstreamIssuer, err := ExtractStringClaimValue(oidcapi.IDTokenClaimIssuer, oidcUpstream.GetName(), token.IDToken.Claims) if err != nil { return nil, err } customSessionData := &psession.CustomSessionData{ + Username: username, ProviderUID: oidcUpstream.GetResourceUID(), ProviderName: oidcUpstream.GetName(), ProviderType: psession.ProviderTypeOIDC, @@ -148,11 +157,30 @@ func MakeDownstreamOIDCCustomSessionData(oidcUpstream provider.UpstreamOIDCIdent return customSessionData, nil } -// GrantScopesIfRequested auto-grants the scopes for which we do not require end-user approval, if they were requested. -func GrantScopesIfRequested(authorizeRequester fosite.AuthorizeRequester, scopes []string) { - for _, scope := range scopes { +// AutoApproveScopes auto-grants the scopes which we support and for which we do not require end-user approval, +// if they were requested. This should only be called after it has been validated that the client is allowed to request +// the scopes that it requested (which is a check performed by fosite). +func AutoApproveScopes(authorizeRequester fosite.AuthorizeRequester) { + for _, scope := range []string{ + oidcapi.ScopeOpenID, + oidcapi.ScopeOfflineAccess, + oidcapi.ScopeRequestAudience, + oidcapi.ScopeUsername, + oidcapi.ScopeGroups, + } { oidc.GrantScopeIfRequested(authorizeRequester, scope) } + + // For backwards-compatibility with old pinniped CLI binaries which never request the username and groups scopes + // (because those scopes did not exist yet when those CLIs were released), grant/approve the username and groups + // scopes even if the CLI did not request them. Basically, pretend that the CLI requested them and auto-approve + // them. Newer versions of the CLI binaries will request these scopes, so after enough time has passed that + // we can assume the old versions of the CLI are no longer in use in the wild, then we can remove this code and + // just let the above logic handle all clients. + if authorizeRequester.GetClient().GetID() == oidcapi.ClientIDPinnipedCLI { + authorizeRequester.GrantScope(oidcapi.ScopeUsername) + authorizeRequester.GrantScope(oidcapi.ScopeGroups) + } } // GetDownstreamIdentityFromUpstreamIDToken returns the mapped subject, username, and group names, in that order. @@ -179,11 +207,11 @@ func getSubjectAndUsernameFromUpstreamIDToken( ) (string, string, error) { // The spec says the "sub" claim is only unique per issuer, // so we will prepend the issuer string to make it globally unique. - upstreamIssuer, err := ExtractStringClaimValue(oidc.IDTokenIssuerClaim, upstreamIDPConfig.GetName(), idTokenClaims) + upstreamIssuer, err := ExtractStringClaimValue(oidcapi.IDTokenClaimIssuer, upstreamIDPConfig.GetName(), idTokenClaims) if err != nil { return "", "", err } - upstreamSubject, err := ExtractStringClaimValue(oidc.IDTokenSubjectClaim, upstreamIDPConfig.GetName(), idTokenClaims) + upstreamSubject, err := ExtractStringClaimValue(oidcapi.IDTokenClaimSubject, upstreamIDPConfig.GetName(), idTokenClaims) if err != nil { return "", "", err } @@ -266,13 +294,13 @@ func DownstreamSubjectFromUpstreamLDAP(ldapUpstream provider.UpstreamLDAPIdentit func DownstreamLDAPSubject(uid string, ldapURL url.URL) string { q := ldapURL.Query() - q.Set(oidc.IDTokenSubjectClaim, uid) + q.Set(oidcapi.IDTokenClaimSubject, uid) ldapURL.RawQuery = q.Encode() return ldapURL.String() } func downstreamSubjectFromUpstreamOIDC(upstreamIssuerAsString string, upstreamSubject string) string { - return fmt.Sprintf("%s?%s=%s", upstreamIssuerAsString, oidc.IDTokenSubjectClaim, url.QueryEscape(upstreamSubject)) + return fmt.Sprintf("%s?%s=%s", upstreamIssuerAsString, oidcapi.IDTokenClaimSubject, url.QueryEscape(upstreamSubject)) } // GetGroupsFromUpstreamIDToken returns mapped group names coerced into a slice of strings. diff --git a/internal/oidc/login/post_login_handler.go b/internal/oidc/login/post_login_handler.go index 4c214452..a9fe251a 100644 --- a/internal/oidc/login/post_login_handler.go +++ b/internal/oidc/login/post_login_handler.go @@ -7,7 +7,6 @@ import ( "net/http" "net/url" - coreosoidc "github.com/coreos/go-oidc/v3/oidc" "github.com/ory/fosite" "go.pinniped.dev/internal/httputil/httperr" @@ -46,10 +45,10 @@ func NewPostHandler(issuerURL string, upstreamIDPs oidc.UpstreamIdentityProvider return httperr.New(http.StatusBadRequest, "error using state downstream auth params") } - // Automatically grant the openid, offline_access, pinniped:request-audience and groups scopes, but only if they were requested. + // Automatically grant certain scopes, but only if they were requested. // This is instead of asking the user to approve these scopes. Note that `NewAuthorizeRequest` would have returned // an error if the client requested a scope that they are not allowed to request, so we don't need to worry about that here. - downstreamsession.GrantScopesIfRequested(authorizeRequester, []string{coreosoidc.ScopeOpenID, coreosoidc.ScopeOfflineAccess, oidc.RequestAudienceScope, oidc.DownstreamGroupsScope}) + downstreamsession.AutoApproveScopes(authorizeRequester) // Get the username and password form params from the POST body. username := r.PostFormValue(usernameParamName) @@ -83,7 +82,7 @@ func NewPostHandler(issuerURL string, upstreamIDPs oidc.UpstreamIdentityProvider subject := downstreamsession.DownstreamSubjectFromUpstreamLDAP(ldapUpstream, authenticateResponse) username = authenticateResponse.User.GetName() groups := authenticateResponse.User.GetGroups() - customSessionData := downstreamsession.MakeDownstreamLDAPOrADCustomSessionData(ldapUpstream, idpType, authenticateResponse) + customSessionData := downstreamsession.MakeDownstreamLDAPOrADCustomSessionData(ldapUpstream, idpType, authenticateResponse, username) openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups, authorizeRequester.GetGrantedScopes(), customSessionData) oidc.PerformAuthcodeRedirect(r, w, oauthHelper, authorizeRequester, openIDSession, false) diff --git a/internal/oidc/login/post_login_handler_test.go b/internal/oidc/login/post_login_handler_test.go index 80931ee9..72bce69a 100644 --- a/internal/oidc/login/post_login_handler_test.go +++ b/internal/oidc/login/post_login_handler_test.go @@ -17,10 +17,12 @@ import ( "k8s.io/apiserver/pkg/authentication/user" "k8s.io/client-go/kubernetes/fake" + configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake" "go.pinniped.dev/internal/authenticators" "go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc/jwks" + "go.pinniped.dev/internal/oidc/oidcclientvalidator" "go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil/oidctestutil" @@ -86,8 +88,8 @@ func TestPostLoginEndpoint(t *testing.T) { } ) - happyDownstreamScopesRequested := []string{"openid", "groups"} - happyDownstreamScopesGranted := []string{"openid", "groups"} + happyDownstreamScopesRequested := []string{"openid", "username", "groups"} + happyDownstreamScopesGranted := []string{"openid", "username", "groups"} happyDownstreamRequestParamsQuery := url.Values{ "response_type": []string{"code"}, @@ -192,6 +194,7 @@ func TestPostLoginEndpoint(t *testing.T) { } expectedHappyActiveDirectoryUpstreamCustomSession := &psession.CustomSessionData{ + Username: happyLDAPUsernameFromAuthenticator, ProviderUID: activeDirectoryUpstreamResourceUID, ProviderName: activeDirectoryUpstreamName, ProviderType: psession.ProviderTypeActiveDirectory, @@ -204,6 +207,7 @@ func TestPostLoginEndpoint(t *testing.T) { } expectedHappyLDAPUpstreamCustomSession := &psession.CustomSessionData{ + Username: happyLDAPUsernameFromAuthenticator, ProviderUID: ldapUpstreamResourceUID, ProviderName: ldapUpstreamName, ProviderType: psession.ProviderTypeLDAP, @@ -216,7 +220,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 - happyAuthcodeDownstreamRedirectLocationRegexp := downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+groups&state=` + happyDownstreamState + happyAuthcodeDownstreamRedirectLocationRegexp := downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+username\+groups&state=` + happyDownstreamState happyUsernamePasswordFormParams := url.Values{userParam: []string{happyLDAPUsername}, passParam: []string{happyLDAPPassword}} @@ -237,7 +241,8 @@ func TestPostLoginEndpoint(t *testing.T) { addFullyCapableDynamicClientAndSecretToKubeResources := func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) { oidcClient, secret := testutil.FullyCapableOIDCClientAndStorageSecret(t, - "some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID, downstreamRedirectURI, []string{testutil.HashedPassword1AtGoMinCost}) + "some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID, downstreamRedirectURI, + []string{testutil.HashedPassword1AtGoMinCost}, oidcclientvalidator.Validate) require.NoError(t, supervisorClient.Tracker().Add(oidcClient)) require.NoError(t, kubeClient.Tracker().Add(secret)) } @@ -413,7 +418,7 @@ func TestPostLoginEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantContentType: htmlContentType, wantBodyString: "", - wantRedirectLocationRegexp: "http://127.0.0.1:4242/callback" + `\?code=([^&]+)&scope=openid\+groups&state=` + happyDownstreamState, + wantRedirectLocationRegexp: "http://127.0.0.1:4242/callback" + `\?code=([^&]+)&scope=openid\+username\+groups&state=` + happyDownstreamState, wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID, wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, wantDownstreamIDTokenGroups: happyLDAPGroups, @@ -439,7 +444,7 @@ func TestPostLoginEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantContentType: htmlContentType, wantBodyString: "", - wantRedirectLocationRegexp: "http://127.0.0.1:4242/callback" + `\?code=([^&]+)&scope=openid\+groups&state=` + happyDownstreamState, + wantRedirectLocationRegexp: "http://127.0.0.1:4242/callback" + `\?code=([^&]+)&scope=openid\+username\+groups&state=` + happyDownstreamState, wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID, wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, wantDownstreamIDTokenGroups: happyLDAPGroups, @@ -460,17 +465,18 @@ func TestPostLoginEndpoint(t *testing.T) { map[string]string{"scope": "openid offline_access pinniped:request-audience"}, ).Encode() }), - formParams: happyUsernamePasswordFormParams, - wantStatus: http.StatusSeeOther, - wantContentType: htmlContentType, - wantBodyString: "", - wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+pinniped%3Arequest-audience&state=` + happyDownstreamState, + formParams: happyUsernamePasswordFormParams, + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantBodyString: "", + // username and groups scopes were not requested but are granted anyway for the pinniped-cli client for backwards compatibility + wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+pinniped%3Arequest-audience\+username\+groups&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"}, + wantDownstreamGrantedScopes: []string{"openid", "offline_access", "pinniped:request-audience", "username", "groups"}, wantDownstreamNonce: downstreamNonce, wantDownstreamClient: downstreamPinnipedCLIClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, @@ -478,7 +484,7 @@ func TestPostLoginEndpoint(t *testing.T) { wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, }, { - name: "happy LDAP login when there are additional allowed downstream requested scopes with dynamic client", + name: "happy LDAP login when there are additional allowed downstream requested scopes with dynamic client, when dynamic client is allowed to request username and groups but does not request them", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { @@ -492,8 +498,8 @@ func TestPostLoginEndpoint(t *testing.T) { wantBodyString: "", wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+pinniped%3Arequest-audience&state=` + happyDownstreamState, wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID, - wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, - wantDownstreamIDTokenGroups: happyLDAPGroups, + wantDownstreamIDTokenUsername: "", // username scope was not requested, so there should be no username in the ID token + wantDownstreamIDTokenGroups: []string{}, // groups scope was not requested, so there should be no groups in the ID token wantDownstreamRequestedScopes: []string{"openid", "offline_access", "pinniped:request-audience"}, wantDownstreamRedirectURI: downstreamRedirectURI, wantDownstreamGrantedScopes: []string{"openid", "offline_access", "pinniped:request-audience"}, @@ -503,6 +509,74 @@ func TestPostLoginEndpoint(t *testing.T) { wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, }, + { + name: "happy LDAP login when there are additional allowed downstream requested scopes with dynamic client, when dynamic client is not allowed to request username and does not request username", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + kubeResources: func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) { + oidcClient, secret := testutil.OIDCClientAndStorageSecret(t, + "some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID, + []configv1alpha1.GrantType{"authorization_code", "refresh_token"}, // token exchange not allowed (required to exclude username scope) + []configv1alpha1.Scope{"openid", "offline_access", "groups"}, // username not allowed + downstreamRedirectURI, []string{testutil.HashedPassword1AtGoMinCost}, oidcclientvalidator.Validate) + require.NoError(t, supervisorClient.Tracker().Add(oidcClient)) + require.NoError(t, kubeClient.Tracker().Add(secret)) + }, + decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { + data.AuthParams = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQueryForDynamicClient, + map[string]string{"scope": "openid groups offline_access"}, + ).Encode() + }), + formParams: happyUsernamePasswordFormParams, + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantBodyString: "", + wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+groups&state=` + happyDownstreamState, + wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID, + wantDownstreamIDTokenUsername: "", // username scope was not requested, so there should be no username in the ID token + wantDownstreamIDTokenGroups: happyLDAPGroups, + wantDownstreamRequestedScopes: []string{"openid", "offline_access", "groups"}, + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamGrantedScopes: []string{"openid", "offline_access", "groups"}, + wantDownstreamNonce: downstreamNonce, + wantDownstreamClient: downstreamDynamicClientID, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, + }, + { + name: "happy LDAP login when there are additional allowed downstream requested scopes with dynamic client, when dynamic client is not allowed to request groups and does not request groups", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + kubeResources: func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) { + oidcClient, secret := testutil.OIDCClientAndStorageSecret(t, + "some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID, + []configv1alpha1.GrantType{"authorization_code", "refresh_token"}, // token exchange not allowed (required to exclude groups scope) + []configv1alpha1.Scope{"openid", "offline_access", "username"}, // groups not allowed + downstreamRedirectURI, []string{testutil.HashedPassword1AtGoMinCost}, oidcclientvalidator.Validate) + require.NoError(t, supervisorClient.Tracker().Add(oidcClient)) + require.NoError(t, kubeClient.Tracker().Add(secret)) + }, + decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { + data.AuthParams = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQueryForDynamicClient, + map[string]string{"scope": "openid username offline_access"}, + ).Encode() + }), + formParams: happyUsernamePasswordFormParams, + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantBodyString: "", + wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+username&state=` + happyDownstreamState, + wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID, + wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, + wantDownstreamIDTokenGroups: []string{}, // groups scope was not requested, so there should be no groups in the ID token + wantDownstreamRequestedScopes: []string{"openid", "offline_access", "username"}, + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamGrantedScopes: []string{"openid", "offline_access", "username"}, + wantDownstreamNonce: downstreamNonce, + wantDownstreamClient: downstreamDynamicClientID, + 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), @@ -515,17 +589,18 @@ func TestPostLoginEndpoint(t *testing.T) { }, ).Encode() }), - formParams: happyUsernamePasswordFormParams, - wantStatus: http.StatusSeeOther, - wantContentType: htmlContentType, - wantBodyString: "", - wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=&state=` + happyDownstreamState, // no scopes granted + formParams: happyUsernamePasswordFormParams, + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantBodyString: "", + // username and groups scopes were not requested but are granted anyway for the pinniped-cli client for backwards compatibility + wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=username\+groups&state=` + happyDownstreamState, wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID, wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, wantDownstreamIDTokenGroups: happyLDAPGroups, wantDownstreamRequestedScopes: []string{"email"}, // only email was requested wantDownstreamRedirectURI: downstreamRedirectURI, - wantDownstreamGrantedScopes: []string{}, // no scopes granted + wantDownstreamGrantedScopes: []string{"username", "groups"}, wantDownstreamNonce: downstreamNonce, wantDownstreamClient: downstreamPinnipedCLIClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, @@ -533,7 +608,7 @@ func TestPostLoginEndpoint(t *testing.T) { wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, }, { - name: "happy LDAP login when groups scope is not requested", + name: "happy LDAP login when username and groups scopes are not requested", idps: oidctestutil.NewUpstreamIDPListerBuilder(). WithLDAP(&upstreamLDAPIdentityProvider). // should pick this one WithActiveDirectory(&erroringUpstreamLDAPIdentityProvider), @@ -542,16 +617,18 @@ func TestPostLoginEndpoint(t *testing.T) { map[string]string{"scope": "openid"}, ).Encode() }), - formParams: happyUsernamePasswordFormParams, - wantStatus: http.StatusSeeOther, - wantContentType: htmlContentType, - wantBodyString: "", - wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid&state=` + happyDownstreamState, + formParams: happyUsernamePasswordFormParams, + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantBodyString: "", + // username and groups scopes were not requested but are granted anyway for the pinniped-cli client for backwards compatibility + wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+username\+groups&state=` + happyDownstreamState, wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID, wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, + wantDownstreamIDTokenGroups: happyLDAPGroups, wantDownstreamRequestedScopes: []string{"openid"}, wantDownstreamRedirectURI: downstreamRedirectURI, - wantDownstreamGrantedScopes: []string{"openid"}, + wantDownstreamGrantedScopes: []string{"openid", "username", "groups"}, wantDownstreamNonce: downstreamNonce, wantDownstreamClient: downstreamPinnipedCLIClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, @@ -810,6 +887,46 @@ func TestPostLoginEndpoint(t *testing.T) { formParams: happyUsernamePasswordFormParams, wantErr: "error using state downstream auth params", }, + { + name: "using dynamic client which is not allowed to request username scope in authorize request but requests it anyway", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + kubeResources: func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) { + oidcClient, secret := testutil.OIDCClientAndStorageSecret(t, + "some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID, + []configv1alpha1.GrantType{"authorization_code", "refresh_token"}, // token exchange not allowed (required to exclude username scope) + []configv1alpha1.Scope{"openid", "offline_access", "groups"}, // username not allowed + downstreamRedirectURI, []string{testutil.HashedPassword1AtGoMinCost}, oidcclientvalidator.Validate) + require.NoError(t, supervisorClient.Tracker().Add(oidcClient)) + require.NoError(t, kubeClient.Tracker().Add(secret)) + }, + decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { + data.AuthParams = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQueryForDynamicClient, + map[string]string{"scope": "openid username offline_access"}, + ).Encode() + }), + formParams: happyUsernamePasswordFormParams, + wantErr: "error using state downstream auth params", + }, + { + name: "using dynamic client which is not allowed to request groups scope in authorize request but requests it anyway", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + kubeResources: func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) { + oidcClient, secret := testutil.OIDCClientAndStorageSecret(t, + "some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID, + []configv1alpha1.GrantType{"authorization_code", "refresh_token"}, // token exchange not allowed (required to exclude groups scope) + []configv1alpha1.Scope{"openid", "offline_access", "username"}, // groups not allowed + downstreamRedirectURI, []string{testutil.HashedPassword1AtGoMinCost}, oidcclientvalidator.Validate) + require.NoError(t, supervisorClient.Tracker().Add(oidcClient)) + require.NoError(t, kubeClient.Tracker().Add(secret)) + }, + decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { + data.AuthParams = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQueryForDynamicClient, + map[string]string{"scope": "openid groups offline_access"}, + ).Encode() + }), + formParams: happyUsernamePasswordFormParams, + wantErr: "error using state downstream auth params", + }, { name: "downstream scopes do not match what is configured for client with dynamic client", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), diff --git a/internal/oidc/oidc.go b/internal/oidc/oidc.go index 11218e58..ad87a3ec 100644 --- a/internal/oidc/oidc.go +++ b/internal/oidc/oidc.go @@ -11,13 +11,13 @@ import ( "net/http" "time" - coreosoidc "github.com/coreos/go-oidc/v3/oidc" "github.com/felixge/httpsnoop" "github.com/ory/fosite" "github.com/ory/fosite/compose" errorsx "github.com/pkg/errors" "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1" + oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc" "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/oidc/csrftoken" "go.pinniped.dev/internal/oidc/jwks" @@ -40,16 +40,17 @@ const ( ) const ( - // Just in case we need to make a breaking change to the format of the upstream state param, - // we are including a format version number. This gives the opportunity for a future version of Pinniped - // to have the consumer of this format decide to reject versions that it doesn't understand. + // UpstreamStateParamFormatVersion exists just in case we need to make a breaking change to the format of the + // upstream state param, we are including a format version number. This gives the opportunity for a future version + // of Pinniped to have the consumer of this format decide to reject versions that it doesn't understand. // // Version 1 was the original version. // Version 2 added the UpstreamType field to the UpstreamStateParamData struct. UpstreamStateParamFormatVersion = "2" - // The `name` passed to the encoder for encoding the upstream state param value. This name is short - // because it will be encoded into the upstream state param value and we're trying to keep that small. + // UpstreamStateParamEncodingName is the `name` passed to the encoder for encoding the upstream state param value. + // This name is short because it will be encoded into the upstream state param value, and we're trying to keep that + // small. UpstreamStateParamEncodingName = "s" // CSRFCookieName is the name of the browser cookie which shall hold our CSRF value. @@ -61,29 +62,6 @@ const ( // cookie contents. CSRFCookieEncodingName = "csrf" - // The name of the issuer claim specified in the OIDC spec. - IDTokenIssuerClaim = "iss" - - // The name of the subject claim specified in the OIDC spec. - IDTokenSubjectClaim = "sub" - - // DownstreamUsernameClaim is a custom claim in the downstream ID token - // whose value is mapped from a claim in the upstream token. - // By default the value is the same as the downstream subject claim's. - DownstreamUsernameClaim = "username" - - // DownstreamGroupsClaim is what we will use to encode the groups in the downstream OIDC ID token - // information. - 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 // 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. @@ -229,7 +207,7 @@ func FositeOauth2Helper( EnforcePKCE: true, // "offline_access" as per https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess - RefreshTokenScopes: []string{coreosoidc.ScopeOfflineAccess}, + RefreshTokenScopes: []string{oidcapi.ScopeOfflineAccess}, // The default is to support all prompt values from the spec. // See https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest diff --git a/internal/oidc/oidcclientvalidator/oidcclientvalidator.go b/internal/oidc/oidcclientvalidator/oidcclientvalidator.go index 9a374de8..fd09894c 100644 --- a/internal/oidc/oidcclientvalidator/oidcclientvalidator.go +++ b/internal/oidc/oidcclientvalidator/oidcclientvalidator.go @@ -7,11 +7,11 @@ import ( "fmt" "strings" - "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/crypto/bcrypt" v1 "k8s.io/api/core/v1" "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" + oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc" "go.pinniped.dev/internal/oidcclientsecretstorage" ) @@ -27,16 +27,6 @@ const ( reasonNoClientSecretFound = "NoClientSecretFound" reasonInvalidClientSecretFound = "InvalidClientSecretFound" - authorizationCodeGrantTypeName = "authorization_code" - refreshTokenGrantTypeName = "refresh_token" - tokenExchangeGrantTypeName = "urn:ietf:params:oauth:grant-type:token-exchange" //nolint:gosec // this is not a credential - - openidScopeName = oidc.ScopeOpenID - offlineAccessScopeName = oidc.ScopeOfflineAccess - requestAudienceScopeName = "pinniped:request-audience" - usernameScopeName = "username" - groupsScopeName = "groups" - allowedGrantTypesFieldName = "allowedGrantTypes" allowedScopesFieldName = "allowedScopes" ) @@ -67,21 +57,21 @@ func Validate(oidcClient *v1alpha1.OIDCClient, secret *v1.Secret, minBcryptCost func validateAllowedScopes(oidcClient *v1alpha1.OIDCClient, conditions []*v1alpha1.Condition) []*v1alpha1.Condition { m := make([]string, 0, 4) - if !allowedScopesContains(oidcClient, openidScopeName) { - m = append(m, fmt.Sprintf("%q must always be included in %q", openidScopeName, allowedScopesFieldName)) + if !allowedScopesContains(oidcClient, oidcapi.ScopeOpenID) { + m = append(m, fmt.Sprintf("%q must always be included in %q", oidcapi.ScopeOpenID, allowedScopesFieldName)) } - if allowedGrantTypesContains(oidcClient, refreshTokenGrantTypeName) && !allowedScopesContains(oidcClient, offlineAccessScopeName) { + if allowedGrantTypesContains(oidcClient, oidcapi.GrantTypeRefreshToken) && !allowedScopesContains(oidcClient, oidcapi.ScopeOfflineAccess) { m = append(m, fmt.Sprintf("%q must be included in %q when %q is included in %q", - offlineAccessScopeName, allowedScopesFieldName, refreshTokenGrantTypeName, allowedGrantTypesFieldName)) + oidcapi.ScopeOfflineAccess, allowedScopesFieldName, oidcapi.GrantTypeRefreshToken, allowedGrantTypesFieldName)) } - if allowedScopesContains(oidcClient, requestAudienceScopeName) && - (!allowedScopesContains(oidcClient, usernameScopeName) || !allowedScopesContains(oidcClient, groupsScopeName)) { + if allowedScopesContains(oidcClient, oidcapi.ScopeRequestAudience) && + (!allowedScopesContains(oidcClient, oidcapi.ScopeUsername) || !allowedScopesContains(oidcClient, oidcapi.ScopeGroups)) { m = append(m, fmt.Sprintf("%q and %q must be included in %q when %q is included in %q", - usernameScopeName, groupsScopeName, allowedScopesFieldName, requestAudienceScopeName, allowedScopesFieldName)) + oidcapi.ScopeUsername, oidcapi.ScopeGroups, allowedScopesFieldName, oidcapi.ScopeRequestAudience, allowedScopesFieldName)) } - if allowedGrantTypesContains(oidcClient, tokenExchangeGrantTypeName) && !allowedScopesContains(oidcClient, requestAudienceScopeName) { + if allowedGrantTypesContains(oidcClient, oidcapi.GrantTypeTokenExchange) && !allowedScopesContains(oidcClient, oidcapi.ScopeRequestAudience) { m = append(m, fmt.Sprintf("%q must be included in %q when %q is included in %q", - requestAudienceScopeName, allowedScopesFieldName, tokenExchangeGrantTypeName, allowedGrantTypesFieldName)) + oidcapi.ScopeRequestAudience, allowedScopesFieldName, oidcapi.GrantTypeTokenExchange, allowedGrantTypesFieldName)) } if len(m) == 0 { @@ -107,17 +97,17 @@ func validateAllowedScopes(oidcClient *v1alpha1.OIDCClient, conditions []*v1alph func validateAllowedGrantTypes(oidcClient *v1alpha1.OIDCClient, conditions []*v1alpha1.Condition) []*v1alpha1.Condition { m := make([]string, 0, 3) - if !allowedGrantTypesContains(oidcClient, authorizationCodeGrantTypeName) { + if !allowedGrantTypesContains(oidcClient, oidcapi.GrantTypeAuthorizationCode) { m = append(m, fmt.Sprintf("%q must always be included in %q", - authorizationCodeGrantTypeName, allowedGrantTypesFieldName)) + oidcapi.GrantTypeAuthorizationCode, allowedGrantTypesFieldName)) } - if allowedScopesContains(oidcClient, offlineAccessScopeName) && !allowedGrantTypesContains(oidcClient, refreshTokenGrantTypeName) { + if allowedScopesContains(oidcClient, oidcapi.ScopeOfflineAccess) && !allowedGrantTypesContains(oidcClient, oidcapi.GrantTypeRefreshToken) { m = append(m, fmt.Sprintf("%q must be included in %q when %q is included in %q", - refreshTokenGrantTypeName, allowedGrantTypesFieldName, offlineAccessScopeName, allowedScopesFieldName)) + oidcapi.GrantTypeRefreshToken, allowedGrantTypesFieldName, oidcapi.ScopeOfflineAccess, allowedScopesFieldName)) } - if allowedScopesContains(oidcClient, requestAudienceScopeName) && !allowedGrantTypesContains(oidcClient, tokenExchangeGrantTypeName) { + if allowedScopesContains(oidcClient, oidcapi.ScopeRequestAudience) && !allowedGrantTypesContains(oidcClient, oidcapi.GrantTypeTokenExchange) { m = append(m, fmt.Sprintf("%q must be included in %q when %q is included in %q", - tokenExchangeGrantTypeName, allowedGrantTypesFieldName, requestAudienceScopeName, allowedScopesFieldName)) + oidcapi.GrantTypeTokenExchange, allowedGrantTypesFieldName, oidcapi.ScopeRequestAudience, allowedScopesFieldName)) } if len(m) == 0 { diff --git a/internal/oidc/provider/manager/manager_test.go b/internal/oidc/provider/manager/manager_test.go index 272387e9..919f139c 100644 --- a/internal/oidc/provider/manager/manager_test.go +++ b/internal/oidc/provider/manager/manager_test.go @@ -171,7 +171,7 @@ func TestManager(t *testing.T) { r.NoError(err) actualLocationQueryParams := parsedLocation.Query() r.Contains(actualLocationQueryParams, "code") - r.Equal("openid", actualLocationQueryParams.Get("scope")) + r.Equal("openid username groups", actualLocationQueryParams.Get("scope")) r.Equal("some-state-value-with-enough-bytes-to-exceed-min-allowed", actualLocationQueryParams.Get("state")) // Make sure that we wired up the callback endpoint to use kube storage for fosite sessions. @@ -343,7 +343,7 @@ func TestManager(t *testing.T) { authRequestParams := "?" + url.Values{ "response_type": []string{"code"}, - "scope": []string{"openid profile email"}, + "scope": []string{"openid profile email username groups"}, "client_id": []string{downstreamClientID}, "state": []string{"some-state-value-with-enough-bytes-to-exceed-min-allowed"}, "nonce": []string{"some-nonce-value-with-enough-bytes-to-exceed-min-allowed"}, diff --git a/internal/oidc/token/token_handler.go b/internal/oidc/token/token_handler.go index c0044fc5..ebd7307d 100644 --- a/internal/oidc/token/token_handler.go +++ b/internal/oidc/token/token_handler.go @@ -17,6 +17,7 @@ import ( "k8s.io/apiserver/pkg/warning" "k8s.io/utils/strings/slices" + oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc" "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc/downstreamsession" @@ -39,7 +40,7 @@ func NewHandler( } // Check if we are performing a refresh grant. - if accessRequest.GetGrantTypes().ExactOne("refresh_token") { + if accessRequest.GetGrantTypes().ExactOne(oidcapi.GrantTypeRefreshToken) { // The above call to NewAccessRequest has loaded the session from storage into the accessRequest variable. // The session, requested scopes, and requested audience from the original authorize request was retrieved // from the Kube storage layer and added to the accessRequest. Additionally, the audience and scopes may @@ -54,7 +55,7 @@ func NewHandler( // When we are in the authorization code flow, check if we have any warnings that previous handlers want us // to send to the client to be printed on the CLI. - if accessRequest.GetGrantTypes().ExactOne("authorization_code") { + if accessRequest.GetGrantTypes().ExactOne(oidcapi.GrantTypeAuthorizationCode) { storedSession := accessRequest.GetSession().(*psession.PinnipedSession) customSessionData := storedSession.Custom if customSessionData != nil { @@ -108,20 +109,27 @@ func upstreamRefresh(ctx context.Context, accessRequest fosite.AccessRequester, } grantedScopes := accessRequest.GetGrantedScopes() + clientID := accessRequest.GetClient().GetID() switch customSessionData.ProviderType { case psession.ProviderTypeOIDC: - return upstreamOIDCRefresh(ctx, session, providerCache, grantedScopes) + return upstreamOIDCRefresh(ctx, session, providerCache, grantedScopes, clientID) case psession.ProviderTypeLDAP: - return upstreamLDAPRefresh(ctx, providerCache, session, grantedScopes) + return upstreamLDAPRefresh(ctx, providerCache, session, grantedScopes, clientID) case psession.ProviderTypeActiveDirectory: - return upstreamLDAPRefresh(ctx, providerCache, session, grantedScopes) + return upstreamLDAPRefresh(ctx, providerCache, session, grantedScopes, clientID) default: return errorsx.WithStack(errMissingUpstreamSessionInternalError()) } } -func upstreamOIDCRefresh(ctx context.Context, session *psession.PinnipedSession, providerCache oidc.UpstreamIdentityProvidersLister, grantedScopes []string) error { +func upstreamOIDCRefresh( + ctx context.Context, + session *psession.PinnipedSession, + providerCache oidc.UpstreamIdentityProvidersLister, + grantedScopes []string, + clientID string, +) error { s := session.Custom if s.OIDC == nil { return errorsx.WithStack(errMissingUpstreamSessionInternalError()) @@ -180,7 +188,7 @@ func upstreamOIDCRefresh(ctx context.Context, session *psession.PinnipedSession, return err } - groupsScope := slices.Contains(grantedScopes, oidc.DownstreamGroupsScope) + groupsScope := slices.Contains(grantedScopes, oidcapi.ScopeGroups) if groupsScope { //nolint:nestif // If possible, update the user's group memberships. The configured groups claim name (if there is one) may or // may not be included in the newly fetched and merged claims. It could be missing due to a misconfiguration of the @@ -204,8 +212,8 @@ func upstreamOIDCRefresh(ctx context.Context, session *psession.PinnipedSession, if err != nil { return err } - warnIfGroupsChanged(ctx, oldGroups, refreshedGroups, username) - session.Fosite.Claims.Extra[oidc.DownstreamGroupsClaim] = refreshedGroups + warnIfGroupsChanged(ctx, oldGroups, refreshedGroups, username, clientID) + session.Fosite.Claims.Extra[oidcapi.IDTokenClaimGroups] = refreshedGroups } } @@ -240,7 +248,7 @@ func validateIdentityUnchangedSinceInitialLogin(mergedClaims map[string]interfac return nil } - newSub, hasSub := getString(mergedClaims, oidc.IDTokenSubjectClaim) + newSub, hasSub := getString(mergedClaims, oidcapi.IDTokenClaimSubject) if !hasSub { return errUpstreamRefreshError().WithHintf( "Upstream refresh failed.").WithTrace(errors.New("subject in upstream refresh not found")). @@ -253,7 +261,10 @@ func validateIdentityUnchangedSinceInitialLogin(mergedClaims map[string]interfac } newUsername, hasUsername := getString(mergedClaims, usernameClaimName) - oldUsername := session.Fosite.Claims.Extra[oidc.DownstreamUsernameClaim] + oldUsername, err := getDownstreamUsernameFromPinnipedSession(session) + if err != nil { + return err + } // It's possible that a username wasn't returned by the upstream provider during refresh, // but if it is, verify that it hasn't changed. if hasUsername && oldUsername != newUsername { @@ -262,7 +273,7 @@ func validateIdentityUnchangedSinceInitialLogin(mergedClaims map[string]interfac WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType) } - newIssuer, hasIssuer := getString(mergedClaims, oidc.IDTokenIssuerClaim) + newIssuer, hasIssuer := getString(mergedClaims, oidcapi.IDTokenClaimIssuer) // It's possible that an issuer wasn't returned by the upstream provider during refresh, // but if it is, verify that it hasn't changed. if hasIssuer && s.OIDC.UpstreamIssuer != newIssuer { @@ -297,14 +308,20 @@ func findOIDCProviderByNameAndValidateUID( WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType)) } -func upstreamLDAPRefresh(ctx context.Context, providerCache oidc.UpstreamIdentityProvidersLister, session *psession.PinnipedSession, grantedScopes []string) error { +func upstreamLDAPRefresh( + ctx context.Context, + providerCache oidc.UpstreamIdentityProvidersLister, + session *psession.PinnipedSession, + grantedScopes []string, + clientID string, +) error { username, err := getDownstreamUsernameFromPinnipedSession(session) if err != nil { return err } subject := session.Fosite.Claims.Subject var oldGroups []string - if slices.Contains(grantedScopes, oidc.DownstreamGroupsScope) { + if slices.Contains(grantedScopes, oidcapi.ScopeGroups) { oldGroups, err = getDownstreamGroupsFromPinnipedSession(session) if err != nil { return err @@ -349,12 +366,11 @@ func upstreamLDAPRefresh(ctx context.Context, providerCache oidc.UpstreamIdentit "Upstream refresh failed.").WithTrace(err). WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType) } - groupsScope := slices.Contains(grantedScopes, oidc.DownstreamGroupsScope) + groupsScope := slices.Contains(grantedScopes, oidcapi.ScopeGroups) if groupsScope { + warnIfGroupsChanged(ctx, oldGroups, groups, username, clientID) // Replace the old value with the new value. - session.Fosite.Claims.Extra[oidc.DownstreamGroupsClaim] = groups - - warnIfGroupsChanged(ctx, oldGroups, groups, username) + session.Fosite.Claims.Extra[oidcapi.IDTokenClaimGroups] = groups } return nil @@ -391,16 +407,8 @@ func findLDAPProviderByNameAndValidateUID( } func getDownstreamUsernameFromPinnipedSession(session *psession.PinnipedSession) (string, error) { - extra := session.Fosite.Claims.Extra - if extra == nil { - return "", errorsx.WithStack(errMissingUpstreamSessionInternalError()) - } - downstreamUsernameInterface := extra[oidc.DownstreamUsernameClaim] - if downstreamUsernameInterface == nil { - return "", errorsx.WithStack(errMissingUpstreamSessionInternalError()) - } - downstreamUsername, ok := downstreamUsernameInterface.(string) - if !ok || len(downstreamUsername) == 0 { + downstreamUsername := session.Custom.Username + if len(downstreamUsername) == 0 { return "", errorsx.WithStack(errMissingUpstreamSessionInternalError()) } return downstreamUsername, nil @@ -411,7 +419,7 @@ func getDownstreamGroupsFromPinnipedSession(session *psession.PinnipedSession) ( if extra == nil { return nil, errorsx.WithStack(errMissingUpstreamSessionInternalError()) } - downstreamGroupsInterface := extra[oidc.DownstreamGroupsClaim] + downstreamGroupsInterface := extra[oidcapi.IDTokenClaimGroups] if downstreamGroupsInterface == nil { return nil, errorsx.WithStack(errMissingUpstreamSessionInternalError()) } @@ -431,8 +439,17 @@ func getDownstreamGroupsFromPinnipedSession(session *psession.PinnipedSession) ( return downstreamGroups, nil } -func warnIfGroupsChanged(ctx context.Context, oldGroups, newGroups []string, username string) { +func warnIfGroupsChanged(ctx context.Context, oldGroups, newGroups []string, username string, clientID string) { + if clientID != oidcapi.ClientIDPinnipedCLI { + // Only send these warnings to the CLI client. They are intended for kubectl to print to the screen. + // A webapp using a dynamic client wouldn't know to look for these special warning headers, and + // if the dynamic client lacked the username scope, then these warning messages would be leaking + // the user's username to the client within the text of the warning. + return + } + added, removed := diffSortedGroups(oldGroups, newGroups) + if len(added) > 0 { warning.AddWarning(ctx, "", fmt.Sprintf("User %q has been added to the following groups: %q", username, added)) } diff --git a/internal/oidc/token/token_handler_test.go b/internal/oidc/token/token_handler_test.go index b22e8ad4..efecad95 100644 --- a/internal/oidc/token/token_handler_test.go +++ b/internal/oidc/token/token_handler_test.go @@ -36,6 +36,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/types" + "k8s.io/apiserver/pkg/warning" "k8s.io/client-go/kubernetes/fake" v1 "k8s.io/client-go/kubernetes/typed/core/v1" @@ -52,6 +53,7 @@ import ( "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc/jwks" + "go.pinniped.dev/internal/oidc/oidcclientvalidator" "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/oidcclientsecretstorage" "go.pinniped.dev/internal/psession" @@ -236,7 +238,7 @@ var ( happyAuthRequest = &http.Request{ Form: url.Values{ "response_type": {"code"}, - "scope": {"openid profile email groups"}, + "scope": {"openid profile email username groups"}, "client_id": {pinnipedCLIClientID}, "state": {"some-state-value-with-enough-bytes-to-exceed-min-allowed"}, "nonce": {goodNonce}, @@ -277,10 +279,12 @@ type tokenEndpointResponseExpectedValues struct { wantClientID string wantRequestedScopes []string wantGrantedScopes []string + wantUsername string wantGroups []string wantUpstreamRefreshCall *expectedUpstreamRefresh wantUpstreamOIDCValidateTokenCall *expectedUpstreamValidateTokens wantCustomSessionDataStored *psession.CustomSessionData + wantWarnings []RecordedWarning } type authcodeExchangeInputs struct { @@ -303,6 +307,7 @@ func addFullyCapableDynamicClientAndSecretToKubeResources(t *testing.T, supervis dynamicClientUID, goodRedirectURI, []string{testutil.HashedPassword1AtGoMinCost, testutil.HashedPassword2AtGoMinCost}, + oidcclientvalidator.Validate, ) require.NoError(t, supervisorClient.Tracker().Add(oidcClient)) require.NoError(t, kubeClient.Tracker().Add(secret)) @@ -327,13 +332,14 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { { name: "request is valid and tokens are issued", authcodeExchange: authcodeExchangeInputs{ - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid profile email groups") }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid profile email username groups") }, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "scope", "expires_in"}, // no refresh token - wantRequestedScopes: []string{"openid", "profile", "email", "groups"}, - wantGrantedScopes: []string{"openid", "groups"}, + wantRequestedScopes: []string{"openid", "profile", "email", "username", "groups"}, + wantGrantedScopes: []string{"openid", "username", "groups"}, + wantUsername: goodUsername, wantGroups: goodGroups, }, }, @@ -344,15 +350,16 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { authcodeExchange: authcodeExchangeInputs{ modifyAuthRequest: func(r *http.Request) { addDynamicClientIDToFormPostBody(r) - r.Form.Set("scope", "openid pinniped:request-audience groups") + r.Form.Set("scope", "openid pinniped:request-audience username groups") }, modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, wantClientID: dynamicClientID, wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "scope", "expires_in"}, // no refresh token - wantRequestedScopes: []string{"openid", "pinniped:request-audience", "groups"}, - wantGrantedScopes: []string{"openid", "pinniped:request-audience", "groups"}, + wantRequestedScopes: []string{"openid", "pinniped:request-audience", "username", "groups"}, + wantGrantedScopes: []string{"openid", "pinniped:request-audience", "username", "groups"}, + wantUsername: goodUsername, wantGroups: goodGroups, }, }, @@ -366,8 +373,9 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"access_token", "token_type", "scope", "expires_in"}, // no id or refresh tokens wantRequestedScopes: []string{"profile", "email"}, - wantGrantedScopes: []string{}, - wantGroups: nil, + wantGrantedScopes: []string{"username", "groups"}, // username and groups were not requested, but granted anyway for backwards compatibility + wantUsername: goodUsername, + wantGroups: goodGroups, }, }, }, @@ -377,21 +385,22 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { authcodeExchange: authcodeExchangeInputs{ modifyAuthRequest: func(r *http.Request) { addDynamicClientIDToFormPostBody(r) - r.Form.Set("scope", "pinniped:request-audience groups") + r.Form.Set("scope", "pinniped:request-audience username groups") }, modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, wantClientID: dynamicClientID, wantSuccessBodyFields: []string{"access_token", "token_type", "scope", "expires_in"}, // no id or refresh tokens - wantRequestedScopes: []string{"pinniped:request-audience", "groups"}, - wantGrantedScopes: []string{"pinniped:request-audience", "groups"}, - wantGroups: nil, + wantRequestedScopes: []string{"pinniped:request-audience", "username", "groups"}, + wantGrantedScopes: []string{"pinniped:request-audience", "username", "groups"}, + wantUsername: goodUsername, + wantGroups: goodGroups, }, }, }, { - name: "offline_access and openid scopes were requested and granted from authorize endpoint (no groups)", + name: "offline_access and openid scopes were requested and granted from authorize endpoint (no username or groups requested)", authcodeExchange: authcodeExchangeInputs{ modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, want: tokenEndpointResponseExpectedValues{ @@ -399,30 +408,52 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "scope", "expires_in", "refresh_token"}, // all possible tokens wantRequestedScopes: []string{"openid", "offline_access"}, - wantGrantedScopes: []string{"openid", "offline_access"}, - wantGroups: nil, + wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, // username and groups were not requested, but granted anyway for backwards compatibility + wantUsername: goodUsername, + wantGroups: goodGroups, }, }, }, { - name: "offline_access and openid scopes were requested and granted from authorize endpoint for dynamic client (no groups)", + name: "openid, offline_access, and username scopes (no groups) were requested and granted from authorize endpoint for dynamic client", kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, authcodeExchange: authcodeExchangeInputs{ modifyAuthRequest: func(r *http.Request) { addDynamicClientIDToFormPostBody(r) - r.Form.Set("scope", "openid offline_access") + r.Form.Set("scope", "openid offline_access username") }, modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, wantClientID: dynamicClientID, wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "scope", "expires_in", "refresh_token"}, // all possible tokens - wantRequestedScopes: []string{"openid", "offline_access"}, - wantGrantedScopes: []string{"openid", "offline_access"}, + wantRequestedScopes: []string{"openid", "offline_access", "username"}, + wantGrantedScopes: []string{"openid", "offline_access", "username"}, + wantUsername: goodUsername, wantGroups: nil, }, }, }, + { + name: "openid, offline_access, and groups scopes (no username) were requested and granted from authorize endpoint for dynamic client", + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + authcodeExchange: authcodeExchangeInputs{ + modifyAuthRequest: func(r *http.Request) { + addDynamicClientIDToFormPostBody(r) + r.Form.Set("scope", "openid offline_access groups") + }, + modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantClientID: dynamicClientID, + wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "scope", "expires_in", "refresh_token"}, // all possible tokens + wantRequestedScopes: []string{"openid", "offline_access", "groups"}, + wantGrantedScopes: []string{"openid", "offline_access", "groups"}, + wantUsername: "", + wantGroups: goodGroups, + }, + }, + }, { name: "offline_access (without openid scope) was requested and granted from authorize endpoint", authcodeExchange: authcodeExchangeInputs{ @@ -432,13 +463,14 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"access_token", "token_type", "scope", "expires_in", "refresh_token"}, // no id token wantRequestedScopes: []string{"offline_access"}, - wantGrantedScopes: []string{"offline_access"}, - wantGroups: nil, + wantGrantedScopes: []string{"offline_access", "username", "groups"}, // username and groups were not requested, but granted anyway for backwards compatibility + wantUsername: goodUsername, + wantGroups: goodGroups, }, }, }, { - name: "offline_access (without openid scope) was requested and granted from authorize endpoint for dynamic client", + name: "offline_access (without openid, username, groups scopes) was requested and granted from authorize endpoint for dynamic client", kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, authcodeExchange: authcodeExchangeInputs{ modifyAuthRequest: func(r *http.Request) { @@ -452,20 +484,22 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { wantSuccessBodyFields: []string{"access_token", "token_type", "scope", "expires_in", "refresh_token"}, // no id token wantRequestedScopes: []string{"offline_access"}, wantGrantedScopes: []string{"offline_access"}, + wantUsername: "", wantGroups: nil, }, }, }, { - name: "groups scope is requested", + name: "username and groups scopes are requested", authcodeExchange: authcodeExchangeInputs{ - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid profile email groups") }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid profile email username groups") }, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "scope", "expires_in"}, // no refresh token - wantRequestedScopes: []string{"openid", "profile", "email", "groups"}, - wantGrantedScopes: []string{"openid", "groups"}, + wantRequestedScopes: []string{"openid", "profile", "email", "username", "groups"}, + wantGrantedScopes: []string{"openid", "username", "groups"}, + wantUsername: goodUsername, wantGroups: goodGroups, }, }, @@ -783,13 +817,14 @@ func TestTokenEndpointWhenAuthcodeIsUsedTwice(t *testing.T) { { name: "authcode exchange succeeds once and then fails when the same authcode is used again", authcodeExchange: authcodeExchangeInputs{ - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access profile email groups") }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access profile email username groups") }, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"openid", "offline_access", "profile", "email", "groups"}, - wantGrantedScopes: []string{"openid", "offline_access", "groups"}, + wantRequestedScopes: []string{"openid", "offline_access", "profile", "email", "username", "groups"}, + wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, + wantUsername: goodUsername, wantGroups: goodGroups, }, }, @@ -832,9 +867,10 @@ func TestTokenEndpointWhenAuthcodeIsUsedTwice(t *testing.T) { // Note that customSessionData is only relevant to refresh grant, so we leave it as nil for this // authcode exchange test, even though in practice it would actually be in the session. requireValidOIDCStorage(t, parsedResponseBody, authCode, oauthStore, - test.authcodeExchange.want.wantClientID, test.authcodeExchange.want.wantRequestedScopes, - test.authcodeExchange.want.wantGrantedScopes, test.authcodeExchange.want.wantGroups, nil, - approxRequestTime) + test.authcodeExchange.want.wantClientID, + test.authcodeExchange.want.wantRequestedScopes, test.authcodeExchange.want.wantGrantedScopes, + test.authcodeExchange.want.wantUsername, test.authcodeExchange.want.wantGroups, + nil, approxRequestTime) // Check that the access token and refresh token storage were both deleted, and the number of other storage objects did not change. testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: authorizationcode.TypeLabelValue}, 1) @@ -853,8 +889,9 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn wantStatus: http.StatusOK, wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"openid", "pinniped:request-audience", "groups"}, - wantGrantedScopes: []string{"openid", "pinniped:request-audience", "groups"}, + wantRequestedScopes: []string{"openid", "pinniped:request-audience", "username", "groups"}, + wantGrantedScopes: []string{"openid", "pinniped:request-audience", "username", "groups"}, + wantUsername: goodUsername, wantGroups: goodGroups, } @@ -862,14 +899,15 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn wantStatus: http.StatusOK, wantClientID: dynamicClientID, wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"openid", "pinniped:request-audience", "groups"}, - wantGrantedScopes: []string{"openid", "pinniped:request-audience", "groups"}, + wantRequestedScopes: []string{"openid", "pinniped:request-audience", "username", "groups"}, + wantGrantedScopes: []string{"openid", "pinniped:request-audience", "username", "groups"}, + wantUsername: goodUsername, wantGroups: goodGroups, } doValidAuthCodeExchange := authcodeExchangeInputs{ modifyAuthRequest: func(authRequest *http.Request) { - authRequest.Form.Set("scope", "openid pinniped:request-audience groups") + authRequest.Form.Set("scope", "openid pinniped:request-audience username groups") }, want: successfulAuthCodeExchange, } @@ -877,7 +915,7 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn doValidAuthCodeExchangeUsingDynamicClient := authcodeExchangeInputs{ modifyAuthRequest: func(authRequest *http.Request) { addDynamicClientIDToFormPostBody(authRequest) - authRequest.Form.Set("scope", "openid pinniped:request-audience groups") + authRequest.Form.Set("scope", "openid pinniped:request-audience username groups") }, modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, want: successfulAuthCodeExchangeUsingDynamicClient, @@ -902,6 +940,25 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn requestedAudience: "some-workload-cluster", wantStatus: http.StatusOK, }, + { + name: "happy path without requesting username and groups scopes", + authcodeExchange: authcodeExchangeInputs{ + modifyAuthRequest: func(authRequest *http.Request) { + authRequest.Form.Set("scope", "openid pinniped:request-audience") + }, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, + wantSuccessBodyFields: []string{"access_token", "token_type", "expires_in", "scope", "id_token"}, + wantRequestedScopes: []string{"openid", "pinniped:request-audience"}, + wantGrantedScopes: []string{"openid", "pinniped:request-audience", "username", "groups"}, // username and groups were not requested, but granted anyway for backwards compatibility + wantUsername: goodUsername, + wantGroups: goodGroups, + }, + }, + requestedAudience: "some-workload-cluster", + wantStatus: http.StatusOK, + }, { name: "happy path with dynamic client", kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, @@ -915,6 +972,34 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn requestedAudience: "some-workload-cluster", wantStatus: http.StatusOK, }, + { + name: "happy path with dynamic client without requesting groups, so gets no groups in ID tokens", + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + authcodeExchange: authcodeExchangeInputs{ + modifyAuthRequest: func(authRequest *http.Request) { + addDynamicClientIDToFormPostBody(authRequest) + authRequest.Form.Set("scope", "openid pinniped:request-audience username") // don't request groups scope + }, + modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantClientID: dynamicClientID, + wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"}, + wantRequestedScopes: []string{"openid", "pinniped:request-audience", "username"}, // don't want groups scope + wantGrantedScopes: []string{"openid", "pinniped:request-audience", "username"}, // don't want groups scope + wantUsername: goodUsername, + wantGroups: nil, + }, + }, + modifyRequestParams: func(t *testing.T, params url.Values) { + params.Del("client_id") // client auth for dynamic clients must be in basic auth header + }, + modifyRequestHeaders: func(r *http.Request) { + r.SetBasicAuth(dynamicClientID, testutil.PlaintextPassword1) + }, + requestedAudience: "some-workload-cluster", + wantStatus: http.StatusOK, + }, { name: "dynamic client lacks the required urn:ietf:params:oauth:grant-type:token-exchange grant type", kubeResources: func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) { @@ -934,15 +1019,16 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn authcodeExchange: authcodeExchangeInputs{ modifyAuthRequest: func(authRequest *http.Request) { addDynamicClientIDToFormPostBody(authRequest) - authRequest.Form.Set("scope", "openid groups") // don't request pinniped:request-audience scope + authRequest.Form.Set("scope", "openid username groups") // don't request pinniped:request-audience scope }, modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, wantClientID: dynamicClientID, wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"openid", "groups"}, // don't want pinniped:request-audience scope - wantGrantedScopes: []string{"openid", "groups"}, // don't want pinniped:request-audience scope + wantRequestedScopes: []string{"openid", "username", "groups"}, // don't want pinniped:request-audience scope + wantGrantedScopes: []string{"openid", "username", "groups"}, // don't want pinniped:request-audience scope + wantUsername: goodUsername, wantGroups: goodGroups, }, }, @@ -962,15 +1048,16 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn authcodeExchange: authcodeExchangeInputs{ modifyAuthRequest: func(authRequest *http.Request) { addDynamicClientIDToFormPostBody(authRequest) - authRequest.Form.Set("scope", "openid groups") // don't request pinniped:request-audience scope + authRequest.Form.Set("scope", "openid username groups") // don't request pinniped:request-audience scope }, modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, wantClientID: dynamicClientID, wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"openid", "groups"}, // don't want pinniped:request-audience scope - wantGrantedScopes: []string{"openid", "groups"}, // don't want pinniped:request-audience scope + wantRequestedScopes: []string{"openid", "username", "groups"}, // don't want pinniped:request-audience scope + wantGrantedScopes: []string{"openid", "username", "groups"}, // don't want pinniped:request-audience scope + wantUsername: goodUsername, wantGroups: goodGroups, }, }, @@ -990,16 +1077,17 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn authcodeExchange: authcodeExchangeInputs{ modifyAuthRequest: func(authRequest *http.Request) { addDynamicClientIDToFormPostBody(authRequest) - authRequest.Form.Set("scope", "pinniped:request-audience groups") // don't request openid scope + authRequest.Form.Set("scope", "pinniped:request-audience username groups") // don't request openid scope }, modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, wantClientID: dynamicClientID, wantSuccessBodyFields: []string{"access_token", "token_type", "expires_in", "scope"}, // no id token - wantRequestedScopes: []string{"pinniped:request-audience", "groups"}, // don't want openid scope - wantGrantedScopes: []string{"pinniped:request-audience", "groups"}, // don't want openid scope - wantGroups: nil, + wantRequestedScopes: []string{"pinniped:request-audience", "username", "groups"}, // don't want openid scope + wantGrantedScopes: []string{"pinniped:request-audience", "username", "groups"}, // don't want openid scope + wantUsername: goodUsername, + wantGroups: goodGroups, }, }, modifyRequestParams: func(t *testing.T, params url.Values) { @@ -1012,6 +1100,35 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn wantStatus: http.StatusForbidden, wantResponseBodyContains: `The resource owner or authorization server denied the request. missing the 'openid' scope`, }, + { + name: "dynamic client did not ask for the username scope in the original authorization request, so the session during token exchange has no username associated with it", + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + authcodeExchange: authcodeExchangeInputs{ + modifyAuthRequest: func(authRequest *http.Request) { + addDynamicClientIDToFormPostBody(authRequest) + authRequest.Form.Set("scope", "openid pinniped:request-audience groups") // don't request username scope + }, + modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantClientID: dynamicClientID, + wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"}, + wantRequestedScopes: []string{"openid", "pinniped:request-audience", "groups"}, // no username scope + wantGrantedScopes: []string{"openid", "pinniped:request-audience", "groups"}, // no username scope + wantUsername: "", + wantGroups: goodGroups, + }, + }, + modifyRequestParams: func(t *testing.T, params url.Values) { + params.Del("client_id") // client auth for dynamic clients must be in basic auth header + }, + modifyRequestHeaders: func(r *http.Request) { + r.SetBasicAuth(dynamicClientID, testutil.PlaintextPassword1) + }, + requestedAudience: "some-workload-cluster", + wantStatus: http.StatusForbidden, + wantResponseBodyContains: `The resource owner or authorization server denied the request. No username found in session. Ensure that the 'username' scope was requested and granted at the authorization endpoint.`, + }, { name: "missing audience", authcodeExchange: doValidAuthCodeExchange, @@ -1160,14 +1277,15 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn name: "access token missing pinniped:request-audience scope", authcodeExchange: authcodeExchangeInputs{ modifyAuthRequest: func(authRequest *http.Request) { - authRequest.Form.Set("scope", "openid groups") + authRequest.Form.Set("scope", "openid username groups") }, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"openid", "groups"}, - wantGrantedScopes: []string{"openid", "groups"}, + wantRequestedScopes: []string{"openid", "username", "groups"}, + wantGrantedScopes: []string{"openid", "username", "groups"}, + wantUsername: goodUsername, wantGroups: goodGroups, }, }, @@ -1179,44 +1297,27 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn name: "access token missing openid scope", authcodeExchange: authcodeExchangeInputs{ modifyAuthRequest: func(authRequest *http.Request) { - authRequest.Form.Set("scope", "pinniped:request-audience groups") + authRequest.Form.Set("scope", "pinniped:request-audience username groups") }, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"access_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"pinniped:request-audience", "groups"}, - wantGrantedScopes: []string{"pinniped:request-audience", "groups"}, - wantGroups: nil, + wantRequestedScopes: []string{"pinniped:request-audience", "username", "groups"}, + wantGrantedScopes: []string{"pinniped:request-audience", "username", "groups"}, + wantUsername: goodUsername, + wantGroups: goodGroups, }, }, requestedAudience: "some-workload-cluster", wantStatus: http.StatusForbidden, wantResponseBodyContains: `missing the 'openid' scope`, }, - { - name: "access token missing groups scope", - authcodeExchange: authcodeExchangeInputs{ - modifyAuthRequest: func(authRequest *http.Request) { - authRequest.Form.Set("scope", "openid pinniped:request-audience") - }, - want: tokenEndpointResponseExpectedValues{ - wantStatus: http.StatusOK, - wantClientID: pinnipedCLIClientID, - wantSuccessBodyFields: []string{"access_token", "token_type", "expires_in", "scope", "id_token"}, - wantRequestedScopes: []string{"openid", "pinniped:request-audience"}, - wantGrantedScopes: []string{"openid", "pinniped:request-audience"}, - wantGroups: nil, - }, - }, - requestedAudience: "some-workload-cluster", - wantStatus: http.StatusOK, - }, { name: "token minting failure", authcodeExchange: authcodeExchangeInputs{ modifyAuthRequest: func(authRequest *http.Request) { - authRequest.Form.Set("scope", "openid pinniped:request-audience groups") + authRequest.Form.Set("scope", "openid pinniped:request-audience username groups") }, // Fail to fetch a JWK signing key after the authcode exchange has happened. makeOathHelper: makeOauthHelperWithJWTKeyThatWorksOnlyOnce, @@ -1313,7 +1414,11 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn require.Contains(t, tokenClaims["aud"], test.requestedAudience) require.Equal(t, goodSubject, tokenClaims["sub"]) require.Equal(t, goodIssuer, tokenClaims["iss"]) - require.Equal(t, goodUsername, tokenClaims["username"]) + if test.authcodeExchange.want.wantUsername != "" { + require.Equal(t, test.authcodeExchange.want.wantUsername, tokenClaims["username"]) + } else { + require.Nil(t, tokenClaims["username"]) + } if test.authcodeExchange.want.wantGroups != nil { require.Equal(t, toSliceOfInterface(test.authcodeExchange.want.wantGroups), tokenClaims["groups"]) } else { @@ -1381,6 +1486,7 @@ func TestRefreshGrant(t *testing.T) { initialUpstreamOIDCRefreshTokenCustomSessionData := func() *psession.CustomSessionData { return &psession.CustomSessionData{ + Username: goodUsername, ProviderName: oidcUpstreamName, ProviderUID: oidcUpstreamResourceUID, ProviderType: oidcUpstreamType, @@ -1394,6 +1500,7 @@ func TestRefreshGrant(t *testing.T) { initialUpstreamOIDCAccessTokenCustomSessionData := func() *psession.CustomSessionData { return &psession.CustomSessionData{ + Username: goodUsername, ProviderName: oidcUpstreamName, ProviderUID: oidcUpstreamResourceUID, ProviderType: oidcUpstreamType, @@ -1463,9 +1570,10 @@ func TestRefreshGrant(t *testing.T) { wantStatus: http.StatusOK, wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"openid", "offline_access", "groups"}, - wantGrantedScopes: []string{"openid", "offline_access", "groups"}, + wantRequestedScopes: []string{"openid", "offline_access", "username", "groups"}, + wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, wantCustomSessionDataStored: wantCustomSessionDataStored, + wantUsername: goodUsername, wantGroups: goodGroups, } return want @@ -1526,6 +1634,7 @@ func TestRefreshGrant(t *testing.T) { } happyActiveDirectoryCustomSessionData := &psession.CustomSessionData{ + Username: goodUsername, ProviderUID: activeDirectoryUpstreamResourceUID, ProviderName: activeDirectoryUpstreamName, ProviderType: activeDirectoryUpstreamType, @@ -1533,7 +1642,9 @@ func TestRefreshGrant(t *testing.T) { UserDN: activeDirectoryUpstreamDN, }, } + happyLDAPCustomSessionData := &psession.CustomSessionData{ + Username: goodUsername, ProviderUID: ldapUpstreamResourceUID, ProviderName: ldapUpstreamName, ProviderType: ldapUpstreamType, @@ -1541,6 +1652,21 @@ func TestRefreshGrant(t *testing.T) { UserDN: ldapUpstreamDN, }, } + + happyAuthcodeExchangeInputsForOIDCUpstream := authcodeExchangeInputs{ + customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access username groups") }, + want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()), + } + + happyAuthcodeExchangeInputsForLDAPUpstream := authcodeExchangeInputs{ + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access username groups") }, + customSessionData: happyLDAPCustomSessionData, + want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( + happyLDAPCustomSessionData, + ), + } + tests := []struct { name string idps *oidctestutil.UpstreamIDPListerBuilder @@ -1559,11 +1685,7 @@ func TestRefreshGrant(t *testing.T) { }, }, }).WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), - authcodeExchange: authcodeExchangeInputs{ - customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()), - }, + authcodeExchange: happyAuthcodeExchangeInputsForOIDCUpstream, refreshRequest: refreshRequestInputs{ want: happyRefreshTokenResponseForOpenIDAndOfflineAccess( upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), @@ -1586,7 +1708,7 @@ func TestRefreshGrant(t *testing.T) { customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), modifyAuthRequest: func(r *http.Request) { addDynamicClientIDToFormPostBody(r) - r.Form.Set("scope", "openid offline_access groups") + r.Form.Set("scope", "openid offline_access username groups") }, modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, want: withWantDynamicClientID(happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData())), @@ -1599,6 +1721,53 @@ func TestRefreshGrant(t *testing.T) { )), }, }, + { + name: "happy path refresh grant with upstream username claim but without downstream username scope granted, using dynamic client", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + upstreamOIDCIdentityProviderBuilder().WithUsernameClaim("username-claim").WithValidatedAndMergedWithUserInfoTokens(&oidctypes.Token{ + IDToken: &oidctypes.IDToken{ + Claims: map[string]interface{}{ + "some-claim": "some-value", + "sub": goodUpstreamSubject, + "username-claim": goodUsername, + }, + }, + }).WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + authcodeExchange: authcodeExchangeInputs{ + customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), + modifyAuthRequest: func(r *http.Request) { + addDynamicClientIDToFormPostBody(r) + r.Form.Set("scope", "openid offline_access groups") + }, + modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, + want: withWantDynamicClientID(tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantClientID: dynamicClientID, + wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, + wantRequestedScopes: []string{"openid", "offline_access", "groups"}, + wantGrantedScopes: []string{"openid", "offline_access", "groups"}, + wantCustomSessionDataStored: initialUpstreamOIDCRefreshTokenCustomSessionData(), + wantUsername: "", + wantGroups: goodGroups, + }), + }, + refreshRequest: refreshRequestInputs{ + modifyTokenRequest: modifyRefreshTokenRequestWithDynamicClientAuth, + want: withWantDynamicClientID(tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantClientID: dynamicClientID, + wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, + wantRequestedScopes: []string{"openid", "offline_access", "groups"}, + wantGrantedScopes: []string{"openid", "offline_access", "groups"}, + wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), + wantUsername: "", + wantGroups: goodGroups, + wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), + wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true), + }), + }, + }, { name: "refresh grant with unchanged username claim", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( @@ -1611,11 +1780,7 @@ func TestRefreshGrant(t *testing.T) { }, }, }).WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), - authcodeExchange: authcodeExchangeInputs{ - customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()), - }, + authcodeExchange: happyAuthcodeExchangeInputsForOIDCUpstream, refreshRequest: refreshRequestInputs{ want: happyRefreshTokenResponseForOpenIDAndOfflineAccess( upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), @@ -1641,7 +1806,7 @@ func TestRefreshGrant(t *testing.T) { }).WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), authcodeExchange: authcodeExchangeInputs{ customSessionData: initialUpstreamOIDCAccessTokenCustomSessionData(), - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access username groups") }, want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCAccessTokenCustomSessionData()), }, refreshRequest: refreshRequestInputs{ @@ -1649,8 +1814,9 @@ func TestRefreshGrant(t *testing.T) { wantStatus: http.StatusOK, wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"refresh_token", "id_token", "access_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"openid", "offline_access", "groups"}, - wantGrantedScopes: []string{"openid", "offline_access", "groups"}, + wantRequestedScopes: []string{"openid", "offline_access", "username", "groups"}, + wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, + wantUsername: goodUsername, wantGroups: goodGroups, wantUpstreamOIDCValidateTokenCall: &expectedUpstreamValidateTokens{ oidcUpstreamName, @@ -1682,8 +1848,10 @@ func TestRefreshGrant(t *testing.T) { wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"offline_access"}, - wantGrantedScopes: []string{"offline_access"}, + wantGrantedScopes: []string{"offline_access", "username", "groups"}, // username and groups were not requested, but granted anyway for backwards compatibility wantCustomSessionDataStored: initialUpstreamOIDCRefreshTokenCustomSessionData(), + wantUsername: goodUsername, + wantGroups: goodGroups, }, }, refreshRequest: refreshRequestInputs{ @@ -1692,10 +1860,12 @@ func TestRefreshGrant(t *testing.T) { wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"offline_access"}, - wantGrantedScopes: []string{"offline_access"}, + wantGrantedScopes: []string{"offline_access", "username", "groups"}, wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithRefreshTokenWithoutIDToken(), false), wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), + wantUsername: goodUsername, + wantGroups: goodGroups, }, }, }, @@ -1707,18 +1877,15 @@ func TestRefreshGrant(t *testing.T) { Claims: map[string]interface{}{}, }, }).WithRefreshedTokens(refreshedUpstreamTokensWithRefreshTokenWithoutIDToken()).Build()), - authcodeExchange: authcodeExchangeInputs{ - customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()), - }, + authcodeExchange: happyAuthcodeExchangeInputsForOIDCUpstream, refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"refresh_token", "access_token", "id_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"openid", "offline_access", "groups"}, - wantGrantedScopes: []string{"openid", "offline_access", "groups"}, + wantRequestedScopes: []string{"openid", "offline_access", "username", "groups"}, + wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, + wantUsername: goodUsername, wantGroups: goodGroups, wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithRefreshTokenWithoutIDToken(), false), @@ -1737,22 +1904,61 @@ func TestRefreshGrant(t *testing.T) { }, }, }).WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), - authcodeExchange: authcodeExchangeInputs{ - customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()), - }, + authcodeExchange: happyAuthcodeExchangeInputsForOIDCUpstream, refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"refresh_token", "access_token", "id_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"openid", "offline_access", "groups"}, - wantGrantedScopes: []string{"openid", "offline_access", "groups"}, + wantRequestedScopes: []string{"openid", "offline_access", "username", "groups"}, + wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, + wantUsername: goodUsername, wantGroups: []string{"new-group1", "new-group2", "new-group3"}, wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true), wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), + wantWarnings: []RecordedWarning{ + {Text: `User "some-username" has been added to the following groups: ["new-group1" "new-group2" "new-group3"]`}, + {Text: `User "some-username" has been removed from the following groups: ["group1" "groups2"]`}, + }, + }, + }, + }, + { + name: "happy path refresh grant when the upstream refresh returns new group memberships (as strings) from the merged ID token and userinfo results, it updates groups, using dynamic client - updates groups without outputting warnings", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + upstreamOIDCIdentityProviderBuilder().WithGroupsClaim("my-groups-claim").WithValidatedAndMergedWithUserInfoTokens(&oidctypes.Token{ + IDToken: &oidctypes.IDToken{ + Claims: map[string]interface{}{ + "sub": goodUpstreamSubject, + "my-groups-claim": []string{"new-group1", "new-group2", "new-group3"}, // refreshed claims includes updated groups + }, + }, + }).WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + authcodeExchange: authcodeExchangeInputs{ + customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), + modifyAuthRequest: func(r *http.Request) { + addDynamicClientIDToFormPostBody(r) + r.Form.Set("scope", "openid offline_access username groups") + }, + modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, + want: withWantDynamicClientID(happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData())), + }, + refreshRequest: refreshRequestInputs{ + modifyTokenRequest: modifyRefreshTokenRequestWithDynamicClientAuth, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantClientID: dynamicClientID, + wantSuccessBodyFields: []string{"refresh_token", "access_token", "id_token", "token_type", "expires_in", "scope"}, + wantRequestedScopes: []string{"openid", "offline_access", "username", "groups"}, + wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, + wantUsername: goodUsername, + wantGroups: []string{"new-group1", "new-group2", "new-group3"}, + wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), + wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true), + wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), + wantWarnings: nil, // dynamic clients should not get these warnings which are intended for the pinniped-cli client }, }, }, @@ -1767,22 +1973,23 @@ func TestRefreshGrant(t *testing.T) { }, }, }).WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), - authcodeExchange: authcodeExchangeInputs{ - customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()), - }, + authcodeExchange: happyAuthcodeExchangeInputsForOIDCUpstream, refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"refresh_token", "access_token", "id_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"openid", "offline_access", "groups"}, - wantGrantedScopes: []string{"openid", "offline_access", "groups"}, + wantRequestedScopes: []string{"openid", "offline_access", "username", "groups"}, + wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, + wantUsername: goodUsername, wantGroups: []string{"new-group1", "new-group2", "new-group3"}, wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true), wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), + wantWarnings: []RecordedWarning{ + {Text: `User "some-username" has been added to the following groups: ["new-group1" "new-group2" "new-group3"]`}, + {Text: `User "some-username" has been removed from the following groups: ["group1" "groups2"]`}, + }, }, }, }, @@ -1797,22 +2004,22 @@ func TestRefreshGrant(t *testing.T) { }, }, }).WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), - authcodeExchange: authcodeExchangeInputs{ - customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()), - }, + authcodeExchange: happyAuthcodeExchangeInputsForOIDCUpstream, refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"refresh_token", "access_token", "id_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"openid", "offline_access", "groups"}, - wantGrantedScopes: []string{"openid", "offline_access", "groups"}, + wantRequestedScopes: []string{"openid", "offline_access", "username", "groups"}, + wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, + wantUsername: goodUsername, wantGroups: []string{}, // the user no longer belongs to any groups wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true), wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), + wantWarnings: []RecordedWarning{ + {Text: `User "some-username" has been removed from the following groups: ["group1" "groups2"]`}, + }, }, }, }, @@ -1827,18 +2034,15 @@ func TestRefreshGrant(t *testing.T) { }, }, }).WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), - authcodeExchange: authcodeExchangeInputs{ - customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()), - }, + authcodeExchange: happyAuthcodeExchangeInputsForOIDCUpstream, refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"refresh_token", "access_token", "id_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"openid", "offline_access", "groups"}, - wantGrantedScopes: []string{"openid", "offline_access", "groups"}, + wantRequestedScopes: []string{"openid", "offline_access", "username", "groups"}, + wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, + wantUsername: goodUsername, wantGroups: goodGroups, // the same groups as from the initial login wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true), @@ -1854,23 +2058,56 @@ func TestRefreshGrant(t *testing.T) { URL: ldapUpstreamURL, PerformRefreshGroups: []string{"new-group1", "new-group2", "new-group3"}, }), - authcodeExchange: authcodeExchangeInputs{ - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, - customSessionData: happyLDAPCustomSessionData, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( - happyLDAPCustomSessionData, - ), - }, + authcodeExchange: happyAuthcodeExchangeInputsForLDAPUpstream, refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"refresh_token", "access_token", "id_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"openid", "offline_access", "groups"}, - wantGrantedScopes: []string{"openid", "offline_access", "groups"}, + wantRequestedScopes: []string{"openid", "offline_access", "username", "groups"}, + wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, + wantUsername: goodUsername, wantGroups: []string{"new-group1", "new-group2", "new-group3"}, wantUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(), wantCustomSessionDataStored: happyLDAPCustomSessionData, + wantWarnings: []RecordedWarning{ + {Text: `User "some-username" has been added to the following groups: ["new-group1" "new-group2" "new-group3"]`}, + {Text: `User "some-username" has been removed from the following groups: ["group1" "groups2"]`}, + }, + }, + }, + }, + { + name: "happy path refresh grant when the upstream refresh returns new group memberships from LDAP, it updates groups, using dynamic client - updates groups without outputting warnings", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{ + Name: ldapUpstreamName, + ResourceUID: ldapUpstreamResourceUID, + URL: ldapUpstreamURL, + PerformRefreshGroups: []string{"new-group1", "new-group2", "new-group3"}, + }), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + authcodeExchange: authcodeExchangeInputs{ + customSessionData: happyLDAPCustomSessionData, + modifyAuthRequest: func(r *http.Request) { + addDynamicClientIDToFormPostBody(r) + r.Form.Set("scope", "openid offline_access username groups") + }, + modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, + want: withWantDynamicClientID(happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(happyLDAPCustomSessionData)), + }, + refreshRequest: refreshRequestInputs{ + modifyTokenRequest: modifyRefreshTokenRequestWithDynamicClientAuth, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantClientID: dynamicClientID, + wantSuccessBodyFields: []string{"refresh_token", "access_token", "id_token", "token_type", "expires_in", "scope"}, + wantRequestedScopes: []string{"openid", "offline_access", "username", "groups"}, + wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, + wantUsername: goodUsername, + wantGroups: []string{"new-group1", "new-group2", "new-group3"}, + wantUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(), + wantCustomSessionDataStored: happyLDAPCustomSessionData, + wantWarnings: nil, // dynamic clients should not get these warnings which are intended for the pinniped-cli client }, }, }, @@ -1882,33 +2119,31 @@ func TestRefreshGrant(t *testing.T) { URL: ldapUpstreamURL, PerformRefreshGroups: []string{}, }), - authcodeExchange: authcodeExchangeInputs{ - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, - customSessionData: happyLDAPCustomSessionData, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( - happyLDAPCustomSessionData, - ), - }, + authcodeExchange: happyAuthcodeExchangeInputsForLDAPUpstream, refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"refresh_token", "access_token", "id_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"openid", "offline_access", "groups"}, - wantGrantedScopes: []string{"openid", "offline_access", "groups"}, + wantRequestedScopes: []string{"openid", "offline_access", "username", "groups"}, + wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, + wantUsername: goodUsername, wantGroups: []string{}, wantUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(), wantCustomSessionDataStored: happyLDAPCustomSessionData, + wantWarnings: []RecordedWarning{ + {Text: `User "some-username" has been removed from the following groups: ["group1" "groups2"]`}, + }, }, }, }, { - name: "ldap refresh grant when the upstream refresh when groups scope not requested on original request or refresh", + name: "ldap refresh grant when the upstream refresh when username and groups scopes are not requested on original request or refresh", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{ Name: ldapUpstreamName, ResourceUID: ldapUpstreamResourceUID, URL: ldapUpstreamURL, - PerformRefreshGroups: []string{}, + PerformRefreshGroups: []string{"new-group1", "new-group2", "new-group3"}, }), authcodeExchange: authcodeExchangeInputs{ modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, @@ -1918,9 +2153,10 @@ func TestRefreshGrant(t *testing.T) { wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"openid", "offline_access"}, - wantGrantedScopes: []string{"openid", "offline_access"}, + wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, // username and groups were not requested, but granted anyway for backwards compatibility wantCustomSessionDataStored: happyLDAPCustomSessionData, - wantGroups: nil, + wantUsername: goodUsername, + wantGroups: goodGroups, }, }, refreshRequest: refreshRequestInputs{ @@ -1932,15 +2168,20 @@ func TestRefreshGrant(t *testing.T) { wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"refresh_token", "access_token", "id_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"openid", "offline_access"}, - wantGrantedScopes: []string{"openid", "offline_access"}, - wantGroups: nil, + wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, // username and groups were not requested, but granted anyway for backwards compatibility + wantUsername: goodUsername, + wantGroups: []string{"new-group1", "new-group2", "new-group3"}, wantUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(), wantCustomSessionDataStored: happyLDAPCustomSessionData, + wantWarnings: []RecordedWarning{ + {Text: `User "some-username" has been added to the following groups: ["new-group1" "new-group2" "new-group3"]`}, + {Text: `User "some-username" has been removed from the following groups: ["group1" "groups2"]`}, + }, }, }, }, { - name: "oidc refresh grant when the upstream refresh when groups scope not requested on original request or refresh", + name: "oidc refresh grant when the upstream refresh when username and groups scopes are not requested on original request or refresh", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( upstreamOIDCIdentityProviderBuilder().WithGroupsClaim("my-groups-claim").WithValidatedAndMergedWithUserInfoTokens(&oidctypes.Token{ IDToken: &oidctypes.IDToken{ @@ -1958,9 +2199,10 @@ func TestRefreshGrant(t *testing.T) { wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"openid", "offline_access"}, - wantGrantedScopes: []string{"openid", "offline_access"}, + wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, // username and groups were not requested, but granted anyway for backwards compatibility wantCustomSessionDataStored: initialUpstreamOIDCRefreshTokenCustomSessionData(), - wantGroups: nil, + wantUsername: goodUsername, + wantGroups: goodGroups, }, }, refreshRequest: refreshRequestInputs{ @@ -1972,11 +2214,16 @@ func TestRefreshGrant(t *testing.T) { wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"refresh_token", "access_token", "id_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"openid", "offline_access"}, - wantGrantedScopes: []string{"openid", "offline_access"}, - wantGroups: nil, + wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, // username and groups were not requested, but granted anyway for backwards compatibility + wantUsername: goodUsername, + wantGroups: []string{"new-group1", "new-group2", "new-group3"}, wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true), wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), + wantWarnings: []RecordedWarning{ + {Text: `User "some-username" has been added to the following groups: ["new-group1" "new-group2" "new-group3"]`}, + {Text: `User "some-username" has been removed from the following groups: ["group1" "groups2"]`}, + }, }, }, }, @@ -1995,7 +2242,7 @@ func TestRefreshGrant(t *testing.T) { authcodeExchange: authcodeExchangeInputs{ modifyAuthRequest: func(r *http.Request) { addDynamicClientIDToFormPostBody(r) - r.Form.Set("scope", "openid offline_access") + r.Form.Set("scope", "openid offline_access username") }, modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), @@ -2003,23 +2250,25 @@ func TestRefreshGrant(t *testing.T) { wantStatus: http.StatusOK, wantClientID: dynamicClientID, wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"openid", "offline_access"}, - wantGrantedScopes: []string{"openid", "offline_access"}, + wantRequestedScopes: []string{"openid", "offline_access", "username"}, + wantGrantedScopes: []string{"openid", "offline_access", "username"}, wantCustomSessionDataStored: initialUpstreamOIDCRefreshTokenCustomSessionData(), + wantUsername: goodUsername, wantGroups: nil, }, }, refreshRequest: refreshRequestInputs{ modifyTokenRequest: func(r *http.Request, refreshToken string, accessToken string) { - r.Body = happyRefreshRequestBody(refreshToken).WithClientID("").WithScope("openid offline_access").ReadCloser() + r.Body = happyRefreshRequestBody(refreshToken).WithClientID("").WithScope("openid offline_access username").ReadCloser() r.SetBasicAuth(dynamicClientID, testutil.PlaintextPassword1) // Use basic auth header instead. }, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, wantClientID: dynamicClientID, wantSuccessBodyFields: []string{"refresh_token", "access_token", "id_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"openid", "offline_access"}, - wantGrantedScopes: []string{"openid", "offline_access"}, + wantRequestedScopes: []string{"openid", "offline_access", "username"}, + wantGrantedScopes: []string{"openid", "offline_access", "username"}, + wantUsername: goodUsername, wantGroups: nil, wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true), @@ -2038,15 +2287,16 @@ func TestRefreshGrant(t *testing.T) { PerformRefreshGroups: []string{"new-group1", "new-group2", "new-group3"}, }), authcodeExchange: authcodeExchangeInputs{ - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access username groups") }, customSessionData: happyLDAPCustomSessionData, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"openid", "offline_access", "groups"}, - wantGrantedScopes: []string{"openid", "offline_access", "groups"}, + wantRequestedScopes: []string{"openid", "offline_access", "username", "groups"}, + wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, wantCustomSessionDataStored: happyLDAPCustomSessionData, + wantUsername: goodUsername, wantGroups: goodGroups, }, }, @@ -2058,11 +2308,16 @@ func TestRefreshGrant(t *testing.T) { wantStatus: http.StatusOK, wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"refresh_token", "access_token", "id_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"openid", "offline_access", "groups"}, - wantGrantedScopes: []string{"openid", "offline_access", "groups"}, + wantRequestedScopes: []string{"openid", "offline_access", "username", "groups"}, + wantGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, + wantUsername: goodUsername, wantGroups: []string{"new-group1", "new-group2", "new-group3"}, // groups are updated even though the scope was not included wantUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(), wantCustomSessionDataStored: happyLDAPCustomSessionData, + wantWarnings: []RecordedWarning{ + {Text: `User "some-username" has been added to the following groups: ["new-group1" "new-group2" "new-group3"]`}, + {Text: `User "some-username" has been removed from the following groups: ["group1" "groups2"]`}, + }, }, }, }, @@ -2077,11 +2332,7 @@ func TestRefreshGrant(t *testing.T) { }, }, }).WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), - authcodeExchange: authcodeExchangeInputs{ - customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()), - }, + authcodeExchange: happyAuthcodeExchangeInputsForOIDCUpstream, refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusUnauthorized, @@ -2101,11 +2352,7 @@ func TestRefreshGrant(t *testing.T) { }, }, }).WithRefreshedTokens(refreshedUpstreamTokensWithIDTokenWithoutRefreshToken()).Build()), - authcodeExchange: authcodeExchangeInputs{ - customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()), - }, + authcodeExchange: happyAuthcodeExchangeInputsForOIDCUpstream, refreshRequest: refreshRequestInputs{ want: happyRefreshTokenResponseForOpenIDAndOfflineAccess( initialUpstreamOIDCRefreshTokenCustomSessionData(), // still has the initial refresh token stored @@ -2123,11 +2370,7 @@ func TestRefreshGrant(t *testing.T) { }, }, }).WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), - authcodeExchange: authcodeExchangeInputs{ - customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()), - }, + authcodeExchange: happyAuthcodeExchangeInputsForOIDCUpstream, refreshRequest: refreshRequestInputs{ modifyTokenRequest: func(r *http.Request, refreshToken string, accessToken string) { r.Body = happyRefreshRequestBody(refreshToken).WithScope("openid some-other-scope-not-from-auth-request").ReadCloser() @@ -2150,13 +2393,16 @@ func TestRefreshGrant(t *testing.T) { }).WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), authcodeExchange: authcodeExchangeInputs{ customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access pinniped:request-audience groups") }, + modifyAuthRequest: func(r *http.Request) { + r.Form.Set("scope", "openid offline_access pinniped:request-audience username groups") + }, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"openid", "offline_access", "pinniped:request-audience", "groups"}, - wantGrantedScopes: []string{"openid", "offline_access", "pinniped:request-audience", "groups"}, + wantRequestedScopes: []string{"openid", "offline_access", "pinniped:request-audience", "username", "groups"}, + wantGrantedScopes: []string{"openid", "offline_access", "pinniped:request-audience", "username", "groups"}, + wantUsername: goodUsername, wantGroups: goodGroups, wantCustomSessionDataStored: initialUpstreamOIDCRefreshTokenCustomSessionData(), }, @@ -2169,8 +2415,9 @@ func TestRefreshGrant(t *testing.T) { wantStatus: http.StatusOK, wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"openid", "offline_access", "pinniped:request-audience", "groups"}, - wantGrantedScopes: []string{"openid", "offline_access", "pinniped:request-audience", "groups"}, + wantRequestedScopes: []string{"openid", "offline_access", "pinniped:request-audience", "username", "groups"}, + wantGrantedScopes: []string{"openid", "offline_access", "pinniped:request-audience", "username", "groups"}, + wantUsername: goodUsername, wantGroups: goodGroups, wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true), @@ -2188,11 +2435,7 @@ func TestRefreshGrant(t *testing.T) { }, }, }).WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), - authcodeExchange: authcodeExchangeInputs{ - customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()), - }, + authcodeExchange: happyAuthcodeExchangeInputsForOIDCUpstream, refreshRequest: refreshRequestInputs{ modifyTokenRequest: func(r *http.Request, refreshToken string, accessToken string) { r.Body = happyRefreshRequestBody(refreshToken).WithScope("").ReadCloser() @@ -2208,14 +2451,16 @@ func TestRefreshGrant(t *testing.T) { idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), authcodeExchange: authcodeExchangeInputs{ customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "offline_access") }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "offline_access username groups") }, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"offline_access"}, - wantGrantedScopes: []string{"offline_access"}, + wantRequestedScopes: []string{"offline_access", "username", "groups"}, + wantGrantedScopes: []string{"offline_access", "username", "groups"}, wantCustomSessionDataStored: initialUpstreamOIDCRefreshTokenCustomSessionData(), + wantUsername: goodUsername, + wantGroups: goodGroups, }, }, refreshRequest: refreshRequestInputs{ @@ -2233,14 +2478,16 @@ func TestRefreshGrant(t *testing.T) { idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), authcodeExchange: authcodeExchangeInputs{ customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "offline_access") }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "offline_access username groups") }, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"offline_access"}, - wantGrantedScopes: []string{"offline_access"}, + wantRequestedScopes: []string{"offline_access", "username", "groups"}, + wantGrantedScopes: []string{"offline_access", "username", "groups"}, wantCustomSessionDataStored: initialUpstreamOIDCRefreshTokenCustomSessionData(), + wantUsername: goodUsername, + wantGroups: goodGroups, }, }, refreshRequest: refreshRequestInputs{ @@ -2258,14 +2505,16 @@ func TestRefreshGrant(t *testing.T) { idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), authcodeExchange: authcodeExchangeInputs{ customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "offline_access") }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "offline_access username groups") }, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"offline_access"}, - wantGrantedScopes: []string{"offline_access"}, + wantRequestedScopes: []string{"offline_access", "username", "groups"}, + wantGrantedScopes: []string{"offline_access", "username", "groups"}, wantCustomSessionDataStored: initialUpstreamOIDCRefreshTokenCustomSessionData(), + wantUsername: goodUsername, + wantGroups: goodGroups, }, }, refreshRequest: refreshRequestInputs{ @@ -2288,15 +2537,8 @@ func TestRefreshGrant(t *testing.T) { }, }, }).WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), - kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, - authcodeExchange: authcodeExchangeInputs{ - // Make the auth request and authcode exchange request using the pinniped-cli client. - customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), - modifyAuthRequest: func(r *http.Request) { - r.Form.Set("scope", "openid offline_access groups") - }, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()), - }, + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + authcodeExchange: happyAuthcodeExchangeInputsForOIDCUpstream, // Make the auth request and authcode exchange request using the pinniped-cli client. refreshRequest: refreshRequestInputs{ // Make the refresh request with the dynamic client. modifyTokenRequest: modifyRefreshTokenRequestWithDynamicClientAuth, @@ -2321,7 +2563,7 @@ func TestRefreshGrant(t *testing.T) { customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), modifyAuthRequest: func(r *http.Request) { addDynamicClientIDToFormPostBody(r) - r.Form.Set("scope", "openid offline_access groups") + r.Form.Set("scope", "openid offline_access username groups") }, modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, want: withWantDynamicClientID(happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData())), @@ -2352,7 +2594,7 @@ func TestRefreshGrant(t *testing.T) { customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), modifyAuthRequest: func(r *http.Request) { addDynamicClientIDToFormPostBody(r) - r.Form.Set("scope", "openid offline_access groups") + r.Form.Set("scope", "openid offline_access username groups") }, modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, want: withWantDynamicClientID(happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData())), @@ -2373,7 +2615,7 @@ func TestRefreshGrant(t *testing.T) { idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), authcodeExchange: authcodeExchangeInputs{ customSessionData: nil, // this should not happen in practice - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access username groups") }, want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(nil), }, refreshRequest: refreshRequestInputs{ @@ -2393,7 +2635,7 @@ func TestRefreshGrant(t *testing.T) { ProviderType: oidcUpstreamType, OIDC: &psession.OIDCSessionData{UpstreamRefreshToken: oidcUpstreamInitialRefreshToken}, }, - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access username groups") }, want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( &psession.CustomSessionData{ // want the initial customSessionData to be unmodified ProviderName: "", // this should not happen in practice @@ -2420,7 +2662,7 @@ func TestRefreshGrant(t *testing.T) { ProviderType: oidcUpstreamType, OIDC: &psession.OIDCSessionData{UpstreamRefreshToken: oidcUpstreamInitialRefreshToken}, }, - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access username groups") }, want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( &psession.CustomSessionData{ // want the initial customSessionData to be unmodified ProviderName: oidcUpstreamName, @@ -2447,7 +2689,7 @@ func TestRefreshGrant(t *testing.T) { ProviderType: "", // this should not happen in practice OIDC: &psession.OIDCSessionData{UpstreamRefreshToken: oidcUpstreamInitialRefreshToken}, }, - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access username groups") }, want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( &psession.CustomSessionData{ // want the initial customSessionData to be unmodified ProviderName: oidcUpstreamName, @@ -2474,7 +2716,7 @@ func TestRefreshGrant(t *testing.T) { ProviderType: "not-an-allowed-provider-type", // this should not happen in practice OIDC: &psession.OIDCSessionData{UpstreamRefreshToken: oidcUpstreamInitialRefreshToken}, }, - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access username groups") }, want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( &psession.CustomSessionData{ // want the initial customSessionData to be unmodified ProviderName: oidcUpstreamName, @@ -2501,7 +2743,7 @@ func TestRefreshGrant(t *testing.T) { ProviderType: oidcUpstreamType, OIDC: nil, // this should not happen in practice }, - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access username groups") }, want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( &psession.CustomSessionData{ // want the initial customSessionData to be unmodified ProviderName: oidcUpstreamName, @@ -2531,7 +2773,7 @@ func TestRefreshGrant(t *testing.T) { UpstreamAccessToken: "", }, }, - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access username groups") }, want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( &psession.CustomSessionData{ // want the initial customSessionData to be unmodified ProviderName: oidcUpstreamName, @@ -2561,7 +2803,7 @@ func TestRefreshGrant(t *testing.T) { ProviderType: oidcUpstreamType, OIDC: &psession.OIDCSessionData{UpstreamRefreshToken: oidcUpstreamInitialRefreshToken}, }, - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access username groups") }, want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( &psession.CustomSessionData{ // want the initial customSessionData to be unmodified ProviderName: "this-name-will-not-be-found", // this could happen if the OIDCIdentityProvider was deleted since original login @@ -2593,7 +2835,7 @@ func TestRefreshGrant(t *testing.T) { ProviderType: oidcUpstreamType, OIDC: &psession.OIDCSessionData{UpstreamRefreshToken: oidcUpstreamInitialRefreshToken}, }, - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access username groups") }, want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( &psession.CustomSessionData{ // want the initial customSessionData to be unmodified ProviderName: oidcUpstreamName, @@ -2619,11 +2861,7 @@ func TestRefreshGrant(t *testing.T) { name: "when the upstream refresh fails during the refresh request", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder(). WithPerformRefreshError(errors.New("some upstream refresh error")).Build()), - authcodeExchange: authcodeExchangeInputs{ - customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()), - }, + authcodeExchange: happyAuthcodeExchangeInputsForOIDCUpstream, refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), @@ -2644,11 +2882,7 @@ func TestRefreshGrant(t *testing.T) { // This is the current format of the errors returned by the production code version of ValidateTokenAndMergeWithUserInfo, see ValidateTokenAndMergeWithUserInfo in upstreamoidc.go WithValidateTokenAndMergeWithUserInfoError(httperr.Wrap(http.StatusBadRequest, "some validate error", errors.New("some validate cause"))). Build()), - authcodeExchange: authcodeExchangeInputs{ - customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()), - }, + authcodeExchange: happyAuthcodeExchangeInputsForOIDCUpstream, refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), @@ -2676,11 +2910,7 @@ func TestRefreshGrant(t *testing.T) { }, }). Build()), - authcodeExchange: authcodeExchangeInputs{ - customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()), - }, + authcodeExchange: happyAuthcodeExchangeInputsForOIDCUpstream, refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), @@ -2705,11 +2935,7 @@ func TestRefreshGrant(t *testing.T) { }, }, }).WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), - authcodeExchange: authcodeExchangeInputs{ - customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()), - }, + authcodeExchange: happyAuthcodeExchangeInputsForOIDCUpstream, refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), @@ -2736,11 +2962,7 @@ func TestRefreshGrant(t *testing.T) { }, }, }).WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), - authcodeExchange: authcodeExchangeInputs{ - customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()), - }, + authcodeExchange: happyAuthcodeExchangeInputsForOIDCUpstream, refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), @@ -2767,11 +2989,7 @@ func TestRefreshGrant(t *testing.T) { }, }, }).WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), - authcodeExchange: authcodeExchangeInputs{ - customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()), - }, + authcodeExchange: happyAuthcodeExchangeInputsForOIDCUpstream, refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), @@ -2794,19 +3012,78 @@ func TestRefreshGrant(t *testing.T) { URL: ldapUpstreamURL, PerformRefreshGroups: goodGroups, }), - authcodeExchange: authcodeExchangeInputs{ - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, - customSessionData: happyLDAPCustomSessionData, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( - happyLDAPCustomSessionData, - ), - }, + authcodeExchange: happyAuthcodeExchangeInputsForLDAPUpstream, refreshRequest: refreshRequestInputs{ want: happyRefreshTokenResponseForLDAP( happyLDAPCustomSessionData, ), }, }, + { + name: "upstream ldap refresh happy path using dynamic client", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{ + Name: ldapUpstreamName, + ResourceUID: ldapUpstreamResourceUID, + URL: ldapUpstreamURL, + PerformRefreshGroups: goodGroups, + }), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + authcodeExchange: authcodeExchangeInputs{ + modifyAuthRequest: func(r *http.Request) { + addDynamicClientIDToFormPostBody(r) + r.Form.Set("scope", "openid offline_access username groups") + }, + modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, + customSessionData: happyLDAPCustomSessionData, + want: withWantDynamicClientID(happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(happyLDAPCustomSessionData)), + }, + refreshRequest: refreshRequestInputs{ + modifyTokenRequest: modifyRefreshTokenRequestWithDynamicClientAuth, + want: withWantDynamicClientID(happyRefreshTokenResponseForLDAP(happyLDAPCustomSessionData)), + }, + }, + { + name: "upstream ldap refresh happy path without downstream username scope granted, using dynamic client", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{ + Name: ldapUpstreamName, + ResourceUID: ldapUpstreamResourceUID, + URL: ldapUpstreamURL, + PerformRefreshGroups: goodGroups, + }), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + authcodeExchange: authcodeExchangeInputs{ + modifyAuthRequest: func(r *http.Request) { + addDynamicClientIDToFormPostBody(r) + r.Form.Set("scope", "openid offline_access groups") + }, + modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, + customSessionData: happyLDAPCustomSessionData, + want: withWantDynamicClientID(tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantClientID: dynamicClientID, + wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, + wantRequestedScopes: []string{"openid", "offline_access", "groups"}, + wantGrantedScopes: []string{"openid", "offline_access", "groups"}, + wantCustomSessionDataStored: happyLDAPCustomSessionData, + wantUsername: "", + wantGroups: goodGroups, + }), + }, + refreshRequest: refreshRequestInputs{ + modifyTokenRequest: modifyRefreshTokenRequestWithDynamicClientAuth, + want: withWantDynamicClientID(tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantClientID: dynamicClientID, + wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, + wantRequestedScopes: []string{"openid", "offline_access", "groups"}, + wantGrantedScopes: []string{"openid", "offline_access", "groups"}, + wantCustomSessionDataStored: happyLDAPCustomSessionData, + wantUsername: "", + wantGroups: goodGroups, + wantUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(), + }), + }, + }, { name: "upstream active directory refresh happy path", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&oidctestutil.TestUpstreamLDAPIdentityProvider{ @@ -2816,7 +3093,7 @@ func TestRefreshGrant(t *testing.T) { PerformRefreshGroups: goodGroups, }), authcodeExchange: authcodeExchangeInputs{ - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access username groups") }, customSessionData: happyActiveDirectoryCustomSessionData, want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( happyActiveDirectoryCustomSessionData, @@ -2836,7 +3113,7 @@ func TestRefreshGrant(t *testing.T) { URL: ldapUpstreamURL, }), authcodeExchange: authcodeExchangeInputs{ - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access username groups") }, customSessionData: &psession.CustomSessionData{ ProviderUID: ldapUpstreamResourceUID, ProviderName: ldapUpstreamName, @@ -2872,7 +3149,7 @@ func TestRefreshGrant(t *testing.T) { URL: ldapUpstreamURL, }), authcodeExchange: authcodeExchangeInputs{ - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access username groups") }, customSessionData: &psession.CustomSessionData{ ProviderUID: activeDirectoryUpstreamResourceUID, ProviderName: activeDirectoryUpstreamName, @@ -2908,7 +3185,7 @@ func TestRefreshGrant(t *testing.T) { URL: ldapUpstreamURL, }), authcodeExchange: authcodeExchangeInputs{ - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access username groups") }, customSessionData: &psession.CustomSessionData{ ProviderUID: ldapUpstreamResourceUID, ProviderName: ldapUpstreamName, @@ -2948,7 +3225,7 @@ func TestRefreshGrant(t *testing.T) { URL: ldapUpstreamURL, }), authcodeExchange: authcodeExchangeInputs{ - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access username groups") }, customSessionData: &psession.CustomSessionData{ ProviderUID: ldapUpstreamResourceUID, ProviderName: ldapUpstreamName, @@ -2988,13 +3265,7 @@ func TestRefreshGrant(t *testing.T) { URL: ldapUpstreamURL, PerformRefreshErr: errors.New("Some error performing upstream refresh"), }), - authcodeExchange: authcodeExchangeInputs{ - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, - customSessionData: happyLDAPCustomSessionData, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( - happyLDAPCustomSessionData, - ), - }, + authcodeExchange: happyAuthcodeExchangeInputsForLDAPUpstream, refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ wantUpstreamRefreshCall: happyLDAPUpstreamRefreshCall(), @@ -3017,7 +3288,7 @@ func TestRefreshGrant(t *testing.T) { PerformRefreshErr: errors.New("Some error performing upstream refresh"), }), authcodeExchange: authcodeExchangeInputs{ - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access username groups") }, customSessionData: happyActiveDirectoryCustomSessionData, want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( happyActiveDirectoryCustomSessionData, @@ -3037,15 +3308,9 @@ func TestRefreshGrant(t *testing.T) { }, }, { - name: "upstream ldap idp not found", - idps: oidctestutil.NewUpstreamIDPListerBuilder(), - authcodeExchange: authcodeExchangeInputs{ - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, - customSessionData: happyLDAPCustomSessionData, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( - happyLDAPCustomSessionData, - ), - }, + name: "upstream ldap idp not found", + idps: oidctestutil.NewUpstreamIDPListerBuilder(), + authcodeExchange: happyAuthcodeExchangeInputsForLDAPUpstream, refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusUnauthorized, @@ -3062,7 +3327,7 @@ func TestRefreshGrant(t *testing.T) { name: "upstream active directory idp not found", idps: oidctestutil.NewUpstreamIDPListerBuilder(), authcodeExchange: authcodeExchangeInputs{ - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access username groups") }, customSessionData: happyActiveDirectoryCustomSessionData, want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( happyActiveDirectoryCustomSessionData, @@ -3087,13 +3352,7 @@ func TestRefreshGrant(t *testing.T) { ResourceUID: ldapUpstreamResourceUID, URL: ldapUpstreamURL, }), - authcodeExchange: authcodeExchangeInputs{ - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, - customSessionData: happyLDAPCustomSessionData, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( - happyLDAPCustomSessionData, - ), - }, + authcodeExchange: happyAuthcodeExchangeInputsForLDAPUpstream, modifyRefreshTokenStorage: func(t *testing.T, oauthStore *oidc.KubeStorage, refreshToken string) { refreshTokenSignature := getFositeDataSignature(t, refreshToken) firstRequester, err := oauthStore.GetRefreshTokenSession(context.Background(), refreshTokenSignature, nil) @@ -3118,16 +3377,15 @@ func TestRefreshGrant(t *testing.T) { }, }, { - name: "username not found in extra field", + name: "groups not found in extra field when the groups scope was granted", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{ Name: ldapUpstreamName, ResourceUID: ldapUpstreamResourceUID, URL: ldapUpstreamURL, }), authcodeExchange: authcodeExchangeInputs{ - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access username groups") }, customSessionData: happyLDAPCustomSessionData, - //fositeSessionData: &openid.DefaultSession{}, want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( happyLDAPCustomSessionData, ), @@ -3137,11 +3395,7 @@ func TestRefreshGrant(t *testing.T) { firstRequester, err := oauthStore.GetRefreshTokenSession(context.Background(), refreshTokenSignature, nil) require.NoError(t, err) session := firstRequester.GetSession().(*psession.PinnipedSession) - session.Fosite = &openid.DefaultSession{ - Claims: &jwt.IDTokenClaims{ - Extra: map[string]interface{}{}, - }, - } + delete(session.Fosite.Claims.Extra, "groups") err = oauthStore.DeleteRefreshTokenSession(context.Background(), refreshTokenSignature) require.NoError(t, err) err = oauthStore.CreateRefreshTokenSession(context.Background(), refreshTokenSignature, firstRequester) @@ -3160,16 +3414,15 @@ func TestRefreshGrant(t *testing.T) { }, }, { - name: "username in extra is not a string", + name: "username in custom session is empty string during refresh", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{ Name: ldapUpstreamName, ResourceUID: ldapUpstreamResourceUID, URL: ldapUpstreamURL, }), authcodeExchange: authcodeExchangeInputs{ - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access username groups") }, customSessionData: happyLDAPCustomSessionData, - //fositeSessionData: &openid.DefaultSession{}, want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( happyLDAPCustomSessionData, ), @@ -3179,53 +3432,7 @@ func TestRefreshGrant(t *testing.T) { firstRequester, err := oauthStore.GetRefreshTokenSession(context.Background(), refreshTokenSignature, nil) require.NoError(t, err) session := firstRequester.GetSession().(*psession.PinnipedSession) - session.Fosite = &openid.DefaultSession{ - Claims: &jwt.IDTokenClaims{ - Extra: map[string]interface{}{"username": 123}, - }, - } - err = oauthStore.DeleteRefreshTokenSession(context.Background(), refreshTokenSignature) - require.NoError(t, err) - err = oauthStore.CreateRefreshTokenSession(context.Background(), refreshTokenSignature, firstRequester) - require.NoError(t, err) - }, - refreshRequest: refreshRequestInputs{ - want: tokenEndpointResponseExpectedValues{ - wantStatus: http.StatusInternalServerError, - wantErrorResponseBody: here.Doc(` - { - "error": "error", - "error_description": "There was an internal server error. Required upstream data not found in session." - } - `), - }, - }, - }, - { - name: "username in extra is an empty string", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{ - Name: ldapUpstreamName, - ResourceUID: ldapUpstreamResourceUID, - URL: ldapUpstreamURL, - }), - authcodeExchange: authcodeExchangeInputs{ - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, - customSessionData: happyLDAPCustomSessionData, - //fositeSessionData: &openid.DefaultSession{}, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( - happyLDAPCustomSessionData, - ), - }, - modifyRefreshTokenStorage: func(t *testing.T, oauthStore *oidc.KubeStorage, refreshToken string) { - refreshTokenSignature := getFositeDataSignature(t, refreshToken) - firstRequester, err := oauthStore.GetRefreshTokenSession(context.Background(), refreshTokenSignature, nil) - require.NoError(t, err) - session := firstRequester.GetSession().(*psession.PinnipedSession) - session.Fosite = &openid.DefaultSession{ - Claims: &jwt.IDTokenClaims{ - Extra: map[string]interface{}{"username": ""}, - }, - } + session.Custom.Username = "" err = oauthStore.DeleteRefreshTokenSession(context.Background(), refreshTokenSignature) require.NoError(t, err) err = oauthStore.CreateRefreshTokenSession(context.Background(), refreshTokenSignature, firstRequester) @@ -3250,13 +3457,7 @@ func TestRefreshGrant(t *testing.T) { ResourceUID: "the-wrong-uid", URL: ldapUpstreamURL, }), - authcodeExchange: authcodeExchangeInputs{ - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, - customSessionData: happyLDAPCustomSessionData, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( - happyLDAPCustomSessionData, - ), - }, + authcodeExchange: happyAuthcodeExchangeInputsForLDAPUpstream, refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusUnauthorized, @@ -3277,7 +3478,7 @@ func TestRefreshGrant(t *testing.T) { URL: ldapUpstreamURL, }), authcodeExchange: authcodeExchangeInputs{ - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access username groups") }, customSessionData: happyActiveDirectoryCustomSessionData, want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( happyActiveDirectoryCustomSessionData, @@ -3295,128 +3496,6 @@ func TestRefreshGrant(t *testing.T) { }, }, }, - { - name: "upstream ldap idp not found", - idps: oidctestutil.NewUpstreamIDPListerBuilder(), - authcodeExchange: authcodeExchangeInputs{ - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, - customSessionData: happyLDAPCustomSessionData, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( - happyLDAPCustomSessionData, - ), - }, - refreshRequest: refreshRequestInputs{ - want: tokenEndpointResponseExpectedValues{ - wantStatus: http.StatusUnauthorized, - wantErrorResponseBody: here.Doc(` - { - "error": "error", - "error_description": "Error during upstream refresh. Provider from upstream session data was not found." - } - `), - }, - }, - }, - { - name: "upstream active directory idp not found", - idps: oidctestutil.NewUpstreamIDPListerBuilder(), - authcodeExchange: authcodeExchangeInputs{ - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, - customSessionData: happyActiveDirectoryCustomSessionData, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( - happyActiveDirectoryCustomSessionData, - ), - }, - refreshRequest: refreshRequestInputs{ - want: tokenEndpointResponseExpectedValues{ - wantStatus: http.StatusUnauthorized, - wantErrorResponseBody: here.Doc(` - { - "error": "error", - "error_description": "Error during upstream refresh. Provider from upstream session data was not found." - } - `), - }, - }, - }, - { - name: "fosite session is empty", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{ - Name: ldapUpstreamName, - ResourceUID: ldapUpstreamResourceUID, - URL: ldapUpstreamURL, - }), - authcodeExchange: authcodeExchangeInputs{ - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, - customSessionData: happyLDAPCustomSessionData, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( - happyLDAPCustomSessionData, - ), - }, - modifyRefreshTokenStorage: func(t *testing.T, oauthStore *oidc.KubeStorage, refreshToken string) { - refreshTokenSignature := getFositeDataSignature(t, refreshToken) - firstRequester, err := oauthStore.GetRefreshTokenSession(context.Background(), refreshTokenSignature, nil) - require.NoError(t, err) - session := firstRequester.GetSession().(*psession.PinnipedSession) - session.Fosite = &openid.DefaultSession{} - err = oauthStore.DeleteRefreshTokenSession(context.Background(), refreshTokenSignature) - require.NoError(t, err) - err = oauthStore.CreateRefreshTokenSession(context.Background(), refreshTokenSignature, firstRequester) - require.NoError(t, err) - }, - refreshRequest: refreshRequestInputs{ - want: tokenEndpointResponseExpectedValues{ - wantStatus: http.StatusInternalServerError, - wantErrorResponseBody: here.Doc(` - { - "error": "error", - "error_description": "There was an internal server error. Required upstream data not found in session." - } - `), - }, - }, - }, - { - name: "username not found in extra field", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{ - Name: ldapUpstreamName, - ResourceUID: ldapUpstreamResourceUID, - URL: ldapUpstreamURL, - }), - authcodeExchange: authcodeExchangeInputs{ - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, - customSessionData: happyLDAPCustomSessionData, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( - happyLDAPCustomSessionData, - ), - }, - modifyRefreshTokenStorage: func(t *testing.T, oauthStore *oidc.KubeStorage, refreshToken string) { - refreshTokenSignature := getFositeDataSignature(t, refreshToken) - firstRequester, err := oauthStore.GetRefreshTokenSession(context.Background(), refreshTokenSignature, nil) - require.NoError(t, err) - session := firstRequester.GetSession().(*psession.PinnipedSession) - session.Fosite = &openid.DefaultSession{ - Claims: &jwt.IDTokenClaims{ - Extra: map[string]interface{}{}, - }, - } - err = oauthStore.DeleteRefreshTokenSession(context.Background(), refreshTokenSignature) - require.NoError(t, err) - err = oauthStore.CreateRefreshTokenSession(context.Background(), refreshTokenSignature, firstRequester) - require.NoError(t, err) - }, - refreshRequest: refreshRequestInputs{ - want: tokenEndpointResponseExpectedValues{ - wantStatus: http.StatusInternalServerError, - wantErrorResponseBody: here.Doc(` - { - "error": "error", - "error_description": "There was an internal server error. Required upstream data not found in session." - } - `), - }, - }, - }, { name: "auth time is the zero value", // time.Times can never be nil, but it is possible that it would be the zero value which would mean something's wrong idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{ @@ -3424,13 +3503,7 @@ func TestRefreshGrant(t *testing.T) { ResourceUID: ldapUpstreamResourceUID, URL: ldapUpstreamURL, }), - authcodeExchange: authcodeExchangeInputs{ - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, - customSessionData: happyLDAPCustomSessionData, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( - happyLDAPCustomSessionData, - ), - }, + authcodeExchange: happyAuthcodeExchangeInputsForLDAPUpstream, modifyRefreshTokenStorage: func(t *testing.T, oauthStore *oidc.KubeStorage, refreshToken string) { refreshTokenSignature := getFositeDataSignature(t, refreshToken) firstRequester, err := oauthStore.GetRefreshTokenSession(context.Background(), refreshTokenSignature, nil) @@ -3456,58 +3529,6 @@ func TestRefreshGrant(t *testing.T) { }, }, }, - { - name: "when the ldap provider in the session storage is found but has the wrong resource UID during the refresh request", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{ - Name: ldapUpstreamName, - ResourceUID: "the-wrong-uid", - URL: ldapUpstreamURL, - }), - authcodeExchange: authcodeExchangeInputs{ - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, - customSessionData: happyLDAPCustomSessionData, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( - happyLDAPCustomSessionData, - ), - }, - refreshRequest: refreshRequestInputs{ - want: tokenEndpointResponseExpectedValues{ - wantStatus: http.StatusUnauthorized, - wantErrorResponseBody: here.Doc(` - { - "error": "error", - "error_description": "Error during upstream refresh. Provider from upstream session data has changed its resource UID since authentication." - } - `), - }, - }, - }, - { - name: "when the active directory provider in the session storage is found but has the wrong resource UID during the refresh request", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&oidctestutil.TestUpstreamLDAPIdentityProvider{ - Name: activeDirectoryUpstreamName, - ResourceUID: "the-wrong-uid", - URL: ldapUpstreamURL, - }), - authcodeExchange: authcodeExchangeInputs{ - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access groups") }, - customSessionData: happyActiveDirectoryCustomSessionData, - want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( - happyActiveDirectoryCustomSessionData, - ), - }, - refreshRequest: refreshRequestInputs{ - want: tokenEndpointResponseExpectedValues{ - wantStatus: http.StatusUnauthorized, - wantErrorResponseBody: here.Doc(` - { - "error": "error", - "error_description": "Error during upstream refresh. Provider from upstream session data has changed its resource UID since authentication." - } - `), - }, - }, - }, } for _, test := range tests { test := test @@ -3540,7 +3561,9 @@ func TestRefreshGrant(t *testing.T) { if test.modifyRefreshTokenStorage != nil { test.modifyRefreshTokenStorage(t, oauthStore, firstRefreshToken) } - reqContext := context.WithValue(context.Background(), struct{ name string }{name: "test"}, "request-context") + + reqContextWarningRecorder := &TestWarningRecorder{} + reqContext := warning.WithWarningRecorder(context.WithValue(context.Background(), struct{ name string }{name: "test"}, "request-context"), reqContextWarningRecorder) req := httptest.NewRequest("POST", "/path/shouldn't/matter", happyRefreshRequestBody(firstRefreshToken).ReadCloser()).WithContext(reqContext) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") @@ -3577,6 +3600,13 @@ func TestRefreshGrant(t *testing.T) { test.idps.RequireExactlyZeroCallsToValidateToken(t) } + // Test that the expected warnings were set on the request context. + if test.refreshRequest.want.wantWarnings != nil { + require.Equal(t, test.refreshRequest.want.wantWarnings, reqContextWarningRecorder.Warnings) + } else { + require.Len(t, reqContextWarningRecorder.Warnings, 0, "wanted no warnings on the request context, but found some") + } + // The bug in fosite that prevents at_hash from appearing in the initial ID token does not impact the refreshed ID token wantAtHashClaimInIDToken := true // Refreshed ID tokens do not include the nonce from the original auth request @@ -3584,6 +3614,7 @@ func TestRefreshGrant(t *testing.T) { requireTokenEndpointBehavior(t, test.refreshRequest.want, + test.authcodeExchange.want.wantUsername, // the old username from the initial login test.authcodeExchange.want.wantGroups, // the old groups from the initial login test.authcodeExchange.customSessionData, // the old custom session data from the initial login wantAtHashClaimInIDToken, @@ -3726,6 +3757,7 @@ func exchangeAuthcodeForTokens( requireTokenEndpointBehavior(t, test.want, + test.want.wantUsername, // the old username from the initial login test.want.wantGroups, // the old groups from the initial login test.customSessionData, // the old custom session data from the initial login wantAtHashClaimInIDToken, @@ -3744,6 +3776,7 @@ func exchangeAuthcodeForTokens( func requireTokenEndpointBehavior( t *testing.T, test tokenEndpointResponseExpectedValues, + oldUsername string, oldGroups []string, oldCustomSessionData *psession.CustomSessionData, wantAtHashClaimInIDToken bool, @@ -3769,10 +3802,10 @@ func requireTokenEndpointBehavior( wantRefreshToken := contains(test.wantSuccessBodyFields, "refresh_token") requireInvalidAuthCodeStorage(t, authCode, oauthStore, secrets, requestTime) - requireValidAccessTokenStorage(t, parsedResponseBody, oauthStore, test.wantClientID, test.wantRequestedScopes, test.wantGrantedScopes, test.wantGroups, test.wantCustomSessionDataStored, secrets, requestTime) + requireValidAccessTokenStorage(t, parsedResponseBody, oauthStore, test.wantClientID, test.wantRequestedScopes, test.wantGrantedScopes, test.wantUsername, test.wantGroups, test.wantCustomSessionDataStored, secrets, requestTime) requireInvalidPKCEStorage(t, authCode, oauthStore) - // Performing a refresh does not update the OIDC storage, so after a refresh it should still have the old custom session data and old groups from the initial login. - requireValidOIDCStorage(t, parsedResponseBody, authCode, oauthStore, test.wantClientID, test.wantRequestedScopes, test.wantGrantedScopes, oldGroups, oldCustomSessionData, requestTime) + // Performing a refresh does not update the OIDC storage, so after a refresh it should still have the old custom session data and old username and groups from the initial login. + requireValidOIDCStorage(t, parsedResponseBody, authCode, oauthStore, test.wantClientID, test.wantRequestedScopes, test.wantGrantedScopes, oldUsername, oldGroups, oldCustomSessionData, requestTime) expectedNumberOfRefreshTokenSessionsStored := 0 if wantRefreshToken { @@ -3781,10 +3814,10 @@ func requireTokenEndpointBehavior( expectedNumberOfIDSessionsStored := 0 if wantIDToken { expectedNumberOfIDSessionsStored = 1 - requireValidIDToken(t, parsedResponseBody, jwtSigningKey, test.wantClientID, wantAtHashClaimInIDToken, wantNonceValueInIDToken, test.wantGroups, parsedResponseBody["access_token"].(string), requestTime) + requireValidIDToken(t, parsedResponseBody, jwtSigningKey, test.wantClientID, wantAtHashClaimInIDToken, wantNonceValueInIDToken, test.wantUsername, test.wantGroups, parsedResponseBody["access_token"].(string), requestTime) } if wantRefreshToken { - requireValidRefreshTokenStorage(t, parsedResponseBody, oauthStore, test.wantClientID, test.wantRequestedScopes, test.wantGrantedScopes, test.wantGroups, test.wantCustomSessionDataStored, secrets, requestTime) + requireValidRefreshTokenStorage(t, parsedResponseBody, oauthStore, test.wantClientID, test.wantRequestedScopes, test.wantGrantedScopes, test.wantUsername, test.wantGroups, test.wantCustomSessionDataStored, secrets, requestTime) } testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: authorizationcode.TypeLabelValue}, 1) @@ -3963,12 +3996,10 @@ func simulateAuthEndpointHavingAlreadyRun( Subject: goodSubject, RequestedAt: goodRequestedAtTime, AuthTime: goodAuthTime, - Extra: map[string]interface{}{ - oidc.DownstreamUsernameClaim: goodUsername, - }, + Extra: map[string]interface{}{}, }, - Subject: "", // not used, note that callback_handler.go does not set this - Username: "", // not used, note that callback_handler.go does not set this + Subject: "", // not used, note that the authorization and callback endpoints do not set this + Username: "", // not used, note that the authorization and callback endpoints do not set this }, Custom: initialCustomSessionData, } @@ -3983,10 +4014,19 @@ func simulateAuthEndpointHavingAlreadyRun( if strings.Contains(authRequest.Form.Get("scope"), "pinniped:request-audience") { authRequester.GrantScope("pinniped:request-audience") } - if strings.Contains(authRequest.Form.Get("scope"), "groups") { - authRequester.GrantScope("groups") - session.Fosite.Claims.Extra[oidc.DownstreamGroupsClaim] = goodGroups + + // The authorization endpoint makes a special exception for the pinniped-cli client for backwards compatibility + // and grants the username and groups scopes to that client even if it did not ask for them. Simulate that + // behavior here too. + if strings.Contains(authRequest.Form.Get("scope"), "username") || authRequest.Form.Get("client_id") == pinnipedCLIClientID { + authRequester.GrantScope("username") + session.Fosite.Claims.Extra["username"] = goodUsername } + if strings.Contains(authRequest.Form.Get("scope"), "groups") || authRequest.Form.Get("client_id") == pinnipedCLIClientID { + authRequester.GrantScope("groups") + session.Fosite.Claims.Extra["groups"] = goodGroups + } + authResponder, err := oauthHelper.NewAuthorizeResponse(ctx, authRequester, session) require.NoError(t, err) return authResponder @@ -4032,6 +4072,7 @@ func requireValidRefreshTokenStorage( wantClientID string, wantRequestedScopes []string, wantGrantedScopes []string, + wantUsername string, wantGroups []string, wantCustomSessionData *psession.CustomSessionData, secrets v1.SecretInterface, @@ -4060,6 +4101,7 @@ func requireValidRefreshTokenStorage( wantRequestedScopes, wantGrantedScopes, true, + wantUsername, wantGroups, wantCustomSessionData, requestTime, @@ -4075,6 +4117,7 @@ func requireValidAccessTokenStorage( wantClientID string, wantRequestedScopes []string, wantGrantedScopes []string, + wantUsername string, wantGroups []string, wantCustomSessionData *psession.CustomSessionData, secrets v1.SecretInterface, @@ -4122,6 +4165,7 @@ func requireValidAccessTokenStorage( wantRequestedScopes, wantGrantedScopes, true, + wantUsername, wantGroups, wantCustomSessionData, requestTime, @@ -4167,6 +4211,7 @@ func requireValidOIDCStorage( wantClientID string, wantRequestedScopes []string, wantGrantedScopes []string, + wantUsername string, wantGroups []string, wantCustomSessionData *psession.CustomSessionData, requestTime time.Time, @@ -4193,6 +4238,7 @@ func requireValidOIDCStorage( wantRequestedScopes, wantGrantedScopes, false, + wantUsername, wantGroups, wantCustomSessionData, requestTime, @@ -4211,6 +4257,7 @@ func requireValidStoredRequest( wantRequestedScopes []string, wantGrantedScopes []string, wantAccessTokenExpiresAt bool, + wantUsername string, wantGroups []string, wantCustomSessionData *psession.CustomSessionData, requestTime time.Time, @@ -4231,49 +4278,45 @@ func requireValidStoredRequest( session, ok := request.GetSession().(*psession.PinnipedSession) require.Truef(t, ok, "could not cast %T to %T", request.GetSession(), &psession.PinnipedSession{}) - // Assert that the session claims are what we think they should be, but only if we are doing OIDC. - if contains(wantGrantedScopes, "openid") { - claims := session.Fosite.Claims - require.Empty(t, claims.JTI) // When claims.JTI is empty, Fosite will generate a UUID for this field. - require.Equal(t, goodSubject, claims.Subject) + // Assert that the session claims are what we think they should be. + claims := session.Fosite.Claims + require.Empty(t, claims.JTI) // When claims.JTI is empty, Fosite will generate a UUID for this field. + require.Equal(t, goodSubject, claims.Subject) - // Our custom claims from the authorize endpoint should still be set. - expectedExtra := map[string]interface{}{ - "username": goodUsername, - } - if wantGroups != nil { - expectedExtra["groups"] = toSliceOfInterface(wantGroups) - } - require.Equal(t, expectedExtra, claims.Extra) - - // We are in charge of setting these fields. For the purpose of testing, we ensure that the - // sentinel test value is set correctly. - require.Equal(t, goodRequestedAtTime, claims.RequestedAt) - require.Equal(t, goodAuthTime, claims.AuthTime) - - // These fields will all be given good defaults by fosite at runtime and we only need to use them - // if we want to override the default behaviors. We currently don't need to override these defaults, - // so they do not end up being stored. Fosite sets its defaults at runtime in openid.DefaultStrategy's - // GenerateIDToken() method. - require.Empty(t, claims.Issuer) - require.Empty(t, claims.Audience) - require.Empty(t, claims.Nonce) - require.Zero(t, claims.ExpiresAt) - require.Zero(t, claims.IssuedAt) - - // Fosite unconditionally overwrites claims.AccessTokenHash at runtime in openid.OpenIDConnectExplicitHandler's - // PopulateTokenEndpointResponse() method, just before it calls the same GenerateIDToken() mentioned above, - // so it does not end up saved in storage. - require.Empty(t, claims.AccessTokenHash) - - // At this time, we don't use any of these optional (per the OIDC spec) fields. - require.Empty(t, claims.AuthenticationContextClassReference) - require.Empty(t, claims.AuthenticationMethodsReferences) - require.Empty(t, claims.CodeHash) - } else if wantGroups != nil { - t.Fatal("test did not want the openid scope to be granted, but also wanted groups, " + - "which is a combination that doesn't make sense since you need an ID token to get groups") + // Our custom claims from the authorize endpoint should still be set. + expectedExtra := map[string]interface{}{} + if wantUsername != "" { + expectedExtra["username"] = wantUsername } + if wantGroups != nil { + expectedExtra["groups"] = toSliceOfInterface(wantGroups) + } + require.Equal(t, expectedExtra, claims.Extra) + + // We are in charge of setting these fields. For the purpose of testing, we ensure that the + // sentinel test value is set correctly. + require.Equal(t, goodRequestedAtTime, claims.RequestedAt) + require.Equal(t, goodAuthTime, claims.AuthTime) + + // These fields will all be given good defaults by fosite at runtime and we only need to use them + // if we want to override the default behaviors. We currently don't need to override these defaults, + // so they do not end up being stored. Fosite sets its defaults at runtime in openid.DefaultStrategy's + // GenerateIDToken() method. + require.Empty(t, claims.Issuer) + require.Empty(t, claims.Audience) + require.Empty(t, claims.Nonce) + require.Zero(t, claims.ExpiresAt) + require.Zero(t, claims.IssuedAt) + + // Fosite unconditionally overwrites claims.AccessTokenHash at runtime in openid.OpenIDConnectExplicitHandler's + // PopulateTokenEndpointResponse() method, just before it calls the same GenerateIDToken() mentioned above, + // so it does not end up saved in storage. + require.Empty(t, claims.AccessTokenHash) + + // At this time, we don't use any of these optional (per the OIDC spec) fields. + require.Empty(t, claims.AuthenticationContextClassReference) + require.Empty(t, claims.AuthenticationMethodsReferences) + require.Empty(t, claims.CodeHash) // Assert that the session headers are what we think they should be. headers := session.Fosite.Headers @@ -4336,6 +4379,7 @@ func requireValidIDToken( wantClientID string, wantAtHashClaimInIDToken bool, wantNonceValueInIDToken bool, + wantUsernameInIDToken string, wantGroupsInIDToken []string, actualAccessToken string, requestTime time.Time, @@ -4368,13 +4412,16 @@ func requireValidIDToken( // Note that there is a bug in fosite which prevents the `at_hash` claim from appearing in this ID token // during the initial authcode exchange, but does not prevent `at_hash` from appearing in the refreshed ID token. // We can add a workaround for this later. - idTokenFields := []string{"sub", "aud", "iss", "jti", "auth_time", "exp", "iat", "rat", "username"} + idTokenFields := []string{"sub", "aud", "iss", "jti", "auth_time", "exp", "iat", "rat"} if wantAtHashClaimInIDToken { idTokenFields = append(idTokenFields, "at_hash") } if wantNonceValueInIDToken { idTokenFields = append(idTokenFields, "nonce") } + if wantUsernameInIDToken != "" { + idTokenFields = append(idTokenFields, "username") + } if wantGroupsInIDToken != nil { idTokenFields = append(idTokenFields, "groups") } @@ -4388,7 +4435,7 @@ func requireValidIDToken( err := token.Claims(&claims) require.NoError(t, err) require.Equal(t, goodSubject, claims.Subject) - require.Equal(t, goodUsername, claims.Username) + require.Equal(t, wantUsernameInIDToken, claims.Username) require.Equal(t, wantGroupsInIDToken, claims.Groups) require.Len(t, claims.Audience, 1) require.Equal(t, wantClientID, claims.Audience[0]) @@ -4499,3 +4546,24 @@ func TestDiffSortedGroups(t *testing.T) { }) } } + +type RecordedWarning struct { + Agent string + Text string +} + +type TestWarningRecorder struct { + Warnings []RecordedWarning +} + +var _ warning.Recorder = (*TestWarningRecorder)(nil) + +func (t *TestWarningRecorder) AddWarning(agent, text string) { + if t.Warnings == nil { + t.Warnings = []RecordedWarning{} + } + t.Warnings = append(t.Warnings, RecordedWarning{ + Agent: agent, + Text: text, + }) +} diff --git a/internal/oidc/token_exchange.go b/internal/oidc/token_exchange.go index 9cbf566d..dde15f70 100644 --- a/internal/oidc/token_exchange.go +++ b/internal/oidc/token_exchange.go @@ -8,21 +8,19 @@ import ( "net/url" "strings" - "github.com/coreos/go-oidc/v3/oidc" "github.com/ory/fosite" "github.com/ory/fosite/compose" "github.com/ory/fosite/handler/oauth2" "github.com/ory/fosite/handler/openid" "github.com/pkg/errors" - "go.pinniped.dev/internal/oidc/clientregistry" + oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc" + "go.pinniped.dev/internal/psession" ) const ( - tokenExchangeGrantType = "urn:ietf:params:oauth:grant-type:token-exchange" //nolint: gosec - tokenTypeAccessToken = "urn:ietf:params:oauth:token-type:access_token" //nolint: gosec - tokenTypeJWT = "urn:ietf:params:oauth:token-type:jwt" //nolint: gosec - pinnipedTokenExchangeScope = "pinniped:request-audience" //nolint: gosec + tokenTypeAccessToken = "urn:ietf:params:oauth:token-type:access_token" //nolint: gosec + tokenTypeJWT = "urn:ietf:params:oauth:token-type:jwt" //nolint: gosec ) type stsParams struct { @@ -78,17 +76,22 @@ func (t *TokenExchangeHandler) PopulateTokenEndpointResponse(ctx context.Context } // Check that the client is allowed to perform this grant type. - if !requester.GetClient().GetGrantTypes().Has(tokenExchangeGrantType) { + if !requester.GetClient().GetGrantTypes().Has(oidcapi.GrantTypeTokenExchange) { // This error message is trying to be similar to the analogous one in fosite's flow_authorize_code_token.go. - return errors.WithStack(fosite.ErrUnauthorizedClient.WithHintf(`The OAuth 2.0 Client is not allowed to use token exchange grant "%s".`, tokenExchangeGrantType)) + return errors.WithStack(fosite.ErrUnauthorizedClient.WithHintf(`The OAuth 2.0 Client is not allowed to use token exchange grant "%s".`, oidcapi.GrantTypeTokenExchange)) } // Require that the incoming access token has the pinniped:request-audience and OpenID scopes. - if !originalRequester.GetGrantedScopes().Has(pinnipedTokenExchangeScope) { - return errors.WithStack(fosite.ErrAccessDenied.WithHintf("missing the %q scope", pinnipedTokenExchangeScope)) + if !originalRequester.GetGrantedScopes().Has(oidcapi.ScopeRequestAudience) { + return errors.WithStack(fosite.ErrAccessDenied.WithHintf("missing the %q scope", oidcapi.ScopeRequestAudience)) } - if !originalRequester.GetGrantedScopes().Has(oidc.ScopeOpenID) { - return errors.WithStack(fosite.ErrAccessDenied.WithHintf("missing the %q scope", oidc.ScopeOpenID)) + if !originalRequester.GetGrantedScopes().Has(oidcapi.ScopeOpenID) { + return errors.WithStack(fosite.ErrAccessDenied.WithHintf("missing the %q scope", oidcapi.ScopeOpenID)) + } + + // Check that the stored session meets the minimum requirements for token exchange. + if err := t.validateSession(originalRequester); err != nil { + return errors.WithStack(err) } // Use the original authorize request information, along with the requested audience, to mint a new JWT. @@ -110,6 +113,22 @@ func (t *TokenExchangeHandler) mintJWT(ctx context.Context, requester fosite.Req return t.idTokenStrategy.GenerateIDToken(ctx, downscoped) } +func (t *TokenExchangeHandler) validateSession(requester fosite.Requester) error { + pSession, ok := requester.GetSession().(*psession.PinnipedSession) + if !ok { + // This shouldn't really happen. + return fosite.ErrServerError.WithHint("Invalid session storage.") + } + username, ok := pSession.IDTokenClaims().Extra[oidcapi.IDTokenClaimUsername].(string) + if !ok || username == "" { + // No username was stored in the session's ID token claims (or the stored username was not a string, which + // shouldn't really happen). Usernames will not be stored in the session's ID token claims when the username + // scope was not requested/granted, but otherwise they should be stored. + return fosite.ErrAccessDenied.WithHintf("No username found in session. Ensure that the %q scope was requested and granted at the authorization endpoint.", oidcapi.ScopeUsername) + } + return nil +} + func (t *TokenExchangeHandler) validateParams(params url.Values) (*stsParams, error) { var result stsParams @@ -157,8 +176,8 @@ func (t *TokenExchangeHandler) validateParams(params url.Values) (*stsParams, er if strings.Contains(result.requestedAudience, ".pinniped.dev") { return nil, fosite.ErrInvalidRequest.WithHintf("requested audience cannot contain '.pinniped.dev'") } - if result.requestedAudience == clientregistry.PinnipedCLIClientID { - return nil, fosite.ErrInvalidRequest.WithHintf("requested audience cannot equal '%s'", clientregistry.PinnipedCLIClientID) + if result.requestedAudience == oidcapi.ClientIDPinnipedCLI { + return nil, fosite.ErrInvalidRequest.WithHintf("requested audience cannot equal '%s'", oidcapi.ClientIDPinnipedCLI) } return &result, nil @@ -181,5 +200,5 @@ func (t *TokenExchangeHandler) CanSkipClientAuth(_ fosite.AccessRequester) bool } func (t *TokenExchangeHandler) CanHandleTokenEndpointRequest(requester fosite.AccessRequester) bool { - return requester.GetGrantTypes().ExactOne(tokenExchangeGrantType) + return requester.GetGrantTypes().ExactOne(oidcapi.GrantTypeTokenExchange) } diff --git a/internal/psession/pinniped_session.go b/internal/psession/pinniped_session.go index 665d0a90..136b2312 100644 --- a/internal/psession/pinniped_session.go +++ b/internal/psession/pinniped_session.go @@ -27,6 +27,11 @@ var _ openid.Session = &PinnipedSession{} // CustomSessionData is the custom session data needed by Pinniped. It should be treated as a union type, // where the value of ProviderType decides which other fields to use. type CustomSessionData struct { + // Username will contain the downstream username determined during initial authorization. We store this + // so that we can validate that it does not change upon refresh. This should normally never be empty, since + // all users must have a username. + Username string `json:"username"` + // The Kubernetes resource UID of the identity provider CRD for the upstream IDP used to start this session. // This should be validated again upon downstream refresh to make sure that we are not refreshing against // a different identity provider CRD which just happens to have the same name. diff --git a/internal/testutil/oidcclient.go b/internal/testutil/oidcclient.go index 621aea2e..6b8968d0 100644 --- a/internal/testutil/oidcclient.go +++ b/internal/testutil/oidcclient.go @@ -7,6 +7,8 @@ import ( "strings" "testing" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" @@ -41,19 +43,30 @@ func allDynamicClientScopes() []configv1alpha1.Scope { return scopes } -// fullyCapableOIDCClient returns an OIDC client which is allowed to use all grant types and all scopes that -// are supported by the Supervisor for dynamic clients. -func fullyCapableOIDCClient(namespace string, clientID string, clientUID string, redirectURI string) *configv1alpha1.OIDCClient { +func newOIDCClient( + namespace string, + clientID string, + clientUID string, + redirectURI string, + allowedGrantTypes []configv1alpha1.GrantType, + allowedScopes []configv1alpha1.Scope, +) *configv1alpha1.OIDCClient { return &configv1alpha1.OIDCClient{ ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: clientID, Generation: 1, UID: types.UID(clientUID)}, Spec: configv1alpha1.OIDCClientSpec{ - AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"}, - AllowedScopes: allDynamicClientScopes(), + AllowedGrantTypes: allowedGrantTypes, + AllowedScopes: allowedScopes, AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(redirectURI)}, }, } } +// OIDCClientValidatorFunc is an interface-like type that allows these test helpers to avoid having a direct dependency +// on the production code, to avoid circular module dependencies. Implemented by oidcclientvalidator.Validate. +type OIDCClientValidatorFunc func(oidcClient *configv1alpha1.OIDCClient, secret *corev1.Secret, minBcryptCost int) (bool, []*configv1alpha1.Condition, []string) + +// FullyCapableOIDCClientAndStorageSecret returns an OIDC client which is allowed to use all grant types and all scopes +// that are supported by the Supervisor for dynamic clients, along with a corresponding client secret storage Secret. func FullyCapableOIDCClientAndStorageSecret( t *testing.T, namespace string, @@ -61,7 +74,38 @@ func FullyCapableOIDCClientAndStorageSecret( clientUID string, redirectURI string, hashes []string, + validateFunc OIDCClientValidatorFunc, ) (*configv1alpha1.OIDCClient, *corev1.Secret) { - return fullyCapableOIDCClient(namespace, clientID, clientUID, redirectURI), - OIDCClientSecretStorageSecretForUID(t, namespace, clientUID, hashes) + allScopes := allDynamicClientScopes() + + allGrantTypes := []configv1alpha1.GrantType{ + "authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token", + } + + return OIDCClientAndStorageSecret(t, namespace, clientID, clientUID, allGrantTypes, allScopes, redirectURI, hashes, validateFunc) +} + +// OIDCClientAndStorageSecret returns an OIDC client which is allowed to use the specified grant types and scopes, +// along with a corresponding client secret storage Secret. It also validates the client to make sure that the specified +// combination of grant types and scopes is considered valid before returning the client. +func OIDCClientAndStorageSecret( + t *testing.T, + namespace string, + clientID string, + clientUID string, + allowedGrantTypes []configv1alpha1.GrantType, + allowedScopes []configv1alpha1.Scope, + redirectURI string, + hashes []string, + validateFunc OIDCClientValidatorFunc, +) (*configv1alpha1.OIDCClient, *corev1.Secret) { + oidcClient := newOIDCClient(namespace, clientID, clientUID, redirectURI, allowedGrantTypes, allowedScopes) + secret := OIDCClientSecretStorageSecretForUID(t, namespace, clientUID, hashes) + + // If a test made an invalid OIDCClient then inform the author of the test, so they can fix the test case. + // This is an easy mistake to make when writing tests because there are lots of validations on OIDCClients. + valid, conditions, _ := validateFunc(oidcClient, secret, bcrypt.MinCost) + require.True(t, valid, "Test's OIDCClient should have been valid. See conditions for errors: %s", conditions) + + return oidcClient, secret } diff --git a/internal/testutil/oidctestutil/oidctestutil.go b/internal/testutil/oidctestutil/oidctestutil.go index 23fcc821..2056e2d1 100644 --- a/internal/testutil/oidctestutil/oidctestutil.go +++ b/internal/testutil/oidctestutil/oidctestutil.go @@ -1064,14 +1064,22 @@ func validateAuthcodeStorage( // 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, wantDownstreamIDTokenUsername, actualClaims.Extra["username"]) + wantDownstreamIDTokenUsernameClaimToExist := 1 + if wantDownstreamIDTokenUsername == "" { + wantDownstreamIDTokenUsernameClaimToExist = 0 + require.NotContains(t, actualClaims.Extra, "username") + } else { + require.Equal(t, wantDownstreamIDTokenUsername, actualClaims.Extra["username"]) + } if slices.Contains(wantDownstreamGrantedScopes, "groups") { - require.Len(t, actualClaims.Extra, 2) + require.Len(t, actualClaims.Extra, wantDownstreamIDTokenUsernameClaimToExist+1) actualDownstreamIDTokenGroups := actualClaims.Extra["groups"] require.NotNil(t, actualDownstreamIDTokenGroups) require.ElementsMatch(t, wantDownstreamIDTokenGroups, actualDownstreamIDTokenGroups) } else { - require.Len(t, actualClaims.Extra, 1) + require.Emptyf(t, wantDownstreamIDTokenGroups, "test case did not want the groups scope to be granted, "+ + "but wanted something in the groups claim, which doesn't make sense. please review the test case's expectations.") + require.Len(t, actualClaims.Extra, wantDownstreamIDTokenUsernameClaimToExist) actualDownstreamIDTokenGroups := actualClaims.Extra["groups"] require.Nil(t, actualDownstreamIDTokenGroups) } diff --git a/internal/testutil/psession.go b/internal/testutil/psession.go index 83aacb13..88eb658b 100644 --- a/internal/testutil/psession.go +++ b/internal/testutil/psession.go @@ -24,6 +24,7 @@ func NewFakePinnipedSession() *psession.PinnipedSession { Subject: "panda", }, Custom: &psession.CustomSessionData{ + Username: "fake-username", ProviderUID: "fake-provider-uid", ProviderType: "fake-provider-type", ProviderName: "fake-provider-name", diff --git a/internal/upstreamldap/upstreamldap.go b/internal/upstreamldap/upstreamldap.go index cfbd437f..9c8dd1d6 100644 --- a/internal/upstreamldap/upstreamldap.go +++ b/internal/upstreamldap/upstreamldap.go @@ -23,10 +23,10 @@ import ( "k8s.io/utils/strings/slices" "k8s.io/utils/trace" + oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc" "go.pinniped.dev/internal/authenticators" "go.pinniped.dev/internal/crypto/ptls" "go.pinniped.dev/internal/endpointaddr" - "go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc/downstreamsession" "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/plog" @@ -241,7 +241,7 @@ func (p *Provider) PerformRefresh(ctx context.Context, storedRefreshAttributes p return storedRefreshAttributes.Groups, nil } // if we were not granted the groups scope, we should not search for groups or return any. - if !slices.Contains(storedRefreshAttributes.GrantedScopes, oidc.DownstreamGroupsScope) { + if !slices.Contains(storedRefreshAttributes.GrantedScopes, oidcapi.ScopeGroups) { return nil, nil } @@ -593,7 +593,7 @@ func (p *Provider) searchAndBindUser(conn Conn, username string, grantedScopes [ } var mappedGroupNames []string - if slices.Contains(grantedScopes, oidc.DownstreamGroupsScope) { + if slices.Contains(grantedScopes, oidcapi.ScopeGroups) { mappedGroupNames, err = p.searchGroupsForUserDN(conn, userEntry.DN) if err != nil { return nil, err diff --git a/internal/upstreamoidc/upstreamoidc.go b/internal/upstreamoidc/upstreamoidc.go index cb480f8b..dfe31137 100644 --- a/internal/upstreamoidc/upstreamoidc.go +++ b/internal/upstreamoidc/upstreamoidc.go @@ -20,8 +20,8 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/sets" + oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc" "go.pinniped.dev/internal/httputil/httperr" - "go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/plog" "go.pinniped.dev/pkg/oidcclient/nonce" @@ -286,7 +286,7 @@ func (p *ProviderConfig) ValidateTokenAndMergeWithUserInfo(ctx context.Context, return nil, err } - idTokenSubject, _ := validatedClaims[oidc.IDTokenSubjectClaim].(string) + idTokenSubject, _ := validatedClaims[oidcapi.IDTokenClaimSubject].(string) if len(idTokenSubject) > 0 || !requireIDToken { // only fetch userinfo if the ID token has a subject or if we are ignoring the id token completely. @@ -346,7 +346,7 @@ func (p *ProviderConfig) validateIDToken(ctx context.Context, tok *oauth2.Token, } func (p *ProviderConfig) maybeFetchUserInfoAndMergeClaims(ctx context.Context, tok *oauth2.Token, claims map[string]interface{}, requireIDToken bool, requireUserInfo bool) error { - idTokenSubject, _ := claims[oidc.IDTokenSubjectClaim].(string) + idTokenSubject, _ := claims[oidcapi.IDTokenClaimSubject].(string) userInfo, err := p.maybeFetchUserInfo(ctx, tok, requireUserInfo) if err != nil { @@ -371,7 +371,7 @@ func (p *ProviderConfig) maybeFetchUserInfoAndMergeClaims(ctx context.Context, t } // keep track of the issuer from the ID token - idTokenIssuer := claims["iss"] + idTokenIssuer := claims[oidcapi.IDTokenClaimIssuer] // merge existing claims with user info claims if err := userInfo.Claims(&claims); err != nil { @@ -381,9 +381,9 @@ func (p *ProviderConfig) maybeFetchUserInfoAndMergeClaims(ctx context.Context, t // "If signed, the UserInfo Response SHOULD contain the Claims iss (issuer) and aud (audience) as members. The iss value SHOULD be the OP's Issuer Identifier URL." // See https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse // So we just ignore it and use it the version from the id token, which has stronger guarantees. - delete(claims, "iss") + delete(claims, oidcapi.IDTokenClaimIssuer) if idTokenIssuer != nil { - claims["iss"] = idTokenIssuer + claims[oidcapi.IDTokenClaimIssuer] = idTokenIssuer } maybeLogClaims("claims from ID token and userinfo", p.Name, claims) diff --git a/pkg/oidcclient/login.go b/pkg/oidcclient/login.go index 3ff1b9a4..cfb36784 100644 --- a/pkg/oidcclient/login.go +++ b/pkg/oidcclient/login.go @@ -21,14 +21,14 @@ import ( "sync" "time" - "github.com/coreos/go-oidc/v3/oidc" + coreosoidc "github.com/coreos/go-oidc/v3/oidc" "github.com/go-logr/logr" "github.com/pkg/browser" "golang.org/x/oauth2" "golang.org/x/term" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - supervisoroidc "go.pinniped.dev/generated/latest/apis/supervisor/oidc" + oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc" "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/httputil/securityheader" "go.pinniped.dev/internal/net/phttp" @@ -91,7 +91,7 @@ type handlerState struct { callbackPath string // Generated parameters of a login flow. - provider *oidc.Provider + provider *coreosoidc.Provider oauth2Config *oauth2.Config useFormPost bool state state.State @@ -106,8 +106,8 @@ type handlerState struct { getEnv func(key string) string listen func(string, string) (net.Listener, error) isTTY func(int) bool - getProvider func(*oauth2.Config, *oidc.Provider, *http.Client) provider.UpstreamOIDCIdentityProviderI - validateIDToken func(ctx context.Context, provider *oidc.Provider, audience string, token string) (*oidc.IDToken, error) + getProvider func(*oauth2.Config, *coreosoidc.Provider, *http.Client) provider.UpstreamOIDCIdentityProviderI + validateIDToken func(ctx context.Context, provider *coreosoidc.Provider, audience string, token string) (*coreosoidc.IDToken, error) promptForValue func(ctx context.Context, promptLabel string) (string, error) promptForSecret func(promptLabel string) (string, error) @@ -268,7 +268,7 @@ func Login(issuer string, clientID string, opts ...Option) (*oidctypes.Token, er issuer: issuer, clientID: clientID, listenAddr: "localhost:0", - scopes: []string{oidc.ScopeOfflineAccess, oidc.ScopeOpenID, "email", "profile"}, + scopes: []string{oidcapi.ScopeOfflineAccess, oidcapi.ScopeOpenID, oidcapi.ScopeEmail, oidcapi.ScopeProfile}, cache: &nopCache{}, callbackPath: "/callback", ctx: context.Background(), @@ -285,8 +285,8 @@ func Login(issuer string, clientID string, opts ...Option) (*oidctypes.Token, er listen: net.Listen, isTTY: term.IsTerminal, getProvider: upstreamoidc.New, - validateIDToken: func(ctx context.Context, provider *oidc.Provider, audience string, token string) (*oidc.IDToken, error) { - return provider.Verifier(&oidc.Config{ClientID: audience}).Verify(ctx, token) + validateIDToken: func(ctx context.Context, provider *coreosoidc.Provider, audience string, token string) (*coreosoidc.IDToken, error) { + return provider.Verifier(&coreosoidc.Config{ClientID: audience}).Verify(ctx, token) }, promptForValue: promptForValue, promptForSecret: promptForSecret, @@ -305,7 +305,7 @@ func Login(issuer string, clientID string, opts ...Option) (*oidctypes.Token, er // Always set a long, but non-infinite timeout for this operation. ctx, cancel := context.WithTimeout(h.ctx, overallTimeout) defer cancel() - ctx = oidc.ClientContext(ctx, h.httpClient) + ctx = coreosoidc.ClientContext(ctx, h.httpClient) h.ctx = ctx // Initialize login parameters. @@ -386,10 +386,10 @@ func (h *handlerState) baseLogin() (*oidctypes.Token, error) { } if h.upstreamIdentityProviderName != "" { authorizeOptions = append(authorizeOptions, - oauth2.SetAuthURLParam(supervisoroidc.AuthorizeUpstreamIDPNameParamName, h.upstreamIdentityProviderName), + oauth2.SetAuthURLParam(oidcapi.AuthorizeUpstreamIDPNameParamName, h.upstreamIdentityProviderName), ) authorizeOptions = append(authorizeOptions, - oauth2.SetAuthURLParam(supervisoroidc.AuthorizeUpstreamIDPTypeParamName, h.upstreamIdentityProviderType), + oauth2.SetAuthURLParam(oidcapi.AuthorizeUpstreamIDPTypeParamName, h.upstreamIdentityProviderType), ) } @@ -447,8 +447,8 @@ func (h *handlerState) cliBasedAuth(authorizeOptions *[]oauth2.AuthCodeOption) ( if err != nil { return nil, fmt.Errorf("could not build authorize request: %w", err) } - authReq.Header.Set(supervisoroidc.AuthorizeUsernameHeaderName, username) - authReq.Header.Set(supervisoroidc.AuthorizePasswordHeaderName, password) + authReq.Header.Set(oidcapi.AuthorizeUsernameHeaderName, username) + authReq.Header.Set(oidcapi.AuthorizePasswordHeaderName, password) authRes, err := h.httpClient.Do(authReq) if err != nil { return nil, fmt.Errorf("authorization response error: %w", err) @@ -710,7 +710,7 @@ func (h *handlerState) initOIDCDiscovery() error { h.logger.V(plog.KlogLevelDebug).Info("Pinniped: Performing OIDC discovery", "issuer", h.issuer) var err error - h.provider, err = oidc.NewProvider(h.ctx, h.issuer) + h.provider, err = coreosoidc.NewProvider(h.ctx, h.issuer) if err != nil { return fmt.Errorf("could not perform OIDC discovery for %q: %w", h.issuer, err) } @@ -775,7 +775,7 @@ func (h *handlerState) tokenExchangeRFC8693(baseToken *oidctypes.Token) (*oidcty // Form the HTTP POST request with the parameters specified by RFC8693. reqBody := strings.NewReader(url.Values{ "client_id": []string{h.clientID}, - "grant_type": []string{"urn:ietf:params:oauth:grant-type:token-exchange"}, + "grant_type": []string{oidcapi.GrantTypeTokenExchange}, "audience": []string{h.requestedAudience}, "subject_token": []string{baseToken.AccessToken.Token}, "subject_token_type": []string{"urn:ietf:params:oauth:token-type:access_token"}, diff --git a/pkg/oidcclient/nonce/nonce.go b/pkg/oidcclient/nonce/nonce.go index 6766bec2..28b650dd 100644 --- a/pkg/oidcclient/nonce/nonce.go +++ b/pkg/oidcclient/nonce/nonce.go @@ -1,7 +1,7 @@ -// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -// Package nonce implements +// Package nonce implements helpers for OIDC nonce parameter handling. package nonce import ( @@ -11,7 +11,7 @@ import ( "fmt" "io" - "github.com/coreos/go-oidc/v3/oidc" + coreosoidc "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" ) @@ -36,11 +36,11 @@ func (n *Nonce) String() string { // Param returns the OAuth2 auth code parameter for sending the nonce during the authorization request. func (n *Nonce) Param() oauth2.AuthCodeOption { - return oidc.Nonce(string(*n)) + return coreosoidc.Nonce(string(*n)) } // Validate the returned ID token). Returns true iff the nonce matches or the returned JWT does not have a nonce. -func (n *Nonce) Validate(token *oidc.IDToken) error { +func (n *Nonce) Validate(token *coreosoidc.IDToken) error { if subtle.ConstantTimeCompare([]byte(token.Nonce), []byte(*n)) != 1 { return InvalidNonceError{Expected: *n, Got: Nonce(token.Nonce)} } diff --git a/site/content/docs/reference/code-walkthrough.md b/site/content/docs/reference/code-walkthrough.md index 5ce69299..9035ebea 100644 --- a/site/content/docs/reference/code-walkthrough.md +++ b/site/content/docs/reference/code-walkthrough.md @@ -188,7 +188,7 @@ The Supervisor's endpoints are: - And a number of endpoints for each FederationDomain that is configured by the user. Each FederationDomain's endpoints are mounted under the path of the FederationDomain's `spec.issuer`, -if the issuer as a path specified in its URL. If the issuer has no path, then they are mounted under `/`. +if the `spec.issuer` URL has a path component specified. If the issuer has no path, then they are mounted under `/`. These per-FederationDomain endpoint are all mounted by the code in [internal/oidc/provider/manager/manager.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/oidc/provider/manager/manager.go). @@ -202,6 +202,10 @@ The per-FederationDomain endpoints are: See [internal/oidc/auth/auth_handler.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/oidc/auth/auth_handler.go). - `/oauth2/token` is the standard OIDC token endpoint. See [internal/oidc/token/token_handler.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/oidc/token/token_handler.go). + The token endpoint can handle the standard OIDC `authorization_code` and `refresh_token` grant types, and has also been + extended in [internal/oidc/token_exchange.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/oidc/token_exchange.go) + to handle an additional grant type for [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token exchanges to + reduce the applicable scope (technically, the `aud` claim) of ID tokens. - `/callback` is a special endpoint that is used as the redirect URL when performing an OIDC authcode flow against an upstream OIDC identity provider as configured by an OIDCIdentityProvider custom resource. See [internal/oidc/callback/callback_handler.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/oidc/callback/callback_handler.go). - `/v1alpha1/pinniped_identity_providers` is a custom discovery endpoint for clients to learn about available upstream identity providers. diff --git a/test/integration/e2e_test.go b/test/integration/e2e_test.go index aaf152f4..b2e95649 100644 --- a/test/integration/e2e_test.go +++ b/test/integration/e2e_test.go @@ -24,7 +24,6 @@ import ( "testing" "time" - coreosoidc "github.com/coreos/go-oidc/v3/oidc" "github.com/creack/pty" "github.com/sclevine/agouti" "github.com/stretchr/testify/require" @@ -40,7 +39,6 @@ import ( "go.pinniped.dev/internal/certauthority" "go.pinniped.dev/internal/crud" "go.pinniped.dev/internal/here" - "go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/pkg/oidcclient" "go.pinniped.dev/pkg/oidcclient/filesession" @@ -53,6 +51,8 @@ import ( func TestE2EFullIntegration_Browser(t *testing.T) { env := testlib.IntegrationEnv(t) + allScopes := []string{"openid", "offline_access", "pinniped:request-audience", "username", "groups"} + // Avoid allowing PINNIPED_UPSTREAM_IDENTITY_PROVIDER_FLOW to interfere with these tests. originalFlowEnvVarValue, flowOverrideEnvVarSet := os.LookupEnv("PINNIPED_UPSTREAM_IDENTITY_PROVIDER_FLOW") if flowOverrideEnvVarSet { @@ -170,7 +170,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { "--oidc-skip-browser", "--oidc-ca-bundle", testCABundlePath, "--oidc-session-cache", sessionCachePath, - "--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups", + // use default for --oidc-scopes, which is to request all relevant scopes }) // Run "kubectl get namespaces" which should trigger a browser login via the plugin. @@ -193,11 +193,12 @@ func TestE2EFullIntegration_Browser(t *testing.T) { requireKubectlGetNamespaceOutput(t, env, waitForKubectlOutput(t, kubectlOutputChan)) - requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, []string{coreosoidc.ScopeOfflineAccess, coreosoidc.ScopeOpenID, "pinniped:request-audience", "groups"}) + requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) }) - // If scopes aren't specified, we don't request the groups scope, which means we won't get any groups back in our token. - t.Run("with Supervisor OIDC upstream IDP and browser flow, scopes not specified", func(t *testing.T) { + // If the username and groups scope are not requested by the CLI, then the CLI still gets them, to allow for + // backwards compatibility with old CLIs that did not request those scopes because they did not exist yet. + t.Run("with Supervisor OIDC upstream IDP and browser flow, downstream username and groups scopes not requested", func(t *testing.T) { testCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) t.Cleanup(cancel) @@ -249,6 +250,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { "--oidc-skip-browser", "--oidc-ca-bundle", testCABundlePath, "--oidc-session-cache", sessionCachePath, + "--oidc-scopes", "offline_access,openid,pinniped:request-audience", // does not request username or groups }) // Run "kubectl get namespaces" which should trigger a browser login via the plugin. @@ -271,7 +273,12 @@ func TestE2EFullIntegration_Browser(t *testing.T) { requireKubectlGetNamespaceOutput(t, env, waitForKubectlOutput(t, kubectlOutputChan)) - requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, []string{}, []string{coreosoidc.ScopeOfflineAccess, coreosoidc.ScopeOpenID, "pinniped:request-audience"}) + // Note that the list of scopes param here is used to form the cache key for looking up local session storage. + // The scopes portion of the cache key is made up of the requested scopes from the CLI flag, not the granted + // scopes returned by the Supervisor, so list the requested scopes from the CLI flag here. This helper will + // assert that the expected username and groups claims/values are in the downstream ID token. + requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, + pinnipedExe, expectedUsername, []string{}, []string{"offline_access", "openid", "pinniped:request-audience"}) }) t.Run("with Supervisor OIDC upstream IDP and manual authcode copy-paste from browser flow", func(t *testing.T) { @@ -328,7 +335,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { "--oidc-skip-listen", "--oidc-ca-bundle", testCABundlePath, "--oidc-session-cache", sessionCachePath, - "--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups", + // use default for --oidc-scopes, which is to request all relevant scopes }) // Run "kubectl get namespaces" which should trigger a browser login via the plugin. @@ -382,7 +389,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { t.Logf("first kubectl command took %s", time.Since(start).String()) - requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, []string{coreosoidc.ScopeOfflineAccess, coreosoidc.ScopeOpenID, "pinniped:request-audience", "groups"}) + requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) }) t.Run("access token based refresh with Supervisor OIDC upstream IDP and manual authcode copy-paste from browser flow", func(t *testing.T) { @@ -447,7 +454,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { "--oidc-skip-listen", "--oidc-ca-bundle", testCABundlePath, "--oidc-session-cache", sessionCachePath, - "--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups", + // use default for --oidc-scopes, which is to request all relevant scopes }) // Run "kubectl get namespaces" which should trigger a browser login via the plugin. @@ -518,7 +525,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { t.Logf("first kubectl command took %s", time.Since(start).String()) - requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, []string{coreosoidc.ScopeOfflineAccess, coreosoidc.ScopeOpenID, "pinniped:request-audience", "groups"}) + requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) }) t.Run("with Supervisor OIDC upstream IDP and CLI password flow without web browser", func(t *testing.T) { @@ -574,7 +581,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { "--upstream-identity-provider-flow", "cli_password", // create a kubeconfig configured to use the cli_password flow "--oidc-ca-bundle", testCABundlePath, "--oidc-session-cache", sessionCachePath, - "--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups", + // use default for --oidc-scopes, which is to request all relevant scopes }) // Run "kubectl get namespaces" which should trigger a browser-less CLI prompt login via the plugin. @@ -601,7 +608,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { t.Logf("first kubectl command took %s", time.Since(start).String()) - requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, []string{coreosoidc.ScopeOfflineAccess, coreosoidc.ScopeOpenID, "pinniped:request-audience", "groups"}) + requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) }) t.Run("with Supervisor OIDC upstream IDP and CLI password flow when OIDCIdentityProvider disallows it", func(t *testing.T) { @@ -648,7 +655,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { "--upstream-identity-provider-flow", "cli_password", "--oidc-ca-bundle", testCABundlePath, "--oidc-session-cache", sessionCachePath, - "--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups", + // use default for --oidc-scopes, which is to request all relevant scopes }) // Run "kubectl get --raw /healthz" which should trigger a browser-less CLI prompt login via the plugin. @@ -710,7 +717,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { "--concierge-authenticator-type", "jwt", "--concierge-authenticator-name", authenticator.Name, "--oidc-session-cache", sessionCachePath, - "--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups", + // use default for --oidc-scopes, which is to request all relevant scopes }) // Run "kubectl get namespaces" which should trigger an LDAP-style login CLI prompt via the plugin. @@ -737,7 +744,66 @@ func TestE2EFullIntegration_Browser(t *testing.T) { t.Logf("first kubectl command took %s", time.Since(start).String()) - requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, []string{coreosoidc.ScopeOfflineAccess, coreosoidc.ScopeOpenID, "pinniped:request-audience", "groups"}) + requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) + }) + + // If the username and groups scope are not requested by the CLI, then the CLI still gets them, to allow for + // backwards compatibility with old CLIs that did not request those scopes because they did not exist yet. + t.Run("with Supervisor LDAP upstream IDP using username and password prompts, downstream username and groups scopes not requested", func(t *testing.T) { + testlib.SkipTestWhenLDAPIsUnavailable(t, env) + + testCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + + tempDir := testutil.TempDir(t) // per-test tmp dir to avoid sharing files between tests + + expectedUsername := env.SupervisorUpstreamLDAP.TestUserMailAttributeValue + expectedGroups := env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs + + setupClusterForEndToEndLDAPTest(t, expectedUsername, env) + + // Use a specific session cache for this test. + sessionCachePath := tempDir + "/test-sessions.yaml" + + kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ + "get", "kubeconfig", + "--concierge-api-group-suffix", env.APIGroupSuffix, + "--concierge-authenticator-type", "jwt", + "--concierge-authenticator-name", authenticator.Name, + "--oidc-session-cache", sessionCachePath, + "--oidc-scopes", "offline_access,openid,pinniped:request-audience", // does not request username or groups + }) + + // Run "kubectl get namespaces" which should trigger an LDAP-style login CLI prompt via the plugin. + start := time.Now() + kubectlCmd := exec.CommandContext(testCtx, "kubectl", "get", "namespace", "--kubeconfig", kubeconfigPath) + kubectlCmd.Env = append(os.Environ(), env.ProxyEnv()...) + ptyFile, err := pty.Start(kubectlCmd) + require.NoError(t, err) + + // Wait for the subprocess to print the username prompt, then type the user's username. + readFromFileUntilStringIsSeen(t, ptyFile, "Username: ") + _, err = ptyFile.WriteString(expectedUsername + "\n") + require.NoError(t, err) + + // Wait for the subprocess to print the password prompt, then type the user's password. + readFromFileUntilStringIsSeen(t, ptyFile, "Password: ") + _, err = ptyFile.WriteString(env.SupervisorUpstreamLDAP.TestUserPassword + "\n") + require.NoError(t, err) + + // Read all output from the subprocess until EOF. + // Ignore any errors returned because there is always an error on linux. + kubectlOutputBytes, _ := ioutil.ReadAll(ptyFile) + requireKubectlGetNamespaceOutput(t, env, string(kubectlOutputBytes)) + + t.Logf("first kubectl command took %s", time.Since(start).String()) + + // Note that the list of scopes param here is used to form the cache key for looking up local session storage. + // The scopes portion of the cache key is made up of the requested scopes from the CLI flag, not the granted + // scopes returned by the Supervisor, so list the requested scopes from the CLI flag here. This helper will + // assert that the expected username and groups claims/values are in the downstream ID token. + requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, + pinnipedExe, expectedUsername, expectedGroups, []string{"offline_access", "openid", "pinniped:request-audience"}) }) // Add an LDAP upstream IDP and try using it to authenticate during kubectl commands @@ -764,7 +830,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { "--concierge-authenticator-type", "jwt", "--concierge-authenticator-name", authenticator.Name, "--oidc-session-cache", sessionCachePath, - "--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups", + // use default for --oidc-scopes, which is to request all relevant scopes }) // Set up the username and password env vars to avoid the interactive prompts. @@ -803,7 +869,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { require.NoError(t, os.Unsetenv(usernameEnvVar)) require.NoError(t, os.Unsetenv(passwordEnvVar)) - requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, []string{coreosoidc.ScopeOfflineAccess, coreosoidc.ScopeOpenID, "pinniped:request-audience", "groups"}) + requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) }) // Add an Active Directory upstream IDP and try using it to authenticate during kubectl commands @@ -830,7 +896,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { "--concierge-authenticator-type", "jwt", "--concierge-authenticator-name", authenticator.Name, "--oidc-session-cache", sessionCachePath, - "--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups", + // use default for --oidc-scopes, which is to request all relevant scopes }) // Run "kubectl get namespaces" which should trigger an LDAP-style login CLI prompt via the plugin. @@ -857,7 +923,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { t.Logf("first kubectl command took %s", time.Since(start).String()) - requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, []string{coreosoidc.ScopeOfflineAccess, coreosoidc.ScopeOpenID, "pinniped:request-audience", "groups"}) + requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) }) // Add an ActiveDirectory upstream IDP and try using it to authenticate during kubectl commands @@ -884,7 +950,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { "--concierge-authenticator-type", "jwt", "--concierge-authenticator-name", authenticator.Name, "--oidc-session-cache", sessionCachePath, - "--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups", + // use default for --oidc-scopes, which is to request all relevant scopes }) // Set up the username and password env vars to avoid the interactive prompts. @@ -923,7 +989,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { require.NoError(t, os.Unsetenv(usernameEnvVar)) require.NoError(t, os.Unsetenv(passwordEnvVar)) - requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, []string{coreosoidc.ScopeOfflineAccess, coreosoidc.ScopeOpenID, "pinniped:request-audience", "groups"}) + requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) }) // Add an LDAP upstream IDP and try using it to authenticate during kubectl commands, using the browser flow. @@ -955,7 +1021,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { "--oidc-ca-bundle", testCABundlePath, "--upstream-identity-provider-flow", "browser_authcode", "--oidc-session-cache", sessionCachePath, - "--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups", + // use default for --oidc-scopes, which is to request all relevant scopes }) // Run "kubectl get namespaces" which should trigger a browser login via the plugin. @@ -973,7 +1039,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { requireKubectlGetNamespaceOutput(t, env, waitForKubectlOutput(t, kubectlOutputChan)) - requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, []string{coreosoidc.ScopeOfflineAccess, coreosoidc.ScopeOpenID, "pinniped:request-audience", "groups"}) + requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) }) // Add an Active Directory upstream IDP and try using it to authenticate during kubectl commands, using the browser flow. @@ -1005,7 +1071,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { "--oidc-ca-bundle", testCABundlePath, "--upstream-identity-provider-flow", "browser_authcode", "--oidc-session-cache", sessionCachePath, - "--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups", + // use default for --oidc-scopes, which is to request all relevant scopes }) // Run "kubectl get namespaces" which should trigger a browser login via the plugin. @@ -1023,7 +1089,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { requireKubectlGetNamespaceOutput(t, env, waitForKubectlOutput(t, kubectlOutputChan)) - requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, []string{coreosoidc.ScopeOfflineAccess, coreosoidc.ScopeOpenID, "pinniped:request-audience", "groups"}) + requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) }) // Add an LDAP upstream IDP and try using it to authenticate during kubectl commands, using the env var to choose the browser flow. @@ -1055,7 +1121,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { "--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 "--oidc-session-cache", sessionCachePath, - "--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups", + // use default for --oidc-scopes, which is to request all relevant scopes }) // Override the --upstream-identity-provider-flow flag from the kubeconfig using the env var. @@ -1079,7 +1145,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { requireKubectlGetNamespaceOutput(t, env, waitForKubectlOutput(t, kubectlOutputChan)) - requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, []string{coreosoidc.ScopeOfflineAccess, coreosoidc.ScopeOpenID, "pinniped:request-audience", "groups"}) + requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) }) } @@ -1337,17 +1403,17 @@ func requireUserCanUseKubectlWithoutAuthenticatingAgain( requireGCAnnotationsOnSessionStorage(ctx, t, env.SupervisorNamespace, startTime, token) idTokenClaims := token.IDToken.Claims - require.Equal(t, expectedUsername, idTokenClaims[oidc.DownstreamUsernameClaim]) + require.Equal(t, expectedUsername, idTokenClaims["username"]) if expectedGroups == nil { - require.Nil(t, idTokenClaims[oidc.DownstreamGroupsClaim]) + require.Nil(t, idTokenClaims["groups"]) } else { // The groups claim in the file ends up as an []interface{}, so adjust our expectation to match. expectedGroupsAsEmptyInterfaces := make([]interface{}, 0, len(expectedGroups)) for _, g := range expectedGroups { expectedGroupsAsEmptyInterfaces = append(expectedGroupsAsEmptyInterfaces, g) } - require.ElementsMatch(t, expectedGroupsAsEmptyInterfaces, idTokenClaims[oidc.DownstreamGroupsClaim]) + require.ElementsMatch(t, expectedGroupsAsEmptyInterfaces, idTokenClaims["groups"]) } expectedGroupsPlusAuthenticated := append([]string{}, expectedGroups...) diff --git a/test/integration/supervisor_discovery_test.go b/test/integration/supervisor_discovery_test.go index 2d828e4c..30f96d41 100644 --- a/test/integration/supervisor_discovery_test.go +++ b/test/integration/supervisor_discovery_test.go @@ -502,11 +502,11 @@ func requireWellKnownEndpointIsWorking(t *testing.T, supervisorScheme, superviso "token_endpoint": "%s/oauth2/token", "token_endpoint_auth_methods_supported": ["client_secret_basic"], "jwks_uri": "%s/jwks.json", - "scopes_supported": ["openid", "offline"], + "scopes_supported": ["openid", "offline_access", "pinniped:request-audience", "username", "groups"], "response_types_supported": ["code"], "response_modes_supported": ["query", "form_post"], "code_challenge_methods_supported": ["S256"], - "claims_supported": ["groups"], + "claims_supported": ["username", "groups"], "discovery.supervisor.pinniped.dev/v1alpha1": {"pinniped_identity_providers_endpoint": "%s/v1alpha1/pinniped_identity_providers"}, "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["ES256"] diff --git a/test/integration/supervisor_login_test.go b/test/integration/supervisor_login_test.go index b465a17d..069923ff 100644 --- a/test/integration/supervisor_login_test.go +++ b/test/integration/supervisor_login_test.go @@ -207,6 +207,9 @@ func TestSupervisorLogin_Browser(t *testing.T) { // The scopes to request from the authorization endpoint. Defaults will be used when not specified. downstreamScopes []string + // The scopes to want granted from the authorization endpoint. Defaults to the downstreamScopes value when not, + // specified, i.e. by default it expects that all requested scopes were granted. + wantDownstreamScopes []string // When we want the localhost callback to have never happened, then the flow will stop there. The login was // unable to finish so there is nothing to assert about what should have happened with the callback, and there @@ -218,6 +221,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { // The expected ID token subject claim value as a regexp, for the original ID token and the refreshed ID token. wantDownstreamIDTokenSubjectToMatch string // The expected ID token username claim value as a regexp, for the original ID token and the refreshed ID token. + // This function should return an empty string when there should be no username claim in the ID tokens. wantDownstreamIDTokenUsernameToMatch func(username string) string // The expected ID token groups claim value, for the original ID token and the refreshed ID token. wantDownstreamIDTokenGroups []string @@ -240,7 +244,8 @@ func TestSupervisorLogin_Browser(t *testing.T) { wantTokenExchangeResponse func(t *testing.T, status int, body string) // Optionally edit the refresh session data between the initial login and the first refresh, - // which is still expected to succeed after these edits. + // which is still expected to succeed after these edits. Returns the group memberships expected after the + // refresh is performed. editRefreshSessionDataWithoutBreaking func(t *testing.T, sessionData *psession.PinnipedSession, idpName, username string) []string // Optionally either revoke the user's session on the upstream provider, or manipulate the user's session // data in such a way that it should cause the next upstream refresh attempt to fail. @@ -278,8 +283,8 @@ func TestSupervisorLogin_Browser(t *testing.T) { }, requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC, breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { - fositeSessionData := pinnipedSession.Fosite - fositeSessionData.Claims.Extra["username"] = "some-incorrect-username" + customSessionData := pinnipedSession.Custom + customSessionData.Username = "some-incorrect-username" }, wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Username) + "$" }, @@ -321,8 +326,8 @@ func TestSupervisorLogin_Browser(t *testing.T) { }, requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC, breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { - fositeSessionData := pinnipedSession.Fosite - fositeSessionData.Claims.Extra["username"] = "some-incorrect-username" + customSessionData := pinnipedSession.Custom + customSessionData.Username = "some-incorrect-username" }, wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Username) + "$" }, @@ -400,13 +405,14 @@ func TestSupervisorLogin_Browser(t *testing.T) { wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, }, { - name: "ldap without requesting groups scope", + name: "ldap without requesting username and groups scope gets them anyway for pinniped-cli for backwards compatibility with old CLIs", maybeSkip: skipLDAPTests, createIDP: func(t *testing.T) string { idp, _ := createLDAPIdentityProvider(t, nil) return idp.Name }, - downstreamScopes: []string{"openid", "pinniped:request-audience", "offline_access"}, + downstreamScopes: []string{"openid", "pinniped:request-audience", "offline_access"}, + wantDownstreamScopes: []string{"openid", "pinniped:request-audience", "offline_access", "username", "groups"}, requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, @@ -426,10 +432,10 @@ func TestSupervisorLogin_Browser(t *testing.T) { wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$" }, - wantDownstreamIDTokenGroups: []string{}, + wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, }, { - name: "oidc without requesting groups scope", + name: "oidc without requesting username and groups scope gets them anyway for pinniped-cli for backwards compatibility with old CLIs", maybeSkip: skipNever, createIDP: func(t *testing.T) string { spec := basicOIDCIdentityProviderSpec() @@ -443,10 +449,11 @@ func TestSupervisorLogin_Browser(t *testing.T) { return testlib.CreateTestOIDCIdentityProvider(t, spec, idpv1alpha1.PhaseReady).Name }, downstreamScopes: []string{"openid", "pinniped:request-audience", "offline_access"}, + wantDownstreamScopes: []string{"openid", "pinniped:request-audience", "offline_access", "username", "groups"}, requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC, wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Username) + "$" }, - wantDownstreamIDTokenGroups: nil, + wantDownstreamIDTokenGroups: env.SupervisorUpstreamOIDC.ExpectedGroups, }, { name: "ldap with browser flow", @@ -649,8 +656,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { customSessionData := pinnipedSession.Custom require.Equal(t, psession.ProviderTypeLDAP, customSessionData.ProviderType) require.NotEmpty(t, customSessionData.LDAP.UserDN) - fositeSessionData := pinnipedSession.Fosite - fositeSessionData.Claims.Extra["username"] = "not-the-same" + customSessionData.Username = "not-the-same" }, // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( @@ -829,8 +835,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { customSessionData := pinnipedSession.Custom require.Equal(t, psession.ProviderTypeActiveDirectory, customSessionData.ProviderType) require.NotEmpty(t, customSessionData.ActiveDirectory.UserDN) - fositeSessionData := pinnipedSession.Fosite - fositeSessionData.Claims.Extra["username"] = "not-the-same" + customSessionData.Username = "not-the-same" }, // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( @@ -1284,7 +1289,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { }, }, { - name: "oidc upstream with downstream dynamic client happy path", + name: "oidc upstream with downstream dynamic client happy path, requesting all scopes", maybeSkip: skipNever, createIDP: func(t *testing.T) string { return testlib.CreateTestOIDCIdentityProvider(t, basicOIDCIdentityProviderSpec(), idpv1alpha1.PhaseReady).Name @@ -1301,9 +1306,10 @@ func TestSupervisorLogin_Browser(t *testing.T) { wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", // the ID token Username should include the upstream user ID after the upstream issuer name wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+" }, + wantDownstreamIDTokenGroups: env.SupervisorUpstreamOIDC.ExpectedGroups, }, { - name: "ldap upstream with downstream dynamic client happy path", + name: "ldap upstream with downstream dynamic client happy path, requesting all scopes", maybeSkip: skipLDAPTests, createIDP: func(t *testing.T) string { idp, _ := createLDAPIdentityProvider(t, nil) @@ -1334,6 +1340,237 @@ func TestSupervisorLogin_Browser(t *testing.T) { }, wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, }, + { + name: "ldap upstream with downstream dynamic client when dynamic client is not allowed to use the token exchange grant type, causes token exchange error", + maybeSkip: skipLDAPTests, + createIDP: func(t *testing.T) string { + idp, _ := createLDAPIdentityProvider(t, nil) + return idp.Name + }, + createOIDCClient: func(t *testing.T, callbackURL string) (string, string) { + return testlib.CreateOIDCClient(t, configv1alpha1.OIDCClientSpec{ + AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)}, + AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "refresh_token"}, // token exchange grant type not allowed + AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "username", "groups"}, // a validation requires that we also disallow the pinniped:request-audience scope + }, configv1alpha1.PhaseReady) + }, + testUser: func(t *testing.T) (string, string) { + // return the username and password of the existing user that we want to use for this test + return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login + env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login + }, + downstreamScopes: []string{"openid", "offline_access", "username", "groups"}, // does not request (or expect) pinniped:request-audience token exchange scope + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAP, + // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { + return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$" + }, + wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, + wantTokenExchangeResponse: func(t *testing.T, status int, body string) { // can't do token exchanges without the token exchange grant type + require.Equal(t, http.StatusBadRequest, status) + require.Equal(t, + `{"error":"unauthorized_client","error_description":"The client is not authorized to request a token using this method. `+ + `The OAuth 2.0 Client is not allowed to use token exchange grant 'urn:ietf:params:oauth:grant-type:token-exchange'."}`, + body) + }, + }, + { + name: "ldap upstream with downstream dynamic client when dynamic client that does not request the pinniped:request-audience scope, causes token exchange error", + maybeSkip: skipLDAPTests, + createIDP: func(t *testing.T) string { + idp, _ := createLDAPIdentityProvider(t, nil) + return idp.Name + }, + createOIDCClient: func(t *testing.T, callbackURL string) (string, string) { + return testlib.CreateOIDCClient(t, configv1alpha1.OIDCClientSpec{ + AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)}, + AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"}, + AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"}, + }, configv1alpha1.PhaseReady) + }, + testUser: func(t *testing.T) (string, string) { + // return the username and password of the existing user that we want to use for this test + return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login + env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login + }, + downstreamScopes: []string{"openid", "offline_access", "username", "groups"}, // does not request (or expect) pinniped:request-audience token exchange scope + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAP, + // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { + return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$" + }, + wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, + wantTokenExchangeResponse: func(t *testing.T, status int, body string) { // can't do token exchanges without the pinniped:request-audience token exchange scope + require.Equal(t, http.StatusForbidden, status) + require.Equal(t, + `{"error":"access_denied","error_description":"The resource owner or authorization server denied the request. `+ + `missing the 'pinniped:request-audience' scope"}`, + body) + }, + }, + { + name: "ldap upstream with downstream dynamic client when dynamic client is not allowed to request username but requests username anyway, causes authorization error", + maybeSkip: skipLDAPTests, + createIDP: func(t *testing.T) string { + idp, _ := createLDAPIdentityProvider(t, nil) + return idp.Name + }, + createOIDCClient: func(t *testing.T, callbackURL string) (string, string) { + return testlib.CreateOIDCClient(t, configv1alpha1.OIDCClientSpec{ + AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)}, + AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "refresh_token"}, // token exchange not allowed (required to exclude groups scope) + AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "groups"}, // username not allowed + }, configv1alpha1.PhaseReady) + }, + testUser: func(t *testing.T) (string, string) { + // return the username and password of the existing user that we want to use for this test + return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login + env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login + }, + downstreamScopes: []string{"openid", "offline_access", "username"}, // request username, even though the client is not allowed to request it + // Should have been immediately redirected back to the local callback server with an error in this case, + // since we requested a scope that the client is not allowed to request. The login UI page is never shown. + requestAuthorization: requestAuthorizationAndExpectImmediateRedirectToCallback, + wantAuthorizationErrorDescription: "The requested scope is invalid, unknown, or malformed. The OAuth 2.0 Client is not allowed to request scope 'username'.", + wantAuthorizationErrorType: "invalid_scope", + }, + { + name: "ldap upstream with downstream dynamic client when dynamic client is not allowed to request groups but requests groups anyway, causes authorization error", + maybeSkip: skipLDAPTests, + createIDP: func(t *testing.T) string { + idp, _ := createLDAPIdentityProvider(t, nil) + return idp.Name + }, + createOIDCClient: func(t *testing.T, callbackURL string) (string, string) { + return testlib.CreateOIDCClient(t, configv1alpha1.OIDCClientSpec{ + AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)}, + AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "refresh_token"}, // token exchange not allowed (required to exclude groups scope) + AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "username"}, // groups not allowed + }, configv1alpha1.PhaseReady) + }, + testUser: func(t *testing.T) (string, string) { + // return the username and password of the existing user that we want to use for this test + return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login + env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login + }, + downstreamScopes: []string{"openid", "offline_access", "groups"}, // request groups, even though the client is not allowed to request it + // Should have been immediately redirected back to the local callback server with an error in this case, + // since we requested a scope that the client is not allowed to request. The login UI page is never shown. + requestAuthorization: requestAuthorizationAndExpectImmediateRedirectToCallback, + wantAuthorizationErrorDescription: "The requested scope is invalid, unknown, or malformed. The OAuth 2.0 Client is not allowed to request scope 'groups'.", + wantAuthorizationErrorType: "invalid_scope", + }, + { + name: "ldap upstream with downstream dynamic client when dynamic client does not request groups happy path", + maybeSkip: skipLDAPTests, + createIDP: func(t *testing.T) string { + idp, _ := createLDAPIdentityProvider(t, nil) + return idp.Name + }, + createOIDCClient: func(t *testing.T, callbackURL string) (string, string) { + return testlib.CreateOIDCClient(t, configv1alpha1.OIDCClientSpec{ + AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)}, + AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"}, + AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"}, + }, configv1alpha1.PhaseReady) + }, + testUser: func(t *testing.T) (string, string) { + // return the username and password of the existing user that we want to use for this test + return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login + env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login + }, + downstreamScopes: []string{"openid", "pinniped:request-audience", "offline_access", "username"}, // do not request (or expect) groups + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAP, + // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute + wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( + "ldaps://"+env.SupervisorUpstreamLDAP.Host+ + "?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+ + "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), + ) + "$", + // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { + return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$" + }, + wantDownstreamIDTokenGroups: nil, // did not request groups, so should not have got any groups + }, + { + name: "ldap upstream with downstream dynamic client when dynamic client does not request username, is allowed to auth but cannot do token exchange", + maybeSkip: skipLDAPTests, + createIDP: func(t *testing.T) string { + idp, _ := createLDAPIdentityProvider(t, nil) + return idp.Name + }, + createOIDCClient: func(t *testing.T, callbackURL string) (string, string) { + return testlib.CreateOIDCClient(t, configv1alpha1.OIDCClientSpec{ + AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)}, + AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"}, + AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"}, + }, configv1alpha1.PhaseReady) + }, + testUser: func(t *testing.T) (string, string) { + // return the username and password of the existing user that we want to use for this test + return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login + env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login + }, + downstreamScopes: []string{"openid", "pinniped:request-audience", "offline_access", "groups"}, // do not request (or expect) username + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAP, + // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute + wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( + "ldaps://"+env.SupervisorUpstreamLDAP.Host+ + "?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+ + "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), + ) + "$", + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { + return "" // username should not exist as a claim since we did not request it + }, + wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, + wantTokenExchangeResponse: func(t *testing.T, status int, body string) { // can't do token exchanges without a username + require.Equal(t, http.StatusForbidden, status) + require.Equal(t, + `{"error":"access_denied","error_description":"The resource owner or authorization server denied the request. `+ + `No username found in session. Ensure that the 'username' scope was requested and granted at the authorization endpoint."}`, + body) + }, + }, + { + name: "ldap upstream with downstream dynamic client when dynamic client is not allowed to request username or groups and does not request them, is allowed to auth but cannot do token exchange", + maybeSkip: skipLDAPTests, + createIDP: func(t *testing.T) string { + idp, _ := createLDAPIdentityProvider(t, nil) + return idp.Name + }, + createOIDCClient: func(t *testing.T, callbackURL string) (string, string) { + return testlib.CreateOIDCClient(t, configv1alpha1.OIDCClientSpec{ + AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)}, + AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "refresh_token"}, + AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access"}, // validations require that when username/groups are excluded, then token exchange must also not be allowed + }, configv1alpha1.PhaseReady) + }, + testUser: func(t *testing.T) (string, string) { + // return the username and password of the existing user that we want to use for this test + return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login + env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login + }, + downstreamScopes: []string{"openid", "offline_access"}, // do not request (or expect) pinniped:request-audience or username or groups + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAP, + // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute + wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( + "ldaps://"+env.SupervisorUpstreamLDAP.Host+ + "?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+ + "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), + ) + "$", + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { + return "" // username should not exist as a claim since we did not request it + }, + wantDownstreamIDTokenGroups: nil, // did not request groups, so should not have got any groups + wantTokenExchangeResponse: func(t *testing.T, status int, body string) { // can't do token exchanges without the token exchange grant type + require.Equal(t, http.StatusBadRequest, status) + require.Equal(t, + `{"error":"unauthorized_client","error_description":"The client is not authorized to request a token using this method. `+ + `The OAuth 2.0 Client is not allowed to use token exchange grant 'urn:ietf:params:oauth:grant-type:token-exchange'."}`, + body) + }, + }, { name: "active directory with all default options with downstream dynamic client happy path", maybeSkip: skipActiveDirectoryTests, @@ -1411,6 +1648,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { tt.createOIDCClient, tt.downstreamScopes, tt.requestTokenExchangeAud, + tt.wantDownstreamScopes, tt.wantLocalhostCallbackToNeverHappen, tt.wantDownstreamIDTokenSubjectToMatch, tt.wantDownstreamIDTokenUsernameToMatch, @@ -1552,6 +1790,7 @@ func testSupervisorLogin( createOIDCClient func(t *testing.T, callbackURL string) (string, string), downstreamScopes []string, requestTokenExchangeAud string, + wantDownstreamScopes []string, wantLocalhostCallbackToNeverHappen bool, wantDownstreamIDTokenSubjectToMatch string, wantDownstreamIDTokenUsernameToMatch func(username string) string, @@ -1672,7 +1911,13 @@ func testSupervisorLogin( }, 30*time.Second, 200*time.Millisecond) if downstreamScopes == nil { - downstreamScopes = []string{"openid", "pinniped:request-audience", "offline_access", "groups"} + // By default, tests will request all the relevant groups. + downstreamScopes = []string{"openid", "pinniped:request-audience", "offline_access", "username", "groups"} + } + if wantDownstreamScopes == nil { + // By default, tests will want that all requested scopes were granted. + wantDownstreamScopes = make([]string, len(downstreamScopes)) + copy(wantDownstreamScopes, downstreamScopes) } // Create the OAuth2 configuration. @@ -1728,14 +1973,14 @@ func testSupervisorLogin( if wantAuthorizationErrorType != "" { errorDescription := callback.URL.Query().Get("error_description") errorType := callback.URL.Query().Get("error") - require.Equal(t, errorDescription, wantAuthorizationErrorDescription) - require.Equal(t, errorType, wantAuthorizationErrorType) + require.Equal(t, wantAuthorizationErrorDescription, errorDescription) + require.Equal(t, wantAuthorizationErrorType, errorType) // The authorization has failed, so can't continue the login flow, making this the end of the test case. return } require.Equal(t, stateParam.String(), callback.URL.Query().Get("state")) - require.ElementsMatch(t, downstreamScopes, strings.Split(callback.URL.Query().Get("scope"), " ")) + require.ElementsMatch(t, wantDownstreamScopes, strings.Split(callback.URL.Query().Get("scope"), " ")) authcode := callback.URL.Query().Get("code") require.NotEmpty(t, authcode) @@ -1750,8 +1995,14 @@ func testSupervisorLogin( return } require.NoError(t, err) - expectedIDTokenClaims := []string{"iss", "exp", "sub", "aud", "auth_time", "iat", "jti", "nonce", "rat", "username"} - if slices.Contains(downstreamScopes, "groups") { + + expectedIDTokenClaims := []string{"iss", "exp", "sub", "aud", "auth_time", "iat", "jti", "nonce", "rat"} + if slices.Contains(wantDownstreamScopes, "username") { + // If the test wants the username scope to have been granted, then also expect the claim in the ID token. + expectedIDTokenClaims = append(expectedIDTokenClaims, "username") + } + if slices.Contains(wantDownstreamScopes, "groups") { + // If the test wants the groups scope to have been granted, then also expect the claim in the ID token. expectedIDTokenClaims = append(expectedIDTokenClaims, "groups") } verifyTokenResponse(t, @@ -1764,7 +2015,7 @@ func testSupervisorLogin( } doTokenExchange(t, requestTokenExchangeAud, &downstreamOAuth2Config, tokenResponse, httpClient, discovery, wantTokenExchangeResponse) - refreshedGroups := wantDownstreamIDTokenGroups + wantRefreshedGroups := wantDownstreamIDTokenGroups if editRefreshSessionDataWithoutBreaking != nil { latestRefreshToken := tokenResponse.RefreshToken signatureOfLatestRefreshToken := getFositeDataSignature(t, latestRefreshToken) @@ -1780,7 +2031,7 @@ func testSupervisorLogin( pinnipedSession, ok := storedRefreshSession.GetSession().(*psession.PinnipedSession) require.True(t, ok, "should have been able to cast session data to PinnipedSession") - refreshedGroups = editRefreshSessionDataWithoutBreaking(t, pinnipedSession, idpName, username) + wantRefreshedGroups = editRefreshSessionDataWithoutBreaking(t, pinnipedSession, idpName, username) // Then save the mutated Secret back to Kubernetes. // There is no update function, so delete and create again at the same name. @@ -1793,13 +2044,18 @@ func testSupervisorLogin( require.NoError(t, err) // When refreshing, expect to get an "at_hash" claim, but no "nonce" claim. - expectRefreshedIDTokenClaims := []string{"iss", "exp", "sub", "aud", "auth_time", "iat", "jti", "rat", "username", "at_hash"} - if slices.Contains(downstreamScopes, "groups") { + expectRefreshedIDTokenClaims := []string{"iss", "exp", "sub", "aud", "auth_time", "iat", "jti", "rat", "at_hash"} + if slices.Contains(wantDownstreamScopes, "username") { + // If the test wants the username scope to have been granted, then also expect the claim in the refreshed ID token. + expectRefreshedIDTokenClaims = append(expectRefreshedIDTokenClaims, "username") + } + if slices.Contains(wantDownstreamScopes, "groups") { + // If the test wants the groups scope to have been granted, then also expect the claim in the refreshed ID token. expectRefreshedIDTokenClaims = append(expectRefreshedIDTokenClaims, "groups") } verifyTokenResponse(t, refreshedTokenResponse, discovery, downstreamOAuth2Config, "", - expectRefreshedIDTokenClaims, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch(username), refreshedGroups) + expectRefreshedIDTokenClaims, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch(username), wantRefreshedGroups) require.NotEqual(t, tokenResponse.AccessToken, refreshedTokenResponse.AccessToken) require.NotEqual(t, tokenResponse.RefreshToken, refreshedTokenResponse.RefreshToken) @@ -1892,8 +2148,11 @@ func verifyTokenResponse( } require.ElementsMatch(t, expectedIDTokenClaims, idTokenClaimNames) - // Check username claim of the ID token. - require.Regexp(t, wantDownstreamIDTokenUsernameToMatch, idTokenClaims["username"].(string)) + // Check username claim of the ID token, if one is expected. Asserting on the lack of a username claim is + // handled above where the full list of claims are asserted. + if wantDownstreamIDTokenUsernameToMatch != "" { + require.Regexp(t, wantDownstreamIDTokenUsernameToMatch, idTokenClaims["username"].(string)) + } // Check the groups claim. require.ElementsMatch(t, wantDownstreamIDTokenGroups, idTokenClaims["groups"]) @@ -1912,6 +2171,21 @@ func verifyTokenResponse( require.True(t, strings.HasPrefix(tokenResponse.RefreshToken, "pin_rt_"), "token %q did not have expected prefix 'pin_rt_'", tokenResponse.RefreshToken) } +func requestAuthorizationAndExpectImmediateRedirectToCallback(t *testing.T, _, downstreamAuthorizeURL, downstreamCallbackURL, _, _ string, _ *http.Client) { + t.Helper() + + // Open the web browser and navigate to the downstream authorize URL. + page := browsertest.Open(t) + t.Logf("opening browser to downstream authorize URL %s", testlib.MaskTokens(downstreamAuthorizeURL)) + require.NoError(t, page.Navigate(downstreamAuthorizeURL)) + + // Expect that it immediately redirects back to the callback, which is what happens for certain types of errors + // where it is not worth redirecting to the login UI page. + t.Logf("waiting for redirect to callback") + callbackURLPattern := regexp.MustCompile(`\A` + regexp.QuoteMeta(downstreamCallbackURL) + `\?.+\z`) + browsertest.WaitForURL(t, page, callbackURLPattern) +} + func requestAuthorizationUsingBrowserAuthcodeFlowOIDC(t *testing.T, _, downstreamAuthorizeURL, downstreamCallbackURL, _, _ string, httpClient *http.Client) { t.Helper() env := testlib.IntegrationEnv(t) diff --git a/test/integration/supervisor_warnings_test.go b/test/integration/supervisor_warnings_test.go index 55cbf7dd..dcc05a4c 100644 --- a/test/integration/supervisor_warnings_test.go +++ b/test/integration/supervisor_warnings_test.go @@ -19,7 +19,6 @@ import ( "testing" "time" - coreosoidc "github.com/coreos/go-oidc/v3/oidc" "github.com/creack/pty" "github.com/stretchr/testify/require" authorizationv1 "k8s.io/api/authorization/v1" @@ -173,7 +172,7 @@ func TestSupervisorWarnings_Browser(t *testing.T) { })) // construct the cache key - downstreamScopes := []string{coreosoidc.ScopeOfflineAccess, coreosoidc.ScopeOpenID, "pinniped:request-audience", "groups"} + downstreamScopes := []string{"offline_access", "openid", "pinniped:request-audience", "groups"} sort.Strings(downstreamScopes) sessionCacheKey := oidcclient.SessionCacheKey{ Issuer: downstream.Spec.Issuer, @@ -481,7 +480,7 @@ func TestSupervisorWarnings_Browser(t *testing.T) { })) // construct the cache key - downstreamScopes := []string{coreosoidc.ScopeOfflineAccess, coreosoidc.ScopeOpenID, "pinniped:request-audience", "groups"} + downstreamScopes := []string{"offline_access", "openid", "pinniped:request-audience", "groups"} sort.Strings(downstreamScopes) sessionCacheKey := oidcclient.SessionCacheKey{ Issuer: downstream.Spec.Issuer,