MySched

Authentication

MySched supports three authentication methods via Supabase Auth: Google OAuth, Apple Sign-In, and email/password. The auth pipeline includes a 7-file error handling system, session management with device tracking, and remote session revocation.

Supported Auth Methods

MethodPlatformNotes
Google OAuthiOS, AndroidPrimary sign-in method
Apple Sign-IniOSRequired by App Store guidelines
Email/PasswordiOS, AndroidFallback method with email verification

Auth Flow

User taps "Sign In"
       │
       ▼
┌─────────────────┐
│  Select Method   │
│  Google / Apple  │
│  / Email         │
└────────┬────────┘
         │
         ▼
┌─────────────────┐     ┌──────────────┐
│  Supabase Auth   │────▶│  Error?       │
│  (OAuth / email) │     │  → Pipeline   │
└────────┬────────┘     └──────────────┘
         │ Success
         ▼
┌─────────────────┐
│  Register Device │
│  (user_devices)  │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  Load Profile    │
│  → ProfileCache  │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  Navigate to     │
│  Dashboard       │
└─────────────────┘

Auth Error Pipeline

Authentication errors are handled by a dedicated 7-file pipeline in lib/core/auth/:

lib/core/auth/
├── auth_error.dart           # Base error types and codes
├── auth_error_handler.dart   # Central error routing
├── auth_error_messages.dart  # User-facing error messages
├── auth_error_logger.dart    # Error logging and analytics
├── auth_retry_policy.dart    # Retry logic for transient failures
├── auth_state_machine.dart   # Auth state transitions
└── auth_validator.dart       # Input validation (email, password)

Error Types

Dart
enum AuthErrorCode {
  // Network
  networkTimeout,
  noConnection,

  // OAuth
  oauthCancelled,
  oauthDenied,
  oauthInvalidGrant,

  // Email/Password
  invalidEmail,
  weakPassword,
  emailAlreadyInUse,
  userNotFound,
  wrongPassword,

  // Session
  sessionExpired,
  tokenRefreshFailed,
  deviceRevoked,

  // Unknown
  unknown,
}

class AuthError {
  final AuthErrorCode code;
  final String message;
  final dynamic originalError;

  bool get isRetryable => [
    AuthErrorCode.networkTimeout,
    AuthErrorCode.noConnection,
    AuthErrorCode.tokenRefreshFailed,
  ].contains(code);
}

Error Handler

Dart
class AuthErrorHandler {
  static AuthError handle(dynamic error) {
    if (error is AuthException) {
      return _mapSupabaseError(error);
    }
    if (error is SocketException || error is TimeoutException) {
      return AuthError(
        code: AuthErrorCode.networkTimeout,
        message: AuthErrorMessages.networkTimeout,
        originalError: error,
      );
    }
    return AuthError(
      code: AuthErrorCode.unknown,
      message: AuthErrorMessages.unknown,
      originalError: error,
    );
  }

  static AuthError _mapSupabaseError(AuthException error) {
    switch (error.statusCode) {
      case '400': return _mapBadRequest(error);
      case '401': return AuthError(code: AuthErrorCode.sessionExpired, ...);
      case '422': return AuthError(code: AuthErrorCode.invalidEmail, ...);
      default: return AuthError(code: AuthErrorCode.unknown, ...);
    }
  }
}

Session Management

Sessions are managed through Supabase Auth with automatic token refresh and device tracking.

Token Lifecycle

Dart
class SessionManager {
  Timer? _refreshTimer;

  void startSession(Session session) {
    // Register this device
    _registerDevice();

    // Schedule token refresh before expiry
    final expiresIn = session.expiresIn ?? 3600;
    final refreshAt = Duration(seconds: expiresIn - 300); // 5 min before
    _refreshTimer = Timer(refreshAt, _refreshToken);
  }

  Future<void> _refreshToken() async {
    try {
      final response = await supabase.auth.refreshSession();
      if (response.session != null) {
        startSession(response.session!); // Reschedule
      }
    } catch (e) {
      final error = AuthErrorHandler.handle(e);
      if (!error.isRetryable) {
        _forceSignOut();
      }
    }
  }

  Future<void> endSession() async {
    _refreshTimer?.cancel();
    await _deregisterDevice();
    await supabase.auth.signOut();
    ProfileCache().profile.value = null;
  }
}

Device Registration

Each device is tracked in the user_devices table for multi-device session management:

Dart
Future<void> _registerDevice() async {
  final deviceInfo = await DeviceInfoPlugin().deviceInfo;

  await supabase.from('user_devices').upsert({
    'user_id': supabase.auth.currentUser!.id,
    'device_name': deviceInfo.name,
    'device_platform': Platform.isIOS ? 'ios' : 'android',
    'push_token': await FirebaseMessaging.instance.getToken(),
    'last_active_at': DateTime.now().toIso8601String(),
    'is_revoked': false,
  }, onConflict: 'user_id, device_platform, device_name');
}

Remote Revocation

Users can revoke sessions on other devices from the Settings screen:

Dart
// Revoke a specific device session
Future<void> revokeDevice(String deviceId) async {
  await supabase
      .from('user_devices')
      .update({'is_revoked': true})
      .eq('id', deviceId)
      .eq('user_id', supabase.auth.currentUser!.id);
}

On app launch, the client checks if its device has been revoked:

Dart
Future<bool> isDeviceRevoked() async {
  final response = await supabase
      .from('user_devices')
      .select('is_revoked')
      .eq('user_id', supabase.auth.currentUser!.id)
      .eq('device_name', _currentDeviceName)
      .single();

  return response['is_revoked'] == true;
}

Auth Guards

Routes are protected using GoRouter's redirect mechanism:

Dart
GoRouter(
  redirect: (context, state) {
    final isAuthenticated = supabase.auth.currentSession != null;
    final isAuthRoute = state.matchedLocation.startsWith('/auth');

    if (!isAuthenticated && !isAuthRoute) {
      return '/auth/sign-in';
    }
    if (isAuthenticated && isAuthRoute) {
      return '/dashboard';
    }
    return null; // No redirect
  },
  routes: [
    GoRoute(path: '/auth/sign-in', builder: ...),
    GoRoute(path: '/dashboard', builder: ...),
    // ...
  ],
)

Security Considerations

  • OAuth tokens are never stored client-side — Supabase handles token storage securely
  • JWT expiry is set to 1 hour — Refresh tokens have a 7-day lifetime
  • Auth state changes are serialized — Prevents race conditions (see Supabase Backend)
  • Password requirements: Minimum 8 characters (enforced by AuthValidator)
  • Email verification is required for email/password accounts before first sign-in
  • Rate limiting is enforced by Supabase (default: 30 requests/minute for auth endpoints)