Skip to main content
The Sure mobile app supports multiple authentication methods, including email/password login, SSO (Single Sign-On) with OAuth 2.0, and API key authentication.

Authentication Methods

The app supports three authentication modes:

Email/Password

Standard login with optional two-factor authentication (MFA)

SSO (OAuth 2.0)

Sign in with Google or other configured identity providers

API Key

Authenticate using a personal API key

Email/Password Authentication

Login Flow

The standard login process follows these steps:
1

User Enters Credentials

User provides email and password on the login screen (login_screen.dart).
2

Device Information Collection

The app collects device metadata:
// lib/services/device_service.dart
{
  "device_id": "<UUID>",
  "device_name": "iPhone 15 Pro",
  "device_type": "ios",
  "os_version": "17.0",
  "app_version": "0.6.9"
}
3

API Request

The app sends a POST request to /api/v1/auth/login:
// lib/services/auth_service.dart
final response = await http.post(
  Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/login'),
  headers: {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
  },
  body: jsonEncode({
    'email': email,
    'password': password,
    'device': deviceInfo,
  }),
);
4

Token Storage

On success, the app receives and securely stores tokens:
{
  "access_token": "...",
  "refresh_token": "...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "created_at": 1709539200,
  "user": {
    "id": "...",
    "email": "[email protected]",
    "first_name": "John",
    "last_name": "Doe"
  }
}
5

Navigate to Dashboard

User is authenticated and redirected to the main dashboard.

Two-Factor Authentication (MFA)

When MFA is enabled on a user account:
1

Initial Login Attempt

User submits email and password. Backend responds with:
{
  "mfa_required": true,
  "error": "Two-factor authentication required"
}
2

Show OTP Input

The app displays a 6-digit OTP input field:
// AuthProvider sets state
_mfaRequired = true;
_showMfaInput = true;
3

Submit OTP Code

User enters the OTP from their authenticator app. The app retries login with the code:
final result = await _authService.login(
  email: email,
  password: password,
  deviceInfo: deviceInfo,
  otpCode: otpCode,  // Included in second attempt
);
4

Verify and Authenticate

Backend validates the OTP and issues tokens if correct.
MFA users cannot use SSO authentication on mobile. They must sign in with email and password.

SSO Authentication (OAuth 2.0)

The mobile app supports SSO through configured identity providers (e.g., Google).

SSO Login Flow

1

Initiate SSO

User taps “Sign in with Google” button. The app builds an SSO URL:
// lib/services/auth_service.dart
String buildSsoUrl({
  required String provider,
  required Map<String, String> 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']!,
  };
  return '${ApiConfig.baseUrl}/auth/mobile/$provider?${params}';
}
2

Open Browser

The app launches the system browser with the SSO URL:
await launchUrl(
  Uri.parse(ssoUrl),
  mode: LaunchMode.externalApplication
);
3

Server Handles SSO

On the backend, SessionsController#mobile_sso_start (sessions_controller.rb:117-143):
  1. Validates the provider is configured
  2. Verifies device information is present
  3. Stores device info in session:
    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]
    }
    
  4. Renders auto-submitting form to POST to OmniAuth
4

User Authenticates with IdP

User completes authentication with their identity provider (e.g., Google login).
5

OmniAuth Callback

Backend receives callback at SessionsController#openid_connect (sessions_controller.rb:145-210):
  1. Validates OIDC identity exists
  2. Checks if this is a mobile SSO flow (session[:mobile_sso].present?)
  3. If MFA is required, returns error (MFA not supported for mobile SSO)
  4. Calls handle_mobile_sso_callback (sessions_controller.rb:244-274):
    • Creates/updates MobileDevice record
    • Issues Doorkeeper OAuth tokens
    • Generates one-time authorization code
    • Stores tokens in Redis cache (5-minute expiry)
6

Redirect to App

Backend redirects to deep link:
redirect_to "sureapp://oauth/callback?code=#{authorization_code}"
7

App Deep Link Handler

The app receives the deep link via app_links package and extracts the code:
// Handles: sureapp://oauth/callback?code=...
final code = uri.queryParameters['code'];
8

Exchange Authorization Code

App exchanges the one-time code for tokens:
final response = await http.post(
  Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/sso_exchange'),
  body: jsonEncode({'code': code}),
);
Backend retrieves tokens from cache, returns them, and deletes the cache entry.
9

Store Tokens and Authenticate

App stores tokens securely and user is authenticated.

SSO Error Handling

Common SSO error scenarios:
Error: account_not_linkedCause: User’s identity provider account is not linked to a Sure account.Solution: User must first link their Google account from the web app:
  1. Log into Sure web app
  2. Go to Settings → Connected Accounts
  3. Link Google account
  4. Retry mobile SSO
Error: mfa_not_supportedCause: User has MFA enabled on their account.Solution: MFA users must sign in with email and password, not SSO.
Error: invalid_providerCause: SSO provider is not configured on the backend.Solution: Administrator must configure the provider in config/omniauth.yml or via database.
Error: missing_device_infoCause: Device information is incomplete.Solution: This is an app bug. Ensure DeviceService.getDeviceInfo() returns all required fields.

API Key Authentication

For programmatic access or testing, users can authenticate with an API key:
1

Generate API Key

Users create an API key from the web app:
  1. Settings → API Keys
  2. Click “Create New API Key”
  3. Copy the key (shown only once)
2

Enter in Mobile App

On the login screen, switch to “API Key” tab and paste the key.
3

Validate Key

App validates the key by making a test API request:
// lib/services/auth_service.dart
final response = await http.get(
  Uri.parse('${ApiConfig.baseUrl}/api/v1/accounts'),
  headers: {
    'X-Api-Key': apiKey,
    'Accept': 'application/json',
  },
);

if (response.statusCode == 200) {
  await _saveApiKey(apiKey);
  return {'success': true};
}
4

Store and Use

If valid, the API key is stored and used for all subsequent requests:
// lib/services/api_config.dart
static Map<String, String> getAuthHeaders(String token) {
  if (_isApiKeyAuth && _apiKeyValue != null) {
    return {'X-Api-Key': _apiKeyValue!, 'Accept': 'application/json'};
  }
  return {'Authorization': 'Bearer $token', 'Accept': 'application/json'};
}
API keys provide full account access and never expire. Store them securely and revoke unused keys.

Token Management

Token Storage

Tokens are stored using Flutter Secure Storage:
Tokens are stored in the iOS Keychain:
  • Encrypted by the system
  • Tied to the app’s bundle ID
  • Automatically deleted when app is uninstalled
  • Survives app updates
// lib/services/auth_service.dart
class AuthService {
  final FlutterSecureStorage _storage = const FlutterSecureStorage();
  static const String _tokenKey = 'auth_tokens';
  static const String _userKey = 'user_data';
  
  Future<void> _saveTokens(AuthTokens tokens) async {
    await _storage.write(
      key: _tokenKey,
      value: jsonEncode(tokens.toJson()),
    );
  }
}

Token Refresh

Access tokens expire after a configured duration (typically 1 hour). The app automatically refreshes them:
1

Check Expiration

Before each API request, check if the access token is expired:
// lib/models/auth_tokens.dart
bool get isExpired {
  final expirationTime = DateTime.fromMillisecondsSinceEpoch(
    (createdAt + expiresIn) * 1000,
  );
  return DateTime.now().isAfter(expirationTime);
}
2

Refresh if Needed

If expired, refresh using the refresh token:
// lib/providers/auth_provider.dart
Future<String?> getValidAccessToken() async {
  if (_tokens == null) return null;
  
  if (_tokens!.isExpired) {
    final refreshed = await _refreshToken();
    if (!refreshed) return null;
  }
  
  return _tokens?.accessToken;
}
3

Call Refresh Endpoint

// lib/services/auth_service.dart
final response = await http.post(
  Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/refresh'),
  body: jsonEncode({
    'refresh_token': refreshToken,
    'device': deviceInfo,
  }),
);
4

Update Stored Tokens

On success, store the new tokens and retry the original request.
5

Logout on Failure

If refresh fails (invalid or expired refresh token), log the user out:
if (!refreshed) {
  await logout();
  return null;
}

Token Lifecycle

Device Management

Each mobile device is tracked on the backend:

Device Information

Collected on every login:
// lib/services/device_service.dart
class DeviceService {
  Future<Map<String, String>> getDeviceInfo() async {
    final deviceInfo = await DeviceInfoPlugin();
    final packageInfo = await PackageInfo.fromPlatform();
    
    return {
      'device_id': await _getOrCreateDeviceId(),
      'device_name': _getDeviceName(deviceInfo),
      'device_type': Platform.isIOS ? 'ios' : 'android',
      'os_version': _getOsVersion(deviceInfo),
      'app_version': packageInfo.version,
    };
  }
}

Backend Device Records

The backend creates/updates a MobileDevice record:
# app/models/mobile_device.rb
class MobileDevice < ApplicationRecord
  belongs_to :user
  
  def self.upsert_device!(user, device_info)
    # Creates or updates device record
    # Tracks: device_id, device_name, device_type, os_version, app_version
    # Records: last_used_at, created_at
  end
  
  def issue_token!
    # Issues Doorkeeper OAuth token for this device
  end
end
Users can view and revoke device access from the web app (Settings → Devices).

Authentication State

The app uses Provider for authentication state management:
// lib/providers/auth_provider.dart
class AuthProvider with ChangeNotifier {
  User? _user;
  AuthTokens? _tokens;
  String? _apiKey;
  bool _isApiKeyAuth = false;
  bool _isLoading = true;
  String? _errorMessage;
  bool _mfaRequired = false;
  
  bool get isAuthenticated =>
      (_isApiKeyAuth && _apiKey != null) ||
      (_tokens != null && !_tokens!.isExpired);
}

Using Auth State in UI

// Example: Conditional rendering based on auth state
Widget build(BuildContext context) {
  return Consumer<AuthProvider>(
    builder: (context, auth, child) {
      if (auth.isLoading) {
        return LoadingScreen();
      }
      
      if (!auth.isAuthenticated) {
        return LoginScreen();
      }
      
      return DashboardScreen();
    },
  );
}

Logout

Logging out clears all authentication state:
// lib/providers/auth_provider.dart
Future<void> logout() async {
  await _authService.logout();  // Clear secure storage
  _tokens = null;
  _user = null;
  _apiKey = null;
  _isApiKeyAuth = false;
  _errorMessage = null;
  _mfaRequired = false;
  ApiConfig.clearApiKeyAuth();
  notifyListeners();
}
Logout only clears local data. Backend sessions remain active until they expire or are revoked.

Security Best Practices

Secure Storage

  • Never store tokens in SharedPreferences
  • Always use FlutterSecureStorage for sensitive data
  • Tokens are encrypted at rest

Token Rotation

  • Access tokens expire regularly (1 hour)
  • Refresh tokens rotate on use
  • Old tokens are immediately invalidated

Device Tracking

  • Each device has a unique OAuth token
  • Users can revoke device access remotely
  • Monitor devices from web app

HTTPS Only

  • Production requires HTTPS
  • Certificate pinning (future enhancement)
  • No tokens sent over unencrypted connections

Troubleshooting

Check:
  1. Backend server is running and accessible
  2. Correct backend URL in lib/services/api_config.dart
  3. Network connectivity (WiFi/cellular)
  4. View logs: flutter logs
Test backend:
curl -X POST http://your-server/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","password":"password"}'
Check:
  1. Deep link scheme registered: sureapp://
  2. app_links package configured in android/app/src/main/AndroidManifest.xml and ios/Runner/Info.plist
  3. SSO provider configured on backend
  4. Account is linked in web app
View deep link logs:
// Add to main.dart
appLinks.allUriLinkStream.listen((uri) {
  print('Received deep link: $uri');
});
Possible causes:
  1. Refresh token expired (user was inactive for extended period)
  2. Backend clock skew causing expiration calculation errors
  3. Refresh token revoked (user changed password)
Solution: Clear app data and login again
Check:
  1. API key is valid (not revoked)
  2. API key has correct permissions
  3. Backend accepts X-Api-Key header
Test:
curl http://your-server/api/v1/accounts \
  -H "X-Api-Key: your-api-key"

Next Steps

  • Review API Documentation for available endpoints
  • Explore state management in lib/providers/
  • Implement custom authentication screens
  • Add biometric authentication (future feature)

Backend Controller

app/controllers/sessions_controller.rb - Handles web and mobile SSO

Auth Service

mobile/lib/services/auth_service.dart - Mobile authentication logic

Auth Provider

mobile/lib/providers/auth_provider.dart - State management

Device Service

mobile/lib/services/device_service.dart - Device info collection

Build docs developers (and LLMs) love