Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code c457a0266e Renewed workspace-agnostic refresh tokens become unverifiable due to workspaceId: null in JWT
https://sonarly.com/issue/9509?type=bug

Workspace-agnostic refresh tokens that have been renewed once embed `workspaceId: null` in their JWT payload, making all subsequent renewal attempts fail with "Invalid token type". Combined with a frontend race condition on OAuth redirects, this locks returning users out of the app.

Fix: ## Two-layer fix for workspace-agnostic refresh token renewal failure

### Problem
After one token renewal cycle, workspace-agnostic refresh tokens embed `workspaceId: null` in their JWT payload. On the next renewal attempt, `verifyJwtToken` in `jwt-wrapper.service.ts` uses the `in` operator (`'workspaceId' in payload`) which returns `true` even when the value is `null`, selecting `null` as `appSecretBody`. Since `isDefined(null)` is `false`, the token is rejected with "Invalid token type" — permanently locking the user out after their first renewal.

### Fix 1: Prevent new bad tokens (`renew-token.service.ts`)
Changed `workspaceId` to `workspaceId ?? undefined` when calling `generateRefreshToken`. Since `undefined` values are omitted by `JSON.stringify` during JWT signing, the renewed refresh token will not contain a `workspaceId` key at all — matching the structure of the original first-generation token.

### Fix 2: Handle existing bad tokens (`jwt-wrapper.service.ts`)
Added `isDefined(payload.workspaceId)` to the `'workspaceId' in payload` check, so tokens already in the wild with `workspaceId: null` will correctly fall through to `userId` as the secret body. This is backward-compatible: tokens with a valid `workspaceId` string are unaffected.

### Test
Added a spec covering the workspace-agnostic renewal case with `workspaceId: null` from the DB entity, asserting that `generateRefreshToken` receives `workspaceId: undefined`.
2026-03-11 12:50:07 +00:00
3 changed files with 67 additions and 4 deletions
@@ -16,12 +16,13 @@ import { RenewTokenService } from './renew-token.service';
describe('RenewTokenService', () => {
let service: RenewTokenService;
let module: TestingModule;
let appTokenRepository: Repository<AppTokenEntity>;
let accessTokenService: AccessTokenService;
let refreshTokenService: RefreshTokenService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
module = await Test.createTestingModule({
providers: [
RenewTokenService,
{
@@ -184,6 +185,60 @@ describe('RenewTokenService', () => {
);
});
it('should pass undefined workspaceId when renewing a workspace-agnostic token with null workspaceId', async () => {
const mockRefreshToken = 'valid-refresh-token';
const mockUser = { id: 'user-id' } as UserEntity;
const mockTokenId = 'token-id';
const mockWAToken = {
token: 'new-wa-token',
expiresAt: new Date(),
};
const mockNewRefreshToken = {
token: 'new-refresh-token',
expiresAt: new Date(),
targetedTokenType: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
};
const mockAppToken = {
id: mockTokenId,
workspaceId: null,
} as AppTokenEntity;
const workspaceAgnosticTokenService = module.get<WorkspaceAgnosticTokenService>(
WorkspaceAgnosticTokenService,
);
jest.spyOn(refreshTokenService, 'verifyRefreshToken').mockResolvedValue({
user: mockUser,
token: mockAppToken as AppTokenEntity,
authProvider: AuthProviderEnum.Google,
targetedTokenType: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
isImpersonating: false,
impersonatorUserWorkspaceId: undefined,
impersonatedUserWorkspaceId: undefined,
});
jest.spyOn(appTokenRepository, 'update').mockResolvedValue({} as any);
jest
.spyOn(workspaceAgnosticTokenService, 'generateWorkspaceAgnosticToken')
.mockResolvedValue(mockWAToken);
const refreshSpy = jest
.spyOn(refreshTokenService, 'generateRefreshToken')
.mockResolvedValue(mockNewRefreshToken);
await service.generateTokensFromRefreshToken(mockRefreshToken);
// workspaceId must be undefined (not null) so it is omitted from the
// JWT payload — a null value would cause verifyJwtToken to reject the
// token with "Invalid token type" on the next renewal.
expect(refreshSpy).toHaveBeenCalledWith(
expect.objectContaining({
userId: mockUser.id,
workspaceId: undefined,
authProvider: AuthProviderEnum.Google,
targetedTokenType: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
}),
);
});
it('should throw an error if refresh token is not provided', async () => {
await expect(service.generateTokensFromRefreshToken('')).rejects.toThrow(
AuthException,
@@ -86,9 +86,13 @@ export class RenewTokenService {
impersonatedUserWorkspaceId,
});
// Coerce null → undefined so that workspaceId is omitted from the JWT
// payload. When the key is present with a null value, verifyJwtToken's
// `'workspaceId' in payload` check selects null as the secret body and
// rejects the token with "Invalid token type" on the next renewal.
const refreshToken = await this.refreshTokenService.generateRefreshToken({
userId: user.id,
workspaceId,
workspaceId: workspaceId ?? undefined,
authProvider: resolvedAuthProvider,
targetedTokenType,
isImpersonating,
@@ -58,8 +58,12 @@ export class JwtWrapperService {
const type = payload.type;
// Prefer workspaceId when present and non-null, then fall back to userId.
// Tokens renewed from a workspace-agnostic session may carry an explicit
// `workspaceId: null` key — the `in` operator would match it, but the
// null value is not a valid secret body. Using isDefined guards both cases.
const appSecretBody =
'workspaceId' in payload
'workspaceId' in payload && isDefined(payload.workspaceId)
? payload.workspaceId
: 'userId' in payload
? payload.userId
@@ -67,7 +71,7 @@ export class JwtWrapperService {
if (!isDefined(appSecretBody)) {
throw new AuthException(
'Invalid token type',
`Invalid token type: missing secret body for type ${type ?? 'undefined'}`,
AuthExceptionCode.INVALID_JWT_TOKEN_TYPE,
);
}