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
| Method | Platform | Notes |
|---|---|---|
| Google OAuth | iOS, Android | Primary sign-in method |
| Apple Sign-In | iOS | Required by App Store guidelines |
| Email/Password | iOS, Android | Fallback 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)