From ee1c77a01f6463dd7a1d8bc2046e91b932900602 Mon Sep 17 00:00:00 2001 From: Stevche Radevski Date: Thu, 16 Oct 2025 10:07:08 +0200 Subject: [PATCH] feat: Add support for multiple active keys at a time (#13754) --- .changeset/tender-points-judge.md | 5 ++ .../__tests__/api-key-module-service.spec.ts | 45 ++++----------- .../migrations/.snapshot-medusa-api-key.json | 28 ++++++++- .../src/migrations/Migration20251015123842.ts | 15 +++++ .../modules/api-key/src/models/api-key.ts | 6 ++ .../src/services/api-key-module-service.ts | 57 +++---------------- 6 files changed, 70 insertions(+), 86 deletions(-) create mode 100644 .changeset/tender-points-judge.md create mode 100644 packages/modules/api-key/src/migrations/Migration20251015123842.ts diff --git a/.changeset/tender-points-judge.md b/.changeset/tender-points-judge.md new file mode 100644 index 0000000000..9d4acae6bc --- /dev/null +++ b/.changeset/tender-points-judge.md @@ -0,0 +1,5 @@ +--- +"@medusajs/api-key": patch +--- + +Allow creating multiple active API keys at a time diff --git a/packages/modules/api-key/integration-tests/__tests__/api-key-module-service.spec.ts b/packages/modules/api-key/integration-tests/__tests__/api-key-module-service.spec.ts index 802d5e5ef4..bdf5447fd0 100644 --- a/packages/modules/api-key/integration-tests/__tests__/api-key-module-service.spec.ts +++ b/packages/modules/api-key/integration-tests/__tests__/api-key-module-service.spec.ts @@ -115,43 +115,18 @@ moduleIntegrationTestRunner({ ) }) - it("should only allow creating one active token", async function () { - await expect( - service.createApiKeys([ - createSecretKeyFixture, - createSecretKeyFixture, - ]) - ).rejects.toThrow( - "You can only create one secret key at a time. You tried to create 2 secret keys." - ) + it("should allow creating multiple active token", async function () { + const apiKeys = await service.createApiKeys([ + createSecretKeyFixture, + createSecretKeyFixture, + ]) - await service.createApiKeys(createSecretKeyFixture) - const err = await service - .createApiKeys(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." - ) - }) + apiKeys.push(await service.createApiKeys(createSecretKeyFixture)) - it("should allow for at most two tokens, where one is revoked", async function () { - const firstApiKey = await service.createApiKeys( - createSecretKeyFixture - ) - await service.revoke( - { id: firstApiKey.id }, - { - revoked_by: "test", - } - ) - - await service.createApiKeys(createSecretKeyFixture) - const err = await service - .createApiKeys(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." - ) + expect(apiKeys).toHaveLength(3) + expect(apiKeys[0].revoked_at).toBeFalsy() + expect(apiKeys[1].revoked_at).toBeFalsy() + expect(apiKeys[2].revoked_at).toBeFalsy() }) }) diff --git a/packages/modules/api-key/src/migrations/.snapshot-medusa-api-key.json b/packages/modules/api-key/src/migrations/.snapshot-medusa-api-key.json index c8086a75f8..9c1aa7481d 100644 --- a/packages/modules/api-key/src/migrations/.snapshot-medusa-api-key.json +++ b/packages/modules/api-key/src/migrations/.snapshot-medusa-api-key.json @@ -142,6 +142,7 @@ "keyName": "IDX_api_key_deleted_at", "columnNames": [], "composite": false, + "constraint": false, "primary": false, "unique": false, "expression": "CREATE INDEX IF NOT EXISTS \"IDX_api_key_deleted_at\" ON \"api_key\" (deleted_at) WHERE deleted_at IS NULL" @@ -150,14 +151,34 @@ "keyName": "IDX_api_key_token_unique", "columnNames": [], "composite": false, + "constraint": false, "primary": false, "unique": false, "expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_api_key_token_unique\" ON \"api_key\" (token) WHERE deleted_at IS NULL" }, + { + "keyName": "IDX_api_key_revoked_at", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_api_key_revoked_at\" ON \"api_key\" (revoked_at) WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_api_key_redacted", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_api_key_redacted\" ON \"api_key\" (redacted) WHERE deleted_at IS NULL" + }, { "keyName": "IDX_api_key_type", "columnNames": [], "composite": false, + "constraint": false, "primary": false, "unique": false, "expression": "CREATE INDEX IF NOT EXISTS \"IDX_api_key_type\" ON \"api_key\" (type) WHERE deleted_at IS NULL" @@ -168,12 +189,15 @@ "id" ], "composite": false, + "constraint": true, "primary": true, "unique": true } ], "checks": [], - "foreignKeys": {} + "foreignKeys": {}, + "nativeEnums": {} } - ] + ], + "nativeEnums": {} } diff --git a/packages/modules/api-key/src/migrations/Migration20251015123842.ts b/packages/modules/api-key/src/migrations/Migration20251015123842.ts new file mode 100644 index 0000000000..68a4db1d79 --- /dev/null +++ b/packages/modules/api-key/src/migrations/Migration20251015123842.ts @@ -0,0 +1,15 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20251015123842 extends Migration { + + override async up(): Promise { + this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_api_key_revoked_at" ON "api_key" (revoked_at) WHERE deleted_at IS NULL;`); + this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_api_key_redacted" ON "api_key" (redacted) WHERE deleted_at IS NULL;`); + } + + override async down(): Promise { + this.addSql(`drop index if exists "IDX_api_key_revoked_at";`); + this.addSql(`drop index if exists "IDX_api_key_redacted";`); + } + +} diff --git a/packages/modules/api-key/src/models/api-key.ts b/packages/modules/api-key/src/models/api-key.ts index 621b738304..84e72cb3d2 100644 --- a/packages/modules/api-key/src/models/api-key.ts +++ b/packages/modules/api-key/src/models/api-key.ts @@ -18,6 +18,12 @@ const ApiKey = model on: ["token"], unique: true, }, + { + on: ["revoked_at"], + }, + { + on: ["redacted"], + }, { on: ["type"], }, diff --git a/packages/modules/api-key/src/services/api-key-module-service.ts b/packages/modules/api-key/src/services/api-key-module-service.ts index 62f4be0470..d3d68a45e7 100644 --- a/packages/modules/api-key/src/services/api-key-module-service.ts +++ b/packages/modules/api-key/src/services/api-key-module-service.ts @@ -155,8 +155,6 @@ export class ApiKeyModuleService data: ApiKeyTypes.CreateApiKeyDTO[], @MedusaContext() sharedContext: Context = {} ): Promise<[InferEntityType[], TokenDTO[]]> { - await this.validateCreateApiKeys_(data, sharedContext) - const normalizedInput: CreateApiKeyDTO[] = [] const generatedTokens: TokenDTO[] = [] for (const key of data) { @@ -461,18 +459,21 @@ export class ApiKeyModuleService token: string, @MedusaContext() sharedContext: Context = {} ): Promise | false> { - // Since we only allow up to 2 active tokens, getitng the list and checking each token isn't an issue. - // We can always filter on the redacted key if we add support for an arbitrary number of tokens. const secretKeys = await this.apiKeyService_.list( { type: ApiKeyType.SECRET, + // There could be many unrevoked keys at the same time, so we narrow the list down by the redacted key. + // Note that the redacted key doesn't guarantee uniqueness and is not an authentication check, but just an optimization. + redacted: redactKey(token), // If the revoke date is set in the future, it means the key is still valid. $or: [ { revoked_at: { $eq: null } }, { revoked_at: { $gt: new Date() } }, ], }, - {}, + { + take: undefined, + }, sharedContext ) @@ -494,49 +495,7 @@ export class ApiKeyModuleService if (!matchedKeys.length) { return false } - return matchedKeys[0]! - } - - 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) { - return - } - - 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, - $or: [ - { revoked_at: { $eq: null } }, - { revoked_at: { $gt: new Date() } }, - ], - }, - {}, - 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.` - ) - } + return matchedKeys[0] } protected async normalizeUpdateInput_( @@ -594,7 +553,7 @@ export class ApiKeyModuleService { id: data.map((k) => k.id), type: ApiKeyType.SECRET, - revoked_at: { $ne: null }, + revoked_at: { $lt: new Date() }, }, {}, sharedContext