fix: harden token renewal and soften refresh token revocation (#19175)
## Summary - **Apollo factory** (`apollo.factory.ts`): Bail early with `EMPTY` when no token pair exists so no request is forwarded without credentials. Propagate renewal success/failure as a boolean so failed renewals stop the operation chain instead of forwarding with stale tokens. - **Refresh token service** (`refresh-token.service.ts`): When a revoked refresh token is reused past the grace period, reject only that token instead of mass-revoking all user tokens. The most common cause is a lost renewal response (e.g. navigation during refresh), not actual token theft. This eliminates the "Suspicious activity detected" errors users were seeing. Also switches to `findOneBy` since the `appTokens` relation is no longer needed. ## Test plan - [x] `refresh-token.service.spec.ts` — all 7 tests pass - [ ] Verify login flow: sign in from `app.localhost`, get redirected to workspace subdomain without "Suspicious activity" errors - [ ] Verify token renewal: let access token expire, confirm silent renewal works and operations resume - [ ] Verify concurrent tabs: open multiple tabs, let tokens expire, confirm no mass revocation cascade Made with [Cursor](https://cursor.com) --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
+2
-2
@@ -95,7 +95,7 @@ describe('RefreshTokenService', () => {
|
||||
jest
|
||||
.spyOn(appTokenRepository, 'findOneBy')
|
||||
.mockResolvedValue(mockAppToken);
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser);
|
||||
jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(mockUser);
|
||||
jest.spyOn(twentyConfigService, 'get').mockReturnValue('1h');
|
||||
|
||||
const result = await service.verifyRefreshToken(mockToken);
|
||||
@@ -204,7 +204,7 @@ describe('RefreshTokenService', () => {
|
||||
|
||||
const user = { id: userId } as UserEntity;
|
||||
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(user);
|
||||
jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(user);
|
||||
|
||||
const out = await service.verifyRefreshToken(refreshToken);
|
||||
|
||||
|
||||
+5
-19
@@ -67,9 +67,8 @@ export class RefreshTokenService {
|
||||
);
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: jwtPayload.sub },
|
||||
relations: ['appTokens'],
|
||||
const user = await this.userRepository.findOneBy({
|
||||
id: jwtPayload.sub,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
@@ -84,23 +83,10 @@ export class RefreshTokenService {
|
||||
token.revokedAt.getTime() <= Date.now() - ms(reuseGracePeriod);
|
||||
|
||||
if (wasRevokedBeforeGracePeriod) {
|
||||
// Token was revoked long ago and is being reused -- suspicious.
|
||||
// Revoke all user refresh tokens as a safety measure.
|
||||
await Promise.all(
|
||||
user.appTokens.map(async ({ id, type }) => {
|
||||
if (type === AppTokenType.RefreshToken) {
|
||||
await this.appTokenRepository.update(
|
||||
{ id },
|
||||
{
|
||||
revokedAt: new Date(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Reject the stale token but don't revoke all tokens — the most
|
||||
// common cause is a lost renewal response, not actual token theft.
|
||||
throw new AuthException(
|
||||
'Suspicious activity detected, this refresh token has been revoked. All tokens have been revoked.',
|
||||
'This refresh token has been revoked.',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user