Skip to content

Codebar auth creates duplicate members when GitHub login returns a different email, causing loss of roles/admin access #2805

Description

@mroderick

Bug description

Since the sign-in flow was switched from GitHub to codebar auth (#2717), existing members who log in via the GitHub option on the codebar auth page can be silently created as a brand-new member if the email returned by the GitHub identity does not match the email stored on their existing planner record. The new member has no roles, so the user loses admin/organiser access and is sent through the new-user onboarding flow.

Impact

  • At least one confirmed duplicate member in production: member 31221 was created on 2026-08-10 during login, while the original member 3125 (with all roles) still exists.
  • A very similar case was reported last week for member 26470; the duplicate account appears to have been manually merged since, leaving a codebar AuthService (31219) attached to the original member.
  • With ~29,000 legacy GitHub auth services and only ~50 codebar auth services, most existing members have not yet logged in via codebar auth and are potentially affected.

Steps to reproduce

  1. Have an existing member whose planner email differs from the primary email of their GitHub account (e.g. member 3125 with a GitHub auth service 3000).
  2. Click Sign in in the planner navigation → redirect to /auth/codebar.
  3. On the codebar auth page, choose the Sign in with GitHub option (auth/src/app/components/login.js).
  4. The codebar auth app authenticates via GitHub (auth/src/app/routes/auth.js, POST /login/github) and redirects back to /auth/codebar/callback.
  5. The OmniAuth hash has provider: 'codebar' and uid/info.email set to the GitHub email (lib/omniauth/strategies/codebar.rb:88-91).
  6. AuthServicesController#create finds no existing AuthService and no Member with that email, so it creates a new member and a new codebar auth service.
  7. The session is set to the new member id; the user is redirected to edit_member_details and has no roles.

Expected behaviour

Logging in should always return the user to their existing member record, preserving roles, subscriptions, and history.

Actual behaviour

A new member record is created and the user loses all admin/organiser permissions.

Root cause

lib/omniauth/strategies/codebar.rb:88-91 uses the callback email as both uid and info.email:

email = payload['email'] || payload['sub']
@env['omniauth.auth'] = AuthHash.new({
  provider: name,
  uid: email,
  info: { email: email, name: payload['name'] || email },
  ...
})

app/controllers/auth_services_controller.rb:12-67 then looks up the user by (provider, uid) and falls back to Member.find_by(email:). If neither matches, it creates a new member:

current_service = AuthService.find_by(provider: omnihash[:provider], uid: omnihash[:uid])
# ...
member = Member.find_by(email:)
member ||= Member.new(email:)

Because the GitHub email differs from the stored planner email, the lookup misses the existing record and Member.new is used.

The auth app already tries to help by resolving the primary, verified GitHub email in auth/src/auth/github-provider.js:39-54, matching the behaviour of the legacy omniauth-github integration. That only works when the GitHub primary email happens to match the planner email. When the two differ, better-auth creates a new auth-app user, and the planner creates a new member.

Evidence from production dump

Member id Auth service id Provider Notes
3125 3000 github Original member; has organiser roles on chapters 1 and 9 and many workshops.
31221 31256 codebar Created on 2026-08-10 during the reported login; has no roles.

Member 26470 shows the same pattern after a likely manual merge:

Member id Auth service id Provider Notes
26470 26450 github Original member.
26470 31219 codebar Added on 2026-08-06 with a uid that differs from the member’s stored email.

Proposed solutions

Option 1: Link by GitHub account id in the id_token (recommended)

The auth app has access to the user’s GitHub account id in auth/src/auth/github-provider.js:54 (String(profile?.id ?? "")) and stores it in the account table (accountId, providerId). The planner already stores the same id in legacy AuthService rows (provider: 'github'). We can use that common id to bridge the two systems.

Auth app change

In auth/src/auth.js, extend the JWT payload to include the stable auth-app user id (sub) and the linked GitHub account id:

jwt: {
  // ...
  definePayload: async (session) => {
    const githubAccount = await db.query(
      `SELECT "accountId" FROM account WHERE "userId" = $1 AND "providerId" = $2`,
      [session.user.id, "github"]
    );
    return {
      sub: session.user.id,
      email: session.user.email,
      name: session.user.name,
      github_id: githubAccount.rows[0]?.accountId,
    };
  },
}

Planner changes

  1. Update lib/omniauth/strategies/codebar.rb to keep the full payload in extra.raw_info (it already does).
  2. In app/controllers/auth_services_controller.rb, before creating a new member, look up any legacy GitHub auth service by the github_id claim:
github_service = AuthService.find_by(provider: 'github',
                                     uid: omnihash.dig(:extra, :raw_info, 'github_id'))
member = github_service&.member || Member.find_by(email: omnihash[:info][:email])
member ||= Member.new(email: omnihash[:info][:email])

If a legacy GitHub service is found, the codebar service is added to the existing member instead of creating a duplicate.

Why this is the best option

  • It fixes the bug for the affected users without requiring the auth app to prevent new-account creation.
  • It is backwards-compatible: users whose GitHub email already matched will continue to work as before.
  • It keeps the two systems loosely coupled — the auth app does not need to know about planner’s member database.

Caveat

This only helps users who already have a legacy GitHub auth service in planner. Brand-new sign-ups who choose GitHub and have no planner history will still create a new member, which is the desired behaviour.

Option 2: Use the auth-app stable user id as the OmniAuth uid

If the auth app returns sub as a stable user id, change lib/omniauth/strategies/codebar.rb to use it for uid:

uid: payload['sub'],
info: { email: payload['email'], name: payload['name'] }

Then AuthServicesController#create would look up existing services by sub instead of by email. This prevents duplicate auth services when a member changes their codebar account email, as long as the underlying codebar account is the same.

Caveats

  • It does not prevent duplicates if the auth app itself creates a new codebar user for the GitHub login.
  • Existing codebar auth services in the planner database currently use the email as uid; a migration or fallback lookup would be needed.

Option 3: Prevent duplicate accounts in the auth app

The auth app could be configured or extended so that a GitHub login cannot create a new account when the GitHub account id or email is already known:

  • If the GitHub account id is already linked to a codebar user, sign the user in to that account (better-auth already does this on subsequent logins).
  • If the GitHub account id is not linked but the GitHub email matches an existing codebar user, link the GitHub account to that user after verification.
  • If neither matches, show an interstitial asking the user whether they already have a codebar account and offering to link via magic link, rather than silently creating a new account.

This is the most user-friendly long-term fix but is outside this repository and requires product decisions in the auth app.

Option 4: Planner-side duplicate guard

If the auth app cannot expose the GitHub account id and cannot prevent duplicate accounts, add a guard in AuthServicesController#create before Member.new(email:):

  • Detect a likely duplicate, for example a member with the same name who has only a legacy GitHub auth service and no codebar auth service.
  • Instead of silently creating a new member, redirect to an interstitial page explaining that an existing account was found and asking the user to verify ownership (e.g. by logging in once via the original GitHub method). After verification, attach the codebar auth service to the existing member.

This is the most work inside planner and carries UX risk, but it keeps the data clean without depending on the auth app.

Suggested tests

  • Controller spec for AuthServicesController#create: codebar callback includes a github_id claim that matches an existing GitHub AuthService but the email does not match any member → assert the codebar service is added to the existing member and no new member is created.
  • Controller spec for AuthServicesController#create: codebar callback returns an unknown email and no matching github_id → assert a new member is created (preserves current new-sign-up behaviour).
  • Feature spec: user with mismatched GitHub/planner emails logs in via the GitHub option → assert they land on their original dashboard, not the new-user details page.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions