feat(server): migrate all remaining JWT token types to ES256 (#20513)

## Summary

Extends the asymmetric signing work from #20467 to cover **every
remaining `JwtTokenTypeEnum` value**: `LOGIN`, `WORKSPACE_AGNOSTIC`,
`FILE`, `API_KEY`, `APPLICATION_ACCESS`, `APPLICATION_REFRESH`,
`APP_OAUTH_STATE`, plus the ACCESS-shaped session token issued by the
code interpreter tool.

After this PR, every JWT the server signs is ES256 with a `kid` pointing
at the current `core."signingKey"` row, while legacy HS256 tokens (no
`kid` header) remain verifiable indefinitely through the existing
fallback in `JwtWrapperService.resolveVerificationKey`. No new entity /
migration / config: this is a pure routing change on top of the
infrastructure that already shipped.

## Why

`#20467` only flipped `ACCESS` and `REFRESH` to ES256. Every other JWT
type was still HS256-signed against the global `APP_SECRET`, which kept
the original blast radius (a leaked `APP_SECRET` invalidates *every* JWT
type forever). Migrating the rest unifies the sign path on rotatable
per-server private keys without forcing any token reissue.

## Mechanical changes

### Sign side (8 services)
- `LoginTokenService.generateLoginToken`
- `TransientTokenService.generateTransientToken`
- `WorkspaceAgnosticTokenService.generateWorkspaceAgnosticToken`
- `ApplicationTokenService.signApplicationToken` (`APPLICATION_ACCESS` +
`APPLICATION_REFRESH`)
- `ApiKeyService.generateApiKeyToken`
- `FileUrlService.signFileByIdUrl` / `signWorkspaceLogoUrl`
- `ConnectionProviderOAuthFlowService.signState` (`APP_OAUTH_STATE`)
- `CodeInterpreterTool.generateSessionToken`

Each call site swaps `jwtWrapperService.sign(payload, { secret:
generateAppSecret(...), ... })` for `await
jwtWrapperService.signAsync(payload, { expiresIn, [jwtid] })`. The
`generateAppSecret` calls on the sign side are dropped (verifier-side
`generateAppSecret` stays in `resolveVerificationKey` for the HS256
fallback).

### Verifier side
- `WorkspaceAgnosticTokenService.validateToken` now goes through
`verifyJwtToken` instead of the bespoke `verify({ secret })` path, so
new ES256 tokens are accepted while the legacy HS256 fallback inside
`resolveVerificationKey` still serves the old shape.
- `JwtWrapperService.sign()` is kept (legacy compat / tests) but is now
strictly deprecated — there are no remaining production callers.

### Async ripple (`signFileByIdUrl` was synchronous)
- `FileUrlService.signFileByIdUrl` and `signWorkspaceLogoUrl` are now
`async`; the `signUrl` callback used by `getRecordImageIdentifier` is
widened to accept `Promise<string | null>`.
- Every direct/indirect caller is updated: admin panel (user lookup +
statistics + top workspaces), search service
(`computeSearchObjectResults`, `getImageIdentifierValue`), workspace
resolver (`logo` resolver, public workspace by domain/id),
`WorkspaceMemberTranspiler` (now `async toWorkspaceMemberDto[s]` /
`toDeletedWorkspaceMemberDto[s]` / `generateSignedAvatarUrl`),
`UserService.loadSignedAvatarUrlsByUserId`,
`UserWorkspaceService.castWorkspaceToAvailableWorkspace`,
workspace-invitation, approved-access-domain, agent-chat-streaming,
agent-message-part resolver, navigation-menu-item record identifier,
file-ai-chat / file-core-picture / file-email-attachment / file-workflow
/ files-field services, rich-text & files-field query result getters,
and the code-interpreter tool.

## Backward compatibility

- **Legacy HS256 tokens (no `kid`)** keep verifying via
`resolveVerificationKey` → `extractAppSecretBody` → `generateAppSecret`
for both `workspaceId`-bearing and `userId`-bearing payloads.
- The `API_KEY` HS256-via-ACCESS-secret fallback (#16504) still kicks in
inside `verifyJwtToken` for pre-2025-12-12 API keys.
- No payload shape changes, no DB writes, no env var changes — old
tokens issued by `main` continue to authenticate.

## Tests

### Unit (all green locally — 63/63)
Updated specs for every migrated service to mock `signAsync` instead of
`sign` and assert the new option shape:
- `login-token.service.spec.ts`, `transient-token.service.spec.ts`,
`workspace-agnostic-token.service.spec.ts`,
`application-token.service.spec.ts`, `api-key.service.spec.ts`,
`connection-provider-oauth-flow.service.spec.ts`.

### Integration (`jwt-key-rotation.integration-spec.ts`)
- Existing ACCESS coverage (current key, legacy HS256 fallback,
rotated-out key, revoked key, unknown kid) is preserved.
- New `it.each` assertion: `REFRESH`, `WORKSPACE_AGNOSTIC`, and `LOGIN`
tokens emitted by the real signUp → signUpInNewWorkspace →
getAuthTokensFromLoginToken pipeline are ES256 with a `kid` matching the
current signing key — proves end-to-end that the migration didn't
regress those flows.

## Open question (separate decision)

This PR keeps the legacy HS256 verification fallback **forever**. We may
eventually want to sunset it for `API_KEY` once telemetry shows
pre-migration tokens are gone, but that's a separate product/security
decision and not part of this change.

## Test plan

- [ ] CI green
- [ ] `npx nx lint:diff-with-main twenty-server` passes
- [ ] `npx nx typecheck twenty-server` passes
- [ ] `jwt-key-rotation` integration suite passes (new + existing
assertions)
- [ ] Manually verify: signing in issues an ES256 ACCESS / REFRESH
token, generating an API key issues an ES256 token with `kid`, signed
file URL JWT is ES256 with `kid`
- [ ] Pre-existing HS256 tokens still authenticate (covered by
integration test, but worth a manual check with a token from `main`)
This commit is contained in:
Charles Bochet
2026-05-13 12:53:45 +02:00
committed by GitHub
parent a159a68e2c
commit ac653182b2
51 changed files with 959 additions and 474 deletions
@@ -37,8 +37,7 @@ describe('AccessTokenService', () => {
{
provide: JwtWrapperService,
useValue: {
sign: jest.fn(),
signAsync: jest.fn(),
signAsyncOrThrow: jest.fn(),
verifyJwtToken: jest.fn(),
decode: jest.fn(),
generateAppSecret: jest.fn(),
@@ -138,7 +137,9 @@ describe('AccessTokenService', () => {
jest.spyOn(globalWorkspaceOrmManager, 'getRepository').mockResolvedValue({
findOne: jest.fn().mockResolvedValue(mockWorkspaceMember),
} as any);
jest.spyOn(jwtWrapperService, 'signAsync').mockResolvedValue(mockToken);
jest
.spyOn(jwtWrapperService, 'signAsyncOrThrow')
.mockResolvedValue(mockToken);
const result = await service.generateAccessToken({
userId,
@@ -150,7 +151,7 @@ describe('AccessTokenService', () => {
token: mockToken,
expiresAt: expect.any(Date),
});
expect(jwtWrapperService.signAsync).toHaveBeenCalledWith(
expect(jwtWrapperService.signAsyncOrThrow).toHaveBeenCalledWith(
expect.objectContaining({
sub: userId,
workspaceId: workspaceId,
@@ -198,7 +199,7 @@ describe('AccessTokenService', () => {
findOne: jest.fn().mockResolvedValue(mockWorkspaceMember),
} as any);
const signSpy = jest
.spyOn(jwtWrapperService, 'signAsync')
.spyOn(jwtWrapperService, 'signAsyncOrThrow')
.mockResolvedValue(mockToken);
await service.generateAccessToken({
@@ -141,7 +141,7 @@ export class AccessTokenService {
impersonatedUserWorkspaceId: payloadOriginalUserWorkspaceId,
};
const token = await this.jwtWrapperService.signAsync(jwtPayload, {
const token = await this.jwtWrapperService.signAsyncOrThrow(jwtPayload, {
expiresIn,
});
@@ -29,10 +29,9 @@ describe('ApplicationTokenService', () => {
{
provide: JwtWrapperService,
useValue: {
sign: jest.fn(),
signAsyncOrThrow: jest.fn(),
verifyJwtToken: jest.fn(),
decode: jest.fn(),
generateAppSecret: jest.fn(),
extractJwtFromRequest: jest.fn(),
},
},
@@ -81,7 +80,9 @@ describe('ApplicationTokenService', () => {
jest
.spyOn(applicationRepository, 'findOne')
.mockResolvedValue(mockApplication as ApplicationEntity);
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
jest
.spyOn(jwtWrapperService, 'signAsyncOrThrow')
.mockResolvedValue(mockToken);
const result = await service.generateApplicationAccessToken({
workspaceId,
@@ -92,7 +93,7 @@ describe('ApplicationTokenService', () => {
token: mockToken,
expiresAt: expect.any(Date),
});
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
expect(jwtWrapperService.signAsyncOrThrow).toHaveBeenCalledWith(
expect.objectContaining({
sub: applicationId,
applicationId,
@@ -116,7 +117,9 @@ describe('ApplicationTokenService', () => {
jest
.spyOn(applicationRepository, 'findOne')
.mockResolvedValue(mockApplication as ApplicationEntity);
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
jest
.spyOn(jwtWrapperService, 'signAsyncOrThrow')
.mockResolvedValue(mockToken);
const result = await service.generateApplicationAccessToken({
workspaceId,
@@ -129,7 +132,7 @@ describe('ApplicationTokenService', () => {
token: mockToken,
expiresAt: expect.any(Date),
});
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
expect(jwtWrapperService.signAsyncOrThrow).toHaveBeenCalledWith(
expect.objectContaining({
sub: applicationId,
applicationId,
@@ -271,7 +274,9 @@ describe('ApplicationTokenService', () => {
jest
.spyOn(applicationRepository, 'findOne')
.mockResolvedValue(mockApplication as ApplicationEntity);
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
jest
.spyOn(jwtWrapperService, 'signAsyncOrThrow')
.mockResolvedValue(mockToken);
const result = await service.generateApplicationTokenPair({
workspaceId,
@@ -286,7 +291,7 @@ describe('ApplicationTokenService', () => {
token: mockToken,
expiresAt: expect.any(Date),
});
expect(jwtWrapperService.sign).toHaveBeenCalledTimes(2);
expect(jwtWrapperService.signAsyncOrThrow).toHaveBeenCalledTimes(2);
});
});
@@ -304,7 +309,9 @@ describe('ApplicationTokenService', () => {
jest
.spyOn(applicationRepository, 'findOne')
.mockResolvedValue(mockApplication as ApplicationEntity);
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
jest
.spyOn(jwtWrapperService, 'signAsyncOrThrow')
.mockResolvedValue(mockToken);
const result = await service.renewApplicationTokens({
workspaceId,
@@ -91,23 +91,26 @@ export class ApplicationTokenService {
'APPLICATION_REFRESH_TOKEN_EXPIRES_IN',
);
const applicationAccessToken = this.signApplicationToken({
workspaceId,
applicationId,
userWorkspaceId,
userId,
tokenType: JwtTokenTypeEnum.APPLICATION_ACCESS,
expiresIn: accessTokenExpiresIn,
});
const applicationRefreshToken = this.signApplicationToken({
workspaceId,
applicationId,
userWorkspaceId,
userId,
tokenType: JwtTokenTypeEnum.APPLICATION_REFRESH,
expiresIn: refreshTokenExpiresIn,
});
const [applicationAccessToken, applicationRefreshToken] = await Promise.all(
[
this.signApplicationToken({
workspaceId,
applicationId,
userWorkspaceId,
userId,
tokenType: JwtTokenTypeEnum.APPLICATION_ACCESS,
expiresIn: accessTokenExpiresIn,
}),
this.signApplicationToken({
workspaceId,
applicationId,
userWorkspaceId,
userId,
tokenType: JwtTokenTypeEnum.APPLICATION_REFRESH,
expiresIn: refreshTokenExpiresIn,
}),
],
);
return { applicationAccessToken, applicationRefreshToken };
}
@@ -229,7 +232,7 @@ export class ApplicationTokenService {
);
}
private signApplicationToken({
private async signApplicationToken({
workspaceId,
applicationId,
userWorkspaceId,
@@ -245,7 +248,7 @@ export class ApplicationTokenService {
| JwtTokenTypeEnum.APPLICATION_ACCESS
| JwtTokenTypeEnum.APPLICATION_REFRESH;
expiresIn: string;
}): AuthToken {
}): Promise<AuthToken> {
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const jwtPayload:
@@ -260,11 +263,7 @@ export class ApplicationTokenService {
};
return {
token: this.jwtWrapperService.sign(jwtPayload, {
secret: this.jwtWrapperService.generateAppSecret(
tokenType,
workspaceId,
),
token: await this.jwtWrapperService.signAsyncOrThrow(jwtPayload, {
expiresIn,
}),
expiresAt,
@@ -19,8 +19,7 @@ describe('LoginTokenService', () => {
{
provide: JwtWrapperService,
useValue: {
generateAppSecret: jest.fn(),
sign: jest.fn(),
signAsyncOrThrow: jest.fn(),
verifyJwtToken: jest.fn(),
decode: jest.fn(),
},
@@ -46,16 +45,14 @@ describe('LoginTokenService', () => {
describe('generateLoginToken', () => {
it('should generate a login token successfully', async () => {
const email = 'test@example.com';
const mockSecret = 'mock-secret';
const mockExpiresIn = '1h';
const mockToken = 'mock-token';
const workspaceId = 'workspace-id';
jest
.spyOn(jwtWrapperService, 'generateAppSecret')
.mockReturnValue(mockSecret);
jest.spyOn(twentyConfigService, 'get').mockReturnValue(mockExpiresIn);
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
jest
.spyOn(jwtWrapperService, 'signAsyncOrThrow')
.mockResolvedValue(mockToken);
const result = await service.generateLoginToken(
email,
@@ -67,39 +64,33 @@ describe('LoginTokenService', () => {
token: mockToken,
expiresAt: expect.any(Date),
});
expect(jwtWrapperService.generateAppSecret).toHaveBeenCalledWith(
JwtTokenTypeEnum.LOGIN,
workspaceId,
);
expect(twentyConfigService.get).toHaveBeenCalledWith(
'LOGIN_TOKEN_EXPIRES_IN',
);
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
expect(jwtWrapperService.signAsyncOrThrow).toHaveBeenCalledWith(
{
sub: email,
workspaceId,
type: JwtTokenTypeEnum.LOGIN,
authProvider: AuthProviderEnum.Password,
impersonatorUserId: undefined,
impersonatorUserWorkspaceId: undefined,
},
{ secret: mockSecret, expiresIn: mockExpiresIn },
{ expiresIn: mockExpiresIn },
);
});
});
describe('generateLoginToken with impersonation', () => {
it('should include impersonatorUserId in JWT payload when using Impersonation auth provider', async () => {
it('should include impersonatorUserWorkspaceId in JWT payload when using Impersonation auth provider', async () => {
const email = 'test@example.com';
const mockSecret = 'mock-secret';
const mockToken = 'mock-token';
const workspaceId = 'workspace-id';
const impersonatorUserWorkspaceId = 'impersonator-id';
jest
.spyOn(jwtWrapperService, 'generateAppSecret')
.mockReturnValue(mockSecret);
jest.spyOn(twentyConfigService, 'get').mockReturnValue('1h');
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
jest
.spyOn(jwtWrapperService, 'signAsyncOrThrow')
.mockResolvedValue(mockToken);
const result = await service.generateLoginToken(
email,
@@ -112,11 +103,7 @@ describe('LoginTokenService', () => {
token: mockToken,
expiresAt: expect.any(Date),
});
expect(jwtWrapperService.generateAppSecret).toHaveBeenCalledWith(
JwtTokenTypeEnum.LOGIN,
workspaceId,
);
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
expect(jwtWrapperService.signAsyncOrThrow).toHaveBeenCalledWith(
{
sub: email,
workspaceId,
@@ -124,7 +111,7 @@ describe('LoginTokenService', () => {
authProvider: AuthProviderEnum.Impersonation,
impersonatorUserWorkspaceId,
},
{ secret: mockSecret, expiresIn: expect.any(String) },
{ expiresIn: expect.any(String) },
);
});
});
@@ -37,18 +37,12 @@ export class LoginTokenService {
impersonatorUserWorkspaceId: options?.impersonatorUserWorkspaceId,
};
const secret = this.jwtWrapperService.generateAppSecret(
jwtPayload.type,
workspaceId,
);
const expiresIn = this.twentyConfigService.get('LOGIN_TOKEN_EXPIRES_IN');
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
return {
token: this.jwtWrapperService.sign(jwtPayload, {
secret,
token: await this.jwtWrapperService.signAsyncOrThrow(jwtPayload, {
expiresIn,
}),
expiresAt,
@@ -31,8 +31,7 @@ describe('RefreshTokenService', () => {
useValue: {
verifyJwtToken: jest.fn(),
decode: jest.fn(),
sign: jest.fn(),
signAsync: jest.fn(),
signAsyncOrThrow: jest.fn(),
generateAppSecret: jest.fn(),
},
},
@@ -126,7 +125,9 @@ describe('RefreshTokenService', () => {
const mockExpiresIn = '7d';
jest.spyOn(twentyConfigService, 'get').mockReturnValue(mockExpiresIn);
jest.spyOn(jwtWrapperService, 'signAsync').mockResolvedValue(mockToken);
jest
.spyOn(jwtWrapperService, 'signAsyncOrThrow')
.mockResolvedValue(mockToken);
jest
.spyOn(appTokenRepository, 'create')
.mockReturnValue({ id: 'new-token-id' } as AppTokenEntity);
@@ -145,7 +146,7 @@ describe('RefreshTokenService', () => {
expiresAt: expect.any(Date),
});
expect(appTokenRepository.save).toHaveBeenCalled();
expect(jwtWrapperService.signAsync).toHaveBeenCalledWith(
expect(jwtWrapperService.signAsyncOrThrow).toHaveBeenCalledWith(
{
sub: userId,
workspaceId,
@@ -139,7 +139,7 @@ export class RefreshTokenService {
type: JwtTokenTypeEnum.REFRESH,
};
const token = await this.jwtWrapperService.signAsync(jwtPayload, {
const token = await this.jwtWrapperService.signAsyncOrThrow(jwtPayload, {
expiresIn,
jwtid: refreshToken.id,
});
@@ -18,10 +18,9 @@ describe('TransientTokenService', () => {
{
provide: JwtWrapperService,
useValue: {
sign: jest.fn(),
signAsyncOrThrow: jest.fn(),
verifyJwtToken: jest.fn(),
decode: jest.fn(),
generateAppSecret: jest.fn().mockReturnValue('mocked-secret'),
},
},
{
@@ -55,7 +54,9 @@ describe('TransientTokenService', () => {
return undefined;
});
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
jest
.spyOn(jwtWrapperService, 'signAsyncOrThrow')
.mockResolvedValue(mockToken);
const result = await service.generateTransientToken({
workspaceMemberId,
@@ -70,7 +71,7 @@ describe('TransientTokenService', () => {
expect(twentyConfigService.get).toHaveBeenCalledWith(
'SHORT_TERM_TOKEN_EXPIRES_IN',
);
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
expect(jwtWrapperService.signAsyncOrThrow).toHaveBeenCalledWith(
{
sub: workspaceMemberId,
type: JwtTokenTypeEnum.LOGIN,
@@ -78,10 +79,7 @@ describe('TransientTokenService', () => {
workspaceId,
workspaceMemberId,
},
expect.objectContaining({
secret: 'mocked-secret',
expiresIn: mockExpiresIn,
}),
{ expiresIn: mockExpiresIn },
);
});
});
@@ -35,10 +35,6 @@ export class TransientTokenService {
type: JwtTokenTypeEnum.LOGIN,
};
const secret = this.jwtWrapperService.generateAppSecret(
jwtPayload.type,
workspaceId,
);
const expiresIn = this.twentyConfigService.get(
'SHORT_TERM_TOKEN_EXPIRES_IN',
);
@@ -46,8 +42,7 @@ export class TransientTokenService {
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
return {
token: this.jwtWrapperService.sign(jwtPayload, {
secret,
token: await this.jwtWrapperService.signAsyncOrThrow(jwtPayload, {
expiresIn,
}),
expiresAt,
@@ -24,10 +24,9 @@ describe('WorkspaceAgnosticToken', () => {
{
provide: JwtWrapperService,
useValue: {
sign: jest.fn(),
verify: jest.fn(),
signAsyncOrThrow: jest.fn(),
verifyJwtToken: jest.fn(),
decode: jest.fn(),
generateAppSecret: jest.fn().mockReturnValue('mocked-secret'),
},
},
{
@@ -71,7 +70,9 @@ describe('WorkspaceAgnosticToken', () => {
return undefined;
});
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
jest
.spyOn(jwtWrapperService, 'signAsyncOrThrow')
.mockResolvedValue(mockToken);
jest
.spyOn(userRepository, 'findOne')
.mockResolvedValue(mockUser as UserEntity);
@@ -91,17 +92,14 @@ describe('WorkspaceAgnosticToken', () => {
expect(userRepository.findOne).toHaveBeenCalledWith({
where: { id: userId },
});
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
expect(jwtWrapperService.signAsyncOrThrow).toHaveBeenCalledWith(
{
authProvider: AuthProviderEnum.Password,
sub: userId,
userId: userId,
type: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
},
expect.objectContaining({
secret: 'mocked-secret',
expiresIn: mockExpiresIn,
}),
{ expiresIn: mockExpiresIn },
);
});
@@ -143,7 +141,9 @@ describe('WorkspaceAgnosticToken', () => {
} as unknown as UserEntity;
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockPayload);
jest.spyOn(jwtWrapperService, 'verify').mockReturnValue({});
jest
.spyOn(jwtWrapperService, 'verifyJwtToken')
.mockResolvedValue(mockPayload);
jest
.spyOn(userRepository, 'findOne')
.mockResolvedValue(mockUser as UserEntity);
@@ -153,13 +153,8 @@ describe('WorkspaceAgnosticToken', () => {
expect(result.user).toMatchObject({
id: userId,
});
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(mockToken);
expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken);
expect(jwtWrapperService.verify).toHaveBeenCalledWith(
mockToken,
expect.objectContaining({
secret: 'mocked-secret',
}),
);
expect(userRepository.findOne).toHaveBeenCalledWith({
where: { id: userId },
});
@@ -168,9 +163,9 @@ describe('WorkspaceAgnosticToken', () => {
it('should throw an error if token verification fails', async () => {
const mockToken = 'invalid-token';
jest.spyOn(jwtWrapperService, 'verify').mockImplementation(() => {
throw new Error('Invalid token');
});
jest
.spyOn(jwtWrapperService, 'verifyJwtToken')
.mockRejectedValue(new Error('Invalid token'));
await expect(service.validateToken(mockToken)).rejects.toThrow(
AuthException,
@@ -187,12 +182,33 @@ describe('WorkspaceAgnosticToken', () => {
};
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockPayload);
jest.spyOn(jwtWrapperService, 'verify').mockReturnValue({});
jest
.spyOn(jwtWrapperService, 'verifyJwtToken')
.mockResolvedValue(mockPayload);
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
await expect(service.validateToken(mockToken)).rejects.toThrow(
AuthException,
);
});
it('should reject a valid token that is not of WORKSPACE_AGNOSTIC type', async () => {
const mockToken = 'valid-but-wrong-type-token';
const userId = 'user-id';
const mockPayload = {
sub: userId,
userId: userId,
type: JwtTokenTypeEnum.ACCESS,
};
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockPayload);
jest
.spyOn(jwtWrapperService, 'verifyJwtToken')
.mockResolvedValue(mockPayload);
await expect(service.validateToken(mockToken)).rejects.toThrow(
AuthException,
);
});
});
});
@@ -60,11 +60,7 @@ export class WorkspaceAgnosticTokenService {
};
return {
token: this.jwtWrapperService.sign(jwtPayload, {
secret: this.jwtWrapperService.generateAppSecret(
JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
user.id,
),
token: await this.jwtWrapperService.signAsyncOrThrow(jwtPayload, {
expiresIn,
}),
expiresAt,
@@ -73,15 +69,17 @@ export class WorkspaceAgnosticTokenService {
async validateToken(token: string): Promise<AuthContext> {
try {
await this.jwtWrapperService.verifyJwtToken(token);
const decoded =
this.jwtWrapperService.decode<WorkspaceAgnosticTokenJwtPayload>(token);
this.jwtWrapperService.verify(token, {
secret: this.jwtWrapperService.generateAppSecret(
JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
decoded.userId,
),
});
if (decoded.type !== JwtTokenTypeEnum.WORKSPACE_AGNOSTIC) {
throw new AuthException(
'Expected a workspace-agnostic token',
AuthExceptionCode.INVALID_JWT_TOKEN_TYPE,
);
}
const user = await this.userRepository.findOne({
where: { id: decoded.sub },