ajolote 0.2.0
ajolote: ^0.2.0 copied to clipboard
Cliente Flutter para el servicio de autenticación centralizado de Anfibia/4Quinas. Multi-tenant, JWT, OTP por SMS, biometría.
ajolote #
Cliente Flutter para el servicio de autenticación centralizado de Anfibia/4Quinas.
Equivalente al paquete @anfibia/ajolote-client-mobile del lado JavaScript, pero idiomático para Flutter/Dart.
Features #
- 🔐 Autenticación con email + password.
- 📱 OTP por SMS (integrado con Infobip vía el backend).
- 🌐 OAuth social (Google) con deep links + validación de state contra CSRF.
- 🔁 Refresh automático del access token con deduplicación de concurrencia.
- 🛡️ Tokens persistidos en Keychain/Keystore (flutter_secure_storage).
- 👆 Biometric unlock opcional (vía local_auth en la app consumidora).
- 🔄 Reactividad con
ChangeNotifier— funciona con cualquier state management. - 🌐 Cliente S2S para backends Dart (Dart Frog, Shelf).
- 🎯 Multi-tenant por diseño — cada app es un tenant aislado.
Instalación #
El SDK se distribuye via git ref desde el monorepo. Dos opciones de autenticación según el contexto.
Opción A — SSH (recomendada para desarrollo local) #
dependencies:
flutter:
sdk: flutter
ajolote:
git:
url: git@github.com:cuatro-quinas/ajolote.git
path: packages-dart/ajolote
ref: dart-v0.2.0
# Opcionales según features que uses:
local_auth: ^2.3.0 # para biometric unlock
app_links: ^6.3.0 # para OAuth social
url_launcher: ^6.3.0 # para OAuth social
Requisito: SSH key configurada en GitHub.
Opción B — HTTPS + Personal Access Token (recomendada para CI) #
dependencies:
ajolote:
git:
url: https://github.com/cuatro-quinas/ajolote.git
path: packages-dart/ajolote
ref: dart-v0.2.0
Antes de flutter pub get, configurar Git para usar el PAT:
# Local (una vez por máquina):
git config --global url."https://${GITHUB_PAT}@github.com/".insteadOf "https://github.com/"
# En CI/CD:
git config --global url."https://${{ secrets.AJOLOTE_READ_TOKEN }}@github.com/".insteadOf "https://github.com/"
El PAT necesita scope mínimo: repo (read) o fine-grained con "Contents: read" sobre el repo ajolote.
Importante: pinear el tag #
Siempre usar ref: dart-vX.Y.Z, nunca ref: main. Apuntar a main significa que cualquier cambio mergeado al repo puede romper tu app sin aviso.
Ver docs/dart-distribution.md para guía completa de versioning, publicación y troubleshooting.
Quickstart #
import 'package:flutter/material.dart';
import 'package:ajolote/ajolote.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final auth = AjoloteClient(
appId: AppId.visitar,
baseUrl: Uri.parse('https://auth.anfibia.com.ar'),
);
await auth.init(); // restaura sesión persistida
runApp(
AuthProvider(
client: auth,
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: AuthBuilder(
loading: (_) => const SplashScreen(),
authenticated: (user) => HomeScreen(user: user),
unauthenticated: (_) => const LoginScreen(),
),
);
}
}
Uso #
Login con email + password #
final auth = AuthProvider.read(context);
try {
await auth.signInWithEmail(
email: 'medico@anfibia.com.ar',
password: 'contraseña-segura',
);
// auth.isAuthenticated == true
// auth.currentUser disponible
} on AuthException catch (e) {
// e.code: invalidCredentials, networkError, etc.
print('Login falló: ${e.message}');
}
Login con OTP por SMS #
final auth = AuthProvider.read(context);
final phone = PhoneAuth(client: auth);
// Paso 1: enviar código
await phone.sendOtp('+5491155551234');
// Paso 2: usuario ingresa el código
await phone.verifyOtp(
phoneNumber: '+5491155551234',
code: '123456',
);
// Sesión activa después de verify exitoso
Sign-out #
await AuthProvider.read(context).signOut();
Reactividad #
Tres formas de consumir el estado:
1. AuthBuilder (más simple)
AuthBuilder(
loading: (_) => const SplashScreen(),
authenticated: (user) => HomeScreen(user: user),
unauthenticated: (_) => const LoginScreen(),
)
2. AuthProvider.of(context) (rebuild en cambios)
final auth = AuthProvider.of(context);
if (auth.isAuthenticated) {
return Text('Hola ${auth.currentUser!.name}');
}
return const LoginScreen();
3. AnimatedBuilder (control fino)
AnimatedBuilder(
animation: AuthProvider.of(context),
builder: (context, _) {
final auth = AuthProvider.of(context);
return Text(auth.isAuthenticated ? 'In' : 'Out');
},
)
Biometric unlock #
Requiere local_auth en la app:
dependencies:
local_auth: ^2.3.0
// 1. Implementar BiometricAuthenticator en tu app
class MyAuthenticator implements BiometricAuthenticator {
final _auth = LocalAuthentication();
@override
Future<bool> isAvailable() async {
return await _auth.canCheckBiometrics
&& await _auth.isDeviceSupported();
}
@override
Future<bool> authenticate({required String reason}) async {
return await _auth.authenticate(localizedReason: reason);
}
}
// 2. Crear el módulo
final biometric = BiometricAuth(
storage: SecureTokenStorage(),
storagePrefix: 'ajolote-visitar',
authenticator: MyAuthenticator(),
);
// 3. Usarlo
if (await biometric.isEnabled()) {
final ok = await biometric.unlock(reason: 'Acceder a VisitAr');
if (!ok) {
// Fallback a login
}
}
Llamadas HTTP autenticadas a tu propia API #
El SDK refresca el access token automáticamente cuando está por expirar. Usá getValidToken() para firmar requests a tu propia API:
final auth = AuthProvider.read(context);
try {
final token = await auth.getValidToken();
final response = await http.get(
Uri.parse('https://visitar-api.anfibia.com.ar/visits'),
headers: {'Authorization': 'Bearer $token'},
);
} on AuthException catch (e) {
if (e.code == AuthErrorCode.expiredToken) {
// Sesión expiró completamente — redirigir a login
router.replace('/login');
}
}
Tu backend (VisitAr API, NestJS) valida el JWT con @anfibia/ajolote-verify — el mismo paquete que usan los backends consumidores del SDK JS.
Refresh tokens automáticos #
El SDK maneja dos tokens internamente:
- Session token: largo (días), persistido en SecureStore. Funciona como refresh token.
- Access token (JWT): corto (15 min), en memoria. Se renueva ~60s antes de expirar.
getValidToken():
- Devuelve el JWT actual si todavía es válido.
- Refresca automáticamente si está por expirar.
- Si N llamadas concurrentes piden el token con el JWT expirado, dispara 1 sola llamada de refresh y todas esperan el mismo Future.
- Si la sesión también expiró, lanza
AuthException(expiredToken).
Para ajustar el threshold de refresh anticipado:
final auth = AjoloteClient(
appId: AppId.visitar,
baseUrl: Uri.parse('https://auth.anfibia.com.ar'),
refreshThreshold: Duration(seconds: 90), // default 60s
);
OAuth social (Google) #
Requiere dos dependencias en la app:
dependencies:
app_links: ^6.3.0
url_launcher: ^6.3.0
Implementar el adaptador de deep links:
// lib/app_links_adapter.dart
import 'package:ajolote/ajolote.dart';
import 'package:app_links/app_links.dart';
class AppLinksAdapter implements DeepLinkListener {
final AppLinks _appLinks;
AppLinksAdapter(this._appLinks);
@override
Stream<Uri> get uriStream => _appLinks.uriLinkStream;
}
Configurar el módulo:
final socialAuth = SocialAuth(SocialAuthConfig(
client: authClient,
deepLinkListener: AppLinksAdapter(AppLinks()),
appScheme: 'visitar', // debe coincidir con app.json / Info.plist
));
Disparar el flujo:
import 'package:url_launcher/url_launcher.dart';
try {
final session = await socialAuth.signIn(
SocialProvider.google,
launchUrl: (uri) => launchUrl(
uri,
mode: LaunchMode.externalApplication,
),
);
// authClient.isAuthenticated == true
} on AuthException catch (e) {
// Errores: usuario canceló, timeout, state mismatch, etc.
}
Registrar el deep link scheme en iOS y Android
iOS (ios/Runner/Info.plist):
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>ar.com.anfibia.visitar</string>
<key>CFBundleURLSchemes</key>
<array>
<string>visitar</string>
</array>
</dict>
</array>
Android (android/app/src/main/AndroidManifest.xml):
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="visitar" android:host="auth" />
</intent-filter>
Configurar el backend
En el .env del ajolote-service:
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
TRUSTED_REDIRECT_SCHEMES=visitar,his,lyra
El redirect URI a configurar en console.cloud.google.com es:
https://auth.anfibia.com.ar/api/auth/callback/google
Server-to-server (backends Dart) #
Si tenés un backend en Dart (Dart Frog, Shelf) que necesita llamarse con otros servicios:
final s2s = ServerAuthClient(
apiKey: Platform.environment['ANFIBIA_AUTH_KEY']!,
appId: AppId.visitar,
baseUrl: Uri.parse('https://auth.anfibia.com.ar'),
);
// Caso A: emitir requests
final response = await s2s.fetch(
Uri.parse('https://his-api.anfibia.com.ar/patients/123'),
);
// Caso B: validar API keys entrantes
final result = await s2s.verifyApiKey(request.headers['X-Api-Key']);
if (!result.valid) return Response(statusCode: 401);
Errores #
Todos los métodos lanzan AuthException con un código tipado:
try {
await auth.signInWithEmail(email: '...', password: '...');
} on AuthException catch (e) {
switch (e.code) {
case AuthErrorCode.invalidCredentials:
// Email/password incorrectos
case AuthErrorCode.networkError:
// Sin conexión / DNS / timeout
case AuthErrorCode.expiredToken:
// Sesión expiró
case AuthErrorCode.invalidAppId:
// Header X-App-Id rechazado
case AuthErrorCode.userBanned:
// Usuario baneado
case AuthErrorCode.insufficientRole:
// 403
case AuthErrorCode.invalidToken:
// JWT inválido
case AuthErrorCode.unknown:
// Otro
}
}
Testing #
Para tests, inyectar storage en memoria y mockear el cliente HTTP:
final storage = InMemoryTokenStorage();
final auth = AjoloteClient(
appId: AppId.visitar,
baseUrl: Uri.parse('https://test'),
storage: storage,
);
Versionado #
Este paquete se versiona independientemente del lado TS porque pub.dev/Dart no entiende changesets. Cuando agregues un nuevo AppId en ajolote-core del lado JS, hay que reflejarlo manualmente en lib/src/models.dart y bumpear versión.
Releases:
- Tag git:
dart-vX.Y.Z - Las apps importan vía
git:ref en pubspec hasta que se publique a un pub.dev privado.
Limitaciones conocidas #
- No hay cliente optimizado para Flutter web: si necesitás un web app en Flutter web, este cliente funciona, pero pierde la ventaja de cookies httpOnly (usa bearer tokens en memoria, menos seguro contra XSS). Para web preferí React +
@anfibia/ajolote-client-web. - Apple Sign-In nativo no incluido: el flujo OAuth genérico funciona para Apple via web view, pero la experiencia ideal en iOS usa la API nativa de
SignInWithAppleque requiere el pluginsign_in_with_apple. Si lo necesitás, abrir issue. - Sin garantía de renovación silenciosa de sesión: el access token se refresca automáticamente, pero el session token largo no se rota. Cuando expire (días/semanas), el usuario debe re-loguearse.
Roadmap #
- ✅ Refresh token automático antes de expiración (v0.2.0)
- ✅ OAuth social (Google) con app_links (v0.2.0)
- ✅ Contract compartido con TS via JSON Schema + tests de conformance (v0.2.0)
- ❌ Apple Sign-In nativo con
sign_in_with_apple - ❌ Rotación de session token (sin requerir re-login)
- ❌ Publicar a Cloudsmith cuando lleguemos a 2+ apps Flutter
- ❌ Generador de modelos automático (quicktype u otro) si la divergencia crece
Soporte #
- Mantenedor: Nico Wyler (nico@anfibia.com.ar)
- Issues: https://github.com/cuatro-quinas/ajolote/issues
- Ver docs/README.md en la raíz del repo para arquitectura completa.