Summary
Google and GitHub OAuth sign-in share a single identity field (google_sub), with no provider discriminator. Once a user authenticates with one provider, signing in with the other on the same email permanently fails with a misleading error and no self-service recovery.
Background
AuthService::oauth_login_or_register is the shared resolution function for both Google and GitHub sign-in. It takes a parameter literally named google_sub and persists it to the user document's google_sub field. GitHub's callback passes its own numeric GitHub user ID into this same parameter (api/src/api/handlers/auth_handler.rs, github callback, ~line 795: service.oauth_login_or_register(github_id.to_string(), email)).
Evidence
api/src/services/auth_service.rs:417-421 — oauth_login_or_register(&self, google_sub: String, email: String, ...), called identically from both the Google and GitHub callback paths.
api/src/model/user.rs:22 — single pub google_sub: Option<String> field, no provider tag anywhere in the schema.
api/src/repositories/user_repository.rs:68-76 — find_by_google_sub queries doc! { "google_sub": google_sub } with no way to know which provider actually wrote that value. This field also has no index — every OAuth login does a full collection scan on users.
api/src/services/auth_service.rs:484-507 — resolve_oauth_account: if a user is found by email but their stored google_sub doesn't match the value just presented, it hard-rejects with Err(Unauthorized("This Google account is not linked to the existing user")), regardless of which provider actually wrote the stored value.
- No linking endpoint exists for Google (
grep -rn "link_google" api/src returns nothing), unlike GitHub, which has an unlink route and auto-links post-hoc without ever touching google_sub.
Deterministic repro, traced end-to-end against current api/src:
- User registers via "Sign in with GitHub" (
user@example.com) → new account created, google_sub is set to the GitHub numeric ID (User::new_oauth).
- Same user later clicks "Sign in with Google" using the same email.
find_by_google_sub(<real_google_sub>) → None (doesn't match the stored GitHub ID).
- Falls back to
find_by_email → finds the existing account.
user.google_sub is Some("<github_id>"), which doesn't equal the real Google sub → hits the reject branch: "This Google account is not linked to the existing user."
- There is no in-app recovery — only DB intervention. The failure is symmetric for a Google-first user later trying GitHub.
User impact: any user who tries both supported OAuth providers on the same email — a normal thing to attempt on an app that advertises both — is permanently and confusingly locked out of the second provider.
Security note: because the field is shared with no re-verification against the presented email, the pattern is also a weak point for future provider additions — a bare field match with no domain separation between providers.
Proposed Solution
Give each provider its own identity anchor — either separate google_sub: Option<String> / github_sub: Option<String> fields (simplest, matches the existing one-field-per-provider shape already used for github_account), or a Vec<OAuthIdentity { provider, sub }> if more providers are planned. Update oauth_login_or_register (or split it per provider) and the corresponding lookup functions. Add an explicit "link this provider to my current account" flow mirroring the auto-link GitHub already does, so a user who already has a password or a Google account can deliberately attach GitHub, and vice versa, instead of hard-erroring. Add an index on whichever field(s) replace the current unindexed lookup.
Alternatives considered: leaving the single field but adding a provider sibling field was considered, but a per-provider field (or typed list) avoids ambiguity when a migration touches historical rows.
Technical Scope
api/src/services/auth_service.rs — oauth_login_or_register, resolve_oauth_account
api/src/model/user.rs — user schema (identity fields)
api/src/repositories/user_repository.rs — find_by_google_sub and any new lookup functions, plus index creation
api/src/api/handlers/auth_handler.rs — Google and GitHub callback handlers
- Data migration for existing
google_sub rows
- New "link provider" endpoint (optional but recommended)
- Unit/integration tests for both OAuth flows
Acceptance Criteria
Edge Cases
- A genuinely already-linked different account on the same provider must still correctly reject (don't weaken this check while fixing the cross-provider case)
- User has no password and only one OAuth provider linked, then tries the second provider for the first time
- Migration must handle rows where
google_sub currently holds a GitHub ID due to this bug
Risks
- Migration touches every existing OAuth user row — needs a dry-run/backup before applying in production
- Auto-linking a second provider by email match has account-takeover implications if email verification isn't enforced consistently across both providers — mirror whatever verification precedent GitHub's existing auto-link already sets
Deliverables
- Code changes to identity storage/lookup and both OAuth callback paths
- Migration script for existing
google_sub data
- Unit tests covering: GitHub-first → Google link succeeds; Google-first → GitHub link succeeds; genuine already-linked-to-a-different-account still rejects; migration correctness
Priority
High — breaks an advertised core feature (multi-provider login) for every user who naturally tries both, with no recovery path short of support intervention.
GrantFox Evaluation
Impact Score: 68/100
Difficulty Score: 45/100
Priority Score: 61
Confidence: 90%
Category: Bug / Architecture
Estimated Reward Tier: B
AI Rationale: A concrete, evidence-traced, deterministically-reproducible identity bug affecting a core advertised feature (dual OAuth login) with no user-facing recovery path today. Difficulty is moderate — touches auth service, repository, and handler layers plus a data migration, but the fix pattern mirrors an existing precedent (GitHub's own auto-link) already in the codebase.
Estimated Completion: 96 hours
Telegram: https://t.me/txioCommunity
Summary
Google and GitHub OAuth sign-in share a single identity field (
google_sub), with no provider discriminator. Once a user authenticates with one provider, signing in with the other on the same email permanently fails with a misleading error and no self-service recovery.Background
AuthService::oauth_login_or_registeris the shared resolution function for both Google and GitHub sign-in. It takes a parameter literally namedgoogle_suband persists it to the user document'sgoogle_subfield. GitHub's callback passes its own numeric GitHub user ID into this same parameter (api/src/api/handlers/auth_handler.rs, github callback, ~line 795:service.oauth_login_or_register(github_id.to_string(), email)).Evidence
api/src/services/auth_service.rs:417-421—oauth_login_or_register(&self, google_sub: String, email: String, ...), called identically from both the Google and GitHub callback paths.api/src/model/user.rs:22— singlepub google_sub: Option<String>field, noprovidertag anywhere in the schema.api/src/repositories/user_repository.rs:68-76—find_by_google_subqueriesdoc! { "google_sub": google_sub }with no way to know which provider actually wrote that value. This field also has no index — every OAuth login does a full collection scan onusers.api/src/services/auth_service.rs:484-507—resolve_oauth_account: if a user is found by email but their storedgoogle_subdoesn't match the value just presented, it hard-rejects withErr(Unauthorized("This Google account is not linked to the existing user")), regardless of which provider actually wrote the stored value.grep -rn "link_google" api/srcreturns nothing), unlike GitHub, which has an unlink route and auto-links post-hoc without ever touchinggoogle_sub.Deterministic repro, traced end-to-end against current
api/src:user@example.com) → new account created,google_subis set to the GitHub numeric ID (User::new_oauth).find_by_google_sub(<real_google_sub>)→None(doesn't match the stored GitHub ID).find_by_email→ finds the existing account.user.google_subisSome("<github_id>"), which doesn't equal the real Google sub → hits the reject branch: "This Google account is not linked to the existing user."User impact: any user who tries both supported OAuth providers on the same email — a normal thing to attempt on an app that advertises both — is permanently and confusingly locked out of the second provider.
Security note: because the field is shared with no re-verification against the presented email, the pattern is also a weak point for future provider additions — a bare field match with no domain separation between providers.
Proposed Solution
Give each provider its own identity anchor — either separate
google_sub: Option<String>/github_sub: Option<String>fields (simplest, matches the existing one-field-per-provider shape already used forgithub_account), or aVec<OAuthIdentity { provider, sub }>if more providers are planned. Updateoauth_login_or_register(or split it per provider) and the corresponding lookup functions. Add an explicit "link this provider to my current account" flow mirroring the auto-link GitHub already does, so a user who already has a password or a Google account can deliberately attach GitHub, and vice versa, instead of hard-erroring. Add an index on whichever field(s) replace the current unindexed lookup.Alternatives considered: leaving the single field but adding a
providersibling field was considered, but a per-provider field (or typed list) avoids ambiguity when a migration touches historical rows.Technical Scope
api/src/services/auth_service.rs—oauth_login_or_register,resolve_oauth_accountapi/src/model/user.rs— user schema (identity fields)api/src/repositories/user_repository.rs—find_by_google_suband any new lookup functions, plus index creationapi/src/api/handlers/auth_handler.rs— Google and GitHub callback handlersgoogle_subrowsAcceptance Criteria
google_subdata migrates without breaking their existing loginEdge Cases
google_subcurrently holds a GitHub ID due to this bugRisks
Deliverables
google_subdataPriority
High — breaks an advertised core feature (multi-provider login) for every user who naturally tries both, with no recovery path short of support intervention.
GrantFox Evaluation
Impact Score: 68/100
Difficulty Score: 45/100
Priority Score: 61
Confidence: 90%
Category: Bug / Architecture
Estimated Reward Tier: B
AI Rationale: A concrete, evidence-traced, deterministically-reproducible identity bug affecting a core advertised feature (dual OAuth login) with no user-facing recovery path today. Difficulty is moderate — touches auth service, repository, and handler layers plus a data migration, but the fix pattern mirrors an existing precedent (GitHub's own auto-link) already in the codebase.
Estimated Completion: 96 hours
Telegram: https://t.me/txioCommunity