// lib/services/auth/keycloak_auth_service.dart // // سرویس مسئول صحبت مستقیم با سرور Keycloak برای: // - login (Resource Owner Password Credentials) // - refresh (refresh_token grant) // - logout (revoke refresh_token) // // به دلیل ساده بودن این endpoint ها و عدم وابستگی به ساختار قدیمی RequestService // (که با JSON کار می‌کند و این endpoint ها form-urlencoded هستند) به صورت // مستقل از package:http استفاده می‌کنیم. import 'dart:async'; import 'dart:convert'; import 'dart:developer' as dev; import 'dart:io' show SocketException; // ignore: depend_on_referenced_packages import 'package:http/http.dart' as http; import 'package:didvan/config/auth_config.dart'; import 'package:didvan/services/auth/jwt_utils.dart'; import 'package:didvan/services/auth/token_storage.dart'; /// نتیجه‌ی یک تلاش احراز هویت با Keycloak. class KeycloakAuthResult { final bool success; final String? errorMessage; final int? statusCode; KeycloakAuthResult.ok() : success = true, errorMessage = null, statusCode = 200; KeycloakAuthResult.fail(this.errorMessage, {this.statusCode}) : success = false; } class KeycloakAuthService { KeycloakAuthService._(); static final KeycloakAuthService instance = KeycloakAuthService._(); /// در حال refresh هستیم؟ (برای جلوگیری از refresh موازی) Future? _refreshing; /// Public wrapper so the identifier→username lookup can be reused by /// pre-login flows (e.g. required-actions check on the auth screen). Future resolveIdentifier(String input) => _resolveUsername(input); /// If [input] looks like an Iran phone number (0 followed by 10 digits), /// ask the api gateway to look up the corresponding Keycloak username. /// Otherwise return [input] unchanged (assume it's already the username). /// Returns null when the phone doesn't match any user. Future _resolveUsername(String input) async { final trimmed = input.trim(); final isPhone = RegExp(r'^0\d{10}$').hasMatch(trimmed); if (!isPhone) return trimmed; try { final resp = await http .get( Uri.parse( '${AuthConfig.apiBaseUrl}/auth/resolve-username?identifier=$trimmed', ), headers: const {'Accept': 'application/json'}, ) .timeout(const Duration(seconds: 10)); if (resp.statusCode == 200) { final data = jsonDecode(resp.body) as Map; final u = data['username']; if (u is String && u.isNotEmpty) return u; } // 404 or any non-200 -> treat as "no user"; caller returns fail. return null; } catch (e) { dev.log('resolve-username failed: $e', name: 'KeycloakAuthService'); return null; } } /// لاگین با username/password. /// [username] can be a Keycloak username OR an Iran phone number /// (0XXXXXXXXXX). Phone gets resolved to username server-side before the /// password grant so Keycloak sees a real username either way. /// /// در صورت موفقیت، توکن‌ها در [TokenStorage] ذخیره می‌شوند. Future login({ required String username, required String password, }) async { try { final resolved = await _resolveUsername(username); if (resolved == null) { return KeycloakAuthResult.fail( 'نام کاربری یا رمز عبور اشتباه است.', statusCode: 401, ); } final body = { 'grant_type': 'password', 'client_id': AuthConfig.keycloakClientId, 'username': resolved, 'password': password, // `offline_access` requests an OFFLINE refresh token that is NOT bound // to the SSO session idle timeout. Result: the app effectively never // logs the user out on its own — the offline session persists until // the user or an admin explicitly revokes it. 'scope': 'openid offline_access', }; final secret = AuthConfig.keycloakClientSecret; if (secret != null && secret.isNotEmpty) { body['client_secret'] = secret; } final response = await _withSocketRetry( () => http .post( Uri.parse(AuthConfig.keycloakTokenUrl), headers: const { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json', }, body: body, ) .timeout(const Duration(seconds: 30)), ); if (response.statusCode == 200) { await _persistTokenResponse(response.body); return KeycloakAuthResult.ok(); } final message = _extractError(response.body) ?? 'نام کاربری یا رمز عبور اشتباه است.'; return KeycloakAuthResult.fail(message, statusCode: response.statusCode); } catch (e) { dev.log('Keycloak login error: $e', name: 'KeycloakAuthService'); return KeycloakAuthResult.fail( 'خطا در ارتباط با سرور احراز هویت.', ); } } /// Retry on transient network failures. Covers: /// - SocketException (DNS glitch at emulator boot, transient network drop) /// - http.ClientException (connection reset, TLS handshake fail) /// - TimeoutException (Keycloak cold-start; first call can exceed 30s /// while the JVM warms up, next call reuses the warm connection). /// Backoff: 250ms, 500ms. Future _withSocketRetry(Future Function() body) async { for (var attempt = 0; attempt < 3; attempt++) { try { return await body(); } on SocketException { if (attempt == 2) rethrow; await Future.delayed(Duration(milliseconds: 250 * (attempt + 1))); } on http.ClientException { if (attempt == 2) rethrow; await Future.delayed(Duration(milliseconds: 250 * (attempt + 1))); } on TimeoutException { if (attempt == 2) rethrow; await Future.delayed(Duration(milliseconds: 250 * (attempt + 1))); } } throw StateError('retry loop exited without return'); } /// تلاش برای تمدید توکن. در صورت موفقیت true. /// اگر چند درخواست همزمان نیاز به refresh داشته باشند فقط یک‌بار اجرا می‌شود. Future refresh() async { final inProgress = _refreshing; if (inProgress != null) { await inProgress; return TokenStorage.isAccessTokenValid; } final completer = _refreshing = _doRefresh(); try { await completer; } finally { _refreshing = null; } return TokenStorage.isAccessTokenValid; } Future _doRefresh() async { final refreshToken = TokenStorage.refreshToken; if (refreshToken == null || refreshToken.isEmpty) { // ignore: avoid_print print('🔐 refresh: NO refresh token -> abort'); return; } // 🔐 PROBE موقت final now = DateTime.now().millisecondsSinceEpoch; // ignore: avoid_print print('🔐 refresh START accessValid=${TokenStorage.isAccessTokenValid} ' 'refreshValid=${TokenStorage.isRefreshTokenValid} ' 'accessExpiresInMs=${TokenStorage.accessExpiresAt != null ? TokenStorage.accessExpiresAt! - now : "?"} ' 'refreshExpiresInMs=${TokenStorage.refreshExpiresAt != null ? TokenStorage.refreshExpiresAt! - now : "?"}'); if (!TokenStorage.isRefreshTokenValid) { // refresh token هم منقضی شده => پاک می‌کنیم تا کاربر دوباره login کند. // ignore: avoid_print print('🔐 refresh: refresh token EXPIRED -> clearing tokens (logout)'); await TokenStorage.clear(); return; } try { final body = { 'grant_type': 'refresh_token', 'client_id': AuthConfig.keycloakClientId, 'refresh_token': refreshToken, // Keep offline_access on rotation so the renewed refresh token is // also an offline token — otherwise a normal refresh would downgrade // us to a session-bound refresh that expires with SSO idle. 'scope': 'openid offline_access', }; final secret = AuthConfig.keycloakClientSecret; if (secret != null && secret.isNotEmpty) { body['client_secret'] = secret; } final response = await _withSocketRetry( () => http .post( Uri.parse(AuthConfig.keycloakTokenUrl), headers: const { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json', }, body: body, ) .timeout(const Duration(seconds: 30)), ); if (response.statusCode == 200) { await _persistTokenResponse(response.body); // ignore: avoid_print print('🔐 refresh OK 200 -> tokens renewed'); return; } // فقط در شکست‌های "قطعی" (400 invalid_grant / 401 unauthorized) که // یعنی refresh token واقعا نامعتبر شده، state را پاک می‌کنیم. // بقیه‌ی خطاها (5xx، شبکه، CORS، ...) ممکن است گذرا باشند و نباید // باعث logout اجباری شوند. if (response.statusCode == 400 || response.statusCode == 401) { // ignore: avoid_print print('🔐 refresh FAIL ${response.statusCode} body=${response.body} ' '-> clearing tokens (logout)'); dev.log( 'Refresh permanent failure ${response.statusCode}: ${response.body}', name: 'KeycloakAuthService', ); await TokenStorage.clear(); } else { // ignore: avoid_print print('🔐 refresh transient ${response.statusCode} ' 'body=${response.body} -> keeping tokens'); dev.log( 'Refresh transient failure ${response.statusCode}: ' '${response.body} — keeping tokens', name: 'KeycloakAuthService', ); } } catch (e) { // خطای transient (شبکه/SSL/timeout) — token پاک نمی‌شود. dev.log('Refresh exception (transient): $e', name: 'KeycloakAuthService'); } } /// دریافت اطلاعات استاندارد OpenID Connect کاربر جاری. /// این endpoint برای هر کاربر authenticated در دسترس است و نیازی به /// scope خاصی ندارد. /// /// پاسخ معمول شامل: sub, preferred_username, email, given_name, /// family_name, name. در صورت تنظیم Protocol Mapper در client، /// attributes اضافی (مثل phone یا avatar) نیز در پاسخ ظاهر می‌شوند. Future?> fetchUserInfo() async { final token = TokenStorage.accessToken; if (token == null || token.isEmpty) return null; try { final response = await _withSocketRetry( () => http.get( Uri.parse(AuthConfig.keycloakUserInfoUrl), headers: { 'Authorization': 'Bearer $token', 'Accept': 'application/json', }, ).timeout(const Duration(seconds: 30)), ); if (response.statusCode == 200) { final data = json.decode(response.body); if (data is Map) return data; if (data is Map) return Map.from(data); } dev.log( 'userinfo failed ${response.statusCode}: ${response.body}', name: 'KeycloakAuthService', ); return null; } catch (e) { dev.log('userinfo error: $e', name: 'KeycloakAuthService'); return null; } } /// revoke یک refresh token مشخص روی سرور Keycloak. /// بدون تاثیر روی state لوکال. خطاها silently log می‌شوند. Future revokeRefreshToken(String refreshToken) async { if (refreshToken.isEmpty) return; try { final body = { 'client_id': AuthConfig.keycloakClientId, 'refresh_token': refreshToken, }; final secret = AuthConfig.keycloakClientSecret; if (secret != null && secret.isNotEmpty) { body['client_secret'] = secret; } await http .post( Uri.parse(AuthConfig.keycloakLogoutUrl), headers: const { 'Content-Type': 'application/x-www-form-urlencoded', }, body: body, ) .timeout(const Duration(seconds: 10)); } catch (e) { dev.log('Keycloak revoke error: $e', name: 'KeycloakAuthService'); } } /// logout کامل: revoke روی سرور + پاکسازی state لوکال. /// در هر حالت state لوکال پاک می‌شود. Future logout() async { final refreshToken = TokenStorage.refreshToken; await TokenStorage.clear(); if (refreshToken != null) { await revokeRefreshToken(refreshToken); } } // ------------------------------------------------------------- // Helpers // ------------------------------------------------------------- Future _persistTokenResponse(String responseBody) async { final Map data = json.decode(responseBody); final access = data['access_token'] as String?; final refresh = data['refresh_token'] as String?; if (access == null || refresh == null) { throw StateError('Token response missing required fields'); } final userId = JwtUtils.subjectOf(access); // ignore: avoid_print print('🔐 token lifetimes: expires_in=${data['expires_in']} ' 'refresh_expires_in=${data['refresh_expires_in']}'); await TokenStorage.saveTokens( accessToken: access, refreshToken: refresh, idToken: data['id_token'] as String?, expiresIn: (data['expires_in'] as num?)?.toInt(), refreshExpiresIn: (data['refresh_expires_in'] as num?)?.toInt(), userId: userId, ); } /// تلاش برای استخراج پیام خطای قابل نمایش از body پاسخ Keycloak. String? _extractError(String body) { if (body.isEmpty) return null; try { final Map data = json.decode(body); final desc = data['error_description'] ?? data['error']; if (desc is String && desc.isNotEmpty) { // ترجمه‌ی چند خطای متداول Keycloak switch (desc) { case 'Invalid user credentials': return 'نام کاربری یا رمز عبور اشتباه است.'; case 'Account disabled': return 'حساب کاربری غیرفعال است.'; case 'Account is not fully set up': return 'حساب شما کامل نیست. لطفا ابتدا رمز خود را تنظیم کنید.'; } return desc; } } catch (_) {} return null; } }