From 5a7776006a791767f2923e04fd6bfabaff889361 Mon Sep 17 00:00:00 2001 From: Gero Posmyk-Leinemann Date: Wed, 11 Sep 2024 15:04:38 +0000 Subject: [PATCH] [idp] Guarantee stable email address for IDP token for organization-bound users --- .../gitpod-protocol/go/gitpod-service.go | 9 ++- components/gitpod-protocol/go/user.go | 42 +++++++++++ components/gitpod-protocol/go/user_test.go | 75 +++++++++++++++++++ components/gitpod-protocol/src/protocol.ts | 2 +- .../pkg/apiv1/identityprovider.go | 24 +++--- .../pkg/apiv1/identityprovider_test.go | 6 +- 6 files changed, 140 insertions(+), 18 deletions(-) create mode 100644 components/gitpod-protocol/go/user.go create mode 100644 components/gitpod-protocol/go/user_test.go diff --git a/components/gitpod-protocol/go/gitpod-service.go b/components/gitpod-protocol/go/gitpod-service.go index 9663f40b601fa5..9f1711422fac3f 100644 --- a/components/gitpod-protocol/go/gitpod-service.go +++ b/components/gitpod-protocol/go/gitpod-service.go @@ -2052,10 +2052,11 @@ type Identity struct { AuthProviderID string `json:"authProviderId,omitempty"` // This is a flag that triggers the HARD DELETION of this entity - Deleted bool `json:"deleted,omitempty"` - PrimaryEmail string `json:"primaryEmail,omitempty"` - Readonly bool `json:"readonly,omitempty"` - Tokens []*Token `json:"tokens,omitempty"` + Deleted bool `json:"deleted,omitempty"` + PrimaryEmail string `json:"primaryEmail,omitempty"` + Readonly bool `json:"readonly,omitempty"` + Tokens []*Token `json:"tokens,omitempty"` + LastSigninTime string `json:"lastSigninTime,omitempty"` } // User is the User message type diff --git a/components/gitpod-protocol/go/user.go b/components/gitpod-protocol/go/user.go new file mode 100644 index 00000000000000..0bc5f029d62b5c --- /dev/null +++ b/components/gitpod-protocol/go/user.go @@ -0,0 +1,42 @@ +// Copyright (c) 2024 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package protocol + +import ( + "cmp" + "slices" +) + +// GetSSOEmail returns the email of the user's last-used SSO identity, if any. It mirros the funcationality we have implemeted in TS here: https://github.com/gitpod-io/gitpod/blob/e4ccbf0b4d224714ffd16719a3b5c50630d6edbc/components/public-api/typescript-common/src/user-utils.ts#L24-L35 +func (u *User) GetSSOEmail() string { + var ssoIdentities []*Identity + for _, id := range u.Identities { + // LastSigninTime is empty for non-SSO identities, and used as a filter here. + if id == nil || id.Deleted || id.LastSigninTime == "" { + continue + } + ssoIdentities = append(ssoIdentities, id) + } + if len(ssoIdentities) == 0 { + return "" + } + + // We are looking for the latest-used SSO identity. + slices.SortFunc(ssoIdentities, func(i, j *Identity) int { + return cmp.Compare(j.LastSigninTime, i.LastSigninTime) + }) + return ssoIdentities[0].PrimaryEmail +} + +// GetRandomEmail returns an email address of any of the user's identities. +func (u *User) GetRandomEmail() string { + for _, id := range u.Identities { + if id == nil || id.Deleted || id.PrimaryEmail == "" { + continue + } + return id.PrimaryEmail + } + return "" +} diff --git a/components/gitpod-protocol/go/user_test.go b/components/gitpod-protocol/go/user_test.go new file mode 100644 index 00000000000000..845139232e9d99 --- /dev/null +++ b/components/gitpod-protocol/go/user_test.go @@ -0,0 +1,75 @@ +// Copyright (c) 2024 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package protocol + +import "testing" + +func TestGetSSOEmail(t *testing.T) { + u := &User{ + Identities: []*Identity{ + { + PrimaryEmail: "john@example.com", + LastSigninTime: "2022-01-01T00:00:00Z", + }, + { + PrimaryEmail: "bob@example.com", + LastSigninTime: "2022-03-01T00:00:00Z", + }, + { + PrimaryEmail: "jane@example.com", + LastSigninTime: "2022-02-01T00:00:00Z", + }, + { + PrimaryEmail: "jane22@example.com", + LastSigninTime: "", + }, + }, + } + + expectedEmail := "bob@example.com" + actualEmail := u.GetSSOEmail() + + if actualEmail != expectedEmail { + t.Errorf("Expected SSO email to be %s, but got %s", expectedEmail, actualEmail) + } +} +func TestGetRandomEmail(t *testing.T) { + u := &User{ + Identities: []*Identity{ + { + PrimaryEmail: "", + LastSigninTime: "", + }, + { + PrimaryEmail: "oldjohn@example.com", + LastSigninTime: "", + Deleted: true, + }, + { + PrimaryEmail: "john@example.com", + LastSigninTime: "", + }, + { + PrimaryEmail: "bob@example.com", + LastSigninTime: "2022-03-01T00:00:00Z", + }, + { + PrimaryEmail: "jane@example.com", + LastSigninTime: "2022-02-01T00:00:00Z", + }, + { + PrimaryEmail: "jane22@example.com", + LastSigninTime: "", + }, + }, + } + + expectedEmail := "john@example.com" + actualEmail := u.GetRandomEmail() + + if actualEmail != expectedEmail { + t.Errorf("Expected random email to be %s, but got %s", expectedEmail, actualEmail) + } +} diff --git a/components/gitpod-protocol/src/protocol.ts b/components/gitpod-protocol/src/protocol.ts index d9b22d9e8522f0..497d387796b776 100644 --- a/components/gitpod-protocol/src/protocol.ts +++ b/components/gitpod-protocol/src/protocol.ts @@ -564,7 +564,7 @@ export interface Identity { primaryEmail?: string; /** This is a flag that triggers the HARD DELETION of this entity */ deleted?: boolean; - // The last time this entry was touched during a signin. + // The last time this entry was touched during a signin. It's only set for SSO identities. lastSigninTime?: string; // @deprecated as no longer in use since '19 diff --git a/components/public-api-server/pkg/apiv1/identityprovider.go b/components/public-api-server/pkg/apiv1/identityprovider.go index 6b43ef5f1e0ceb..b5d6b40bc7bf1b 100644 --- a/components/public-api-server/pkg/apiv1/identityprovider.go +++ b/components/public-api-server/pkg/apiv1/identityprovider.go @@ -75,15 +75,6 @@ func (srv *IdentityProviderService) GetIDToken(ctx context.Context, req *connect return nil, proxy.ConvertError(err) } - var email string - for _, id := range user.Identities { - if id == nil || id.Deleted || id.PrimaryEmail == "" { - continue - } - email = id.PrimaryEmail - break - } - if workspace.Workspace == nil { log.Extract(ctx).WithError(err).Error("Server did not return a workspace.") return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("workspace not found")) @@ -103,8 +94,21 @@ func (srv *IdentityProviderService) GetIDToken(ctx context.Context, req *connect if workspace.Workspace.Context != nil && workspace.Workspace.Context.Repository != nil && workspace.Workspace.Context.Repository.CloneURL != "" { userInfo.AppendClaims("repository", workspace.Workspace.Context.Repository.CloneURL) } + + var email string + var emailVerified bool + if user.OrganizationId != "" { + emailVerified = true + email = user.GetSSOEmail() + if email == "" { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("SSO email is empty")) + } + } else { + emailVerified = false + email = user.GetRandomEmail() + } if email != "" { - userInfo.SetEmail(email, user.OrganizationId != "") + userInfo.SetEmail(email, emailVerified) userInfo.AppendClaims("email", email) } diff --git a/components/public-api-server/pkg/apiv1/identityprovider_test.go b/components/public-api-server/pkg/apiv1/identityprovider_test.go index 513f9a8dd8f0e4..a98c44902ec298 100644 --- a/components/public-api-server/pkg/apiv1/identityprovider_test.go +++ b/components/public-api-server/pkg/apiv1/identityprovider_test.go @@ -76,7 +76,7 @@ func TestGetIDToken(t *testing.T) { Identities: []*protocol.Identity{ nil, {Deleted: true, PrimaryEmail: "nonsense@gitpod.io"}, - {Deleted: false, PrimaryEmail: "correct@gitpod.io"}, + {Deleted: false, PrimaryEmail: "correct@gitpod.io", LastSigninTime: "2021-01-01T00:00:00Z"}, }, OrganizationId: "test", }, @@ -125,7 +125,7 @@ func TestGetIDToken(t *testing.T) { Identities: []*protocol.Identity{ nil, {Deleted: true, PrimaryEmail: "nonsense@gitpod.io"}, - {Deleted: false, PrimaryEmail: "correct@gitpod.io"}, + {Deleted: false, PrimaryEmail: "correct@gitpod.io", LastSigninTime: "2021-01-01T00:00:00Z"}, }, }, nil, @@ -243,7 +243,7 @@ func TestGetIDToken(t *testing.T) { Identities: []*protocol.Identity{ nil, {Deleted: true, PrimaryEmail: "nonsense@gitpod.io"}, - {Deleted: false, PrimaryEmail: "correct@gitpod.io"}, + {Deleted: false, PrimaryEmail: "correct@gitpod.io", LastSigninTime: "2021-01-01T00:00:00Z"}, }, OrganizationId: "test", },