refactor: extract OAuth provider identities into dedicated external_identities table#25
refactor: extract OAuth provider identities into dedicated external_identities table#25
Conversation
…entities table Agent-Logs-Url: https://github.com/poly-workshop/identra/sessions/42a73a29-3769-4a5e-891a-7ab32d085f24 Co-authored-by: slhmy <31381093+slhmy@users.noreply.github.com>
Agent-Logs-Url: https://github.com/poly-workshop/identra/sessions/42a73a29-3769-4a5e-891a-7ab32d085f24 Co-authored-by: slhmy <31381093+slhmy@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Refactors OAuth identity persistence to a provider-agnostic (provider, provider_user_id) → user_id binding model by introducing a dedicated external_identities store/table/collection and updating OAuth login/bind flows to use it instead of per-provider columns on users.
Changes:
- Introduces
ExternalIdentityModel+ExternalIdentityStore, with MongoDB and GORM persistence implementations. - Removes
GithubIDandGetByGithubIDfrom the user model/store and migrates service logic to resolve OAuth users via external identities. - Updates OAuth-related service tests to validate external identity linkage.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/infrastructure/persistence/mongo_user_store.go | Removes GitHub ID index and GitHub-ID lookup from the Mongo user store. |
| internal/infrastructure/persistence/mongo_external_identity_store.go | Adds Mongo external identity store with compound unique index. |
| internal/infrastructure/persistence/gorm_user_store.go | Removes GitHub-ID lookup from the GORM user store. |
| internal/infrastructure/persistence/gorm_external_identity_store.go | Adds GORM external identity store implementation. |
| internal/domain/user.go | Removes GithubID from UserModel and GetByGithubID from UserStore. |
| internal/domain/external_identity.go | Adds the new external identity domain model and store interface. |
| internal/application/identra/user_info_provider.go | Adds Provider to UserInfo and populates it for GitHub. |
| internal/application/identra/service_ensure_oauth_user_test.go | Updates tests to assert external identity creation/linking. |
| internal/application/identra/service.go | Updates OAuth bind/login flows to use externalIdentityStore; adds compensating delete. |
| if createErr := s.externalIdentityStore.Create(ctx, identity); createErr != nil { | ||
| return nil, status.Error(codes.Internal, "failed to link oauth account") | ||
| } |
There was a problem hiding this comment.
ensureOAuthUser maps externalIdentityStore.Create errors to codes.Internal even when the cause is domain.ErrAlreadyExists (identity already linked, possibly due to races or email-merge conflicts). Handle ErrAlreadyExists explicitly so the API returns a conflict-like code (AlreadyExists/FailedPrecondition) instead of Internal.
| if createErr := s.externalIdentityStore.Create(ctx, identity); createErr != nil { | ||
| // Compensate: remove the newly created user to avoid orphaned records. | ||
| if deleteErr := s.userStore.Delete(ctx, userModel.ID); deleteErr != nil { | ||
| slog.ErrorContext(ctx, "failed to clean up orphaned user after identity create failure", | ||
| "error", deleteErr, "user_id", userModel.ID) | ||
| } | ||
| return nil, status.Error(codes.Internal, "failed to create oauth identity") | ||
| } |
There was a problem hiding this comment.
New compensating-delete behavior (deleting the newly created user when external identity creation fails) is important for correctness but currently has no test coverage. Add a test that forces externalIdentityStore.Create to fail and asserts the user is deleted (or that deletion is attempted) so regressions are caught.
| }, | ||
| } | ||
|
|
||
| if _, err := r.coll.Indexes().CreateMany(ctx, models); err != nil { |
There was a problem hiding this comment.
Now that the github_id index model has been removed, note that CreateMany will not drop an already-existing idx_github_id_unique index in MongoDB. If the goal is to actually remove that index (per the PR description), add an explicit Indexes().DropOne(...) (or document a manual migration) so existing deployments don’t keep the stale index indefinitely.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
|
@copilot apply changes based on the comments in this thread |
…e mongo index Agent-Logs-Url: https://github.com/poly-workshop/identra/sessions/08bbc906-0d17-4698-83b9-bb727335733f Co-authored-by: slhmy <31381093+slhmy@users.noreply.github.com>
Agent-Logs-Url: https://github.com/poly-workshop/identra/sessions/08bbc906-0d17-4698-83b9-bb727335733f Co-authored-by: slhmy <31381093+slhmy@users.noreply.github.com>
Applied all changes from the review thread (commits
|
| providerIdentity, err := s.externalIdentityStore.GetByProviderID(ctx, stateData.Provider, userInfo.ID) | ||
| switch { | ||
| case err == nil && providerUser.ID != bindingUser.ID: | ||
| case err == nil && providerIdentity.UserID != bindingUser.ID: | ||
| return nil, status.Error(codes.AlreadyExists, "oauth account already linked to another user") | ||
| case err == nil: |
There was a problem hiding this comment.
BindUserByOAuth uses userInfo.ID as ProviderUserID without validating it. If a provider implementation ever returns an empty ID, this can create invalid external identity records and trigger uniqueness conflicts. Add a non-empty check for userInfo.ID before calling GetByProviderID/Create (similar to ensureOAuthUser).
…ore.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
|
@copilot apply changes based on the comments in this thread |
…il rejection Agent-Logs-Url: https://github.com/poly-workshop/identra/sessions/9730e222-ed3f-49c8-b632-3686f678b549 Co-authored-by: slhmy <31381093+slhmy@users.noreply.github.com>
Agent-Logs-Url: https://github.com/poly-workshop/identra/sessions/9730e222-ed3f-49c8-b632-3686f678b549 Co-authored-by: slhmy <31381093+slhmy@users.noreply.github.com>
Applied all changes from the review thread (commits
|
| @@ -53,10 +74,6 @@ func (r *mongoUserStore) ensureIndexes(ctx context.Context) error { | |||
| // This enables OAuth users without email to be created. | |||
| Options: options.Index().SetUnique(true).SetSparse(true).SetName("idx_email_unique"), | |||
There was a problem hiding this comment.
The comment above the MongoDB email index claims sparse+unique allows multiple NULL/empty emails and enables OAuth users without email. Sparse indexes only exclude documents where the field is missing; an empty string is still indexed, and UserModel’s bson tag always stores the field, so this comment is misleading (and conflicts with the new service behavior that rejects missing emails). Update the comment (and/or the model/index strategy) to reflect the actual semantics.
| if errors.As(err, &cmdErr) && cmdErr.Code == 27 { | ||
| return true | ||
| } | ||
|
|
||
| return strings.Contains(strings.ToLower(err.Error()), "index not found") |
There was a problem hiding this comment.
isIndexNotFoundError falls back to substring-matching the error text ("index not found"). That can accidentally treat unrelated operational failures as ignorable if they happen to include that phrase, masking real startup/migration issues. Prefer relying on MongoDB error codes (e.g., CommandError code 27 / IndexNotFound) and/or explicit structured error types instead of string matching.
| if errors.As(err, &cmdErr) && cmdErr.Code == 27 { | |
| return true | |
| } | |
| return strings.Contains(strings.ToLower(err.Error()), "index not found") | |
| return errors.As(err, &cmdErr) && cmdErr.Code == 27 |
| return nil, status.Error(codes.AlreadyExists, "oauth account already linked") | ||
| } | ||
| return nil, status.Error(codes.Internal, "failed to link oauth account") |
There was a problem hiding this comment.
BindUserByOAuth has a TOCTOU race: if GetByProviderID returns not found but another concurrent bind creates the identity before Create runs, Create will return ErrAlreadyExists and this code returns codes.AlreadyExists. For an identity that is (now) linked to the same bindingUser, the bind flow should be idempotent success. Consider re-fetching the identity on ErrAlreadyExists and returning success if it is linked to bindingUser.ID, otherwise return AlreadyExists.
| return nil, status.Error(codes.AlreadyExists, "oauth account already linked") | |
| } | |
| return nil, status.Error(codes.Internal, "failed to link oauth account") | |
| providerIdentity, refetchErr := s.externalIdentityStore.GetByProviderID(ctx, stateData.Provider, userInfo.ID) | |
| if refetchErr != nil { | |
| return nil, status.Error(codes.Internal, "failed to verify oauth link") | |
| } | |
| if providerIdentity.UserID != bindingUser.ID { | |
| return nil, status.Error(codes.AlreadyExists, "oauth account already linked to another user") | |
| } | |
| // Another concurrent bind linked this OAuth account to the same user. | |
| // Treat the operation as idempotent success. | |
| } else { | |
| return nil, status.Error(codes.Internal, "failed to link oauth account") | |
| } |
| ProviderUserID: info.ID, | ||
| } | ||
| if createErr := s.externalIdentityStore.Create(ctx, identity); createErr != nil { | ||
| if errors.Is(createErr, domain.ErrAlreadyExists) { |
There was a problem hiding this comment.
ensureOAuthUser has the same TOCTOU issue when linking an existing user by email: if Create returns ErrAlreadyExists due to a concurrent request that just created the same external identity for this user, the login currently fails with codes.AlreadyExists. For OAuth login this should usually be treated as success by re-fetching the identity and proceeding when it points at the same user.
| if errors.Is(createErr, domain.ErrAlreadyExists) { | |
| if errors.Is(createErr, domain.ErrAlreadyExists) { | |
| // A concurrent request may have created the same external identity | |
| // after our initial lookup. Re-fetch and treat it as success when | |
| // it is linked to the same user we intended to link. | |
| existingIdentity, getErr := s.externalIdentityStore.GetByProviderAndProviderUserID(ctx, info.Provider, info.ID) | |
| if getErr != nil { | |
| return nil, status.Error(codes.Internal, "failed to verify oauth account link") | |
| } | |
| if existingIdentity.UserID == byEmail.ID { | |
| return byEmail, nil | |
| } |
| userModel := &domain.UserModel{Email: info.Email, GithubID: &info.ID} | ||
| // Create new user and link external identity. | ||
| userModel := &domain.UserModel{Email: info.Email} | ||
| if createErr := s.userStore.Create(ctx, userModel); createErr != nil { |
There was a problem hiding this comment.
When creating a new user in ensureOAuthUser (email not found case), userStore.Create errors are always mapped to codes.Internal. If the create fails due to a duplicate email (ErrAlreadyExists), the more accurate gRPC status is codes.AlreadyExists, consistent with other flows (e.g., RegisterByPassword). Handling ErrAlreadyExists here also helps with concurrent OAuth logins that race on user creation.
| if createErr := s.userStore.Create(ctx, userModel); createErr != nil { | |
| if createErr := s.userStore.Create(ctx, userModel); createErr != nil { | |
| if errors.Is(createErr, domain.ErrAlreadyExists) { | |
| return nil, status.Error(codes.AlreadyExists, "user already exists") | |
| } |
| if err == nil { | ||
| t.Fatal("expected error, got nil") | ||
| } | ||
|
|
There was a problem hiding this comment.
This test exercises the orphan-cleanup path when externalIdentityStore.Create fails, but it doesn’t assert the returned gRPC status code (the implementation intends to return codes.AlreadyExists for ErrAlreadyExists). Adding an assertion on status.FromError(err).Code() would prevent regressions where cleanup logic accidentally changes the observed error code.
| st, ok := status.FromError(err) | |
| if !ok { | |
| t.Fatalf("expected gRPC status error, got %v", err) | |
| } | |
| if st.Code() != codes.AlreadyExists { | |
| t.Fatalf("expected gRPC code %v, got %v", codes.AlreadyExists, st.Code()) | |
| } |
|
@copilot apply changes based on the comments in this thread |
Agent-Logs-Url: https://github.com/poly-workshop/identra/sessions/2fe3c333-609f-4187-9841-46ebe7516ff7 Co-authored-by: slhmy <31381093+slhmy@users.noreply.github.com>
Agent-Logs-Url: https://github.com/poly-workshop/identra/sessions/2fe3c333-609f-4187-9841-46ebe7516ff7 Co-authored-by: slhmy <31381093+slhmy@users.noreply.github.com>
Applied all changes from the review thread (commits
|
ExternalIdentityModelandExternalIdentityStoreGithubIDfromUserModel/UserStore; removeGetByGithubIDexternalIdentityStoreErrAlreadyExistshandling and compensating delete inensureOAuthUserErrAlreadyExistshandling inBindUserByOAuthidx_github_id_uniquewith targeted error handlingGithubIdselection inGetCurrentUserLoginInfocodes.FailedPrecondition)userInfo.IDnon-empty inBindUserByOAuth(provider, user_id)unique index addedisIndexNotFoundError: removed string-match fallback; relies only on MongoDB error code 27mongoUserStore.ensureIndexesBindUserByOAuthTOCTOU: re-fetch identity onErrAlreadyExistsand return idempotent success if same user, orAlreadyExistsif another userensureOAuthUseremail-merge TOCTOU: re-fetch identity onErrAlreadyExistsand return success if same user, orAlreadyExists"to another user" if differentErrAlreadyExistsfromuserStore.Createtocodes.AlreadyExistscodes.AlreadyExists) to compensating-delete test