-
Notifications
You must be signed in to change notification settings - Fork 0
refactor: extract OAuth provider identities into dedicated external_identities table #25
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bec8276
78ce8b7
1e5856d
504fbc8
ffb7fca
493a97f
f2f68aa
78a4bdc
f542d33
84a425d
4571686
dca0618
5c7be9b
72ce88c
ebab67f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -44,6 +44,7 @@ type Service struct { | |||||||||||||||||||||||||
| emailCodeStore cache.EmailCodeStore | ||||||||||||||||||||||||||
| oauthStateStore oauth.StateStore | ||||||||||||||||||||||||||
| userStore domain.UserStore | ||||||||||||||||||||||||||
| externalIdentityStore domain.ExternalIdentityStore | ||||||||||||||||||||||||||
| userStoreCleanup func(context.Context) error | ||||||||||||||||||||||||||
| keyManager *security.KeyManager | ||||||||||||||||||||||||||
| tokenCfg security.TokenConfig | ||||||||||||||||||||||||||
|
|
@@ -100,7 +101,7 @@ func NewService(ctx context.Context, cfg Config) (*Service, error) { | |||||||||||||||||||||||||
| stateTTL = DefaultOAuthStateExpiration | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| userStore, cleanup, storeErr := buildUserStore(ctx, cfg) | ||||||||||||||||||||||||||
| userStore, externalIdentityStore, cleanup, storeErr := buildStores(ctx, cfg) | ||||||||||||||||||||||||||
| if storeErr != nil { | ||||||||||||||||||||||||||
| return nil, storeErr | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
@@ -164,6 +165,7 @@ func NewService(ctx context.Context, cfg Config) (*Service, error) { | |||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return &Service{ | ||||||||||||||||||||||||||
| userStore: userStore, | ||||||||||||||||||||||||||
| externalIdentityStore: externalIdentityStore, | ||||||||||||||||||||||||||
| keyManager: km, | ||||||||||||||||||||||||||
| tokenCfg: tokenCfg, | ||||||||||||||||||||||||||
| oauthStateStore: oauthStore, | ||||||||||||||||||||||||||
|
|
@@ -409,34 +411,44 @@ func (s *Service) BindUserByOAuth( | |||||||||||||||||||||||||
| slog.ErrorContext(ctx, "failed to fetch user info (bind)", "error", err) | ||||||||||||||||||||||||||
| return nil, status.Error(codes.Unauthenticated, "failed to fetch user info") | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| s.maybeFillOAuthEmail(ctx, userProvider, token.AccessToken, &userInfo) | ||||||||||||||||||||||||||
| if userInfo.ID == "" { | ||||||||||||||||||||||||||
| slog.ErrorContext(ctx, "provider returned empty user id (bind)", "provider", stateData.Provider) | ||||||||||||||||||||||||||
| return nil, status.Error(codes.Internal, "provider returned empty user id") | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| providerUser, err := s.userStore.GetByGithubID(ctx, userInfo.ID) | ||||||||||||||||||||||||||
| 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: | ||||||||||||||||||||||||||
| // Already linked to this user; continue to refresh token pair and email if needed. | ||||||||||||||||||||||||||
| // Already linked to this user; continue to refresh token pair. | ||||||||||||||||||||||||||
| case errors.Is(err, domain.ErrNotFound): | ||||||||||||||||||||||||||
| // Not linked yet. | ||||||||||||||||||||||||||
| // Not linked yet; create the external identity. | ||||||||||||||||||||||||||
| identity := &domain.ExternalIdentityModel{ | ||||||||||||||||||||||||||
| UserID: bindingUser.ID, | ||||||||||||||||||||||||||
| Provider: stateData.Provider, | ||||||||||||||||||||||||||
| ProviderUserID: userInfo.ID, | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| if createErr := s.externalIdentityStore.Create(ctx, identity); createErr != nil { | ||||||||||||||||||||||||||
|
slhmy marked this conversation as resolved.
|
||||||||||||||||||||||||||
| if errors.Is(createErr, domain.ErrAlreadyExists) { | ||||||||||||||||||||||||||
| // A concurrent request may have created the same identity. Re-fetch | ||||||||||||||||||||||||||
| // and treat as success if it is linked to this user. | ||||||||||||||||||||||||||
| existing, refetchErr := s.externalIdentityStore.GetByProviderID(ctx, stateData.Provider, userInfo.ID) | ||||||||||||||||||||||||||
| if refetchErr != nil { | ||||||||||||||||||||||||||
| return nil, status.Error(codes.Internal, "failed to verify oauth link") | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| if existing.UserID != bindingUser.ID { | ||||||||||||||||||||||||||
| return nil, status.Error(codes.AlreadyExists, "oauth account already linked to another user") | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| // Idempotent: identity exists and belongs to this user. | ||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||
| return nil, status.Error(codes.Internal, "failed to link oauth account") | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| default: | ||||||||||||||||||||||||||
| return nil, status.Error(codes.Internal, "failed to check existing oauth link") | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if bindingUser.GithubID == nil || *bindingUser.GithubID == "" { | ||||||||||||||||||||||||||
| bindingUser.GithubID = &userInfo.ID | ||||||||||||||||||||||||||
| } else if *bindingUser.GithubID != userInfo.ID { | ||||||||||||||||||||||||||
| return nil, status.Error(codes.FailedPrecondition, "user already linked to another oauth account") | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if _, err := s.updateEmailIfNeeded(ctx, bindingUser, userInfo.Email); err != nil { | ||||||||||||||||||||||||||
| return nil, err | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if err := s.userStore.Update(ctx, bindingUser); err != nil { | ||||||||||||||||||||||||||
| return nil, status.Error(codes.Internal, "failed to link oauth account") | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| s.recordLogin(ctx, bindingUser) | ||||||||||||||||||||||||||
| tokenPair, err := security.NewTokenPair(bindingUser.ID, s.tokenCfg) | ||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||
|
|
@@ -789,12 +801,23 @@ func (s *Service) GetCurrentUserLoginInfo( | |||||||||||||||||||||||||
| PasswordEnabled: usr.HashedPassword != nil && strings.TrimSpace(*usr.HashedPassword) != "", | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if usr.GithubID != nil && strings.TrimSpace(*usr.GithubID) != "" { | ||||||||||||||||||||||||||
| resp.GithubId = usr.GithubID | ||||||||||||||||||||||||||
| resp.OauthConnections = append(resp.OauthConnections, &identra_v1_pb.OAuthConnection{ | ||||||||||||||||||||||||||
| Provider: "github", | ||||||||||||||||||||||||||
| ProviderUserId: *usr.GithubID, | ||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||
| identities, err := s.externalIdentityStore.GetByUserID(ctx, usr.ID) | ||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||
| slog.WarnContext(ctx, "failed to fetch external identities", "error", err, "user_id", usr.ID) | ||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||
| var githubID string | ||||||||||||||||||||||||||
| for _, identity := range identities { | ||||||||||||||||||||||||||
| resp.OauthConnections = append(resp.OauthConnections, &identra_v1_pb.OAuthConnection{ | ||||||||||||||||||||||||||
| Provider: identity.Provider, | ||||||||||||||||||||||||||
| ProviderUserId: identity.ProviderUserID, | ||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||
| if identity.Provider == "github" && (githubID == "" || identity.ProviderUserID < githubID) { | ||||||||||||||||||||||||||
| githubID = identity.ProviderUserID | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
slhmy marked this conversation as resolved.
|
||||||||||||||||||||||||||
| if githubID != "" { | ||||||||||||||||||||||||||
| resp.GithubId = &githubID | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return resp, nil | ||||||||||||||||||||||||||
|
|
@@ -804,41 +827,85 @@ func (s *Service) ensureOAuthUser(ctx context.Context, info UserInfo) (*domain.U | |||||||||||||||||||||||||
| if info.ID == "" { | ||||||||||||||||||||||||||
| return nil, status.Error(codes.Internal, "provider user id is empty") | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| if info.Provider == "" { | ||||||||||||||||||||||||||
| return nil, status.Error(codes.Internal, "provider name is empty") | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| existing, err := s.userStore.GetByGithubID(ctx, info.ID) | ||||||||||||||||||||||||||
| existing, err := s.externalIdentityStore.GetByProviderID(ctx, info.Provider, info.ID) | ||||||||||||||||||||||||||
| switch { | ||||||||||||||||||||||||||
| case err == nil: | ||||||||||||||||||||||||||
| return s.updateEmailIfNeeded(ctx, existing, info.Email) | ||||||||||||||||||||||||||
| // External identity already exists; fetch and return the linked user. | ||||||||||||||||||||||||||
| usr, userErr := s.userStore.GetByID(ctx, existing.UserID) | ||||||||||||||||||||||||||
| if userErr != nil { | ||||||||||||||||||||||||||
| return nil, status.Error(codes.Internal, "failed to fetch user linked to oauth identity") | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| return s.updateEmailIfNeeded(ctx, usr, info.Email) | ||||||||||||||||||||||||||
| case errors.Is(err, domain.ErrNotFound): | ||||||||||||||||||||||||||
| // If user is not linked by provider id, try to link by email if available. | ||||||||||||||||||||||||||
| // (Email may be missing for some OAuth users, depending on provider settings / privacy.) | ||||||||||||||||||||||||||
| // No existing external identity; try to link by email if available. | ||||||||||||||||||||||||||
| if strings.TrimSpace(info.Email) != "" { | ||||||||||||||||||||||||||
| byEmail, emailErr := s.userStore.GetByEmail(ctx, info.Email) | ||||||||||||||||||||||||||
| switch { | ||||||||||||||||||||||||||
| case emailErr == nil: | ||||||||||||||||||||||||||
| byEmail.GithubID = &info.ID | ||||||||||||||||||||||||||
| if updateErr := s.userStore.Update(ctx, byEmail); updateErr != nil { | ||||||||||||||||||||||||||
| return nil, status.Error(codes.Internal, "failed to link github account") | ||||||||||||||||||||||||||
| // Merge: link the external identity to the existing user. | ||||||||||||||||||||||||||
| identity := &domain.ExternalIdentityModel{ | ||||||||||||||||||||||||||
| UserID: byEmail.ID, | ||||||||||||||||||||||||||
| Provider: info.Provider, | ||||||||||||||||||||||||||
| ProviderUserID: info.ID, | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| if createErr := s.externalIdentityStore.Create(ctx, identity); createErr != nil { | ||||||||||||||||||||||||||
| if errors.Is(createErr, domain.ErrAlreadyExists) { | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
| 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
AI
Apr 15, 2026
There was a problem hiding this comment.
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
AI
Apr 16, 2026
There was a problem hiding this comment.
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.
| 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
AI
Apr 15, 2026
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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).