Skip to content

Commit 730853c

Browse files
wdoweraaronlippold
andauthored
Fix/OIDC provider conflict (#711)
* fix: resolve OIDC provider conflict and add auto-link OmniAuth returns auth.provider as symbol (:oidc) but DB stores string ("oidc"). Comparison always triggered ProviderConflictError. rescue_from ordering hid the actionable message behind generic "unexpected error." - Provider+uid-first lookup in User.from_omniauth - .to_s coercion for type-safe comparison - Fix rescue_from ordering (StandardError first) - Add VULCAN_AUTO_LINK_USER global setting - email_verified check for auto-link security - just_auto_linked? flag (no duplicate lookup) - Genericize oauth_error flash message - Replace gitlab_omniauth-ldap with omniauth-ldap - Remove nkf gem, lazy-load LDAP - 75 backend auth specs Authored by: Aaron Lippold<lippold@gmail.com> * feat: session auth tracking, profile UX, unlink - session[:auth_method] tracks HOW user signed in - Profile shows "Signed in via X" + "Linked to Y" - POST /users/unlink_identity with password check - Unlink button with confirmation modal - 15 backend + 29 frontend tests Authored by: Aaron Lippold<lippold@gmail.com> * fix: pre-existing bugs + infrastructure hardening Bug fixes found during auth code review: - vulcan_audit.rb: bitwise & vs && crash on nil rule - users_controller: Slack fires on every update (now gated on saved_change_to_admin?) - History.vue: suppress raw field changes when audit has a comment (link/unlink UX) - registrations: polymorphic audit + user_type Infrastructure: - Ruby 3.4.8 -> 3.4.9 - parallel_sync.rake: recursion guard - docker-compose.dev.yml: trust auth for VPN Authored by: Aaron Lippold<lippold@gmail.com> * docs: add what's-left tracking and beads export - WHATS-LEFT.md: pending bug fixes, future features - beads-export.jsonl: full task board for handoff Authored by: Aaron Lippold<lippold@gmail.com> * chore: fix Gemfile extra blank line (rubocop) Authored by: Aaron Lippold<lippold@gmail.com> * test: add TDD tests for Slack notification gate and polymorphic audit filter 71q.1: Verify Slack notification only fires on admin flag changes, not on name/email updates. Tests promotion, demotion, and no-op cases. 71q.2: Verify registrations#edit filters audits by user_type: 'User', excluding rogue audits with matching user_id but different user_type. Signed-off-by: Will Dower <will@dower.dev> * fix: restore prev_unconfirmed_email for reconfirmation flash (71q.3) The update action read resource.unconfirmed_email but discarded the value (no assignment). After email change, users got generic "Profile updated" instead of "confirmation link sent to new address". Fix: capture prev_unconfirmed_email, compare after update, show appropriate flash message per Devise stock behavior. TDD: red (email-change test failed with generic flash) → green. Signed-off-by: Will Dower <will@dower.dev> * fix: use update_columns for reset token to bypass validations (71q.4) generate_reset_url used update! which runs full validations. If a user has pre-existing validation failures (e.g., name exceeds a tightened limit), the admin's reset link generation fails with RecordInvalid — blocking an unrelated recovery flow. Fix: use update_columns (matches Devise's save(validate: false) pattern). Token writes carry no business logic. TDD: red (user with 500-char name → validation error) → green. Signed-off-by: Will Dower <will@dower.dev> * fix: stop leaking exception.message in users_controller rescues (71q.5) Both send_password_reset and set_password rescue blocks echoed e.message directly to the client, leaking SMTP hostnames, DB connection strings, or other internal details. Fix: log full exception with backtrace server-side via Rails.logger.error, return generic message to client. TDD: - send_password_reset: red (response contained 'smtp.internal.corp') → green (generic message returned) - set_password: source inspection test verifies rescue block does not interpolate e.message (Rails test mode re-raises before controller rescue runs, preventing request-level testing) Signed-off-by: Will Dower <will@dower.dev> * fix: remove dead authProvider computed and add visibility guard (71q.6, 71q.7) 71q.6: Add source-inspection test ensuring no bare 'public' keyword exists between private and protected sections in registrations controller (issue was already fixed, test prevents regression). 71q.7: Remove dead authProvider computed property from UserProfile.vue. Nothing in the template or script references it — the refactored linkedProvider + currentSessionMethod properties cover all use cases. TDD: red (authProvider still in computed options) → green (removed). Signed-off-by: Will Dower <will@dower.dev> * fix: P3/P4 hardening — backtraces, email_verified, null guard, constants, docs 71q.8: Log OmniAuth exception backtraces in all environments (was dev-only). Use error level with first 10 frames. 71q.9: Cast email_verified OIDC claim via ActiveModel::Type::Boolean to catch providers sending "false" (string) instead of false. 71q.10: Use falsy check in UsersTable typeColumn to handle both null and undefined provider gracefully. 71q.11: Normalize PROJECT_MEMBER_ADMINS to array for consistency with VIEWERS/AUTHORS/REVIEWERS. Add ROLE_ADMIN scalar constant for attribute assignment. Update call sites. 71q.12: Document valid_password? hidden bcrypt→PBKDF2 rehash side-effect at the unlink call site. Signed-off-by: Will Dower <will@dower.dev> * fix: use fake ID in vulcan_audit destroy action test Test referenced undefined `rule` variable. Use a fake ID like the adjacent nil-rule test — the destroy action guard skips before any DB lookup so the ID value doesn't matter. Signed-off-by: Will Dower <will@dower.dev> * fix: address Copilot review — v-for key, remove dev artifacts - Fix History.vue v-for key: changes.id is undefined, use changes.field with index fallback for stable Vue diffing - Remove WHATS-LEFT.md and beads-export.jsonl (branch-specific tracking docs, not for the repo) Signed-off-by: Will Dower <will@dower.dev> * fix: properly exercise RecordNotUnique retry path in race condition test Test claimed to verify retry-on-RecordNotUnique but never stubbed save! to raise. Now stubs save! to raise once, pre-creates the user with matching provider+uid so retry lookup succeeds, and asserts save! was called exactly once before the retry found the existing user. Signed-off-by: Will Dower <will@dower.dev> --------- Signed-off-by: Will Dower <will@dower.dev> Co-authored-by: Aaron Lippold <lippold@gmail.com>
1 parent 5e863a9 commit 730853c

36 files changed

Lines changed: 1243 additions & 194 deletions

.ruby-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.4.8
1+
3.4.9

.tool-versions

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
ruby 3.3.9
1+
ruby 3.4.9
22
nodejs 22.13.1

ENVIRONMENT_VARIABLES.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ DB_SUFFIX=_v3 # → vulcan_vue_development_v3, vulcan_vue_test_v3
5959
| `VULCAN_ENABLE_REMEMBER_ME` | Show "Remember Me" checkbox on login forms | `true` | `false` for DoD |
6060
| `VULCAN_REMEMBER_ME_DURATION` | How long Remember Me keeps session alive. Same format as session timeout. | `8h` | `1d`, `28800` |
6161

62+
### Account Linking
63+
| Variable | Description | Default | Example |
64+
|----------|-------------|---------|---------|
65+
| `VULCAN_AUTO_LINK_USER` | Automatically link external identities (OIDC, LDAP, GitHub) to existing local accounts with the same email. Only enable when all configured identity providers verify email ownership (e.g., enterprise Okta, corporate LDAP). When `false`, users see a clear error directing them to sign in with their existing method or contact an administrator. | `false` | `true` or `false` |
66+
6267
### User Registration
6368
| Variable | Description | Default | Example |
6469
|----------|-------------|---------|---------|

Gemfile

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
source 'https://rubygems.org'
44

5-
ruby '3.4.8'
5+
ruby '3.4.9'
66

77
# Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
88
gem 'rails', '~> 8.0.0'
@@ -29,10 +29,10 @@ gem 'activerecord-session_store'
2929
gem 'devise-security', github: 'mitre/devise-security', ref: '9f560ed125c096205ba9434de8feea532ce97b4c'
3030
# Use Omniauth to support additional login providers
3131
gem 'omniauth', '~> 2.1'
32-
# LDAP Auth
33-
# GitLab fork with several improvements to original library. For full list of changes
34-
# see https://github.com/intridea/omniauth-ldap/compare/master...gitlabhq:master
35-
gem 'gitlab_omniauth-ldap', '~> 2.2.0', require: 'omniauth-ldap'
32+
# LDAP Auth — original omniauth-ldap (actively maintained, no nkf/kconv dependency).
33+
# Replaces gitlab_omniauth-ldap which had a dead `require 'kconv'` that triggered
34+
# Ruby VM crash bugs.ruby-lang.org/issues/21967 on parallel_tests forking.
35+
gem 'omniauth-ldap', '~> 2.3', require: false
3636
# Allow users to sign in with GitHub
3737
gem 'omniauth-github'
3838
# https://github.com/omniauth/omniauth/wiki/Resolving-CVE-2015-9284
@@ -81,9 +81,6 @@ gem 'caxlsx'
8181
# For reading excel files
8282
gem 'ruh-roo', '~> 3.0.0', require: 'roo'
8383

84-
# NKF/kconv - removed from default gems in Ruby 3.4+, needed by gitlab_omniauth-ldap
85-
gem 'nkf'
86-
8784
# REXML - required explicitly in Ruby 3.0+
8885
gem 'rexml'
8986

Gemfile.lock

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -223,11 +223,6 @@ GEM
223223
foreman (0.90.0)
224224
thor (~> 1.4)
225225
fuzzyurl (0.9.0)
226-
gitlab_omniauth-ldap (2.2.0)
227-
net-ldap (~> 0.16)
228-
omniauth (>= 1.3, < 3)
229-
pyu-ruby-sasl (>= 0.0.3.3, < 0.1)
230-
rubyntlm (~> 0.5)
231226
gli (2.22.2)
232227
ostruct
233228
globalid (1.3.0)
@@ -352,7 +347,9 @@ GEM
352347
net-imap (0.6.3)
353348
date
354349
net-protocol
355-
net-ldap (0.19.0)
350+
net-ldap (0.20.0)
351+
base64
352+
ostruct
356353
net-pop (0.1.2)
357354
net-protocol
358355
net-protocol (0.2.2)
@@ -364,7 +361,6 @@ GEM
364361
net-ssh (7.3.0)
365362
netrc (0.11.0)
366363
nio4r (2.7.5)
367-
nkf (0.2.0)
368364
nokogiri (1.19.2)
369365
mini_portile2 (~> 2.8.2)
370366
racc (~> 1.4)
@@ -378,23 +374,30 @@ GEM
378374
racc (~> 1.4)
379375
nokogiri-happymapper (0.10.0)
380376
nokogiri (~> 1.5)
381-
oauth2 (2.0.12)
377+
oauth2 (2.0.18)
382378
faraday (>= 0.17.3, < 4.0)
383379
jwt (>= 1.0, < 4.0)
384380
logger (~> 1.2)
385381
multi_xml (~> 0.5)
386382
rack (>= 1.2, < 4)
387383
snaky_hash (~> 2.0, >= 2.0.3)
388-
version_gem (>= 1.1.8, < 3)
384+
version_gem (~> 1.1, >= 1.1.9)
389385
omniauth (2.1.3)
390386
hashie (>= 3.4.6)
391387
rack (>= 2.2.3)
392388
rack-protection
393389
omniauth-github (2.0.1)
394390
omniauth (~> 2.0)
395391
omniauth-oauth2 (~> 1.8)
396-
omniauth-oauth2 (1.8.0)
397-
oauth2 (>= 1.4, < 3)
392+
omniauth-ldap (2.3.3)
393+
net-ldap (~> 0.16, < 1)
394+
omniauth (>= 1.2, < 3)
395+
pyu-ruby-sasl (>= 0.0.3.3, < 0.1)
396+
rack (>= 1, < 4)
397+
rubyntlm (~> 0.6.2, < 1)
398+
version_gem (~> 1.1, >= 1.1.9)
399+
omniauth-oauth2 (1.9.0)
400+
oauth2 (>= 2.0.2, < 3)
398401
omniauth (~> 2.0)
399402
omniauth-rails_csrf_protection (1.0.2)
400403
actionpack (>= 4.2)
@@ -426,11 +429,11 @@ GEM
426429
parslet (2.0.0)
427430
pastel (0.8.0)
428431
tty-color (~> 0.5)
429-
pg (1.6.1)
430-
pg (1.6.1-aarch64-linux)
431-
pg (1.6.1-arm64-darwin)
432-
pg (1.6.1-x86_64-linux)
433-
pg (1.6.1-x86_64-linux-musl)
432+
pg (1.6.3)
433+
pg (1.6.3-aarch64-linux)
434+
pg (1.6.3-arm64-darwin)
435+
pg (1.6.3-x86_64-linux)
436+
pg (1.6.3-x86_64-linux-musl)
434437
pg_search (2.3.7)
435438
activerecord (>= 6.1)
436439
activesupport (>= 6.1)
@@ -681,7 +684,7 @@ GEM
681684
validate_url (1.0.15)
682685
activemodel (>= 3.0.0)
683686
public_suffix
684-
version_gem (1.1.8)
687+
version_gem (1.1.9)
685688
warden (1.2.9)
686689
rack (>= 2.0.9)
687690
web-console (4.2.1)
@@ -739,7 +742,6 @@ DEPENDENCIES
739742
factory_bot_rails (~> 6.5.0)
740743
ffaker (~> 2.10)
741744
foreman
742-
gitlab_omniauth-ldap (~> 2.2.0)
743745
haml-rails (~> 2.0)
744746
health_check (~> 3.1)
745747
highline (~> 2.0)
@@ -749,11 +751,11 @@ DEPENDENCIES
749751
listen (~> 3.7)
750752
mitre-inspec-objects
751753
mitre-settingslogic (~> 3.0)
752-
nkf
753754
nokogiri
754755
nokogiri-happymapper
755756
omniauth (~> 2.1)
756757
omniauth-github
758+
omniauth-ldap (~> 2.3)
757759
omniauth-rails_csrf_protection (~> 1.0)
758760
omniauth_openid_connect (~> 0.6.0)
759761
ox
@@ -790,7 +792,7 @@ DEPENDENCIES
790792
with_advisory_lock (~> 5.1)
791793

792794
RUBY VERSION
793-
ruby 3.4.8p72
795+
ruby 3.4.9p82
794796

795797
BUNDLED WITH
796798
2.3.27

app.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@
8282
},
8383
"VULCAN_SEED_DEMO_DATA": {
8484
"value": "true"
85+
},
86+
"VULCAN_AUTO_LINK_USER": {
87+
"value": "true"
8588
}
8689
},
8790
"scripts": {

app/constants/project_member_constants.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@ module ProjectMemberConstants
66
PROJECT_MEMBER_VIEWERS = %w[viewer author reviewer admin].freeze
77
PROJECT_MEMBER_AUTHORS = %w[author reviewer admin].freeze
88
PROJECT_MEMBER_REVIEWERS = %w[reviewer admin].freeze
9-
PROJECT_MEMBER_ADMINS = 'admin'
9+
PROJECT_MEMBER_ADMINS = %w[admin].freeze
10+
ROLE_ADMIN = 'admin'
1011
end

app/controllers/projects_controller.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ def index
2424
@projects = current_user.available_projects.eager_load(:memberships).alphabetical.as_json(methods: %i[memberships])
2525
@projects.each do |project|
2626
project['admin'] = project['memberships'].any? do |m|
27-
m['role'] == PROJECT_MEMBER_ADMINS && m['user_id'] == current_user.id
27+
PROJECT_MEMBER_ADMINS.include?(m['role']) && m['user_id'] == current_user.id
2828
end
2929
project['is_member'] = project['memberships'].any? do |m|
3030
m['user_id'] == current_user.id
@@ -76,7 +76,7 @@ def create
7676
project = Project.new(
7777
name: new_project_params[:name],
7878
description: new_project_params[:description],
79-
memberships_attributes: [{ user: current_user, role: PROJECT_MEMBER_ADMINS }],
79+
memberships_attributes: [{ user: current_user, role: ROLE_ADMIN }],
8080
visibility: new_project_params[:visibility]
8181
)
8282
project.project_metadata_attributes = { data: { 'Slack Channel ID' => new_project_params[:slack_channel_id] } } if new_project_params[:slack_channel_id].present?
@@ -368,7 +368,7 @@ def perform_create_from_backup(file, include_reviews, include_memberships,
368368
name: project_name,
369369
description: project_description,
370370
visibility: project_visibility,
371-
memberships_attributes: [{ user: current_user, role: PROJECT_MEMBER_ADMINS }]
371+
memberships_attributes: [{ user: current_user, role: ROLE_ADMIN }]
372372
)
373373

374374
result = Import::JsonArchiveImporter.new(

app/controllers/sessions_controller.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,15 @@ class SessionsController < Devise::SessionsController
1111
# Devise calls reset_session on login (session fixation protection).
1212
# We save the consent timestamp before and restore it after so the
1313
# user doesn't have to acknowledge twice (once on login page, once after).
14+
#
15+
# We also record session[:auth_method] AFTER super so it survives the reset.
16+
# This lets the profile show "Signed in via email and password" vs "Signed in
17+
# via Okta" — distinct from user.provider (which tracks linked identities).
1418
def create
1519
consent_at = session[:consent_acknowledged_at]
1620
super
1721
session[:consent_acknowledged_at] = consent_at if consent_at.present?
22+
session[:auth_method] = :local if user_signed_in?
1823
end
1924

2025
def destroy

app/controllers/users/omniauth_callbacks_controller.rb

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,17 @@ module Users
55
# that is hit. Currently we don't have any provider-specific code, since
66
# both LDAP and Github return data in a similar enough manner.
77
class OmniauthCallbacksController < Devise::OmniauthCallbacksController
8-
# Comprehensive error handling for various OmniAuth failure scenarios
8+
# Error handling for OmniAuth failure scenarios.
9+
# Rails checks rescue_from in REVERSE order (last-defined = first-checked).
10+
# StandardError must be FIRST so specific subclasses defined after it take priority.
11+
rescue_from StandardError, with: :omniauth_generic_error
912
rescue_from Rack::OAuth2::Client::Error, with: :oauth_error
1013
rescue_from OmniAuth::Strategies::OAuth2::CallbackError, with: :omniauth_callback_error
1114
rescue_from Timeout::Error, with: :omniauth_timeout_error
1215
rescue_from Faraday::TimeoutError, with: :omniauth_timeout_error
1316
rescue_from User::ProviderConflictError, with: :omniauth_provider_conflict
1417
rescue_from ArgumentError, with: :omniauth_validation_error
1518
rescue_from ActiveRecord::RecordInvalid, with: :omniauth_record_error
16-
rescue_from StandardError, with: :omniauth_generic_error
1719
def all
1820
auth = request.env['omniauth.auth']
1921

@@ -29,6 +31,18 @@ def all
2931

3032
user = User.from_omniauth(auth)
3133

34+
# Notify user when their local account was auto-linked to an external provider.
35+
# `just_auto_linked?` is set by User.from_omniauth (no extra DB query needed).
36+
if user.just_auto_linked?
37+
provider_name = auth.provider.to_s == 'oidc' ? (Settings.oidc&.title || 'OIDC') : auth.provider.to_s.upcase
38+
flash.notice = "Your account has been linked to #{provider_name}. You can now sign in with either method."
39+
end
40+
41+
# Record the auth method for this session so the profile can distinguish
42+
# "Signed in via Okta" from "Signed in via email and password" — this is
43+
# distinct from user.provider which records linked identities.
44+
session[:auth_method] = auth.provider.to_s.to_sym
45+
3246
# Store ID token in session for OIDC logout
3347
if auth.credentials&.id_token
3448
session[:id_token] = auth.credentials.id_token
@@ -44,7 +58,7 @@ def all
4458
should_remember = params[:remember_me] == '1' || omniauth_params['remember_me'] == '1'
4559
remember_me(user) if should_remember
4660

47-
flash.notice = I18n.t('devise.sessions.signed_in')
61+
flash.notice ||= I18n.t('devise.sessions.signed_in')
4862
sign_in_and_redirect(user) && return
4963
end
5064

@@ -53,54 +67,59 @@ def all
5367
alias oidc all
5468

5569
def oauth_error(exception)
70+
# Log full details server-side for debugging.
5671
Rails.logger.error "OAuth authentication error: #{exception.class} - #{exception.message}"
57-
Rails.logger.debug exception.backtrace.join("\n") if Rails.env.development?
72+
Rails.logger.error exception.backtrace&.first(10)&.join("\n")
5873

59-
flash.alert = "OAuth error: #{exception.message}"
60-
redirect_to root_path
74+
# Do NOT include exception.message in the user-facing flash — Rack::OAuth2 errors
75+
# can include sensitive details (token hints, client config, redirect URIs).
76+
flash.alert = 'Authentication failed. Please try again or contact your administrator.'
77+
redirect_to new_user_session_path
6178
end
6279

6380
def omniauth_callback_error(exception)
6481
Rails.logger.error "OmniAuth callback error: #{exception.class} - #{exception.message}"
65-
Rails.logger.debug exception.backtrace.join("\n") if Rails.env.development?
82+
Rails.logger.error exception.backtrace&.first(10)&.join("\n")
6683

6784
flash.alert = 'Authentication failed. Please try again or contact your administrator.'
6885
redirect_to new_user_session_path
6986
end
7087

7188
def omniauth_timeout_error(exception)
7289
Rails.logger.error "OmniAuth timeout error: #{exception.class} - #{exception.message}"
73-
Rails.logger.debug exception.backtrace.join("\n") if Rails.env.development?
90+
Rails.logger.error exception.backtrace&.first(10)&.join("\n")
7491

7592
flash.alert = 'Authentication timed out. Please try again.'
7693
redirect_to new_user_session_path
7794
end
7895

7996
def omniauth_provider_conflict(exception)
8097
Rails.logger.warn "Provider conflict: #{exception.message}"
81-
flash.alert = exception.message
98+
flash.alert = "#{exception.message} " \
99+
'Please sign in using your existing account, ' \
100+
'or contact an administrator to link your accounts.'
82101
redirect_to new_user_session_path
83102
end
84103

85104
def omniauth_validation_error(exception)
86105
Rails.logger.error "OmniAuth validation error: #{exception.class} - #{exception.message}"
87-
Rails.logger.debug exception.backtrace.join("\n") if Rails.env.development?
106+
Rails.logger.error exception.backtrace&.first(10)&.join("\n")
88107

89108
flash.alert = "Authentication failed: #{exception.message}"
90109
redirect_to new_user_session_path
91110
end
92111

93112
def omniauth_record_error(exception)
94113
Rails.logger.error "OmniAuth database error: #{exception.class} - #{exception.message}"
95-
Rails.logger.debug exception.backtrace.join("\n") if Rails.env.development?
114+
Rails.logger.error exception.backtrace&.first(10)&.join("\n")
96115

97116
flash.alert = 'Account creation failed. Please contact your administrator.'
98117
redirect_to new_user_session_path
99118
end
100119

101120
def omniauth_generic_error(exception)
102121
Rails.logger.error "Unexpected OmniAuth error: #{exception.class} - #{exception.message}"
103-
Rails.logger.debug exception.backtrace.join("\n") if Rails.env.development?
122+
Rails.logger.error exception.backtrace&.first(10)&.join("\n")
104123

105124
flash.alert = 'An unexpected error occurred during authentication. Please try again.'
106125
redirect_to new_user_session_path

0 commit comments

Comments
 (0)