Skip to content

Commit bec62b0

Browse files
committed
feat(auth): Add JWT token invalidation
- Implemented token blacklist - Added invalidateToken method - Prevents further token use
1 parent da52b6d commit bec62b0

File tree

6 files changed

+375
-32
lines changed

6 files changed

+375
-32
lines changed

lib/src/services/auth_service.dart

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -171,17 +171,46 @@ class AuthService {
171171
}
172172
}
173173

174-
/// Performs sign-out actions (currently placeholder).
174+
/// Performs server-side sign-out actions.
175175
///
176-
/// In a real implementation, this might involve invalidating the token
177-
/// on the server-side (e.g., adding to a blacklist if using JWTs).
178-
/// The primary sign-out action (clearing local token) happens client-side.
176+
/// Currently, this method logs the sign-out attempt. True server-side
177+
/// token invalidation (e.g., blacklisting a JWT) is not implemented
178+
/// in the underlying [AuthTokenService] and would require adding that
179+
/// capability (e.g., an `invalidateToken` method and a blacklist store).
180+
///
181+
/// The primary client-side action (clearing the local token) is handled
182+
/// separately by the client application.
183+
///
184+
/// Throws: This implementation currently does not throw exceptions under
185+
/// normal circumstances. Future implementations involving token
186+
/// invalidation might throw [OperationFailedException] if invalidation fails.
179187
Future<void> performSignOut({required String userId}) async {
180-
// Placeholder: Server-side token invalidation logic would go here.
181-
// For the current SimpleAuthTokenService, there's nothing server-side
182-
// to invalidate easily. A real JWT implementation might use a blacklist.
183-
print('Performing server-side sign-out actions for user $userId (if any).');
184-
await Future<void>.delayed(Duration.zero); // Simulate async
185-
// No exceptions thrown here unless invalidation fails.
188+
// Log the attempt.
189+
print(
190+
'[AuthService] Received request for server-side sign-out actions '
191+
'for user $userId.',
192+
);
193+
194+
// --- Placeholder for Future Token Invalidation ---
195+
// If AuthTokenService had an invalidateToken method, it would be called here:
196+
// try {
197+
// // Assuming the token itself or its JTI was passed or derivable
198+
// // String tokenToInvalidate = ...;
199+
// // await _authTokenService.invalidateToken(tokenToInvalidate);
200+
// print('[AuthService] Token invalidation logic executed (if implemented).');
201+
// } catch (e) {
202+
// print('[AuthService] Error during token invalidation for user $userId: $e');
203+
// // Decide whether to rethrow or just log
204+
// // throw OperationFailedException('Failed server-side sign-out: $e');
205+
// }
206+
// ------------------------------------------------
207+
208+
// Simulate potential async work if needed in the future.
209+
await Future<void>.delayed(Duration.zero);
210+
211+
print(
212+
'[AuthService] Server-side sign-out actions complete for user $userId.',
213+
);
214+
// No specific exceptions are thrown in this placeholder implementation.
186215
}
187216
}

lib/src/services/auth_token_service.dart

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ abstract class AuthTokenService {
2323
/// Throws [OperationFailedException] for unexpected validation errors.
2424
Future<User?> validateToken(String token);
2525

26-
// Potential future methods:
27-
// Future<void> invalidateToken(String token); // For token blacklisting
26+
/// Invalidates the given token, preventing its further use.
27+
///
28+
/// Implementations might achieve this by adding the token's identifier
29+
/// (e.g., JWT ID 'jti') to a blacklist until its original expiry time.
30+
///
31+
/// - [token]: The token string to invalidate.
32+
///
33+
/// Throws:
34+
/// - [InvalidInputException] if the token format is invalid.
35+
/// - [OperationFailedException] if the invalidation process fails unexpectedly.
36+
Future<void> invalidateToken(String token);
2837
}

lib/src/services/jwt_auth_token_service.dart

Lines changed: 114 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,37 @@
1+
import 'dart:convert'; // For potential base64 decoding if needed
2+
13
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
24
import 'package:ht_api/src/services/auth_token_service.dart';
5+
// Import the blacklist service
6+
import 'package:ht_api/src/services/token_blacklist_service.dart';
37
import 'package:ht_data_repository/ht_data_repository.dart';
48
import 'package:ht_shared/ht_shared.dart';
59
import 'package:uuid/uuid.dart';
610

711
/// {@template jwt_auth_token_service}
812
/// An implementation of [AuthTokenService] using JSON Web Tokens (JWT).
913
///
10-
/// Handles the creation (signing) and validation (verification) of JWTs
11-
/// for user authentication.
14+
/// Handles the creation (signing) and validation (verification) of JWTs,
15+
/// including support for token invalidation via blacklisting.
1216
/// {@endtemplate}
1317
class JwtAuthTokenService implements AuthTokenService {
1418
/// {@macro jwt_auth_token_service}
1519
///
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).
20+
/// Requires:
21+
/// - [userRepository]: To fetch user details after validating the token's
22+
/// subject claim.
23+
/// - [blacklistService]: To manage the blacklist of invalidated tokens.
24+
/// - [uuidGenerator]: For creating unique JWT IDs (jti).
1925
const JwtAuthTokenService({
2026
required HtDataRepository<User> userRepository,
27+
required TokenBlacklistService blacklistService,
2128
required Uuid uuidGenerator,
2229
}) : _userRepository = userRepository,
30+
_blacklistService = blacklistService,
2331
_uuid = uuidGenerator;
2432

2533
final HtDataRepository<User> _userRepository;
34+
final TokenBlacklistService _blacklistService;
2635
final Uuid _uuid;
2736

2837
// --- Configuration ---
@@ -89,7 +98,30 @@ class JwtAuthTokenService implements AuthTokenService {
8998
final jwt = JWT.verify(token, SecretKey(_secretKey));
9099
print('[validateToken] Token verified. Payload: ${jwt.payload}');
91100

92-
// Extract user ID from the subject claim
101+
// --- Blacklist Check ---
102+
// Extract the JWT ID (jti) claim
103+
final jti = jwt.payload['jti'] as String?;
104+
if (jti == null || jti.isEmpty) {
105+
print(
106+
'[validateToken] Token validation failed: Missing or empty "jti" claim.');
107+
// Throw specific exception for malformed token
108+
throw const BadRequestException(
109+
'Malformed token: Missing or empty JWT ID (jti) claim.',
110+
);
111+
}
112+
113+
print('[validateToken] Checking blacklist for jti: $jti');
114+
final isBlacklisted = await _blacklistService.isBlacklisted(jti);
115+
if (isBlacklisted) {
116+
print(
117+
'[validateToken] Token validation failed: Token is blacklisted (jti: $jti).');
118+
// Throw specific exception for blacklisted token
119+
throw const UnauthorizedException('Token has been invalidated.');
120+
}
121+
print('[validateToken] Token is not blacklisted (jti: $jti).');
122+
// --- End Blacklist Check ---
123+
124+
// Extract user ID from the subject claim ('sub')
93125
final subClaim = jwt.payload['sub'];
94126
print(
95127
'[validateToken] Extracted "sub" claim: $subClaim '
@@ -104,12 +136,11 @@ class JwtAuthTokenService implements AuthTokenService {
104136
'[validateToken] "sub" claim successfully cast to String: $userId',
105137
);
106138
} else if (subClaim != null) {
139+
// Treat non-string sub as an error
107140
print(
108-
'[validateToken] WARNING: "sub" claim is not a String. '
109-
'Attempting toString().',
141+
'[validateToken] ERROR: "sub" claim is not a String '
142+
'(Type: ${subClaim.runtimeType}).',
110143
);
111-
// Handle potential non-string types if necessary, or throw error
112-
// For now, let's treat non-string sub as an error
113144
throw BadRequestException(
114145
'Malformed token: "sub" claim is not a String '
115146
'(Type: ${subClaim.runtimeType}).',
@@ -135,8 +166,8 @@ class JwtAuthTokenService implements AuthTokenService {
135166
return user;
136167
} on JWTExpiredException catch (e, s) {
137168
print('[validateToken] CATCH JWTExpiredException: Token expired. $e\n$s');
138-
// Throw specific exception for expired token
139-
throw const UnauthorizedException('Token expired.');
169+
// Let the specific UnauthorizedException for expiry propagate
170+
rethrow;
140171
} on JWTInvalidException catch (e, s) {
141172
print(
142173
'[validateToken] CATCH JWTInvalidException: Invalid token. '
@@ -145,7 +176,7 @@ class JwtAuthTokenService implements AuthTokenService {
145176
// Throw specific exception for invalid token signature/format
146177
throw UnauthorizedException('Invalid token: ${e.message}');
147178
} on JWTException catch (e, s) {
148-
// Use JWTException as the general catch-all
179+
// Use JWTException as the general catch-all for other JWT issues
149180
print(
150181
'[validateToken] CATCH JWTException: General JWT error. '
151182
'Reason: ${e.message}\n$s',
@@ -154,11 +185,12 @@ class JwtAuthTokenService implements AuthTokenService {
154185
throw UnauthorizedException('Invalid token: ${e.message}');
155186
} on HtHttpException catch (e, s) {
156187
// Handle errors from the user repository (e.g., user not found)
188+
// or blacklist check (if it threw HtHttpException)
157189
print(
158-
'[validateToken] CATCH HtHttpException: Error fetching user. '
190+
'[validateToken] CATCH HtHttpException: Error during validation. '
159191
'Type: ${e.runtimeType}, Message: $e\n$s',
160192
);
161-
// Re-throw repository exceptions directly for the error handler
193+
// Re-throw repository/blacklist exceptions directly
162194
rethrow;
163195
} catch (e, s) {
164196
// Catch unexpected errors during validation
@@ -169,4 +201,71 @@ class JwtAuthTokenService implements AuthTokenService {
169201
);
170202
}
171203
}
204+
205+
@override
206+
Future<void> invalidateToken(String token) async {
207+
print('[invalidateToken] Attempting to invalidate token...');
208+
try {
209+
// 1. Verify the token signature FIRST, but ignore expiry for blacklisting
210+
// We want to blacklist even if it's already expired, to be safe.
211+
print('[invalidateToken] Verifying token signature (ignoring expiry)...');
212+
final jwt = JWT.verify(
213+
token,
214+
SecretKey(_secretKey),
215+
checkExpiresIn: false, // IMPORTANT: Don't fail if expired here
216+
checkHeaderType: true, // Keep other standard checks
217+
// checkIssuedAt: true, // This parameter doesn't exist
218+
);
219+
print('[invalidateToken] Token signature verified.');
220+
221+
// 2. Extract JTI (JWT ID)
222+
final jti = jwt.payload['jti'] as String?;
223+
if (jti == null || jti.isEmpty) {
224+
print('[invalidateToken] Failed: Missing or empty "jti" claim.');
225+
throw const InvalidInputException(
226+
'Cannot invalidate token: Missing or empty JWT ID (jti) claim.',
227+
);
228+
}
229+
print('[invalidateToken] Extracted jti: $jti');
230+
231+
// 3. Extract Expiry Time (exp)
232+
final expClaim = jwt.payload['exp'];
233+
if (expClaim == null || expClaim is! int) {
234+
print('[invalidateToken] Failed: Missing or invalid "exp" claim.');
235+
throw const InvalidInputException(
236+
'Cannot invalidate token: Missing or invalid expiry (exp) claim.',
237+
);
238+
}
239+
final expiryDateTime =
240+
DateTime.fromMillisecondsSinceEpoch(expClaim * 1000, isUtc: true);
241+
print('[invalidateToken] Extracted expiry: $expiryDateTime');
242+
243+
// 4. Add JTI to the blacklist
244+
print('[invalidateToken] Adding jti $jti to blacklist...');
245+
await _blacklistService.blacklist(jti, expiryDateTime);
246+
print('[invalidateToken] Token (jti: $jti) successfully blacklisted.');
247+
} on JWTException catch (e, s) {
248+
// Catch errors during the initial verification (e.g., bad signature)
249+
print(
250+
'[invalidateToken] CATCH JWTException: Invalid token format/signature. '
251+
'Reason: ${e.message}\n$s',
252+
);
253+
// Treat as invalid input for invalidation purposes
254+
throw InvalidInputException('Invalid token format: ${e.message}');
255+
} on HtHttpException catch (e, s) {
256+
// Catch errors from the blacklist service itself
257+
print(
258+
'[invalidateToken] CATCH HtHttpException: Error during blacklisting. '
259+
'Type: ${e.runtimeType}, Message: $e\n$s',
260+
);
261+
// Re-throw blacklist service exceptions
262+
rethrow;
263+
} catch (e, s) {
264+
// Catch unexpected errors
265+
print('[invalidateToken] CATCH UNEXPECTED Exception: $e\n$s');
266+
throw OperationFailedException(
267+
'Token invalidation failed unexpectedly: $e',
268+
);
269+
}
270+
}
172271
}

lib/src/services/simple_auth_token_service.dart

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,4 +73,18 @@ class SimpleAuthTokenService implements AuthTokenService {
7373
);
7474
}
7575
}
76+
77+
@override
78+
Future<void> invalidateToken(String token) async {
79+
// This service uses simple prefixed tokens, not JWTs with JTI.
80+
// True invalidation/blacklisting isn't applicable here.
81+
// This method is implemented to satisfy the AuthTokenService interface.
82+
print(
83+
'[SimpleAuthTokenService] Received request to invalidate token: $token. '
84+
'No server-side invalidation is performed for simple tokens.',
85+
);
86+
// Simulate async operation
87+
await Future<void>.delayed(Duration.zero);
88+
// No specific exceptions thrown here.
89+
}
7690
}

0 commit comments

Comments
 (0)