Path: blob/main/components/public-api-server/pkg/apiv1/identityprovider_test.go
2499 views
// Copyright (c) 2023 Gitpod GmbH. All rights reserved.1// Licensed under the GNU Affero General Public License (AGPL).2// See License.AGPL.txt in the project root for license information.34package apiv156import (7"context"8"errors"9"fmt"10"net/http"11"net/http/httptest"12"testing"1314connect "github.com/bufbuild/connect-go"15"github.com/gitpod-io/gitpod/common-go/experiments"16"github.com/gitpod-io/gitpod/common-go/experiments/experimentstest"17"github.com/gitpod-io/gitpod/components/public-api/go/config"18v1 "github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1"19"github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1/v1connect"20protocol "github.com/gitpod-io/gitpod/gitpod-protocol"21"github.com/gitpod-io/gitpod/public-api-server/pkg/auth"22"github.com/gitpod-io/gitpod/public-api-server/pkg/jws"23"github.com/gitpod-io/gitpod/public-api-server/pkg/jws/jwstest"24"github.com/golang/mock/gomock"25"github.com/google/go-cmp/cmp"26"github.com/google/go-cmp/cmp/cmpopts"27"github.com/sourcegraph/jsonrpc2"28"github.com/stretchr/testify/require"29"github.com/zitadel/oidc/pkg/oidc"30)3132func TestGetIDToken(t *testing.T) {33const workspaceID = "gitpodio-gitpod-te23l4bjejv"34type Expectation struct {35Error string36Response *v1.GetIDTokenResponse37}38tests := []struct {39Name string40TokenSource func(t *testing.T) IDTokenSource41ServerSetup func(*protocol.MockAPIInterface)42Request *v1.GetIDTokenRequest4344Expectation Expectation45}{46{47Name: "org-owned user",48TokenSource: func(t *testing.T) IDTokenSource {49return functionIDTokenSource(func(ctx context.Context, org string, audience []string, userInfo oidc.UserInfo) (string, error) {50require.Equal(t, "[email protected]", userInfo.GetEmail())51require.True(t, userInfo.IsEmailVerified())5253return "foobar", nil54})55},56ServerSetup: func(ma *protocol.MockAPIInterface) {57ma.EXPECT().GetIDToken(gomock.Any()).MinTimes(1).Return(nil)58ma.EXPECT().GetWorkspace(gomock.Any(), workspaceID).MinTimes(1).Return(59&protocol.WorkspaceInfo{60Workspace: &protocol.Workspace{61ContextURL: "https://github.com/gitpod-io/gitpod",62Context: &protocol.WorkspaceContext{63Repository: &protocol.Repository{64CloneURL: "https://github.com/gitpod-io/gitpod.git",65},66NormalizedContextURL: "https://github.com/gitpod-io/gitpod",67},68},69},70nil,71)72ma.EXPECT().GetLoggedInUser(gomock.Any()).Return(73&protocol.User{74Name: "foobar",75Identities: []*protocol.Identity{76nil,77{Deleted: true, PrimaryEmail: "[email protected]"},78{Deleted: false, PrimaryEmail: "[email protected]", LastSigninTime: "2021-01-01T00:00:00Z"},79},80OrganizationId: "test",81},82nil,83)84},85Request: &v1.GetIDTokenRequest{86WorkspaceId: workspaceID,87Audience: []string{"some.audience.com"},88},89Expectation: Expectation{90Response: &v1.GetIDTokenResponse{91Token: "foobar",92},93},94},95{96Name: "none org-owned user",97TokenSource: func(t *testing.T) IDTokenSource {98return functionIDTokenSource(func(ctx context.Context, org string, audience []string, userInfo oidc.UserInfo) (string, error) {99require.Equal(t, "[email protected]", userInfo.GetEmail())100require.False(t, userInfo.IsEmailVerified())101102return "foobar", nil103})104},105ServerSetup: func(ma *protocol.MockAPIInterface) {106ma.EXPECT().GetIDToken(gomock.Any()).MinTimes(1).Return(nil)107ma.EXPECT().GetWorkspace(gomock.Any(), workspaceID).MinTimes(1).Return(108&protocol.WorkspaceInfo{109Workspace: &protocol.Workspace{110ContextURL: "https://github.com/gitpod-io/gitpod",111Context: &protocol.WorkspaceContext{112Repository: &protocol.Repository{113CloneURL: "https://github.com/gitpod-io/gitpod.git",114},115NormalizedContextURL: "https://github.com/gitpod-io/gitpod",116},117},118},119nil,120)121ma.EXPECT().GetLoggedInUser(gomock.Any()).Return(122&protocol.User{123Name: "foobar",124Identities: []*protocol.Identity{125nil,126{Deleted: true, PrimaryEmail: "[email protected]"},127{Deleted: false, PrimaryEmail: "[email protected]", LastSigninTime: "2021-01-01T00:00:00Z"},128},129},130nil,131)132},133Request: &v1.GetIDTokenRequest{134WorkspaceId: workspaceID,135Audience: []string{"some.audience.com"},136},137Expectation: Expectation{138Response: &v1.GetIDTokenResponse{139Token: "foobar",140},141},142},143{144Name: "workspace not found",145TokenSource: func(t *testing.T) IDTokenSource {146return functionIDTokenSource(func(ctx context.Context, org string, audience []string, userInfo oidc.UserInfo) (string, error) {147return "foobar", nil148})149},150ServerSetup: func(ma *protocol.MockAPIInterface) {151ma.EXPECT().GetIDToken(gomock.Any()).MinTimes(1).Return(nil)152ma.EXPECT().GetWorkspace(gomock.Any(), workspaceID).MinTimes(1).Return(153nil,154&jsonrpc2.Error{Code: 400, Message: "workspace not found"},155)156},157Request: &v1.GetIDTokenRequest{158WorkspaceId: workspaceID,159Audience: []string{"some.audience.com"},160},161Expectation: Expectation{162Error: connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("workspace not found")).Error(),163},164},165{166Name: "no logged in user",167TokenSource: func(t *testing.T) IDTokenSource {168return functionIDTokenSource(func(ctx context.Context, org string, audience []string, userInfo oidc.UserInfo) (string, error) {169return "foobar", nil170})171},172ServerSetup: func(ma *protocol.MockAPIInterface) {173ma.EXPECT().GetIDToken(gomock.Any()).MinTimes(1).Return(nil)174ma.EXPECT().GetWorkspace(gomock.Any(), workspaceID).MinTimes(1).Return(175&protocol.WorkspaceInfo{176Workspace: &protocol.Workspace{177ContextURL: "https://github.com/gitpod-io/gitpod",178Context: &protocol.WorkspaceContext{179NormalizedContextURL: "https://github.com/gitpod-io/gitpod",180},181},182},183nil,184)185ma.EXPECT().GetLoggedInUser(gomock.Any()).Return(186nil,187&jsonrpc2.Error{Code: 401, Message: "User is not authenticated. Please login."},188)189},190Request: &v1.GetIDTokenRequest{191WorkspaceId: workspaceID,192Audience: []string{"some.audience.com"},193},194Expectation: Expectation{195Error: connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("User is not authenticated. Please login.")).Error(),196},197},198{199Name: "no audience",200TokenSource: func(t *testing.T) IDTokenSource {201return functionIDTokenSource(func(ctx context.Context, org string, audience []string, userInfo oidc.UserInfo) (string, error) {202return "foobar", nil203})204},205Request: &v1.GetIDTokenRequest{206WorkspaceId: workspaceID,207},208Expectation: Expectation{209Error: connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("Must have at least one audience entry")).Error(),210},211},212{213Name: "include scope",214TokenSource: func(t *testing.T) IDTokenSource {215return functionIDTokenSource(func(ctx context.Context, org string, audience []string, userInfo oidc.UserInfo) (string, error) {216require.Equal(t, "[email protected]", userInfo.GetEmail())217require.True(t, userInfo.IsEmailVerified())218require.Equal(t, "foo", userInfo.GetClaim("scope"))219220return "foobar", nil221})222},223ServerSetup: func(ma *protocol.MockAPIInterface) {224ma.EXPECT().GetIDToken(gomock.Any()).MinTimes(1).Return(nil)225ma.EXPECT().GetWorkspace(gomock.Any(), workspaceID).MinTimes(1).Return(226&protocol.WorkspaceInfo{227Workspace: &protocol.Workspace{228ContextURL: "https://github.com/gitpod-io/gitpod",229Context: &protocol.WorkspaceContext{230Repository: &protocol.Repository{231CloneURL: "https://github.com/gitpod-io/gitpod.git",232},233NormalizedContextURL: "https://github.com/gitpod-io/gitpod",234},235},236},237nil,238)239ma.EXPECT().GetLoggedInUser(gomock.Any()).Return(240&protocol.User{241Name: "foobar",242Identities: []*protocol.Identity{243nil,244{Deleted: true, PrimaryEmail: "[email protected]"},245{Deleted: false, PrimaryEmail: "[email protected]", LastSigninTime: "2021-01-01T00:00:00Z"},246},247OrganizationId: "test",248},249nil,250)251},252Request: &v1.GetIDTokenRequest{253WorkspaceId: workspaceID,254Audience: []string{"some.audience.com"},255Scope: "foo",256},257Expectation: Expectation{258Response: &v1.GetIDTokenResponse{259Token: "foobar",260},261},262},263{264Name: "token source error",265TokenSource: func(t *testing.T) IDTokenSource {266return functionIDTokenSource(func(ctx context.Context, org string, audience []string, userInfo oidc.UserInfo) (string, error) {267return "", fmt.Errorf("cannot produce token")268})269},270ServerSetup: func(ma *protocol.MockAPIInterface) {271ma.EXPECT().GetIDToken(gomock.Any()).MinTimes(1).Return(nil)272ma.EXPECT().GetWorkspace(gomock.Any(), workspaceID).MinTimes(1).Return(273&protocol.WorkspaceInfo{274Workspace: &protocol.Workspace{275ContextURL: "https://github.com/gitpod-io/gitpod",276Context: &protocol.WorkspaceContext{277NormalizedContextURL: "https://github.com/gitpod-io/gitpod",278},279},280},281nil,282)283ma.EXPECT().GetLoggedInUser(gomock.Any()).Return(284&protocol.User{285Name: "foobar",286},287nil,288)289},290Request: &v1.GetIDTokenRequest{291WorkspaceId: workspaceID,292Audience: []string{"some.audience.com"},293},294Expectation: Expectation{295Error: connect.NewError(connect.CodeInternal, fmt.Errorf("cannot produce token")).Error(),296},297},298}299300for _, test := range tests {301t.Run(test.Name, func(t *testing.T) {302ctrl := gomock.NewController(t)303t.Cleanup(ctrl.Finish)304serverMock := protocol.NewMockAPIInterface(ctrl)305if test.ServerSetup != nil {306test.ServerSetup(serverMock)307}308309keyset := jwstest.GenerateKeySet(t)310rsa256, err := jws.NewRSA256(keyset)311require.NoError(t, err)312313svc := NewIdentityProviderService(&FakeServerConnPool{api: serverMock}, test.TokenSource(t), &experimentstest.Client{314StringMatcher: func(ctx context.Context, experimentName, defaultValue string, attributes experiments.Attributes) string {315return ""316},317})318_, handler := v1connect.NewIdentityProviderServiceHandler(svc, connect.WithInterceptors(auth.NewServerInterceptor(config.SessionConfig{319Issuer: "unitetest.com",320Cookie: config.CookieConfig{321Name: "cookie_jwt",322},323}, rsa256)))324srv := httptest.NewServer(handler)325t.Cleanup(srv.Close)326327client := v1connect.NewIdentityProviderServiceClient(http.DefaultClient, srv.URL, connect.WithInterceptors(328auth.NewClientInterceptor("auth-token"),329))330331resp, err := client.GetIDToken(context.Background(), &connect.Request[v1.GetIDTokenRequest]{332Msg: test.Request,333})334var act Expectation335if err != nil {336act.Error = err.Error()337} else {338act.Response = resp.Msg339}340341if diff := cmp.Diff(test.Expectation, act, cmpopts.IgnoreUnexported(v1.GetIDTokenResponse{})); diff != "" {342t.Errorf("GetIDToken() mismatch (-want +got):\n%s", diff)343}344})345}346}347348type functionIDTokenSource func(ctx context.Context, org string, audience []string, userInfo oidc.UserInfo) (string, error)349350func (f functionIDTokenSource) IDToken(ctx context.Context, org string, audience []string, userInfo oidc.UserInfo) (string, error) {351return f(ctx, org, audience, userInfo)352}353354func TestGetOIDCSubject(t *testing.T) {355normalizedContextUrl := "https://github.com/gitpod-io/gitpod"356defaultWorkspace := &protocol.Workspace{357ContextURL: "SOME_ENV=test/" + normalizedContextUrl,358Context: &protocol.WorkspaceContext{359NormalizedContextURL: normalizedContextUrl,360}}361tests := []struct {362Name string363Keys string364Claims map[string]interface{}365Subject string366Workspace *protocol.Workspace367}{368{369Name: "happy path",370Keys: "",371Claims: map[string]interface{}{},372Subject: normalizedContextUrl,373Workspace: defaultWorkspace,374},375{376Name: "happy path 2",377Keys: "undefined",378Claims: map[string]interface{}{},379Subject: normalizedContextUrl,380Workspace: defaultWorkspace,381},382{383Name: "with custom keys",384Keys: "key1,key3,key2",385Claims: map[string]interface{}{"key1": 1, "key2": "hello"},386Subject: "key1:1:key3::key2:hello",387Workspace: defaultWorkspace,388},389{390Name: "with custom keys",391Keys: "key1,key3,key2",392Claims: map[string]interface{}{"key1": 1, "key3": errors.New("test")},393Subject: "key1:1:key3:test:key2:",394Workspace: defaultWorkspace,395},396{397Name: "happy path with strange prefix",398Keys: "",399Claims: map[string]interface{}{},400Subject: normalizedContextUrl,401Workspace: &protocol.Workspace{ContextURL: "referrer:jetbrains-gateway:intellij/" + normalizedContextUrl, Context: &protocol.WorkspaceContext{402NormalizedContextURL: normalizedContextUrl,403}},404},405{406Name: "happy path without NormalizedContextURL",407Keys: "",408Claims: map[string]interface{}{},409Subject: "no-context",410Workspace: &protocol.Workspace{ContextURL: "referrer:jetbrains-gateway:intellij/" + normalizedContextUrl, Context: &protocol.WorkspaceContext{411NormalizedContextURL: "",412}},413},414}415416for _, test := range tests {417t.Run(test.Name, func(t *testing.T) {418svc := NewIdentityProviderService(nil, nil, &experimentstest.Client{419StringMatcher: func(ctx context.Context, experimentName string, defaultValue string, attributes experiments.Attributes) string {420return test.Keys421},422})423userinfo := oidc.NewUserInfo()424for k, v := range test.Claims {425userinfo.AppendClaims(k, v)426}427act := svc.getOIDCSubject(context.Background(), userinfo, &protocol.User{}, &protocol.WorkspaceInfo{428Workspace: test.Workspace,429})430if diff := cmp.Diff(test.Subject, act); diff != "" {431t.Errorf("getOIDCSubject() mismatch (-want +got):\n%s", diff)432}433})434}435}436437438