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.
}

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 SignInWithApple que requiere el plugin sign_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

  • x Refresh token automático antes de expiración (v0.2.0)
  • x OAuth social (Google) con app_links (v0.2.0)
  • x 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

Libraries

ajolote
Ajolote — Cliente Flutter para el servicio de autenticación centralizado de Anfibia/4Quinas.