diff --git a/Gemfile b/Gemfile index 8c6d39d0fd..67b9929556 100644 --- a/Gemfile +++ b/Gemfile @@ -23,6 +23,8 @@ gem "trilogy", "~> 2.9" # Features gem "bcrypt", "~> 3.1.7" +gem "omniauth_openid_connect", "~> 0.8.0" +gem "omniauth-rails_csrf_protection", "~> 1.0" gem "geared_pagination", "~> 1.2" gem "rqrcode" gem "redcarpet" diff --git a/Gemfile.lock b/Gemfile.lock index 0e48985b0f..9c69c8af70 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -120,7 +120,9 @@ GEM railties addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) + aes_key_wrap (1.1.0) ast (2.4.3) + attr_required (1.0.2) autotuner (1.1.0) aws-eventstream (1.4.0) aws-partitions (1.1203.0) @@ -146,6 +148,7 @@ GEM bcrypt_pbkdf (1.1.2) benchmark (0.5.0) bigdecimal (4.0.1) + bindata (2.5.1) bindex (0.8.1) bootsnap (1.20.1) msgpack (~> 1.2) @@ -180,12 +183,22 @@ GEM dotenv (3.2.0) drb (2.2.3) ed25519 (1.4.0) + email_validator (2.2.4) + activemodel erb (6.0.1) erubi (1.13.1) et-orbi (1.4.0) tzinfo faker (3.5.3) i18n (>= 1.8.11, < 2) + faraday (2.14.0) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-follow_redirects (0.5.0) + faraday (>= 1, < 3) + faraday-net_http (3.4.2) + net-http (~> 0.5) ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.2-aarch64-linux-musl) ffi (1.17.2-arm-linux-gnu) @@ -203,6 +216,8 @@ GEM globalid (1.3.0) activesupport (>= 6.1) hashdiff (1.2.1) + hashie (5.1.0) + logger i18n (1.14.8) concurrent-ruby (~> 1.0) image_processing (1.14.0) @@ -222,6 +237,13 @@ GEM activesupport (>= 7.0.0) jmespath (1.6.2) json (2.18.0) + json-jwt (1.17.0) + activesupport (>= 4.2) + aes_key_wrap + base64 + bindata + faraday (~> 2.0) + faraday-follow_redirects jwt (3.1.2) base64 kamal (2.10.1) @@ -274,6 +296,8 @@ GEM mocha (3.0.1) ruby2_keywords (>= 0.0.5) msgpack (1.8.0) + net-http (0.9.1) + uri (>= 0.11.1) net-http-persistent (4.0.8) connection_pool (>= 2.2.4, < 4) net-imap (0.6.2) @@ -307,6 +331,30 @@ GEM racc (~> 1.4) nokogiri (1.19.0-x86_64-linux-musl) racc (~> 1.4) + omniauth (2.1.4) + hashie (>= 3.4.6) + logger + rack (>= 2.2.3) + rack-protection + omniauth-rails_csrf_protection (1.0.2) + actionpack (>= 4.2) + omniauth (~> 2.0) + omniauth_openid_connect (0.8.0) + omniauth (>= 1.9, < 3) + openid_connect (~> 2.2) + openid_connect (2.3.1) + activemodel + attr_required (>= 1.0.0) + email_validator + faraday (~> 2.0) + faraday-follow_redirects + json-jwt (>= 1.16) + mail + rack-oauth2 (~> 2.2) + swd (~> 2.0) + tzinfo + validate_url + webfinger (~> 2.0) openssl (4.0.0) ostruct (0.6.3) parallel (1.27.0) @@ -335,6 +383,17 @@ GEM rack (3.2.4) rack-mini-profiler (4.0.1) rack (>= 1.2.0) + rack-oauth2 (2.3.0) + activesupport + attr_required + faraday (~> 2.0) + faraday-follow_redirects + json-jwt (>= 1.11.0) + rack (>= 2.1.0) + rack-protection (4.2.1) + base64 (>= 0.1.0) + logger (>= 1.6.0) + rack (>= 3.0.0, < 4) rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -440,6 +499,11 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.2.0) + swd (2.0.3) + activesupport (>= 3) + attr_required (>= 0.0.5) + faraday (~> 2.0) + faraday-follow_redirects thor (1.5.0) thruster (0.1.17) thruster (0.1.17-aarch64-linux) @@ -458,6 +522,9 @@ GEM unicode-emoji (~> 4.1) unicode-emoji (4.1.0) uri (1.1.1) + validate_url (1.0.15) + activemodel (>= 3.0.0) + public_suffix vcr (6.4.0) web-console (4.2.1) actionview (>= 6.0.0) @@ -467,6 +534,10 @@ GEM web-push (3.1.0) jwt (~> 3.0) openssl (>= 3.0) + webfinger (2.1.3) + activesupport + faraday (~> 2.0) + faraday-follow_redirects webmock (3.26.1) addressable (>= 2.8.0) crack (>= 0.3.2) @@ -514,6 +585,8 @@ DEPENDENCIES mittens mocha net-http-persistent + omniauth-rails_csrf_protection (~> 1.0) + omniauth_openid_connect (~> 0.8.0) platform_agent propshaft puma (>= 5.0) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 05efbf4fc0..f712ac75ce 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -9,7 +9,7 @@ module Authentication etag { Current.identity.id if authenticated? } - include Authentication::ViaMagicLink, LoginHelper + include Authentication::ViaMagicLink, Authentication::ViaOidc, LoginHelper end class_methods do @@ -104,4 +104,18 @@ def terminate_session def session_token cookies[:session_token] end + + def authentication_failed(message: "Something went wrong. Please try again.", redirect_path: new_session_path) + respond_to do |format| + format.html { redirect_to redirect_path, alert: message } + format.json { render json: { message: message }, status: :unauthorized } + end + end + + def rate_limit_exceeded(message: "Try again later.", redirect_path: new_session_path) + respond_to do |format| + format.html { redirect_to redirect_path, alert: message } + format.json { render json: { message: message }, status: :too_many_requests } + end + end end diff --git a/app/controllers/concerns/authentication/via_oidc.rb b/app/controllers/concerns/authentication/via_oidc.rb new file mode 100644 index 0000000000..8cf8717670 --- /dev/null +++ b/app/controllers/concerns/authentication/via_oidc.rb @@ -0,0 +1,15 @@ +module Authentication::ViaOidc + extend ActiveSupport::Concern + + private + def authenticate_with_oidc(auth_hash) + identity = Identity.find_or_create_from_oidc(auth_hash) + + if identity.present? + start_new_session_for identity + redirect_to after_authentication_url + else + authentication_failed(message: "Something went wrong using your identity provider.") + end + end +end diff --git a/app/controllers/sessions/magic_links_controller.rb b/app/controllers/sessions/magic_links_controller.rb index 32257d9415..8c4b65e77a 100644 --- a/app/controllers/sessions/magic_links_controller.rb +++ b/app/controllers/sessions/magic_links_controller.rb @@ -52,12 +52,7 @@ def sign_in(magic_link) def email_address_mismatch clear_pending_authentication_token - alert_message = "Something went wrong. Please try again." - - respond_to do |format| - format.html { redirect_to new_session_path, alert: alert_message } - format.json { render json: { message: alert_message }, status: :unauthorized } - end + authentication_failed end def invalid_code @@ -76,10 +71,6 @@ def after_sign_in_url(magic_link) end def rate_limit_exceeded - rate_limit_exceeded_message = "Try again in 15 minutes." - respond_to do |format| - format.html { redirect_to session_magic_link_path, alert: rate_limit_exceeded_message } - format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests } - end + super(message: "Try again in 15 minutes.", redirect_path: session_magic_link_path) end end diff --git a/app/controllers/sessions/oidc_controller.rb b/app/controllers/sessions/oidc_controller.rb new file mode 100644 index 0000000000..575d8a3665 --- /dev/null +++ b/app/controllers/sessions/oidc_controller.rb @@ -0,0 +1,27 @@ +class Sessions::OidcController < ApplicationController + disallow_account_scope + require_unauthenticated_access + rate_limit to: 10, within: 15.minutes, only: :create, with: :rate_limit_exceeded + skip_forgery_protection only: :create + + layout "public" + + def create + auth_hash = request.env["omniauth.auth"] + + if auth_hash.present? + authenticate_with_oidc(auth_hash) + else + Rails.logger.debug "OIDC data not found" + authentication_failed(message: "OIDC authentication failed.") + end + rescue => e + Rails.error.report(e, severity: :error) + authentication_failed(message: "Error during OIDC authentication.") + end + + def failure + error_type = params[:message] || "unknown_error" + authentication_failed(message: "OIDC authentication failed: #{error_type}") + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index d0de9e84cf..f50601b9ca 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -37,15 +37,6 @@ def email_address params.expect(:email_address) end - def rate_limit_exceeded - rate_limit_exceeded_message = "Try again later." - - respond_to do |format| - format.html { redirect_to new_session_path, alert: rate_limit_exceeded_message } - format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests } - end - end - def sign_in(identity) redirect_to_session_magic_link identity.send_magic_link end diff --git a/app/helpers/login_helper.rb b/app/helpers/login_helper.rb index 6004282a67..1efabfcebb 100644 --- a/app/helpers/login_helper.rb +++ b/app/helpers/login_helper.rb @@ -14,4 +14,12 @@ def redirect_to_login_url def redirect_to_logout_url redirect_to logout_url, allow_other_host: true end + + def oidc_enabled? + ENV["OIDC_ISSUER"].present? + end + + def oidc_required? + ENV["OIDC_REQUIRED"] == "true" + end end diff --git a/app/models/identity.rb b/app/models/identity.rb index 7495e37c3f..7503576c1e 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -1,5 +1,5 @@ class Identity < ApplicationRecord - include Joinable, Transferable + include Joinable, OidcCompatible, Transferable has_many :access_tokens, dependent: :destroy has_many :magic_links, dependent: :destroy @@ -12,6 +12,7 @@ class Identity < ApplicationRecord before_destroy :deactivate_users, prepend: true validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :oidc_subject, uniqueness: { scope: :oidc_provider }, allow_nil: true normalizes :email_address, with: ->(value) { value.strip.downcase.presence } def self.find_by_permissable_access_token(token, method:) diff --git a/app/models/identity/oidc_compatible.rb b/app/models/identity/oidc_compatible.rb new file mode 100644 index 0000000000..e7b29ee4c2 --- /dev/null +++ b/app/models/identity/oidc_compatible.rb @@ -0,0 +1,42 @@ +module Identity::OidcCompatible + extend ActiveSupport::Concern + + class_methods do + def find_or_create_from_oidc(auth_hash) + provider = auth_hash.provider + subject = auth_hash.uid + email = auth_hash.info.email + email_verified = auth_hash.extra.raw_info.email_verified || false + + return nil unless subject.present? && email.present? + + # First, try to find existing identity by OIDC subject + identity = find_by(oidc_subject: subject, oidc_provider: provider) + + if identity + if email_verified && identity.email_address != email + identity.update(email_address: email) + end + return identity + end + + # Next, try to find by email and link OIDC credentials + identity = find_by(email_address: email) + + if identity + identity.update( + oidc_subject: subject, + oidc_provider: provider + ) + return identity + end + + # Create new identity with OIDC + create( + email_address: email, + oidc_subject: subject, + oidc_provider: provider + ) + end + end +end diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 4e82c0b703..7da53575de 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -3,23 +3,36 @@

Get into Fizzy

- <%= form_with url: session_path, class: "flex flex-column gap-half txt-medium" do |form| %> -
- -
+ <% if oidc_enabled? %> + <%= button_to "Sign in with OIDC", "/auth/oidc", + method: :post, + data: { turbo: false }, + class: "btn btn--primary center txt-medium margin-block-end" %> - <% if Account.accepting_signups? %> -

New here? <%= link_to "Sign up", new_signup_path %> to create an account. Already have an account? Enter your email and we’ll get you signed in.

- <% else %> -

Enter your email and we’ll get you signed in.

+ <% unless oidc_required? %> +

Or sign in with email

<% end %> + <% end %> + + <% unless oidc_required? %> + <%= form_with url: session_path, class: "flex flex-column gap-half txt-medium" do |form| %> +
+ +
- + <% if Account.accepting_signups? %> +

New here? <%= link_to "Sign up", new_signup_path %> to create an account. Already have an account? Enter your email and we'll get you signed in.

+ <% else %> +

Enter your email and we'll get you signed in.

+ <% end %> + + + <% end %> <% end %>
diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb new file mode 100644 index 0000000000..1820beaea7 --- /dev/null +++ b/config/initializers/omniauth.rb @@ -0,0 +1,17 @@ +Rails.application.config.middleware.use OmniAuth::Builder do + if ENV["OIDC_ISSUER"].present? + provider :openid_connect, + name: :oidc, + discovery: true, + issuer: ENV["OIDC_ISSUER"], + client_options: { + identifier: ENV["OIDC_CLIENT_ID"], + secret: ENV["OIDC_CLIENT_SECRET"], + redirect_uri: ENV.fetch("OIDC_REDIRECT_URI") { "#{ENV.fetch('BASE_URL', 'http://localhost:3000')}/auth/oidc/callback" } + }, + scope: ENV.fetch("OIDC_SCOPES", "openid email profile").split + end +end + +OmniAuth.config.allowed_request_methods = [ :get, :post ] +OmniAuth.config.silence_get_warning = true diff --git a/config/routes.rb b/config/routes.rb index 97e34290cb..01e1da4d0e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -153,6 +153,10 @@ end end + get "/auth/oidc/callback", to: "sessions/oidc#create" + post "/auth/oidc/callback", to: "sessions/oidc#create" + get "/auth/failure", to: "sessions/oidc#failure" + get "/signup", to: redirect("/signup/new") resource :signup, only: %i[ new create ] do diff --git a/db/migrate/20260111000000_add_oidc_fields_to_identities.rb b/db/migrate/20260111000000_add_oidc_fields_to_identities.rb new file mode 100644 index 0000000000..5bee91648d --- /dev/null +++ b/db/migrate/20260111000000_add_oidc_fields_to_identities.rb @@ -0,0 +1,10 @@ +class AddOidcFieldsToIdentities < ActiveRecord::Migration[8.2] + def change + add_column :identities, :oidc_subject, :string, limit: 255 + add_column :identities, :oidc_provider, :string, limit: 255 + + add_index :identities, [:oidc_subject, :oidc_provider], + unique: true, + name: "index_identities_on_oidc_subject_and_provider" + end +end diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index b76f998659..40db13cc85 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_12_24_092315) do +ActiveRecord::Schema[8.2].define(version: 2026_01_11_000000) do create_table "accesses", id: :uuid, force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -314,9 +314,12 @@ create_table "identities", id: :uuid, force: :cascade do |t| t.datetime "created_at", null: false t.string "email_address", limit: 255, null: false + t.string "oidc_provider", limit: 255 + t.string "oidc_subject", limit: 255 t.boolean "staff", default: false, null: false t.datetime "updated_at", null: false t.index ["email_address"], name: "index_identities_on_email_address", unique: true + t.index ["oidc_subject", "oidc_provider"], name: "index_identities_on_oidc_subject_and_provider", unique: true end create_table "identity_access_tokens", id: :uuid, force: :cascade do |t| diff --git a/docs/docker-deployment.md b/docs/docker-deployment.md index bc0ff0f044..1c650d1625 100644 --- a/docs/docker-deployment.md +++ b/docs/docker-deployment.md @@ -140,6 +140,16 @@ If you're using a provider other than AWS, you will also need some of the follow - `S3_REQUEST_CHECKSUM_CALCULATION` - `S3_RESPONSE_CHECKSUM_VALIDATION` +#### OIDC / OAuth2 Authentication + +OIDC with a single provider is supported when the following variables are set. + +- `OIDC_ISSUER` +- `OIDC_CLIENT_ID` +- `OIDC_CLIENT_SECRET` + +If you'd like to hide the magic-link login flow, you can set `OIDC_REQUIRED=true`. + #### Multi-tenant mode By default, when you run the Fizzy Docker image you'll be limited to creating a single account (although that account can have as many users as you like). diff --git a/docs/kamal-deployment.md b/docs/kamal-deployment.md index 19aa208c8f..4680322c76 100644 --- a/docs/kamal-deployment.md +++ b/docs/kamal-deployment.md @@ -111,3 +111,14 @@ Optional for S3-compatible endpoints: - `S3_REQUEST_CHECKSUM_CALCULATION` (defaults to `when_supported`) - `S3_RESPONSE_CHECKSUM_VALIDATION` (defaults to `when_supported`) +### Configuring OIDC / OAuth2 authentication + +To enable OAuth2 login, set: + +- `OIDC_ISSUER` +- `OIDC_CLIENT_ID` +- `OIDC_CLIENT_SECRET` + +Optional: + +- `OIDC_REQUIRED` (defaults to `false`; set to `true` to hide magic link login) diff --git a/test/controllers/sessions/oidc_controller_test.rb b/test/controllers/sessions/oidc_controller_test.rb new file mode 100644 index 0000000000..ca2566c8cf --- /dev/null +++ b/test/controllers/sessions/oidc_controller_test.rb @@ -0,0 +1,81 @@ +require "test_helper" + +class Sessions::OidcControllerTest < ActionDispatch::IntegrationTest + setup do + OmniAuth.config.test_mode = true + end + + teardown do + OmniAuth.config.test_mode = false + OmniAuth.config.mock_auth[:oidc] = nil + end + + test "successful authentication creates session" do + sign_in_via_oidc( + uid: "oidc-123", + email: "test@example.com", + email_verified: true + ) + + untenanted do + identity = Identity.find_by(email_address: "test@example.com") + assert identity.present? + assert_equal "oidc-123", identity.oidc_subject + assert_equal "oidc", identity.oidc_provider + end + end + + test "links OIDC to existing identity by email" do + existing = identities(:david) + + sign_in_via_oidc( + uid: "oidc-456", + email: existing.email_address, + email_verified: true + ) + + untenanted do + existing.reload + assert_equal "oidc-456", existing.oidc_subject + assert_equal "oidc", existing.oidc_provider + end + end + + test "handles authentication failure" do + OmniAuth.config.mock_auth[:oidc] = :invalid_credentials + + untenanted do + get "/auth/failure", params: { message: "invalid_credentials" } + + assert_redirected_to new_session_path + assert_not cookies[:session_token].present? + end + end + + test "handles missing auth hash" do + untenanted do + # auth_hash will be nil + post "/auth/oidc/callback" + + assert_redirected_to new_session_path + assert_match flash[:alert], "OIDC authentication failed." + end + end + + test "handles identity creation failure" do + auth_hash = OmniAuth::AuthHash.new( + provider: "oidc", + uid: "" + ) + + OmniAuth.config.mock_auth[:oidc] = auth_hash + + untenanted do + post "/auth/oidc/callback", env: { "omniauth.auth" => auth_hash } + + assert_redirected_to new_session_path + assert_not cookies[:session_token].present? + assert_match flash[:alert], "Error during OIDC authentication." + end + end +end diff --git a/test/fixtures/identities.yml b/test/fixtures/identities.yml index bf5260c035..70b9a468f3 100644 --- a/test/fixtures/identities.yml +++ b/test/fixtures/identities.yml @@ -15,3 +15,5 @@ kevin: mike: email_address: mike@37signals.com + oidc_subject: "google-oauth2|mike123" + oidc_provider: "oidc" diff --git a/test/integration/oidc_authentication_flow_test.rb b/test/integration/oidc_authentication_flow_test.rb new file mode 100644 index 0000000000..fb76ede5d5 --- /dev/null +++ b/test/integration/oidc_authentication_flow_test.rb @@ -0,0 +1,49 @@ +require "test_helper" + +class OidcAuthenticationFlowTest < ActionDispatch::IntegrationTest + setup do + OmniAuth.config.test_mode = true + end + + teardown do + OmniAuth.config.test_mode = false + OmniAuth.config.mock_auth[:oidc] = nil + end + + test "complete OIDC sign in flow for new user" do + sign_in_via_oidc(uid: "cory-123", email: "cory@73signals.com") + + identity = Identity.find_by(email_address: "cory@73signals.com") + assert identity.present? + assert_equal "cory-123", identity.oidc_subject + assert_equal "oidc", identity.oidc_provider + end + + test "OIDC sign in links to existing magic link user" do + existing = identities(:david) + original_count = Identity.count + + sign_in_via_oidc(uid: "link-789", email: existing.email_address) + + # Should link to existing Identity + assert_equal original_count, Identity.count + + existing.reload + assert_equal "link-789", existing.oidc_subject + assert_equal "oidc", existing.oidc_provider + end + + test "OIDC user can sign in multiple times" do + existing = identities(:mike) + + sign_in_via_oidc(uid: "repeat-user", email: existing.email_address) + first_session_token = cookies[:session_token] + + sign_out + + sign_in_via_oidc(uid: "repeat-user", email: existing.email_address) + second_session_token = cookies[:session_token] + + assert_not_equal first_session_token, second_session_token + end +end diff --git a/test/integration/oidc_multi_tenant_test.rb b/test/integration/oidc_multi_tenant_test.rb new file mode 100644 index 0000000000..ee0e18c7c9 --- /dev/null +++ b/test/integration/oidc_multi_tenant_test.rb @@ -0,0 +1,72 @@ +require "test_helper" + +class OidcMultiTenantTest < ActionDispatch::IntegrationTest + setup do + OmniAuth.config.test_mode = true + end + + teardown do + OmniAuth.config.test_mode = false + OmniAuth.config.mock_auth[:oidc] = nil + end + + test "User created through OIDC flow can access their account in multi-tenant mode" do + with_multi_tenant_mode(true) do + sign_in_via_oidc(uid: "multi-tenant-user", email: "multitenant@example.com") + + identity = Identity.find_by(email_address: "multitenant@example.com") + assert identity.present? + assert_equal "multi-tenant-user", identity.oidc_subject + + account = accounts("37s") + + Current.without_account do + identity.join(account) + end + + get "#{account.slug}/cards" + assert_response :success, "User should be able to access their account after OIDC auth" + end + end + + test "OIDC user with multiple accounts can access session menu" do + with_multi_tenant_mode(true) do + sign_in_via_oidc(uid: "multi-account-user", email: "multiaccounts@example.com") + + identity = Identity.find_by(email_address: "multiaccounts@example.com") + + account1 = accounts("37s") + account2 = accounts(:initech) + + Current.without_account do + identity.join(account1) + identity.join(account2) + end + + untenanted do + get session_menu_path + assert_response :success, "User should be able to access session menu" + end + end + end + + test "OIDC links existing magic link user in multi-tenant mode" do + with_multi_tenant_mode(true) do + existing = identities(:david) + account = accounts("37s") + user = users(:david) + + assert_equal account, user.account + assert_equal existing, user.identity + + sign_in_via_oidc(uid: "david-oidc-link", email: existing.email_address) + + existing.reload + assert_equal "david-oidc-link", existing.oidc_subject + assert_equal "oidc", existing.oidc_provider + + get "#{account.slug}/cards" + assert_response :success, "User has access to their original account" + end + end +end diff --git a/test/models/identity/oidc_compatible_test.rb b/test/models/identity/oidc_compatible_test.rb new file mode 100644 index 0000000000..416f2307d5 --- /dev/null +++ b/test/models/identity/oidc_compatible_test.rb @@ -0,0 +1,83 @@ +require "test_helper" + +class Identity::OidcCompatibleTest < ActiveSupport::TestCase + test "find_or_create_from_oidc creates new identity" do + auth_hash = build_oidc_auth_hash( + uid: "new-user-123", + email: "newoidc@example.com" + ) + + identity = Identity.find_or_create_from_oidc(auth_hash) + + assert identity.persisted? + assert_equal "newoidc@example.com", identity.email_address + assert_equal "new-user-123", identity.oidc_subject + assert_equal "oidc", identity.oidc_provider + end + + test "find_or_create_from_oidc links to existing identity by email" do + existing = identities(:david) + + auth_hash = build_oidc_auth_hash( + uid: "link-123", + email: existing.email_address + ) + + identity = Identity.find_or_create_from_oidc(auth_hash) + + assert_equal existing.id, identity.id + assert_equal "link-123", identity.oidc_subject + end + + test "find_or_create_from_oidc finds by oidc_subject" do + existing = identities(:mike) + + auth_hash = build_oidc_auth_hash( + uid: existing.oidc_subject, + email: "different@example.com", # Different email + email_verified: false + ) + + identity = Identity.find_or_create_from_oidc(auth_hash) + + assert_equal existing.id, identity.id + # Email should NOT change if not verified + assert_equal existing.email_address, identity.email_address + end + + test "find_or_create_from_oidc updates email when verified" do + existing = identities(:mike) + new_email = "updated@example.com" + + auth_hash = build_oidc_auth_hash( + uid: existing.oidc_subject, + email: new_email, + email_verified: true + ) + + identity = Identity.find_or_create_from_oidc(auth_hash) + + assert_equal existing.id, identity.id + assert_equal new_email, identity.email_address + end + + test "find_or_create_from_oidc returns nil when missing required fields" do + # Missing uid + auth_hash = build_oidc_auth_hash(uid: nil, email: "test@example.com") + assert_nil Identity.find_or_create_from_oidc(auth_hash) + + # Missing email + auth_hash = build_oidc_auth_hash(uid: "123", email: nil) + assert_nil Identity.find_or_create_from_oidc(auth_hash) + end + + private + def build_oidc_auth_hash(uid:, email:, email_verified: true, name: "Test User") + OmniAuth::AuthHash.new( + provider: "oidc", + uid: uid, + info: { email: email, name: name }, + extra: { raw_info: { email_verified: email_verified } } + ) + end +end diff --git a/test/test_helpers/session_test_helper.rb b/test/test_helpers/session_test_helper.rb index d07d6612a3..91fb66078b 100644 --- a/test/test_helpers/session_test_helper.rb +++ b/test/test_helpers/session_test_helper.rb @@ -28,6 +28,27 @@ def sign_in_as(identity) assert_not_nil cookie, "Expected session_token cookie to be set after sign in" end + def sign_in_via_oidc(uid: "test-oidc-uid", email: "oidc@example.com", email_verified: true) + auth_hash = OmniAuth::AuthHash.new( + provider: "oidc", + uid: uid, + info: { email: email, name: "OIDC User" }, + extra: { raw_info: { email_verified: email_verified } } + ) + + OmniAuth.config.mock_auth[:oidc] = auth_hash + + untenanted do + get new_session_path + assert_response :success, "Can access new session page" + + post "/auth/oidc/callback", env: { "omniauth.auth" => auth_hash } + end + + assert_response :redirect, "OIDC callback should grant access" + assert cookies[:session_token].present?, "Expected session_token cookie to be set after OIDC sign in" + end + def logout_and_sign_in_as(identity) Session.delete_all sign_in_as identity