Cliente OIDC en Flutter¶
Guía técnica completa para implementar la autenticación OIDC con Keycloak en custodiam-app. Cubre la configuración nativa por plataforma (Android, iOS, Web), la asimetría móvil/web del flujo OAuth + PKCE, las dos implementaciones de AuthService, el almacenamiento seguro de tokens, el refresh automático, la integración con el cliente HTTP, el router con /callback y el sistema de gates de permiso del RBAC del proyecto.
Decisiones arquitectónicas relevantes
- ADR-010 OAuth + PKCE + Keycloak + PyJWT — stack
oauth2 + url_launcher + app_linkspara móvil y SPA web. - ADR-011 Deep links — custom scheme
es.custodiam://callbackpara OAuth + App Links HTTPS para emails. - ADR-012 Riverpod — DI + estado reactivo.
- ADR-013 RBAC lockstep —
Permissionenum espejo del backend, gates declarativos. - ADR-022 iOS 15 mínimo — versión mínima soportada.
- ADR-023 OAuth web vs móvil — dos implementaciones de
AuthServiceporkIsWeb+ persistencia delcode_verifierensessionStorage. - ADR-024 Patrol E2E — framework de testing end-to-end para los tres escenarios del DoD.
Prerrequisitos¶
- Realm Keycloak operativo con el cliente
custodiam-appconfigurado (Configuración de Keycloak). - Proyecto Flutter creado con la arquitectura interna Clean + Riverpod +
Result<T>+EnvConfig. - Docker Compose levantado con Keycloak accesible.
Qué consigues al terminar¶
- Login vía Keycloak con Authorization Code + PKCE (abre navegador, el usuario se autentica, vuelve a la app).
- Logout con cierre de sesión en Keycloak.
- Almacenamiento seguro de tokens (Keychain iOS, EncryptedSharedPreferences Android, IndexedDB cifrado web).
- Refresh automático del access token cuando expira.
- Persistencia de sesión entre reinicios.
ApiClientque añade el token automáticamente a cada request y maneja el refresh + retry en401.LoginPageconstruida solo con componentesApp*del Design System.SplashPagedecidiendo/homeo/loginsegún el estado real de la sesión.- Configuración nativa para Android, iOS y Web.
AppPermissionGatepara mostrar/ocultar UI según los permisos del usuario actual.
Conceptos: cómo funciona el login¶
1. El usuario pulsa "Iniciar sesión" en la app.
2. La app abre el navegador del sistema con la URL de Keycloak.
3. El usuario escribe su usuario y contraseña EN KEYCLOAK (no en nuestra app).
4. Keycloak verifica las credenciales.
5. Keycloak redirige de vuelta a la app con un "código de autorización".
6. La app intercambia el código + code_verifier por tokens (access_token + refresh_token).
7. La app guarda los tokens de forma segura.
8. Todas las llamadas a la API llevan el access_token en el header.
Por qué abrir el navegador: para que la app nunca vea la contraseña. Keycloak se encarga de la autenticación y nuestra app solo recibe tokens. Es el estándar OAuth 2.0 / OIDC.
Qué es PKCE: es una protección extra que evita el scheme hijacking en clientes públicos. Detalle conceptual en ADR-010. El paquete oauth2 lo gestiona automáticamente cuando se crea el AuthorizationCodeGrant sin secret.
La asimetría móvil/web¶
El flujo OAuth + PKCE se comporta de forma distinta en móvil y en web. Es el detalle más importante de esta guía y la razón de tener dos implementaciones de AuthService (ADR-023).
Móvil (Android + iOS)¶
launchUrl(authUrl, mode: LaunchMode.externalApplication) abre el navegador externo. La app Flutter sigue viva en memoria, esperando el callback por deep link a través de app_links. Al volver del navegador, el método _pendingGrant.handleAuthorizationResponse() se ejecuta dentro de la misma instancia que conserva el code_verifier PKCE generado al inicio del flujo.
Web (Flutter Web)¶
launchUrl(authUrl, webOnlyWindowName: '_self') sustituye la pestaña actual por la URL de Keycloak. Cuando Keycloak redirige de vuelta a /callback, el navegador carga la aplicación Flutter completamente desde cero: una nueva instancia de KeycloakWebAuthService se crea con _pendingGrant == null. Para que el flujo funcione, el code_verifier debe persistirse en window.sessionStorage antes del redirect y leerse de vuelta tras la nueva carga.
Móvil: app viva → navegador → app misma instancia → code_verifier en memoria → OK
Web: app viva → navegador → recarga app desde cero → ❌ code_verifier perdido
╰────────────── solución: sessionStorage ────────────────╯
sessionStorage se elige sobre localStorage por su semántica precisa: vida útil ligada a la pestaña del navegador (se limpia al cerrarla), no se sincroniza entre pestañas y desaparece automáticamente sin que la app tenga que limpiarlo en caminos de error.
Mapa arquitectónico¶
lib/
├── infrastructure/
│ ├── auth/
│ │ ├── auth_service.dart # interface invariante
│ │ ├── auth_failure.dart # AuthFailure.userCancelled, .sessionStorageUnavailable, ...
│ │ ├── keycloak_config.dart # endpoints derivados de EnvConfig
│ │ ├── token_store.dart # wrapper sobre FlutterSecureStorage
│ │ ├── keycloak_mobile_auth_service.dart # flujo móvil con deep links
│ │ ├── keycloak_web_auth_service.dart # flujo web con sessionStorage
│ │ ├── session_storage_gateway.dart # interface del gateway de sessionStorage
│ │ ├── web_session_storage_gateway.dart # impl web (importa package:web)
│ │ ├── stub_session_storage_gateway.dart # stub VM (sin package:web)
│ │ ├── current_user.dart # claims decodificados del JWT
│ │ ├── jwt_claims.dart # decodificación segura del JWT
│ │ └── permissions.dart # enum Permission + matriz roles → permisos
│ ├── di/
│ │ └── providers.dart # authServiceProvider selecciona por kIsWeb
│ ├── network/
│ │ └── api_client.dart # getValidAccessToken antes de cada request
│ └── error/
│ └── failure.dart # AuthFailure como subtipo de Failure
├── features/
│ └── auth/
│ └── presentation/
│ ├── pages/
│ │ └── login_page.dart # UI con App* components
│ └── viewmodels/
│ ├── auth_di.dart # providers de la feature
│ └── auth_view_model.dart # AsyncNotifier orquesta login/logout
├── core/
│ ├── config/
│ │ └── env_config.dart # keycloakRealm + keycloakClientId
│ └── ui/ # Design System App*
└── app/
└── router.dart # GoRouter con /callback para web
Patrón clave: el contrato (AuthService) es el mismo en las dos plataformas. La diferencia operativa (¿la app sigue viva durante el redirect a Keycloak?) es explícita en el tipo de la implementación, no oculta en ramas kIsWeb que comparten estado por accidente. Es la aplicación de Clean Architecture al problema concreto descrito en ADR-023.
Paso 1 — Dependencias¶
pubspec.yaml:
dependencies:
flutter:
sdk: flutter
flutter_web_plugins:
sdk: flutter
# State management
flutter_riverpod: ^2.6.0
# OAuth + PKCE
oauth2: ^2.0.3
url_launcher: ^6.3.1
app_links: ^6.3.3 # solo móvil — captura deep link es.custodiam://callback
# Storage seguro
flutter_secure_storage: ^10.0.0
# OIDC web — sessionStorage para code_verifier
web: ^1.1.0 # solo se importa desde web_session_storage_gateway.dart
# JWT (decodificación local, sin validar firma — el backend valida)
jwt_decoder: ^2.0.1
# Routing
go_router: ^17.1.0
Tras editar:
Regla crítica con package:web
package:web solo compila para target web. Importarlo al top level de cualquier archivo que se cargue desde código no-web (incluida la suite de tests unitarios, que corre en Dart VM) hace que el compilador VM muera sin diagnóstico útil. La implementación real va en su propio archivo (web_session_storage_gateway.dart) y se importa condicionalmente desde providers.dart con if (dart.library.js_interop). Detalle completo en Paso 7.
Paso 2 — Configuración nativa por plataforma¶
Android — AndroidManifest.xml¶
Edita android/app/src/main/AndroidManifest.xml. Dentro de <activity android:name=".MainActivity"> añade este intent-filter (sin tocar los existentes):
<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="es.custodiam" android:host="callback" />
</intent-filter>
Cuando Keycloak redirige a es.custodiam://callback, Android sabe que tiene que devolver el control a la app pasándole la URL.
iOS — Info.plist¶
Edita ios/Runner/Info.plist. Dentro del <dict> raíz añade:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>es.custodiam</string>
<key>CFBundleURLSchemes</key>
<array>
<string>es.custodiam</string>
</array>
</dict>
</array>
Versión mínima de iOS: 15.0 (ADR-022).
Web — PathUrlStrategy obligatoria¶
En web, Keycloak redirige a ${Uri.base.origin}/callback. El router de la app captura /callback y procesa el código. Pero esto solo funciona si la app declara PathUrlStrategy — el default de Flutter Web es HashUrlStrategy, que reescribe las rutas a /#/login, /#/callback, etc. y hace que GoRouter no matchee el path crudo que devuelve Keycloak.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_web_plugins/url_strategy.dart';
import 'package:custodiam/app/app.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
usePathUrlStrategy(); // OBLIGATORIO en web, no-op en mobile
runApp(
const ProviderScope(
child: CustodiamApp(),
),
);
}
Por qué es necesario en web pero no en móvil: OAuth (RFC 6749 §3.1.2) prohíbe fragmentos (#) en redirect_uri — el IdP nunca va a devolver /#/callback. En móvil esto no importa porque los deep links los entrega el SO directamente al listener AppLinks sin pasar por un router HTTP. En web, la única forma de que /callback matchee GoRoute(path: '/callback') es declarar PathUrlStrategy explícitamente. La función es segura en todas las plataformas; en móvil carga un stub no-op.
Contrato implícito con el servidor: activar PathUrlStrategy obliga a que el servidor que sirve la PWA haga SPA fallback (servir index.html para cualquier ruta desconocida). En desarrollo flutter run -d chrome --web-port=3000 lo hace automáticamente. En producción, el nginx que sirve custodiam-web ya está configurado con try_files $uri /index.html (ADR-006).
Puerto 3000 en dev local¶
Cualquier origen que sirva la app debe estar registrado como redirect_uri válida en el cliente custodiam-app de Keycloak:
| Origen | Uso |
|---|---|
es.custodiam://callback |
Móvil (Android + iOS) |
http://localhost:3000/callback |
Dev local — puerto fijo 3000 |
https://app.custodiam.es/callback |
Producción |
Importante para dev local: flutter run -d chrome asigna un puerto random cada arranque (:54321, :48132, …). Si tu puerto no coincide con :3000 el redirect a Keycloak da Invalid parameter: redirect_uri. Solución: forzar siempre el mismo puerto.
# Liberar el puerto 3000 del contenedor web del stack dev
cd custodiam-workspace/custodiam-infra
docker compose stop custodiam-web
# Lanzar el dev server Flutter en :3000
cd ../custodiam-app
flutter run -d chrome --web-port=3000
Nuevos dominios (staging, preview deploys) requieren registro adicional en el realm Keycloak (Configuración de Keycloak — Paso 8).
Paso 3 — Ampliar EnvConfig¶
El EnvConfig (ADR-015) ya expone apiBaseUrl y keycloakBaseUrl. Añadir el realm y el client id:
class EnvConfig {
// ... apiBaseUrl, keycloakBaseUrl, ...
static const String keycloakRealm = String.fromEnvironment(
'KEYCLOAK_REALM',
defaultValue: 'custodiam',
);
static const String keycloakClientId = String.fromEnvironment(
'KEYCLOAK_CLIENT_ID',
defaultValue: 'custodiam-app',
);
}
Build de producción:
flutter build apk --release \
--dart-define=API_BASE_URL=https://api.custodiam.es/api/v1 \
--dart-define=KEYCLOAK_BASE_URL=https://auth.custodiam.es \
--dart-define=KEYCLOAK_REALM=custodiam \
--dart-define=KEYCLOAK_CLIENT_ID=custodiam-app
Paso 4 — KeycloakConfig — endpoints derivados de EnvConfig¶
import 'package:flutter/foundation.dart' show kIsWeb;
import '../../core/config/env_config.dart';
class KeycloakConfig {
KeycloakConfig._();
static String get realmBase =>
'${EnvConfig.keycloakBaseUrl}/realms/${EnvConfig.keycloakRealm}';
static String get _oidcBase => '$realmBase/protocol/openid-connect';
static Uri get authorizationEndpoint => Uri.parse('$_oidcBase/auth');
static Uri get tokenEndpoint => Uri.parse('$_oidcBase/token');
static Uri get endSessionEndpoint => Uri.parse('$_oidcBase/logout');
/// Scopes que la app pide al hacer login.
/// - openid: obligatorio.
/// - profile: given_name, family_name.
/// - email: email, email_verified.
/// Los roles llegan vía mapper `realm-roles` del client scope
/// `custodiam-roles`; no es un scope.
static const List<String> scopes = ['openid', 'profile', 'email'];
/// URI de callback según plataforma.
/// - Móvil: deep link con custom scheme.
/// - Web: origin actual + /callback (funciona dev y prod sin tocar código).
static Uri get redirectUri {
if (kIsWeb) {
return Uri.parse('${Uri.base.origin}/callback');
}
return Uri.parse('es.custodiam://callback');
}
static Uri get postLogoutRedirectUri {
if (kIsWeb) {
return Uri.parse(Uri.base.origin);
}
return Uri.parse('es.custodiam://logout');
}
}
Paso 5 — TokenStore¶
Wrapper sobre FlutterSecureStorage que persiste las credenciales OAuth serializadas. El JSON incluye access_token, refresh_token, expiry y scopes; lo produce Credentials.toJson() y se rehidrata con Credentials.fromJson().
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class TokenStore {
TokenStore({FlutterSecureStorage? storage})
: _storage = storage ?? const FlutterSecureStorage();
static const _key = 'custodiam.oauth.credentials';
final FlutterSecureStorage _storage;
Future<String?> read() => _storage.read(key: _key);
Future<void> write(String credentialsJson) =>
_storage.write(key: _key, value: credentialsJson);
Future<void> clear() => _storage.delete(key: _key);
}
En iOS usa Keychain, en Android EncryptedSharedPreferences, en web IndexedDB cifrado por el SO/navegador. Es el patrón canónico de flutter_secure_storage.
Paso 6 — AuthService y AuthFailure¶
Interface invariante¶
import '../error/failure.dart';
abstract class AuthService {
/// Inicia el flujo de login. En móvil abre el navegador y espera el
/// deep link; en web redirige a Keycloak con `_self` y persiste el
/// code_verifier en sessionStorage.
Future<Result<void>> login();
/// Procesa el callback (solo relevante en web). En móvil es no-op.
Future<Result<void>> handleWebCallback(Uri uri);
/// Devuelve un access_token válido. Si está expirado, hace refresh
/// transparentemente.
Future<Result<String>> getValidAccessToken();
/// Cierra la sesión en Keycloak y borra los tokens locales.
Future<Result<void>> logout();
/// Estado observable de la sesión.
Stream<bool> get isAuthenticatedStream;
bool get isAuthenticated;
/// Claims decodificados del JWT — null si no hay sesión activa.
CurrentUser? get currentUser;
}
AuthFailure (jerarquía sellada)¶
sealed class AuthFailure extends Failure {
const AuthFailure();
const factory AuthFailure.sessionExpired() = _SessionExpired;
const factory AuthFailure.userCancelled() = _UserCancelled;
const factory AuthFailure.browserError(String message) = _BrowserError;
const factory AuthFailure.invalidGrant() = _InvalidGrant;
const factory AuthFailure.refreshFailed() = _RefreshFailed;
const factory AuthFailure.sessionStorageUnavailable() = _SessionStorageUnavailable;
}
sessionStorageUnavailable es el caso de error específico de web (ADR-023): si el navegador del usuario tiene sessionStorage deshabilitado (modo privado en algunos navegadores, configuración corporativa), el flujo no puede completar. La UI debe mostrar un mensaje claro en este caso.
Paso 6.A — KeycloakMobileAuthService¶
Implementación para Android e iOS. Mantiene _pendingGrant en memoria porque la app sobrevive a la redirección al navegador.
class KeycloakMobileAuthService implements AuthService {
KeycloakMobileAuthService({
required TokenStore tokenStore,
AppLinks? appLinks,
LaunchUrlFn? launcher,
}) : _tokenStore = tokenStore,
_appLinks = appLinks ?? AppLinks(),
_launcher = launcher ?? launchUrl;
oauth2.AuthorizationCodeGrant? _pendingGrant;
oauth2.Client? _client;
// ...
@override
Future<Result<void>> login() async {
_pendingGrant = oauth2.AuthorizationCodeGrant(
EnvConfig.keycloakClientId,
KeycloakConfig.authorizationEndpoint,
KeycloakConfig.tokenEndpoint,
// PKCE automático para clientes públicos (sin secret).
);
final authUrl = _pendingGrant!.getAuthorizationUrl(
KeycloakConfig.redirectUri,
scopes: KeycloakConfig.scopes,
);
// Escuchar el deep link es.custodiam://callback ANTES de abrir el browser.
_deepLinkSubscription = _appLinks.uriLinkStream.listen(_handleMobileCallback);
final launched = await _launcher(
authUrl,
mode: LaunchMode.externalApplication,
);
if (!launched) {
return const Fail(AuthFailure.browserError('No se pudo abrir el navegador'));
}
return const Success(null);
}
Future<void> _handleMobileCallback(Uri uri) async {
if (_pendingGrant == null) return;
try {
_client = await _pendingGrant!.handleAuthorizationResponse(
uri.queryParameters,
);
await _tokenStore.write(_client!.credentials.toJson());
_authStateController.add(true);
} on oauth2.AuthorizationException catch (e) {
// user_denied, invalid_grant, ...
_emitFailure(e);
} finally {
_pendingGrant = null;
await _deepLinkSubscription?.cancel();
}
}
@override
Future<Result<void>> handleWebCallback(Uri uri) async {
// No-op en móvil — el deep link lo gestiona AppLinks.
return const Success(null);
}
// getValidAccessToken, logout, isAuthenticated, currentUser ...
}
Detalle clave: _pendingGrant se mantiene en memoria entre login() y _handleMobileCallback() porque la app no se reinicia al volver del navegador externo. El code_verifier PKCE vive dentro del _pendingGrant.
Paso 6.B — KeycloakWebAuthService¶
Implementación para Flutter Web. El code_verifier se persiste en sessionStorage antes del redirect porque el navegador recarga la app completa al volver del callback.
Gateway aislado para sessionStorage¶
package:web solo compila para web. La implementación se aísla en su propio archivo:
abstract class SessionStorageGateway {
bool get available;
String? read(String key);
void write(String key, String value);
void remove(String key);
}
// Este archivo SOLO se importa cuando dart.library.js_interop está disponible.
import 'package:web/web.dart' as web;
import 'session_storage_gateway.dart';
class WebSessionStorageGateway implements SessionStorageGateway {
@override
bool get available {
try {
web.window.sessionStorage.setItem('__custodiam_probe', '1');
web.window.sessionStorage.removeItem('__custodiam_probe');
return true;
} catch (_) {
return false;
}
}
@override
String? read(String key) => web.window.sessionStorage.getItem(key);
@override
void write(String key, String value) =>
web.window.sessionStorage.setItem(key, value);
@override
void remove(String key) => web.window.sessionStorage.removeItem(key);
}
import 'session_storage_gateway.dart';
/// Stub para target VM (tests, build mobile). Siempre `available: false`
/// — pero los tests inyectan InMemorySessionStorageGateway por DI.
class StubSessionStorageGateway implements SessionStorageGateway {
@override
bool get available => false;
@override
String? read(String key) => null;
@override
void write(String key, String value) {}
@override
void remove(String key) {}
}
Implementación web del AuthService¶
class KeycloakWebAuthService implements AuthService {
KeycloakWebAuthService({
required TokenStore tokenStore,
required SessionStorageGateway sessionStorage,
LaunchUrlFn? launcher,
}) : _tokenStore = tokenStore,
_sessionStorage = sessionStorage,
_launcher = launcher ?? launchUrl;
static const _codeVerifierKey = 'custodiam.oauth.code_verifier';
@override
Future<Result<void>> login() async {
if (!_sessionStorage.available) {
return const Fail(AuthFailure.sessionStorageUnavailable());
}
final grant = oauth2.AuthorizationCodeGrant(
EnvConfig.keycloakClientId,
KeycloakConfig.authorizationEndpoint,
KeycloakConfig.tokenEndpoint,
);
final authUrl = grant.getAuthorizationUrl(
KeycloakConfig.redirectUri,
scopes: KeycloakConfig.scopes,
);
// CRÍTICO — persistir el code_verifier ANTES del redirect.
_sessionStorage.write(_codeVerifierKey, grant.codeVerifier);
final launched = await _launcher(
authUrl,
webOnlyWindowName: '_self',
);
if (!launched) {
_sessionStorage.remove(_codeVerifierKey);
return const Fail(AuthFailure.browserError('No se pudo redirigir'));
}
// El navegador ya está cargando la URL de Keycloak. Volveremos
// a /callback en una nueva instancia de la app.
return const Success(null);
}
@override
Future<Result<void>> handleWebCallback(Uri uri) async {
final codeVerifier = _sessionStorage.read(_codeVerifierKey);
if (codeVerifier == null) {
// Llegada huérfana a /callback (nunca pulsó login).
return const Fail(AuthFailure.invalidGrant());
}
// Reconstruir el grant con el code_verifier persistido.
final grant = oauth2.AuthorizationCodeGrant(
EnvConfig.keycloakClientId,
KeycloakConfig.authorizationEndpoint,
KeycloakConfig.tokenEndpoint,
codeVerifier: codeVerifier,
);
// El grant exige llamar a getAuthorizationUrl una vez antes de
// handleAuthorizationResponse, aunque no se use.
grant.getAuthorizationUrl(
KeycloakConfig.redirectUri,
scopes: KeycloakConfig.scopes,
);
try {
final client = await grant.handleAuthorizationResponse(uri.queryParameters);
await _tokenStore.write(client.credentials.toJson());
_authStateController.add(true);
return const Success(null);
} on oauth2.AuthorizationException {
return const Fail(AuthFailure.invalidGrant());
} finally {
_sessionStorage.remove(_codeVerifierKey);
}
}
// getValidAccessToken, logout, ...
}
Paso 7 — Selector kIsWeb en authServiceProvider¶
providers.dart selecciona la implementación correcta en runtime. El import de WebSessionStorageGateway es condicional:
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/auth_service.dart';
import '../auth/keycloak_mobile_auth_service.dart';
import '../auth/keycloak_web_auth_service.dart';
import '../auth/session_storage_gateway.dart';
import '../auth/stub_session_storage_gateway.dart'
if (dart.library.js_interop) '../auth/web_session_storage_gateway.dart';
import '../auth/token_store.dart';
final tokenStoreProvider = Provider<TokenStore>((ref) => TokenStore());
final sessionStorageProvider = Provider<SessionStorageGateway>((ref) {
// En target web carga WebSessionStorageGateway por el conditional import;
// en VM/mobile carga StubSessionStorageGateway sin tocar package:web.
if (kIsWeb) {
return WebSessionStorageGateway();
}
return StubSessionStorageGateway();
});
final authServiceProvider = Provider<AuthService>((ref) {
final tokenStore = ref.read(tokenStoreProvider);
if (kIsWeb) {
return KeycloakWebAuthService(
tokenStore: tokenStore,
sessionStorage: ref.read(sessionStorageProvider),
);
}
return KeycloakMobileAuthService(tokenStore: tokenStore);
});
El truco del conditional import (import '...stub' if (dart.library.js_interop) '...real') es lo que permite que la suite de tests VM compile sin que package:web se materialice nunca en su grafo de imports.
Paso 8 — ApiClient consume AuthService¶
El wrapper HTTP (ADR-004) llama a getValidAccessToken() antes de cada request y maneja el refresh + retry en 401:
class ApiClient {
ApiClient(this._authService, this._httpClient);
final AuthService _authService;
final http.Client _httpClient;
Future<Map<String, dynamic>> get(String path) async {
return _withAuth((token) => _httpClient.get(
Uri.parse('${EnvConfig.apiBaseUrl}$path'),
headers: {
'Authorization': 'Bearer $token',
'Accept': 'application/json',
},
));
}
Future<Map<String, dynamic>> _withAuth(
Future<http.Response> Function(String token) request,
) async {
final tokenResult = await _authService.getValidAccessToken();
final token = switch (tokenResult) {
Success(:final value) => value,
Fail() => throw UnauthenticatedException(),
};
var response = await request(token);
if (response.statusCode == 401) {
// El access token podría haber expirado entre getValid() y el send.
// Reintentar UNA vez con refresh forzado.
final retryResult = await _authService.getValidAccessToken();
final retryToken = switch (retryResult) {
Success(:final value) => value,
Fail() => throw UnauthenticatedException(),
};
response = await request(retryToken);
if (response.statusCode == 401) throw UnauthenticatedException();
}
return jsonDecode(response.body) as Map<String, dynamic>;
}
}
Paso 9 — Router con /callback¶
GoRouter necesita una ruta /callback que delegue en KeycloakWebAuthService.handleWebCallback:
GoRouter buildRouter(Ref ref) {
return GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
builder: (_, __) => const SplashPage(),
),
GoRoute(
path: '/login',
builder: (_, __) => const LoginPage(),
),
GoRoute(
path: '/home',
builder: (_, __) => const HomePage(),
),
GoRoute(
path: '/callback',
builder: (context, state) {
// Solo se activa en web. El uri completo viene en state.uri.
ref.read(authViewModelProvider.notifier).handleCallback(state.uri);
return const _CallbackHandlerPage();
},
),
],
);
}
_CallbackHandlerPage muestra un spinner mientras el AuthViewModel procesa el callback. Tras éxito redirige a /home; en error a /login con el mensaje.
Paso 10 — AuthViewModel¶
AsyncNotifier que orquesta login/logout/callback (ADR-012):
class AuthViewModel extends AsyncNotifier<bool> {
@override
Future<bool> build() async {
final authService = ref.read(authServiceProvider);
return authService.isAuthenticated;
}
Future<void> login() async {
state = const AsyncLoading();
final result = await ref.read(authServiceProvider).login();
state = switch (result) {
Success() => const AsyncData(true),
Fail(:final failure) => AsyncError(failure, StackTrace.current),
};
}
Future<void> handleCallback(Uri uri) async {
state = const AsyncLoading();
final result = await ref.read(authServiceProvider).handleWebCallback(uri);
state = switch (result) {
Success() => const AsyncData(true),
Fail(:final failure) => AsyncError(failure, StackTrace.current),
};
if (state.hasValue && state.value!) {
ref.read(routerProvider).go('/home');
}
}
Future<void> logout() async { /* ... */ }
}
Paso 11 — LoginPage¶
UI con componentes App* del Design System (ADR-018):
class LoginPage extends ConsumerWidget {
const LoginPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authViewModelProvider);
// Mostrar SnackBar en caso de error específico
ref.listen(authViewModelProvider, (_, next) {
if (next.hasError && next.error is AuthFailure) {
AppSnackbar.error(context, failureToUserMessage(next.error as Failure));
}
});
return AppPageScaffold(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AppLogo(),
const SizedBox(height: AppSpacing.xl),
Text('Custodiam', style: Theme.of(context).textTheme.headlineLarge),
const SizedBox(height: AppSpacing.md),
AppPrimaryButton(
label: 'Iniciar sesión',
loading: authState.isLoading,
onPressed: () =>
ref.read(authViewModelProvider.notifier).login(),
),
],
),
),
);
}
}
Paso 12 — Tests¶
La pirámide de tests del repo (ADR-024):
- Unit + widget (
test/): mocks deAuthService,TokenStore,SessionStorageGateway. Cubren los caminos felices y losAuthFailureconmocktail. No tocan red. - Integración (
patrol_test/auth/): contra mock OIDC server levantado condocker compose --profile test. - E2E web (
patrol_test/web/): tres escenarios del DoD ejercitados con Chromium real:- Login feliz hasta
/home. - Llegada huérfana a
/callback(sin pasar por login) → mensaje de error. sessionStoragedeshabilitado →AuthFailure.sessionStorageUnavailable+AppSnackbar.
- Login feliz hasta
Para el caso 3, el test inyecta InMemorySessionStorageGateway(available: false) vía ProviderScope.overrides, no manipula el browser real (el WebAutomator de Patrol no expone runJsInBrowser).
Verificación final¶
- Configuración nativa Android (
intent-filterparaes.custodiam://callback). - Configuración nativa iOS (
CFBundleURLTypes). -
usePathUrlStrategy()enmain.dartpara web. - Las tres
redirect_uriregistradas en Keycloak (es.custodiam://callback,http://localhost:3000/callback,https://app.custodiam.es/callback). -
flutter run -d chrome --web-port=3000arranca y permite login completo. -
flutter runen Android device físico permite login completo via Custom Tab. - Tras logout, los tokens del
TokenStorequedan limpios. - El
ApiClientadjunta elAuthorization: Bearer <token>en todas las requests. -
flutter testpasa con los mocks deAuthService. -
flutter analyzesin errores.
Anexo — CurrentUser y AppPermissionGate¶
El cliente Flutter espeja la matriz rol → permisos del backend (ADR-013). Los permisos se evalúan localmente desde los roles del JWT — el backend revalida en cada request.
Permission enum¶
enum Permission {
voluntariosCrear('voluntarios.crear'),
voluntariosEditar('voluntarios.editar'),
// ... 40 permisos en total
;
final String value;
const Permission(this.value);
}
const Map<String, Set<Permission>> rolePermissions = {
'voluntario': {Permission.serviciosApuntarsePropio, /* ... */},
'jefe_equipo': {Permission.voluntariosVer, Permission.serviciosCrear, /* ... */},
// ... 12 roles
'admin': {Permission.sistemaAdmin, /* ... */},
};
Set<Permission> permissionsForRoles(Iterable<String> roles) {
final result = <Permission>{};
for (final role in roles) {
result.addAll(rolePermissions[role] ?? const {});
}
return result;
}
CurrentUser decodificado del JWT¶
class CurrentUser {
CurrentUser({
required this.id,
required this.username,
required this.email,
required this.givenName,
required this.familyName,
required this.roles,
});
final String id; // sub del JWT
final String username; // preferred_username
final String email;
final String givenName;
final String familyName;
final List<String> roles;
late final Set<Permission> permissions = permissionsForRoles(roles);
bool hasPermission(Permission p) => permissions.contains(p);
factory CurrentUser.fromJwt(Map<String, dynamic> claims) {
return CurrentUser(
id: claims['sub'] as String,
username: claims['preferred_username'] as String,
email: claims['email'] as String,
givenName: claims['given_name'] as String? ?? '',
familyName: claims['family_name'] as String? ?? '',
roles: (claims['roles'] as List?)?.cast<String>() ?? const [],
);
}
}
AppPermissionGate declarativo¶
class AppPermissionGate extends ConsumerWidget {
const AppPermissionGate({
required this.permission,
required this.child,
this.fallback,
super.key,
});
final Permission permission;
final Widget child;
final Widget? fallback;
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(currentUserProvider);
if (user != null && user.hasPermission(permission)) {
return child;
}
return fallback ?? const SizedBox.shrink();
}
}
Uso en cualquier feature:
AppPermissionGate(
permission: Permission.voluntariosCrear,
child: AppPrimaryButton(
label: 'Crear voluntario',
onPressed: () => context.go('/voluntarios/nuevo'),
),
)
Concentra la lógica de RBAC en un solo widget, evita esparcir if (currentUser.hasPermission(...)) por toda la UI y permite testear con un único patrón (pumpRiverpod + override del currentUserProvider + find.byType(AppPermissionGate)).
Problemas comunes¶
Invalid redirect URI al hacer login¶
- Verificar que la URI esté exactamente en Valid redirect URIs del cliente
custodiam-appen Keycloak. - En web local con
flutter run -d chrome: forzar--web-port=3000para que coincida con el registro del cliente. - En móvil: confirmar
es.custodiam://callback(nocustodiam://callback).
En móvil el callback no llega¶
- Verificar el
intent-filterenAndroidManifest.xmlconandroid:scheme="es.custodiam"yandroid:host="callback". - Verificar
CFBundleURLSchemesenInfo.plistcones.custodiam. - Comprobar que
AppLinks().uriLinkStream.listen(...)se suscribe antes de abrir el navegador.
El token no llega al backend¶
- Verificar que el
ApiClientadjuntaAuthorization: Bearer <token>en las cabeceras. - En las DevTools del navegador (web) o en
dev.log(name: 'API')(móvil), comprobar las requests reales. - Si el backend devuelve
401, verificar elazpcheck (ADR-010) — el cliente del token debe coincidir conKEYCLOAK_AUTHORIZED_PARTYdel backend.
Web: Keycloak no abre¶
- Comprobar que el navegador no esté bloqueando popups (debería usar
webOnlyWindowName: '_self', no'_blank'). - Verificar
EnvConfig.keycloakBaseUrlcondev.log(name: 'Auth').
Web: la app vuelve a /login tras autenticar en Keycloak¶
Síntoma: el browser entra a Keycloak, el usuario se autentica, vuelve a /callback, pero la app rebota a /login sin error visible. Causa más probable: code_verifier no se persistió o se perdió.
- Comprobar que
usePathUrlStrategy()está enmain.dartantes derunApp(). - Comprobar que
KeycloakWebAuthService.login()invoca_sessionStorage.write(_codeVerifierKey, grant.codeVerifier)antes de_launcher(...). - En DevTools → Application → Session Storage, verificar que la clave
custodiam.oauth.code_verifierexiste antes del redirect.
Web: sessionStorage deshabilitado¶
Algunos navegadores en modo privado / configuración corporativa devuelven excepción al escribir en sessionStorage. El WebSessionStorageGateway lo detecta con un probe setItem + removeItem y devuelve available: false. El KeycloakWebAuthService.login() retorna AuthFailure.sessionStorageUnavailable y la UI muestra AppSnackbar con un mensaje explicativo.
flutter test falla con "The Dart compiler exited unexpectedly"¶
Causa: algún archivo del grafo de imports importa package:web al top level. Solo se debe importar desde web_session_storage_gateway.dart y consumirlo desde providers.dart con conditional import. Buscar import 'package:web/web.dart' en lib/ — solo debe existir en ese archivo.
flutter analyze se queja por package:web¶
Comprobar el constraint web: ^1.1.0 en pubspec.yaml. La rama 0.x usa nombres distintos para los símbolos.
Tests E2E web requieren browser real¶
Los tests bajo patrol_test/web/ son E2E reales y solo corren bajo patrol test --target patrol_test/web/ --device chrome --web-headless=true, no bajo flutter test. Detalle en ADR-024.
Referencias¶
oauth2en pub.dev — librería que gestiona el flujo OAuth y PKCE.url_launcher— abre el navegador externo.app_links— captura deep links en móvil.flutter_secure_storage— almacenamiento cifrado de tokens.package:web— bindings web del SDK Dart.- RFC 7636 — PKCE — fundamento de la protección.
- RFC 9700 — OAuth 2.0 Security Best Current Practice — recomendaciones de seguridad.
- ADR-010, ADR-011, ADR-013, ADR-022, ADR-023, ADR-024 — decisiones que esta guía implementa.
- Configuración de Keycloak — setup del realm y los clientes OIDC.