import 'dart:convert'; import 'dart:math'; import 'package:crypto/crypto.dart'; import 'package:injectable/injectable.dart'; import 'package:isar/isar.dart'; import 'package:mnemo_cards_backend/main.dart' as main; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; /// Service for JWT token generation and verification /// /// Generates access tokens (short-lived) and refresh tokens (long-lived) @lazySingleton class JwtService { // In production, use environment variables or secure key management static const String _secretKey = 'your-secret-key-change-in-production'; static const int _accessTokenExpirySeconds = 3600; // 1 hour static const int _refreshTokenExpirySeconds = 2592000; // 30 days JwtService(); /// Generate access and refresh tokens for a user Future generateTokens(UserModel user) async { final now = DateTime.now(); final jti = _generateJti(); // Access token payload final accessTokenPayload = { 'sub': user.id.toString(), 'iat': now.millisecondsSinceEpoch ~/ 1000, 'exp': (now.millisecondsSinceEpoch ~/ 1000) + _accessTokenExpirySeconds, 'type': 'access', 'userId': user.id, }; // Refresh token payload final refreshTokenPayload = { 'sub': user.id.toString(), 'iat': now.millisecondsSinceEpoch ~/ 1000, 'exp': (now.millisecondsSinceEpoch ~/ 1000) + _refreshTokenExpirySeconds, 'type': 'refresh', 'userId': user.id, 'jti': jti, // JWT ID for token invalidation }; // Create JWT tokens with proper HMAC-SHA256 signing final accessToken = _createSimpleJwt(accessTokenPayload); final refreshToken = _createSimpleJwt(refreshTokenPayload); // Store refresh token for invalidation final userId = user.id; if (userId == null) { throw Exception('Cannot create tokens for user without ID'); } final expiresAt = now.add(Duration(seconds: _refreshTokenExpirySeconds)); await _storeRefreshToken(jti, userId, now, expiresAt); return JwtTokens( accessToken: accessToken, refreshToken: refreshToken, expiresIn: _accessTokenExpirySeconds, ); } /// Verify access token and return user ID String? verifyAccessToken(String token) { try { final payload = _verifySimpleJwt(token); if (payload == null) return null; // Check if token is expired final exp = payload['exp'] as int?; if (exp != null) { final expiryTime = DateTime.fromMillisecondsSinceEpoch(exp * 1000); if (DateTime.now().isAfter(expiryTime)) { return null; } } // Check token type if (payload['type'] != 'access') { return null; } return payload['userId']?.toString(); } catch (e) { return null; } } /// Verify refresh token and return user ID Future verifyRefreshToken(String token) async { try { final payload = _verifySimpleJwt(token); if (payload == null) return null; // Check if token is expired final exp = payload['exp'] as int?; if (exp != null) { final expiryTime = DateTime.fromMillisecondsSinceEpoch(exp * 1000); if (DateTime.now().isAfter(expiryTime)) { return null; } } // Check token type if (payload['type'] != 'refresh') { return null; } // Check if token is blacklisted final jti = payload['jti'] as String?; if (jti != null) { final isBlacklisted = await _isTokenBlacklisted(jti); if (isBlacklisted) { return null; } } return payload['userId']?.toString(); } catch (e) { return null; } } /// Create a simple JWT (base64 encoded JSON) /// Note: This is a simplified implementation /// For production, use a proper JWT library with proper signing String _createSimpleJwt(Map payload) { final header = {'alg': 'HS256', 'typ': 'JWT'}; final headerB64 = base64UrlEncode(utf8.encode(jsonEncode(header))); final payloadB64 = base64UrlEncode(utf8.encode(jsonEncode(payload))); // Create signature (simplified - in production use proper HMAC) final signatureInput = '$headerB64.$payloadB64'; final signature = _hmacSha256(utf8.encode(signatureInput), _secretKey); final signatureB64 = base64UrlEncode(signature); return '$headerB64.$payloadB64.$signatureB64'; } /// Verify a simple JWT Map? _verifySimpleJwt(String token) { try { final parts = token.split('.'); if (parts.length != 3) return null; final headerB64 = parts[0]; final payloadB64 = parts[1]; final signatureB64 = parts[2]; // Verify signature final signatureInput = '$headerB64.$payloadB64'; final expectedSignature = _hmacSha256(utf8.encode(signatureInput), _secretKey); final expectedSignatureB64 = base64UrlEncode(expectedSignature); if (signatureB64 != expectedSignatureB64) { return null; // Invalid signature } // Decode payload final payloadJson = utf8.decode(base64Url.decode(payloadB64)); return jsonDecode(payloadJson) as Map; } catch (e) { return null; } } /// Extract payload from JWT without verification (for extracting jti during logout) Map? extractPayload(String token) { try { final parts = token.split('.'); if (parts.length != 3) return null; final payloadB64 = parts[1]; final payloadJson = utf8.decode(base64Url.decode(payloadB64)); return jsonDecode(payloadJson) as Map; } catch (e) { return null; } } /// HMAC-SHA256 implementation using crypto package List _hmacSha256(List data, String key) { final keyBytes = utf8.encode(key); final hmac = Hmac(sha256, keyBytes); final digest = hmac.convert(data); return digest.bytes; } String _generateJti() { final random = Random(); return base64UrlEncode( List.generate(16, (_) => random.nextInt(256)), ); } Future _storeRefreshToken( String jti, int userId, DateTime createdAt, DateTime expiresAt, ) async { await main.isar.writeTxn(() async { // Delete old token if exists (shouldn't happen due to unique index, but be safe) final existing = await main.isar.refreshTokenModels .filter() .jtiEqualTo(jti) .findFirst(); if (existing != null) { await main.isar.refreshTokenModels.delete(existing.id!); } // Store new token await main.isar.refreshTokenModels.put( RefreshTokenModel( jti: jti, userId: userId, createdAt: createdAt, expiresAt: expiresAt, isBlacklisted: false, ), ); }); } Future _isTokenBlacklisted(String jti) async { final token = await main.isar.refreshTokenModels.filter().jtiEqualTo(jti).findFirst(); if (token == null) { return true; // Token not found, consider it invalid } // Check if expired if (token.expiresAt.isBefore(DateTime.now())) { return true; // Expired tokens are considered invalid } return token.isBlacklisted; } /// Blacklist a refresh token (for logout) Future blacklistRefreshToken(String jti) async { await main.isar.writeTxn(() async { final token = await main.isar.refreshTokenModels .filter() .jtiEqualTo(jti) .findFirst(); if (token != null) { await main.isar.refreshTokenModels.put( token.copyWith(isBlacklisted: true), ); } }); } /// Clean up expired tokens (should be called periodically) Future cleanupExpiredTokens() async { await main.isar.writeTxn(() async { final now = DateTime.now(); final expired = await main.isar.refreshTokenModels .filter() .expiresAtLessThan(now) .findAll(); if (expired.isNotEmpty) { final ids = expired .map((t) => t.id) .where((id) => id != null) .cast() .toList(); if (ids.isNotEmpty) { await main.isar.refreshTokenModels.deleteAll(ids); } } }); } } /// JWT tokens result class JwtTokens { final String accessToken; final String refreshToken; final int expiresIn; // seconds JwtTokens({ required this.accessToken, required this.refreshToken, required this.expiresIn, }); }