Host-remote refresh token implementation (#18044)

This commit is contained in:
nitin
2026-02-25 00:07:29 +05:30
committed by GitHub
parent b56f85f36a
commit 887371054a
22 changed files with 1234 additions and 59 deletions
@@ -11,6 +11,7 @@ import { PermissionFlagType } from 'twenty-shared/constants';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import {
ApplicationException,
ApplicationExceptionCode,
@@ -37,7 +38,7 @@ import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/wo
@UsePipes(ResolverValidationPipe)
@MetadataResolver()
@UseInterceptors(WorkspaceMigrationGraphqlApiExceptionInterceptor)
@UseFilters(ApplicationExceptionFilter)
@UseFilters(ApplicationExceptionFilter, AuthGraphqlApiExceptionFilter)
@UseGuards(WorkspaceAuthGuard)
export class ApplicationResolver {
constructor(
@@ -19,6 +19,8 @@ export const AuthExceptionCode = appendCommonExceptionCode({
FORBIDDEN_EXCEPTION: 'FORBIDDEN_EXCEPTION',
INSUFFICIENT_SCOPES: 'INSUFFICIENT_SCOPES',
UNAUTHENTICATED: 'UNAUTHENTICATED',
APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED:
'APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED',
INVALID_DATA: 'INVALID_DATA',
OAUTH_ACCESS_DENIED: 'OAUTH_ACCESS_DENIED',
SSO_AUTH_FAILED: 'SSO_AUTH_FAILED',
@@ -56,6 +58,7 @@ const getAuthExceptionUserFriendlyMessage = (
case AuthExceptionCode.INSUFFICIENT_SCOPES:
return msg`Insufficient permissions.`;
case AuthExceptionCode.UNAUTHENTICATED:
case AuthExceptionCode.APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED:
return msg`You must be authenticated to perform this action.`;
case AuthExceptionCode.OAUTH_ACCESS_DENIED:
return msg`OAuth access was denied.`;
@@ -5,7 +5,10 @@ import { Repository } from 'typeorm';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationException } from 'src/engine/core-modules/application/application.exception';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
@@ -205,18 +208,49 @@ describe('ApplicationTokenService', () => {
expect(() => service.validateApplicationRefreshToken(mockToken)).toThrow(
AuthException,
);
try {
service.validateApplicationRefreshToken(mockToken);
} catch (error) {
expect((error as AuthException).code).toBe(
AuthExceptionCode.APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED,
);
}
});
it('should throw when token verification fails', () => {
it('should throw dedicated code when token verification fails', () => {
const mockToken = 'invalid-token';
jest.spyOn(jwtWrapperService, 'verifyJwtToken').mockImplementation(() => {
throw new Error('Invalid token');
throw new AuthException(
'Token has expired.',
AuthExceptionCode.UNAUTHENTICATED,
);
});
expect(() =>
service.validateApplicationRefreshToken(mockToken),
).toThrow();
expect(() => service.validateApplicationRefreshToken(mockToken)).toThrow(
AuthException,
);
try {
service.validateApplicationRefreshToken(mockToken);
} catch (error) {
expect((error as AuthException).code).toBe(
AuthExceptionCode.APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED,
);
}
});
it('should rethrow unexpected token verification errors', () => {
const mockToken = 'invalid-token';
jest.spyOn(jwtWrapperService, 'verifyJwtToken').mockImplementation(() => {
throw new Error('Unexpected verification error');
});
expect(() => service.validateApplicationRefreshToken(mockToken)).toThrow(
'Unexpected verification error',
);
});
});
@@ -27,6 +27,8 @@ import {
const APPLICATION_ACCESS_TOKEN_EXPIRY_SECONDS = 1800;
const APPLICATION_REFRESH_TOKEN_EXPIRY_SECONDS = 60 * 60 * 24 * 60; // 60 days
const APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED_MESSAGE =
'Application refresh token invalid or expired';
@Injectable()
export class ApplicationTokenService {
@@ -107,22 +109,37 @@ export class ApplicationTokenService {
validateApplicationRefreshToken(
refreshToken: string,
): ApplicationRefreshTokenJwtPayload {
this.jwtWrapperService.verifyJwtToken(refreshToken);
try {
this.jwtWrapperService.verifyJwtToken(refreshToken);
const payload =
this.jwtWrapperService.decode<ApplicationRefreshTokenJwtPayload>(
refreshToken,
{ json: true },
);
const payload =
this.jwtWrapperService.decode<ApplicationRefreshTokenJwtPayload>(
refreshToken,
{ json: true },
);
if (payload.type !== JwtTokenTypeEnum.APPLICATION_REFRESH) {
throw new AuthException(
'Expected an application refresh token',
AuthExceptionCode.INVALID_JWT_TOKEN_TYPE,
);
if (payload.type !== JwtTokenTypeEnum.APPLICATION_REFRESH) {
throw new AuthException(
'Expected an application refresh token',
AuthExceptionCode.INVALID_JWT_TOKEN_TYPE,
);
}
return payload;
} catch (error) {
if (
error instanceof AuthException &&
(error.code === AuthExceptionCode.UNAUTHENTICATED ||
error.code === AuthExceptionCode.INVALID_JWT_TOKEN_TYPE)
) {
throw new AuthException(
APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED_MESSAGE,
AuthExceptionCode.APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED,
);
}
throw error;
}
return payload;
}
async renewApplicationTokens(payload: {
@@ -46,6 +46,7 @@ export const authGraphqlApiExceptionHandler = (exception: AuthException) => {
subCode: exception.code,
});
case AuthExceptionCode.UNAUTHENTICATED:
case AuthExceptionCode.APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED:
throw new AuthenticationError(exception.message, {
userFriendlyMessage: msg`You must be authenticated to perform this action.`,
subCode: exception.code,
@@ -28,6 +28,7 @@ export const getAuthExceptionRestStatus = (exception: AuthException) => {
case AuthExceptionCode.TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED:
case AuthExceptionCode.INVALID_DATA:
case AuthExceptionCode.UNAUTHENTICATED:
case AuthExceptionCode.APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED:
case AuthExceptionCode.USER_NOT_FOUND:
case AuthExceptionCode.WORKSPACE_NOT_FOUND:
case AuthExceptionCode.APPLICATION_NOT_FOUND:
@@ -1,6 +1,7 @@
import { Inject, Injectable } from '@nestjs/common';
import {
DEFAULT_APP_ACCESS_TOKEN_NAME,
DEFAULT_API_KEY_NAME,
DEFAULT_API_URL_NAME,
} from 'twenty-shared/application';
@@ -192,6 +193,7 @@ export class LogicFunctionExecutorService {
return {
[DEFAULT_API_URL_NAME]: baseUrl ?? '',
[DEFAULT_APP_ACCESS_TOKEN_NAME]: applicationAccessToken.token,
[DEFAULT_API_KEY_NAME]: applicationAccessToken.token,
...buildEnvVar(flatApplicationVariables, this.secretEncryptionService),
};