1043 timebox prepare zapier integration (#1967)
* Add create api-key route * Import module * Remove required mutation parameter * Fix Authentication * Generate random key * Update Read ApiKeyAbility handler * Add findMany apiKey route * Remove useless attribute * Use signed token for apiKeys * Authenticate with api keys * Fix typo * Add a test for apiKey module * Revoke token when api key does not exist * Handler expiresAt parameter * Fix user passport * Code review returns: Add API_TOKEN_SECRET * Code review returns: Rename variable * Code review returns: Update code style * Update apiKey schema * Update create token route * Update delete token route * Filter revoked api keys from listApiKeys * Rename endpoint * Set default expiry to 2 years * Code review returns: Update comment * Generate token after create apiKey * Code review returns: Update env variable * Code review returns: Move method to proper service --------- Co-authored-by: martmull <martmull@hotmail.com>
This commit is contained in:
12
server/src/core/api-key/api-key.module.ts
Normal file
12
server/src/core/api-key/api-key.module.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
|
||||
import { TokenService } from 'src/core/auth/services/token.service';
|
||||
|
||||
import { ApiKeyResolver } from './api-key.resolver';
|
||||
import { ApiKeyService } from './api-key.service';
|
||||
|
||||
@Module({
|
||||
providers: [ApiKeyResolver, ApiKeyService, TokenService, JwtService],
|
||||
})
|
||||
export class ApiKeyModule {}
|
||||
28
server/src/core/api-key/api-key.resolver.spec.ts
Normal file
28
server/src/core/api-key/api-key.resolver.spec.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
|
||||
import { AbilityFactory } from 'src/ability/ability.factory';
|
||||
import { TokenService } from 'src/core/auth/services/token.service';
|
||||
|
||||
import { ApiKeyResolver } from './api-key.resolver';
|
||||
import { ApiKeyService } from './api-key.service';
|
||||
|
||||
describe('ApiKeyResolver', () => {
|
||||
let resolver: ApiKeyResolver;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ApiKeyResolver,
|
||||
{ provide: ApiKeyService, useValue: {} },
|
||||
{ provide: TokenService, useValue: {} },
|
||||
{ provide: JwtService, useValue: {} },
|
||||
{ provide: AbilityFactory, useValue: {} },
|
||||
],
|
||||
}).compile();
|
||||
resolver = module.get<ApiKeyResolver>(ApiKeyResolver);
|
||||
});
|
||||
it('should be defined', () => {
|
||||
expect(resolver).toBeDefined();
|
||||
});
|
||||
});
|
||||
82
server/src/core/api-key/api-key.resolver.ts
Normal file
82
server/src/core/api-key/api-key.resolver.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import { NotFoundException, UseGuards } from '@nestjs/common';
|
||||
|
||||
import { accessibleBy } from '@casl/prisma';
|
||||
|
||||
import { AbilityGuard } from 'src/guards/ability.guard';
|
||||
import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator';
|
||||
import { Workspace } from 'src/core/@generated/workspace/workspace.model';
|
||||
import { CreateOneApiKeyArgs } from 'src/core/@generated/api-key/create-one-api-key.args';
|
||||
import { ApiKey } from 'src/core/@generated/api-key/api-key.model';
|
||||
import { FindManyApiKeyArgs } from 'src/core/@generated/api-key/find-many-api-key.args';
|
||||
import { DeleteOneApiKeyArgs } from 'src/core/@generated/api-key/delete-one-api-key.args';
|
||||
import { CheckAbilities } from 'src/decorators/check-abilities.decorator';
|
||||
import {
|
||||
CreateApiKeyAbilityHandler,
|
||||
UpdateApiKeyAbilityHandler,
|
||||
ReadApiKeyAbilityHandler,
|
||||
} from 'src/ability/handlers/api-key.ability-handler';
|
||||
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
|
||||
import { UserAbility } from 'src/decorators/user-ability.decorator';
|
||||
import { AppAbility } from 'src/ability/ability.factory';
|
||||
import { AuthToken } from 'src/core/auth/dto/token.entity';
|
||||
|
||||
import { ApiKeyService } from './api-key.service';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Resolver(() => ApiKey)
|
||||
export class ApiKeyResolver {
|
||||
constructor(private readonly apiKeyService: ApiKeyService) {}
|
||||
|
||||
@Mutation(() => AuthToken)
|
||||
@UseGuards(AbilityGuard)
|
||||
@CheckAbilities(CreateApiKeyAbilityHandler)
|
||||
async createOneApiKey(
|
||||
@Args() args: CreateOneApiKeyArgs,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
): Promise<AuthToken> {
|
||||
return await this.apiKeyService.generateApiKeyToken(
|
||||
workspaceId,
|
||||
args.data.name,
|
||||
args.data.expiresAt,
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => ApiKey)
|
||||
@UseGuards(AbilityGuard)
|
||||
@CheckAbilities(UpdateApiKeyAbilityHandler)
|
||||
async revokeOneApiKey(
|
||||
@Args() args: DeleteOneApiKeyArgs,
|
||||
): Promise<Partial<ApiKey>> {
|
||||
const apiKeyToDelete = await this.apiKeyService.findFirst({
|
||||
where: { ...args.where },
|
||||
});
|
||||
if (!apiKeyToDelete) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return this.apiKeyService.update({
|
||||
where: args.where,
|
||||
data: {
|
||||
revokedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@Query(() => [ApiKey])
|
||||
@UseGuards(AbilityGuard)
|
||||
@CheckAbilities(ReadApiKeyAbilityHandler)
|
||||
async findManyApiKey(
|
||||
@Args() args: FindManyApiKeyArgs,
|
||||
@UserAbility() ability: AppAbility,
|
||||
) {
|
||||
const filterOptions = [
|
||||
accessibleBy(ability).WorkspaceMember,
|
||||
{ revokedAt: null },
|
||||
];
|
||||
if (args.where) filterOptions.push(args.where);
|
||||
return this.apiKeyService.findMany({
|
||||
...args,
|
||||
where: { AND: filterOptions },
|
||||
});
|
||||
}
|
||||
}
|
||||
63
server/src/core/api-key/api-key.service.ts
Normal file
63
server/src/core/api-key/api-key.service.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
|
||||
import { addMilliseconds, addSeconds } from 'date-fns';
|
||||
import ms from 'ms';
|
||||
|
||||
import { PrismaService } from 'src/database/prisma.service';
|
||||
import { AuthToken } from 'src/core/auth/dto/token.entity';
|
||||
import { assert } from 'src/utils/assert';
|
||||
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyService {
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
findFirst = this.prismaService.client.apiKey.findFirst;
|
||||
findUniqueOrThrow = this.prismaService.client.apiKey.findUniqueOrThrow;
|
||||
findMany = this.prismaService.client.apiKey.findMany;
|
||||
create = this.prismaService.client.apiKey.create;
|
||||
update = this.prismaService.client.apiKey.update;
|
||||
delete = this.prismaService.client.apiKey.delete;
|
||||
|
||||
async generateApiKeyToken(
|
||||
workspaceId: string,
|
||||
name: string,
|
||||
expiresAt?: Date | string,
|
||||
): Promise<AuthToken> {
|
||||
const secret = this.environmentService.getApiTokenSecret();
|
||||
let expiresIn: string | number;
|
||||
let expirationDate: Date;
|
||||
const now = new Date().getTime();
|
||||
if (expiresAt) {
|
||||
expiresIn = Math.floor((new Date(expiresAt).getTime() - now) / 1000);
|
||||
expirationDate = addSeconds(now, expiresIn);
|
||||
} else {
|
||||
expiresIn = this.environmentService.getApiTokenExpiresIn();
|
||||
expirationDate = addMilliseconds(now, ms(expiresIn));
|
||||
}
|
||||
assert(expiresIn, '', InternalServerErrorException);
|
||||
const jwtPayload = {
|
||||
sub: workspaceId,
|
||||
};
|
||||
const { id } = await this.prismaService.client.apiKey.create({
|
||||
data: {
|
||||
expiresAt: expiresAt,
|
||||
name: name,
|
||||
workspaceId: workspaceId,
|
||||
},
|
||||
});
|
||||
return {
|
||||
token: this.jwtService.sign(jwtPayload, {
|
||||
secret,
|
||||
expiresIn,
|
||||
jwtid: id,
|
||||
}),
|
||||
expiresAt: expirationDate,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,14 +1,19 @@
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import {
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { Strategy, ExtractJwt } from 'passport-jwt';
|
||||
import { User, Workspace } from '@prisma/client';
|
||||
|
||||
import { PrismaService } from 'src/database/prisma.service';
|
||||
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||
import { assert } from 'src/utils/assert';
|
||||
|
||||
export type JwtPayload = { sub: string; workspaceId: string };
|
||||
export type PassportUser = { user: User; workspace: Workspace };
|
||||
export type JwtPayload = { sub: string; workspaceId: string; jti?: string };
|
||||
export type PassportUser = { user?: User; workspace: Workspace };
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
@ -24,22 +29,25 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload): Promise<PassportUser> {
|
||||
const user = await this.prismaService.client.user.findUniqueOrThrow({
|
||||
where: { id: payload.sub },
|
||||
const workspace = await this.prismaService.client.workspace.findUnique({
|
||||
where: { id: payload.workspaceId ?? payload.sub },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const workspace =
|
||||
await this.prismaService.client.workspace.findUniqueOrThrow({
|
||||
where: { id: payload.workspaceId },
|
||||
});
|
||||
|
||||
if (!workspace) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
if (payload.jti) {
|
||||
// If apiKey has been deleted or revoked, we throw an error
|
||||
const apiKey = await this.prismaService.client.apiKey.findUniqueOrThrow({
|
||||
where: { id: payload.jti },
|
||||
});
|
||||
assert(!apiKey.revokedAt, 'This API Key is revoked', ForbiddenException);
|
||||
}
|
||||
|
||||
const user = payload.workspaceId
|
||||
? await this.prismaService.client.user.findUniqueOrThrow({
|
||||
where: { id: payload.sub },
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return { user, workspace };
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ import { AttachmentModule } from './attachment/attachment.module';
|
||||
import { ActivityModule } from './activity/activity.module';
|
||||
import { ViewModule } from './view/view.module';
|
||||
import { FavoriteModule } from './favorite/favorite.module';
|
||||
import { ApiKeyModule } from './api-key/api-key.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -31,6 +32,7 @@ import { FavoriteModule } from './favorite/favorite.module';
|
||||
ActivityModule,
|
||||
ViewModule,
|
||||
FavoriteModule,
|
||||
ApiKeyModule,
|
||||
],
|
||||
exports: [
|
||||
AuthModule,
|
||||
@ -43,6 +45,7 @@ import { FavoriteModule } from './favorite/favorite.module';
|
||||
AnalyticsModule,
|
||||
AttachmentModule,
|
||||
FavoriteModule,
|
||||
ApiKeyModule,
|
||||
],
|
||||
})
|
||||
export class CoreModule {}
|
||||
|
||||
Reference in New Issue
Block a user