Skip to content

Implement JWT token rotation for authentication#1620

Open
paustint wants to merge 1 commit intomainfrom
sec/improve-jwt-token-storage-webext-desktop
Open

Implement JWT token rotation for authentication#1620
paustint wants to merge 1 commit intomainfrom
sec/improve-jwt-token-storage-webext-desktop

Conversation

@paustint
Copy link
Copy Markdown
Contributor

@paustint paustint commented Apr 1, 2026

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.

Copilot AI review requested due to automatic review settings April 1, 2026 03:26
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-Rotation header 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.

@paustint paustint force-pushed the sec/improve-jwt-token-storage-webext-desktop branch from 48d9edc to b1b5447 Compare April 3, 2026 03:23
@paustint paustint requested a review from Copilot April 3, 2026 03:52
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@paustint paustint force-pushed the sec/improve-jwt-token-storage-webext-desktop branch from b1b5447 to 1d0c78f Compare April 5, 2026 17:35
@paustint paustint requested a review from Copilot April 5, 2026 17:35
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@paustint paustint force-pushed the sec/improve-jwt-token-storage-webext-desktop branch from 1d0c78f to 6465990 Compare April 5, 2026 18:33
@paustint paustint requested a review from Copilot April 5, 2026 18:33
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@paustint paustint force-pushed the sec/improve-jwt-token-storage-webext-desktop branch from 6465990 to 7c3130b Compare April 7, 2026 14:22
@paustint paustint requested a review from Copilot April 7, 2026 14:22
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@paustint paustint force-pushed the sec/improve-jwt-token-storage-webext-desktop branch from 7c3130b to 7340eec Compare April 7, 2026 14:39
@paustint paustint requested a review from Copilot April 7, 2026 14:39
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@paustint paustint force-pushed the sec/improve-jwt-token-storage-webext-desktop branch from 7340eec to d04b6a0 Compare April 8, 2026 03:01
@paustint paustint requested a review from Copilot April 8, 2026 03:01
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +191 to +215
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 });
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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 });

Copilot uses AI. Check for mistakes.
Comment on lines +212 to +232
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) {
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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 {

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants