awesome_node_auth_flutter 1.10.0
awesome_node_auth_flutter: ^1.10.0 copied to clipboard
A Flutter/Dart authentication client for the awesome-node-auth backend. Supports web (including WASM) via cookie + CSRF and native platforms via Bearer token.
awesome_node_auth_flutter #
Flutter/Dart authentication client for awesome-node-auth backends.
Supports web (including WASM) via HttpOnly cookies + CSRF, and native (iOS, Android, Desktop) via Bearer token.
Table of Contents #
- Installation
- Quick start
- Examples
- Platform behaviour
- AuthOptions
- AuthState & userStream
- Events stream
- Registration
- Login & 2FA
- Two-factor authentication (2FA)
- Password management
- Email management
- Active sessions
- OAuth (redirect-based)
- Account linking
- AuthUser model
- AuthResult<T>
- Custom TokenStorage
- Internal behaviour — automatic refresh
- Headless mode
- Authenticated HTTP client (interceptor)
- SSE stream
- UI config
- WASM compatibility
- License
Installation #
dependencies:
awesome_node_auth_flutter: ^1.10.0
Quick start #
import 'package:awesome_node_auth_flutter/awesome_node_auth_flutter.dart';
// 1. Create the client (checkSession is called automatically)
final auth = AuthClient(AuthOptions(apiPrefix: '/api/auth'));
// 2. React to state changes
auth.state.userStream.listen((user) {
if (user != null) {
print('Logged in: ${user.email}');
} else {
print('Not authenticated');
}
});
// 3. Login
final result = await auth.login('user@example.com', 'password');
if (result.success && !result.requires2fa) {
print('Welcome, ${result.data?.firstName}');
} else if (result.requires2fa) {
// Proceed to the 2FA step — see the Login & 2FA section
}
Examples #
A complete working example with Flutter app + Node.js backend is available in the example/ directory.
Run the Example #
1. Start the Node.js server (in-memory user store):
cd example/server
npm install
npm start
# Runs on http://localhost:3000
2. Run the Flutter app:
cd example
flutter pub get
flutter run
Test with demo credentials:
- Email:
demo@example.com - Password:
demo123
See example/README.md for full setup guide, architecture diagram, API endpoints, and customization options.
Platform behaviour #
| Platform | Auth mode | Token management | CSRF |
|---|---|---|---|
| Web / WASM | Cookie (HttpOnly) | Browser automatic | X-CSRF-Token same-origin only |
| iOS / Android / Desktop | Bearer token | TokenStorage (in-memory default) |
N/A |
On web the browser handles cookie storage. The client reads the CSRF token from document.cookie and attaches the X-CSRF-Token header to every same-origin mutating request.
On native platforms the access token is stored in TokenStorage (default: InMemoryTokenStorage). Provide a custom implementation backed by flutter_secure_storage for persistence across app restarts.
AuthOptions #
All constructor parameters are optional; sensible defaults are provided.
final auth = AuthClient(
AuthOptions(
apiPrefix: '/api/auth', // Relative or absolute base URL for auth endpoints (default: '/auth')
homeUrl: '/', // Redirect destination after successful login (default: '/')
loginUrl: '/login', // Redirect destination when session expires (web, headless: false only)
// Defaults to '$apiPrefix/ui/login' when omitted
headless: false, // true = disable automatic redirects; listen to events instead
initializeOnStartup: true, // Calls checkSession() automatically on construction (default: true)
tokenStorage: MySecureStorage(), // Native only: custom persistent storage for Bearer tokens
),
);
// If initializeOnStartup is false, call checkSession() explicitly:
await auth.checkSession();
⚠️ Race condition with
initializeOnStartup: true
initializeOnStartup: true(the default) callscheckSession()as a fire-and-forget task in the constructor — Dart constructors cannotawaitit. If you make authenticated calls immediately after constructingAuthClient, the session may not be loaded yet and you could receive unexpected 401 responses.Option 1 — wait for the
initializedevent before making authenticated calls:await auth.events.firstWhere((e) => e.type == AuthEventType.initialized); // Now safe to make authenticated callsOption 2 — disable
initializeOnStartupandawaitexplicitly:final auth = AuthClient(AuthOptions( apiPrefix: '/api/auth', initializeOnStartup: false, )); await auth.checkSession(); // explicit await before use
AuthState & userStream #
auth.state is the reactive authentication state object. It exposes:
| Member | Type | Description |
|---|---|---|
userStream |
Stream<AuthUser?> |
Emits the current user on every change. Replays the current value to new subscribers immediately. |
currentUser |
AuthUser? |
Synchronous access to the current user. |
isAuthenticated |
bool |
true when a user is signed in. |
isInitialized |
bool |
true after the first checkSession() call completes. |
StreamBuilder (Flutter) #
StreamBuilder<AuthUser?>(
stream: auth.state.userStream,
builder: (context, snapshot) {
final user = snapshot.data;
if (!auth.state.isInitialized) {
return const CircularProgressIndicator();
}
if (user == null) {
return const LoginPage();
}
return HomePage(user: user);
},
);
Synchronous access #
if (auth.state.isAuthenticated) {
final user = auth.state.currentUser!;
print(user.email);
}
Note:
userStreamis implemented withasync*+yieldso it always replays the latest value to new subscribers before emitting future changes.
Events stream #
auth.events is a broadcast stream of AuthEvent objects. Subscribe to it to react to lifecycle changes outside the UI tree (e.g. in a GoRouter redirect, background service, or logging layer).
auth.events.listen((event) {
switch (event.type) {
case AuthEventType.initialized:
// First checkSession() completed (success or failure)
print('Auth ready. User: ${event.user?.email}');
case AuthEventType.loggedIn:
// User authenticated successfully
print('Logged in as ${event.user?.email}');
case AuthEventType.loggedOut:
// User logged out (manually or after a failed refresh)
print('Logged out');
case AuthEventType.sessionExpired:
// Access token refresh failed — session is gone
print('Session expired');
case AuthEventType.sessionRevoked:
// Backend returned SESSION_REVOKED — forced logout
print('Session was revoked server-side');
case AuthEventType.emailChanged:
// User confirmed an email address change
print('Email changed — new address: ${auth.state.currentUser?.email}');
}
});
| Event type | Trigger |
|---|---|
initialized |
First checkSession() completed (regardless of outcome) |
loggedIn |
Successful login, magic-link / SMS / TOTP verification, OAuth callback, or conflict-linking resolution |
loggedOut |
Explicit logout() or failed token refresh |
sessionExpired |
Token refresh call returned a non-2xx response |
sessionRevoked |
Backend returned { "code": "SESSION_REVOKED" } |
emailChanged |
User successfully confirmed an email address change via confirmEmailChange() |
Note: The
initializedevent is emitted whethercheckSession()succeeds or fails (e.g. network error, 401 on startup). It only signals that the first session check has completed — not that the user is authenticated. Always checkauth.state.isAuthenticatedor theevent.uservalue before treating the session as valid.
Registration #
final result = await auth.register(
'user@example.com', // email
'strongPassword1!', // password
'Jane', // firstName
'Doe', // lastName
);
if (result.success) {
print('New user ID: ${result.data}');
} else {
print('Registration failed: ${result.error}');
}
Login & 2FA #
Simple login (no 2FA) #
final result = await auth.login('user@example.com', 'password');
if (result.success && !result.requires2fa) {
// Authenticated — state is already updated
print('Welcome ${result.data?.firstName}');
} else if (!result.success) {
print('Login failed: ${result.error}');
}
Login with 2FA required #
final result = await auth.login('user@example.com', 'password');
if (result.success && !result.requires2fa) {
// Direct login — no 2FA needed
} else if (result.requires2fa && !result.requires2FASetup) {
// User has 2FA enabled — verify with one of the available methods
print('Available 2FA methods: ${result.availableMethods}');
// Use result.tempToken for the chosen 2FA call
await _handle2fa(result.tempToken!, result.availableMethods);
} else if (result.requires2fa && result.requires2FASetup) {
// User is required to set up 2FA before continuing
await _setup2fa(result.tempToken!);
}
LoginResult extends AuthResult<AuthUser> with additional fields:
| Field | Type | Description |
|---|---|---|
requires2fa |
bool |
true when a 2FA step is needed before authentication completes |
requires2FASetup |
bool |
true when the user must enrol in 2FA first |
tempToken |
String? |
Temporary session token to pass to the 2FA verification calls |
availableMethods |
List<String> |
Methods the user can use (e.g. ['totp', 'sms', 'magic-link']) |
Two-factor authentication (2FA) #
TOTP #
// 1. Start setup — returns a QR code and secret
final setup = await auth.setup2fa();
if (setup.success) {
final qrCode = setup.data!.qrCode; // data URL — render with Image.network / Image.memory
final secret = setup.data!.secret; // show as fallback text entry
}
// 2. Confirm setup with the code from the authenticator app
final verify = await auth.verify2faSetup(totpCode, secret);
if (verify.success) {
print('TOTP enabled');
}
// 3. During login, validate the code using the temp token
final validate = await auth.validate2fa(tempToken, totpCode);
if (validate.success) {
// User is now fully authenticated — state updated automatically
}
// 4. Disable TOTP (requires active session)
await auth.disable2fa();
SMS one-time password #
// ── Passwordless login via SMS OTP ───────────────────────────────────────────
// 1. Send an OTP to the user's registered phone
await auth.sendSmsLogin('user@example.com');
// 2. Verify the OTP (userId is returned by the backend in the send response)
final result = await auth.verifySmsLogin(userId, otpCode);
if (result.success) {
// Authenticated — state updated automatically
}
// ── 2FA step via SMS ─────────────────────────────────────────────────────────
// 1. Request an SMS OTP during the 2FA step of a password login
await auth.send2faSms(tempToken);
// 2. Verify the code
final result2fa = await auth.validateSms(tempToken, otpCode);
if (result2fa.success) {
// Fully authenticated — state updated automatically
}
// ── Register a phone number ───────────────────────────────────────────────────
await auth.addPhone('+1234567890');
Magic link #
// ── Passwordless login via magic link ────────────────────────────────────────
// 1. Send a magic link to the user's email
await auth.sendMagicLink('user@example.com');
// 2. When the user clicks the link, the app receives the token (e.g. via deep link)
final result = await auth.verifyMagicLink(token);
if (result.success) {
// Authenticated — state updated automatically
}
// ── 2FA step via magic link ───────────────────────────────────────────────────
// 1. Send the magic link using the temp token from the login flow
await auth.send2faMagicLink(tempToken);
// 2. Verify the token when the user clicks the link
final result2fa = await auth.verifyMagicLink(token);
Password management #
// Initiate password recovery — sends a reset email
final forgot = await auth.forgotPassword('user@example.com');
// Reset password using the token from the recovery email
final reset = await auth.resetPassword('newPassword1!', resetToken);
// Change password for an authenticated user
final change = await auth.changePassword('currentPassword', 'newPassword1!');
// Set a password for an OAuth-only account (no existing password)
final set = await auth.setPassword('newPassword1!');
// Check result
if (reset.success) {
print('Password updated');
} else {
print('Error: ${reset.error}');
}
Email management #
// Resend the verification email for the current user
await auth.resendVerificationEmail();
// Verify the email using the token from the verification email
final verify = await auth.verifyEmail(emailToken);
// Request an email address change (sends a confirmation to newEmail)
await auth.requestEmailChange('new@example.com');
// Confirm the email change using the token from the confirmation email
final confirm = await auth.confirmEmailChange(confirmToken);
Active sessions #
// List all active sessions for the current user
final sessions = await auth.getActiveSessions();
for (final session in sessions) {
print('Handle: ${session.handle}');
print('User-agent: ${session.userAgent}');
print('IP address: ${session.ipAddress}');
print('Created at: ${session.createdAt}');
print('Last active: ${session.lastActiveAt}');
print('Current: ${session.isCurrent}');
print('---');
}
// Revoke a specific session (e.g. from a "sign out everywhere" screen)
final result = await auth.revokeSession(session.handle);
if (result.success) {
print('Session revoked');
}
// Remove expired / invalid sessions from the server store
final cleanup = await auth.cleanupSessions();
if (cleanup.success) {
print('Session store cleaned up');
}
SessionInfo fields:
| Field | Type | Description |
|---|---|---|
handle |
String |
Unique session identifier — pass to revokeSession() |
userAgent |
String? |
User-agent string of the client |
ipAddress |
String? |
IP address of the client |
createdAt |
DateTime? |
When the session was created |
lastActiveAt |
DateTime? |
Timestamp of the last activity |
isCurrent |
bool |
true for the session that belongs to the current request |
OAuth (redirect-based) #
The library provides helpers to initiate OAuth provider flows. The actual redirect is always browser-driven (web) or opened in a webview / system browser (native); the library does not embed a browser itself.
// 1. Get the redirect URL for the chosen provider
final url = auth.getOAuthUrl('github');
// → 'https://api.example.com/auth/oauth/github'
// ── Web ───────────────────────────────────────────────────────────────────────
// Redirect the browser — the callback is handled server-side and the session
// cookie is set automatically.
// (In headless / SPA mode you may use window.location.href or your router)
// ── Native (iOS / Android / Desktop) ─────────────────────────────────────────
// Open the URL in a webview or system browser, e.g. with url_launcher:
// await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
//
// Configure your deep-link scheme in Android's AndroidManifest.xml (intent-filter)
// and iOS's Info.plist (CFBundleURLTypes) so the OAuth callback redirects back
// to your app.
// 2. After the callback, pick up the session
final user = await auth.handleOAuthCallback();
if (user != null) {
print('OAuth login successful: ${user.email}');
// AuthEventType.loggedIn is emitted automatically
}
Background token refresh on app resume #
The client does not hook into the Flutter app lifecycle automatically. Add a
checkSession() call in your AppLifecycleListener.onResume (Flutter ≥ 3.13)
or WidgetsBindingObserver.didChangeAppLifecycleState handler to restore the
session after the app returns to the foreground:
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
auth.checkSession(); // silently restores or expires the session
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
}
Deep-link handling for email flows (native) #
Wire your platform deep-link handler to call the appropriate client method:
| Deep-link token type | Client method |
|---|---|
| Email verification | auth.verifyEmail(token) |
| Password reset | auth.resetPassword(newPassword, token) |
| Magic link | auth.verifyMagicLink(token) |
| OAuth callback | auth.handleOAuthCallback() |
Account linking #
Link a second OAuth provider to an existing account.
// 1. Request a linking email for the given provider
await auth.requestLinkingEmail('user@example.com', 'github');
// 2. Verify the token from the linking email
await auth.verifyLinkingToken(linkingToken, 'github');
// ── Conflict resolution ───────────────────────────────────────────────────────
// When the provider account already belongs to another user, a conflict occurs.
// 1. Request a conflict-resolution linking email
await auth.requestConflictLinkingEmail('user@example.com', 'github');
// 2. Verify the conflict token (merges accounts and logs the user in)
await auth.verifyConflictLinkingToken(conflictToken);
// ── Manage linked accounts ────────────────────────────────────────────────────
// List all linked OAuth providers for the current user
final accounts = await auth.getLinkedAccounts();
// Returns a List<dynamic> where each item has 'provider' and 'providerAccountId'
// Remove a linked provider
await auth.unlinkAccount('github', providerAccountId);
AuthUser model #
Every field returned by the backend /me endpoint is mapped to this model.
| Field | Type | Description |
|---|---|---|
sub |
String |
Stable unique user identifier |
email |
String |
Email address |
isEmailVerified |
bool |
Whether the email has been verified |
id |
String? |
Optional database ID (may differ from sub) |
firstName |
String? |
First name |
lastName |
String? |
Last name |
name |
String? |
Display name |
phoneNumber |
String? |
Registered phone number |
role |
String? |
Primary role string |
roles |
List<String>? |
All roles assigned to the user |
permissions |
List<String>? |
Permissions granted to the user |
isAdmin |
bool? |
Whether the user has administrator privileges |
loginProvider |
String? |
OAuth provider used for the last login (e.g. 'github') |
isTotpEnabled |
bool? |
Whether TOTP 2FA is active |
hasPassword |
bool? |
false for OAuth-only accounts that have never set a password |
lastLogin |
DateTime? |
Timestamp of the most recent login |
metadata |
Map<String, dynamic>? |
Arbitrary key-value data attached by the backend |
final user = auth.state.currentUser!;
print(user.sub); // '64a1f...'
print(user.email); // 'jane@example.com'
print(user.isEmailVerified); // true
print(user.firstName); // 'Jane'
print(user.roles); // ['user', 'editor']
print(user.isAdmin); // false
print(user.isTotpEnabled); // true
print(user.hasPassword); // true
print(user.loginProvider); // null (password login)
AuthResult<T> #
Most AuthClient methods return AuthResult<T>. LoginResult is a subtype returned only by login().
AuthResult<T> #
| Field | Type | Description |
|---|---|---|
success |
bool |
true when the operation succeeded |
data |
T? |
Payload on success (null on failure) |
error |
String? |
Human-readable error message on failure |
errorCode |
String? |
Machine-readable error code (e.g. "SESSION_REVOKED") |
final result = await auth.changePassword('old', 'new');
if (result.success) {
print('Done');
} else {
print('${result.errorCode}: ${result.error}');
}
LoginResult (extends AuthResult<AuthUser>) #
| Field | Type | Description |
|---|---|---|
requires2fa |
bool |
true when a 2FA verification step is required |
requires2FASetup |
bool |
true when the user must set up 2FA before continuing |
tempToken |
String? |
Temporary token for the 2FA verification calls |
availableMethods |
List<String> |
2FA methods available to this user |
Custom TokenStorage #
On native platforms, provide a custom TokenStorage implementation to persist tokens across app restarts. flutter_secure_storage is not a dependency of this package — add it to your own pubspec.yaml.
# In your app's pubspec.yaml
dependencies:
flutter_secure_storage: ^9.0.0
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:awesome_node_auth_flutter/awesome_node_auth_flutter.dart';
class SecureTokenStorage implements TokenStorage {
final _storage = const FlutterSecureStorage();
static const _accessKey = 'auth_access_token';
static const _refreshKey = 'auth_refresh_token';
@override
Future<String?> readAccessToken() => _storage.read(key: _accessKey);
@override
Future<void> writeAccessToken(String token) =>
_storage.write(key: _accessKey, value: token);
@override
Future<String?> readRefreshToken() => _storage.read(key: _refreshKey);
@override
Future<void> writeRefreshToken(String token) =>
_storage.write(key: _refreshKey, value: token);
@override
Future<void> clear() async {
await _storage.delete(key: _accessKey);
await _storage.delete(key: _refreshKey);
}
}
// Pass it to AuthOptions
final auth = AuthClient(
AuthOptions(
apiPrefix: '/api/auth',
tokenStorage: SecureTokenStorage(),
),
);
TokenStorage interface:
| Method | Description |
|---|---|
readAccessToken() |
Returns the stored access token, or null |
writeAccessToken(token) |
Persists a new access token |
readRefreshToken() |
Returns the stored refresh token, or null |
writeRefreshToken(token) |
Persists a new refresh token |
clear() |
Deletes all stored tokens (called on logout) |
Internal behaviour — automatic refresh #
The client transparently handles token expiry so individual API calls do not need to manage refresh logic.
Flow #
- Every request that receives a
401or403response checks whether the endpoint is in the no-retry list (see below). - If eligible for retry, the client calls
POST $apiPrefix/refresh. - On success (native) the new
accessTokenfrom the response body is written back toTokenStorage. On web the browser updates the cookie automatically. - The original request is retried with the new token.
- If the refresh also fails, the session is considered expired and
handleLogout()is called.
Concurrent refresh deduplication #
If multiple requests fail at the same time, only one refresh call is made. All concurrent requests wait for that single call to complete before retrying. This prevents the POST /refresh endpoint from being hammered.
Session revoked #
When the backend responds with { "code": "SESSION_REVOKED" }, the client skips the refresh attempt and immediately calls handleLogout(revoked: true), which emits AuthEventType.sessionRevoked.
Endpoints excluded from auto-retry #
The following endpoints always receive the raw response — they are never retried after a 401/403:
| Endpoint segment | Reason |
|---|---|
login |
Credentials failure, not a token issue |
logout |
Already logging out |
refresh |
Avoid infinite loop |
register |
No session expected |
forgot-password |
Unauthenticated flow |
reset-password |
Token-based, not session-based |
verify-email |
Token-based |
confirm |
One-time confirmation token flow |
2fa/verify |
Uses a temp token, not a session token |
Web vs native refresh #
| Web | Native | |
|---|---|---|
| Token location | HttpOnly cookie | TokenStorage |
| Refresh request body | Empty (browser sends the HttpOnly cookie automatically) | Empty body; Authorization: Bearer <accessToken> header is sent so the backend can identify the session |
| New token delivery | Cookie set by server | accessToken field in JSON response body |
Headless mode #
Set headless: true to disable all automatic browser redirects. In this mode the client never calls window.location.href — your code is responsible for reacting to events and navigating appropriately.
final auth = AuthClient(
AuthOptions(
apiPrefix: '/api/auth',
headless: true, // No automatic redirects
),
);
auth.events.listen((event) {
if (event.type == AuthEventType.loggedOut ||
event.type == AuthEventType.sessionExpired ||
event.type == AuthEventType.sessionRevoked) {
// Navigate to login using your router
GoRouter.of(context).go('/login');
}
});
When to use headless mode:
- Apps using GoRouter or AutoRoute for programmatic navigation.
- Web iframes where redirecting the frame would break the embedding page.
- Any scenario where the auth client must not touch
window.location.
Authenticated HTTP client (interceptor) #
auth.httpClient is a standard http.Client with authentication baked in — analogous to an Angular HttpInterceptor or the auth.js middleware for Express.
Use it for all calls to your own backend. There is nothing extra to configure: the library automatically injects the correct credentials for the running platform.
| Platform | What is injected |
|---|---|
| Web / WASM | X-CSRF-Token for same-origin requests; the browser sends the HttpOnly cookie automatically |
| Native | Authorization: Bearer <token> + X-Auth-Strategy: bearer; token is refreshed silently on 401 / 403 |
// auth.httpClient is a drop-in http.Client — use it anywhere
final client = auth.httpClient;
// GET a protected resource — no manual token handling
final response = await client.get(Uri.parse('https://api.example.com/todos'));
// POST with a JSON body
final res = await client.post(
Uri.parse('https://api.example.com/todos'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'title': 'Buy milk'}),
);
Because httpClient is an http.Client, it is compatible with any package that accepts one (e.g. dio adapters, chopper, retrofit, GraphQL clients):
// Example: pass the authenticated client to a third-party package
final graphql = GraphQLClient(
link: HttpLink(apiUrl, httpClient: auth.httpClient),
cache: GraphQLCache(),
);
SSE stream #
The client exposes a server-sent events stream from $apiPrefix/tools/stream. This is only available on web; on native platforms getToolsStream() returns Stream.empty().
// Web: live event stream from the backend
auth.getToolsStream().listen(
(event) => print('SSE event: $event'),
onDone: () => print('Stream closed'),
);
The stream automatically closes when the EventSource emits an error (e.g. server disconnect). To reconnect, call getToolsStream() again.
UI config #
Load feature flags and enabled providers from the backend to drive conditional UI rendering.
final config = await auth.loadUiConfig();
if (config != null) {
print('Registration enabled: ${config.registrationEnabled}');
print('Magic link enabled: ${config.magicLinkEnabled}');
print('SMS enabled: ${config.smsEnabled}');
print('TOTP enabled: ${config.totpEnabled}');
print('GitHub OAuth: ${config.githubEnabled}');
print('Google OAuth: ${config.googleEnabled}');
print('Providers: ${config.availableProviders}');
}
UiConfig fields:
| Field | Type | Description |
|---|---|---|
registrationEnabled |
bool |
Whether new user registration is allowed |
magicLinkEnabled |
bool |
Whether magic-link (passwordless) login is enabled |
smsEnabled |
bool |
Whether SMS OTP login is enabled |
totpEnabled |
bool |
Whether TOTP 2FA is enabled |
githubEnabled |
bool |
Whether GitHub OAuth login is enabled |
googleEnabled |
bool |
Whether Google OAuth login is enabled |
availableProviders |
List<String> |
All enabled OAuth provider names |
extra |
Map<String, dynamic>? |
Backend-specific extra configuration |
WASM compatibility #
This package is WASM-ready. The following design decisions ensure compatibility:
package:webis used instead ofdart:htmlfor all DOM/browser APIs (e.g.window.location,EventSource, cookie reading).package:httpis used for all HTTP calls instead ofdart:io'sHttpClient.- Platform-specific code is gated with conditional imports using
dart.library.js_interop:
import 'platform/native_auth_client.dart'
if (dart.library.js_interop) 'platform/web_auth_client.dart' as platform;
Verify WASM build #
flutter build web --wasm
License #
MIT