fix: migrate external id of federated users (#6312)

* feat: migrate external id

* implement tests and some renaming

* fix projection

* cleanup

* i18n

* fix event type

* handle migration for new services as well

* typo
This commit is contained in:
Livio Spring 2023-08-04 11:35:36 +02:00 committed by GitHub
parent d33a4fbb2f
commit 45262e6829
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 611 additions and 9 deletions

View File

@ -120,6 +120,11 @@ func (h *Handler) handleCallback(w http.ResponseWriter, r *http.Request) {
userID, err := h.checkExternalUser(ctx, intent.IDPID, idpUser.GetID()) userID, err := h.checkExternalUser(ctx, intent.IDPID, idpUser.GetID())
logging.WithFields("intent", intent.AggregateID).OnError(err).Error("could not check if idp user already exists") logging.WithFields("intent", intent.AggregateID).OnError(err).Error("could not check if idp user already exists")
if userID == "" {
userID, err = h.tryMigrateExternalUser(ctx, intent.IDPID, idpUser, idpSession)
logging.WithFields("intent", intent.AggregateID).OnError(err).Error("migration check failed")
}
token, err := h.commands.SucceedIDPIntent(ctx, intent, idpUser, idpSession, userID) token, err := h.commands.SucceedIDPIntent(ctx, intent, idpUser, idpSession, userID)
if err != nil { if err != nil {
redirectToFailureURLErr(w, r, intent, z_errs.ThrowInternal(err, "IDP-JdD3g", "Errors.Intent.TokenCreationFailed")) redirectToFailureURLErr(w, r, intent, z_errs.ThrowInternal(err, "IDP-JdD3g", "Errors.Intent.TokenCreationFailed"))
@ -128,6 +133,22 @@ func (h *Handler) handleCallback(w http.ResponseWriter, r *http.Request) {
redirectToSuccessURL(w, r, intent, token, userID) redirectToSuccessURL(w, r, intent, token, userID)
} }
func (h *Handler) tryMigrateExternalUser(ctx context.Context, idpID string, idpUser idp.User, idpSession idp.Session) (userID string, err error) {
migration, ok := idpSession.(idp.SessionSupportsMigration)
if !ok {
return "", nil
}
previousID, err := migration.RetrievePreviousID()
if err != nil || previousID == "" {
return "", err
}
userID, err = h.checkExternalUser(ctx, idpID, previousID)
if err != nil {
return "", err
}
return userID, h.commands.MigrateUserIDP(ctx, userID, "", idpID, previousID, idpUser.GetID())
}
func (h *Handler) parseCallbackRequest(r *http.Request) (*externalIDPCallbackData, error) { func (h *Handler) parseCallbackRequest(r *http.Request) (*externalIDPCallbackData, error) {
data := new(externalIDPCallbackData) data := new(externalIDPCallbackData)
err := h.parser.Parse(r, data) err := h.parser.Parse(r, data)
@ -196,7 +217,7 @@ func (h *Handler) fetchIDPUser(ctx context.Context, identityProvider idp.Provide
case *openid.Provider: case *openid.Provider:
session = &openid.Session{Provider: provider, Code: code} session = &openid.Session{Provider: provider, Code: code}
case *azuread.Provider: case *azuread.Provider:
session = &oauth.Session{Provider: provider.Provider, Code: code} session = &azuread.Session{Session: &oauth.Session{Provider: provider.Provider, Code: code}}
case *github.Provider: case *github.Provider:
session = &oauth.Session{Provider: provider.Provider, Code: code} session = &oauth.Session{Provider: provider.Provider, Code: code}
case *gitlab.Provider: case *gitlab.Provider:

View File

@ -222,7 +222,7 @@ func (l *Login) handleExternalLoginCallback(w http.ResponseWriter, r *http.Reque
l.externalAuthFailed(w, r, authReq, nil, nil, err) l.externalAuthFailed(w, r, authReq, nil, nil, err)
return return
} }
session = &oauth.Session{Provider: provider.(*azuread.Provider).Provider, Code: data.Code} session = &azuread.Session{Session: &oauth.Session{Provider: provider.(*azuread.Provider).Provider, Code: data.Code}}
case domain.IDPTypeGitHub: case domain.IDPTypeGitHub:
provider, err = l.githubProvider(r.Context(), identityProvider) provider, err = l.githubProvider(r.Context(), identityProvider)
if err != nil { if err != nil {
@ -275,6 +275,46 @@ func (l *Login) handleExternalLoginCallback(w http.ResponseWriter, r *http.Reque
l.handleExternalUserAuthenticated(w, r, authReq, identityProvider, session, user, l.renderNextStep) l.handleExternalUserAuthenticated(w, r, authReq, identityProvider, session, user, l.renderNextStep)
} }
func (l *Login) tryMigrateExternalUserID(r *http.Request, session idp.Session, authReq *domain.AuthRequest, externalUser *domain.ExternalUser) (previousIDMatched bool, err error) {
migration, ok := session.(idp.SessionSupportsMigration)
if !ok {
return false, nil
}
previousID, err := migration.RetrievePreviousID()
if err != nil {
return false, err
}
return l.migrateExternalUserID(r, authReq, externalUser, previousID)
}
func (l *Login) migrateExternalUserID(r *http.Request, authReq *domain.AuthRequest, externalUser *domain.ExternalUser, previousID string) (previousIDMatched bool, err error) {
if previousID == "" {
return false, nil
}
// save the currentID, so we're able to reset to it later on if the user is not found with the old ID as well
externalUserID := externalUser.ExternalUserID
externalUser.ExternalUserID = previousID
if err = l.authRepo.CheckExternalUserLogin(setContext(r.Context(), ""), authReq.ID, authReq.AgentID, externalUser, domain.BrowserInfoFromRequest(r), true); err != nil {
// always reset to the mapped ID
externalUser.ExternalUserID = externalUserID
// but ignore the error if the user was just not found with the previousID
if errors.IsNotFound(err) {
return false, nil
}
return false, err
}
previousIDMatched = true
if err = l.authRepo.ResetLinkingUsers(r.Context(), authReq.ID, authReq.AgentID); err != nil {
return previousIDMatched, err
}
// read current auth request state (incl. authorized user)
authReq, err = l.authRepo.AuthRequestByID(r.Context(), authReq.ID, authReq.AgentID)
if err != nil {
return previousIDMatched, err
}
return previousIDMatched, l.command.MigrateUserIDP(setContext(r.Context(), authReq.UserOrgID), authReq.UserID, authReq.UserOrgID, externalUser.IDPConfigID, previousID, externalUserID)
}
// handleExternalUserAuthenticated maps the IDP user, checks for a corresponding externalID // handleExternalUserAuthenticated maps the IDP user, checks for a corresponding externalID
func (l *Login) handleExternalUserAuthenticated( func (l *Login) handleExternalUserAuthenticated(
w http.ResponseWriter, w http.ResponseWriter,
@ -287,11 +327,22 @@ func (l *Login) handleExternalUserAuthenticated(
) { ) {
externalUser := mapIDPUserToExternalUser(user, provider.ID) externalUser := mapIDPUserToExternalUser(user, provider.ID)
// check and fill in local linked user // check and fill in local linked user
externalErr := l.authRepo.CheckExternalUserLogin(setContext(r.Context(), ""), authReq.ID, authReq.AgentID, externalUser, domain.BrowserInfoFromRequest(r)) externalErr := l.authRepo.CheckExternalUserLogin(setContext(r.Context(), ""), authReq.ID, authReq.AgentID, externalUser, domain.BrowserInfoFromRequest(r), false)
if externalErr != nil && !errors.IsNotFound(externalErr) { if externalErr != nil && !errors.IsNotFound(externalErr) {
l.renderError(w, r, authReq, externalErr) l.renderError(w, r, authReq, externalErr)
return return
} }
if externalErr != nil && errors.IsNotFound(externalErr) {
previousIDMatched, err := l.tryMigrateExternalUserID(r, session, authReq, externalUser)
if err != nil {
l.renderError(w, r, authReq, err)
return
}
// if the old ID matched, ignore the not found error from the current ID
if previousIDMatched {
externalErr = nil
}
}
var err error var err error
// read current auth request state (incl. authorized user) // read current auth request state (incl. authorized user)
authReq, err = l.authRepo.AuthRequestByID(r.Context(), authReq.ID, authReq.AgentID) authReq, err = l.authRepo.AuthRequestByID(r.Context(), authReq.ID, authReq.AgentID)

View File

@ -15,7 +15,7 @@ type AuthRequestRepository interface {
DeleteAuthRequest(ctx context.Context, id string) error DeleteAuthRequest(ctx context.Context, id string) error
CheckLoginName(ctx context.Context, id, loginName, userAgentID string) error CheckLoginName(ctx context.Context, id, loginName, userAgentID string) error
CheckExternalUserLogin(ctx context.Context, authReqID, userAgentID string, user *domain.ExternalUser, info *domain.BrowserInfo) error CheckExternalUserLogin(ctx context.Context, authReqID, userAgentID string, user *domain.ExternalUser, info *domain.BrowserInfo, migrationCheck bool) error
SetExternalUserLogin(ctx context.Context, authReqID, userAgentID string, user *domain.ExternalUser) error SetExternalUserLogin(ctx context.Context, authReqID, userAgentID string, user *domain.ExternalUser) error
SetLinkingUser(ctx context.Context, request *domain.AuthRequest, externalUser *domain.ExternalUser) error SetLinkingUser(ctx context.Context, request *domain.AuthRequest, externalUser *domain.ExternalUser) error
SelectUser(ctx context.Context, id, userID, userAgentID string) error SelectUser(ctx context.Context, id, userID, userAgentID string) error

View File

@ -238,7 +238,7 @@ func (repo *AuthRequestRepo) SelectExternalIDP(ctx context.Context, authReqID, i
return repo.AuthRequests.UpdateAuthRequest(ctx, request) return repo.AuthRequests.UpdateAuthRequest(ctx, request)
} }
func (repo *AuthRequestRepo) CheckExternalUserLogin(ctx context.Context, authReqID, userAgentID string, externalUser *domain.ExternalUser, info *domain.BrowserInfo) (err error) { func (repo *AuthRequestRepo) CheckExternalUserLogin(ctx context.Context, authReqID, userAgentID string, externalUser *domain.ExternalUser, info *domain.BrowserInfo, migrationCheck bool) (err error) {
ctx, span := tracing.NewSpan(ctx) ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }() defer func() { span.EndWithError(err) }()
request, err := repo.getAuthRequest(ctx, authReqID, userAgentID) request, err := repo.getAuthRequest(ctx, authReqID, userAgentID)
@ -249,6 +249,11 @@ func (repo *AuthRequestRepo) CheckExternalUserLogin(ctx context.Context, authReq
if errors.IsNotFound(err) { if errors.IsNotFound(err) {
// clear potential user information (e.g. when username was entered but another external user was returned) // clear potential user information (e.g. when username was entered but another external user was returned)
request.SetUserInfo("", "", "", "", "", request.UserOrgID) request.SetUserInfo("", "", "", "", "", request.UserOrgID)
// in case the check was done with an ID, that was retrieved by a session that allows migration,
// we do not need to set the linking user and return early
if migrationCheck {
return err
}
if err := repo.setLinkingUser(ctx, request, externalUser); err != nil { if err := repo.setLinkingUser(ctx, request, externalUser); err != nil {
return err return err
} }

View File

@ -348,6 +348,95 @@ func TestCommands_AuthURLFromProvider(t *testing.T) {
authURL: "auth?client_id=clientID&prompt=select_account&redirect_uri=url&response_type=code&state=state", authURL: "auth?client_id=clientID&prompt=select_account&redirect_uri=url&response_type=code&state=state",
}, },
}, },
{
"migrated and push",
fields{
secretCrypto: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
eventstore: eventstoreExpect(t,
expectFilter(
eventFromEventPusherWithInstanceID(
"instance",
instance.NewOIDCIDPAddedEvent(context.Background(), &instance.NewAggregate("instance").Aggregate,
"idp",
"name",
"issuer",
"clientID",
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("clientSecret"),
},
[]string{"openid", "profile", "User.Read"},
false,
rep_idp.Options{},
)),
eventFromEventPusherWithInstanceID(
"instance",
instance.NewOIDCIDPMigratedAzureADEvent(context.Background(), &instance.NewAggregate("instance").Aggregate,
"idp",
"name",
"clientID",
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("clientSecret"),
},
[]string{"openid", "profile", "User.Read"},
"tenant",
true,
rep_idp.Options{},
)),
),
expectFilter(
eventFromEventPusherWithInstanceID(
"instance",
instance.NewOIDCIDPAddedEvent(context.Background(), &instance.NewAggregate("instance").Aggregate,
"idp",
"name",
"issuer",
"clientID",
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("clientSecret"),
},
[]string{"openid", "profile", "User.Read"},
false,
rep_idp.Options{},
)),
eventFromEventPusherWithInstanceID(
"instance",
instance.NewOIDCIDPMigratedAzureADEvent(context.Background(), &instance.NewAggregate("instance").Aggregate,
"idp",
"name",
"clientID",
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("clientSecret"),
},
[]string{"openid", "profile", "User.Read"},
"tenant",
true,
rep_idp.Options{},
)),
),
),
},
args{
ctx: authz.SetCtxData(context.Background(), authz.CtxData{OrgID: "ro"}),
idpID: "idp",
state: "state",
callbackURL: "url",
},
res{
authURL: "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize?client_id=clientID&prompt=select_account&redirect_uri=url&response_type=code&scope=openid+profile+User.Read&state=state",
},
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {

View File

@ -1653,6 +1653,14 @@ func (wm *IDPTypeWriteModel) Reduce() error {
wm.reduceAdded(e.ID, domain.IDPTypeLDAP, e.Aggregate()) wm.reduceAdded(e.ID, domain.IDPTypeLDAP, e.Aggregate())
case *org.LDAPIDPAddedEvent: case *org.LDAPIDPAddedEvent:
wm.reduceAdded(e.ID, domain.IDPTypeLDAP, e.Aggregate()) wm.reduceAdded(e.ID, domain.IDPTypeLDAP, e.Aggregate())
case *instance.OIDCIDPMigratedAzureADEvent:
wm.reduceChanged(e.ID, domain.IDPTypeAzureAD)
case *org.OIDCIDPMigratedAzureADEvent:
wm.reduceChanged(e.ID, domain.IDPTypeAzureAD)
case *instance.OIDCIDPMigratedGoogleEvent:
wm.reduceChanged(e.ID, domain.IDPTypeGoogle)
case *org.OIDCIDPMigratedGoogleEvent:
wm.reduceChanged(e.ID, domain.IDPTypeGoogle)
case *instance.IDPRemovedEvent: case *instance.IDPRemovedEvent:
wm.reduceRemoved(e.ID) wm.reduceRemoved(e.ID)
case *org.IDPRemovedEvent: case *org.IDPRemovedEvent:
@ -1688,6 +1696,13 @@ func (wm *IDPTypeWriteModel) reduceAdded(id string, t domain.IDPType, agg events
wm.InstanceID = agg.InstanceID wm.InstanceID = agg.InstanceID
} }
func (wm *IDPTypeWriteModel) reduceChanged(id string, t domain.IDPType) {
if wm.ID != id {
return
}
wm.Type = t
}
func (wm *IDPTypeWriteModel) reduceRemoved(id string) { func (wm *IDPTypeWriteModel) reduceRemoved(id string) {
if wm.ID != id { if wm.ID != id {
return return
@ -1713,6 +1728,8 @@ func (wm *IDPTypeWriteModel) Query() *eventstore.SearchQueryBuilder {
instance.GitLabSelfHostedIDPAddedEventType, instance.GitLabSelfHostedIDPAddedEventType,
instance.GoogleIDPAddedEventType, instance.GoogleIDPAddedEventType,
instance.LDAPIDPAddedEventType, instance.LDAPIDPAddedEventType,
instance.OIDCIDPMigratedAzureADEventType,
instance.OIDCIDPMigratedGoogleEventType,
instance.IDPRemovedEventType, instance.IDPRemovedEventType,
). ).
EventData(map[string]interface{}{"id": wm.ID}). EventData(map[string]interface{}{"id": wm.ID}).
@ -1729,6 +1746,8 @@ func (wm *IDPTypeWriteModel) Query() *eventstore.SearchQueryBuilder {
org.GitLabSelfHostedIDPAddedEventType, org.GitLabSelfHostedIDPAddedEventType,
org.GoogleIDPAddedEventType, org.GoogleIDPAddedEventType,
org.LDAPIDPAddedEventType, org.LDAPIDPAddedEventType,
org.OIDCIDPMigratedAzureADEventType,
org.OIDCIDPMigratedGoogleEventType,
org.IDPRemovedEventType, org.IDPRemovedEventType,
). ).
EventData(map[string]interface{}{"id": wm.ID}). EventData(map[string]interface{}{"id": wm.ID}).

View File

@ -139,6 +139,24 @@ func (c *Commands) UserIDPLoginChecked(ctx context.Context, orgID, userID string
return err return err
} }
func (c *Commands) MigrateUserIDP(ctx context.Context, userID, orgID, idpConfigID, previousID, newID string) (err error) {
if userID == "" {
return caos_errs.ThrowInvalidArgument(nil, "COMMAND-Sn3l1", "Errors.IDMissing")
}
writeModel, err := c.userIDPLinkWriteModelByID(ctx, userID, idpConfigID, previousID, orgID)
if err != nil {
return err
}
if writeModel.State != domain.UserIDPLinkStateActive {
return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-KJH2o", "Errors.User.ExternalIDP.NotFound")
}
userAgg := UserAggregateFromWriteModel(&writeModel.WriteModel)
_, err = c.eventstore.Push(ctx, user.NewUserIDPExternalIDMigratedEvent(ctx, userAgg, idpConfigID, previousID, newID))
return err
}
func (c *Commands) userIDPLinkWriteModelByID(ctx context.Context, userID, idpConfigID, externalUserID, resourceOwner string) (writeModel *UserIDPLinkWriteModel, err error) { func (c *Commands) userIDPLinkWriteModelByID(ctx context.Context, userID, idpConfigID, externalUserID, resourceOwner string) (writeModel *UserIDPLinkWriteModel, err error) {
ctx, span := tracing.NewSpan(ctx) ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }() defer func() { span.EndWithError(err) }()

View File

@ -35,6 +35,11 @@ func (wm *UserIDPLinkWriteModel) AppendEvents(events ...eventstore.Event) {
continue continue
} }
wm.WriteModel.AppendEvents(e) wm.WriteModel.AppendEvents(e)
case *user.UserIDPExternalIDMigratedEvent:
if e.IDPConfigID != wm.IDPConfigID || e.PreviousID != wm.ExternalUserID {
continue
}
wm.WriteModel.AppendEvents(e)
case *user.UserIDPLinkRemovedEvent: case *user.UserIDPLinkRemovedEvent:
if e.IDPConfigID != wm.IDPConfigID || e.ExternalUserID != wm.ExternalUserID { if e.IDPConfigID != wm.IDPConfigID || e.ExternalUserID != wm.ExternalUserID {
continue continue
@ -59,6 +64,8 @@ func (wm *UserIDPLinkWriteModel) Reduce() error {
wm.DisplayName = e.DisplayName wm.DisplayName = e.DisplayName
wm.ExternalUserID = e.ExternalUserID wm.ExternalUserID = e.ExternalUserID
wm.State = domain.UserIDPLinkStateActive wm.State = domain.UserIDPLinkStateActive
case *user.UserIDPExternalIDMigratedEvent:
wm.ExternalUserID = e.NewID
case *user.UserIDPLinkRemovedEvent: case *user.UserIDPLinkRemovedEvent:
wm.State = domain.UserIDPLinkStateRemoved wm.State = domain.UserIDPLinkStateRemoved
case *user.UserIDPLinkCascadeRemovedEvent: case *user.UserIDPLinkCascadeRemovedEvent:
@ -77,6 +84,7 @@ func (wm *UserIDPLinkWriteModel) Query() *eventstore.SearchQueryBuilder {
AggregateTypes(user.AggregateType). AggregateTypes(user.AggregateType).
AggregateIDs(wm.AggregateID). AggregateIDs(wm.AggregateID).
EventTypes(user.UserIDPLinkAddedType, EventTypes(user.UserIDPLinkAddedType,
user.UserIDPExternalIDMigratedType,
user.UserIDPLinkRemovedType, user.UserIDPLinkRemovedType,
user.UserIDPLinkCascadeRemovedType, user.UserIDPLinkCascadeRemovedType,
user.UserRemovedType). user.UserRemovedType).

View File

@ -669,3 +669,119 @@ func TestCommandSide_ExternalLoginCheck(t *testing.T) {
}) })
} }
} }
func TestCommandSide_MigrateUserIDP(t *testing.T) {
type fields struct {
eventstore func(t *testing.T) *eventstore.Eventstore
}
type args struct {
ctx context.Context
userID string
orgID string
idpConfigID string
previousID string
newID string
}
type res struct {
err error
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
name: "userid missing, invalid argument error",
fields: fields{
eventstore: expectEventstore(),
},
args: args{
ctx: context.Background(),
userID: "",
orgID: "org1",
idpConfigID: "idpConfig1",
previousID: "previousID",
newID: "newID",
},
res: res{
err: caos_errs.ThrowInvalidArgument(nil, "COMMAND-Sn3l1", "Errors.IDMissing"),
},
},
{
name: "idp link not active, precondition failed error",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewUserIDPLinkAddedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
"idpConfig1",
"displayName",
"externalUserID",
),
),
),
),
},
args: args{
ctx: context.Background(),
userID: "user1",
orgID: "org1",
idpConfigID: "idpConfig1",
previousID: "previousID",
newID: "newID",
},
res: res{
err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-KJH2o", "Errors.User.ExternalIDP.NotFound"),
},
},
{
name: "external login check, ok",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewUserIDPLinkAddedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
"idpConfig1",
"displayName",
"previousID",
),
),
),
expectPush(
[]*repository.Event{
eventFromEventPusher(
user.NewUserIDPExternalIDMigratedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
"idpConfig1",
"previousID",
"newID",
),
),
},
),
),
},
args: args{
ctx: context.Background(),
userID: "user1",
orgID: "org1",
idpConfigID: "idpConfig1",
previousID: "previousID",
newID: "newID",
},
res: res{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &Commands{
eventstore: tt.fields.eventstore(t),
}
err := r.MigrateUserIDP(tt.args.ctx, tt.args.userID, tt.args.orgID, tt.args.idpConfigID, tt.args.previousID, tt.args.newID)
assert.ErrorIs(t, err, tt.res.err)
})
}
}

View File

@ -15,7 +15,8 @@ import (
const ( const (
authURLTemplate string = "https://login.microsoftonline.com/%s/oauth2/v2.0/authorize" authURLTemplate string = "https://login.microsoftonline.com/%s/oauth2/v2.0/authorize"
tokenURLTemplate string = "https://login.microsoftonline.com/%s/oauth2/v2.0/token" tokenURLTemplate string = "https://login.microsoftonline.com/%s/oauth2/v2.0/token"
userinfoURL string = "https://graph.microsoft.com/v1.0/me" userURL string = "https://graph.microsoft.com/v1.0/me"
userinfoEndpoint string = "https://graph.microsoft.com/oidc/userinfo"
ScopeUserRead string = "User.Read" ScopeUserRead string = "User.Read"
) )
@ -87,7 +88,7 @@ func New(name, clientID, clientSecret, redirectURI string, scopes []string, opts
rp, err := oauth.New( rp, err := oauth.New(
config, config,
name, name,
userinfoURL, userURL,
func() idp.User { func() idp.User {
return &User{isEmailVerified: provider.emailVerified} return &User{isEmailVerified: provider.emailVerified}
}, },

View File

@ -0,0 +1,29 @@
package azuread
import (
"net/http"
httphelper "github.com/zitadel/oidc/v2/pkg/http"
"github.com/zitadel/oidc/v2/pkg/oidc"
"github.com/zitadel/zitadel/internal/idp/providers/oauth"
)
// Session extends the [oauth.Session] to extend it with the [idp.SessionSupportsMigration] functionality
type Session struct {
*oauth.Session
}
// RetrievePreviousID implements the [idp.SessionSupportsMigration] interface by returning the `sub` from the userinfo endpoint
func (s *Session) RetrievePreviousID() (string, error) {
req, err := http.NewRequest("GET", userinfoEndpoint, nil)
if err != nil {
return "", err
}
req.Header.Set("authorization", s.Tokens.TokenType+" "+s.Tokens.AccessToken)
userinfo := new(oidc.UserInfo)
if err := httphelper.HttpRequest(s.Provider.HttpClient(), req, &userinfo); err != nil {
return "", err
}
return userinfo.Subject, nil
}

View File

@ -247,12 +247,12 @@ func TestSession_FetchUser(t *testing.T) {
provider, err := New(tt.fields.name, tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes, tt.fields.options...) provider, err := New(tt.fields.name, tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes, tt.fields.options...)
require.NoError(t, err) require.NoError(t, err)
session := &oauth.Session{ session := &Session{Session: &oauth.Session{
AuthURL: tt.fields.authURL, AuthURL: tt.fields.authURL,
Code: tt.fields.code, Code: tt.fields.code,
Tokens: tt.fields.tokens, Tokens: tt.fields.tokens,
Provider: provider.Provider, Provider: provider.Provider,
} }}
user, err := session.FetchUser(context.Background()) user, err := session.FetchUser(context.Background())
if tt.want.err != nil && !tt.want.err(err) { if tt.want.err != nil && !tt.want.err(err) {
@ -294,3 +294,116 @@ func userinfo() *User {
UserPrincipalName: "username", UserPrincipalName: "username",
} }
} }
func TestSession_RetrievePreviousID(t *testing.T) {
type fields struct {
name string
clientID string
clientSecret string
redirectURI string
scopes []string
httpMock func()
tokens *oidc.Tokens[*oidc.IDTokenClaims]
}
type res struct {
id string
err bool
}
tests := []struct {
name string
fields fields
res res
}{
{
name: "invalid token",
fields: fields{
clientID: "clientID",
clientSecret: "clientSecret",
redirectURI: "redirectURI",
httpMock: func() {
gock.New("https://graph.microsoft.com").
Get("/oidc/userinfo").
Reply(401)
},
tokens: &oidc.Tokens[*oidc.IDTokenClaims]{
Token: &oauth2.Token{
AccessToken: "accessToken",
TokenType: oidc.BearerToken,
},
IDTokenClaims: oidc.NewIDTokenClaims(
"https://login.microsoftonline.com/consumers/oauth2/v2.0",
"sub",
[]string{"clientID"},
time.Now().Add(1*time.Hour),
time.Now().Add(-1*time.Second),
"nonce",
"",
nil,
"clientID",
0,
),
},
},
res: res{
err: true,
},
},
{
name: "success",
fields: fields{
clientID: "clientID",
clientSecret: "clientSecret",
redirectURI: "redirectURI",
httpMock: func() {
gock.New("https://graph.microsoft.com").
Get("/oidc/userinfo").
Reply(200).
JSON(`{"sub":"sub"}`)
},
tokens: &oidc.Tokens[*oidc.IDTokenClaims]{
Token: &oauth2.Token{
AccessToken: "accessToken",
TokenType: oidc.BearerToken,
},
IDTokenClaims: oidc.NewIDTokenClaims(
"https://login.microsoftonline.com/consumers/oauth2/v2.0",
"sub",
[]string{"clientID"},
time.Now().Add(1*time.Hour),
time.Now().Add(-1*time.Second),
"nonce",
"",
nil,
"clientID",
0,
),
},
},
res: res{
id: "sub",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer gock.Off()
tt.fields.httpMock()
a := assert.New(t)
provider, err := New(tt.fields.name, tt.fields.clientID, tt.fields.clientSecret, tt.fields.redirectURI, tt.fields.scopes)
require.NoError(t, err)
session := &Session{Session: &oauth.Session{
Tokens: tt.fields.tokens,
Provider: provider.Provider,
}}
id, err := session.RetrievePreviousID()
if tt.res.err {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
a.Equal(tt.res.id, id)
})
}
}

View File

@ -9,3 +9,12 @@ type Session interface {
GetAuthURL() string GetAuthURL() string
FetchUser(ctx context.Context) (User, error) FetchUser(ctx context.Context) (User, error)
} }
// SessionSupportsMigration is an optional extension to the Session interface.
// It can be implemented to support migrating users, were the initial external id has changed because of a migration of the Provider type.
// E.g. when a user was linked on a generic OIDC provider and this provider has now been migrated to an AzureAD provider.
// In this case OIDC used the `sub` claim and Azure now uses the id of the user endpoint, which differ.
// The RetrievePreviousID will return the `sub` claim again, so that the user can be matched and safely migrated to the new id.
type SessionSupportsMigration interface {
RetrievePreviousID() (previousID string, err error)
}

View File

@ -77,6 +77,10 @@ func (p *idpUserLinkProjection) reducers() []handler.AggregateReducer {
Event: user.UserRemovedType, Event: user.UserRemovedType,
Reduce: p.reduceUserRemoved, Reduce: p.reduceUserRemoved,
}, },
{
Event: user.UserIDPExternalIDMigratedType,
Reduce: p.reduceExternalIDMigrated,
},
}, },
}, },
{ {
@ -195,6 +199,27 @@ func (p *idpUserLinkProjection) reduceUserRemoved(event eventstore.Event) (*hand
), nil ), nil
} }
func (p *idpUserLinkProjection) reduceExternalIDMigrated(event eventstore.Event) (*handler.Statement, error) {
e, err := assertEvent[*user.UserIDPExternalIDMigratedEvent](event)
if err != nil {
return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-AS3th", "reduce.wrong.event.type %s", user.UserIDPExternalIDMigratedType)
}
return crdb.NewUpdateStatement(e,
[]handler.Column{
handler.NewCol(IDPUserLinkChangeDateCol, e.CreationDate()),
handler.NewCol(IDPUserLinkSequenceCol, e.Sequence()),
handler.NewCol(IDPUserLinkExternalUserIDCol, e.NewID),
},
[]handler.Condition{
handler.NewCond(IDPUserLinkIDPIDCol, e.IDPConfigID),
handler.NewCond(IDPUserLinkUserIDCol, e.Aggregate().ID),
handler.NewCond(IDPUserLinkExternalUserIDCol, e.PreviousID),
handler.NewCond(IDPUserLinkInstanceIDCol, e.Aggregate().InstanceID),
},
), nil
}
func (p *idpUserLinkProjection) reduceIDPConfigRemoved(event eventstore.Event) (*handler.Statement, error) { func (p *idpUserLinkProjection) reduceIDPConfigRemoved(event eventstore.Event) (*handler.Statement, error) {
var idpID string var idpID string

View File

@ -207,6 +207,42 @@ func TestIDPUserLinkProjection_reduces(t *testing.T) {
}, },
}, },
}, },
{
name: "reduceExternalIDMigrated",
args: args{
event: getEvent(testEvent(
repository.EventType(user.UserIDPExternalIDMigratedType),
user.AggregateType,
[]byte(`{
"idpConfigId": "idp-config-id",
"previousId": "previous-id",
"newId": "new-id"
}`),
), eventstore.GenericEventMapper[user.UserIDPExternalIDMigratedEvent]),
},
reduce: (&idpUserLinkProjection{}).reduceExternalIDMigrated,
want: wantReduce{
aggregateType: user.AggregateType,
sequence: 15,
previousSequence: 10,
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "UPDATE projections.idp_user_links3 SET (change_date, sequence, external_user_id) = ($1, $2, $3) WHERE (idp_id = $4) AND (user_id = $5) AND (external_user_id = $6) AND (instance_id = $7)",
expectedArgs: []interface{}{
anyArg{},
uint64(15),
"new-id",
"idp-config-id",
"agg-id",
"previous-id",
"instance-id",
},
},
},
},
},
},
{ {
name: "org IDPConfigRemovedEvent", name: "org IDPConfigRemovedEvent",
args: args{ args: args{

View File

@ -67,6 +67,7 @@ func RegisterEventMappers(es *eventstore.Eventstore) {
RegisterFilterEventMapper(AggregateType, UserIDPLinkRemovedType, UserIDPLinkRemovedEventMapper). RegisterFilterEventMapper(AggregateType, UserIDPLinkRemovedType, UserIDPLinkRemovedEventMapper).
RegisterFilterEventMapper(AggregateType, UserIDPLinkCascadeRemovedType, UserIDPLinkCascadeRemovedEventMapper). RegisterFilterEventMapper(AggregateType, UserIDPLinkCascadeRemovedType, UserIDPLinkCascadeRemovedEventMapper).
RegisterFilterEventMapper(AggregateType, UserIDPLoginCheckSucceededType, UserIDPCheckSucceededEventMapper). RegisterFilterEventMapper(AggregateType, UserIDPLoginCheckSucceededType, UserIDPCheckSucceededEventMapper).
RegisterFilterEventMapper(AggregateType, UserIDPExternalIDMigratedType, eventstore.GenericEventMapper[UserIDPExternalIDMigratedEvent]).
RegisterFilterEventMapper(AggregateType, HumanEmailChangedType, HumanEmailChangedEventMapper). RegisterFilterEventMapper(AggregateType, HumanEmailChangedType, HumanEmailChangedEventMapper).
RegisterFilterEventMapper(AggregateType, HumanEmailVerifiedType, HumanEmailVerifiedEventMapper). RegisterFilterEventMapper(AggregateType, HumanEmailVerifiedType, HumanEmailVerifiedEventMapper).
RegisterFilterEventMapper(AggregateType, HumanEmailVerificationFailedType, HumanEmailVerificationFailedEventMapper). RegisterFilterEventMapper(AggregateType, HumanEmailVerificationFailedType, HumanEmailVerificationFailedEventMapper).

View File

@ -18,6 +18,7 @@ const (
UserIDPLinkAddedType = UserIDPLinkEventPrefix + "added" UserIDPLinkAddedType = UserIDPLinkEventPrefix + "added"
UserIDPLinkRemovedType = UserIDPLinkEventPrefix + "removed" UserIDPLinkRemovedType = UserIDPLinkEventPrefix + "removed"
UserIDPLinkCascadeRemovedType = UserIDPLinkEventPrefix + "cascade.removed" UserIDPLinkCascadeRemovedType = UserIDPLinkEventPrefix + "cascade.removed"
UserIDPExternalIDMigratedType = UserIDPLinkEventPrefix + "id.migrated"
UserIDPLoginCheckSucceededType = idpLoginEventPrefix + "check.succeeded" UserIDPLoginCheckSucceededType = idpLoginEventPrefix + "check.succeeded"
) )
@ -212,3 +213,41 @@ func UserIDPCheckSucceededEventMapper(event *repository.Event) (eventstore.Event
return e, nil return e, nil
} }
type UserIDPExternalIDMigratedEvent struct {
eventstore.BaseEvent `json:"-"`
IDPConfigID string `json:"idpConfigId"`
PreviousID string `json:"previousId"`
NewID string `json:"newId"`
}
func (e *UserIDPExternalIDMigratedEvent) Data() interface{} {
return e
}
func (e *UserIDPExternalIDMigratedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
return nil
}
func (e *UserIDPExternalIDMigratedEvent) SetBaseEvent(event *eventstore.BaseEvent) {
e.BaseEvent = *event
}
func NewUserIDPExternalIDMigratedEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
idpConfigID,
previousID,
newID string,
) *UserIDPExternalIDMigratedEvent {
return &UserIDPExternalIDMigratedEvent{
BaseEvent: *eventstore.NewBaseEventForPush(
ctx,
aggregate,
UserIDPExternalIDMigratedType,
),
IDPConfigID: idpConfigID,
PreviousID: previousID,
NewID: newID,
}
}

View File

@ -607,6 +607,8 @@ EventTypes:
removed: Външният IDP е премахнат removed: Външният IDP е премахнат
cascade: cascade:
removed: Външната IDP каскада е премахната removed: Външната IDP каскада е премахната
id:
migrated: Външният потребителски идентификатор на IDP беше мигриран
phone: phone:
changed: Телефонният номер е променен changed: Телефонният номер е променен
verified: Телефонният номер е потвърден verified: Телефонният номер е потвърден

View File

@ -595,6 +595,8 @@ EventTypes:
removed: Externer IDP wurde gelöscht removed: Externer IDP wurde gelöscht
cascade: cascade:
removed: Externer IDP wurde kaskadiert gelöscht removed: Externer IDP wurde kaskadiert gelöscht
id:
migrated: Externe UserID des IDP wurde migriert
phone: phone:
changed: Telefonnummer geändert changed: Telefonnummer geändert
verified: Telefonnummer verifiziert verified: Telefonnummer verifiziert

View File

@ -595,6 +595,8 @@ EventTypes:
removed: External IDP removed removed: External IDP removed
cascade: cascade:
removed: External IDP cascade removed removed: External IDP cascade removed
id:
migrated: External UserID of IDP was migrated
phone: phone:
changed: Phone number changed changed: Phone number changed
verified: Phone number verified verified: Phone number verified

View File

@ -595,6 +595,8 @@ EventTypes:
removed: IDP externo eliminado removed: IDP externo eliminado
cascade: cascade:
removed: IDP externo eliminado en cascada removed: IDP externo eliminado en cascada
id:
migrated: Se migró el ID de usuario externo del IDP
phone: phone:
changed: Número de teléfono modificado changed: Número de teléfono modificado
verified: Número de teléfono verificado verified: Número de teléfono verificado

View File

@ -593,6 +593,8 @@ EventTypes:
removed: Externer IDP supprimé removed: Externer IDP supprimé
cascade: cascade:
removed: Externer IDP cascade supprimé removed: Externer IDP cascade supprimé
îd:
migrated: L'ID utilisateur externe de l'IDP a été migré
phone: phone:
changed: Le numéro de téléphone a changé changed: Le numéro de téléphone a changé
verified: Numéro de téléphone vérifié verified: Numéro de téléphone vérifié

View File

@ -593,6 +593,8 @@ EventTypes:
removed: IDP esterno rimosso removed: IDP esterno rimosso
cascade: cascade:
removed: Cascata IDP rimossa removed: Cascata IDP rimossa
id:
migrated: L'ID utente esterno dell'IDP è stato migrato
phone: phone:
changed: Numero di telefono cambiato changed: Numero di telefono cambiato
verified: Numero di telefono verificato verified: Numero di telefono verificato

View File

@ -580,6 +580,8 @@ EventTypes:
removed: 外部IDPの削除 removed: 外部IDPの削除
cascade: cascade:
removed: 外部IDPカスケードの削除 removed: 外部IDPカスケードの削除
id:
migrated: IDP の外部ユーザー ID が移行されました
phone: phone:
changed: 電話番号の変更 changed: 電話番号の変更
verified: 電話番号の検証 verified: 電話番号の検証

View File

@ -591,6 +591,8 @@ EventTypes:
removed: Отстранет надворешен IDP removed: Отстранет надворешен IDP
cascade: cascade:
removed: Отстранета каскадата на надворешни IDP removed: Отстранета каскадата на надворешни IDP
id:
migrated: Надворешниот кориснички ID на IDP е мигриран
phone: phone:
changed: Променет број на телефон changed: Променет број на телефон
verified: Верифициран број на телефон verified: Верифициран број на телефон

View File

@ -595,6 +595,8 @@ EventTypes:
removed: Usunięto zewnętrzne IDP removed: Usunięto zewnętrzne IDP
cascade: cascade:
removed: Usunięto kaskadę zewnętrznego IDP removed: Usunięto kaskadę zewnętrznego IDP
id:
migrated: Identyfikator użytkownika zewnętrznego dostawcy tożsamości został przeniesiony
phone: phone:
changed: Numer telefonu zmieniony changed: Numer telefonu zmieniony
verified: Numer telefonu zweryfikowany verified: Numer telefonu zweryfikowany

View File

@ -586,6 +586,8 @@ EventTypes:
removed: IDP externo removido removed: IDP externo removido
cascade: cascade:
removed: Cascade de IDP externo removido removed: Cascade de IDP externo removido
id:
migrated: O ID de usuário externo do IDP foi migrado
phone: phone:
changed: Número de telefone alterado changed: Número de telefone alterado
verified: Número de telefone verificado verified: Número de telefone verificado

View File

@ -589,6 +589,8 @@ EventTypes:
removed: 移除了外部 IDP removed: 移除了外部 IDP
cascade: cascade:
removed: 移除了外部 IDP removed: 移除了外部 IDP
id:
migrated: IDP 的外部用户 ID 已迁移
phone: phone:
changed: 修改手机号码 changed: 修改手机号码
verified: 已验证手机号码 verified: 已验证手机号码