Skip to content

Commit a1468cb

Browse files
committed
feat(auth): add MongoDB token blacklist service
- Implements token blacklisting using MongoDB. - Uses TTL index for automatic cleanup. - Handles duplicate key errors gracefully. - Includes comprehensive error handling. - Uses `MongoDbConnectionManager` for DB access.
1 parent 1639d16 commit a1468cb

File tree

1 file changed

+128
-0
lines changed

1 file changed

+128
-0
lines changed
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import 'dart:async';
2+
3+
import 'package:ht_api/src/services/token_blacklist_service.dart';
4+
import 'package:ht_data_mongodb/ht_data_mongodb.dart';
5+
import 'package:ht_shared/ht_shared.dart';
6+
import 'package:logging/logging.dart';
7+
import 'package:mongo_dart/mongo_dart.dart';
8+
9+
/// The name of the MongoDB collection used for storing blacklisted tokens.
10+
const String kBlacklistedTokensCollection = 'blacklisted_tokens';
11+
12+
/// {@template mongodb_token_blacklist_service}
13+
/// A MongoDB-backed implementation of [TokenBlacklistService].
14+
///
15+
/// Stores blacklisted JWT IDs (jti) in a dedicated MongoDB collection.
16+
/// It leverages a TTL (Time-To-Live) index on the `expiry` field to have
17+
/// MongoDB automatically purge expired tokens, ensuring efficient cleanup.
18+
/// {@endtemplate}
19+
class MongoDbTokenBlacklistService implements TokenBlacklistService {
20+
/// {@macro mongodb_token_blacklist_service}
21+
MongoDbTokenBlacklistService({
22+
required MongoDbConnectionManager connectionManager,
23+
required Logger log,
24+
}) : _connectionManager = connectionManager,
25+
_log = log {
26+
// Fire-and-forget initialization. Errors are logged internally.
27+
_init();
28+
}
29+
30+
final MongoDbConnectionManager _connectionManager;
31+
final Logger _log;
32+
33+
DbCollection get _collection =>
34+
_connectionManager.db.collection(kBlacklistedTokensCollection);
35+
36+
/// Initializes the service by ensuring the TTL index exists.
37+
/// This is idempotent and safe to call multiple times.
38+
Future<void> _init() async {
39+
try {
40+
_log.info('Ensuring TTL index exists for blacklist collection...');
41+
// Using a raw command is more robust against client library changes.
42+
final command = {
43+
'createIndexes': kBlacklistedTokensCollection,
44+
'indexes': [
45+
{
46+
'key': {'expiry': 1},
47+
'name': 'expiry_ttl_index',
48+
'expireAfterSeconds': 0,
49+
}
50+
]
51+
};
52+
await _connectionManager.db.runCommand(command);
53+
_log.info('Blacklist TTL index is set up correctly.');
54+
} catch (e, s) {
55+
_log.severe(
56+
'Failed to create TTL index for blacklist collection. '
57+
'Failed to create TTL index for blacklist collection. '
58+
'Automatic cleanup of expired tokens will not work.',
59+
e,
60+
s,
61+
);
62+
// Rethrow the exception to halt startup if the index is critical.
63+
rethrow;
64+
}
65+
}
66+
67+
@override
68+
Future<void> blacklist(String jti, DateTime expiry) async {
69+
try {
70+
// The document structure is simple: the JTI is the primary key (_id)
71+
// and `expiry` is the TTL-indexed field.
72+
await _collection.insertOne({
73+
'_id': jti,
74+
'expiry': expiry,
75+
});
76+
_log.info('Blacklisted jti: $jti (expires: $expiry)');
77+
} on MongoDartError catch (e) {
78+
// Handle the specific case of a duplicate key error, which means the
79+
// token is already blacklisted. This is not a failure condition.
80+
// We check the message because the error type may not be specific enough.
81+
if (e.message.contains('duplicate key')) {
82+
_log.warning(
83+
'Attempted to blacklist an already blacklisted jti: $jti',
84+
);
85+
// Swallow the exception as the desired state is already achieved.
86+
return;
87+
}
88+
// For other database errors, rethrow as a standard exception.
89+
_log.severe('MongoDartError while blacklisting jti $jti: $e');
90+
throw OperationFailedException('Failed to blacklist token: $e');
91+
} catch (e) {
92+
_log.severe('Unexpected error while blacklisting jti $jti: $e');
93+
throw OperationFailedException('Failed to blacklist token: $e');
94+
}
95+
}
96+
97+
@override
98+
Future<bool> isBlacklisted(String jti) async {
99+
try {
100+
// We only need to check for the existence of the document.
101+
// The TTL index handles removal of expired tokens automatically,
102+
// so if a document exists, it is considered blacklisted.
103+
final result = await _collection.findOne(where.eq('_id', jti));
104+
return result != null;
105+
} catch (e) {
106+
_log.severe('Error checking blacklist for jti $jti: $e');
107+
throw OperationFailedException('Failed to check token blacklist: $e');
108+
}
109+
}
110+
111+
@override
112+
Future<void> cleanupExpired() async {
113+
// This is a no-op because the TTL index on the MongoDB collection
114+
// handles the cleanup automatically on the server side.
115+
_log.finer(
116+
'cleanupExpired() called, but no action is needed due to TTL index.',
117+
);
118+
return Future.value();
119+
}
120+
121+
@override
122+
void dispose() {
123+
// This is a no-op because the underlying database connection is managed
124+
// by the injected MongoDbConnectionManager, which has its own lifecycle
125+
// managed by AppDependencies.
126+
_log.finer('dispose() called, no action needed.');
127+
}
128+
}

0 commit comments

Comments
 (0)