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:
martmull
2023-10-12 18:07:44 +02:00
committed by GitHub
parent 6b990c8501
commit 8fbad7d3ba
20 changed files with 430 additions and 42 deletions

View 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 {}

View 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();
});
});

View 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 },
});
}
}

View 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,
};
}
}

View File

@ -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 };
}

View File

@ -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 {}