diff --git a/apps/webapp/src/app/(Dashboard)/api-keys/page.tsx b/apps/webapp/src/app/(Dashboard)/api-keys/page.tsx index bb71e4f9a..3206e6f17 100644 --- a/apps/webapp/src/app/(Dashboard)/api-keys/page.tsx +++ b/apps/webapp/src/app/(Dashboard)/api-keys/page.tsx @@ -46,26 +46,37 @@ const formSchema = z.object({ interface TSApiKeys { id_api_key: string; name : string; - token : string; } export default function Page() { const [open,setOpen] = useState(false) const [tsApiKeys,setTSApiKeys] = useState([]) + const [isKeyModalOpen, setIsKeyModalOpen] = useState(false); - const queryClient = useQueryClient(); + const queryClient = useQueryClient(); + const [newApiKey, setNewApiKey] = useState<{ key: string; expiration: Date } | null>(null); const {idProject} = useProjectStore(); const {profile} = useProfileStore(); const { createApiKeyPromise } = useCreateApiKey(); const { data: apiKeys, isLoading, error } = useApiKeys(); const columns = useColumns(); + useEffect(() => { + if (newApiKey) { + const timeUntilExpiration = newApiKey.expiration.getTime() - Date.now(); + const timer = setTimeout(() => { + setNewApiKey(null); + }, timeUntilExpiration); + + return () => clearTimeout(timer); + } + }, [newApiKey]); + useEffect(() => { const temp_tsApiKeys = apiKeys?.map((key) => ({ id_api_key: key.id_api_key, name: key.name || "", - token: key.api_key_hash, })) setTSApiKeys(temp_tsApiKeys) },[apiKeys]) @@ -107,6 +118,13 @@ export default function Page() { queryClient.setQueryData(['api-keys'], (oldQueryData = []) => { return [...oldQueryData, data]; }); + // Store the API key and its expiration time in state + setNewApiKey({ + key: data.api_key, + expiration: new Date(Date.now() + 60000), + }); + setIsKeyModalOpen(true); // Open the modal + return (
@@ -225,6 +243,22 @@ export default function Page() { {tsApiKeys && }
+ + + + Your New API Key + + This key will only be shown for the next minute. Please save it now. + + +
+ API Key:

{newApiKey?.key}

+
+ + + +
+
); } \ No newline at end of file diff --git a/apps/webapp/src/components/ApiKeys/columns.tsx b/apps/webapp/src/components/ApiKeys/columns.tsx index 067bd7943..398fc172c 100644 --- a/apps/webapp/src/components/ApiKeys/columns.tsx +++ b/apps/webapp/src/components/ApiKeys/columns.tsx @@ -45,51 +45,6 @@ export function useColumns() { enableSorting: false, enableHiding: false, }, - { - accessorKey: "token", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const token: string = row.getValue("token"); - const copied = copiedState[token] || false; - - return ( -
-
- -
-
handleCopy(token)} - > - - - - - - -

Copy Key

-
-
-
- -
-
- ); - }, - }, { id: "actions", cell: ({ row }) => , diff --git a/apps/webapp/src/components/ApiKeys/schema.ts b/apps/webapp/src/components/ApiKeys/schema.ts index dfb2acf32..b4db52b4b 100644 --- a/apps/webapp/src/components/ApiKeys/schema.ts +++ b/apps/webapp/src/components/ApiKeys/schema.ts @@ -3,7 +3,6 @@ import { z } from "zod" export const apiKeySchema = z.object({ id_api_key: z.string(), name: z.string(), - token: z.string(), }) export type ApiKey = z.infer \ No newline at end of file diff --git a/apps/webapp/src/hooks/delete/useDeleteApiKey.tsx b/apps/webapp/src/hooks/delete/useDeleteApiKey.tsx index 0784d734c..5efc073e8 100644 --- a/apps/webapp/src/hooks/delete/useDeleteApiKey.tsx +++ b/apps/webapp/src/hooks/delete/useDeleteApiKey.tsx @@ -8,7 +8,7 @@ interface IApiKeyDto { const useDeleteApiKey = () => { const remove = async (apiKeyData: IApiKeyDto) => { - const response = await fetch(`${config.API_URL}/auth/api-keys/${apiKeyData.id_api_key}`, { + const response = await fetch(`${config.API_URL}/auth/api_keys/${apiKeyData.id_api_key}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 67065549b..ad4bb34a5 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -31,6 +31,7 @@ services: context: ./ dockerfile: ./packages/api/Dockerfile.dev environment: + ENV: ${ENV} DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?ssl=false DISTRIBUTION: ${DISTRIBUTION} WEBHOOK_INGRESS: ${WEBHOOK_INGRESS} diff --git a/docker-compose.source.yml b/docker-compose.source.yml index 286afb072..db87dff07 100644 --- a/docker-compose.source.yml +++ b/docker-compose.source.yml @@ -31,6 +31,7 @@ services: context: ./ dockerfile: ./packages/api/Dockerfile environment: + ENV: ${ENV} DOPPLER_TOKEN: ${DOPPLER_TOKEN_API} DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?ssl=false DISTRIBUTION: ${DISTRIBUTION} diff --git a/docker-compose.yml b/docker-compose.yml index 5c7339290..acc1bed12 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,7 @@ services: api: image: panora.docker.scarf.sh/panoradotdev/backend-api:selfhosted environment: + ENV: ${ENV} DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?ssl=false DISTRIBUTION: ${DISTRIBUTION} WEBHOOK_INGRESS: ${WEBHOOK_INGRESS} diff --git a/packages/api/prisma/schema.prisma b/packages/api/prisma/schema.prisma index c6102329b..41e04efb9 100644 --- a/packages/api/prisma/schema.prisma +++ b/packages/api/prisma/schema.prisma @@ -1637,17 +1637,18 @@ model acc_vendor_credits { } model ecom_customers { - id_ecom_customer String @id(map: "pk_ecom_customers") @db.Uuid - remote_id String? - email String? - first_name String? - last_name String? - phone_number String? - modifed_at DateTime @db.Timestamptz(6) - created_at DateTime @db.Timestamptz(6) - id_connection String @db.Uuid - ecom_customer_addresses ecom_customer_addresses[] - ecom_orders ecom_orders[] + id_ecom_customer String @id(map: "pk_ecom_customers") @db.Uuid + remote_id String? + email String? + first_name String? + last_name String? + phone_number String? + modified_at DateTime @db.Timestamptz(6) + created_at DateTime @db.Timestamptz(6) + id_connection String @db.Uuid + remote_deleted Boolean + ecom_addresses ecom_addresses[] + ecom_orders ecom_orders[] } /// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments @@ -1681,8 +1682,10 @@ model ecom_orders { remote_id String? id_ecom_customer String? @db.Uuid id_connection String @db.Uuid - modifed_at DateTime @db.Timestamptz(6) + modified_at DateTime @db.Timestamptz(6) created_at DateTime @db.Timestamptz(6) + remote_deleted Boolean + ecom_addresses ecom_addresses[] ecom_fulfilments ecom_fulfilments[] ecom_customers ecom_customers? @relation(fields: [id_ecom_customer], references: [id_ecom_customer], onDelete: NoAction, onUpdate: NoAction, map: "fk_ecom_customer_orders") @@ -1725,21 +1728,31 @@ model ecom_products { } /// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments -model ecom_customer_addresses { - id_ecom_customer_address String @id(map: "pk_ecom_customer_addresses") @db.Uuid - address_type String? - line_1 String? - line_2 String? - street_1 String? - street_2 String? - city String? - state String? - postal_code String? - country String? - id_ecom_customer String @db.Uuid - modified_at DateTime @db.Timestamptz(6) - created_at DateTime @db.Timestamptz(6) - ecom_customers ecom_customers @relation(fields: [id_ecom_customer], references: [id_ecom_customer], onDelete: NoAction, onUpdate: NoAction, map: "fk_ecom_customer_customeraddress") +model ecom_addresses { + id_ecom_address String @id(map: "pk_ecom_customer_addresses") @db.Uuid + address_type String? + street_1 String? + street_2 String? + city String? + state String? + postal_code String? + country String? + id_ecom_customer String @db.Uuid + modified_at DateTime @db.Timestamptz(6) + created_at DateTime @db.Timestamptz(6) + remote_deleted Boolean + id_ecom_order String @db.Uuid + ecom_customers ecom_customers @relation(fields: [id_ecom_customer], references: [id_ecom_customer], onDelete: NoAction, onUpdate: NoAction, map: "fk_ecom_customer_customeraddress") + ecom_orders ecom_orders @relation(fields: [id_ecom_order], references: [id_ecom_order], onDelete: NoAction, onUpdate: NoAction, map: "fk_ecom_order_address") @@index([id_ecom_customer], map: "fk_index_ecom_customer_customeraddress") + @@index([id_ecom_order], map: "fk_index_fk_ecom_order_address") +} + +model ecom_fulfilment_orders { + id_ecom_fulfilment_order String @id(map: "pk_ecom_fulfilment_order") @db.Uuid +} + +model ecom_order_line_items { + id_ecom_order_line_item String @id(map: "pk_106") @db.Uuid } diff --git a/packages/api/src/@core/auth/auth.service.ts b/packages/api/src/@core/auth/auth.service.ts index 234bd747d..2c5caef12 100644 --- a/packages/api/src/@core/auth/auth.service.ts +++ b/packages/api/src/@core/auth/auth.service.ts @@ -1,22 +1,19 @@ import { LoggerService } from '@@core/@core-services/logger/logger.service'; import { ProjectsService } from '@@core/projects/projects.service'; -import { MailerService } from '@nestjs-modules/mailer'; -import { - BadRequestException, - ConflictException, - Injectable, -} from '@nestjs/common'; +import { AuthError } from '@@core/utils/errors'; +import { Injectable, BadRequestException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; import * as crypto from 'crypto'; -import * as nodemailer from 'nodemailer'; import { v4 as uuidv4 } from 'uuid'; import { PrismaService } from '../@core-services/prisma/prisma.service'; import { CreateUserDto } from './dto/create-user.dto'; import { LoginDto } from './dto/login.dto'; -import { RequestPasswordResetDto } from './dto/request-password-reset.dto'; -import { ResetPasswordDto } from './dto/reset-password.dto'; import { VerifyUserDto } from './dto/verify-user.dto'; +import { ConflictException } from '@nestjs/common'; +import { ResetPasswordDto } from './dto/reset-password.dto'; +import { RequestPasswordResetDto } from './dto/request-password-reset.dto'; +import * as nodemailer from 'nodemailer'; @Injectable() export class AuthService { @@ -25,7 +22,6 @@ export class AuthService { private projectService: ProjectsService, private jwtService: JwtService, private logger: LoggerService, - private mailerService: MailerService, ) { this.logger.setContext(AuthService.name); } @@ -34,7 +30,7 @@ export class AuthService { const { email, new_password, reset_token } = resetPasswordDto; // verify there is a user with corresponding email and non-expired reset token - /*const checkResetRequestIsValid = await this.prisma.users.findFirst({ + const checkResetRequestIsValid = await this.prisma.users.findFirst({ where: { email: email, reset_token_expires_at: { @@ -65,7 +61,7 @@ export class AuthService { where: { email }, data: { password_hash: hashedPassword }, }); - console.log(updatedPassword);*/ + console.log(updatedPassword); return { message: 'Password reset successfully' }; } @@ -80,7 +76,7 @@ export class AuthService { async requestPasswordReset(requestPasswordResetDto: RequestPasswordResetDto) { const { email } = requestPasswordResetDto; - /*if (!email) { + if (!email) { throw new BadRequestException('Incorrect API request'); } @@ -105,7 +101,7 @@ export class AuthService { }); // Send email with resetToken (implementation depends on your email service) - await this.sendResetEmail(email, resetToken);*/ + await this.sendResetEmail(email, resetToken); return { message: 'Password reset link sent' }; } @@ -163,16 +159,11 @@ export class AuthService { async getApiKeys(project_id: string) { try { - const keys = await this.prisma.api_keys.findMany({ + return await this.prisma.api_keys.findMany({ where: { id_project: project_id, }, }); - const res = keys.map((key) => { - const { api_key_hash, ...rest } = key; - return rest; - }); - return res; } catch (error) { throw error; } @@ -338,30 +329,38 @@ export class AuthService { keyName: string, ): Promise<{ api_key: string }> { try { + // Check project & User exist const foundProject = await this.prisma.projects.findUnique({ where: { id_project: projectId }, }); if (!foundProject) { - throw new ReferenceError('project undefined'); + throw new ReferenceError('Project not found'); } const foundUser = await this.prisma.users.findUnique({ where: { id_user: userId }, }); if (!foundUser) { - throw new ReferenceError('user undefined'); + throw new ReferenceError('User Not Found'); } /*if (foundProject.id_organization !== foundUser.id_organization) { throw new ReferenceError('User is not inside the project'); }*/ // Generate a new API key (use a secure method for generation) - const { access_token } = await this.generateApiKey(projectId, userId); + //const { access_token } = await this.generateApiKey(projectId, userId); // Store the API key in the database associated with the user - //const hashed_token = this.hashApiKey(access_token); + //const hashed_token = this.hashApiKey(access_token);" + + const base_key = `sk_${process.env.ENV}_${uuidv4()}`; + const hashed_key = crypto + .createHash('sha256') + .update(base_key) + .digest('hex'); + const new_api_key = await this.prisma.api_keys.create({ data: { id_api_key: uuidv4(), - api_key_hash: access_token, + api_key_hash: hashed_key, name: keyName, id_project: projectId as string, id_user: userId as string, @@ -371,7 +370,7 @@ export class AuthService { throw new ReferenceError('api key undefined'); } - return { api_key: access_token, ...new_api_key }; + return { api_key: base_key, ...new_api_key }; } catch (error) { throw error; } @@ -389,17 +388,11 @@ export class AuthService { } } - async getProjectIdForApiKey(apiKey: string) { + async getProjectIdForApiKey(hashed_apiKey: string) { try { - // Decode the JWT to verify if it's valid and get the payload - const decoded = this.jwtService.verify(apiKey, { - secret: process.env.JWT_SECRET, - }); - - //const hashed_api_key = this.hashApiKey(apiKey); const saved_api_key = await this.prisma.api_keys.findUnique({ where: { - api_key_hash: apiKey, + api_key_hash: hashed_apiKey, }, }); @@ -411,33 +404,43 @@ export class AuthService { async validateApiKey(apiKey: string): Promise { try { - // Decode the JWT to verify if it's valid and get the payload - const decoded = this.jwtService.verify(apiKey, { - secret: process.env.JWT_SECRET, - }); + // TO DO : add Expiration in part 3 - //const hashed_api_key = this.hashApiKey(apiKey); + // Decode the JWT to verify if it's valid and get the payload + // const decoded = this.jwtService.verify(apiKey, { + // secret: process.env.JWT_SECRET, + // }); + + // pseudo-code: + // 1 - SHA256 the API key from the header + const hashed_key = crypto + .createHash('sha256') + .update(apiKey) + .digest('hex'); + + // 2- check against DB + // if not found, return false const saved_api_key = await this.prisma.api_keys.findUnique({ where: { - api_key_hash: apiKey, + api_key_hash: hashed_key, }, }); if (!saved_api_key) { - throw new ReferenceError('Api Key undefined'); - } - if (String(decoded.project_id) !== String(saved_api_key.id_project)) { - throw new ReferenceError( - 'Failed to validate API key: projectId mismatch.', - ); - } - - // Validate that the JWT payload matches the provided userId and projectId - if (String(decoded.sub) !== String(saved_api_key.id_user)) { - throw new ReferenceError( - 'Failed to validate API key: userId mismatch.', - ); + throw new ReferenceError('API Key not found.'); } + // if (String(decoded.project_id) !== String(saved_api_key.id_project)) { + // throw new ReferenceError( + // 'Failed to validate API key: projectId mismatch.', + // ); + // } + + // // Validate that the JWT payload matches the provided userId and projectId + // if (String(decoded.sub) !== String(saved_api_key.id_user)) { + // throw new ReferenceError( + // 'Failed to validate API key: userId mismatch.', + // ); + // } return true; } catch (error) { throw error; diff --git a/packages/api/src/@core/auth/strategies/auth-header-api-key.strategy.ts b/packages/api/src/@core/auth/strategies/auth-header-api-key.strategy.ts index 0fbfe5526..a1d2c10e7 100644 --- a/packages/api/src/@core/auth/strategies/auth-header-api-key.strategy.ts +++ b/packages/api/src/@core/auth/strategies/auth-header-api-key.strategy.ts @@ -2,6 +2,7 @@ import { HeaderAPIKeyStrategy } from 'passport-headerapikey'; import { PassportStrategy } from '@nestjs/passport'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { AuthService } from '../auth.service'; +import * as crypto from 'crypto'; @Injectable() export class ApiKeyStrategy extends PassportStrategy( @@ -10,7 +11,7 @@ export class ApiKeyStrategy extends PassportStrategy( ) { constructor(private authService: AuthService) { super( - { header: 'Authorization', prefix: 'Bearer ' }, + { header: 'x-api-key', prefix: '' }, true, async (apikey: string, done, req) => { try { @@ -18,8 +19,9 @@ export class ApiKeyStrategy extends PassportStrategy( if (!isValid) { return done(new UnauthorizedException('Invalid API Key'), null); } + const hashed_api_key = crypto.createHash('sha256').update(apikey).digest('hex'); const projectId = await this.authService.getProjectIdForApiKey( - apikey, + hashed_api_key, ); //console.log('validating api request... : ' + req.user); // If the API key is valid, attach the user to the request object diff --git a/packages/api/src/@core/connections/@utils/index.ts b/packages/api/src/@core/connections/@utils/index.ts index e0aaf1075..fa2ec0063 100644 --- a/packages/api/src/@core/connections/@utils/index.ts +++ b/packages/api/src/@core/connections/@utils/index.ts @@ -16,6 +16,10 @@ export class ConnectionUtils { token: string, ): Promise { try { + console.log('**********') + console.log(token); + console.log('**********') + const res = await this.prisma.connections.findFirst({ where: { connection_token: token, diff --git a/packages/api/src/main.ts b/packages/api/src/main.ts index 59e8c7ab3..197885c4f 100644 --- a/packages/api/src/main.ts +++ b/packages/api/src/main.ts @@ -43,14 +43,18 @@ async function bootstrap() { .addServer('https://api.panora.dev', 'Production server') .addServer('https://api-sandbox.panora.dev', 'Sandbox server') .addServer('https://api-dev.panora.dev', 'Development server') - .addSecurity('bearer', { - type: 'http', - scheme: 'bearer', - }) + .addApiKey( + { + type: 'apiKey', + name: 'x-api-key', + in: 'header', + }, + 'api_key', + ) .build(); const document = SwaggerModule.createDocument(app, config); + document.security = [{ api_key: [] }]; - document.security = [{ bearer: [] }]; // Dynamically add extended specs const extendedSpecs = { 'x-speakeasy-name-override': [ diff --git a/packages/api/swagger/swagger-spec.yaml b/packages/api/swagger/swagger-spec.yaml index 68aed6ff5..173ab2944 100644 --- a/packages/api/swagger/swagger-spec.yaml +++ b/packages/api/swagger/swagger-spec.yaml @@ -7789,9 +7789,10 @@ servers: description: Development server components: securitySchemes: - bearer: - type: http - scheme: bearer + api_key: + type: apiKey + in: header + name: x-api-key schemas: WebhookResponse: type: object @@ -11445,8 +11446,9 @@ components: - file_name - file_url - uploader + - field_mappings security: - - bearer: [] + - api_key: [] x-speakeasy-name-override: - operationId: ^retrieve.* methodNameOverride: retrieve