|
| 1 | +import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; |
| 2 | +import 'package:ht_api/src/services/auth_token_service.dart'; |
| 3 | +import 'package:ht_data_repository/ht_data_repository.dart'; |
| 4 | +import 'package:ht_shared/ht_shared.dart'; |
| 5 | +import 'package:uuid/uuid.dart'; |
| 6 | + |
| 7 | +/// {@template jwt_auth_token_service} |
| 8 | +/// An implementation of [AuthTokenService] using JSON Web Tokens (JWT). |
| 9 | +/// |
| 10 | +/// Handles the creation (signing) and validation (verification) of JWTs |
| 11 | +/// for user authentication. |
| 12 | +/// {@endtemplate} |
| 13 | +class JwtAuthTokenService implements AuthTokenService { |
| 14 | + /// {@macro jwt_auth_token_service} |
| 15 | + /// |
| 16 | + /// Requires an [HtDataRepository<User>] to fetch user details after |
| 17 | + /// validating the token's subject claim. |
| 18 | + /// Also requires a [Uuid] generator for creating unique JWT IDs (jti). |
| 19 | + const JwtAuthTokenService({ |
| 20 | + required HtDataRepository<User> userRepository, |
| 21 | + required Uuid uuidGenerator, |
| 22 | + }) : _userRepository = userRepository, |
| 23 | + _uuid = uuidGenerator; |
| 24 | + |
| 25 | + final HtDataRepository<User> _userRepository; |
| 26 | + final Uuid _uuid; |
| 27 | + |
| 28 | + // --- Configuration --- |
| 29 | + |
| 30 | + // WARNING: Hardcoding secrets is insecure. Use environment variables |
| 31 | + // or a proper secrets management solution in production. |
| 32 | + static const String _secretKey = |
| 33 | + 'your-very-hardcoded-super-secret-key-replace-this-in-prod'; |
| 34 | + |
| 35 | + // Define token issuer and default expiry duration |
| 36 | + static const String _issuer = 'https://your-api-domain.com'; // Replace |
| 37 | + static const Duration _tokenExpiryDuration = Duration(hours: 1); |
| 38 | + |
| 39 | + // --- Interface Implementation --- |
| 40 | + |
| 41 | + @override |
| 42 | + Future<String> generateToken(User user) async { |
| 43 | + try { |
| 44 | + final now = DateTime.now(); |
| 45 | + final expiry = now.add(_tokenExpiryDuration); |
| 46 | + |
| 47 | + final jwt = JWT( |
| 48 | + { |
| 49 | + // Standard claims |
| 50 | + 'sub': user.id, // Subject (user ID) - REQUIRED |
| 51 | + 'exp': expiry.millisecondsSinceEpoch ~/ 1000, // Expiration Time |
| 52 | + 'iat': now.millisecondsSinceEpoch ~/ 1000, // Issued At |
| 53 | + 'iss': _issuer, // Issuer |
| 54 | + 'jti': _uuid.v4(), // JWT ID (for potential blacklisting) |
| 55 | + |
| 56 | + // Custom claims (optional, include what's useful) |
| 57 | + 'email': user.email, |
| 58 | + 'isAnonymous': user.isAnonymous, |
| 59 | + }, |
| 60 | + issuer: _issuer, |
| 61 | + subject: user.id, |
| 62 | + jwtId: _uuid.v4(), // Re-setting jti here for clarity if needed |
| 63 | + ); |
| 64 | + |
| 65 | + // Sign the token using HMAC-SHA256 |
| 66 | + final token = jwt.sign( |
| 67 | + SecretKey(_secretKey), |
| 68 | + algorithm: JWTAlgorithm.HS256, |
| 69 | + expiresIn: _tokenExpiryDuration, // Redundant but safe |
| 70 | + ); |
| 71 | + |
| 72 | + print('Generated JWT for user ${user.id}'); |
| 73 | + return token; |
| 74 | + } catch (e) { |
| 75 | + print('Error generating JWT for user ${user.id}: $e'); |
| 76 | + // Map to a standard exception |
| 77 | + throw OperationFailedException( |
| 78 | + 'Failed to generate authentication token: ${e.toString()}', |
| 79 | + ); |
| 80 | + } |
| 81 | + } |
| 82 | + |
| 83 | + @override |
| 84 | + Future<User?> validateToken(String token) async { |
| 85 | + try { |
| 86 | + // Verify the token's signature and expiry |
| 87 | + final jwt = JWT.verify(token, SecretKey(_secretKey)); |
| 88 | + |
| 89 | + // Extract user ID from the subject claim |
| 90 | + final userId = jwt.payload['sub'] as String?; |
| 91 | + if (userId == null) { |
| 92 | + print('Token validation failed: Missing "sub" claim.'); |
| 93 | + // Throw specific exception for malformed token |
| 94 | + throw const BadRequestException('Malformed token: Missing subject claim.'); |
| 95 | + } |
| 96 | + |
| 97 | + // Fetch the full user object from the repository |
| 98 | + // This ensures the user still exists and is valid |
| 99 | + final user = await _userRepository.read(userId); |
| 100 | + print('Token validated successfully for user ${user.id}'); |
| 101 | + return user; |
| 102 | + } on JWTExpiredException { |
| 103 | + print('Token validation failed: Token expired.'); |
| 104 | + // Throw specific exception for expired token |
| 105 | + throw const UnauthorizedException('Token expired.'); |
| 106 | + } on JWTInvalidException catch (e) { |
| 107 | + print('Token validation failed: Invalid token. Reason: ${e.message}'); |
| 108 | + // Throw specific exception for invalid token signature/format |
| 109 | + throw UnauthorizedException('Invalid token: ${e.message}'); |
| 110 | + } on JWTException catch (e) { // Use JWTException as the general catch-all |
| 111 | + print('Token validation failed: JWT Exception. Reason: ${e.message}'); |
| 112 | + // Treat other JWT exceptions as invalid tokens |
| 113 | + throw UnauthorizedException('Invalid token: ${e.message}'); |
| 114 | + } on HtHttpException catch (e) { |
| 115 | + // Handle errors from the user repository (e.g., user not found) |
| 116 | + print('Token validation failed: Error fetching user $e'); |
| 117 | + // Re-throw repository exceptions directly for the error handler |
| 118 | + rethrow; |
| 119 | + } catch (e) { |
| 120 | + // Catch unexpected errors during validation |
| 121 | + print('Unexpected error during token validation: $e'); |
| 122 | + // Wrap unexpected errors in a standard exception type |
| 123 | + throw OperationFailedException( |
| 124 | + 'Token validation failed unexpectedly: ${e.toString()}', |
| 125 | + ); |
| 126 | + } |
| 127 | + } |
| 128 | +} |
0 commit comments