From 4946d20933c018b967744f55aaf219d3c5901867 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Tue, 11 Aug 2026 09:37:54 +0200 Subject: [PATCH] fix(auth): link codebar logins to existing members by GitHub id When the codebar auth callback carries a github_id claim, look up the legacy github AuthService before falling back to email matching. This prevents duplicate member records when a user's GitHub email differs from their stored planner email. The github_id lookup is scoped to the codebar provider so other OAuth strategies are unaffected. Request specs cover matching by github_id, github_id precedence over email, fallback to email matching, and new-member creation when nothing matches. --- app/controllers/auth_services_controller.rb | 11 +++- spec/requests/auth_services_callback_spec.rb | 56 ++++++++++++++++++++ spec/support/omniauth.rb | 8 ++- 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/app/controllers/auth_services_controller.rb b/app/controllers/auth_services_controller.rb index 84c004356..470163f44 100644 --- a/app/controllers/auth_services_controller.rb +++ b/app/controllers/auth_services_controller.rb @@ -33,7 +33,7 @@ def create return redirect_to root_url end - member = Member.find_by(email:) + member = member_from_github_id || Member.find_by(email:) member ||= Member.new(email:) new_member = member.new_record? @@ -89,6 +89,15 @@ def failure private + def member_from_github_id + return unless omnihash[:provider] == 'codebar' + + github_id = omnihash.dig(:extra, :raw_info, 'github_id') + return if github_id.blank? + + AuthService.find_by(provider: 'github', uid: github_id.to_s)&.member + end + def referer_or_dashboard_path session[:referer_path] || dashboard_path end diff --git a/spec/requests/auth_services_callback_spec.rb b/spec/requests/auth_services_callback_spec.rb index 71c256ef0..414d3b3fd 100644 --- a/spec/requests/auth_services_callback_spec.rb +++ b/spec/requests/auth_services_callback_spec.rb @@ -74,4 +74,60 @@ expect(response).to redirect_to(edit_member_details_path) expect(session[:new_member]).to be_nil end + + it 'links a codebar callback to an existing member via legacy github id when emails differ' do + original = Fabricate(:member, email: 'original@example.com') + Fabricate(:auth_service, member: original, provider: 'github', uid: '12345') + + mock_auth_hash(provider: 'codebar', uid: 'different@example.com', + email: 'different@example.com', github_id: '12345') + + expect { post '/auth/codebar/callback' } + .to change { original.reload.auth_services.where(provider: 'codebar').count }.by(1) + + expect(response).to redirect_to(dashboard_path) + expect(session[:member_id]).to eq(original.id) + expect(Member.where(email: 'different@example.com').count).to eq(0) + end + + it 'creates a new member when the codebar callback has no matching github id or email' do + Fabricate(:member, email: 'existing@example.com') + Fabricate(:auth_service, provider: 'github', uid: '99999') + + mock_auth_hash(provider: 'codebar', uid: 'brandnew@example.com', + email: 'brandnew@example.com', github_id: '11111') + + expect { post '/auth/codebar/callback' }.to change(Member, :count).by(1) + + expect(response).to redirect_to(edit_member_details_path) + end + + it 'prefers github_id over email when they resolve to different members' do + member_a = Fabricate(:member, email: 'a@example.com') + Fabricate(:auth_service, member: member_a, provider: 'github', uid: '12345') + member_b = Fabricate(:member, email: 'b@example.com') + member_b_codebar_count = member_b.auth_services.where(provider: 'codebar').count + + mock_auth_hash(provider: 'codebar', uid: 'b@example.com', + email: 'b@example.com', github_id: '12345') + + expect { post '/auth/codebar/callback' } + .to change { member_a.reload.auth_services.where(provider: 'codebar').count }.by(1) + + expect(session[:member_id]).to eq(member_a.id) + expect(member_b.reload.auth_services.where(provider: 'codebar').count).to eq(member_b_codebar_count) + expect(Member.count).to eq(2) + end + + it 'falls back to email matching when the codebar callback has no github_id' do + existing = Fabricate(:member, email: 'fallback@example.com') + + mock_auth_hash(provider: 'codebar', uid: 'fallback@example.com', + email: 'fallback@example.com') + + post '/auth/codebar/callback' + + expect(session[:member_id]).to eq(existing.id) + expect(response).to redirect_to(dashboard_path) + end end diff --git a/spec/support/omniauth.rb b/spec/support/omniauth.rb index 64bb8fd6f..ead2367b5 100644 --- a/spec/support/omniauth.rb +++ b/spec/support/omniauth.rb @@ -1,7 +1,8 @@ require 'omniauth' module OmniauthMacros - def mock_auth_hash(name: Faker::Name.name, email: Faker::Internet.email, provider: 'codebar', uid: 'uid') + def mock_auth_hash(name: Faker::Name.name, email: Faker::Internet.email, provider: 'codebar', uid: 'uid', + github_id: nil) OmniAuth.config.mock_auth[provider.to_sym] = { provider: provider, uid: uid, @@ -12,6 +13,11 @@ def mock_auth_hash(name: Faker::Name.name, email: Faker::Internet.email, provide credentials: { token: 'mock_token', secret: 'mock_secret' + }, + extra: { + raw_info: { + 'github_id' => github_id + }.compact } } end