mirror of
https://github.com/we-promise/sure.git
synced 2026-09-02 13:21:17 +00:00
* 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.
779 lines
26 KiB
Ruby
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
|