From c99ca5cc224c01c6cc0a1fa7e1d3d723e4d167bb Mon Sep 17 00:00:00 2001 From: Stevche Radevski Date: Wed, 21 Feb 2024 11:19:22 +0100 Subject: [PATCH] feat(api-key): Add CRUD functionalities to the api key module --- .../integration-tests/__fixtures__/index.ts | 15 +- .../__tests__/api-key-module-service.spec.ts | 253 ++++++++++++++- .../migrations/.snapshot-medusa-api-key.json | 140 ++++++++ .../migrations/InitialSetup20240220155605.ts | 12 + packages/api-key/src/models/api-key.ts | 47 ++- .../src/services/api-key-module-service.ts | 302 ++++++++++++++++-- packages/api-key/src/types/index.ts | 17 + packages/types/src/api-key/common/api-key.ts | 22 +- .../types/src/api-key/mutations/api-key.ts | 21 +- packages/types/src/api-key/service.ts | 7 +- packages/utils/src/api-key/api-key-type.ts | 15 + packages/utils/src/api-key/index.ts | 1 + packages/utils/src/bundles.ts | 1 + packages/utils/src/index.ts | 1 + 14 files changed, 812 insertions(+), 42 deletions(-) create mode 100644 packages/api-key/src/migrations/.snapshot-medusa-api-key.json create mode 100644 packages/api-key/src/migrations/InitialSetup20240220155605.ts create mode 100644 packages/utils/src/api-key/api-key-type.ts create mode 100644 packages/utils/src/api-key/index.ts diff --git a/packages/api-key/integration-tests/__fixtures__/index.ts b/packages/api-key/integration-tests/__fixtures__/index.ts index 172f1ae6a4..10004f3ef5 100644 --- a/packages/api-key/integration-tests/__fixtures__/index.ts +++ b/packages/api-key/integration-tests/__fixtures__/index.ts @@ -1 +1,14 @@ -// noop +import { CreateApiKeyDTO } from "@types" +import { ApiKeyType } from "@medusajs/utils" + +export const createSecretKeyFixture: CreateApiKeyDTO = { + title: "Secret key", + type: ApiKeyType.SECRET, + created_by: "test", +} + +export const createPublishableKeyFixture: CreateApiKeyDTO = { + title: "Test API Key", + type: ApiKeyType.PUBLISHABLE, + created_by: "test", +} diff --git a/packages/api-key/integration-tests/__tests__/api-key-module-service.spec.ts b/packages/api-key/integration-tests/__tests__/api-key-module-service.spec.ts index 00658c1e07..c2d02d7706 100644 --- a/packages/api-key/integration-tests/__tests__/api-key-module-service.spec.ts +++ b/packages/api-key/integration-tests/__tests__/api-key-module-service.spec.ts @@ -1,19 +1,266 @@ +import crypto from "crypto" import { Modules } from "@medusajs/modules-sdk" import { IApiKeyModuleService } from "@medusajs/types" +import { ApiKeyType } from "@medusajs/utils" import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils" +import { + createSecretKeyFixture, + createPublishableKeyFixture, +} from "../__fixtures__" jest.setTimeout(100000) +const mockPublishableKeyBytes = () => { + jest.spyOn(crypto, "randomBytes").mockImplementationOnce(() => { + return Buffer.from( + "44de31ebcf085fa423fc584aa854067025e937a79edb565f472404345f0f23be", + "hex" + ) + }) +} + +const mockSecretKeyBytes = () => { + jest + .spyOn(crypto, "randomBytes") + .mockImplementationOnce(() => { + return Buffer.from( + "44de31ebcf085fa423fc584aa854067025e937a79edb565f472404345f0f23be", + "hex" + ) + }) + .mockImplementationOnce(() => { + return Buffer.from("44de31ebcf085fa423fc584aa8540670", "hex") + }) +} + moduleIntegrationTestRunner({ moduleName: Modules.API_KEY, testSuite: ({ MikroOrmWrapper, service, }: SuiteOptions) => { + afterEach(() => { + jest.restoreAllMocks() + }) + describe("API Key Module Service", () => { - describe("noop", () => { - it("should run", function () { - expect(true).toBe(true) + describe("creating a publishable API key", () => { + it("should create it successfully", async function () { + mockPublishableKeyBytes() + const apiKey = await service.create(createPublishableKeyFixture) + + expect(apiKey).toEqual( + expect.objectContaining({ + title: "Test API Key", + type: ApiKeyType.PUBLISHABLE, + salt: "", + created_by: "test", + last_used_at: null, + revoked_by: null, + revoked_at: null, + redacted: "pk_44d***3be", + token: + "pk_44de31ebcf085fa423fc584aa854067025e937a79edb565f472404345f0f23be", + }) + ) + }) + }) + + describe("creating a secret API key", () => { + it("should get created successfully", async function () { + mockSecretKeyBytes() + const apiKey = await service.create(createSecretKeyFixture) + + expect(apiKey).toEqual( + expect.objectContaining({ + title: "Secret key", + type: ApiKeyType.SECRET, + salt: "44de31ebcf085fa423fc584aa8540670", + created_by: "test", + last_used_at: null, + revoked_by: null, + revoked_at: null, + redacted: "sk_44d***3be", + token: + "sk_44de31ebcf085fa423fc584aa854067025e937a79edb565f472404345f0f23be", + }) + ) + }) + + it("should only allow creating one active token", async function () { + expect( + service.create([createSecretKeyFixture, createSecretKeyFixture]) + ).rejects.toThrow( + "You can only create one secret key at a time. You tried to create 2 secret keys." + ) + + await service.create(createSecretKeyFixture) + const err = await service + .create(createSecretKeyFixture) + .catch((e) => e) + expect(err.message).toEqual( + "You can only have one active secret key a time. Revoke or delete your existing key before creating a new one." + ) + }) + + it("should allow for at most two tokens, where one is revoked", async function () { + const firstApiKey = await service.create(createSecretKeyFixture) + await service.revoke({ + id: firstApiKey.id, + revoked_by: "test", + }) + + await service.create(createSecretKeyFixture) + const err = await service + .create(createSecretKeyFixture) + .catch((e) => e) + expect(err.message).toEqual( + "You can only have one active secret key a time. Revoke or delete your existing key before creating a new one." + ) + }) + }) + + describe("revoking API keys", () => { + it("should have the revoked at and revoked by set when a key is revoked", async function () { + const firstApiKey = await service.create(createSecretKeyFixture) + const revokedKey = await service.revoke({ + id: firstApiKey.id, + revoked_by: "test", + }) + + expect(revokedKey).toEqual( + expect.objectContaining({ + revoked_by: "test", + revoked_at: expect.any(Date), + }) + ) + }) + + it("should not allow revoking an already revoked API key", async function () { + const firstApiKey = await service.create(createSecretKeyFixture) + await service.revoke({ + id: firstApiKey.id, + revoked_by: "test", + }) + + const err = await service + .revoke({ + id: firstApiKey.id, + revoked_by: "test2", + }) + .catch((e) => e) + + expect(err.message).toEqual( + `There are 1 secret keys that are already revoked.` + ) + }) + }) + + describe("updating an API key", () => { + it("should update the name successfully", async function () { + const createdApiKey = await service.create(createSecretKeyFixture) + + const updatedApiKey = await service.update({ + id: createdApiKey.id, + title: "New Name", + }) + expect(updatedApiKey.title).toEqual("New Name") + }) + + it("should not reflect any updates on other fields", async function () { + const createdApiKey = await service.create(createSecretKeyFixture) + + const updatedApiKey = await service.update({ + id: createdApiKey.id, + title: createdApiKey.title, + revoked_by: "test", + revoked_at: new Date(), + last_used_at: new Date(), + }) + + // These should not be returned on an update + createdApiKey.token = "" + createdApiKey.salt = "" + expect(createdApiKey).toEqual(updatedApiKey) + }) + }) + + describe("deleting API keys", () => { + it("should successfully delete existing api keys", async function () { + const createdApiKeys = await service.create([ + createPublishableKeyFixture, + createSecretKeyFixture, + ]) + await service.delete([createdApiKeys[0].id, createdApiKeys[1].id]) + + const apiKeysInDatabase = await service.list() + expect(apiKeysInDatabase).toHaveLength(0) + }) + }) + + describe("retrieving API keys", () => { + it("should successfully return all existing api keys", async function () { + await service.create([ + createPublishableKeyFixture, + createSecretKeyFixture, + ]) + + const apiKeysInDatabase = await service.list() + expect(apiKeysInDatabase).toHaveLength(2) + }) + + it("should not return the token and salt for secret keys when listing", async function () { + await service.create([createSecretKeyFixture]) + + const apiKeysInDatabase = await service.list() + expect(apiKeysInDatabase).toHaveLength(1) + expect(apiKeysInDatabase[0].token).toBeFalsy() + expect(apiKeysInDatabase[0].salt).toBeFalsy() + }) + + it("should return the token for publishable keys when listing", async function () { + await service.create([createPublishableKeyFixture]) + + const apiKeysInDatabase = await service.list() + expect(apiKeysInDatabase).toHaveLength(1) + expect(apiKeysInDatabase[0].token).toBeTruthy() + expect(apiKeysInDatabase[0].salt).toBeFalsy() + }) + + it("should not return the token and salt for secret keys when listing and counting", async function () { + await service.create([createSecretKeyFixture]) + + const [apiKeysInDatabase] = await service.listAndCount() + expect(apiKeysInDatabase).toHaveLength(1) + expect(apiKeysInDatabase[0].token).toBeFalsy() + expect(apiKeysInDatabase[0].salt).toBeFalsy() + }) + + it("should return the token for publishable keys when listing and counting", async function () { + await service.create([createPublishableKeyFixture]) + + const [apiKeysInDatabase] = await service.listAndCount() + expect(apiKeysInDatabase).toHaveLength(1) + expect(apiKeysInDatabase[0].token).toBeTruthy() + expect(apiKeysInDatabase[0].salt).toBeFalsy() + }) + + it("should not return the token and salt for secret keys when retrieving", async function () { + const [createdApiKey] = await service.create([createSecretKeyFixture]) + + const apiKeyInDatabase = await service.retrieve(createdApiKey.id) + expect(apiKeyInDatabase.token).toBeFalsy() + expect(apiKeyInDatabase.salt).toBeFalsy() + }) + + it("should return the token for publishable keys when retrieving", async function () { + const [createdApiKey] = await service.create([ + createPublishableKeyFixture, + ]) + + const apiKeyInDatabase = await service.retrieve(createdApiKey.id) + expect(apiKeyInDatabase.token).toBeTruthy() + expect(apiKeyInDatabase.salt).toBeFalsy() }) }) }) diff --git a/packages/api-key/src/migrations/.snapshot-medusa-api-key.json b/packages/api-key/src/migrations/.snapshot-medusa-api-key.json new file mode 100644 index 0000000000..779d7b0f38 --- /dev/null +++ b/packages/api-key/src/migrations/.snapshot-medusa-api-key.json @@ -0,0 +1,140 @@ +{ + "namespaces": [ + "public" + ], + "name": "public", + "tables": [ + { + "columns": { + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "token": { + "name": "token", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "salt": { + "name": "salt", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "redacted": { + "name": "redacted", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "title": { + "name": "title", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "type": { + "name": "type", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 6, + "mappedType": "datetime" + }, + "created_by": { + "name": "created_by", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "text" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 6, + "mappedType": "datetime" + } + }, + "name": "api_key", + "schema": "public", + "indexes": [ + { + "keyName": "IDX_api_key_type", + "columnNames": [ + "type" + ], + "composite": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_api_key_type\" ON \"api_key\" (type)" + }, + { + "keyName": "api_key_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": {} + } + ] +} diff --git a/packages/api-key/src/migrations/InitialSetup20240220155605.ts b/packages/api-key/src/migrations/InitialSetup20240220155605.ts new file mode 100644 index 0000000000..2b3798caaf --- /dev/null +++ b/packages/api-key/src/migrations/InitialSetup20240220155605.ts @@ -0,0 +1,12 @@ +import { Migration } from "@mikro-orm/migrations" + +export class InitialSetup20240220155605 extends Migration { + async up(): Promise { + this.addSql( + 'create table if not exists "api_key" ("id" text not null, "token" text not null, "salt" text not null, "redacted" text not null, "title" text not null, "type" text not null, "last_used_at" timestamptz null, "created_by" text not null, "created_at" timestamptz not null default now(), "revoked_by" text null, "revoked_at" timestamptz null, constraint "api_key_pkey" primary key ("id"));' + ) + this.addSql( + 'CREATE INDEX IF NOT EXISTS "IDX_api_key_type" ON "api_key" (type);' + ) + } +} diff --git a/packages/api-key/src/models/api-key.ts b/packages/api-key/src/models/api-key.ts index d7fe5a334a..239028a07a 100644 --- a/packages/api-key/src/models/api-key.ts +++ b/packages/api-key/src/models/api-key.ts @@ -1,4 +1,7 @@ -import { generateEntityId } from "@medusajs/utils" +import { + createPsqlIndexStatementHelper, + generateEntityId, +} from "@medusajs/utils" import { BeforeCreate, @@ -6,14 +9,45 @@ import { OnInit, PrimaryKey, Property, + Enum, } from "@mikro-orm/core" -// TODO: +const TypeIndex = createPsqlIndexStatementHelper({ + tableName: "api_key", + columns: "type", +}) + @Entity() export default class ApiKey { @PrimaryKey({ columnType: "text" }) id: string + @Property({ columnType: "text" }) + token: string + + @Property({ columnType: "text" }) + salt: string + + @Property({ columnType: "text" }) + redacted: string + + @Property({ columnType: "text" }) + title: string + + @Property({ columnType: "text" }) + @Enum({ items: ["publishable", "secret"] }) + @TypeIndex.MikroORMIndex() + type: "publishable" | "secret" + + @Property({ + columnType: "timestamptz", + nullable: true, + }) + last_used_at: Date | null = null + + @Property({ columnType: "text" }) + created_by: string + @Property({ onCreate: () => new Date(), columnType: "timestamptz", @@ -21,13 +55,14 @@ export default class ApiKey { }) created_at: Date + @Property({ columnType: "text", nullable: true }) + revoked_by: string | null = null + @Property({ - onCreate: () => new Date(), - onUpdate: () => new Date(), columnType: "timestamptz", - defaultRaw: "now()", + nullable: true, }) - updated_at: Date + revoked_at: Date | null = null @BeforeCreate() onCreate() { diff --git a/packages/api-key/src/services/api-key-module-service.ts b/packages/api-key/src/services/api-key-module-service.ts index d28262ab18..55d3f2020a 100644 --- a/packages/api-key/src/services/api-key-module-service.ts +++ b/packages/api-key/src/services/api-key-module-service.ts @@ -1,3 +1,5 @@ +import crypto from "crypto" +import util from "util" import { Context, DAL, @@ -6,16 +8,21 @@ import { ModulesSdkTypes, InternalModuleDeclaration, ModuleJoinerConfig, + FindConfig, } from "@medusajs/types" +import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config" +import { ApiKey } from "@models" +import { CreateApiKeyDTO, TokenDTO } from "@types" import { + ApiKeyType, InjectManager, InjectTransactionManager, MedusaContext, + MedusaError, ModulesSdkUtils, } from "@medusajs/utils" -import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config" -import { ApiKey } from "@models" +const scrypt = util.promisify(crypto.scrypt) const generateMethodForModels = [] @@ -65,28 +72,60 @@ export default class ApiKeyModuleService data: ApiKeyTypes.CreateApiKeyDTO | ApiKeyTypes.CreateApiKeyDTO[], @MedusaContext() sharedContext: Context = {} ): Promise { - const createdApiKeys = await this.create_(data, sharedContext) + const [createdApiKeys, generatedTokens] = await this.create_( + Array.isArray(data) ? data : [data], + sharedContext + ) - return await this.baseRepository_.serialize< - ApiKeyTypes.ApiKeyDTO | ApiKeyTypes.ApiKeyDTO[] + const serializedResponse = await this.baseRepository_.serialize< + ApiKeyTypes.ApiKeyDTO[] >(createdApiKeys, { populate: true, }) + + // When creating we want to return the raw token, as this will be the only time the user will be able to take note of it for future use. + const responseWithRawToken = serializedResponse.map((key) => ({ + ...key, + token: + generatedTokens.find((t) => t.hashedToken === key.token)?.rawToken ?? + key.token, + })) + + return Array.isArray(data) ? responseWithRawToken : responseWithRawToken[0] } @InjectTransactionManager("baseRepository_") protected async create_( - data: ApiKeyTypes.CreateApiKeyDTO | ApiKeyTypes.CreateApiKeyDTO[], + data: ApiKeyTypes.CreateApiKeyDTO[], @MedusaContext() sharedContext: Context = {} - ): Promise { - const data_ = Array.isArray(data) ? data : [data] + ): Promise<[TEntity[], TokenDTO[]]> { + await this.validateCreateApiKeys(data, sharedContext) + + const normalizedInput: CreateApiKeyDTO[] = [] + const generatedTokens: TokenDTO[] = [] + for (const key of data) { + let tokenData: TokenDTO + if (key.type === ApiKeyType.PUBLISHABLE) { + tokenData = ApiKeyModuleService.generatePublishableKey() + } else { + tokenData = await ApiKeyModuleService.generateSecretKey() + } + + generatedTokens.push(tokenData) + normalizedInput.push({ + ...key, + token: tokenData.hashedToken, + salt: tokenData.salt, + redacted: tokenData.redacted, + }) + } const createdApiKeys = await this.apiKeyService_.create( - data_, + normalizedInput, sharedContext ) - return Array.isArray(data) ? createdApiKeys : createdApiKeys[0] + return [createdApiKeys, generatedTokens] } update( @@ -103,31 +142,148 @@ export default class ApiKeyModuleService data: ApiKeyTypes.UpdateApiKeyDTO[] | ApiKeyTypes.UpdateApiKeyDTO, @MedusaContext() sharedContext: Context = {} ): Promise { - const updatedApiKeys = await this.update_(data, sharedContext) + const updatedApiKeys = await this.update_( + Array.isArray(data) ? data : [data], + sharedContext + ) - return await this.baseRepository_.serialize< - ApiKeyTypes.ApiKeyDTO | ApiKeyTypes.ApiKeyDTO[] - >(updatedApiKeys, { + const serializedResponse = await this.baseRepository_.serialize< + ApiKeyTypes.ApiKeyDTO[] + >(updatedApiKeys.map(omitToken), { populate: true, }) + + return Array.isArray(data) ? serializedResponse : serializedResponse[0] } @InjectTransactionManager("baseRepository_") protected async update_( - data: ApiKeyTypes.UpdateApiKeyDTO[] | ApiKeyTypes.UpdateApiKeyDTO, + data: ApiKeyTypes.UpdateApiKeyDTO[], @MedusaContext() sharedContext: Context = {} - ): Promise { - return [] + ): Promise { + const updateRequest = data.map((k) => ({ + id: k.id, + title: k.title, + })) + + const updatedApiKeys = await this.apiKeyService_.update( + updateRequest, + sharedContext + ) + return updatedApiKeys + } + + @InjectManager("baseRepository_") + async retrieve( + id: string, + config?: FindConfig, + sharedContext?: Context + ): Promise { + const apiKey = await this.apiKeyService_.retrieve(id, config, sharedContext) + + return await this.baseRepository_.serialize( + omitToken(apiKey), + { + populate: true, + } + ) + } + + @InjectManager("baseRepository_") + async list( + filters?: ApiKeyTypes.FilterableApiKeyProps, + config?: FindConfig, + sharedContext?: Context + ): Promise { + const apiKeys = await this.apiKeyService_.list( + filters, + config, + sharedContext + ) + + return this.baseRepository_.serialize( + apiKeys.map(omitToken), + { + populate: true, + } + ) + } + + @InjectManager("baseRepository_") + async listAndCount( + filters?: ApiKeyTypes.FilterableApiKeyProps, + config?: FindConfig, + sharedContext?: Context + ): Promise<[ApiKeyTypes.ApiKeyDTO[], number]> { + const result = await this.apiKeyService_.listAndCount( + filters, + config, + sharedContext + ) + const withoutToken = result[0].map(omitToken) + const count = result[1] + + return [ + await this.baseRepository_.serialize( + withoutToken, + { + populate: true, + } + ), + count, + ] + } + + async revoke( + data: ApiKeyTypes.RevokeApiKeyDTO[], + sharedContext?: Context + ): Promise + async revoke( + data: ApiKeyTypes.RevokeApiKeyDTO, + sharedContext?: Context + ): Promise + + @InjectManager("baseRepository_") + async revoke( + data: ApiKeyTypes.RevokeApiKeyDTO[] | ApiKeyTypes.RevokeApiKeyDTO, + @MedusaContext() sharedContext: Context = {} + ): Promise { + const revokedApiKeys = await this.revoke_( + Array.isArray(data) ? data : [data], + sharedContext + ) + + const serializedResponse = await this.baseRepository_.serialize< + ApiKeyTypes.ApiKeyDTO[] + >(revokedApiKeys.map(omitToken), { + populate: true, + }) + + return Array.isArray(data) ? serializedResponse : serializedResponse[0] } @InjectTransactionManager("baseRepository_") - async revoke( - id: string, + async revoke_( + data: ApiKeyTypes.RevokeApiKeyDTO[], @MedusaContext() sharedContext: Context = {} - ): Promise { - return + ): Promise { + await this.validateRevokeApiKeys(data) + + const updateRequest = data.map((k) => ({ + id: k.id, + revoked_at: new Date(), + revoked_by: k.revoked_by, + })) + + const revokedApiKeys = await this.apiKeyService_.update( + updateRequest, + sharedContext + ) + + return revokedApiKeys } + // TODO: Implement @InjectTransactionManager("baseRepository_") authenticate( id: string, @@ -135,4 +291,108 @@ export default class ApiKeyModuleService ): Promise { return Promise.resolve(false) } + + protected async validateCreateApiKeys( + data: ApiKeyTypes.CreateApiKeyDTO[], + sharedContext: Context = {} + ): Promise { + if (!data.length) { + return + } + + // There can only be 2 secret keys at most, and one has to be with a revoked_at date set, so only 1 can be newly created. + const secretKeysToCreate = data.filter((k) => k.type === ApiKeyType.SECRET) + if (secretKeysToCreate.length > 1) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `You can only create one secret key at a time. You tried to create ${secretKeysToCreate.length} secret keys.` + ) + } + + // There already is a key that is not set to expire/or it hasn't expired + const dbSecretKeys = await this.apiKeyService_.list( + { + type: ApiKeyType.SECRET, + revoked_at: null, + }, + {}, + sharedContext + ) + + if (dbSecretKeys.length) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `You can only have one active secret key a time. Revoke or delete your existing key before creating a new one.` + ) + } + } + + protected async validateRevokeApiKeys( + data: ApiKeyTypes.RevokeApiKeyDTO[], + sharedContext: Context = {} + ): Promise { + if (!data.length) { + return + } + + if (data.some((k) => !k.revoked_by)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `You must provide a revoked_by field when revoking a key.` + ) + } + + const revokedApiKeys = await this.apiKeyService_.list( + { + id: data.map((k) => k.id), + type: ApiKeyType.SECRET, + revoked_at: { $ne: null }, + }, + {}, + sharedContext + ) + + if (revokedApiKeys.length) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `There are ${revokedApiKeys.length} secret keys that are already revoked.` + ) + } + } + + // These are public keys, so there is no point hashing them. + protected static generatePublishableKey(): TokenDTO { + const token = "pk_" + crypto.randomBytes(32).toString("hex") + + return { + rawToken: token, + hashedToken: token, + salt: "", + redacted: redactKey(token), + } + } + + protected static async generateSecretKey(): Promise { + const token = "sk_" + crypto.randomBytes(32).toString("hex") + const salt = crypto.randomBytes(16).toString("hex") + const hashed = ((await scrypt(token, salt, 64)) as Buffer).toString("hex") + + return { + rawToken: token, + hashedToken: hashed, + salt, + redacted: redactKey(token), + } + } +} + +// We are mutating the object here as what microORM relies on non-enumerable fields for serialization, among other things. +const omitToken = (key: ApiKey): ApiKey => { + key.token = key.type === ApiKeyType.SECRET ? "" : key.token + key.salt = "" + return key +} + +const redactKey = (key: string): string => { + return [key.slice(0, 6), key.slice(-3)].join("***") } diff --git a/packages/api-key/src/types/index.ts b/packages/api-key/src/types/index.ts index fdac085753..2655904953 100644 --- a/packages/api-key/src/types/index.ts +++ b/packages/api-key/src/types/index.ts @@ -1,6 +1,23 @@ +import { ApiKeyType } from "@medusajs/types" import { IEventBusModuleService, Logger } from "@medusajs/types" export type InitializeModuleInjectableDependencies = { logger?: Logger eventBusService?: IEventBusModuleService } + +export type CreateApiKeyDTO = { + token: string + salt: string + redacted: string + title: string + type: ApiKeyType + created_by: string +} + +export type TokenDTO = { + rawToken: string + hashedToken: string + salt: string + redacted: string +} diff --git a/packages/types/src/api-key/common/api-key.ts b/packages/types/src/api-key/common/api-key.ts index 0aba76fe89..c11639d45a 100644 --- a/packages/types/src/api-key/common/api-key.ts +++ b/packages/types/src/api-key/common/api-key.ts @@ -1,8 +1,22 @@ import { BaseFilterable } from "../../dal" -// TODO: -export interface ApiKeyDTO {} +export type ApiKeyType = "secret" | "publishable" +export interface ApiKeyDTO { + id: string + token: string + redacted: string + title: string + type: ApiKeyType + last_used_at: Date | null + created_by: string + created_at: Date + revoked_by: string | null + revoked_at: Date | null +} -// TODO: export interface FilterableApiKeyProps - extends BaseFilterable {} + extends BaseFilterable { + id?: string | string[] + title?: string | string[] + type?: ApiKeyType +} diff --git a/packages/types/src/api-key/mutations/api-key.ts b/packages/types/src/api-key/mutations/api-key.ts index 5b69b7d01d..aece40bf26 100644 --- a/packages/types/src/api-key/mutations/api-key.ts +++ b/packages/types/src/api-key/mutations/api-key.ts @@ -1,5 +1,18 @@ -// TODO: -export interface CreateApiKeyDTO {} +import { ApiKeyType } from "../common" -// TODO: -export interface UpdateApiKeyDTO {} +export interface CreateApiKeyDTO { + title: string + type: ApiKeyType + created_by: string + // We could add revoked_at as a parameter (or expires_at that gets mapped to revoked_at internally) in order to support expiring tokens +} + +export interface UpdateApiKeyDTO { + id: string + title?: string +} + +export interface RevokeApiKeyDTO { + id: string + revoked_by: string +} diff --git a/packages/types/src/api-key/service.ts b/packages/types/src/api-key/service.ts index 7d5286a8c4..4447c1e3c1 100644 --- a/packages/types/src/api-key/service.ts +++ b/packages/types/src/api-key/service.ts @@ -2,7 +2,7 @@ import { IModuleService } from "../modules-sdk" import { ApiKeyDTO, FilterableApiKeyProps } from "./common" import { FindConfig } from "../common" import { Context } from "../shared-context" -import { CreateApiKeyDTO, UpdateApiKeyDTO } from "./mutations" +import { CreateApiKeyDTO, RevokeApiKeyDTO, UpdateApiKeyDTO } from "./mutations" export interface IApiKeyModuleService extends IModuleService { /** @@ -67,10 +67,11 @@ export interface IApiKeyModuleService extends IModuleService { /** * Revokes an api key - * @param id + * @param data * @param sharedContext */ - revoke(id: string, sharedContext?: Context): Promise + revoke(data: RevokeApiKeyDTO[], sharedContext?: Context): Promise + revoke(data: RevokeApiKeyDTO, sharedContext?: Context): Promise /** * Check the validity of an api key diff --git a/packages/utils/src/api-key/api-key-type.ts b/packages/utils/src/api-key/api-key-type.ts new file mode 100644 index 0000000000..9927c8ac47 --- /dev/null +++ b/packages/utils/src/api-key/api-key-type.ts @@ -0,0 +1,15 @@ +/** + * @enum + * + * The API key's type. + */ +export enum ApiKeyType { + /** + * Publishable key that is tied to eg. a sales channel + */ + PUBLISHABLE = "publishable", + /** + * Secret key that allows access to the admin API + */ + SECRET = "secret", +} diff --git a/packages/utils/src/api-key/index.ts b/packages/utils/src/api-key/index.ts new file mode 100644 index 0000000000..94dd62f136 --- /dev/null +++ b/packages/utils/src/api-key/index.ts @@ -0,0 +1 @@ +export * from "./api-key-type" diff --git a/packages/utils/src/bundles.ts b/packages/utils/src/bundles.ts index 15b6a512e2..4d4dba2b8b 100644 --- a/packages/utils/src/bundles.ts +++ b/packages/utils/src/bundles.ts @@ -11,3 +11,4 @@ export * as ProductUtils from "./product" export * as PromotionUtils from "./promotion" export * as SearchUtils from "./search" export * as ShippingProfileUtils from "./shipping" +export * as ApiKeyUtils from "./api-key" diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 6dea3aa212..f133931261 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -19,5 +19,6 @@ export * from "./search" export * from "./shipping" export * from "./totals" export * from "./totals/big-number" +export * from "./api-key" export const MedusaModuleType = Symbol.for("MedusaModule")