import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'package:crypto/crypto.dart'; import 'package:drift_postgres/drift_postgres.dart'; import 'package:injectable/injectable.dart'; import 'package:mnemo_cards_backend/database/database.dart'; 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 { // Read secrets from environment variables static final String _jwtSecret = Platform.environment['JWT_SECRET'] ?? 'dev-jwt-secret-change-me-in-production'; static final String _jwtRefreshSecret = Platform.environment['JWT_REFRESH_SECRET'] ?? 'dev-refresh-secret-change-me-in-production'; static const int _accessTokenExpirySeconds = 3600; // 1 hour static const int _refreshTokenExpirySeconds = 2592000; // 30 days final AppDatabase _db; JwtService(this._db); /// 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, ); } // Store last verification failure reason for debugging String? lastVerificationError; /// Verify access token and return user ID String? verifyAccessToken(String token, {bool debug = false}) { lastVerificationError = null; try { final payload = _verifySimpleJwt(token, debug: debug); if (payload == null) { lastVerificationError = lastVerificationError ?? 'Signature verification failed'; 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)) { lastVerificationError = 'Token expired at $expiryTime'; return null; } } // Check token type if (payload['type'] != 'access') { lastVerificationError = 'Wrong token type: ${payload['type']}'; return null; } return payload['userId']?.toString(); } catch (e) { lastVerificationError = 'Exception: $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))); // Use different secrets for access and refresh tokens final isRefreshToken = payload['type'] == 'refresh'; final secret = isRefreshToken ? _jwtRefreshSecret : _jwtSecret; // Create signature with HMAC-SHA256 final signatureInput = '$headerB64.$payloadB64'; final signature = _hmacSha256(utf8.encode(signatureInput), secret); final signatureB64 = base64UrlEncode(signature); return '$headerB64.$payloadB64.$signatureB64'; } /// Verify a simple JWT Map? _verifySimpleJwt(String token, {bool debug = false}) { try { final parts = token.split('.'); if (parts.length != 3) { lastVerificationError = 'Invalid token format: ${parts.length} parts'; return null; } final headerB64 = parts[0]; final payloadB64 = parts[1]; final signatureB64 = parts[2]; // Decode payload first to determine token type final payloadJson = utf8.decode(base64Url.decode(payloadB64)); final payload = jsonDecode(payloadJson) as Map; // Use different secrets for access and refresh tokens final isRefreshToken = payload['type'] == 'refresh'; final secret = isRefreshToken ? _jwtRefreshSecret : _jwtSecret; // Verify signature with appropriate secret final signatureInput = '$headerB64.$payloadB64'; final expectedSignature = _hmacSha256(utf8.encode(signatureInput), secret); final expectedSignatureB64 = base64UrlEncode(expectedSignature); if (signatureB64 != expectedSignatureB64) { lastVerificationError = 'Signature mismatch: got ${signatureB64.substring(0, 10)}..., ' 'expected ${expectedSignatureB64.substring(0, 10)}...'; return null; // Invalid signature } return payload; } catch (e) { lastVerificationError = 'Parse exception: $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, String userId, DateTime createdAt, DateTime expiresAt, ) async { await _db.transaction(() async { // Delete old token if exists (shouldn't happen due to unique index, but be safe) final existing = await _db.userDao.getRefreshTokenByJti(jti); if (existing != null) { await _db.userDao.revokeRefreshToken(existing.id); } // Store new token await _db.userDao.createRefreshToken( RefreshTokensCompanion.insert( jti: jti, userId: userId, expiresAt: PgDateTime(expiresAt), // required field - raw DateTime // createdAt and isBlacklisted use defaults from table ), ); }); } Future _isTokenBlacklisted(String jti) async { final token = await _db.userDao.getRefreshTokenByJti(jti); if (token == null) { return true; // Token not found, consider it invalid } // Check if expired if (token.expiresAt.dateTime.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 _db.userDao.revokeRefreshTokenByJti(jti); } /// Clean up expired tokens (should be called periodically) Future cleanupExpiredTokens() async { await _db.userDao.deleteExpiredRefreshTokens(); } } /// 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, }); }