mirror of
https://github.com/we-promise/sure.git
synced 2026-09-01 21:01:03 +00:00
* feat: add safe admin user removal * fix: address user removal review findings * fix: close remaining user removal review gaps * fix: handle deleted users during session creation * fix: fail closed when session creation fails * fix: reject token issuance for inactive users
842 lines
29 KiB
Ruby
842 lines
29 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 "does not issue a session when user is deleted before row locking" do
|
|
@user.stubs(:with_lock).raises(ActiveRecord::RecordNotFound)
|
|
User.stubs(:authenticate_by).returns(@user)
|
|
|
|
assert_no_difference("Session.count") do
|
|
post sessions_url, params: { email: @user.email, password: user_password_test }
|
|
end
|
|
|
|
assert_redirected_to new_session_url
|
|
assert cookies[:session_token].blank?
|
|
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 "rejects an SSO identity that an administrator permanently removed" do
|
|
oidc_identity = oidc_identities(:bob_google)
|
|
SsoIdentityBlock.block_all!(OidcIdentity.where(id: oidc_identity.id), identity_label: @user.email)
|
|
@user.sessions.destroy_all
|
|
|
|
setup_omniauth_mock(
|
|
provider: oidc_identity.provider,
|
|
uid: oidc_identity.uid,
|
|
email: @user.email,
|
|
name: "Bob Dylan"
|
|
)
|
|
|
|
assert_difference -> { SsoAuditLog.by_event("login_failed").count }, 1 do
|
|
get "/auth/openid_connect/callback"
|
|
end
|
|
|
|
assert_redirected_to new_session_path
|
|
assert_not Session.exists?(user_id: @user.id)
|
|
assert_equal "removed_identity", SsoAuditLog.by_event("login_failed").order(:created_at).last.metadata.fetch("reason")
|
|
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 exchange refuses a code minted before the user was removed" 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 }
|
|
get "/auth/openid_connect/callback"
|
|
code = Rack::Utils.parse_query(URI.parse(@response.redirect_url).query)["code"]
|
|
assert code.present?
|
|
|
|
# The removal lands after the code was minted. Nothing revoked the session
|
|
# this exchange is about to create, because it does not exist yet.
|
|
oidc_identity.user.update_column(:active, false)
|
|
|
|
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_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
|