diff --git a/internal/api/grpc/oidc/v2/integration_test/oidc_test.go b/internal/api/grpc/oidc/v2/integration_test/oidc_test.go index b81abc9fd6..d6b5c7b8cf 100644 --- a/internal/api/grpc/oidc/v2/integration_test/oidc_test.go +++ b/internal/api/grpc/oidc/v2/integration_test/oidc_test.go @@ -5,11 +5,11 @@ package oidc_test import ( "context" "net/url" - "os" "regexp" "testing" "time" + "github.com/brianvoe/gofakeit/v6" "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -22,92 +22,62 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/session/v2" ) -var ( - CTX context.Context - CTXLoginClient context.Context - Instance *integration.Instance - Client oidc_pb.OIDCServiceClient - loginV2 = &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{BaseUri: nil}}} -) - -const ( - redirectURI = "oidcintegrationtest://callback" - redirectURIImplicit = "http://localhost:9999/callback" - logoutRedirectURI = "oidcintegrationtest://logged-out" -) - -func TestMain(m *testing.M) { - os.Exit(func() int { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) - defer cancel() - - Instance = integration.NewInstance(ctx) - Client = Instance.Client.OIDCv2 - - CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) - CTXLoginClient = Instance.WithAuthorization(ctx, integration.UserTypeLogin) - return m.Run() - }()) -} - func TestServer_GetAuthRequest(t *testing.T) { project, err := Instance.CreateProject(CTX) require.NoError(t, err) client, err := Instance.CreateOIDCNativeClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), false) require.NoError(t, err) - now := time.Now() - tests := []struct { - name string - AuthRequestID string - ctx context.Context - want *oidc_pb.GetAuthRequestResponse - wantErr bool + name string + dep func() (time.Time, string, error) + ctx context.Context + want *oidc_pb.GetAuthRequestResponse + wantErr bool }{ { - name: "Not found", - AuthRequestID: "123", - ctx: CTX, - wantErr: true, + name: "Not found", + dep: func() (time.Time, string, error) { + return time.Now(), "123", nil + }, + ctx: CTX, + wantErr: true, }, { name: "success", - AuthRequestID: func() string { - authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users[integration.UserTypeOrgOwner].ID, redirectURI) - require.NoError(t, err) - return authRequestID - }(), + dep: func() (time.Time, string, error) { + return Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users[integration.UserTypeOrgOwner].ID, redirectURI) + }, ctx: CTX, }, { name: "without login client, no permission", - AuthRequestID: func() string { + dep: func() (time.Time, string, error) { client, err := Instance.CreateOIDCClientLoginVersion(CTX, redirectURI, logoutRedirectURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, false, loginV2) require.NoError(t, err) - authRequestID, err := Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, client.GetClientId(), redirectURI, "") - require.NoError(t, err) - return authRequestID - }(), + return Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, client.GetClientId(), redirectURI, "") + }, ctx: CTX, wantErr: true, }, { name: "without login client, with permission", - AuthRequestID: func() string { + dep: func() (time.Time, string, error) { client, err := Instance.CreateOIDCClientLoginVersion(CTX, redirectURI, logoutRedirectURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, false, loginV2) require.NoError(t, err) - authRequestID, err := Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, client.GetClientId(), redirectURI, "") - require.NoError(t, err) - return authRequestID - }(), + return Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, client.GetClientId(), redirectURI, "") + + }, ctx: CTXLoginClient, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + now, authRequestID, err := tt.dep() + require.NoError(t, err) + got, err := Client.GetAuthRequest(tt.ctx, &oidc_pb.GetAuthRequestRequest{ - AuthRequestId: tt.AuthRequestID, + AuthRequestId: authRequestID, }) if tt.wantErr { require.Error(t, err) @@ -116,7 +86,7 @@ func TestServer_GetAuthRequest(t *testing.T) { require.NoError(t, err) authRequest := got.GetAuthRequest() assert.NotNil(t, authRequest) - assert.Equal(t, tt.AuthRequestID, authRequest.GetId()) + assert.Equal(t, authRequestID, authRequest.GetId()) assert.WithinRange(t, authRequest.GetCreationDate().AsTime(), now.Add(-time.Second), now.Add(time.Second)) assert.Contains(t, authRequest.GetScope(), "openid") }) @@ -130,16 +100,7 @@ func TestServer_CreateCallback(t *testing.T) { require.NoError(t, err) clientV2, err := Instance.CreateOIDCClientLoginVersion(CTX, redirectURI, logoutRedirectURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, false, loginV2) require.NoError(t, err) - sessionResp, err := Instance.Client.SessionV2.CreateSession(CTX, &session.CreateSessionRequest{ - Checks: &session.Checks{ - User: &session.CheckUser{ - Search: &session.CheckUser_UserId{ - UserId: Instance.Users[integration.UserTypeOrgOwner].ID, - }, - }, - }, - }) - require.NoError(t, err) + sessionResp := createSession(t, CTX, Instance.Users[integration.UserTypeOrgOwner].ID) tests := []struct { name string @@ -169,7 +130,7 @@ func TestServer_CreateCallback(t *testing.T) { ctx: CTX, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { - authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users[integration.UserTypeOrgOwner].ID, redirectURI) + _, authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users[integration.UserTypeOrgOwner].ID, redirectURI) require.NoError(t, err) return authRequestID }(), @@ -187,7 +148,7 @@ func TestServer_CreateCallback(t *testing.T) { ctx: CTX, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { - authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURI) + _, authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURI) require.NoError(t, err) return authRequestID }(), @@ -205,7 +166,7 @@ func TestServer_CreateCallback(t *testing.T) { ctx: CTX, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { - authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURI) + _, authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURI) require.NoError(t, err) return authRequestID }(), @@ -231,7 +192,7 @@ func TestServer_CreateCallback(t *testing.T) { ctx: CTXLoginClient, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { - authRequestID, err := Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, clientV2.GetClientId(), redirectURI, "") + _, authRequestID, err := Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, clientV2.GetClientId(), redirectURI, "") require.NoError(t, err) return authRequestID }(), @@ -257,7 +218,7 @@ func TestServer_CreateCallback(t *testing.T) { ctx: CTX, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { - authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURI) + _, authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURI) require.NoError(t, err) return authRequestID }(), @@ -282,7 +243,7 @@ func TestServer_CreateCallback(t *testing.T) { ctx: CTX, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { - authRequestID, err := Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, clientV2.GetClientId(), redirectURI, "") + _, authRequestID, err := Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, clientV2.GetClientId(), redirectURI, "") require.NoError(t, err) return authRequestID }(), @@ -300,7 +261,7 @@ func TestServer_CreateCallback(t *testing.T) { ctx: CTXLoginClient, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { - authRequestID, err := Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, clientV2.GetClientId(), redirectURI, "") + _, authRequestID, err := Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, clientV2.GetClientId(), redirectURI, "") require.NoError(t, err) return authRequestID }(), @@ -390,3 +351,324 @@ func TestServer_CreateCallback(t *testing.T) { }) } } + +func TestServer_CreateCallback_Permission(t *testing.T) { + tests := []struct { + name string + ctx context.Context + dep func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest + want *oidc_pb.CreateCallbackResponse + wantURL *url.URL + wantErr bool + }{ + { + name: "usergrant to project and different resourceowner with different project grant", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, true) + projectID2, _ := createOIDCApplication(ctx, t, true, true) + + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + Instance.CreateProjectGrant(ctx, projectID2, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + wantErr: true, + }, + { + name: "usergrant to project and different resourceowner with project grant", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, true) + + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "usergrant to project grant and different resourceowner with project grant", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, true) + + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + projectGrantResp := Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectGrantUserGrant(ctx, orgResp.GetOrganizationId(), projectID, projectGrantResp.GetGrantId(), user.GetUserId()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "no usergrant and different resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + _, clientID := createOIDCApplication(ctx, t, true, true) + + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + wantErr: true, + }, + { + name: "no usergrant and same resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + _, clientID := createOIDCApplication(ctx, t, true, true) + user := Instance.CreateHumanUser(ctx) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + wantErr: true, + }, + { + name: "usergrant and different resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, true) + + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + wantErr: true, + }, + { + name: "usergrant and same resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, true) + user := Instance.CreateHumanUser(ctx) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "projectRoleCheck, usergrant and same resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, false) + user := Instance.CreateHumanUser(ctx) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "projectRoleCheck, no usergrant and same resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + _, clientID := createOIDCApplication(ctx, t, true, false) + user := Instance.CreateHumanUser(ctx) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + wantErr: true, + }, + { + name: "projectRoleCheck, usergrant and different resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, false) + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "projectRoleCheck, no usergrant and different resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + _, clientID := createOIDCApplication(ctx, t, true, false) + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + wantErr: true, + }, + { + name: "projectRoleCheck, usergrant on project grant and different resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, false) + + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + projectGrantResp := Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectGrantUserGrant(ctx, orgResp.GetOrganizationId(), projectID, projectGrantResp.GetGrantId(), user.GetUserId()) + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "projectRoleCheck, no usergrant on project grant and different resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, false) + + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + wantErr: true, + }, + { + name: "hasProjectCheck, same resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + user := Instance.CreateHumanUser(ctx) + _, clientID := createOIDCApplication(ctx, t, false, true) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "hasProjectCheck, different resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + _, clientID := createOIDCApplication(ctx, t, false, true) + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + wantErr: true, + }, + { + name: "hasProjectCheck, different resourceowner with project grant", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, false, true) + + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := tt.dep(IAMCTX, t) + + got, err := Client.CreateCallback(tt.ctx, req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + integration.AssertDetails(t, tt.want, got) + if tt.want != nil { + assert.Regexp(t, regexp.MustCompile(tt.want.CallbackUrl), got.GetCallbackUrl()) + } + }) + } +} + +func createSession(t *testing.T, ctx context.Context, userID string) *session.CreateSessionResponse { + sessionResp, err := Instance.Client.SessionV2.CreateSession(ctx, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: userID, + }, + }, + }, + }) + require.NoError(t, err) + return sessionResp +} + +func createSessionAndAuthRequestForCallback(ctx context.Context, t *testing.T, clientID, loginClient, userID string) *oidc_pb.CreateCallbackRequest { + _, authRequestID, err := Instance.CreateOIDCAuthRequest(ctx, clientID, loginClient, redirectURI) + require.NoError(t, err) + sessionResp := createSession(t, ctx, userID) + return &oidc_pb.CreateCallbackRequest{ + AuthRequestId: authRequestID, + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: sessionResp.GetSessionToken(), + }, + }, + } +} + +func createOIDCApplication(ctx context.Context, t *testing.T, projectRoleCheck, hasProjectCheck bool) (string, string) { + project, err := Instance.CreateProjectWithPermissionCheck(ctx, projectRoleCheck, hasProjectCheck) + require.NoError(t, err) + clientV2, err := Instance.CreateOIDCClientLoginVersion(ctx, redirectURI, logoutRedirectURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, false, loginV2) + require.NoError(t, err) + return project.GetId(), clientV2.GetClientId() +} diff --git a/internal/api/grpc/oidc/v2/integration_test/server_test.go b/internal/api/grpc/oidc/v2/integration_test/server_test.go new file mode 100644 index 0000000000..ccc37e37e5 --- /dev/null +++ b/internal/api/grpc/oidc/v2/integration_test/server_test.go @@ -0,0 +1,44 @@ +//go:build integration + +package oidc_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/app" + oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" +) + +var ( + CTX context.Context + CTXLoginClient context.Context + IAMCTX context.Context + Instance *integration.Instance + Client oidc_pb.OIDCServiceClient + loginV2 = &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{BaseUri: nil}}} +) + +const ( + redirectURI = "oidcintegrationtest://callback" + redirectURIImplicit = "http://localhost:9999/callback" + logoutRedirectURI = "oidcintegrationtest://logged-out" +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + Instance = integration.NewInstance(ctx) + Client = Instance.Client.OIDCv2 + + IAMCTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner) + CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) + CTXLoginClient = Instance.WithAuthorization(ctx, integration.UserTypeLogin) + return m.Run() + }()) +} diff --git a/internal/api/grpc/oidc/v2/oidc.go b/internal/api/grpc/oidc/v2/oidc.go index 826c198fad..d1ddc35cc0 100644 --- a/internal/api/grpc/oidc/v2/oidc.go +++ b/internal/api/grpc/oidc/v2/oidc.go @@ -73,6 +73,20 @@ func promptToPb(p domain.Prompt) oidc_pb.Prompt { } } +func (s *Server) checkPermission(ctx context.Context, clientID string, userID string) error { + permission, err := s.query.CheckProjectPermissionByClientID(ctx, clientID, userID) + if err != nil { + return err + } + if !permission.HasProjectChecked { + return zerrors.ThrowPermissionDenied(nil, "OIDC-foSyH49RvL", "Errors.User.ProjectRequired") + } + if !permission.ProjectRoleChecked { + return zerrors.ThrowPermissionDenied(nil, "OIDC-foSyH49RvL", "Errors.User.GrantRequired") + } + return nil +} + func (s *Server) CreateCallback(ctx context.Context, req *oidc_pb.CreateCallbackRequest) (*oidc_pb.CreateCallbackResponse, error) { switch v := req.GetCallbackKind().(type) { case *oidc_pb.CreateCallbackRequest_Error: @@ -101,7 +115,7 @@ func (s *Server) failAuthRequest(ctx context.Context, authRequestID string, ae * } func (s *Server) linkSessionToAuthRequest(ctx context.Context, authRequestID string, session *oidc_pb.Session) (*oidc_pb.CreateCallbackResponse, error) { - details, aar, err := s.command.LinkSessionToAuthRequest(ctx, authRequestID, session.GetSessionId(), session.GetSessionToken(), true) + details, aar, err := s.command.LinkSessionToAuthRequest(ctx, authRequestID, session.GetSessionId(), session.GetSessionToken(), true, s.checkPermission) if err != nil { return nil, err } diff --git a/internal/api/grpc/oidc/v2beta/integration_test/oidc_test.go b/internal/api/grpc/oidc/v2beta/integration_test/oidc_test.go index 1c83b504dd..1d2a6d2671 100644 --- a/internal/api/grpc/oidc/v2beta/integration_test/oidc_test.go +++ b/internal/api/grpc/oidc/v2beta/integration_test/oidc_test.go @@ -5,76 +5,79 @@ package oidc_test import ( "context" "net/url" - "os" "regexp" "testing" "time" + "github.com/brianvoe/gofakeit/v6" "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/app" object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta" ) -var ( - CTX context.Context - Instance *integration.Instance - Client oidc_pb.OIDCServiceClient -) - -const ( - redirectURI = "oidcintegrationtest://callback" - redirectURIImplicit = "http://localhost:9999/callback" - logoutRedirectURI = "oidcintegrationtest://logged-out" -) - -func TestMain(m *testing.M) { - os.Exit(func() int { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) - defer cancel() - - Instance = integration.NewInstance(ctx) - Client = Instance.Client.OIDCv2beta - - CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) - return m.Run() - }()) -} - func TestServer_GetAuthRequest(t *testing.T) { project, err := Instance.CreateProject(CTX) require.NoError(t, err) client, err := Instance.CreateOIDCNativeClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), false) require.NoError(t, err) - authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURI) - require.NoError(t, err) - now := time.Now() tests := []struct { - name string - AuthRequestID string - want *oidc_pb.GetAuthRequestResponse - wantErr bool + name string + dep func() (time.Time, string, error) + ctx context.Context + want *oidc_pb.GetAuthRequestResponse + wantErr bool }{ { - name: "Not found", - AuthRequestID: "123", - wantErr: true, + name: "Not found", + dep: func() (time.Time, string, error) { + return time.Now(), "123", nil + }, + ctx: CTX, + wantErr: true, }, { - name: "success", - AuthRequestID: authRequestID, + name: "success", + dep: func() (time.Time, string, error) { + return Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users[integration.UserTypeOrgOwner].ID, redirectURI) + }, + ctx: CTX, + }, + { + name: "without login client, no permission", + dep: func() (time.Time, string, error) { + client, err := Instance.CreateOIDCClientLoginVersion(CTX, redirectURI, logoutRedirectURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, false, loginV2) + require.NoError(t, err) + return Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, client.GetClientId(), redirectURI, "") + }, + ctx: CTX, + wantErr: true, + }, + { + name: "without login client, with permission", + dep: func() (time.Time, string, error) { + client, err := Instance.CreateOIDCClientLoginVersion(CTX, redirectURI, logoutRedirectURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, false, loginV2) + require.NoError(t, err) + return Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, client.GetClientId(), redirectURI, "") + + }, + ctx: CTXLoginClient, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := Client.GetAuthRequest(CTX, &oidc_pb.GetAuthRequestRequest{ - AuthRequestId: tt.AuthRequestID, + now, authRequestID, err := tt.dep() + require.NoError(t, err) + + got, err := Client.GetAuthRequest(tt.ctx, &oidc_pb.GetAuthRequestRequest{ + AuthRequestId: authRequestID, }) if tt.wantErr { require.Error(t, err) @@ -95,19 +98,13 @@ func TestServer_CreateCallback(t *testing.T) { require.NoError(t, err) client, err := Instance.CreateOIDCNativeClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), false) require.NoError(t, err) - sessionResp, err := Instance.Client.SessionV2beta.CreateSession(CTX, &session.CreateSessionRequest{ - Checks: &session.Checks{ - User: &session.CheckUser{ - Search: &session.CheckUser_UserId{ - UserId: Instance.Users.Get(integration.UserTypeOrgOwner).ID, - }, - }, - }, - }) + clientV2, err := Instance.CreateOIDCClientLoginVersion(CTX, redirectURI, logoutRedirectURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, false, loginV2) require.NoError(t, err) + sessionResp := createSession(t, CTX, Instance.Users[integration.UserTypeOrgOwner].ID) tests := []struct { name string + ctx context.Context req *oidc_pb.CreateCallbackRequest AuthError string want *oidc_pb.CreateCallbackResponse @@ -116,6 +113,7 @@ func TestServer_CreateCallback(t *testing.T) { }{ { name: "Not found", + ctx: CTX, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: "123", CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ @@ -129,9 +127,10 @@ func TestServer_CreateCallback(t *testing.T) { }, { name: "session not found", + ctx: CTX, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { - authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURI) + _, authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users[integration.UserTypeOrgOwner].ID, redirectURI) require.NoError(t, err) return authRequestID }(), @@ -146,9 +145,10 @@ func TestServer_CreateCallback(t *testing.T) { }, { name: "session token invalid", + ctx: CTX, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { - authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURI) + _, authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURI) require.NoError(t, err) return authRequestID }(), @@ -163,9 +163,36 @@ func TestServer_CreateCallback(t *testing.T) { }, { name: "fail callback", + ctx: CTX, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { - authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURI) + _, authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURI) + require.NoError(t, err) + return authRequestID + }(), + CallbackKind: &oidc_pb.CreateCallbackRequest_Error{ + Error: &oidc_pb.AuthorizationError{ + Error: oidc_pb.ErrorReason_ERROR_REASON_ACCESS_DENIED, + ErrorDescription: gu.Ptr("nope"), + ErrorUri: gu.Ptr("https://example.com/docs"), + }, + }, + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: regexp.QuoteMeta(`oidcintegrationtest://callback?error=access_denied&error_description=nope&error_uri=https%3A%2F%2Fexample.com%2Fdocs&state=state`), + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + wantErr: false, + }, + { + name: "fail callback, no login client header", + ctx: CTXLoginClient, + req: &oidc_pb.CreateCallbackRequest{ + AuthRequestId: func() string { + _, authRequestID, err := Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, clientV2.GetClientId(), redirectURI, "") require.NoError(t, err) return authRequestID }(), @@ -188,9 +215,53 @@ func TestServer_CreateCallback(t *testing.T) { }, { name: "code callback", + ctx: CTX, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { - authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURI) + _, authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURI) + require.NoError(t, err) + return authRequestID + }(), + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: sessionResp.GetSessionToken(), + }, + }, + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + wantErr: false, + }, + { + name: "code callback, no login client header, no permission, error", + ctx: CTX, + req: &oidc_pb.CreateCallbackRequest{ + AuthRequestId: func() string { + _, authRequestID, err := Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, clientV2.GetClientId(), redirectURI, "") + require.NoError(t, err) + return authRequestID + }(), + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: sessionResp.GetSessionToken(), + }, + }, + }, + wantErr: true, + }, + { + name: "code callback, no login client header, with permission", + ctx: CTXLoginClient, + req: &oidc_pb.CreateCallbackRequest{ + AuthRequestId: func() string { + _, authRequestID, err := Instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, clientV2.GetClientId(), redirectURI, "") require.NoError(t, err) return authRequestID }(), @@ -212,6 +283,7 @@ func TestServer_CreateCallback(t *testing.T) { }, { name: "implicit", + ctx: CTX, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { client, err := Instance.CreateOIDCImplicitFlowClient(CTX, redirectURIImplicit, nil) @@ -236,10 +308,37 @@ func TestServer_CreateCallback(t *testing.T) { }, wantErr: false, }, + { + name: "implicit, no login client header", + ctx: CTXLoginClient, + req: &oidc_pb.CreateCallbackRequest{ + AuthRequestId: func() string { + clientV2, err := Instance.CreateOIDCImplicitFlowClient(CTX, redirectURIImplicit, loginV2) + require.NoError(t, err) + authRequestID, err := Instance.CreateOIDCAuthRequestImplicitWithoutLoginClientHeader(CTX, clientV2.GetClientId(), redirectURIImplicit) + require.NoError(t, err) + return authRequestID + }(), + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: sessionResp.GetSessionToken(), + }, + }, + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `http:\/\/localhost:9999\/callback#access_token=(.*)&expires_in=(.*)&id_token=(.*)&state=state&token_type=Bearer`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := Client.CreateCallback(CTX, tt.req) + got, err := Client.CreateCallback(tt.ctx, tt.req) if tt.wantErr { require.Error(t, err) return @@ -252,3 +351,324 @@ func TestServer_CreateCallback(t *testing.T) { }) } } + +func TestServer_CreateCallback_Permission(t *testing.T) { + tests := []struct { + name string + ctx context.Context + dep func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest + want *oidc_pb.CreateCallbackResponse + wantURL *url.URL + wantErr bool + }{ + { + name: "usergrant to project and different resourceowner with different project grant", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, true) + projectID2, _ := createOIDCApplication(ctx, t, true, true) + + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + Instance.CreateProjectGrant(ctx, projectID2, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + wantErr: true, + }, + { + name: "usergrant to project and different resourceowner with project grant", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, true) + + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "usergrant to project grant and different resourceowner with project grant", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, true) + + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + projectGrantResp := Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectGrantUserGrant(ctx, orgResp.GetOrganizationId(), projectID, projectGrantResp.GetGrantId(), user.GetUserId()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "no usergrant and different resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + _, clientID := createOIDCApplication(ctx, t, true, true) + + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + wantErr: true, + }, + { + name: "no usergrant and same resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + _, clientID := createOIDCApplication(ctx, t, true, true) + user := Instance.CreateHumanUser(ctx) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + wantErr: true, + }, + { + name: "usergrant and different resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, true) + + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + wantErr: true, + }, + { + name: "usergrant and same resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, true) + user := Instance.CreateHumanUser(ctx) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "projectRoleCheck, usergrant and same resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, false) + user := Instance.CreateHumanUser(ctx) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "projectRoleCheck, no usergrant and same resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + _, clientID := createOIDCApplication(ctx, t, true, false) + user := Instance.CreateHumanUser(ctx) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + wantErr: true, + }, + { + name: "projectRoleCheck, usergrant and different resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, false) + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "projectRoleCheck, no usergrant and different resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + _, clientID := createOIDCApplication(ctx, t, true, false) + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + wantErr: true, + }, + { + name: "projectRoleCheck, usergrant on project grant and different resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, false) + + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + projectGrantResp := Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectGrantUserGrant(ctx, orgResp.GetOrganizationId(), projectID, projectGrantResp.GetGrantId(), user.GetUserId()) + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "projectRoleCheck, no usergrant on project grant and different resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, true, false) + + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + wantErr: true, + }, + { + name: "hasProjectCheck, same resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + user := Instance.CreateHumanUser(ctx) + _, clientID := createOIDCApplication(ctx, t, false, true) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "hasProjectCheck, different resourceowner", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + _, clientID := createOIDCApplication(ctx, t, false, true) + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + wantErr: true, + }, + { + name: "hasProjectCheck, different resourceowner with project grant", + ctx: CTX, + dep: func(ctx context.Context, t *testing.T) *oidc_pb.CreateCallbackRequest { + projectID, clientID := createOIDCApplication(ctx, t, false, true) + + orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) + Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + + return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := tt.dep(IAMCTX, t) + + got, err := Client.CreateCallback(tt.ctx, req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + integration.AssertDetails(t, tt.want, got) + if tt.want != nil { + assert.Regexp(t, regexp.MustCompile(tt.want.CallbackUrl), got.GetCallbackUrl()) + } + }) + } +} + +func createSession(t *testing.T, ctx context.Context, userID string) *session.CreateSessionResponse { + sessionResp, err := Instance.Client.SessionV2beta.CreateSession(ctx, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: userID, + }, + }, + }, + }) + require.NoError(t, err) + return sessionResp +} + +func createSessionAndAuthRequestForCallback(ctx context.Context, t *testing.T, clientID, loginClient, userID string) *oidc_pb.CreateCallbackRequest { + _, authRequestID, err := Instance.CreateOIDCAuthRequest(ctx, clientID, loginClient, redirectURI) + require.NoError(t, err) + sessionResp := createSession(t, ctx, userID) + return &oidc_pb.CreateCallbackRequest{ + AuthRequestId: authRequestID, + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: sessionResp.GetSessionToken(), + }, + }, + } +} + +func createOIDCApplication(ctx context.Context, t *testing.T, projectRoleCheck, hasProjectCheck bool) (string, string) { + project, err := Instance.CreateProjectWithPermissionCheck(ctx, projectRoleCheck, hasProjectCheck) + require.NoError(t, err) + clientV2, err := Instance.CreateOIDCClientLoginVersion(ctx, redirectURI, logoutRedirectURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, false, loginV2) + require.NoError(t, err) + return project.GetId(), clientV2.GetClientId() +} diff --git a/internal/api/grpc/oidc/v2beta/integration_test/server_test.go b/internal/api/grpc/oidc/v2beta/integration_test/server_test.go new file mode 100644 index 0000000000..227f4f5910 --- /dev/null +++ b/internal/api/grpc/oidc/v2beta/integration_test/server_test.go @@ -0,0 +1,44 @@ +//go:build integration + +package oidc_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/app" + oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" +) + +var ( + CTX context.Context + CTXLoginClient context.Context + IAMCTX context.Context + Instance *integration.Instance + Client oidc_pb.OIDCServiceClient + loginV2 = &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{BaseUri: nil}}} +) + +const ( + redirectURI = "oidcintegrationtest://callback" + redirectURIImplicit = "http://localhost:9999/callback" + logoutRedirectURI = "oidcintegrationtest://logged-out" +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + Instance = integration.NewInstance(ctx) + Client = Instance.Client.OIDCv2beta + + IAMCTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner) + CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) + CTXLoginClient = Instance.WithAuthorization(ctx, integration.UserTypeLogin) + return m.Run() + }()) +} diff --git a/internal/api/grpc/oidc/v2beta/oidc.go b/internal/api/grpc/oidc/v2beta/oidc.go index 04ffdbb348..66c4bee828 100644 --- a/internal/api/grpc/oidc/v2beta/oidc.go +++ b/internal/api/grpc/oidc/v2beta/oidc.go @@ -100,8 +100,22 @@ func (s *Server) failAuthRequest(ctx context.Context, authRequestID string, ae * }, nil } +func (s *Server) checkPermission(ctx context.Context, clientID string, userID string) error { + permission, err := s.query.CheckProjectPermissionByClientID(ctx, clientID, userID) + if err != nil { + return err + } + if !permission.HasProjectChecked { + return zerrors.ThrowPermissionDenied(nil, "OIDC-BakSFPjbfN", "Errors.User.ProjectRequired") + } + if !permission.ProjectRoleChecked { + return zerrors.ThrowPermissionDenied(nil, "OIDC-EP688AF2jA", "Errors.User.GrantRequired") + } + return nil +} + func (s *Server) linkSessionToAuthRequest(ctx context.Context, authRequestID string, session *oidc_pb.Session) (*oidc_pb.CreateCallbackResponse, error) { - details, aar, err := s.command.LinkSessionToAuthRequest(ctx, authRequestID, session.GetSessionId(), session.GetSessionToken(), true) + details, aar, err := s.command.LinkSessionToAuthRequest(ctx, authRequestID, session.GetSessionId(), session.GetSessionToken(), true, s.checkPermission) if err != nil { return nil, err } diff --git a/internal/api/grpc/saml/v2/integration/saml_test.go b/internal/api/grpc/saml/v2/integration/saml_test.go index b70099fb20..1f227ab149 100644 --- a/internal/api/grpc/saml/v2/integration/saml_test.go +++ b/internal/api/grpc/saml/v2/integration/saml_test.go @@ -5,13 +5,13 @@ package saml_test import ( "context" "net/url" - "os" "regexp" "testing" "time" "github.com/brianvoe/gofakeit/v6" "github.com/crewjam/saml" + "github.com/crewjam/saml/samlsp" "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -19,80 +19,48 @@ import ( "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/pkg/grpc/object/v2" - oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" saml_pb "github.com/zitadel/zitadel/pkg/grpc/saml/v2" "github.com/zitadel/zitadel/pkg/grpc/session/v2" ) -var ( - CTX context.Context - Instance *integration.Instance - Client saml_pb.SAMLServiceClient -) - -func TestMain(m *testing.M) { - os.Exit(func() int { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) - defer cancel() - - Instance = integration.NewInstance(ctx) - Client = Instance.Client.SAMLv2 - - CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) - return m.Run() - }()) -} - -func TestServer_GetAuthRequest(t *testing.T) { - rootURL := "https://sp.example.com" +func TestServer_GetSAMLRequest(t *testing.T) { idpMetadata, err := Instance.GetSAMLIDPMetadata() require.NoError(t, err) - spMiddlewareRedirect, err := integration.CreateSAMLSP(rootURL, idpMetadata, saml.HTTPRedirectBinding) - require.NoError(t, err) - spMiddlewarePost, err := integration.CreateSAMLSP(rootURL, idpMetadata, saml.HTTPPostBinding) - require.NoError(t, err) acsRedirect := idpMetadata.IDPSSODescriptors[0].SingleSignOnServices[0] acsPost := idpMetadata.IDPSSODescriptors[0].SingleSignOnServices[1] - project, err := Instance.CreateProject(CTX) - require.NoError(t, err) - _, err = Instance.CreateSAMLClient(CTX, project.GetId(), spMiddlewareRedirect) - require.NoError(t, err) - _, err = Instance.CreateSAMLClient(CTX, project.GetId(), spMiddlewarePost) - require.NoError(t, err) - - now := time.Now() + _, _, spMiddlewareRedirect := createSAMLApplication(CTX, t, idpMetadata, saml.HTTPRedirectBinding, false, false) + _, _, spMiddlewarePost := createSAMLApplication(CTX, t, idpMetadata, saml.HTTPPostBinding, false, false) tests := []struct { name string - dep func() (string, error) - want *oidc_pb.GetAuthRequestResponse + dep func() (time.Time, string, error) wantErr bool }{ { name: "Not found", - dep: func() (string, error) { - return "123", nil + dep: func() (time.Time, string, error) { + return time.Time{}, "123", nil }, wantErr: true, }, { name: "success, redirect binding", - dep: func() (string, error) { + dep: func() (time.Time, string, error) { return Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, gofakeit.BitcoinAddress(), saml.HTTPRedirectBinding) }, }, { name: "success, post binding", - dep: func() (string, error) { + dep: func() (time.Time, string, error) { return Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - authRequestID, err := tt.dep() + creationTime, authRequestID, err := tt.dep() require.NoError(t, err) retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) @@ -108,7 +76,7 @@ func TestServer_GetAuthRequest(t *testing.T) { authRequest := got.GetSamlRequest() assert.NotNil(ttt, authRequest) assert.Equal(ttt, authRequestID, authRequest.GetId()) - assert.WithinRange(ttt, authRequest.GetCreationDate().AsTime(), now.Add(-time.Second), now.Add(time.Second)) + assert.WithinRange(ttt, authRequest.GetCreationDate().AsTime(), creationTime.Add(-time.Second), creationTime.Add(time.Second)) }, retryDuration, tick, "timeout waiting for expected saml request result") }) } @@ -117,33 +85,12 @@ func TestServer_GetAuthRequest(t *testing.T) { func TestServer_CreateResponse(t *testing.T) { idpMetadata, err := Instance.GetSAMLIDPMetadata() require.NoError(t, err) - rootURLRedirect := "spredirect.example.com" - spMiddlewareRedirect, err := integration.CreateSAMLSP("https://"+rootURLRedirect, idpMetadata, saml.HTTPRedirectBinding) - require.NoError(t, err) - rootURLPost := "sppost.example.com" - spMiddlewarePost, err := integration.CreateSAMLSP("https://"+rootURLPost, idpMetadata, saml.HTTPPostBinding) - require.NoError(t, err) - acsRedirect := idpMetadata.IDPSSODescriptors[0].SingleSignOnServices[0] acsPost := idpMetadata.IDPSSODescriptors[0].SingleSignOnServices[1] - project, err := Instance.CreateProject(CTX) - require.NoError(t, err) - _, err = Instance.CreateSAMLClient(CTX, project.GetId(), spMiddlewareRedirect) - require.NoError(t, err) - _, err = Instance.CreateSAMLClient(CTX, project.GetId(), spMiddlewarePost) - require.NoError(t, err) - - sessionResp, err := Instance.Client.SessionV2.CreateSession(CTX, &session.CreateSessionRequest{ - Checks: &session.Checks{ - User: &session.CheckUser{ - Search: &session.CheckUser_UserId{ - UserId: Instance.Users[integration.UserTypeOrgOwner].ID, - }, - }, - }, - }) - require.NoError(t, err) + _, rootURLPost, spMiddlewarePost := createSAMLApplication(CTX, t, idpMetadata, saml.HTTPPostBinding, false, false) + _, rootURLRedirect, spMiddlewareRedirect := createSAMLApplication(CTX, t, idpMetadata, saml.HTTPRedirectBinding, false, false) + sessionResp := createSession(CTX, t, Instance.Users[integration.UserTypeOrgOwner].ID) tests := []struct { name string @@ -170,7 +117,7 @@ func TestServer_CreateResponse(t *testing.T) { name: "session not found", req: &saml_pb.CreateResponseRequest{ SamlRequestId: func() string { - authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, gofakeit.BitcoinAddress(), saml.HTTPRedirectBinding) + _, authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, gofakeit.BitcoinAddress(), saml.HTTPRedirectBinding) require.NoError(t, err) return authRequestID }(), @@ -187,7 +134,7 @@ func TestServer_CreateResponse(t *testing.T) { name: "session token invalid", req: &saml_pb.CreateResponseRequest{ SamlRequestId: func() string { - authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, gofakeit.BitcoinAddress(), saml.HTTPRedirectBinding) + _, authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, gofakeit.BitcoinAddress(), saml.HTTPRedirectBinding) require.NoError(t, err) return authRequestID }(), @@ -204,7 +151,7 @@ func TestServer_CreateResponse(t *testing.T) { name: "fail callback, post", req: &saml_pb.CreateResponseRequest{ SamlRequestId: func() string { - authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) + _, authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) require.NoError(t, err) return authRequestID }(), @@ -232,7 +179,7 @@ func TestServer_CreateResponse(t *testing.T) { name: "fail callback, post, already failed", req: &saml_pb.CreateResponseRequest{ SamlRequestId: func() string { - authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) + _, authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) require.NoError(t, err) Instance.FailSAMLAuthRequest(CTX, authRequestID, saml_pb.ErrorReason_ERROR_REASON_AUTH_N_FAILED) return authRequestID @@ -250,7 +197,7 @@ func TestServer_CreateResponse(t *testing.T) { name: "fail callback, redirect", req: &saml_pb.CreateResponseRequest{ SamlRequestId: func() string { - authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) + _, authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) require.NoError(t, err) return authRequestID }(), @@ -275,7 +222,7 @@ func TestServer_CreateResponse(t *testing.T) { name: "callback, redirect", req: &saml_pb.CreateResponseRequest{ SamlRequestId: func() string { - authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, gofakeit.BitcoinAddress(), saml.HTTPRedirectBinding) + _, authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, gofakeit.BitcoinAddress(), saml.HTTPRedirectBinding) require.NoError(t, err) return authRequestID }(), @@ -300,7 +247,7 @@ func TestServer_CreateResponse(t *testing.T) { name: "callback, post", req: &saml_pb.CreateResponseRequest{ SamlRequestId: func() string { - authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) + _, authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) require.NoError(t, err) return authRequestID }(), @@ -328,7 +275,7 @@ func TestServer_CreateResponse(t *testing.T) { name: "callback, post", req: &saml_pb.CreateResponseRequest{ SamlRequestId: func() string { - authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) + _, authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) require.NoError(t, err) Instance.SuccessfulSAMLAuthRequest(CTX, Instance.Users[integration.UserTypeOrgOwner].ID, authRequestID) return authRequestID @@ -365,3 +312,338 @@ func TestServer_CreateResponse(t *testing.T) { }) } } + +func TestServer_CreateResponse_Permission(t *testing.T) { + idpMetadata, err := Instance.GetSAMLIDPMetadata() + require.NoError(t, err) + acsRedirect := idpMetadata.IDPSSODescriptors[0].SingleSignOnServices[0] + + tests := []struct { + name string + dep func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest + want *saml_pb.CreateResponseResponse + wantURL *url.URL + wantErr bool + }{ + { + name: "usergrant to project and different resourceowner with different project grant", + dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { + projectID, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, true) + projectID2, _, _ := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, true) + + orgResp := Instance.CreateOrganization(ctx, "saml-permission-"+gofakeit.AppName(), gofakeit.Email()) + Instance.CreateProjectGrant(ctx, projectID2, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) + }, + wantErr: true, + }, + { + name: "usergrant to project and different resourceowner with project grant", + dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { + projectID, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, true) + + orgResp := Instance.CreateOrganization(ctx, "saml-permission-"+gofakeit.AppName(), gofakeit.Email()) + Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) + }, + want: &saml_pb.CreateResponseResponse{ + Url: `https:\/\/(.*)\/saml\/acs\?RelayState=(.*)&SAMLResponse=(.*)&SigAlg=(.*)&Signature=(.*)`, + Binding: &saml_pb.CreateResponseResponse_Redirect{Redirect: &saml_pb.RedirectResponse{}}, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + wantErr: false, + }, + { + name: "usergrant to project grant and different resourceowner with project grant", + dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { + projectID, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, true) + + orgResp := Instance.CreateOrganization(ctx, "saml-permission-"+gofakeit.AppName(), gofakeit.Email()) + projectGrantResp := Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectGrantUserGrant(ctx, orgResp.GetOrganizationId(), projectID, projectGrantResp.GetGrantId(), user.GetUserId()) + + return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) + }, + want: &saml_pb.CreateResponseResponse{ + Url: `https:\/\/(.*)\/saml\/acs\?RelayState=(.*)&SAMLResponse=(.*)&SigAlg=(.*)&Signature=(.*)`, + Binding: &saml_pb.CreateResponseResponse_Redirect{Redirect: &saml_pb.RedirectResponse{}}, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + wantErr: false, + }, + { + name: "no usergrant and different resourceowner", + dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { + _, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, true) + + orgResp := Instance.CreateOrganization(ctx, "saml-permisison-"+gofakeit.AppName(), gofakeit.Email()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) + }, + wantErr: true, + }, + { + name: "no usergrant and same resourceowner", + dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { + _, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, true) + user := Instance.CreateHumanUser(ctx) + + return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) + }, + wantErr: true, + }, + { + name: "usergrant and different resourceowner", + dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { + projectID, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, true) + + orgResp := Instance.CreateOrganization(ctx, "saml-permisison-"+gofakeit.AppName(), gofakeit.Email()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) + }, + wantErr: true, + }, + { + name: "usergrant and same resourceowner", + dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { + projectID, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, true) + + user := Instance.CreateHumanUser(ctx) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) + }, + want: &saml_pb.CreateResponseResponse{ + Url: `https:\/\/(.*)\/saml\/acs\?RelayState=(.*)&SAMLResponse=(.*)&SigAlg=(.*)&Signature=(.*)`, + Binding: &saml_pb.CreateResponseResponse_Redirect{Redirect: &saml_pb.RedirectResponse{}}, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "projectRoleCheck, usergrant and same resourceowner", + dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { + projectID, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, false) + + user := Instance.CreateHumanUser(ctx) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) + }, + want: &saml_pb.CreateResponseResponse{ + Url: `https:\/\/(.*)\/saml\/acs\?RelayState=(.*)&SAMLResponse=(.*)&SigAlg=(.*)&Signature=(.*)`, + Binding: &saml_pb.CreateResponseResponse_Redirect{Redirect: &saml_pb.RedirectResponse{}}, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "projectRoleCheck, no usergrant and same resourceowner", + dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { + _, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, false) + user := Instance.CreateHumanUser(ctx) + + return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) + }, + wantErr: true, + }, + { + name: "projectRoleCheck, usergrant and different resourceowner", + dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { + projectID, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, false) + orgResp := Instance.CreateOrganization(ctx, "saml-permisison-"+gofakeit.AppName(), gofakeit.Email()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) + + return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) + }, + want: &saml_pb.CreateResponseResponse{ + Url: `https:\/\/(.*)\/saml\/acs\?RelayState=(.*)&SAMLResponse=(.*)&SigAlg=(.*)&Signature=(.*)`, + Binding: &saml_pb.CreateResponseResponse_Redirect{Redirect: &saml_pb.RedirectResponse{}}, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "projectRoleCheck, no usergrant and different resourceowner", + dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { + _, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, false) + orgResp := Instance.CreateOrganization(ctx, "saml-permisison-"+gofakeit.AppName(), gofakeit.Email()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + + return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) + }, + wantErr: true, + }, + { + name: "projectRoleCheck, usergrant on project grant and different resourceowner", + dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { + projectID, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, false) + + orgResp := Instance.CreateOrganization(ctx, "saml-permissison-"+gofakeit.AppName(), gofakeit.Email()) + projectGrantResp := Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + Instance.CreateProjectGrantUserGrant(ctx, orgResp.GetOrganizationId(), projectID, projectGrantResp.GetGrantId(), user.GetUserId()) + + return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) + }, + want: &saml_pb.CreateResponseResponse{ + Url: `https:\/\/(.*)\/saml\/acs\?RelayState=(.*)&SAMLResponse=(.*)&SigAlg=(.*)&Signature=(.*)`, + Binding: &saml_pb.CreateResponseResponse_Redirect{Redirect: &saml_pb.RedirectResponse{}}, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "projectRoleCheck, no usergrant on project grant and different resourceowner", + dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { + projectID, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, false) + + orgResp := Instance.CreateOrganization(ctx, "saml-permissison-"+gofakeit.AppName(), gofakeit.Email()) + Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + + return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) + }, + wantErr: true, + }, + { + name: "hasProjectCheck, same resourceowner", + dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { + _, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, false, true) + user := Instance.CreateHumanUser(ctx) + + return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) + }, + want: &saml_pb.CreateResponseResponse{ + Url: `https:\/\/(.*)\/saml\/acs\?RelayState=(.*)&SAMLResponse=(.*)&SigAlg=(.*)&Signature=(.*)`, + Binding: &saml_pb.CreateResponseResponse_Redirect{Redirect: &saml_pb.RedirectResponse{}}, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + { + name: "hasProjectCheck, different resourceowner", + dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { + _, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, false, true) + orgResp := Instance.CreateOrganization(ctx, "saml-permisison-"+gofakeit.AppName(), gofakeit.Email()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + + return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) + }, + wantErr: true, + }, + { + name: "hasProjectCheck, different resourceowner with project grant", + dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { + projectID, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, false, true) + orgResp := Instance.CreateOrganization(ctx, "saml-permissison-"+gofakeit.AppName(), gofakeit.Email()) + Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) + + return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) + }, + want: &saml_pb.CreateResponseResponse{ + Url: `https:\/\/(.*)\/saml\/acs\?RelayState=(.*)&SAMLResponse=(.*)&SigAlg=(.*)&Signature=(.*)`, + Binding: &saml_pb.CreateResponseResponse_Redirect{Redirect: &saml_pb.RedirectResponse{}}, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := tt.dep(IAMCTX, t) + + got, err := Client.CreateResponse(CTX, req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + integration.AssertDetails(t, tt.want, got) + if tt.want != nil { + assert.Regexp(t, regexp.MustCompile(tt.want.Url), got.GetUrl()) + if tt.want.GetPost() != nil { + assert.NotEmpty(t, got.GetPost().GetRelayState()) + assert.NotEmpty(t, got.GetPost().GetSamlResponse()) + } + if tt.want.GetRedirect() != nil { + assert.NotNil(t, got.GetRedirect()) + } + } + }) + } +} + +func createSession(ctx context.Context, t *testing.T, userID string) *session.CreateSessionResponse { + sessionResp, err := Instance.Client.SessionV2.CreateSession(ctx, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: userID, + }, + }, + }, + }) + require.NoError(t, err) + return sessionResp +} + +func createSessionAndSmlRequestForCallback(ctx context.Context, t *testing.T, sp *samlsp.Middleware, loginClient string, acsRedirect saml.Endpoint, userID, binding string) *saml_pb.CreateResponseRequest { + _, authRequestID, err := Instance.CreateSAMLAuthRequest(sp, loginClient, acsRedirect, gofakeit.BitcoinAddress(), binding) + require.NoError(t, err) + sessionResp := createSession(ctx, t, userID) + return &saml_pb.CreateResponseRequest{ + SamlRequestId: authRequestID, + ResponseKind: &saml_pb.CreateResponseRequest_Session{ + Session: &saml_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: sessionResp.GetSessionToken(), + }, + }, + } +} + +func createSAMLSP(t *testing.T, idpMetadata *saml.EntityDescriptor, binding string) (string, *samlsp.Middleware) { + rootURL := "example." + gofakeit.DomainName() + spMiddleware, err := integration.CreateSAMLSP("https://"+rootURL, idpMetadata, binding) + require.NoError(t, err) + return rootURL, spMiddleware +} + +func createSAMLApplication(ctx context.Context, t *testing.T, idpMetadata *saml.EntityDescriptor, binding string, projectRoleCheck, hasProjectCheck bool) (string, string, *samlsp.Middleware) { + project, err := Instance.CreateProjectWithPermissionCheck(ctx, projectRoleCheck, hasProjectCheck) + require.NoError(t, err) + rootURL, sp := createSAMLSP(t, idpMetadata, binding) + _, err = Instance.CreateSAMLClient(ctx, project.GetId(), sp) + require.NoError(t, err) + return project.GetId(), rootURL, sp +} diff --git a/internal/api/grpc/saml/v2/integration/server_test.go b/internal/api/grpc/saml/v2/integration/server_test.go new file mode 100644 index 0000000000..ab9e92a157 --- /dev/null +++ b/internal/api/grpc/saml/v2/integration/server_test.go @@ -0,0 +1,34 @@ +//go:build integration + +package saml_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/zitadel/zitadel/internal/integration" + saml_pb "github.com/zitadel/zitadel/pkg/grpc/saml/v2" +) + +var ( + CTX context.Context + IAMCTX context.Context + Instance *integration.Instance + Client saml_pb.SAMLServiceClient +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + Instance = integration.NewInstance(ctx) + Client = Instance.Client.SAMLv2 + + IAMCTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner) + CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) + return m.Run() + }()) +} diff --git a/internal/api/grpc/saml/v2/saml.go b/internal/api/grpc/saml/v2/saml.go index de4f3440ab..866846dfd7 100644 --- a/internal/api/grpc/saml/v2/saml.go +++ b/internal/api/grpc/saml/v2/saml.go @@ -14,7 +14,7 @@ import ( saml_pb "github.com/zitadel/zitadel/pkg/grpc/saml/v2" ) -func (s *Server) GetAuthRequest(ctx context.Context, req *saml_pb.GetSAMLRequestRequest) (*saml_pb.GetSAMLRequestResponse, error) { +func (s *Server) GetSAMLRequest(ctx context.Context, req *saml_pb.GetSAMLRequestRequest) (*saml_pb.GetSAMLRequestResponse, error) { authRequest, err := s.query.SamlRequestByID(ctx, true, req.GetSamlRequestId(), true) if err != nil { logging.WithError(err).Error("query samlRequest by ID") @@ -56,8 +56,22 @@ func (s *Server) failSAMLRequest(ctx context.Context, samlRequestID string, ae * return createCallbackResponseFromBinding(details, url, body, authReq.RelayState), nil } +func (s *Server) checkPermission(ctx context.Context, issuer string, userID string) error { + permission, err := s.query.CheckProjectPermissionByEntityID(ctx, issuer, userID) + if err != nil { + return err + } + if !permission.HasProjectChecked { + return zerrors.ThrowPermissionDenied(nil, "SAML-foSyH49RvL", "Errors.User.ProjectRequired") + } + if !permission.ProjectRoleChecked { + return zerrors.ThrowPermissionDenied(nil, "SAML-foSyH49RvL", "Errors.User.GrantRequired") + } + return nil +} + func (s *Server) linkSessionToSAMLRequest(ctx context.Context, samlRequestID string, session *saml_pb.Session) (*saml_pb.CreateResponseResponse, error) { - details, aar, err := s.command.LinkSessionToSAMLRequest(ctx, samlRequestID, session.GetSessionId(), session.GetSessionToken(), true) + details, aar, err := s.command.LinkSessionToSAMLRequest(ctx, samlRequestID, session.GetSessionId(), session.GetSessionToken(), true, s.checkPermission) if err != nil { return nil, err } diff --git a/internal/api/grpc/session/v2/integration_test/query_test.go b/internal/api/grpc/session/v2/integration_test/query_test.go index 36e412be23..4b2eacf570 100644 --- a/internal/api/grpc/session/v2/integration_test/query_test.go +++ b/internal/api/grpc/session/v2/integration_test/query_test.go @@ -695,6 +695,12 @@ func TestServer_ListSessions(t *testing.T) { return } + // expected count of sessions is not equal to created dependencies + if !assert.Len(ttt, tt.want.Sessions, len(infos)) { + return + } + + // expected count of sessions is not equal to received sessions if !assert.Equal(ttt, got.Details.TotalResult, tt.want.Details.TotalResult) || !assert.Len(ttt, got.Sessions, len(tt.want.Sessions)) { return } @@ -705,8 +711,17 @@ func TestServer_ListSessions(t *testing.T) { tt.want.Sessions[i].CreationDate = infos[i].Details.GetChangeDate() tt.want.Sessions[i].ChangeDate = infos[i].Details.GetChangeDate() - verifySession(ttt, got.Sessions[i], tt.want.Sessions[i], time.Minute, tt.wantExpirationWindow, infos[i].UserID, tt.wantFactors...) + // only check for contents of the session, not sorting for now + found := false + for _, session := range got.Sessions { + if session.Id == infos[i].ID { + verifySession(ttt, session, tt.want.Sessions[i], time.Minute, tt.wantExpirationWindow, infos[i].UserID, tt.wantFactors...) + found = true + } + } + assert.True(t, found) } + integration.AssertListDetails(ttt, tt.want, got) }, retryDuration, tick) }) diff --git a/internal/api/oidc/integration_test/client_test.go b/internal/api/oidc/integration_test/client_test.go index 6d61e84437..43b000108c 100644 --- a/internal/api/oidc/integration_test/client_test.go +++ b/internal/api/oidc/integration_test/client_test.go @@ -307,7 +307,7 @@ func TestServer_VerifyClient(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, tt.client.authReqClientID, Instance.Users.Get(integration.UserTypeLogin).ID, redirectURI, oidc.ScopeOpenID) + _, authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, tt.client.authReqClientID, Instance.Users.Get(integration.UserTypeLogin).ID, redirectURI, oidc.ScopeOpenID) require.NoError(t, err) linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ AuthRequestId: authRequestID, diff --git a/internal/api/oidc/integration_test/oidc_test.go b/internal/api/oidc/integration_test/oidc_test.go index 302c818c36..2ab78b972e 100644 --- a/internal/api/oidc/integration_test/oidc_test.go +++ b/internal/api/oidc/integration_test/oidc_test.go @@ -441,13 +441,13 @@ func createImplicitClientNoLoginClientHeader(t testing.TB) string { } func createAuthRequest(t testing.TB, instance *integration.Instance, clientID, redirectURI string, scope ...string) string { - redURL, err := instance.CreateOIDCAuthRequest(CTX, clientID, instance.Users.Get(integration.UserTypeLogin).ID, redirectURI, scope...) + _, redURL, err := instance.CreateOIDCAuthRequest(CTX, clientID, instance.Users.Get(integration.UserTypeLogin).ID, redirectURI, scope...) require.NoError(t, err) return redURL } func createAuthRequestNoLoginClientHeader(t testing.TB, instance *integration.Instance, clientID, redirectURI string, scope ...string) string { - redURL, err := instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, clientID, redirectURI, "", scope...) + _, redURL, err := instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, clientID, redirectURI, "", scope...) require.NoError(t, err) return redURL } diff --git a/internal/command/auth_request.go b/internal/command/auth_request.go index 91705acedf..340155d11b 100644 --- a/internal/command/auth_request.go +++ b/internal/command/auth_request.go @@ -80,7 +80,7 @@ func (c *Commands) AddAuthRequest(ctx context.Context, authRequest *AuthRequest) return authRequestWriteModelToCurrentAuthRequest(writeModel), nil } -func (c *Commands) LinkSessionToAuthRequest(ctx context.Context, id, sessionID, sessionToken string, checkLoginClient bool) (*domain.ObjectDetails, *CurrentAuthRequest, error) { +func (c *Commands) LinkSessionToAuthRequest(ctx context.Context, id, sessionID, sessionToken string, checkLoginClient bool, projectPermissionCheck domain.ProjectPermissionCheck) (*domain.ObjectDetails, *CurrentAuthRequest, error) { writeModel, err := c.getAuthRequestWriteModel(ctx, id) if err != nil { return nil, nil, err @@ -96,6 +96,7 @@ func (c *Commands) LinkSessionToAuthRequest(ctx context.Context, id, sessionID, return nil, nil, err } } + sessionWriteModel := NewSessionWriteModel(sessionID, authz.GetInstance(ctx).InstanceID()) err = c.eventstore.FilterToQueryReducer(ctx, sessionWriteModel) if err != nil { @@ -108,6 +109,12 @@ func (c *Commands) LinkSessionToAuthRequest(ctx context.Context, id, sessionID, return nil, nil, err } + if projectPermissionCheck != nil { + if err := projectPermissionCheck(ctx, writeModel.ClientID, sessionWriteModel.UserID); err != nil { + return nil, nil, err + } + } + if err := c.pushAppendAndReduce(ctx, writeModel, authrequest.NewSessionLinkedEvent( ctx, &authrequest.NewAggregate(id, authz.GetInstance(ctx).InstanceID()).Aggregate, sessionID, diff --git a/internal/command/auth_request_test.go b/internal/command/auth_request_test.go index 3668b6563b..590e4086f4 100644 --- a/internal/command/auth_request_test.go +++ b/internal/command/auth_request_test.go @@ -181,6 +181,7 @@ func TestCommands_LinkSessionToAuthRequest(t *testing.T) { sessionID string sessionToken string checkLoginClient bool + permissionCheck domain.ProjectPermissionCheck } type res struct { details *domain.ObjectDetails @@ -712,6 +713,163 @@ func TestCommands_LinkSessionToAuthRequest(t *testing.T) { }, }, }, + { + "linked with permission, application permission check", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + authrequest.NewAddedEvent(mockCtx, &authrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "otherLoginClient", + "clientID", + "redirectURI", + "state", + "nonce", + []string{"openid"}, + []string{"audience"}, + domain.OIDCResponseTypeCode, + domain.OIDCResponseModeQuery, + nil, + nil, + nil, + nil, + nil, + nil, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(mockCtx, + &session.NewAggregate("sessionID", "instance1").Aggregate, + &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + )), + eventFromEventPusher( + session.NewUserCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + "userID", "org1", testNow, &language.Afrikaans), + ), + eventFromEventPusher( + session.NewPasswordCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + testNow), + ), + eventFromEventPusherWithCreationDateNow( + session.NewLifetimeSetEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + 2*time.Minute), + ), + ), + expectPush( + authrequest.NewSessionLinkedEvent(mockCtx, &authrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "sessionID", + "userID", + testNow, + []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + ), + ), + ), + tokenVerifier: newMockTokenVerifierValid(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "orgID", "loginClient"), + id: "V2_id", + sessionID: "sessionID", + sessionToken: "token", + checkLoginClient: true, + permissionCheck: newMockProjectPermissionCheckAllowed(), + }, + res{ + details: &domain.ObjectDetails{ResourceOwner: "instanceID"}, + authReq: &CurrentAuthRequest{ + AuthRequest: &AuthRequest{ + ID: "V2_id", + LoginClient: "otherLoginClient", + ClientID: "clientID", + RedirectURI: "redirectURI", + State: "state", + Nonce: "nonce", + Scope: []string{"openid"}, + Audience: []string{"audience"}, + ResponseType: domain.OIDCResponseTypeCode, + ResponseMode: domain.OIDCResponseModeQuery, + }, + SessionID: "sessionID", + UserID: "userID", + AuthMethods: []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + }, + }, + }, + { + "linked with permission, no application permission", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + authrequest.NewAddedEvent(mockCtx, &authrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "otherLoginClient", + "clientID", + "redirectURI", + "state", + "nonce", + []string{"openid"}, + []string{"audience"}, + domain.OIDCResponseTypeCode, + domain.OIDCResponseModeQuery, + nil, + nil, + nil, + nil, + nil, + nil, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(mockCtx, + &session.NewAggregate("sessionID", "instance1").Aggregate, + &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + )), + eventFromEventPusher( + session.NewUserCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + "userID", "org1", testNow, &language.Afrikaans), + ), + eventFromEventPusher( + session.NewPasswordCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + testNow), + ), + eventFromEventPusherWithCreationDateNow( + session.NewLifetimeSetEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + 2*time.Minute), + ), + ), + ), + tokenVerifier: newMockTokenVerifierValid(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "orgID", "loginClient"), + id: "V2_id", + sessionID: "sessionID", + sessionToken: "token", + checkLoginClient: true, + permissionCheck: newMockProjectPermissionCheckOIDCNotAllowed(), + }, + res{ + wantErr: zerrors.ThrowPermissionDenied(nil, "OIDC-foSyH49RvL", "Errors.PermissionDenied"), + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -720,7 +878,7 @@ func TestCommands_LinkSessionToAuthRequest(t *testing.T) { sessionTokenVerifier: tt.fields.tokenVerifier, checkPermission: tt.fields.checkPermission, } - details, got, err := c.LinkSessionToAuthRequest(tt.args.ctx, tt.args.id, tt.args.sessionID, tt.args.sessionToken, tt.args.checkLoginClient) + details, got, err := c.LinkSessionToAuthRequest(tt.args.ctx, tt.args.id, tt.args.sessionID, tt.args.sessionToken, tt.args.checkLoginClient, tt.args.permissionCheck) require.ErrorIs(t, err, tt.res.wantErr) assertObjectDetails(t, tt.res.details, details) if err == nil { diff --git a/internal/command/main_test.go b/internal/command/main_test.go index e75392309f..61d1abf6fd 100644 --- a/internal/command/main_test.go +++ b/internal/command/main_test.go @@ -232,6 +232,24 @@ func newMockPermissionCheckNotAllowed() domain.PermissionCheck { } } +func newMockProjectPermissionCheckAllowed() domain.ProjectPermissionCheck { + return func(ctx context.Context, clientID, userID string) (err error) { + return nil + } +} + +func newMockProjectPermissionCheckOIDCNotAllowed() domain.ProjectPermissionCheck { + return func(ctx context.Context, clientID, userID string) (err error) { + return zerrors.ThrowPermissionDenied(nil, "OIDC-foSyH49RvL", "Errors.PermissionDenied") + } +} + +func newMockProjectPermissionCheckSAMLNotAllowed() domain.ProjectPermissionCheck { + return func(ctx context.Context, clientID, userID string) (err error) { + return zerrors.ThrowPermissionDenied(nil, "SAML-foSyH49RvL", "Errors.PermissionDenied") + } +} + func newMockTokenVerifierValid() func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) { return func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) { return nil diff --git a/internal/command/saml_request.go b/internal/command/saml_request.go index 9d12ba6e44..2dfa8756c7 100644 --- a/internal/command/saml_request.go +++ b/internal/command/saml_request.go @@ -63,7 +63,7 @@ func (c *Commands) AddSAMLRequest(ctx context.Context, samlRequest *SAMLRequest) return samlRequestWriteModelToCurrentSAMLRequest(writeModel), nil } -func (c *Commands) LinkSessionToSAMLRequest(ctx context.Context, id, sessionID, sessionToken string, checkLoginClient bool) (*domain.ObjectDetails, *CurrentSAMLRequest, error) { +func (c *Commands) LinkSessionToSAMLRequest(ctx context.Context, id, sessionID, sessionToken string, checkLoginClient bool, projectPermissionCheck domain.ProjectPermissionCheck) (*domain.ObjectDetails, *CurrentSAMLRequest, error) { writeModel, err := c.getSAMLRequestWriteModel(ctx, id) if err != nil { return nil, nil, err @@ -89,6 +89,12 @@ func (c *Commands) LinkSessionToSAMLRequest(ctx context.Context, id, sessionID, return nil, nil, err } + if projectPermissionCheck != nil { + if err := projectPermissionCheck(ctx, writeModel.Issuer, sessionWriteModel.UserID); err != nil { + return nil, nil, err + } + } + if err := c.pushAppendAndReduce(ctx, writeModel, samlrequest.NewSessionLinkedEvent( ctx, &samlrequest.NewAggregate(id, authz.GetInstance(ctx).InstanceID()).Aggregate, sessionID, diff --git a/internal/command/saml_request_test.go b/internal/command/saml_request_test.go index 18b1c2a392..ed7363e151 100644 --- a/internal/command/saml_request_test.go +++ b/internal/command/saml_request_test.go @@ -141,6 +141,7 @@ func TestCommands_LinkSessionToSAMLRequest(t *testing.T) { sessionID string sessionToken string checkLoginClient bool + checkPermission domain.ProjectPermissionCheck } type res struct { details *domain.ObjectDetails @@ -524,6 +525,144 @@ func TestCommands_LinkSessionToSAMLRequest(t *testing.T) { }, }, }, + { + "linked with login client check, application permission check", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "loginClient", + "application", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + ), + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(mockCtx, + &session.NewAggregate("sessionID", "instance1").Aggregate, + &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + )), + eventFromEventPusher( + session.NewUserCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + "userID", "org1", testNow, &language.Afrikaans), + ), + eventFromEventPusher( + session.NewPasswordCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + testNow), + ), + eventFromEventPusherWithCreationDateNow( + session.NewLifetimeSetEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + 2*time.Minute), + ), + ), + expectPush( + samlrequest.NewSessionLinkedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "sessionID", + "userID", + testNow, + []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + ), + ), + ), + tokenVerifier: newMockTokenVerifierValid(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "orgID", "loginClient"), + id: "V2_id", + sessionID: "sessionID", + sessionToken: "token", + checkLoginClient: true, + checkPermission: newMockProjectPermissionCheckAllowed(), + }, + res{ + details: &domain.ObjectDetails{ResourceOwner: "instanceID"}, + authReq: &CurrentSAMLRequest{ + SAMLRequest: &SAMLRequest{ + ID: "V2_id", + LoginClient: "loginClient", + ApplicationID: "application", + ACSURL: "acs", + RelayState: "relaystate", + RequestID: "request", + Binding: "binding", + Issuer: "issuer", + Destination: "destination", + }, + SessionID: "sessionID", + UserID: "userID", + AuthMethods: []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + }, + }, + }, + { + "linked with login client check, no application permission", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "loginClient", + "application", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + ), + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(mockCtx, + &session.NewAggregate("sessionID", "instance1").Aggregate, + &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + )), + eventFromEventPusher( + session.NewUserCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + "userID", "org1", testNow, &language.Afrikaans), + ), + eventFromEventPusher( + session.NewPasswordCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + testNow), + ), + eventFromEventPusherWithCreationDateNow( + session.NewLifetimeSetEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + 2*time.Minute), + ), + ), + ), + tokenVerifier: newMockTokenVerifierValid(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "orgID", "loginClient"), + id: "V2_id", + sessionID: "sessionID", + sessionToken: "token", + checkLoginClient: true, + checkPermission: newMockProjectPermissionCheckSAMLNotAllowed(), + }, + res{ + wantErr: zerrors.ThrowPermissionDenied(nil, "SAML-foSyH49RvL", "Errors.PermissionDenied"), + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -531,7 +670,7 @@ func TestCommands_LinkSessionToSAMLRequest(t *testing.T) { eventstore: tt.fields.eventstore(t), sessionTokenVerifier: tt.fields.tokenVerifier, } - details, got, err := c.LinkSessionToSAMLRequest(tt.args.ctx, tt.args.id, tt.args.sessionID, tt.args.sessionToken, tt.args.checkLoginClient) + details, got, err := c.LinkSessionToSAMLRequest(tt.args.ctx, tt.args.id, tt.args.sessionID, tt.args.sessionToken, tt.args.checkLoginClient, tt.args.checkPermission) require.ErrorIs(t, err, tt.res.wantErr) assertObjectDetails(t, tt.res.details, details) if err == nil { diff --git a/internal/domain/permission.go b/internal/domain/permission.go index bf24c09e53..0ddf08a664 100644 --- a/internal/domain/permission.go +++ b/internal/domain/permission.go @@ -39,3 +39,7 @@ const ( PermissionIDPRead = "iam.idp.read" PermissionOrgIDPRead = "org.idp.read" ) + +// ProjectPermissionCheck is used as a check for preconditions dependent on application, project, user resourceowner and usergrants. +// Configurable on the project the application belongs to through the flags related to authentication. +type ProjectPermissionCheck func(ctx context.Context, clientID, userID string) (err error) diff --git a/internal/integration/client.go b/internal/integration/client.go index d18c2d9b12..cefaf0ef42 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -678,6 +678,15 @@ func (i *Instance) CreatePasswordSession(t *testing.T, ctx context.Context, user createResp.GetDetails().GetChangeDate().AsTime(), createResp.GetDetails().GetChangeDate().AsTime() } +func (i *Instance) CreateProjectGrant(ctx context.Context, projectID, grantedOrgID string) *mgmt.AddProjectGrantResponse { + resp, err := i.Client.Mgmt.AddProjectGrant(ctx, &mgmt.AddProjectGrantRequest{ + GrantedOrgId: grantedOrgID, + ProjectId: projectID, + }) + logging.OnError(err).Panic("create project grant") + return resp +} + func (i *Instance) CreateProjectUserGrant(t *testing.T, ctx context.Context, projectID, userID string) string { resp, err := i.Client.Mgmt.AddUserGrant(ctx, &mgmt.AddUserGrantRequest{ UserId: userID, @@ -687,6 +696,16 @@ func (i *Instance) CreateProjectUserGrant(t *testing.T, ctx context.Context, pro return resp.GetUserGrantId() } +func (i *Instance) CreateProjectGrantUserGrant(ctx context.Context, orgID, projectID, projectGrantID, userID string) string { + resp, err := i.Client.Mgmt.AddUserGrant(SetOrgID(ctx, orgID), &mgmt.AddUserGrantRequest{ + UserId: userID, + ProjectId: projectID, + ProjectGrantId: projectGrantID, + }) + logging.OnError(err).Panic("create project grant user grant") + return resp.GetUserGrantId() +} + func (i *Instance) CreateOrgMembership(t *testing.T, ctx context.Context, userID string) { _, err := i.Client.Mgmt.AddOrgMember(ctx, &mgmt.AddOrgMemberRequest{ UserId: userID, diff --git a/internal/integration/oidc.go b/internal/integration/oidc.go index 04cf951048..4d7f5277c9 100644 --- a/internal/integration/oidc.go +++ b/internal/integration/oidc.go @@ -194,6 +194,14 @@ func (i *Instance) CreateProject(ctx context.Context) (*management.AddProjectRes }) } +func (i *Instance) CreateProjectWithPermissionCheck(ctx context.Context, projectRoleCheck, hasProjectCheck bool) (*management.AddProjectResponse, error) { + return i.Client.Mgmt.AddProject(ctx, &management.AddProjectRequest{ + Name: fmt.Sprintf("project-%d", time.Now().UnixNano()), + HasProjectCheck: hasProjectCheck, + ProjectRoleCheck: projectRoleCheck, + }) +} + func (i *Instance) CreateAPIClientJWT(ctx context.Context, projectID string) (*management.AddAPIAppResponse, error) { return i.Client.Mgmt.AddAPIApp(ctx, &management.AddAPIAppRequest{ ProjectId: projectID, @@ -212,22 +220,22 @@ func (i *Instance) CreateAPIClientBasic(ctx context.Context, projectID string) ( const CodeVerifier = "codeVerifier" -func (i *Instance) CreateOIDCAuthRequest(ctx context.Context, clientID, loginClient, redirectURI string, scope ...string) (authRequestID string, err error) { +func (i *Instance) CreateOIDCAuthRequest(ctx context.Context, clientID, loginClient, redirectURI string, scope ...string) (now time.Time, authRequestID string, err error) { return i.CreateOIDCAuthRequestWithDomain(ctx, i.Domain, clientID, loginClient, redirectURI, scope...) } -func (i *Instance) CreateOIDCAuthRequestWithoutLoginClientHeader(ctx context.Context, clientID, redirectURI, loginBaseURI string, scope ...string) (authRequestID string, err error) { +func (i *Instance) CreateOIDCAuthRequestWithoutLoginClientHeader(ctx context.Context, clientID, redirectURI, loginBaseURI string, scope ...string) (now time.Time, authRequestID string, err error) { return i.createOIDCAuthRequestWithDomain(ctx, i.Domain, clientID, redirectURI, "", loginBaseURI, scope...) } -func (i *Instance) CreateOIDCAuthRequestWithDomain(ctx context.Context, domain, clientID, loginClient, redirectURI string, scope ...string) (authRequestID string, err error) { +func (i *Instance) CreateOIDCAuthRequestWithDomain(ctx context.Context, domain, clientID, loginClient, redirectURI string, scope ...string) (now time.Time, authRequestID string, err error) { return i.createOIDCAuthRequestWithDomain(ctx, domain, clientID, redirectURI, loginClient, "", scope...) } -func (i *Instance) createOIDCAuthRequestWithDomain(ctx context.Context, domain, clientID, redirectURI, loginClient, loginBaseURI string, scope ...string) (authRequestID string, err error) { +func (i *Instance) createOIDCAuthRequestWithDomain(ctx context.Context, domain, clientID, redirectURI, loginClient, loginBaseURI string, scope ...string) (now time.Time, authRequestID string, err error) { provider, err := i.CreateRelyingPartyForDomain(ctx, domain, clientID, redirectURI, loginClient, scope...) if err != nil { - return "", fmt.Errorf("create relying party: %w", err) + return now, "", fmt.Errorf("create relying party: %w", err) } codeChallenge := oidc.NewSHACodeChallenge(CodeVerifier) authURL := rp.AuthURL("state", provider, rp.WithCodeChallenge(codeChallenge)) @@ -238,21 +246,22 @@ func (i *Instance) createOIDCAuthRequestWithDomain(ctx context.Context, domain, } req, err := GetRequest(authURL, headers) if err != nil { - return "", fmt.Errorf("get request: %w", err) + return now, "", fmt.Errorf("get request: %w", err) } + now = time.Now() loc, err := CheckRedirect(req) if err != nil { - return "", fmt.Errorf("check redirect: %w", err) + return now, "", fmt.Errorf("check redirect: %w", err) } if loginBaseURI == "" { loginBaseURI = provider.Issuer() + i.Config.LoginURLV2 } if !strings.HasPrefix(loc.String(), loginBaseURI) { - return "", fmt.Errorf("login location has not prefix %s, but is %s", loginBaseURI, loc.String()) + return now, "", fmt.Errorf("login location has not prefix %s, but is %s", loginBaseURI, loc.String()) } - return strings.TrimPrefix(loc.String(), loginBaseURI), nil + return now, strings.TrimPrefix(loc.String(), loginBaseURI), nil } func (i *Instance) CreateOIDCAuthRequestImplicitWithoutLoginClientHeader(ctx context.Context, clientID, redirectURI string, scope ...string) (authRequestID string, err error) { diff --git a/internal/integration/saml.go b/internal/integration/saml.go index bf04246956..483543b322 100644 --- a/internal/integration/saml.go +++ b/internal/integration/saml.go @@ -11,6 +11,7 @@ import ( "net/http" "net/url" "strings" + "time" "github.com/brianvoe/gofakeit/v6" "github.com/crewjam/saml" @@ -135,32 +136,33 @@ func (i *Instance) CreateSAMLClient(ctx context.Context, projectID string, m *sa }) } -func (i *Instance) CreateSAMLAuthRequest(m *samlsp.Middleware, loginClient string, acs saml.Endpoint, relayState string, responseBinding string) (authRequestID string, err error) { +func (i *Instance) CreateSAMLAuthRequest(m *samlsp.Middleware, loginClient string, acs saml.Endpoint, relayState string, responseBinding string) (now time.Time, authRequestID string, err error) { authReq, err := m.ServiceProvider.MakeAuthenticationRequest(acs.Location, acs.Binding, responseBinding) if err != nil { - return "", err + return now, "", err } redirectURL, err := authReq.Redirect(relayState, &m.ServiceProvider) if err != nil { - return "", err + return now, "", err } req, err := GetRequest(redirectURL.String(), map[string]string{oidc_internal.LoginClientHeader: loginClient}) if err != nil { - return "", fmt.Errorf("get request: %w", err) + return now, "", fmt.Errorf("get request: %w", err) } + now = time.Now() loc, err := CheckRedirect(req) if err != nil { - return "", fmt.Errorf("check redirect: %w", err) + return now, "", fmt.Errorf("check redirect: %w", err) } prefixWithHost := i.Issuer() + i.Config.LoginURLV2 if !strings.HasPrefix(loc.String(), prefixWithHost) { - return "", fmt.Errorf("login location has not prefix %s, but is %s", prefixWithHost, loc.String()) + return now, "", fmt.Errorf("login location has not prefix %s, but is %s", prefixWithHost, loc.String()) } - return strings.TrimPrefix(loc.String(), prefixWithHost), nil + return now, strings.TrimPrefix(loc.String(), prefixWithHost), nil } func (i *Instance) FailSAMLAuthRequest(ctx context.Context, id string, reason saml_pb.ErrorReason) *saml_pb.CreateResponseResponse { diff --git a/internal/notification/handlers/integration_test/telemetry_pusher_test.go b/internal/notification/handlers/integration_test/telemetry_pusher_test.go index 9252790263..1163195377 100644 --- a/internal/notification/handlers/integration_test/telemetry_pusher_test.go +++ b/internal/notification/handlers/integration_test/telemetry_pusher_test.go @@ -79,7 +79,7 @@ func TestServer_TelemetryPushMilestones(t *testing.T) { func loginToClient(t *testing.T, instance *integration.Instance, clientID, redirectURI, sessionID, sessionToken string) { iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) - authRequestID, err := instance.CreateOIDCAuthRequestWithDomain(iamOwnerCtx, instance.Domain, clientID, instance.Users.Get(integration.UserTypeIAMOwner).ID, redirectURI, "openid") + _, authRequestID, err := instance.CreateOIDCAuthRequestWithDomain(iamOwnerCtx, instance.Domain, clientID, instance.Users.Get(integration.UserTypeIAMOwner).ID, redirectURI, "openid") require.NoError(t, err) callback, err := instance.Client.OIDCv2.CreateCallback(iamOwnerCtx, &oidc_v2.CreateCallbackRequest{ AuthRequestId: authRequestID, diff --git a/internal/query/app.go b/internal/query/app.go index b826bb52b8..1aa0323a5a 100644 --- a/internal/query/app.go +++ b/internal/query/app.go @@ -3,6 +3,7 @@ package query import ( "context" "database/sql" + _ "embed" "errors" "time" @@ -368,6 +369,77 @@ func (q *Queries) ProjectByClientID(ctx context.Context, appID string) (project return project, err } +//go:embed app_oidc_project_permission.sql +var appOIDCProjectPermissionQuery string + +func (q *Queries) CheckProjectPermissionByClientID(ctx context.Context, clientID, userID string) (_ *projectPermission, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + var p *projectPermission + err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { + p, err = scanProjectPermissionByClientID(row) + return err + }, appOIDCProjectPermissionQuery, + authz.GetInstance(ctx).InstanceID(), + clientID, + domain.AppStateActive, + domain.ProjectStateActive, + userID, + domain.UserStateActive, + domain.ProjectGrantStateActive, + domain.UserGrantStateActive, + ) + return p, err +} + +//go:embed app_saml_project_permission.sql +var appSAMLProjectPermissionQuery string + +func (q *Queries) CheckProjectPermissionByEntityID(ctx context.Context, entityID, userID string) (_ *projectPermission, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + var p *projectPermission + err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { + p, err = scanProjectPermissionByClientID(row) + return err + }, appSAMLProjectPermissionQuery, + authz.GetInstance(ctx).InstanceID(), + entityID, + domain.AppStateActive, + domain.ProjectStateActive, + userID, + domain.UserStateActive, + domain.ProjectGrantStateActive, + domain.UserGrantStateActive, + ) + return p, err +} + +type projectPermission struct { + HasProjectChecked bool + ProjectRoleChecked bool +} + +func scanProjectPermissionByClientID(row *sql.Row) (*projectPermission, error) { + var hasProjectChecked, projectRoleChecked sql.NullBool + err := row.Scan( + &hasProjectChecked, + &projectRoleChecked, + ) + if err != nil || !hasProjectChecked.Valid || !projectRoleChecked.Valid { + if errors.Is(err, sql.ErrNoRows) { + return nil, zerrors.ThrowNotFound(err, "QUERY-4tq8wCTCgf", "Errors.App.NotFound") + } + return nil, zerrors.ThrowInternal(err, "QUERY-NwH4lAqlZC", "Errors.Internal") + } + return &projectPermission{ + HasProjectChecked: hasProjectChecked.Bool, + ProjectRoleChecked: projectRoleChecked.Bool, + }, nil +} + func (q *Queries) ProjectIDFromClientID(ctx context.Context, appID string) (id string, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() diff --git a/internal/query/app_oidc_project_permission.sql b/internal/query/app_oidc_project_permission.sql new file mode 100644 index 0000000000..cf25e6763e --- /dev/null +++ b/internal/query/app_oidc_project_permission.sql @@ -0,0 +1,74 @@ +with application as ( + SELECT a.instance_id, + a.resource_owner, + a.project_id, + a.id as app_id, + p.project_role_check, + p.has_project_check + FROM projections.apps7 as a + LEFT JOIN projections.apps7_oidc_configs as aoc + ON aoc.app_id = a.id + AND aoc.instance_id = a.instance_id + INNER JOIN projections.projects4 as p + ON p.instance_id = a.instance_id + AND p.resource_owner = a.resource_owner + AND p.id = a.project_id + WHERE a.instance_id = $1 + AND aoc.client_id = $2 + AND a.state = $3 + AND p.state = $4 +), user_resourceowner as ( +/* resourceowner of the active user */ + SELECT u.instance_id, + u.resource_owner, + u.id as user_id + FROM projections.users14 as u + WHERE u.instance_id = $1 + AND u.id = $5 + AND u.state = $6 +), has_project_grant_check as ( +/* all projectgrants active, then filtered with the project and user resourceowner */ + SELECT pg.instance_id, + pg.resource_owner, + pg.project_id, + pg.granted_org_id + FROM projections.project_grants4 as pg + WHERE pg.instance_id = $1 + AND pg.state = $7 +), project_role_check as ( +/* all usergrants active and associated with the user, then filtered with the project */ + SELECT ug.instance_id, + ug.resource_owner, + ug.project_id + FROM projections.user_grants5 as ug + WHERE ug.instance_id = $1 + AND ug.user_id = $5 + AND ug.state = $8 +) +SELECT + /* project existence does not need to be checked, or resourceowner of user and project are equal, or resourceowner of user has project granted*/ + bool_and(COALESCE( + (NOT a.has_project_check OR + a.resource_owner = uro.resource_owner OR + uro.resource_owner = hpgc.granted_org_id) + , FALSE) + ) as project_checked, + /* authentication existence does not need to checked, or authentication for project is existing*/ + bool_and(COALESCE( + (NOT a.project_role_check OR + a.project_id = prc.project_id) + , FALSE) + ) as role_checked +FROM application as a + LEFT JOIN user_resourceowner as uro + ON uro.instance_id = a.instance_id + LEFT JOIN has_project_grant_check as hpgc + ON hpgc.instance_id = a.instance_id + AND hpgc.project_id = a.project_id + AND hpgc.granted_org_id = uro.resource_owner + LEFT JOIN project_role_check as prc + ON prc.instance_id = a.instance_id + AND prc.project_id = a.project_id +GROUP BY a.instance_id, a.resource_owner, a.project_id, a.app_id, uro.resource_owner, hpgc.granted_org_id, + prc.project_id +LIMIT 1; diff --git a/internal/query/app_saml_project_permission.sql b/internal/query/app_saml_project_permission.sql new file mode 100644 index 0000000000..9f15668c23 --- /dev/null +++ b/internal/query/app_saml_project_permission.sql @@ -0,0 +1,74 @@ +with application as ( + SELECT a.instance_id, + a.resource_owner, + a.project_id, + a.id as app_id, + p.project_role_check, + p.has_project_check + FROM projections.apps7 as a + LEFT JOIN projections.apps7_saml_configs as asaml + ON asaml.app_id = a.id + AND asaml.instance_id = a.instance_id + INNER JOIN projections.projects4 as p + ON p.instance_id = a.instance_id + AND p.resource_owner = a.resource_owner + AND p.id = a.project_id + WHERE a.instance_id = $1 + AND asaml.entity_id = $2 + AND a.state = $3 + AND p.state = $4 +), user_resourceowner as ( +/* resourceowner of the active user */ + SELECT u.instance_id, + u.resource_owner, + u.id as user_id + FROM projections.users14 as u + WHERE u.instance_id = $1 + AND u.id = $5 + AND u.state = $6 +), has_project_grant_check as ( +/* all projectgrants active, then filtered with the project and user resourceowner */ + SELECT pg.instance_id, + pg.resource_owner, + pg.project_id, + pg.granted_org_id + FROM projections.project_grants4 as pg + WHERE pg.instance_id = $1 + AND pg.state = $7 +), project_role_check as ( +/* all usergrants active and associated with the user, then filtered with the project */ + SELECT ug.instance_id, + ug.resource_owner, + ug.project_id + FROM projections.user_grants5 as ug + WHERE ug.instance_id = $1 + AND ug.user_id = $5 + AND ug.state = $8 +) +SELECT + /* project existence does not need to be checked, or resourceowner of user and project are equal, or resourceowner of user has project granted*/ + bool_and(COALESCE( + (NOT a.has_project_check OR + a.resource_owner = uro.resource_owner OR + uro.resource_owner = hpgc.granted_org_id) + , FALSE) + ) as project_checked, + /* authentication existence does not need to checked, or authentication for project is existing*/ + bool_and(COALESCE( + (NOT a.project_role_check OR + a.project_id = prc.project_id) + , FALSE) + ) as role_checked +FROM application as a + LEFT JOIN user_resourceowner as uro + ON uro.instance_id = a.instance_id + LEFT JOIN has_project_grant_check as hpgc + ON hpgc.instance_id = a.instance_id + AND hpgc.project_id = a.project_id + AND hpgc.granted_org_id = uro.resource_owner + LEFT JOIN project_role_check as prc + ON prc.instance_id = a.instance_id + AND prc.project_id = a.project_id +GROUP BY a.instance_id, a.resource_owner, a.project_id, a.app_id, uro.resource_owner, hpgc.granted_org_id, + prc.project_id +LIMIT 1;