Skip to content

refactor: extract OAuth provider identities into dedicated external_identities table#25

Merged
slhmy merged 15 commits intomainfrom
copilot/refactor-provider-logic
Apr 16, 2026
Merged

refactor: extract OAuth provider identities into dedicated external_identities table#25
slhmy merged 15 commits intomainfrom
copilot/refactor-provider-logic

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 15, 2026

  • Add ExternalIdentityModel and ExternalIdentityStore
  • Remove GithubID from UserModel/UserStore; remove GetByGithubID
  • GORM & MongoDB external identity store implementations
  • Service refactored to use externalIdentityStore
  • ErrAlreadyExists handling and compensating delete in ensureOAuthUser
  • ErrAlreadyExists handling in BindUserByOAuth
  • Drop stale idx_github_id_unique with targeted error handling
  • Deterministic GithubId selection in GetCurrentUserLoginInfo
  • Reject no-email OAuth logins (codes.FailedPrecondition)
  • Validate userInfo.ID non-empty in BindUserByOAuth
  • MongoDB (provider, user_id) unique index added
  • isIndexNotFoundError: removed string-match fallback; relies only on MongoDB error code 27
  • Fixed misleading sparse-index comment in mongoUserStore.ensureIndexes
  • BindUserByOAuth TOCTOU: re-fetch identity on ErrAlreadyExists and return idempotent success if same user, or AlreadyExists if another user
  • ensureOAuthUser email-merge TOCTOU: re-fetch identity on ErrAlreadyExists and return success if same user, or AlreadyExists "to another user" if different
  • Map ErrAlreadyExists from userStore.Create to codes.AlreadyExists
  • Added gRPC status code assertion (codes.AlreadyExists) to compensating-delete test

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 GithubID and GetByGithubID from 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.

Comment thread internal/domain/external_identity.go Outdated
Comment thread internal/application/identra/service.go Outdated
Comment thread internal/application/identra/service.go
Comment on lines +835 to 837
if createErr := s.externalIdentityStore.Create(ctx, identity); createErr != nil {
return nil, status.Error(codes.Internal, "failed to link oauth account")
}
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +850 to +857
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")
}
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
},
}

if _, err := r.coll.Indexes().CreateMany(ctx, models); err != nil {
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
slhmy and others added 3 commits April 16, 2026 15:05
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>
@slhmy
Copy link
Copy Markdown
Contributor

slhmy commented Apr 16, 2026

@copilot apply changes based on the comments in this thread

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 16, 2026

@copilot apply changes based on the comments in this thread

Applied all changes from the review thread (commits 493a97ff2f68aa):

  1. ErrAlreadyExists handling in ensureOAuthUser — all three externalIdentityStore.Create call sites now return codes.AlreadyExists instead of codes.Internal on duplicate identity errors. The error type is captured before the compensating delete runs so cleanup outcome doesn't affect the response code (addressed reviewer clarity concern).

  2. Compensating-delete tests — added TestEnsureOAuthUser_IdentityCreateFailure_OrphanedUserDeleted_NoEmail and TestEnsureOAuthUser_IdentityCreateFailure_OrphanedUserDeleted_WithEmail, both force identity creation to fail and verify no orphaned users remain.

  3. Stale MongoDB idx_github_id_unique indexmongoUserStore.ensureIndexes now attempts to drop the old index at startup (best-effort; "not found" errors are logged at debug level and ignored so fresh deployments are unaffected).

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 5 comments.

Comment thread internal/application/identra/service.go
Comment thread internal/application/identra/service.go Outdated
Comment thread internal/infrastructure/persistence/mongo_user_store.go Outdated
Comment on lines +415 to 419
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:
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
slhmy and others added 4 commits April 16, 2026 16:02
…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>
@slhmy
Copy link
Copy Markdown
Contributor

slhmy commented Apr 16, 2026

@copilot apply changes based on the comments in this thread

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 16, 2026

@copilot apply changes based on the comments in this thread

Applied all changes from the review thread (commits dca06185c7be9b):

  1. BindUserByOAuth empty-ID validation — added a userInfo.ID == "" guard (matching the pattern in ensureOAuthUser) that returns codes.Internal before any store operations are attempted.

  2. TestEnsureOAuthUser_WithoutEmail updated — the test now asserts codes.FailedPrecondition is returned (no-email logins are rejected at the service layer) and verifies no user was created.

  3. Removed TestEnsureOAuthUser_IdentityCreateFailure_OrphanedUserDeleted_NoEmail — the no-email path now returns early before any user creation, so the compensating-delete logic is never reached for that path; the test was covering the wrong behavior. The with-email variant still provides coverage for the actual compensating-delete path.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 6 comments.

Comment on lines 72 to 75
@@ -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"),
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +49 to +53
if errors.As(err, &cmdErr) && cmdErr.Code == 27 {
return true
}

return strings.Contains(strings.ToLower(err.Error()), "index not found")
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment thread internal/application/identra/service.go Outdated
Comment on lines +434 to +436
return nil, status.Error(codes.AlreadyExists, "oauth account already linked")
}
return nil, status.Error(codes.Internal, "failed to link oauth account")
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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")
}

Copilot uses AI. Check for mistakes.
ProviderUserID: info.ID,
}
if createErr := s.externalIdentityStore.Create(ctx, identity); createErr != nil {
if errors.Is(createErr, domain.ErrAlreadyExists) {
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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
}

Copilot uses AI. Check for mistakes.
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 {
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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")
}

Copilot uses AI. Check for mistakes.
if err == nil {
t.Fatal("expected error, got nil")
}

Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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 uses AI. Check for mistakes.
@slhmy
Copy link
Copy Markdown
Contributor

slhmy commented Apr 16, 2026

@copilot apply changes based on the comments in this thread

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 16, 2026

@copilot apply changes based on the comments in this thread

Applied all changes from the review thread (commits 72ce88cebab67f):

  1. isIndexNotFoundError — removed the string-match fallback; now relies solely on MongoDB CommandError code 27.

  2. Misleading sparse-index comment — updated to accurately reflect that sparse indexes only exclude missing fields (empty strings are still indexed), so service-layer validation is what prevents blank emails.

  3. BindUserByOAuth TOCTOU — on ErrAlreadyExists from Create, re-fetches the identity: returns idempotent success if it's linked to bindingUser, or codes.AlreadyExists ("already linked to another user") otherwise.

  4. ensureOAuthUser email-merge TOCTOU — same pattern: re-fetches on ErrAlreadyExists and returns success if the existing identity points to byEmail.ID, otherwise codes.AlreadyExists ("already linked to another user").

  5. userStore.Create duplicate email — mapped ErrAlreadyExists to codes.AlreadyExists ("user already exists").

  6. Compensating-delete test — added status.FromError assertion verifying codes.AlreadyExists is returned when identity creation fails.

@slhmy slhmy merged commit c126e51 into main Apr 16, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants