Files
sure/test/controllers/api/v1/auth_controller_test.rb
Dream ca3abd5d8b Add Google Sign-In (SSO) support to Flutter mobile app (#860)
* Add mobile SSO support to sessions controller

Add /auth/mobile/:provider route and mobile_sso_start action that
captures device params in session and renders an auto-submitting POST
form to OmniAuth (required by omniauth-rails_csrf_protection).

Modify openid_connect callback to detect mobile_sso session, issue
Doorkeeper tokens via MobileDevice, and redirect to sureapp://oauth/callback
with tokens. Handles MFA users and unlinked accounts with error redirects.

Validates provider name against configured SSO providers and device info
before proceeding.

* Add SSO auth flow to Flutter service and provider

Add buildSsoUrl() and handleSsoCallback() to AuthService for
constructing the mobile SSO URL and parsing tokens from the deep
link callback.

Add startSsoLogin() and handleSsoCallback() to AuthProvider for
launching browser-based SSO and processing the redirect.

* Register deep link listener for SSO callback

Listen for sureapp://oauth/* deep links via app_links package,
handling both cold start (getInitialLink) and warm (uriLinkStream)
scenarios. Routes callbacks to AuthProvider.handleSsoCallback().

* Add Google Sign-In button to Flutter login screen

Add "or" divider and outlined Google Sign-In button that triggers
browser-based SSO via startSsoLogin('google_oauth2').

Add app_links and url_launcher dependencies to pubspec.yaml.

* Fix mobile SSO failure handling to redirect back to app

When OmniAuth fails during mobile SSO flow, redirect to
sureapp://oauth/callback with the error instead of the web login page.
Cleans up mobile_sso session data on failure.

* Address PR review feedback for mobile SSO flow

- Use strong params for device info in mobile_sso_start
- Guard against nil session data in handle_mobile_sso_callback
- Add error handling for AppLinks initialization and stream
- Handle launchUrl false return value in SSO login
- Use user-friendly error messages instead of exposing exceptions
- Reject empty token strings in SSO callback validation

* Consolidate mobile device token logic into MobileDevice model

Extract duplicated device upsert and token issuance code from
AuthController and SessionsController into MobileDevice. Add
CALLBACK_URL constant and URL builder helpers to eliminate repeated
deep-link strings. Add mobile SSO integration tests covering the
full flow, MFA rejection, unlinked accounts, and failure handling.

* Fix CI: resolve Brakeman redirect warnings and rubocop empty line

Move mobile SSO redirect into a private controller method with an
inline string literal so Brakeman can statically verify the target.
Remove unused URL builder helpers from MobileDevice. Fix extra empty
line at end of AuthController class body.

* Use authorization code exchange for mobile SSO and add signup error handling

Replace passing plaintext tokens in mobile SSO redirect URLs with a
one-time authorization code pattern. Tokens are now stored server-side
in Rails.cache (5min TTL) and exchanged via a secure POST to
/api/v1/auth/sso_exchange. Also wraps device/token creation in the
signup action with error handling and sanitizes device error messages.

* Add error handling for login device registration and blank SSO code guard

* Address PR #860 review: fix SSO race condition, add OpenAPI spec, and cleanup

- Fix race condition in sso_exchange by checking Rails.cache.delete return
  value to ensure only one request can consume an authorization code
- Use strong parameters (params.require) for sso_exchange code param
- Move inline HTML from mobile_sso_start to a proper view template
- Clear stale session[:mobile_sso] flag on web login paths to prevent
  abandoned mobile flows from hijacking subsequent web SSO logins
- Add OpenAPI/rswag spec for all auth API endpoints

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix mobile SSO test to match authorization code exchange pattern

The test was asserting tokens directly in the callback URL, but the code
uses an authorization code exchange pattern. Updated to exchange the code
via the sso_exchange API endpoint. Also swaps in a MemoryStore for this
test since the test environment uses null_store which discards writes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Refactor mobile OAuth to use single shared application

Replace per-device Doorkeeper::Application creation with a shared
"Sure Mobile" OAuth app. Device tracking uses mobile_device_id on
access tokens instead of oauth_application_id on mobile_devices.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 00:45:11 +01:00

421 lines
12 KiB
Ruby

require "test_helper"
class Api::V1::AuthControllerTest < ActionDispatch::IntegrationTest
setup do
# Clean up any existing invite codes
InviteCode.destroy_all
@device_info = {
device_id: "test-device-123",
device_name: "Test iPhone",
device_type: "ios",
os_version: "17.0",
app_version: "1.0.0"
}
# Ensure the shared OAuth application exists
@shared_app = 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
test "should signup new user and return OAuth tokens" do
assert_difference("User.count", 1) do
assert_difference("MobileDevice.count", 1) do
assert_no_difference("Doorkeeper::Application.count") do
assert_difference("Doorkeeper::AccessToken.count", 1) do
post "/api/v1/auth/signup", params: {
user: {
email: "newuser@example.com",
password: "SecurePass123!",
first_name: "New",
last_name: "User"
},
device: @device_info
}
end
end
end
end
assert_response :created
response_data = JSON.parse(response.body)
assert response_data["user"]["id"].present?
assert_equal "newuser@example.com", response_data["user"]["email"]
assert_equal "New", response_data["user"]["first_name"]
assert_equal "User", response_data["user"]["last_name"]
# OAuth token assertions
assert response_data["access_token"].present?
assert response_data["refresh_token"].present?
assert_equal "Bearer", response_data["token_type"]
assert_equal 2592000, response_data["expires_in"] # 30 days
assert response_data["created_at"].present?
# Verify the device was created
new_user = User.find(response_data["user"]["id"])
device = new_user.mobile_devices.first
assert_equal @device_info[:device_id], device.device_id
assert_equal @device_info[:device_name], device.device_name
assert_equal @device_info[:device_type], device.device_type
end
test "should not signup without device info" do
assert_no_difference("User.count") do
post "/api/v1/auth/signup", params: {
user: {
email: "newuser@example.com",
password: "SecurePass123!",
first_name: "New",
last_name: "User"
}
}
end
assert_response :bad_request
response_data = JSON.parse(response.body)
assert_equal "Device information is required", response_data["error"]
end
test "should not signup with invalid password" do
assert_no_difference("User.count") do
post "/api/v1/auth/signup", params: {
user: {
email: "newuser@example.com",
password: "weak",
first_name: "New",
last_name: "User"
},
device: @device_info
}
end
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert response_data["errors"].include?("Password must be at least 8 characters")
end
test "should not signup with duplicate email" do
existing_user = users(:family_admin)
assert_no_difference("User.count") do
post "/api/v1/auth/signup", params: {
user: {
email: existing_user.email,
password: "SecurePass123!",
first_name: "Duplicate",
last_name: "User"
},
device: @device_info
}
end
assert_response :unprocessable_entity
end
test "should create user with admin role and family" do
post "/api/v1/auth/signup", params: {
user: {
email: "newuser@example.com",
password: "SecurePass123!",
first_name: "New",
last_name: "User"
},
device: @device_info
}
assert_response :created
response_data = JSON.parse(response.body)
new_user = User.find(response_data["user"]["id"])
assert_equal "admin", new_user.role
assert new_user.family.present?
end
test "should require invite code when enabled" do
# Mock invite code requirement
Api::V1::AuthController.any_instance.stubs(:invite_code_required?).returns(true)
assert_no_difference("User.count") do
post "/api/v1/auth/signup", params: {
user: {
email: "newuser@example.com",
password: "SecurePass123!",
first_name: "New",
last_name: "User"
},
device: @device_info
}
end
assert_response :forbidden
response_data = JSON.parse(response.body)
assert_equal "Invite code is required", response_data["error"]
end
test "should signup with valid invite code when required" do
# Create a valid invite code
invite_code = InviteCode.create!
# Mock invite code requirement
Api::V1::AuthController.any_instance.stubs(:invite_code_required?).returns(true)
assert_difference("User.count", 1) do
assert_difference("InviteCode.count", -1) do
post "/api/v1/auth/signup", params: {
user: {
email: "newuser@example.com",
password: "SecurePass123!",
first_name: "New",
last_name: "User"
},
device: @device_info,
invite_code: invite_code.token
}
end
end
assert_response :created
end
test "should reject invalid invite code" do
# Mock invite code requirement
Api::V1::AuthController.any_instance.stubs(:invite_code_required?).returns(false)
assert_no_difference("User.count") do
post "/api/v1/auth/signup", params: {
user: {
email: "newuser@example.com",
password: "SecurePass123!",
first_name: "New",
last_name: "User"
},
device: @device_info,
invite_code: "invalid_code"
}
end
assert_response :forbidden
response_data = JSON.parse(response.body)
assert_equal "Invalid invite code", response_data["error"]
end
test "should login existing user and return OAuth tokens" do
user = users(:family_admin)
password = user_password_test
# Ensure user has no mobile devices
user.mobile_devices.destroy_all
assert_difference("MobileDevice.count", 1) do
assert_difference("Doorkeeper::AccessToken.count", 1) do
post "/api/v1/auth/login", params: {
email: user.email,
password: password,
device: @device_info
}
end
end
assert_response :success
response_data = JSON.parse(response.body)
assert_equal user.id.to_s, response_data["user"]["id"]
assert_equal user.email, response_data["user"]["email"]
# OAuth token assertions
assert response_data["access_token"].present?
assert response_data["refresh_token"].present?
assert_equal "Bearer", response_data["token_type"]
assert_equal 2592000, response_data["expires_in"] # 30 days
# Verify the device
device = user.mobile_devices.where(device_id: @device_info[:device_id]).first
assert device.present?
assert device.active?
end
test "should require MFA when enabled" do
user = users(:family_admin)
password = user_password_test
# Enable MFA for user
user.setup_mfa!
user.enable_mfa!
post "/api/v1/auth/login", params: {
email: user.email,
password: password,
device: @device_info
}
assert_response :unauthorized
response_data = JSON.parse(response.body)
assert_equal "Two-factor authentication required", response_data["error"]
assert response_data["mfa_required"]
end
test "should login with valid MFA code" do
user = users(:family_admin)
password = user_password_test
# Enable MFA for user
user.setup_mfa!
user.enable_mfa!
totp = ROTP::TOTP.new(user.otp_secret)
assert_difference("Doorkeeper::AccessToken.count", 1) do
post "/api/v1/auth/login", params: {
email: user.email,
password: password,
otp_code: totp.now,
device: @device_info
}
end
assert_response :success
response_data = JSON.parse(response.body)
assert response_data["access_token"].present?
end
test "should revoke existing tokens for same device on login" do
user = users(:family_admin)
password = user_password_test
# Create an existing device and token
device = user.mobile_devices.create!(@device_info)
existing_token = Doorkeeper::AccessToken.create!(
application: @shared_app,
resource_owner_id: user.id,
mobile_device_id: device.id,
expires_in: 30.days.to_i,
scopes: "read_write"
)
assert existing_token.accessible?
post "/api/v1/auth/login", params: {
email: user.email,
password: password,
device: @device_info
}
assert_response :success
# Check that old token was revoked
existing_token.reload
assert existing_token.revoked?
end
test "should not login with invalid password" do
user = users(:family_admin)
assert_no_difference("Doorkeeper::AccessToken.count") do
post "/api/v1/auth/login", params: {
email: user.email,
password: "wrong_password",
device: @device_info
}
end
assert_response :unauthorized
response_data = JSON.parse(response.body)
assert_equal "Invalid email or password", response_data["error"]
end
test "should not login with non-existent email" do
assert_no_difference("Doorkeeper::AccessToken.count") do
post "/api/v1/auth/login", params: {
email: "nonexistent@example.com",
password: user_password_test,
device: @device_info
}
end
assert_response :unauthorized
response_data = JSON.parse(response.body)
assert_equal "Invalid email or password", response_data["error"]
end
test "should not login without device info" do
user = users(:family_admin)
assert_no_difference("Doorkeeper::AccessToken.count") do
post "/api/v1/auth/login", params: {
email: user.email,
password: user_password_test
}
end
assert_response :bad_request
response_data = JSON.parse(response.body)
assert_equal "Device information is required", response_data["error"]
end
test "should refresh access token with valid refresh token" do
user = users(:family_admin)
device = user.mobile_devices.create!(@device_info)
# Create initial token
initial_token = Doorkeeper::AccessToken.create!(
application: @shared_app,
resource_owner_id: user.id,
mobile_device_id: device.id,
expires_in: 30.days.to_i,
scopes: "read_write",
use_refresh_token: true
)
# Wait to ensure different timestamps
sleep 0.1
assert_difference("Doorkeeper::AccessToken.count", 1) do
post "/api/v1/auth/refresh", params: {
refresh_token: initial_token.refresh_token,
device: @device_info
}
end
assert_response :success
response_data = JSON.parse(response.body)
# New token assertions
assert response_data["access_token"].present?
assert response_data["refresh_token"].present?
assert_not_equal initial_token.token, response_data["access_token"]
assert_equal 2592000, response_data["expires_in"]
# Old token should be revoked
initial_token.reload
assert initial_token.revoked?
end
test "should not refresh with invalid refresh token" do
assert_no_difference("Doorkeeper::AccessToken.count") do
post "/api/v1/auth/refresh", params: {
refresh_token: "invalid_token",
device: @device_info
}
end
assert_response :unauthorized
response_data = JSON.parse(response.body)
assert_equal "Invalid refresh token", response_data["error"]
end
test "should not refresh without refresh token" do
post "/api/v1/auth/refresh", params: {
device: @device_info
}
assert_response :bad_request
response_data = JSON.parse(response.body)
assert_equal "Refresh token is required", response_data["error"]
end
end