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