feat: Add support for multiple active keys at a time (#13754)

This commit is contained in:
Stevche Radevski
2025-10-16 10:07:08 +02:00
committed by GitHub
parent 1ca329d9b3
commit ee1c77a01f
6 changed files with 70 additions and 86 deletions

View File

@@ -0,0 +1,5 @@
---
"@medusajs/api-key": patch
---
Allow creating multiple active API keys at a time

View File

@@ -115,43 +115,18 @@ moduleIntegrationTestRunner<IApiKeyModuleService>({
)
})
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()
})
})

View File

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

View File

@@ -0,0 +1,15 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20251015123842 extends Migration {
override async up(): Promise<void> {
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<void> {
this.addSql(`drop index if exists "IDX_api_key_revoked_at";`);
this.addSql(`drop index if exists "IDX_api_key_redacted";`);
}
}

View File

@@ -18,6 +18,12 @@ const ApiKey = model
on: ["token"],
unique: true,
},
{
on: ["revoked_at"],
},
{
on: ["redacted"],
},
{
on: ["type"],
},

View File

@@ -155,8 +155,6 @@ export class ApiKeyModuleService
data: ApiKeyTypes.CreateApiKeyDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<[InferEntityType<typeof ApiKey>[], 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<InferEntityType<typeof ApiKey> | 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<void> {
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_<T>(
@@ -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