6.7 KiB
6.7 KiB
Flutter Auth Integration Design
Date: 2026-02-25 Status: Approved
Summary
Integrate Flutter mobile app with backend auth APIs, including signup, login, token management, and route protection.
Scope
- Signup flow:
/auth/signup/start+/auth/signup/verify - Login flow:
/auth/login - Token management: access token, refresh token, secure storage
- Route protection: redirect unauthenticated users
Out of scope:
- OTP login (removed from UI)
- Password reset
- Social login
Architecture
Directory Structure
lib/
├── core/
│ ├── api/
│ │ ├── api_client.dart # dio wrapper
│ │ ├── api_interceptor.dart # token injection, refresh logic
│ │ └── api_exception.dart # unified error types
│ ├── storage/
│ │ └── token_storage.dart # flutter_secure_storage wrapper
│ └── di/
│ └── injection.dart # get_it configuration
│
├── features/auth/
│ ├── data/
│ │ ├── models/
│ │ │ ├── signup_request.dart
│ │ │ ├── login_request.dart
│ │ │ └── auth_response.dart
│ │ ├── auth_api.dart # API interface
│ │ ├── auth_repository.dart # abstract interface
│ │ └── auth_repository_impl.dart # implementation
│ ├── presentation/
│ │ ├── bloc/
│ │ │ ├── auth_bloc.dart
│ │ │ ├── auth_event.dart
│ │ │ └── auth_state.dart
│ │ └── cubits/
│ │ ├── register_cubit.dart # signup form state
│ │ └── login_cubit.dart # login form state
│ └── ui/screens/ # existing UI adjustments
│
└── main.dart # DI initialization
API Layer
ApiInterceptor responsibilities:
- Auto-inject
Authorization: Bearer <access_token> - On 401 response, auto-refresh token and retry
- If refresh fails, trigger logout
AuthApi interface:
abstract class AuthApi {
Future<AuthResponse> signupStart(SignupStartRequest request);
Future<AuthResponse> signupVerify(SignupVerifyRequest request);
Future<AuthResponse> signupResend(String email);
Future<AuthResponse> login(LoginRequest request);
Future<AuthResponse> refresh(String refreshToken);
Future<void> logout(String refreshToken);
}
TokenStorage interface:
abstract class TokenStorage {
Future<String?> getAccessToken();
Future<String?> getRefreshToken();
Future<void> saveTokens({required String access, required String refresh});
Future<void> clear();
}
Error handling:
ApiExceptionwraps network errors, timeouts, 4xx/5xx- UI layer displays user-friendly messages via
ApiException
State Management
AuthBloc (global auth state):
abstract class AuthState {}
class AuthInitial extends AuthState {}
class AuthAuthenticated extends AuthState {
final User user;
final String accessToken;
}
class AuthUnauthenticated extends AuthState {}
class AuthLoading extends AuthState {}
// Events
class AuthStarted extends AuthEvent {} // Check token on app start
class AuthLoggedIn extends AuthEvent {} // Login success
class AuthLoggedOut extends AuthEvent {} // Logout
class AuthTokenRefreshed extends AuthEvent {} // Token refreshed
RegisterCubit (signup form):
class RegisterState {
final Username username; // formz input
final Email email;
final Password password;
final FormzSubmissionStatus status;
final String? errorMessage;
final String? pendingEmail; // stored after signup/start success
}
LoginCubit (login form):
class LoginState {
final Email email;
final Password password;
final FormzSubmissionStatus status;
final String? errorMessage;
}
UI Adjustments
Signup flow changes:
| Original | New |
|---|---|
| Step1: nickname + email | Step1: username + email + password -> call /signup/start |
| Step2: password + code + invite | Step2: code + invite(kept) -> call /signup/verify |
Login flow changes:
- Remove "Login with OTP" button
login_email_screen.dart->login_password_screen.dartpasses email- Password page calls
/login, navigates to/homeon success
Route protection:
- Use
GoRouterredirectlogic - Unauthenticated user accessing protected route -> redirect to
/ - Authenticated user accessing
/loginor/register-> redirect to/home
redirect: (context, state) {
final isAuthenticated = // check AuthBloc state;
final isAuthRoute = state.matchedLocation.startsWith('/login')
|| state.matchedLocation.startsWith('/register');
if (!isAuthenticated && !isAuthRoute) return '/';
if (isAuthenticated && isAuthRoute) return '/home';
return null;
}
Dependency Injection
DI configuration:
final sl = GetIt.instance;
Future<void> configureDependencies() async {
// Core
sl.registerSingleton<Dio>(Dio(BaseOptions(baseUrl: env.apiUrl)));
sl.registerSingleton<TokenStorage>(SecureTokenStorage());
sl.registerSingleton<ApiClient>(ApiClient(sl(), sl()));
// Auth
sl.registerSingleton<AuthApi>(AuthApiImpl(sl()));
sl.registerSingleton<AuthRepository>(AuthRepositoryImpl(sl(), sl()));
sl.registerSingleton<AuthBloc>(AuthBloc(sl()));
}
App initialization flow:
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await configureDependencies();
final authBloc = sl<AuthBloc>();
authBloc.add(AuthStarted()); // Check local token
runApp(MyApp(authBloc: authBloc));
}
Complete flow:
App Start
|
AuthStarted event
|
Check TokenStorage
|
Has token? --No--> AuthUnauthenticated -> Show login
|
Yes
|
Validate token (call /auth/refresh)
|
Success? --No--> Clear token -> AuthUnauthenticated
|
Yes
|
AuthAuthenticated -> Navigate to /home
Decisions
- Adjust Flutter UI to match backend API - Move password to Step1, call
/signup/startwith all data - Keep invite code UI but don't send - Backend doesn't support yet, preserve UI for future
- Remove OTP login entry - Backend doesn't have OTP login API
- Use complete Bloc architecture - AuthBloc + Cubits for forms, better testability and extensibility
Risks
- Token refresh race conditions: ApiInterceptor should handle concurrent requests during refresh
- Secure storage availability: fallback to SharedPreferences on platforms without secure storage
Testing Strategy
- Unit tests for: Cubits, AuthBloc, Repository, API client
- Widget tests for: form validation, error display
- Integration tests for: complete signup/login flows