diff --git a/app/controllers/api/v1/auth_controller.rb b/app/controllers/api/v1/auth_controller.rb
index a12e290ad..590b332ee 100644
--- a/app/controllers/api/v1/auth_controller.rb
+++ b/app/controllers/api/v1/auth_controller.rb
@@ -46,8 +46,13 @@ module Api
InviteCode.claim!(params[:invite_code]) if params[:invite_code].present?
# Create device and OAuth token
- device = create_or_update_device(user)
- token_response = create_oauth_token_for_device(user, device)
+ begin
+ device = MobileDevice.upsert_device!(user, device_params)
+ token_response = device.issue_token!
+ rescue ActiveRecord::RecordInvalid => e
+ render json: { error: "Failed to register device: #{e.message}" }, status: :unprocessable_entity
+ return
+ end
render json: token_response.merge(
user: {
@@ -84,8 +89,13 @@ module Api
end
# Create device and OAuth token
- device = create_or_update_device(user)
- token_response = create_oauth_token_for_device(user, device)
+ begin
+ device = MobileDevice.upsert_device!(user, device_params)
+ token_response = device.issue_token!
+ rescue ActiveRecord::RecordInvalid => e
+ render json: { error: "Failed to register device: #{e.message}" }, status: :unprocessable_entity
+ return
+ end
render json: token_response.merge(
user: {
@@ -100,6 +110,44 @@ module Api
end
end
+ def sso_exchange
+ code = sso_exchange_params
+
+ if code.blank?
+ render json: { error: "invalid_or_expired_code", message: "Authorization code is required" }, status: :unauthorized
+ return
+ end
+
+ cache_key = "mobile_sso:#{code}"
+ cached = Rails.cache.read(cache_key)
+
+ unless cached.present?
+ render json: { error: "invalid_or_expired_code", message: "Authorization code is invalid or expired" }, status: :unauthorized
+ return
+ end
+
+ # Atomic delete — only the request that successfully deletes the key may proceed.
+ # This prevents a race where two concurrent requests both read the same code.
+ unless Rails.cache.delete(cache_key)
+ render json: { error: "invalid_or_expired_code", message: "Authorization code is invalid or expired" }, status: :unauthorized
+ return
+ end
+
+ render json: {
+ access_token: cached[:access_token],
+ refresh_token: cached[:refresh_token],
+ token_type: cached[:token_type],
+ expires_in: cached[:expires_in],
+ created_at: cached[:created_at],
+ user: {
+ id: cached[:user_id],
+ email: cached[:user_email],
+ first_name: cached[:user_first_name],
+ last_name: cached[:user_last_name]
+ }
+ }
+ end
+
def refresh
# Find the refresh token
refresh_token = params[:refresh_token]
@@ -121,6 +169,7 @@ module Api
new_token = Doorkeeper::AccessToken.create!(
application: access_token.application,
resource_owner_id: access_token.resource_owner_id,
+ mobile_device_id: access_token.mobile_device_id,
expires_in: 30.days.to_i,
scopes: access_token.scopes,
use_refresh_token: true
@@ -173,38 +222,12 @@ module Api
required_fields.all? { |field| device[field].present? }
end
- def create_or_update_device(user)
- # Handle both string and symbol keys
- device_data = params[:device].permit(:device_id, :device_name, :device_type, :os_version, :app_version)
-
- device = user.mobile_devices.find_or_initialize_by(device_id: device_data[:device_id])
- device.update!(device_data.merge(last_seen_at: Time.current))
- device
+ def device_params
+ params.require(:device).permit(:device_id, :device_name, :device_type, :os_version, :app_version)
end
- def create_oauth_token_for_device(user, device)
- # Create OAuth application for this device if needed
- oauth_app = device.create_oauth_application!
-
- # Revoke any existing tokens for this device
- device.revoke_all_tokens!
-
- # Create new access token with 30-day expiration
- access_token = Doorkeeper::AccessToken.create!(
- application: oauth_app,
- resource_owner_id: user.id,
- expires_in: 30.days.to_i,
- scopes: "read_write",
- use_refresh_token: true
- )
-
- {
- access_token: access_token.plaintext_token,
- refresh_token: access_token.plaintext_refresh_token,
- token_type: "Bearer",
- expires_in: access_token.expires_in,
- created_at: access_token.created_at.to_i
- }
+ def sso_exchange_params
+ params.require(:code)
end
end
end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 2d9007668..30703f515 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -1,6 +1,6 @@
class SessionsController < ApplicationController
before_action :set_session, only: :destroy
- skip_authentication only: %i[index new create openid_connect failure post_logout]
+ skip_authentication only: %i[index new create openid_connect failure post_logout mobile_sso_start]
layout "auth"
@@ -10,6 +10,9 @@ class SessionsController < ApplicationController
end
def new
+ # Clear any stale mobile SSO session flag from an abandoned mobile flow
+ session.delete(:mobile_sso)
+
begin
demo = Rails.application.config_for(:demo)
@prefill_demo_credentials = demo_host_match?(demo)
@@ -29,6 +32,9 @@ class SessionsController < ApplicationController
end
def create
+ # Clear any stale mobile SSO session flag from an abandoned mobile flow
+ session.delete(:mobile_sso)
+
user = nil
if AuthConfig.local_login_enabled?
@@ -104,6 +110,34 @@ class SessionsController < ApplicationController
redirect_to new_session_path, notice: t(".logout_successful")
end
+ def mobile_sso_start
+ provider = params[:provider].to_s
+ configured_providers = Rails.configuration.x.auth.sso_providers.map { |p| p[:name].to_s }
+
+ unless configured_providers.include?(provider)
+ mobile_sso_redirect(error: "invalid_provider", message: "SSO provider not configured")
+ return
+ end
+
+ device_params = params.permit(:device_id, :device_name, :device_type, :os_version, :app_version)
+ unless device_params[:device_id].present? && device_params[:device_name].present? && device_params[:device_type].present?
+ mobile_sso_redirect(error: "missing_device_info", message: "Device information is required")
+ return
+ end
+
+ session[:mobile_sso] = {
+ device_id: device_params[:device_id],
+ device_name: device_params[:device_name],
+ device_type: device_params[:device_type],
+ os_version: device_params[:os_version],
+ app_version: device_params[:app_version]
+ }
+
+ # Render auto-submitting form to POST to OmniAuth (required by omniauth-rails_csrf_protection)
+ @provider = provider
+ render layout: false
+ end
+
def openid_connect
auth = request.env["omniauth.auth"]
@@ -122,13 +156,24 @@ class SessionsController < ApplicationController
oidc_identity.record_authentication!
oidc_identity.sync_user_attributes!(auth)
+ # Log successful SSO login
+ SsoAuditLog.log_login!(user: user, provider: auth.provider, request: request)
+
+ # Mobile SSO: issue Doorkeeper tokens and redirect to app
+ if session[:mobile_sso].present?
+ if user.otp_required?
+ session.delete(:mobile_sso)
+ mobile_sso_redirect(error: "mfa_not_supported", message: "MFA users should sign in with email and password")
+ else
+ handle_mobile_sso_callback(user)
+ end
+ return
+ end
+
# Store id_token and provider for RP-initiated logout
session[:id_token_hint] = auth.credentials&.id_token if auth.credentials&.id_token
session[:sso_login_provider] = auth.provider
- # Log successful SSO login
- SsoAuditLog.log_login!(user: user, provider: auth.provider, request: request)
-
# MFA check: If user has MFA enabled, require verification
if user.otp_required?
session[:mfa_user_id] = user.id
@@ -138,6 +183,13 @@ class SessionsController < ApplicationController
redirect_to root_path
end
else
+ # Mobile SSO with no linked identity - redirect back with error
+ if session[:mobile_sso].present?
+ session.delete(:mobile_sso)
+ mobile_sso_redirect(error: "account_not_linked", message: "Please link your Google account from the web app first")
+ return
+ end
+
# No existing OIDC identity - need to link to account
# Store auth data in session and redirect to linking page
session[:pending_oidc_auth] = {
@@ -164,6 +216,13 @@ class SessionsController < ApplicationController
reason: sanitized_reason
)
+ # Mobile SSO: redirect back to the app with error instead of web login page
+ if session[:mobile_sso].present?
+ session.delete(:mobile_sso)
+ mobile_sso_redirect(error: sanitized_reason, message: "SSO authentication failed")
+ return
+ end
+
message = case sanitized_reason
when "sso_provider_unavailable"
t("sessions.failure.sso_provider_unavailable")
@@ -177,6 +236,40 @@ class SessionsController < ApplicationController
end
private
+ def handle_mobile_sso_callback(user)
+ device_info = session.delete(:mobile_sso)
+
+ unless device_info.present?
+ mobile_sso_redirect(error: "missing_session", message: "Mobile SSO session expired")
+ return
+ end
+
+ device = MobileDevice.upsert_device!(user, device_info.symbolize_keys)
+ token_response = device.issue_token!
+
+ # Store tokens behind a one-time authorization code instead of passing in URL
+ authorization_code = SecureRandom.urlsafe_base64(32)
+ Rails.cache.write(
+ "mobile_sso:#{authorization_code}",
+ token_response.merge(
+ user_id: user.id,
+ user_email: user.email,
+ user_first_name: user.first_name,
+ user_last_name: user.last_name
+ ),
+ expires_in: 5.minutes
+ )
+
+ mobile_sso_redirect(code: authorization_code)
+ rescue ActiveRecord::RecordInvalid => e
+ Rails.logger.warn("[Mobile SSO] Device save failed: #{e.record.errors.full_messages.join(', ')}")
+ mobile_sso_redirect(error: "device_error", message: "Unable to register device")
+ end
+
+ def mobile_sso_redirect(params = {})
+ redirect_to "sureapp://oauth/callback?#{params.to_query}", allow_other_host: true
+ end
+
def set_session
@session = Current.user.sessions.find(params[:id])
end
diff --git a/app/models/mobile_device.rb b/app/models/mobile_device.rb
index 07dffcbe4..18f0c93a3 100644
--- a/app/models/mobile_device.rb
+++ b/app/models/mobile_device.rb
@@ -7,7 +7,6 @@ class MobileDevice < ApplicationRecord
end
belongs_to :user
- belongs_to :oauth_application, class_name: "Doorkeeper::Application", optional: true
validates :device_id, presence: true, uniqueness: { scope: :user_id }
validates :device_name, presence: true
@@ -15,8 +14,27 @@ class MobileDevice < ApplicationRecord
before_validation :set_last_seen_at, on: :create
+ CALLBACK_URL = "sureapp://oauth/callback"
+
scope :active, -> { where("last_seen_at > ?", 90.days.ago) }
+ def self.shared_oauth_application
+ @shared_oauth_application ||= Doorkeeper::Application.find_by!(name: "Sure Mobile")
+ end
+
+ def self.upsert_device!(user, attrs)
+ device = user.mobile_devices.find_or_initialize_by(device_id: attrs[:device_id])
+ device.assign_attributes(
+ device_name: attrs[:device_name],
+ device_type: attrs[:device_type],
+ os_version: attrs[:os_version],
+ app_version: attrs[:app_version],
+ last_seen_at: Time.current
+ )
+ device.save!
+ device
+ end
+
def active?
last_seen_at > 90.days.ago
end
@@ -25,26 +43,9 @@ class MobileDevice < ApplicationRecord
update_column(:last_seen_at, Time.current)
end
- def create_oauth_application!
- return oauth_application if oauth_application.present?
-
- app = Doorkeeper::Application.create!(
- name: "Mobile App - #{device_id}",
- redirect_uri: "sureapp://oauth/callback", # Custom scheme for mobile
- scopes: "read_write", # Use the configured scope
- confidential: false # Public client for mobile
- )
-
- # Store the association
- update!(oauth_application: app)
- app
- end
-
def active_tokens
- return Doorkeeper::AccessToken.none unless oauth_application
-
Doorkeeper::AccessToken
- .where(application: oauth_application)
+ .where(mobile_device_id: id)
.where(resource_owner_id: user_id)
.where(revoked_at: nil)
.where("expires_in IS NULL OR created_at + expires_in * interval '1 second' > ?", Time.current)
@@ -54,6 +55,30 @@ class MobileDevice < ApplicationRecord
active_tokens.update_all(revoked_at: Time.current)
end
+ # Issues a fresh Doorkeeper access token for this device, revoking any
+ # previous tokens. Returns a hash with token details ready for an API
+ # response or deep-link callback.
+ def issue_token!
+ revoke_all_tokens!
+
+ access_token = Doorkeeper::AccessToken.create!(
+ application: self.class.shared_oauth_application,
+ resource_owner_id: user_id,
+ mobile_device_id: id,
+ expires_in: 30.days.to_i,
+ scopes: "read_write",
+ use_refresh_token: true
+ )
+
+ {
+ access_token: access_token.plaintext_token,
+ refresh_token: access_token.plaintext_refresh_token,
+ token_type: "Bearer",
+ expires_in: access_token.expires_in,
+ created_at: access_token.created_at.to_i
+ }
+ end
+
private
def set_last_seen_at
diff --git a/app/views/sessions/mobile_sso_start.html.erb b/app/views/sessions/mobile_sso_start.html.erb
new file mode 100644
index 000000000..74fa9ebfd
--- /dev/null
+++ b/app/views/sessions/mobile_sso_start.html.erb
@@ -0,0 +1,8 @@
+
+
+
+
+
+
diff --git a/config/routes.rb b/config/routes.rb
index 632b5ca20..d5363cb35 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -118,6 +118,7 @@ Rails.application.routes.draw do
resource :registration, only: %i[new create]
resources :sessions, only: %i[index new create destroy]
+ get "/auth/mobile/:provider", to: "sessions#mobile_sso_start"
match "/auth/:provider/callback", to: "sessions#openid_connect", via: %i[get post]
match "/auth/failure", to: "sessions#failure", via: %i[get post]
get "/auth/logout/callback", to: "sessions#post_logout"
@@ -355,6 +356,7 @@ Rails.application.routes.draw do
post "auth/signup", to: "auth#signup"
post "auth/login", to: "auth#login"
post "auth/refresh", to: "auth#refresh"
+ post "auth/sso_exchange", to: "auth#sso_exchange"
# Production API endpoints
resources :accounts, only: [ :index, :show ]
diff --git a/db/migrate/20260203204605_refactor_mobile_device_oauth.rb b/db/migrate/20260203204605_refactor_mobile_device_oauth.rb
new file mode 100644
index 000000000..45c7c9c03
--- /dev/null
+++ b/db/migrate/20260203204605_refactor_mobile_device_oauth.rb
@@ -0,0 +1,7 @@
+class RefactorMobileDeviceOauth < ActiveRecord::Migration[7.2]
+ def change
+ add_column :oauth_access_tokens, :mobile_device_id, :uuid
+ add_index :oauth_access_tokens, :mobile_device_id
+ remove_column :mobile_devices, :oauth_application_id, :integer
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 873fc72b2..6cee63d21 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2026_01_29_200129) do
+ActiveRecord::Schema[7.2].define(version: 2026_02_03_204605) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -855,8 +855,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_29_200129) do
t.datetime "last_seen_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
- t.integer "oauth_application_id"
- t.index ["oauth_application_id"], name: "index_mobile_devices_on_oauth_application_id"
t.index ["user_id", "device_id"], name: "index_mobile_devices_on_user_id_and_device_id", unique: true
t.index ["user_id"], name: "index_mobile_devices_on_user_id"
end
@@ -885,7 +883,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_29_200129) do
t.datetime "created_at", null: false
t.datetime "revoked_at"
t.string "previous_refresh_token", default: "", null: false
+ t.uuid "mobile_device_id"
t.index ["application_id"], name: "index_oauth_access_tokens_on_application_id"
+ t.index ["mobile_device_id"], name: "index_oauth_access_tokens_on_mobile_device_id"
t.index ["refresh_token"], name: "index_oauth_access_tokens_on_refresh_token", unique: true
t.index ["resource_owner_id"], name: "index_oauth_access_tokens_on_resource_owner_id"
t.index ["token"], name: "index_oauth_access_tokens_on_token", unique: true
diff --git a/db/seeds/oauth_applications.rb b/db/seeds/oauth_applications.rb
index 1e82b70d6..40d1d187e 100644
--- a/db/seeds/oauth_applications.rb
+++ b/db/seeds/oauth_applications.rb
@@ -1,14 +1,14 @@
# Create OAuth applications for Sure's first-party apps
# These are the only OAuth apps that will exist - external developers use API keys
-# Sure iOS App
-ios_app = Doorkeeper::Application.find_or_create_by(name: "Sure iOS") do |app|
+# Sure Mobile App (shared across iOS and Android)
+mobile_app = Doorkeeper::Application.find_or_create_by(name: "Sure Mobile") do |app|
app.redirect_uri = "sureapp://oauth/callback"
- app.scopes = "read_accounts read_transactions read_balances"
+ app.scopes = "read_write"
app.confidential = false # Public client (mobile app)
end
puts "Created OAuth applications:"
-puts "iOS App - Client ID: #{ios_app.uid}"
+puts "Mobile App - Client ID: #{mobile_app.uid}"
puts ""
puts "External developers should use API keys instead of OAuth."
diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart
index d9ef30c70..4c7489b3b 100644
--- a/mobile/lib/main.dart
+++ b/mobile/lib/main.dart
@@ -1,3 +1,5 @@
+import 'dart:async';
+import 'package:app_links/app_links.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'providers/auth_provider.dart';
@@ -146,11 +148,46 @@ class AppWrapper extends StatefulWidget {
class _AppWrapperState extends State {
bool _isCheckingConfig = true;
bool _hasBackendUrl = false;
+ late final AppLinks _appLinks;
+ StreamSubscription? _linkSubscription;
@override
void initState() {
super.initState();
_checkBackendConfig();
+ _initDeepLinks();
+ }
+
+ @override
+ void dispose() {
+ _linkSubscription?.cancel();
+ super.dispose();
+ }
+
+ void _initDeepLinks() {
+ _appLinks = AppLinks();
+
+ // Handle deep link that launched the app (cold start)
+ _appLinks.getInitialLink().then((uri) {
+ if (uri != null) _handleDeepLink(uri);
+ }).catchError((e, stackTrace) {
+ LogService.instance.error('DeepLinks', 'Initial link error: $e\n$stackTrace');
+ });
+
+ // Listen for deep links while app is running
+ _linkSubscription = _appLinks.uriLinkStream.listen(
+ (uri) => _handleDeepLink(uri),
+ onError: (e, stackTrace) {
+ LogService.instance.error('DeepLinks', 'Link stream error: $e\n$stackTrace');
+ },
+ );
+ }
+
+ void _handleDeepLink(Uri uri) {
+ if (uri.scheme == 'sureapp' && uri.host == 'oauth') {
+ final authProvider = Provider.of(context, listen: false);
+ authProvider.handleSsoCallback(uri);
+ }
}
Future _checkBackendConfig() async {
diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart
index bfc24670f..0fa47fd63 100644
--- a/mobile/lib/providers/auth_provider.dart
+++ b/mobile/lib/providers/auth_provider.dart
@@ -1,4 +1,5 @@
import 'package:flutter/foundation.dart';
+import 'package:url_launcher/url_launcher.dart';
import '../models/user.dart';
import '../models/auth_tokens.dart';
import '../services/auth_service.dart';
@@ -215,6 +216,60 @@ class AuthProvider with ChangeNotifier {
}
}
+ Future startSsoLogin(String provider) async {
+ _errorMessage = null;
+ _isLoading = true;
+ notifyListeners();
+
+ try {
+ final deviceInfo = await _deviceService.getDeviceInfo();
+ final ssoUrl = _authService.buildSsoUrl(
+ provider: provider,
+ deviceInfo: deviceInfo,
+ );
+
+ final launched = await launchUrl(Uri.parse(ssoUrl), mode: LaunchMode.externalApplication);
+ if (!launched) {
+ _errorMessage = 'Unable to open browser for sign-in.';
+ }
+ } catch (e, stackTrace) {
+ LogService.instance.error('AuthProvider', 'SSO launch error: $e\n$stackTrace');
+ _errorMessage = 'Unable to start sign-in. Please try again.';
+ } finally {
+ _isLoading = false;
+ notifyListeners();
+ }
+ }
+
+ Future handleSsoCallback(Uri uri) async {
+ _errorMessage = null;
+ _isLoading = true;
+ notifyListeners();
+
+ try {
+ final result = await _authService.handleSsoCallback(uri);
+
+ if (result['success'] == true) {
+ _tokens = result['tokens'] as AuthTokens?;
+ _user = result['user'] as User?;
+ _isLoading = false;
+ notifyListeners();
+ return true;
+ } else {
+ _errorMessage = result['error'] as String?;
+ _isLoading = false;
+ notifyListeners();
+ return false;
+ }
+ } catch (e, stackTrace) {
+ LogService.instance.error('AuthProvider', 'SSO callback error: $e\n$stackTrace');
+ _errorMessage = 'Sign-in failed. Please try again.';
+ _isLoading = false;
+ notifyListeners();
+ return false;
+ }
+ }
+
Future logout() async {
await _authService.logout();
_tokens = null;
diff --git a/mobile/lib/screens/login_screen.dart b/mobile/lib/screens/login_screen.dart
index e4c8fc6de..f3089293a 100644
--- a/mobile/lib/screens/login_screen.dart
+++ b/mobile/lib/screens/login_screen.dart
@@ -371,6 +371,44 @@ class _LoginScreenState extends State {
const SizedBox(height: 16),
+ // Divider with "or"
+ Row(
+ children: [
+ Expanded(child: Divider(color: colorScheme.outlineVariant)),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: Text(
+ 'or',
+ style: TextStyle(color: colorScheme.onSurfaceVariant),
+ ),
+ ),
+ Expanded(child: Divider(color: colorScheme.outlineVariant)),
+ ],
+ ),
+
+ const SizedBox(height: 16),
+
+ // Google Sign-In button
+ Consumer(
+ builder: (context, authProvider, _) {
+ return OutlinedButton.icon(
+ onPressed: authProvider.isLoading
+ ? null
+ : () => authProvider.startSsoLogin('google_oauth2'),
+ icon: const Icon(Icons.g_mobiledata, size: 24),
+ label: const Text('Sign in with Google'),
+ style: OutlinedButton.styleFrom(
+ minimumSize: const Size(double.infinity, 50),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ ),
+ );
+ },
+ ),
+
+ const SizedBox(height: 24),
+
// Backend URL info
Container(
padding: const EdgeInsets.all(12),
diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart
index 5c28ff28d..c6b6c8859 100644
--- a/mobile/lib/services/auth_service.dart
+++ b/mobile/lib/services/auth_service.dart
@@ -341,6 +341,100 @@ class AuthService {
}
}
+ String buildSsoUrl({
+ required String provider,
+ required Map deviceInfo,
+ }) {
+ final params = {
+ 'device_id': deviceInfo['device_id']!,
+ 'device_name': deviceInfo['device_name']!,
+ 'device_type': deviceInfo['device_type']!,
+ 'os_version': deviceInfo['os_version']!,
+ 'app_version': deviceInfo['app_version']!,
+ };
+ final uri = Uri.parse('${ApiConfig.baseUrl}/auth/mobile/$provider')
+ .replace(queryParameters: params);
+ return uri.toString();
+ }
+
+ Future