Implement JWT token rotation for authentication#1620
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces opt-in JWT access token rotation for external authentication used by the Jetstream Desktop app and the Jetstream Web Extension. It adds a client capability header, rotates tokens during /auth/verify, and updates persistence + tests to validate rotated token behavior.
Changes:
- Add
X-Supports-Token-Rotationheader and plumb it through desktop + web extension auth verification. - Implement server-side rotation via conditional DB update (race-safe) + LRU cache invalidation.
- Add E2E coverage for rotation success, backward compatibility (no header), and logout invalidation.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| libs/shared/constants/src/lib/shared-constants.ts | Adds the new X-Supports-Token-Rotation header constant. |
| apps/jetstream-web-extension/src/extension-scripts/service-worker.ts | Sends rotation header and persists rotated tokens returned by /auth/verify. |
| apps/jetstream-e2e/src/tests/authentication/external-auth/external-auth-logged-in.spec.ts | Adds E2E scenarios validating rotation + invalidation semantics. |
| apps/jetstream-desktop/src/services/ipc.service.ts | Updates desktop auth-check flow to accept rotated tokens and refresh expiry. |
| apps/jetstream-desktop/src/services/api.service.ts | Updates verify schema + always advertises rotation support to the API. |
| apps/api/src/app/services/external-auth.service.ts | Adds rotateToken, cache invalidation helper, short token duration, and jti issuance. |
| apps/api/src/app/db/web-extension.db.ts | Adds replaceTokenIfCurrent for race-safe token replacement in DB. |
| apps/api/src/app/controllers/web-extension.controller.ts | Adds optional rotated token to verify response + invalidates cache on logout. |
| apps/api/src/app/controllers/desktop-app.controller.ts | Adds optional rotated token to verify response + invalidates cache on logout. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
apps/jetstream-web-extension/src/extension-scripts/service-worker.ts
Outdated
Show resolved
Hide resolved
48d9edc to
b1b5447
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
b1b5447 to
1d0c78f
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
1d0c78f to
6465990
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
6465990 to
7c3130b
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
7c3130b to
7340eec
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
7340eec to
d04b6a0
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (supportsRotation && deviceId) { | ||
| const oldAccessToken = req.get('Authorization')?.split(' ')[1]; | ||
| if (oldAccessToken) { | ||
| rotatedAccessToken = await externalAuthService.rotateToken({ | ||
| userProfile, | ||
| audience: externalAuthService.AUDIENCE_WEB_EXT, | ||
| source: webExtDb.TOKEN_SOURCE_BROWSER_EXTENSION, | ||
| deviceId, | ||
| oldAccessToken, | ||
| ipAddress: res.locals.ipAddress || getApiAddressFromReq(req), | ||
| userAgent: req.get('User-Agent') || 'unknown', | ||
| }); | ||
| if (rotatedAccessToken) { | ||
| res.log.info({ userId: userProfile.id, deviceId }, 'Web extension token verified and rotated'); | ||
| } else { | ||
| res.log.info({ userId: userProfile.id, deviceId }, 'Web extension token verified (rotation skipped — concurrent race)'); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (!supportsRotation) { | ||
| res.log.info({ userId: userProfile.id, deviceId }, 'Web extension token verified'); | ||
| } | ||
|
|
||
| sendJson(res, { success: true, userProfile, accessToken: rotatedAccessToken }); |
There was a problem hiding this comment.
When X-Supports-Token-Rotation is set, this endpoint may still respond with success: true and no accessToken (e.g., if rotateToken loses a concurrent update race). In that scenario the old token may already be invalidated in the DB, so clients that keep the existing token can be logged out on their next request. Consider ensuring the response always includes the active token when rotation is requested, or return a status that forces the client to re-auth instead of returning a success payload without a usable token.
| if (supportsRotation && deviceId) { | |
| const oldAccessToken = req.get('Authorization')?.split(' ')[1]; | |
| if (oldAccessToken) { | |
| rotatedAccessToken = await externalAuthService.rotateToken({ | |
| userProfile, | |
| audience: externalAuthService.AUDIENCE_WEB_EXT, | |
| source: webExtDb.TOKEN_SOURCE_BROWSER_EXTENSION, | |
| deviceId, | |
| oldAccessToken, | |
| ipAddress: res.locals.ipAddress || getApiAddressFromReq(req), | |
| userAgent: req.get('User-Agent') || 'unknown', | |
| }); | |
| if (rotatedAccessToken) { | |
| res.log.info({ userId: userProfile.id, deviceId }, 'Web extension token verified and rotated'); | |
| } else { | |
| res.log.info({ userId: userProfile.id, deviceId }, 'Web extension token verified (rotation skipped — concurrent race)'); | |
| } | |
| } | |
| } | |
| if (!supportsRotation) { | |
| res.log.info({ userId: userProfile.id, deviceId }, 'Web extension token verified'); | |
| } | |
| sendJson(res, { success: true, userProfile, accessToken: rotatedAccessToken }); | |
| if (supportsRotation) { | |
| if (!deviceId) { | |
| res.log.warn({ userId: userProfile.id }, 'Web extension token rotation requested without deviceId'); | |
| throw new InvalidSession(); | |
| } | |
| const oldAccessToken = req.get('Authorization')?.split(' ')[1]; | |
| if (!oldAccessToken) { | |
| res.log.warn({ userId: userProfile.id, deviceId }, 'Web extension token rotation requested without bearer token'); | |
| throw new InvalidSession(); | |
| } | |
| rotatedAccessToken = await externalAuthService.rotateToken({ | |
| userProfile, | |
| audience: externalAuthService.AUDIENCE_WEB_EXT, | |
| source: webExtDb.TOKEN_SOURCE_BROWSER_EXTENSION, | |
| deviceId, | |
| oldAccessToken, | |
| ipAddress: res.locals.ipAddress || getApiAddressFromReq(req), | |
| userAgent: req.get('User-Agent') || 'unknown', | |
| }); | |
| if (!rotatedAccessToken) { | |
| res.log.warn( | |
| { userId: userProfile.id, deviceId }, | |
| 'Web extension token rotation requested but no replacement token was issued' | |
| ); | |
| throw new InvalidSession(); | |
| } | |
| res.log.info({ userId: userProfile.id, deviceId }, 'Web extension token verified and rotated'); | |
| } else { | |
| res.log.info({ userId: userProfile.id, deviceId }, 'Web extension token verified'); | |
| } | |
| sendJson(res, supportsRotation ? { success: true, userProfile, accessToken: rotatedAccessToken } : { success: true, userProfile }); |
| if (supportsRotation && deviceId) { | ||
| const oldAccessToken = req.get('Authorization')?.split(' ')[1]; | ||
| if (oldAccessToken) { | ||
| rotatedAccessToken = await externalAuthService.rotateToken({ | ||
| userProfile, | ||
| audience: externalAuthService.AUDIENCE_DESKTOP, | ||
| source: webExtDb.TOKEN_SOURCE_DESKTOP, | ||
| deviceId, | ||
| oldAccessToken, | ||
| ipAddress: res.locals.ipAddress || getApiAddressFromReq(req), | ||
| userAgent: req.get('User-Agent') || 'unknown', | ||
| }); | ||
| if (rotatedAccessToken) { | ||
| res.log.info({ userId: userProfile.id, deviceId }, 'Desktop App token verified and rotated'); | ||
| } else { | ||
| res.log.info({ userId: userProfile.id, deviceId }, 'Desktop App token verified (rotation skipped — concurrent race)'); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (!supportsRotation) { |
There was a problem hiding this comment.
When X-Supports-Token-Rotation is set, this endpoint can still return success: true with accessToken omitted (e.g., rotateToken loses a concurrent rotation race). If the DB token hash has already been replaced, clients that keep using the existing token will start receiving 401s. Consider guaranteeing that a rotation-capable verify response always includes the currently-active token, or return a non-200 response that instructs the client to re-auth, instead of a success payload without a valid token.
| if (supportsRotation && deviceId) { | |
| const oldAccessToken = req.get('Authorization')?.split(' ')[1]; | |
| if (oldAccessToken) { | |
| rotatedAccessToken = await externalAuthService.rotateToken({ | |
| userProfile, | |
| audience: externalAuthService.AUDIENCE_DESKTOP, | |
| source: webExtDb.TOKEN_SOURCE_DESKTOP, | |
| deviceId, | |
| oldAccessToken, | |
| ipAddress: res.locals.ipAddress || getApiAddressFromReq(req), | |
| userAgent: req.get('User-Agent') || 'unknown', | |
| }); | |
| if (rotatedAccessToken) { | |
| res.log.info({ userId: userProfile.id, deviceId }, 'Desktop App token verified and rotated'); | |
| } else { | |
| res.log.info({ userId: userProfile.id, deviceId }, 'Desktop App token verified (rotation skipped — concurrent race)'); | |
| } | |
| } | |
| } | |
| if (!supportsRotation) { | |
| if (supportsRotation) { | |
| const oldAccessToken = req.get('Authorization')?.split(' ')[1]; | |
| if (!deviceId || !oldAccessToken) { | |
| res.log.warn( | |
| { userId: userProfile.id, deviceId, hasAuthorizationHeader: Boolean(oldAccessToken) }, | |
| 'Desktop App token rotation requested without required token rotation context' | |
| ); | |
| throw new InvalidSession(); | |
| } | |
| rotatedAccessToken = await externalAuthService.rotateToken({ | |
| userProfile, | |
| audience: externalAuthService.AUDIENCE_DESKTOP, | |
| source: webExtDb.TOKEN_SOURCE_DESKTOP, | |
| deviceId, | |
| oldAccessToken, | |
| ipAddress: res.locals.ipAddress || getApiAddressFromReq(req), | |
| userAgent: req.get('User-Agent') || 'unknown', | |
| }); | |
| if (!rotatedAccessToken) { | |
| res.log.warn( | |
| { userId: userProfile.id, deviceId }, | |
| 'Desktop App token rotation failed or lost a concurrent race; forcing re-auth' | |
| ); | |
| throw new InvalidSession(); | |
| } | |
| res.log.info({ userId: userProfile.id, deviceId }, 'Desktop App token verified and rotated'); | |
| } else { |
Introduce JWT token rotation for both desktop and web extension authentication, enhancing security by allowing tokens to be replaced conditionally based on their current state. This update includes support for token rotation in the authentication flow and modifies relevant schemas and routes accordingly.