diff --git a/app/controllers/api/v1/auth_controller.rb b/app/controllers/api/v1/auth_controller.rb index ae1744823..25140f3ea 100644 --- a/app/controllers/api/v1/auth_controller.rb +++ b/app/controllers/api/v1/auth_controller.rb @@ -140,6 +140,80 @@ module Api } end + def sso_link + linking_code = params[:linking_code] + cached = validate_linking_code(linking_code) + return unless cached + + user = User.authenticate_by(email: params[:email], password: params[:password]) + + unless user + render json: { error: "Invalid email or password" }, status: :unauthorized + return + end + + if user.otp_required? + render json: { error: "MFA users should sign in with email and password", mfa_required: true }, status: :unauthorized + return + end + + # Atomically claim the code before creating the identity + return render json: { error: "Linking code is invalid or expired" }, status: :unauthorized unless consume_linking_code!(linking_code) + + OidcIdentity.create_from_omniauth(build_omniauth_hash(cached), user) + + SsoAuditLog.log_link!( + user: user, + provider: cached[:provider], + request: request + ) + + issue_mobile_tokens(user, cached[:device_info]) + end + + def sso_create_account + linking_code = params[:linking_code] + cached = validate_linking_code(linking_code) + return unless cached + + email = cached[:email] + + unless cached[:allow_account_creation] + render json: { error: "SSO account creation is disabled. Please contact an administrator." }, status: :forbidden + return + end + + # Atomically claim the code before creating the user + return render json: { error: "Linking code is invalid or expired" }, status: :unauthorized unless consume_linking_code!(linking_code) + + user = User.new( + email: email, + first_name: params[:first_name].presence || cached[:first_name], + last_name: params[:last_name].presence || cached[:last_name], + skip_password_validation: true + ) + + user.family = Family.new + + provider_config = Rails.configuration.x.auth.sso_providers&.find { |p| p[:name] == cached[:provider] } + provider_default_role = provider_config&.dig(:settings, :default_role) + user.role = User.role_for_new_family_creator(fallback_role: provider_default_role || :admin) + + if user.save + OidcIdentity.create_from_omniauth(build_omniauth_hash(cached), user) + + SsoAuditLog.log_jit_account_created!( + user: user, + provider: cached[:provider], + request: request + ) + + issue_mobile_tokens(user, cached[:device_info]) + else + render json: { errors: user.errors.full_messages }, status: :unprocessable_entity + end + end + def enable_ai user = current_resource_owner @@ -248,6 +322,48 @@ module Api } end + def build_omniauth_hash(cached) + OpenStruct.new( + provider: cached[:provider], + uid: cached[:uid], + info: OpenStruct.new(cached.slice(:email, :name, :first_name, :last_name)), + extra: OpenStruct.new(raw_info: OpenStruct.new(iss: cached[:issuer])) + ) + end + + def validate_linking_code(linking_code) + if linking_code.blank? + render json: { error: "Linking code is required" }, status: :bad_request + return nil + end + + cache_key = "mobile_sso_link:#{linking_code}" + cached = Rails.cache.read(cache_key) + + unless cached.present? + render json: { error: "Linking code is invalid or expired" }, status: :unauthorized + return nil + end + + cached + end + + # Atomically deletes the linking code from cache. + # Returns true only for the first caller; subsequent callers get false. + def consume_linking_code!(linking_code) + Rails.cache.delete("mobile_sso_link:#{linking_code}") + end + + def issue_mobile_tokens(user, device_info) + device_info = device_info.symbolize_keys if device_info.respond_to?(:symbolize_keys) + device = MobileDevice.upsert_device!(user, device_info) + token_response = device.issue_token! + + render json: token_response.merge(user: mobile_user_payload(user)) + rescue ActiveRecord::RecordInvalid => e + render json: { error: "Failed to register device: #{e.message}" }, status: :unprocessable_entity + end + def ensure_write_scope authorize_scope!(:write) end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index ec53ef5f6..7d56a96fe 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -188,10 +188,10 @@ class SessionsController < ApplicationController redirect_to root_path end else - # Mobile SSO with no linked identity - redirect back with error + # Mobile SSO with no linked identity - cache pending auth and redirect + # back to the app with a linking code so the user can link or create an account 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") + handle_mobile_sso_onboarding(auth) return end @@ -273,6 +273,37 @@ class SessionsController < ApplicationController mobile_sso_redirect(error: "device_error", message: "Unable to register device") end + def handle_mobile_sso_onboarding(auth) + device_info = session.delete(:mobile_sso) + email = auth.info&.email + + linking_code = SecureRandom.urlsafe_base64(32) + Rails.cache.write( + "mobile_sso_link:#{linking_code}", + { + provider: auth.provider, + uid: auth.uid, + email: email, + first_name: auth.info&.first_name, + last_name: auth.info&.last_name, + name: auth.info&.name, + issuer: auth.extra&.raw_info&.iss || auth.extra&.raw_info&.[]("iss"), + device_info: device_info, + allow_account_creation: !AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(email) + }, + expires_in: 10.minutes + ) + + mobile_sso_redirect( + status: "account_not_linked", + linking_code: linking_code, + email: email, + first_name: auth.info&.first_name, + last_name: auth.info&.last_name, + allow_account_creation: !AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(email) + ) + end + def mobile_sso_redirect(params = {}) redirect_to "sureapp://oauth/callback?#{params.to_query}", allow_other_host: true end diff --git a/config/routes.rb b/config/routes.rb index 514cece15..d0b6c8827 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -375,6 +375,8 @@ Rails.application.routes.draw do post "auth/login", to: "auth#login" post "auth/refresh", to: "auth#refresh" post "auth/sso_exchange", to: "auth#sso_exchange" + post "auth/sso_link", to: "auth#sso_link" + post "auth/sso_create_account", to: "auth#sso_create_account" patch "auth/enable_ai", to: "auth#enable_ai" # Production API endpoints diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 2f3aca585..55e74ab9d 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -9,6 +9,7 @@ import 'providers/chat_provider.dart'; import 'screens/backend_config_screen.dart'; import 'screens/login_screen.dart'; import 'screens/main_navigation_screen.dart'; +import 'screens/sso_onboarding_screen.dart'; import 'services/api_config.dart'; import 'services/connectivity_service.dart'; import 'services/log_service.dart'; @@ -255,6 +256,10 @@ class _AppWrapperState extends State { return const MainNavigationScreen(); } + if (authProvider.ssoOnboardingPending) { + return const SsoOnboardingScreen(); + } + return LoginScreen( onGoToSettings: _goToBackendConfig, ); diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart index 3b6a6510e..c2e8bdcfc 100644 --- a/mobile/lib/providers/auth_provider.dart +++ b/mobile/lib/providers/auth_provider.dart @@ -22,6 +22,14 @@ class AuthProvider with ChangeNotifier { bool _mfaRequired = false; bool _showMfaInput = false; // Track if we should show MFA input field + // SSO onboarding state + bool _ssoOnboardingPending = false; + String? _ssoLinkingCode; + String? _ssoEmail; + String? _ssoFirstName; + String? _ssoLastName; + bool _ssoAllowAccountCreation = false; + User? get user => _user; bool get isIntroLayout => _user?.isIntroLayout ?? false; bool get aiEnabled => _user?.aiEnabled ?? false; @@ -36,6 +44,14 @@ class AuthProvider with ChangeNotifier { bool get mfaRequired => _mfaRequired; bool get showMfaInput => _showMfaInput; // Expose MFA input state + // SSO onboarding getters + bool get ssoOnboardingPending => _ssoOnboardingPending; + String? get ssoLinkingCode => _ssoLinkingCode; + String? get ssoEmail => _ssoEmail; + String? get ssoFirstName => _ssoFirstName; + String? get ssoLastName => _ssoLastName; + bool get ssoAllowAccountCreation => _ssoAllowAccountCreation; + AuthProvider() { _loadStoredAuth(); } @@ -266,9 +282,21 @@ class AuthProvider with ChangeNotifier { if (result['success'] == true) { _tokens = result['tokens'] as AuthTokens?; _user = result['user'] as User?; + _ssoOnboardingPending = false; _isLoading = false; notifyListeners(); return true; + } else if (result['account_not_linked'] == true) { + // SSO onboarding needed - store linking data + _ssoOnboardingPending = true; + _ssoLinkingCode = result['linking_code'] as String?; + _ssoEmail = result['email'] as String?; + _ssoFirstName = result['first_name'] as String?; + _ssoLastName = result['last_name'] as String?; + _ssoAllowAccountCreation = result['allow_account_creation'] == true; + _isLoading = false; + notifyListeners(); + return false; } else { _errorMessage = result['error'] as String?; _isLoading = false; @@ -284,6 +312,106 @@ class AuthProvider with ChangeNotifier { } } + Future ssoLinkAccount({ + required String email, + required String password, + }) async { + if (_ssoLinkingCode == null) { + _errorMessage = 'No pending SSO session. Please try signing in again.'; + notifyListeners(); + return false; + } + + _errorMessage = null; + _isLoading = true; + notifyListeners(); + + try { + final result = await _authService.ssoLink( + linkingCode: _ssoLinkingCode!, + email: email, + password: password, + ); + + if (result['success'] == true) { + _tokens = result['tokens'] as AuthTokens?; + _user = result['user'] as User?; + _clearSsoOnboardingState(); + _isLoading = false; + notifyListeners(); + return true; + } else { + _errorMessage = result['error'] as String?; + _isLoading = false; + notifyListeners(); + return false; + } + } catch (e, stackTrace) { + LogService.instance.error('AuthProvider', 'SSO link error: $e\n$stackTrace'); + _errorMessage = 'Failed to link account. Please try again.'; + _isLoading = false; + notifyListeners(); + return false; + } + } + + Future ssoCreateAccount({ + String? firstName, + String? lastName, + }) async { + if (_ssoLinkingCode == null) { + _errorMessage = 'No pending SSO session. Please try signing in again.'; + notifyListeners(); + return false; + } + + _errorMessage = null; + _isLoading = true; + notifyListeners(); + + try { + final result = await _authService.ssoCreateAccount( + linkingCode: _ssoLinkingCode!, + firstName: firstName, + lastName: lastName, + ); + + if (result['success'] == true) { + _tokens = result['tokens'] as AuthTokens?; + _user = result['user'] as User?; + _clearSsoOnboardingState(); + _isLoading = false; + notifyListeners(); + return true; + } else { + _errorMessage = result['error'] as String?; + _isLoading = false; + notifyListeners(); + return false; + } + } catch (e, stackTrace) { + LogService.instance.error('AuthProvider', 'SSO create account error: $e\n$stackTrace'); + _errorMessage = 'Failed to create account. Please try again.'; + _isLoading = false; + notifyListeners(); + return false; + } + } + + void cancelSsoOnboarding() { + _clearSsoOnboardingState(); + notifyListeners(); + } + + void _clearSsoOnboardingState() { + _ssoOnboardingPending = false; + _ssoLinkingCode = null; + _ssoEmail = null; + _ssoFirstName = null; + _ssoLastName = null; + _ssoAllowAccountCreation = false; + } + Future logout() async { await _authService.logout(); _tokens = null; diff --git a/mobile/lib/screens/sso_onboarding_screen.dart b/mobile/lib/screens/sso_onboarding_screen.dart new file mode 100644 index 000000000..c98a2c3de --- /dev/null +++ b/mobile/lib/screens/sso_onboarding_screen.dart @@ -0,0 +1,363 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import '../providers/auth_provider.dart'; + +class SsoOnboardingScreen extends StatefulWidget { + const SsoOnboardingScreen({super.key}); + + @override + State createState() => _SsoOnboardingScreenState(); +} + +class _SsoOnboardingScreenState extends State { + bool _showLinkForm = true; + final _linkFormKey = GlobalKey(); + final _createFormKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _firstNameController = TextEditingController(); + final _lastNameController = TextEditingController(); + bool _obscurePassword = true; + + @override + void initState() { + super.initState(); + final authProvider = Provider.of(context, listen: false); + _emailController.text = authProvider.ssoEmail ?? ''; + _firstNameController.text = authProvider.ssoFirstName ?? ''; + _lastNameController.text = authProvider.ssoLastName ?? ''; + } + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + _firstNameController.dispose(); + _lastNameController.dispose(); + super.dispose(); + } + + Future _handleLinkAccount() async { + if (!_linkFormKey.currentState!.validate()) return; + + final authProvider = Provider.of(context, listen: false); + await authProvider.ssoLinkAccount( + email: _emailController.text.trim(), + password: _passwordController.text, + ); + } + + Future _handleCreateAccount() async { + if (!_createFormKey.currentState!.validate()) return; + + final authProvider = Provider.of(context, listen: false); + await authProvider.ssoCreateAccount( + firstName: _firstNameController.text.trim(), + lastName: _lastNameController.text.trim(), + ); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + Provider.of(context, listen: false) + .cancelSsoOnboarding(); + }, + ), + title: const Text('Link Your Account'), + ), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Consumer( + builder: (context, authProvider, _) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + SvgPicture.asset( + 'assets/images/google_g_logo.svg', + width: 48, + height: 48, + ), + const SizedBox(height: 16), + Text( + authProvider.ssoEmail != null + ? 'Signed in as ${authProvider.ssoEmail}' + : 'Google account verified', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 24), + + // Error message + if (authProvider.errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.error_outline, color: colorScheme.error), + const SizedBox(width: 12), + Expanded( + child: Text( + authProvider.errorMessage!, + style: TextStyle( + color: colorScheme.onErrorContainer), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => authProvider.clearError(), + iconSize: 20, + ), + ], + ), + ), + + // Tab selector + if (authProvider.ssoAllowAccountCreation) ...[ + Container( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest + .withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Expanded( + child: _TabButton( + label: 'Link Existing', + isSelected: _showLinkForm, + onTap: () => + setState(() => _showLinkForm = true), + ), + ), + Expanded( + child: _TabButton( + label: 'Create New', + isSelected: !_showLinkForm, + onTap: () => + setState(() => _showLinkForm = false), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + ], + + // Link existing account form + if (_showLinkForm) _buildLinkForm(authProvider, colorScheme), + + // Create new account form + if (!_showLinkForm) + _buildCreateForm(authProvider, colorScheme), + ], + ); + }, + ), + ), + ), + ); + } + + Widget _buildLinkForm(AuthProvider authProvider, ColorScheme colorScheme) { + return Form( + key: _linkFormKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.link, color: colorScheme.primary), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Enter your existing account credentials to link with Google Sign-In.', + style: TextStyle(color: colorScheme.onSurface), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + autocorrect: false, + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: 'Email', + prefixIcon: Icon(Icons.email_outlined), + ), + validator: (value) { + if (value == null || value.isEmpty) return 'Please enter your email'; + if (!value.contains('@')) return 'Please enter a valid email'; + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + obscureText: _obscurePassword, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + labelText: 'Password', + prefixIcon: const Icon(Icons.lock_outlined), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + onPressed: () { + setState(() => _obscurePassword = !_obscurePassword); + }, + ), + ), + validator: (value) { + if (value == null || value.isEmpty) return 'Please enter your password'; + return null; + }, + onFieldSubmitted: (_) => _handleLinkAccount(), + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: authProvider.isLoading ? null : _handleLinkAccount, + child: authProvider.isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Link Account'), + ), + ], + ), + ); + } + + Widget _buildCreateForm(AuthProvider authProvider, ColorScheme colorScheme) { + return Form( + key: _createFormKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.person_add, color: colorScheme.primary), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Create a new account using your Google identity.', + style: TextStyle(color: colorScheme.onSurface), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _firstNameController, + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: 'First Name', + prefixIcon: Icon(Icons.person_outlined), + ), + validator: (value) { + if (value == null || value.isEmpty) return 'Please enter your first name'; + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _lastNameController, + textInputAction: TextInputAction.done, + decoration: const InputDecoration( + labelText: 'Last Name', + prefixIcon: Icon(Icons.person_outlined), + ), + validator: (value) { + if (value == null || value.isEmpty) return 'Please enter your last name'; + return null; + }, + onFieldSubmitted: (_) => _handleCreateAccount(), + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: authProvider.isLoading ? null : _handleCreateAccount, + child: authProvider.isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Create Account'), + ), + ], + ), + ); + } +} + +class _TabButton extends StatelessWidget { + final String label; + final bool isSelected; + final VoidCallback onTap; + + const _TabButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: isSelected ? colorScheme.primary : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + label, + textAlign: TextAlign.center, + style: TextStyle( + color: isSelected ? colorScheme.onPrimary : colorScheme.onSurface, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + ), + ), + ), + ); + } +} diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart index 98a98f31e..a89f25c3e 100644 --- a/mobile/lib/services/auth_service.dart +++ b/mobile/lib/services/auth_service.dart @@ -364,6 +364,19 @@ class AuthService { Future> handleSsoCallback(Uri uri) async { final params = uri.queryParameters; + // Handle account not linked - return linking data for onboarding flow + if (params['status'] == 'account_not_linked') { + return { + 'success': false, + 'account_not_linked': true, + 'linking_code': params['linking_code'] ?? '', + 'email': params['email'] ?? '', + 'first_name': params['first_name'] ?? '', + 'last_name': params['last_name'] ?? '', + 'allow_account_creation': params['allow_account_creation'] == 'true', + }; + } + if (params.containsKey('error')) { return { 'success': false, @@ -440,6 +453,116 @@ class AuthService { } } + Future> ssoLink({ + required String linkingCode, + required String email, + required String password, + }) async { + try { + final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/sso_link'); + final response = await http.post( + url, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: jsonEncode({ + 'linking_code': linkingCode, + 'email': email, + 'password': password, + }), + ).timeout(const Duration(seconds: 30)); + + final responseData = jsonDecode(response.body); + + if (response.statusCode == 200) { + final tokens = AuthTokens.fromJson(responseData); + await _saveTokens(tokens); + + User? user; + if (responseData['user'] != null) { + _logRawUserPayload('sso_link', responseData['user']); + user = User.fromJson(responseData['user']); + await _saveUser(user); + } + + return { + 'success': true, + 'tokens': tokens, + 'user': user, + }; + } else { + return { + 'success': false, + 'error': responseData['error'] ?? responseData['errors']?.join(', ') ?? 'Account linking failed', + }; + } + } on SocketException { + return {'success': false, 'error': 'Network unavailable'}; + } on TimeoutException { + return {'success': false, 'error': 'Request timed out'}; + } catch (e, stackTrace) { + LogService.instance.error('AuthService', 'SSO link error: $e\n$stackTrace'); + return {'success': false, 'error': 'Failed to link account'}; + } + } + + Future> ssoCreateAccount({ + required String linkingCode, + String? firstName, + String? lastName, + }) async { + try { + final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/sso_create_account'); + final body = { + 'linking_code': linkingCode, + }; + if (firstName != null) body['first_name'] = firstName; + if (lastName != null) body['last_name'] = lastName; + + final response = await http.post( + url, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: jsonEncode(body), + ).timeout(const Duration(seconds: 30)); + + final responseData = jsonDecode(response.body); + + if (response.statusCode == 200) { + final tokens = AuthTokens.fromJson(responseData); + await _saveTokens(tokens); + + User? user; + if (responseData['user'] != null) { + _logRawUserPayload('sso_create_account', responseData['user']); + user = User.fromJson(responseData['user']); + await _saveUser(user); + } + + return { + 'success': true, + 'tokens': tokens, + 'user': user, + }; + } else { + return { + 'success': false, + 'error': responseData['error'] ?? responseData['errors']?.join(', ') ?? 'Account creation failed', + }; + } + } on SocketException { + return {'success': false, 'error': 'Network unavailable'}; + } on TimeoutException { + return {'success': false, 'error': 'Request timed out'}; + } catch (e, stackTrace) { + LogService.instance.error('AuthService', 'SSO create account error: $e\n$stackTrace'); + return {'success': false, 'error': 'Failed to create account'}; + } + } + Future> enableAi({ required String accessToken, }) async { diff --git a/spec/requests/api/v1/auth_spec.rb b/spec/requests/api/v1/auth_spec.rb index f21ab79f2..278ec7348 100644 --- a/spec/requests/api/v1/auth_spec.rb +++ b/spec/requests/api/v1/auth_spec.rb @@ -216,6 +216,124 @@ RSpec.describe 'API V1 Auth', type: :request do end end + path '/api/v1/auth/sso_link' do + post 'Link an existing account via SSO' do + tags 'Auth' + consumes 'application/json' + produces 'application/json' + description 'Authenticates with email/password and links the SSO identity from a previously issued linking code. Creates an OidcIdentity, logs the link via SsoAuditLog, and issues mobile OAuth tokens.' + parameter name: :body, in: :body, required: true, schema: { + type: :object, + properties: { + linking_code: { type: :string, description: 'One-time linking code from mobile SSO onboarding redirect' }, + email: { type: :string, format: :email, description: 'Email of the existing account to link' }, + password: { type: :string, description: 'Password for the existing account' } + }, + required: %w[linking_code email password] + } + + response '200', 'account linked and tokens issued' do + schema type: :object, + properties: { + access_token: { type: :string }, + refresh_token: { type: :string }, + token_type: { type: :string }, + expires_in: { type: :integer }, + created_at: { type: :integer }, + user: { + type: :object, + properties: { + id: { type: :string, format: :uuid }, + email: { type: :string }, + first_name: { type: :string }, + last_name: { type: :string }, + ui_layout: { type: :string, enum: %w[dashboard intro] }, + ai_enabled: { type: :boolean } + } + } + } + run_test! + end + + response '400', 'missing linking code' do + schema '$ref' => '#/components/schemas/ErrorResponse' + run_test! + end + + response '401', 'invalid credentials or expired linking code' do + schema '$ref' => '#/components/schemas/ErrorResponse' + run_test! + end + end + end + + path '/api/v1/auth/sso_create_account' do + post 'Create a new account via SSO' do + tags 'Auth' + consumes 'application/json' + produces 'application/json' + description 'Creates a new user and family from a previously issued linking code. Links the SSO identity via OidcIdentity, logs the JIT account creation via SsoAuditLog, and issues mobile OAuth tokens. The linking code must have allow_account_creation enabled.' + parameter name: :body, in: :body, required: true, schema: { + type: :object, + properties: { + linking_code: { type: :string, description: 'One-time linking code from mobile SSO onboarding redirect' }, + first_name: { type: :string, description: 'First name (overrides value from SSO provider if provided)' }, + last_name: { type: :string, description: 'Last name (overrides value from SSO provider if provided)' } + }, + required: %w[linking_code] + } + + response '200', 'account created and tokens issued' do + schema type: :object, + properties: { + access_token: { type: :string }, + refresh_token: { type: :string }, + token_type: { type: :string }, + expires_in: { type: :integer }, + created_at: { type: :integer }, + user: { + type: :object, + properties: { + id: { type: :string, format: :uuid }, + email: { type: :string }, + first_name: { type: :string }, + last_name: { type: :string }, + ui_layout: { type: :string, enum: %w[dashboard intro] }, + ai_enabled: { type: :boolean } + } + } + } + run_test! + end + + response '400', 'missing linking code' do + schema '$ref' => '#/components/schemas/ErrorResponse' + run_test! + end + + response '401', 'invalid or expired linking code' do + schema '$ref' => '#/components/schemas/ErrorResponse' + run_test! + end + + response '403', 'account creation disabled' do + schema '$ref' => '#/components/schemas/ErrorResponse' + run_test! + end + + response '422', 'user validation error' do + schema type: :object, + properties: { + errors: { + type: :array, + items: { type: :string } + } + } + run_test! + end + end + end + path '/api/v1/auth/enable_ai' do patch 'Enable AI features for the authenticated user' do tags 'Auth' diff --git a/test/controllers/api/v1/auth_controller_test.rb b/test/controllers/api/v1/auth_controller_test.rb index 06b2ed537..52207df3a 100644 --- a/test/controllers/api/v1/auth_controller_test.rb +++ b/test/controllers/api/v1/auth_controller_test.rb @@ -22,6 +22,14 @@ class Api::V1::AuthControllerTest < ActionDispatch::IntegrationTest # Clear the memoized class variable so it picks up the test record MobileDevice.instance_variable_set(:@shared_oauth_application, nil) + + # Use a real cache store for SSO linking tests (test env uses :null_store by default) + @original_cache = Rails.cache + Rails.cache = ActiveSupport::Cache::MemoryStore.new + end + + teardown do + Rails.cache = @original_cache if @original_cache end test "should signup new user and return OAuth tokens" do @@ -488,6 +496,311 @@ class Api::V1::AuthControllerTest < ActionDispatch::IntegrationTest assert_response :unauthorized end + # SSO Link tests + test "should link existing account via SSO and return tokens" do + user = users(:family_admin) + + linking_code = SecureRandom.urlsafe_base64(32) + Rails.cache.write("mobile_sso_link:#{linking_code}", { + provider: "google_oauth2", + uid: "google-uid-123", + email: "google@example.com", + first_name: "Google", + last_name: "User", + name: "Google User", + device_info: @device_info.stringify_keys, + allow_account_creation: true + }, expires_in: 10.minutes) + + assert_difference("OidcIdentity.count", 1) do + post "/api/v1/auth/sso_link", params: { + linking_code: linking_code, + email: user.email, + password: user_password_test + } + end + + assert_response :success + response_data = JSON.parse(response.body) + assert response_data["access_token"].present? + assert response_data["refresh_token"].present? + assert_equal user.id.to_s, response_data["user"]["id"] + + # Linking code should be consumed + assert_nil Rails.cache.read("mobile_sso_link:#{linking_code}") + end + + test "should reject SSO link with invalid password" do + user = users(:family_admin) + + linking_code = SecureRandom.urlsafe_base64(32) + Rails.cache.write("mobile_sso_link:#{linking_code}", { + provider: "google_oauth2", + uid: "google-uid-123", + email: "google@example.com", + device_info: @device_info.stringify_keys, + allow_account_creation: true + }, expires_in: 10.minutes) + + assert_no_difference("OidcIdentity.count") do + post "/api/v1/auth/sso_link", params: { + linking_code: linking_code, + email: user.email, + password: "wrong_password" + } + end + + assert_response :unauthorized + response_data = JSON.parse(response.body) + assert_equal "Invalid email or password", response_data["error"] + + # Linking code should NOT be consumed on failed password + assert Rails.cache.read("mobile_sso_link:#{linking_code}").present?, "Expected linking code to survive a failed attempt" + end + + test "should reject SSO link when user has MFA enabled" do + user = users(:family_admin) + user.update!(otp_required: true, otp_secret: ROTP::Base32.random(32)) + + linking_code = SecureRandom.urlsafe_base64(32) + Rails.cache.write("mobile_sso_link:#{linking_code}", { + provider: "google_oauth2", + uid: "google-uid-mfa", + email: "mfa@example.com", + first_name: "MFA", + last_name: "User", + name: "MFA User", + device_info: @device_info.stringify_keys, + allow_account_creation: true + }, expires_in: 10.minutes) + + assert_no_difference("OidcIdentity.count") do + post "/api/v1/auth/sso_link", params: { + linking_code: linking_code, + email: user.email, + password: user_password_test + } + end + + assert_response :unauthorized + response_data = JSON.parse(response.body) + assert_equal true, response_data["mfa_required"] + assert_match(/MFA/, response_data["error"]) + + # Linking code should NOT be consumed on MFA rejection + assert Rails.cache.read("mobile_sso_link:#{linking_code}").present?, "Expected linking code to survive MFA rejection" + end + + test "should reject SSO link with expired linking code" do + post "/api/v1/auth/sso_link", params: { + linking_code: "expired-code", + email: "test@example.com", + password: "password" + } + + assert_response :unauthorized + response_data = JSON.parse(response.body) + assert_equal "Linking code is invalid or expired", response_data["error"] + end + + test "should reject SSO link without linking code" do + post "/api/v1/auth/sso_link", params: { + email: "test@example.com", + password: "password" + } + + assert_response :bad_request + response_data = JSON.parse(response.body) + assert_equal "Linking code is required", response_data["error"] + end + + test "linking_code is single-use under race" do + user = users(:family_admin) + + linking_code = SecureRandom.urlsafe_base64(32) + Rails.cache.write("mobile_sso_link:#{linking_code}", { + provider: "google_oauth2", + uid: "google-uid-race-test", + email: "race@example.com", + first_name: "Race", + last_name: "Test", + name: "Race Test", + device_info: @device_info.stringify_keys, + allow_account_creation: true + }, expires_in: 10.minutes) + + # First request succeeds + assert_difference("OidcIdentity.count", 1) do + post "/api/v1/auth/sso_link", params: { + linking_code: linking_code, + email: user.email, + password: user_password_test + } + end + assert_response :success + + # Second request with the same code is rejected + assert_no_difference("OidcIdentity.count") do + post "/api/v1/auth/sso_link", params: { + linking_code: linking_code, + email: user.email, + password: user_password_test + } + end + assert_response :unauthorized + assert_equal "Linking code is invalid or expired", JSON.parse(response.body)["error"] + assert_nil Rails.cache.read("mobile_sso_link:#{linking_code}") + end + + # SSO Create Account tests + test "should create new account via SSO and return tokens" do + linking_code = SecureRandom.urlsafe_base64(32) + Rails.cache.write("mobile_sso_link:#{linking_code}", { + provider: "google_oauth2", + uid: "google-uid-456", + email: "newgoogleuser@example.com", + first_name: "New", + last_name: "GoogleUser", + name: "New GoogleUser", + device_info: @device_info.stringify_keys, + allow_account_creation: true + }, expires_in: 10.minutes) + + assert_difference([ "User.count", "OidcIdentity.count" ], 1) do + post "/api/v1/auth/sso_create_account", params: { + linking_code: linking_code, + first_name: "New", + last_name: "GoogleUser" + } + end + + assert_response :success + response_data = JSON.parse(response.body) + assert response_data["access_token"].present? + assert response_data["refresh_token"].present? + assert_equal "newgoogleuser@example.com", response_data["user"]["email"] + assert_equal "New", response_data["user"]["first_name"] + assert_equal "GoogleUser", response_data["user"]["last_name"] + + # Linking code should be consumed + assert_nil Rails.cache.read("mobile_sso_link:#{linking_code}") + end + + test "should reject SSO create account when not allowed" do + linking_code = SecureRandom.urlsafe_base64(32) + Rails.cache.write("mobile_sso_link:#{linking_code}", { + provider: "google_oauth2", + uid: "google-uid-789", + email: "blocked@example.com", + first_name: "Blocked", + last_name: "User", + device_info: @device_info.stringify_keys, + allow_account_creation: false + }, expires_in: 10.minutes) + + assert_no_difference("User.count") do + post "/api/v1/auth/sso_create_account", params: { + linking_code: linking_code, + first_name: "Blocked", + last_name: "User" + } + end + + assert_response :forbidden + response_data = JSON.parse(response.body) + assert_match(/disabled/, response_data["error"]) + + # Linking code should NOT be consumed on rejection + assert Rails.cache.read("mobile_sso_link:#{linking_code}").present?, "Expected linking code to survive a rejected create account attempt" + end + + test "should reject SSO create account with expired linking code" do + post "/api/v1/auth/sso_create_account", params: { + linking_code: "expired-code", + first_name: "Test", + last_name: "User" + } + + assert_response :unauthorized + response_data = JSON.parse(response.body) + assert_equal "Linking code is invalid or expired", response_data["error"] + end + + test "should reject SSO create account without linking code" do + post "/api/v1/auth/sso_create_account", params: { + first_name: "Test", + last_name: "User" + } + + assert_response :bad_request + response_data = JSON.parse(response.body) + assert_equal "Linking code is required", response_data["error"] + end + + test "should return 422 when SSO create account fails user validation" do + existing_user = users(:family_admin) + + linking_code = SecureRandom.urlsafe_base64(32) + Rails.cache.write("mobile_sso_link:#{linking_code}", { + provider: "google_oauth2", + uid: "google-uid-dup-email", + email: existing_user.email, + first_name: "Duplicate", + last_name: "Email", + name: "Duplicate Email", + device_info: @device_info.stringify_keys, + allow_account_creation: true + }, expires_in: 10.minutes) + + assert_no_difference([ "User.count", "OidcIdentity.count" ]) do + post "/api/v1/auth/sso_create_account", params: { + linking_code: linking_code, + first_name: "Duplicate", + last_name: "Email" + } + end + + assert_response :unprocessable_entity + response_data = JSON.parse(response.body) + assert response_data["errors"].any? { |e| e.match?(/email/i) }, "Expected email validation error in: #{response_data["errors"]}" + end + + test "sso_create_account linking_code single-use under race" do + linking_code = SecureRandom.urlsafe_base64(32) + Rails.cache.write("mobile_sso_link:#{linking_code}", { + provider: "google_oauth2", + uid: "google-uid-race-create", + email: "raceuser@example.com", + first_name: "Race", + last_name: "CreateUser", + name: "Race CreateUser", + device_info: @device_info.stringify_keys, + allow_account_creation: true + }, expires_in: 10.minutes) + + # First request succeeds + assert_difference([ "User.count", "OidcIdentity.count" ], 1) do + post "/api/v1/auth/sso_create_account", params: { + linking_code: linking_code, + first_name: "Race", + last_name: "CreateUser" + } + end + assert_response :success + + # Second request with the same code is rejected + assert_no_difference([ "User.count", "OidcIdentity.count" ]) do + post "/api/v1/auth/sso_create_account", params: { + linking_code: linking_code, + first_name: "Race", + last_name: "CreateUser" + } + end + assert_response :unauthorized + assert_equal "Linking code is invalid or expired", JSON.parse(response.body)["error"] + assert_nil Rails.cache.read("mobile_sso_link:#{linking_code}") + end + test "should return forbidden when ai is not available" do user = users(:family_admin) user.update!(ai_enabled: false) diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index 8bdad7bf0..d3ec232ef 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -543,20 +543,39 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest { name: "openid_connect", strategy: "openid_connect", label: "Google" } ]) - get "/auth/mobile/openid_connect", params: { - device_id: "flutter-device-006", - device_name: "Pixel 8", - device_type: "android" - } - get "/auth/openid_connect/callback" + # 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 - assert_response :redirect - redirect_url = @response.redirect_url + 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 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["error"] - assert_nil session[:mobile_sso], "Expected mobile_sso session to be cleared" + 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