Repository: serverpod/serverpod

Issue:
JWT access tokens not unique due to jti claim using refresh token ID
## Describe the bug

JWT access tokens generated within the same second can be identical when using the same refresh token, causing test flakiness and potential security concerns. This occurs because the `jti` (JWT ID) claim uses the refresh token ID instead of a unique identifier.

The JWT creation in `JwtUtil.createJwt()` sets the `jti` claim to the refresh token ID:

```dart
jwtId: refreshToken.id!.toString(),
```

When multiple JWT tokens are created from the same refresh token within the same second, they have:
- **Same `jti`**: Refresh token ID (doesn't change during rotation)
- **Same `iat`**: Issued at timestamp (identical when created within same second)
- **Same payload**: All other claims are identical

This results in completely identical JWT tokens, violating the uniqueness principle of JWTs according to RFC 7519.

## To reproduce

1. Create a JWT token using `AuthenticationTokens.createTokens()`
2. Immediately call `AuthenticationTokens.refreshAccessToken()` with the refresh token
3. Compare the original and refreshed JWT access tokens
4. Observe that they can be identical when created within the same second

This can be reproduced consistently by running the test:
```bash
flutter test test/jwt_auth_key_provider_test.dart --plain-name "then it updates auth info."
```

The test fails intermittently because it expects different tokens after refresh operations.

## Expected behavior

Each JWT access token should be unique, even when created from the same refresh token within the same timeframe. The `jti` claim should contain a unique identifier per token as specified in RFC 7519.

From test debugging, tokens created in rapid succession show identical payloads:
```
Original JWT:  eyJ...jti":"01992c94-527e-7ccd-ac14-bcfd718a0ca9"...
Refreshed JWT: eyJ...jti":"01992c94-527e-7ccd-ac14-bcfd718a0ca9"...
```

The server-side tests already acknowledge this issue by using `withClock()` to artificially advance time:
```dart
final newTokenPair = await withClock(
  // Need to move forward the clock, as otherwise the new access token has the same expiry date and thus looks equal.
  Clock.fixed(DateTime.now().add(const Duration(seconds: 2))),
  () => AuthenticationTokens.rotateRefreshToken(...)
);
```

## Library version

- Serverpod: Latest (main branch)
- Dart: 3.5.4
- Flutter: 3.24.4

## Platform information

- OS: Windows 11
- Environment: Development/Testing
- Affected: Server-side JWT generation in `serverpod_auth_core_server`

## Additional context

**Impact:**
1. **Test Flakiness**: Tests expecting different tokens after refresh operations fail intermittently
2. **Security Concerns**: Identical tokens could potentially be confused or cached incorrectly
3. **JWT Best Practices Violation**: The `jti` claim should be unique per token

**Proposed Solution:**
Replace the refresh token ID with a unique UUID for the `jti` claim:

```dart
// Before
jwtId: refreshToken.id!.toString(),

// After  
jwtId: const Uuid().v4obj().toString(),
```

**Breaking Changes Assessment:**
Code that depends on `jti` being the refresh token ID may break:
1. `verifyJwt()` extracts `refreshTokenId` from `jti` - **BREAKING**
2. `AuthenticationInfo.authId` uses `refreshTokenId` from JWT - **BREAKING** 
3. Tests expect `jti` to match refresh token ID - **BREAKING**

**Files Affected:**
- `modules/new_serverpod_auth/serverpod_auth_core/serverpod_auth_core_server/lib/src/jwt/business/jwt_util.dart`
- `modules/new_serverpod_auth/serverpod_auth_core/serverpod_auth_core_server/lib/src/jwt/business/authentication_info_from_jwt.dart`
- Various test files that depend on `jti` being refresh token ID



Fix patch:
diff --git a/modules/new_serverpod_auth/serverpod_auth_core/serverpod_auth_core_server/lib/src/jwt/business/jwt_util.dart b/modules/new_serverpod_auth/serverpod_auth_core/serverpod_auth_core_server/lib/src/jwt/business/jwt_util.dart
index 60a2043bd5..3d9199825f 100644
--- a/modules/new_serverpod_auth/serverpod_auth_core/serverpod_auth_core_server/lib/src/jwt/business/jwt_util.dart
+++ b/modules/new_serverpod_auth/serverpod_auth_core/serverpod_auth_core_server/lib/src/jwt/business/jwt_util.dart
@@ -18,10 +18,12 @@ class JwtUtil {
 
   /// Creates a new JWT for the given refresh token.
   ///
-  /// The auth user ID is set as `subject`.
-  /// The refresh token's ID for which this access token is generated is set as `jwtId`.
-  /// If scopes are present on the fresh token configuration, they will be set on a claim named "dev.serverpod.scopeNames".
-  /// Any extra claims configured with the refresh token will be added as top-level claims.
+  /// The auth user ID is set as `subject`. An UUID `jwtId` is generated for
+  /// each access token to ensure token uniqueness. The refresh token's ID is
+  /// stored in the claim "dev.serverpod.refreshTokenId". If scopes are present
+  /// on the refresh token configuration, they will be set on a claim named
+  /// "dev.serverpod.scopeNames". Any extra claims configured with the refresh
+  /// token will be added as top-level claims.
   String createJwt(final RefreshToken refreshToken) {
     final extraClaims = refreshToken.extraClaims != null
         ? (jsonDecode(refreshToken.extraClaims!) as Map).cast<String, dynamic>()
@@ -49,8 +51,9 @@ class JwtUtil {
         ...?extraClaims,
         if (refreshToken.scopeNames.isNotEmpty)
           _serverpodScopeNamesClaimKey: refreshToken.scopeNames.toList(),
+        _serverpodRefreshTokenIdClaimKey: refreshToken.id!.toString(),
       },
-      jwtId: refreshToken.id!.toString(),
+      jwtId: const Uuid().v4obj().toString(),
       subject: refreshToken.authUserId.toString(),
       issuer: _issuer,
     );
@@ -77,13 +80,15 @@ class JwtUtil {
   /// In practice, when this is used via [AuthenticationTokens.authenticationHandler], these errors will all be caught and a `null` `AuthenticationInfo?` will be returned instead.
   VerifiedJwtData verifyJwt(final String accessToken) {
     final jwt = _verifyJwt(accessToken);
+    final allClaims = (jwt.payload as Map).cast<String, dynamic>();
 
     final UuidValue refreshTokenId;
     try {
-      refreshTokenId = UuidValue.withValidation(jwt.jwtId!);
+      final tokenStr = allClaims[_serverpodRefreshTokenIdClaimKey] as String;
+      refreshTokenId = UuidValue.withValidation(tokenStr);
     } catch (e) {
       throw ArgumentError(
-        'The refresh token ID could not be read from the JWT\'s `id` claim: "${jwt.jwtId}"',
+        "The refresh token ID could not be read from the JWT's `$_serverpodRefreshTokenIdClaimKey` claim.",
         'accessToken',
       );
     }
@@ -100,8 +105,7 @@ class JwtUtil {
 
     final Set<Scope> scopes;
     try {
-      final scopeNamesClaim =
-          (jwt.payload as Map)[_serverpodScopeNamesClaimKey];
+      final scopeNamesClaim = allClaims[_serverpodScopeNamesClaimKey];
       final scopeNames = scopeNamesClaim != null
           ? (scopeNamesClaim as List).cast<String>()
           : const <String>[];
@@ -116,11 +120,11 @@ class JwtUtil {
       );
     }
 
-    final allClaims = (jwt.payload as Map).cast<String, dynamic>();
     final extraClaims = Map.fromEntries(
       allClaims.entries.where((final e) =>
           !_registeredClaims.contains(e.key) &&
-          e.key != _serverpodScopeNamesClaimKey),
+          e.key != _serverpodScopeNamesClaimKey &&
+          e.key != _serverpodRefreshTokenIdClaimKey),
     );
 
     return (
@@ -169,9 +173,16 @@ class JwtUtil {
     'jti',
   };
 
+  /// Prefix for Serverpod-specific JWT claims.
   static const _serverpodClaimPrefix = 'dev.serverpod.';
+
+  /// Claim for Serverpod scopes embedded in the access token.
   static const _serverpodScopeNamesClaimKey =
       '${_serverpodClaimPrefix}scopeNames';
+
+  /// Claim carrying the RefreshToken ID associated with this access token.
+  static const _serverpodRefreshTokenIdClaimKey =
+      '${_serverpodClaimPrefix}refreshTokenId';
 }
 
 /// The data successfully verified and extracted from a JWT token.


Test patch:
diff --git a/modules/new_serverpod_auth/serverpod_auth_core/serverpod_auth_core_server/test/jwt/integration/authentication_tokens_test.dart b/modules/new_serverpod_auth/serverpod_auth_core/serverpod_auth_core_server/test/jwt/integration/authentication_tokens_test.dart
index 59d8390d5b..329f967aa7 100644
--- a/modules/new_serverpod_auth/serverpod_auth_core/serverpod_auth_core_server/test/jwt/integration/authentication_tokens_test.dart
+++ b/modules/new_serverpod_auth/serverpod_auth_core/serverpod_auth_core_server/test/jwt/integration/authentication_tokens_test.dart
@@ -134,19 +134,40 @@ void main() {
     test(
         'when rotating the tokens, then a new refresh and access token is returned.',
         () async {
-      final newTokenPair = await withClock(
-        // Need to more forward the clock, as otherwise the new access token has the same expiry date and thus looks equal.
-        Clock.fixed(DateTime.now().add(const Duration(seconds: 2))),
-        () => AuthenticationTokens.rotateRefreshToken(
-          session,
-          refreshToken: authSuccess.refreshToken!,
-        ),
+      final newTokenPair = await AuthenticationTokens.rotateRefreshToken(
+        session,
+        refreshToken: authSuccess.refreshToken!,
       );
 
       expect(newTokenPair.accessToken, isNot(authSuccess.token));
       expect(newTokenPair.refreshToken, isNot(authSuccess.refreshToken));
     });
 
+    test(
+        'when rotating tokens multiple times within the same second, then new tokens are returned.',
+        () async {
+      final newTokenPairs = await withClock(
+          Clock.fixed(DateTime.now()),
+          () => Future.wait(
+                List.generate(
+                  3,
+                  (final _) => AuthenticationTokens.rotateRefreshToken(
+                    session,
+                    refreshToken: authSuccess.refreshToken!,
+                  ),
+                ),
+              ));
+
+      final tokens = newTokenPairs.map((final t) => t.accessToken).toSet();
+      expect(tokens, hasLength(3));
+      expect(tokens.add(authSuccess.token), isTrue);
+
+      final refreshTokens =
+          newTokenPairs.map((final t) => t.refreshToken).toSet();
+      expect(refreshTokens, hasLength(3));
+      expect(refreshTokens.add(authSuccess.refreshToken!), isTrue);
+    });
+
     test(
         'when rotating the tokens, then the new access token refers to the same refresh token ID.',
         () async {
@@ -158,8 +179,28 @@ void main() {
       final decodedToken = JWT.decode(authSuccess.token);
       final newDecodedToken = JWT.decode(newTokenPair.accessToken);
 
+      UuidValue getRefreshToken(final JWT jwt) {
+        const claimName = 'dev.serverpod.refreshTokenId';
+        final refreshTokenIdClaim = (jwt.payload as Map)[claimName] as String;
+        return UuidValue.withValidation(refreshTokenIdClaim);
+      }
+
+      expect(getRefreshToken(decodedToken), getRefreshToken(newDecodedToken));
+    });
+
+    test(
+        'when rotating the tokens, then the new access token has a different `jwtId`.',
+        () async {
+      final newTokenPair = await AuthenticationTokens.rotateRefreshToken(
+        session,
+        refreshToken: authSuccess.refreshToken!,
+      );
+
+      final decodedToken = JWT.decode(authSuccess.token);
+      final newDecodedToken = JWT.decode(newTokenPair.accessToken);
+
       expect(newDecodedToken.jwtId, isNotNull);
-      expect(decodedToken.jwtId, newDecodedToken.jwtId);
+      expect(decodedToken.jwtId, isNot(newDecodedToken.jwtId));
     });
 
     test(
diff --git a/modules/new_serverpod_auth/serverpod_auth_core/serverpod_auth_core_server/test/jwt/jwt_util_test.dart b/modules/new_serverpod_auth/serverpod_auth_core/serverpod_auth_core_server/test/jwt/jwt_util_test.dart
index 11e102e74c..44bebc529e 100644
--- a/modules/new_serverpod_auth/serverpod_auth_core/serverpod_auth_core_server/test/jwt/jwt_util_test.dart
+++ b/modules/new_serverpod_auth/serverpod_auth_core/serverpod_auth_core_server/test/jwt/jwt_util_test.dart
@@ -34,6 +34,15 @@ void main() {
           isNotEmpty,
         );
       });
+
+      test(
+          'when two JWTs are created within the same second, then they are unique.',
+          () {
+        final refreshToken = _createRefreshToken();
+        final jwt1 = jwtUtil.createJwt(refreshToken);
+        final jwt2 = jwtUtil.createJwt(refreshToken);
+        expect(jwt1, isNot(jwt2));
+      });
     });
 
     group('a refresh token containing a reserved claim,', () {
@@ -109,10 +118,17 @@ void main() {
       });
 
       test(
-          "when the JWT is decoded, then it will contain the `RefreshToken`'s ID as `jwtId`.",
+          'when the JWT is decoded, then it contains an unique `jwtId` that is different from the refresh token ID.',
+          () {
+        expect(JWT.decode(jwt).jwtId, isNotNull);
+        expect(JWT.decode(jwt).jwtId, isNot(refreshToken.id!.toString()));
+      });
+
+      test(
+          'when the JWT is decoded, then it contains the refresh token ID claim.',
           () {
         expect(
-          JWT.decode(jwt).jwtId,
+          (JWT.decode(jwt).payload as Map)['dev.serverpod.refreshTokenId'],
           refreshToken.id!.toString(),
         );
       });