Feat: API Playground (#10376)

/claim #10283

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
oliver
2025-03-07 09:03:57 -08:00
committed by GitHub
parent d1518764a8
commit fc287dac78
55 changed files with 2915 additions and 163 deletions

View File

@ -16,6 +16,7 @@ describe('JwtAuthStrategy', () => {
let userRepository: any;
let dataSourceService: any;
let typeORMService: any;
const jwt = {
sub: 'sub-default',
jti: 'jti-default',
@ -33,6 +34,10 @@ describe('JwtAuthStrategy', () => {
findOne: jest.fn(async () => new UserWorkspace()),
};
const jwtWrapperService: any = {
extractJwtFromRequest: jest.fn(() => () => 'token'),
};
// first we test the API_KEY case
it('should throw AuthException if type is API_KEY and workspace is not found', async () => {
const payload = {
@ -46,7 +51,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
{} as any,
{} as any,
jwtWrapperService,
typeORMService,
dataSourceService,
workspaceRepository,
@ -82,7 +87,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
{} as any,
{} as any,
jwtWrapperService,
typeORMService,
dataSourceService,
workspaceRepository,
@ -120,7 +125,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
{} as any,
{} as any,
jwtWrapperService,
typeORMService,
dataSourceService,
workspaceRepository,
@ -152,7 +157,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
{} as any,
{} as any,
jwtWrapperService,
typeORMService,
dataSourceService,
workspaceRepository,
@ -190,7 +195,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
{} as any,
{} as any,
jwtWrapperService,
typeORMService,
dataSourceService,
workspaceRepository,
@ -231,7 +236,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
{} as any,
{} as any,
jwtWrapperService,
typeORMService,
dataSourceService,
workspaceRepository,

View File

@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { InjectRepository } from '@nestjs/typeorm';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Strategy } from 'passport-jwt';
import { Repository } from 'typeorm';
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
@ -36,25 +36,28 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
@InjectRepository(UserWorkspace, 'core')
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKeyProvider: async (request, rawJwtToken, done) => {
try {
const decodedToken = this.jwtWrapperService.decode(
rawJwtToken,
) as JwtPayload;
const workspaceId = decodedToken.workspaceId;
const secret = this.jwtWrapperService.generateAppSecret(
'ACCESS',
workspaceId,
);
const jwtFromRequestFunction = jwtWrapperService.extractJwtFromRequest();
const secretOrKeyProviderFunction = async (request, rawJwtToken, done) => {
try {
const decodedToken = jwtWrapperService.decode(
rawJwtToken,
) as JwtPayload;
const workspaceId = decodedToken.workspaceId;
const secret = jwtWrapperService.generateAppSecret(
'ACCESS',
workspaceId,
);
done(null, secret);
} catch (error) {
done(error, null);
}
},
done(null, secret);
} catch (error) {
done(error, null);
}
};
super({
jwtFromRequest: jwtFromRequestFunction,
ignoreExpiration: false,
secretOrKeyProvider: secretOrKeyProviderFunction,
});
}

View File

@ -39,6 +39,7 @@ describe('AccessTokenService', () => {
verifyWorkspaceToken: jest.fn(),
decode: jest.fn(),
generateAppSecret: jest.fn(),
extractJwtFromRequest: jest.fn(),
},
},
{
@ -179,6 +180,9 @@ describe('AccessTokenService', () => {
workspaceMemberId: 'workspace-member-id',
};
jest
.spyOn(jwtWrapperService, 'extractJwtFromRequest')
.mockReturnValue(() => mockToken);
jest
.spyOn(jwtWrapperService, 'verifyWorkspaceToken')
.mockResolvedValue(undefined);
@ -207,6 +211,10 @@ describe('AccessTokenService', () => {
headers: {},
} as Request;
jest
.spyOn(jwtWrapperService, 'extractJwtFromRequest')
.mockReturnValue(() => null);
await expect(service.validateTokenByRequest(mockRequest)).rejects.toThrow(
AuthException,
);

View File

@ -4,7 +4,6 @@ import { InjectRepository } from '@nestjs/typeorm';
import { addMilliseconds } from 'date-fns';
import { Request } from 'express';
import ms from 'ms';
import { ExtractJwt } from 'passport-jwt';
import { isWorkspaceActiveOrSuspended } from 'twenty-shared';
import { Repository } from 'typeorm';
@ -125,7 +124,7 @@ export class AccessTokenService {
}
async validateTokenByRequest(request: Request): Promise<AuthContext> {
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request);
const token = this.jwtWrapperService.extractJwtFromRequest()(request);
if (!token) {
throw new AuthException(

View File

@ -3,7 +3,9 @@ import { JwtService, JwtSignOptions, JwtVerifyOptions } from '@nestjs/jwt';
import { createHash } from 'crypto';
import { Request as ExpressRequest } from 'express';
import * as jwt from 'jsonwebtoken';
import { ExtractJwt, JwtFromRequestFunction } from 'passport-jwt';
import { isDefined } from 'twenty-shared';
import {
@ -122,4 +124,20 @@ export class JwtWrapperService {
return accessTokenSecret;
}
extractJwtFromRequest(): JwtFromRequestFunction {
return (request: ExpressRequest) => {
// First try to extract token from Authorization header
const tokenFromHeader = ExtractJwt.fromAuthHeaderAsBearerToken()(request);
if (tokenFromHeader) {
return tokenFromHeader;
}
// If not found in header, try to extract from URL query parameter
// This is for edge cases where we don't control the origin request
// (e.g. the REST API playground)
return ExtractJwt.fromUrlQueryParameter('token')(request);
};
}
}