Files
sure/test/controllers/sessions_controller_test.rb
T
Guillem Arias Fauste 344cf091e1 feat(auth): sign in with a passkey, without a password (#2911)
* feat(auth): sign in with a passkey, without a password

Passkeys could only ever replace the TOTP code: registration required 2FA
to already be on, and the WebAuthn ceremony was reachable only after
User.authenticate_by had succeeded. A registered passkey can now complete
sign-in on its own, from the login page.

The ceremony requests userVerification: "required", so the authenticator
has to confirm the person as well as the device. That makes a lone passkey
two independent factors, the same bar as the password plus TOTP flow it
replaces, which is why this path deliberately skips the TOTP step. A
credential that can only prove presence is rejected here and still works
as a second factor.

Sign-in is usernameless: no email is submitted, because the browser
returns the account handle with the assertion. Nothing on this path can be
probed to learn whether an account exists. Registration now asks for a
discoverable credential with residentKey: "preferred" so the key is
offered by the picker, while authenticators without a free resident-key
slot still register as a second factor.

Where conditional mediation is available, saved passkeys appear in the
email field's autofill menu; everywhere else the button covers it. The
automatic challenge request that conditional mediation makes on every page
load gets its own looser Rack::Attack budget, so ordinary page views can
no longer exhaust the limit that protects the MFA endpoints.

Set AUTH_PASSKEY_LOGIN_ENABLED=false to keep passkeys as a second factor
only. Passkey sign-in follows the same policy as local login, so it stays
closed to regular users when AUTH_LOCAL_LOGIN_ENABLED is false.

* refactor(auth): group the passkey button with the other sign-in methods

It sat directly under the password fields, so the forgot-password link
split it from the identical SSO buttons. It is an alternative to the
credential form rather than part of it.

* fix(auth): close the passkey challenge races and document the upgrade

Three review passes converged on the conditional-mediation flow. The
AbortController was created after `isConditionalMediationAvailable()`
resolved, so a button click or a Turbo disconnect landing in that window
found nothing to abort: the conditional task carried on, re-minted the
challenge, and the assertion the user was about to produce verified
against a challenge the server had already replaced.

It is created before the first await now, and held in a local, because
`abortConditionalMediation()` nulls the field. Checking that one signal
after each await covers both triggers, so no separate connected flag is
needed.

The same symptom had a second cause nobody flagged: `authenticate()` was
not re-entrant. A double-click minted a fresh challenge under an open
authenticator prompt and rejected a perfectly valid passkey, with no race
window at all — and it was live on the MFA step-up too, which shares the
method.

The conditional catch was silent for every failure, including a rejected
assertion the user had deliberately chosen from the autofill menu.
Splitting the try draws the line where it belongs: silence before the
user has been asked anything, feedback once they have picked a passkey.
Filtering on `error.name` cannot draw it, since `fetchOptions` and
`verifyCredential` both raise a plain Error.

Also documents the upgrade: passwordless is on by default and applies to
already-registered credentials, so a passkey added purely as a second
factor can now sign its owner in alone. Nothing in the schema marks a
credential discoverable — the authenticator decides — and the opt-out is
instance-wide.

The invitation test is a guard, not coverage for this change. The pending
token lives in the Rack session and `complete_sign_in` reads it right
after creating the session, so a `reset_session` dropped in between
strands the invitee in their own family, silently and with every existing
test still green.

* fix(auth): cancel the in-flight conditional options request

Aborting the conditional flow did not cancel its options request, because
`fetchOptions` never received the signal. A click landing while that POST
was in flight left it to finish, and its response could apply last.

The challenge rides in the session cookie, so "the server wrote it" only
counts if the Set-Cookie reaches the browser. Threading the signal means
an aborted request's response is discarded, which closes the window
without needing the server to hold two challenges open.

Also drops the absolute claim about which existing credentials gain
passwordless sign-in. `residentKey: "preferred"` is a request an
authenticator may decline, and nothing records what it decided, so the
honest statement is that password managers and platform authenticators
generally store discoverable credentials rather than always.
2026-08-12 20:35:13 +02:00

779 lines
26 KiB
Ruby

require "test_helper"
class SessionsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
# Ensure the shared OAuth application exists
Doorkeeper::Application.find_or_create_by!(name: "Sure Mobile") do |app|
app.redirect_uri = "sureapp://oauth/callback"
app.scopes = "read_write"
app.confidential = false
end
# Clear the memoized class variable so it picks up the test record
MobileDevice.instance_variable_set(:@shared_oauth_application, nil)
end
teardown do
# Clear OmniAuth mock auth after each test
OmniAuth.config.mock_auth[:openid_connect] = nil
end
def setup_omniauth_mock(provider:, uid:, email:, name:, first_name: nil, last_name: nil)
OmniAuth.config.mock_auth[:openid_connect] = OmniAuth::AuthHash.new({
provider: provider,
uid: uid,
info: {
email: email,
name: name,
first_name: first_name,
last_name: last_name
}.compact
})
end
test "login page" do
get new_session_url
assert_response :success
end
test "login page offers passkey sign-in" do
AuthConfig.stubs(:passkey_login_enabled?).returns(true)
get new_session_url
assert_response :success
assert_select "button", text: I18n.t("sessions.new.passkey_button")
assert_select "[data-webauthn-authentication-conditional-value='true']"
assert_select "[data-webauthn-authentication-unsupported-message-value=?]", I18n.t("sessions.new.passkey_unsupported")
assert_select "[data-webauthn-authentication-error-fallback-value=?]", I18n.t("passkey_sessions.invalid_credential")
# Browsers only surface passkeys from autofill when the field carries the
# "webauthn" token.
assert_select "input[type=email][autocomplete='username webauthn']"
end
test "login page hides passkey sign-in when disabled" do
AuthConfig.stubs(:passkey_login_enabled?).returns(false)
get new_session_url
assert_response :success
assert_select "button", text: I18n.t("sessions.new.passkey_button"), count: 0
assert_select "input[type=email][autocomplete='email']"
end
test "can sign in" do
sign_in @user
assert_redirected_to root_url
assert Session.exists?(user_id: @user.id)
get root_url
assert_response :success
end
test "fails to sign in with bad password" do
post sessions_url, params: { email: @user.email, password: "bad" }
assert_response :unprocessable_entity
assert_equal "Invalid email or password.", flash[:alert]
end
test "redirects when local login is disabled" do
AuthConfig.stubs(:local_login_enabled?).returns(false)
AuthConfig.stubs(:local_admin_override_enabled?).returns(false)
post sessions_url, params: { email: @user.email, password: user_password_test }
assert_redirected_to new_session_path
assert_equal "Local password login is disabled. Please use single sign-on.", flash[:alert]
end
test "allows super admin local login when override enabled" do
super_admin = users(:sure_support_staff)
AuthConfig.stubs(:local_login_enabled?).returns(false)
AuthConfig.stubs(:local_admin_override_enabled?).returns(true)
post sessions_url, params: { email: super_admin.email, password: user_password_test }
assert_redirected_to root_path
assert Session.exists?(user_id: super_admin.id)
end
test "shows invalid credentials for super admin when override enabled but password is wrong" do
super_admin = users(:sure_support_staff)
AuthConfig.stubs(:local_login_enabled?).returns(false)
AuthConfig.stubs(:local_admin_override_enabled?).returns(true)
post sessions_url, params: { email: super_admin.email, password: "bad" }
assert_response :unprocessable_entity
assert_equal "Invalid email or password.", flash[:alert]
end
test "blocks non-super-admin local login when override enabled" do
AuthConfig.stubs(:local_login_enabled?).returns(false)
AuthConfig.stubs(:local_admin_override_enabled?).returns(true)
post sessions_url, params: { email: @user.email, password: user_password_test }
assert_redirected_to new_session_path
assert_equal "Local password login is disabled. Please use single sign-on.", flash[:alert]
end
test "renders multiple SSO provider buttons" do
AuthConfig.stubs(:local_login_form_visible?).returns(true)
AuthConfig.stubs(:password_features_enabled?).returns(true)
AuthConfig.stubs(:sso_providers).returns([
{ id: "oidc", strategy: "openid_connect", name: "openid_connect", label: "Sign in with Keycloak", icon: "key" },
{ id: "google", strategy: "google_oauth2", name: "google_oauth2", label: "Sign in with Google", icon: "google" }
])
get new_session_path
assert_response :success
# Generic OIDC button
assert_match %r{/auth/openid_connect}, @response.body
assert_match /Sign in with Keycloak/, @response.body
# Google-branded button — DS outline button carrying Google's official
# multi-color "G" mark (one of its brand hexes proves the inline SVG rendered).
assert_match %r{/auth/google_oauth2}, @response.body
assert_match /#4285F4/i, @response.body
assert_match /Sign in with Google/, @response.body
end
test "can sign out" do
sign_in @user
session_record = @user.sessions.last
delete session_url(session_record)
assert_redirected_to new_session_path
assert_equal "You have signed out successfully.", flash[:notice]
# Verify session is destroyed
assert_nil Session.find_by(id: session_record.id)
end
test "redirects to MFA verification when MFA enabled" do
@user.setup_mfa!
@user.enable_mfa!
@user.sessions.destroy_all # Clean up any existing sessions
post sessions_path, params: { email: @user.email, password: user_password_test }
assert_redirected_to verify_mfa_path
assert_equal @user.id, session[:mfa_user_id]
assert_not Session.exists?(user_id: @user.id)
end
# OIDC Authentication Tests
test "authenticates with existing OIDC identity" do
oidc_identity = oidc_identities(:bob_google)
# Set up OmniAuth mock
setup_omniauth_mock(
provider: oidc_identity.provider,
uid: oidc_identity.uid,
email: @user.email,
name: "Bob Dylan",
first_name: "Bob",
last_name: "Dylan"
)
get "/auth/openid_connect/callback"
assert_redirected_to root_path
assert Session.exists?(user_id: @user.id)
end
test "redirects to MFA when user has MFA and uses OIDC" do
@user.setup_mfa!
@user.enable_mfa!
@user.sessions.destroy_all
oidc_identity = oidc_identities(:bob_google)
# Set up OmniAuth mock
setup_omniauth_mock(
provider: oidc_identity.provider,
uid: oidc_identity.uid,
email: @user.email,
name: "Bob Dylan"
)
get "/auth/openid_connect/callback"
assert_redirected_to verify_mfa_path
assert_equal @user.id, session[:mfa_user_id]
assert_not Session.exists?(user_id: @user.id)
end
test "redirects to account linking when no OIDC identity exists" do
# Use an existing user's email who doesn't have OIDC linked yet
user_without_oidc = users(:new_email)
# Set up OmniAuth mock
setup_omniauth_mock(
provider: "openid_connect",
uid: "new-uid-99999",
email: user_without_oidc.email,
name: "New User"
)
get "/auth/openid_connect/callback"
assert_redirected_to link_oidc_account_path
# Follow redirect to verify session data is accessible
follow_redirect!
assert_response :success
# Verify the session has the pending auth data by checking page content
assert_select "p", text: /To link your openid_connect account/
end
test "handles missing auth data gracefully" do
# Set up mock with invalid/incomplete auth to simulate failure
OmniAuth.config.mock_auth[:openid_connect] = OmniAuth::AuthHash.new({
provider: nil,
uid: nil
})
get "/auth/openid_connect/callback"
assert_redirected_to new_session_path
assert_equal "Could not authenticate via OpenID Connect.", flash[:alert]
end
# ── Mobile SSO: mobile_sso_start ──
test "mobile_sso_start renders auto-submit form for valid provider" do
Rails.configuration.x.auth.stubs(:sso_providers).returns([
{ name: "google_oauth2", strategy: "google_oauth2", label: "Google" }
])
get "/auth/mobile/google_oauth2", params: {
device_id: "test-device-123",
device_name: "Pixel 8",
device_type: "android",
os_version: "14",
app_version: "1.0.0"
}
assert_response :success
assert_match %r{action="/auth/google_oauth2"}, @response.body
assert_match %r{method="post"}, @response.body
assert_match /authenticity_token/, @response.body
end
test "mobile_sso_start stores device info in session" do
Rails.configuration.x.auth.stubs(:sso_providers).returns([
{ name: "google_oauth2", strategy: "google_oauth2", label: "Google" }
])
get "/auth/mobile/google_oauth2", params: {
device_id: "test-device-123",
device_name: "Pixel 8",
device_type: "android",
os_version: "14",
app_version: "1.0.0"
}
assert_equal "test-device-123", session[:mobile_sso][:device_id]
assert_equal "Pixel 8", session[:mobile_sso][:device_name]
assert_equal "android", session[:mobile_sso][:device_type]
assert_equal "14", session[:mobile_sso][:os_version]
assert_equal "1.0.0", session[:mobile_sso][:app_version]
end
test "mobile_sso_start redirects with error for invalid provider" do
Rails.configuration.x.auth.stubs(:sso_providers).returns([
{ name: "google_oauth2", strategy: "google_oauth2", label: "Google" }
])
get "/auth/mobile/unknown_provider", params: {
device_id: "test-device-123",
device_name: "Pixel 8",
device_type: "android"
}
assert_redirected_to %r{\Asureapp://oauth/callback\?error=invalid_provider}
end
test "mobile_sso_start redirects with error when device_id is missing" do
Rails.configuration.x.auth.stubs(:sso_providers).returns([
{ name: "google_oauth2", strategy: "google_oauth2", label: "Google" }
])
get "/auth/mobile/google_oauth2", params: {
device_name: "Pixel 8",
device_type: "android"
}
assert_redirected_to %r{\Asureapp://oauth/callback\?error=missing_device_info}
end
test "mobile_sso_start redirects with error when device_name is missing" do
Rails.configuration.x.auth.stubs(:sso_providers).returns([
{ name: "google_oauth2", strategy: "google_oauth2", label: "Google" }
])
get "/auth/mobile/google_oauth2", params: {
device_id: "test-device-123",
device_type: "android"
}
assert_redirected_to %r{\Asureapp://oauth/callback\?error=missing_device_info}
end
test "mobile_sso_start redirects with error when device_type is missing" do
Rails.configuration.x.auth.stubs(:sso_providers).returns([
{ name: "google_oauth2", strategy: "google_oauth2", label: "Google" }
])
get "/auth/mobile/google_oauth2", params: {
device_id: "test-device-123",
device_name: "Pixel 8"
}
assert_redirected_to %r{\Asureapp://oauth/callback\?error=missing_device_info}
end
# ── Mobile SSO: openid_connect callback with mobile_sso session ──
test "mobile SSO issues Doorkeeper tokens for linked user" do
# Test environment uses null_store; swap in a memory store so the
# authorization code round-trip (write in controller, read in sso_exchange) works.
original_cache = Rails.cache
Rails.cache = ActiveSupport::Cache::MemoryStore.new
oidc_identity = oidc_identities(:bob_google)
setup_omniauth_mock(
provider: oidc_identity.provider,
uid: oidc_identity.uid,
email: @user.email,
name: "Bob Dylan",
first_name: "Bob",
last_name: "Dylan"
)
# Simulate mobile_sso session data (would be set by mobile_sso_start)
post sessions_path, params: { email: @user.email, password: user_password_test }
delete session_url(@user.sessions.last)
# We need to set the session directly via a custom approach:
# Hit mobile_sso_start first, then trigger the OIDC callback
Rails.configuration.x.auth.stubs(:sso_providers).returns([
{ name: "openid_connect", strategy: "openid_connect", label: "Google" }
])
get "/auth/mobile/openid_connect", params: {
device_id: "flutter-device-001",
device_name: "Pixel 8",
device_type: "android",
os_version: "14",
app_version: "1.0.0"
}
assert_response :success
# Now trigger the OIDC callback — session[:mobile_sso] is set from the previous request
get "/auth/openid_connect/callback"
assert_response :redirect
redirect_url = @response.redirect_url
assert redirect_url.start_with?("sureapp://oauth/callback?"), "Expected redirect to sureapp:// but got #{redirect_url}"
uri = URI.parse(redirect_url)
callback_params = Rack::Utils.parse_query(uri.query)
assert callback_params["code"].present?, "Expected authorization code in callback"
# Exchange the authorization code for tokens via the API (as the mobile app would)
post "/api/v1/auth/sso_exchange", params: { code: callback_params["code"] }, as: :json
assert_response :success
token_data = JSON.parse(@response.body)
assert token_data["access_token"].present?, "Expected access_token in response"
assert token_data["refresh_token"].present?, "Expected refresh_token in response"
assert_equal "Bearer", token_data["token_type"]
assert_equal 30.days.to_i, token_data["expires_in"]
assert_equal @user.id, token_data["user"]["id"]
assert_equal @user.email, token_data["user"]["email"]
assert_equal @user.first_name, token_data["user"]["first_name"]
assert_equal @user.last_name, token_data["user"]["last_name"]
ensure
Rails.cache = original_cache
end
test "mobile SSO creates a MobileDevice record" do
oidc_identity = oidc_identities(:bob_google)
setup_omniauth_mock(
provider: oidc_identity.provider,
uid: oidc_identity.uid,
email: @user.email,
name: "Bob Dylan"
)
Rails.configuration.x.auth.stubs(:sso_providers).returns([
{ name: "openid_connect", strategy: "openid_connect", label: "Google" }
])
get "/auth/mobile/openid_connect", params: {
device_id: "flutter-device-002",
device_name: "iPhone 15",
device_type: "ios",
os_version: "17.2",
app_version: "1.0.0"
}
assert_difference "MobileDevice.count", 1 do
get "/auth/openid_connect/callback"
end
device = @user.mobile_devices.find_by(device_id: "flutter-device-002")
assert device.present?, "Expected MobileDevice to be created"
assert_equal "iPhone 15", device.device_name
assert_equal "ios", device.device_type
assert_equal "17.2", device.os_version
assert_equal "1.0.0", device.app_version
end
test "mobile SSO uses the shared OAuth application" do
oidc_identity = oidc_identities(:bob_google)
setup_omniauth_mock(
provider: oidc_identity.provider,
uid: oidc_identity.uid,
email: @user.email,
name: "Bob Dylan"
)
Rails.configuration.x.auth.stubs(:sso_providers).returns([
{ name: "openid_connect", strategy: "openid_connect", label: "Google" }
])
get "/auth/mobile/openid_connect", params: {
device_id: "flutter-device-003",
device_name: "Pixel 8",
device_type: "android"
}
assert_no_difference "Doorkeeper::Application.count" do
get "/auth/openid_connect/callback"
end
device = @user.mobile_devices.find_by(device_id: "flutter-device-003")
assert device.active_tokens.any?, "Expected device to have active tokens via shared app"
end
test "mobile SSO revokes previous tokens for existing device" do
oidc_identity = oidc_identities(:bob_google)
setup_omniauth_mock(
provider: oidc_identity.provider,
uid: oidc_identity.uid,
email: @user.email,
name: "Bob Dylan"
)
Rails.configuration.x.auth.stubs(:sso_providers).returns([
{ name: "openid_connect", strategy: "openid_connect", label: "Google" }
])
# First login — creates device and token
get "/auth/mobile/openid_connect", params: {
device_id: "flutter-device-004",
device_name: "Pixel 8",
device_type: "android"
}
get "/auth/openid_connect/callback"
device = @user.mobile_devices.find_by(device_id: "flutter-device-004")
first_token = Doorkeeper::AccessToken.where(
mobile_device_id: device.id,
resource_owner_id: @user.id,
revoked_at: nil
).last
assert first_token.present?, "Expected first access token"
# Second login with same device — should revoke old token
setup_omniauth_mock(
provider: oidc_identity.provider,
uid: oidc_identity.uid,
email: @user.email,
name: "Bob Dylan"
)
get "/auth/mobile/openid_connect", params: {
device_id: "flutter-device-004",
device_name: "Pixel 8",
device_type: "android"
}
get "/auth/openid_connect/callback"
first_token.reload
assert first_token.revoked_at.present?, "Expected first token to be revoked"
end
test "mobile SSO redirects MFA user with error" do
@user.setup_mfa!
@user.enable_mfa!
oidc_identity = oidc_identities(:bob_google)
setup_omniauth_mock(
provider: oidc_identity.provider,
uid: oidc_identity.uid,
email: @user.email,
name: "Bob Dylan"
)
Rails.configuration.x.auth.stubs(:sso_providers).returns([
{ name: "openid_connect", strategy: "openid_connect", label: "Google" }
])
get "/auth/mobile/openid_connect", params: {
device_id: "flutter-device-005",
device_name: "Pixel 8",
device_type: "android"
}
get "/auth/openid_connect/callback"
assert_response :redirect
redirect_url = @response.redirect_url
assert redirect_url.start_with?("sureapp://oauth/callback?"), "Expected redirect to sureapp://"
params = Rack::Utils.parse_query(URI.parse(redirect_url).query)
assert_equal "mfa_not_supported", params["error"]
assert_nil session[:mobile_sso], "Expected mobile_sso session to be cleared"
end
test "mobile SSO redirects with error when OIDC identity not linked" do
user_without_oidc = users(:new_email)
setup_omniauth_mock(
provider: "openid_connect",
uid: "unlinked-uid-99999",
email: user_without_oidc.email,
name: "New User"
)
Rails.configuration.x.auth.stubs(:sso_providers).returns([
{ name: "openid_connect", strategy: "openid_connect", label: "Google" }
])
# Use a real cache store so we can verify the cache entry written by handle_mobile_sso_onboarding
original_cache = Rails.cache
Rails.cache = ActiveSupport::Cache::MemoryStore.new
begin
get "/auth/mobile/openid_connect", params: {
device_id: "flutter-device-006",
device_name: "Pixel 8",
device_type: "android"
}
get "/auth/openid_connect/callback"
assert_response :redirect
redirect_url = @response.redirect_url
assert redirect_url.start_with?("sureapp://oauth/callback?"), "Expected redirect to sureapp://"
params = Rack::Utils.parse_query(URI.parse(redirect_url).query)
assert_equal "account_not_linked", params["status"]
assert params["linking_code"].present?, "Expected linking_code in redirect params"
assert_nil session[:mobile_sso], "Expected mobile_sso session to be cleared"
# Verify the cache entry written by handle_mobile_sso_onboarding
cached = Rails.cache.read("mobile_sso_link:#{params['linking_code']}")
assert cached.present?, "Expected cache entry for mobile_sso_link:#{params['linking_code']}"
assert_equal "openid_connect", cached[:provider]
assert_equal "unlinked-uid-99999", cached[:uid]
assert_equal user_without_oidc.email, cached[:email]
assert_equal "New User", cached[:name]
assert cached.key?(:device_info), "Expected device_info in cached payload"
assert cached.key?(:allow_account_creation), "Expected allow_account_creation in cached payload"
ensure
Rails.cache = original_cache
end
end
test "mobile SSO does not create a web session" do
oidc_identity = oidc_identities(:bob_google)
setup_omniauth_mock(
provider: oidc_identity.provider,
uid: oidc_identity.uid,
email: @user.email,
name: "Bob Dylan"
)
Rails.configuration.x.auth.stubs(:sso_providers).returns([
{ name: "openid_connect", strategy: "openid_connect", label: "Google" }
])
@user.sessions.destroy_all
get "/auth/mobile/openid_connect", params: {
device_id: "flutter-device-007",
device_name: "Pixel 8",
device_type: "android"
}
assert_no_difference "Session.count" do
get "/auth/openid_connect/callback"
end
end
# ── Mobile SSO: failure action ──
test "failure redirects mobile SSO to app with error" do
# Simulate mobile_sso session being set
Rails.configuration.x.auth.stubs(:sso_providers).returns([
{ name: "google_oauth2", strategy: "google_oauth2", label: "Google" }
])
get "/auth/mobile/google_oauth2", params: {
device_id: "flutter-device-008",
device_name: "Pixel 8",
device_type: "android"
}
# Now simulate a failure callback
get "/auth/failure", params: { message: "sso_failed", strategy: "google_oauth2" }
assert_response :redirect
redirect_url = @response.redirect_url
assert redirect_url.start_with?("sureapp://oauth/callback?"), "Expected redirect to sureapp://"
params = Rack::Utils.parse_query(URI.parse(redirect_url).query)
assert_equal "sso_failed", params["error"]
assert_nil session[:mobile_sso], "Expected mobile_sso session to be cleared"
end
test "failure without mobile SSO session redirects to web login" do
get "/auth/failure", params: { message: "sso_failed", strategy: "google_oauth2" }
assert_redirected_to new_session_path
end
test "failure sanitizes unknown error reasons" do
Rails.configuration.x.auth.stubs(:sso_providers).returns([
{ name: "google_oauth2", strategy: "google_oauth2", label: "Google" }
])
get "/auth/mobile/google_oauth2", params: {
device_id: "flutter-device-009",
device_name: "Pixel 8",
device_type: "android"
}
get "/auth/failure", params: { message: "xss_attempt<script>", strategy: "google_oauth2" }
redirect_url = @response.redirect_url
params = Rack::Utils.parse_query(URI.parse(redirect_url).query)
assert_equal "sso_failed", params["error"], "Unknown reason should be sanitized to sso_failed"
end
test "prevents account takeover via email matching" do
# Clean up any existing sessions
@user.sessions.destroy_all
# This test verifies that we can't authenticate just by matching email
# The user must have an existing OIDC identity with matching provider + uid
# Set up OmniAuth mock
setup_omniauth_mock(
provider: "openid_connect",
uid: "attacker-uid-12345", # Different UID than user's OIDC identity
email: @user.email, # Same email as existing user
name: "Attacker"
)
get "/auth/openid_connect/callback"
# Should NOT create a session, should redirect to account linking
assert_redirected_to link_oidc_account_path
assert_not Session.exists?(user_id: @user.id), "Session should not be created for unlinked OIDC identity"
# Follow redirect to verify we're on the link page (not logged in)
follow_redirect!
assert_response :success
end
# ── Desktop SSO: browser handoff + PKCE code exchange ──
test "desktop SSO exchanges a PKCE-bound code for a web session and is single-use" do
original_cache = Rails.cache
Rails.cache = ActiveSupport::Cache::MemoryStore.new
verifier = SecureRandom.hex(32)
challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
oidc_identity = oidc_identities(:bob_google)
Rails.configuration.x.auth.stubs(:sso_providers).returns([
{ name: "openid_connect", strategy: "openid_connect", label: "Google" }
])
setup_omniauth_mock(provider: oidc_identity.provider, uid: oidc_identity.uid, email: @user.email, name: "Bob Dylan")
get "/auth/desktop/openid_connect", params: { code_challenge: challenge }
assert_response :success
get "/auth/openid_connect/callback"
assert_response :redirect
redirect_url = @response.redirect_url
assert redirect_url.start_with?("sure://sso/callback?code="), "Expected sure://sso/callback but got #{redirect_url}"
code = Rack::Utils.parse_query(URI.parse(redirect_url).query)["code"]
assert code.present?
assert_difference -> { oidc_identity.user.sessions.count }, 1 do
post desktop_sso_exchange_path, params: { code: code, code_verifier: verifier }
end
assert_redirected_to root_path
# Single-use: the same code cannot be redeemed again.
assert_no_difference -> { oidc_identity.user.sessions.count } do
post desktop_sso_exchange_path, params: { code: code, code_verifier: verifier }
end
assert_redirected_to new_session_path
ensure
Rails.cache = original_cache
end
test "desktop SSO exchange rejects a wrong PKCE verifier" do
original_cache = Rails.cache
Rails.cache = ActiveSupport::Cache::MemoryStore.new
challenge = Base64.urlsafe_encode64(Digest::SHA256.digest("the-real-verifier"), padding: false)
oidc_identity = oidc_identities(:bob_google)
Rails.configuration.x.auth.stubs(:sso_providers).returns([
{ name: "openid_connect", strategy: "openid_connect", label: "Google" }
])
setup_omniauth_mock(provider: oidc_identity.provider, uid: oidc_identity.uid, email: @user.email, name: "Bob Dylan")
get "/auth/desktop/openid_connect", params: { code_challenge: challenge }
get "/auth/openid_connect/callback"
code = Rack::Utils.parse_query(URI.parse(@response.redirect_url).query)["code"]
assert_no_difference -> { oidc_identity.user.sessions.count } do
post desktop_sso_exchange_path, params: { code: code, code_verifier: "an-attacker-guess" }
end
assert_redirected_to new_session_path
ensure
Rails.cache = original_cache
end
test "desktop_sso_start rejects a missing PKCE code_challenge" do
Rails.configuration.x.auth.stubs(:sso_providers).returns([
{ name: "openid_connect", strategy: "openid_connect", label: "Google" }
])
get "/auth/desktop/openid_connect"
assert_redirected_to new_session_path
end
end