feat(api-key): Allow revoking in the future, and enforce the secret key (#6484)

Since there is quite a bit of code here already, I'll do the middleware changes in a separate PR
This commit is contained in:
Stevche Radevski
2024-02-27 10:37:32 +00:00
committed by GitHub
parent ca463ae9a9
commit 690e8c2e09
13 changed files with 357 additions and 120 deletions
@@ -13,7 +13,12 @@ import {
} from "@medusajs/types"
import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config"
import { ApiKey } from "@models"
import { CreateApiKeyDTO, TokenDTO } from "@types"
import {
CreateApiKeyDTO,
RevokeApiKeyInput,
TokenDTO,
UpdateApiKeyInput,
} from "@types"
import {
ApiKeyType,
InjectManager,
@@ -23,6 +28,7 @@ import {
ModulesSdkUtils,
isObject,
isString,
promiseAll,
} from "@medusajs/utils"
const scrypt = util.promisify(crypto.scrypt)
@@ -132,61 +138,111 @@ export default class ApiKeyModuleService<TEntity extends ApiKey = ApiKey>
return [createdApiKeys, generatedTokens]
}
async update(
selector: FilterableApiKeyProps,
data: Omit<ApiKeyTypes.UpdateApiKeyDTO, "id">,
async upsert(
data: ApiKeyTypes.UpsertApiKeyDTO[],
sharedContext?: Context
): Promise<ApiKeyTypes.ApiKeyDTO[]>
async upsert(
data: ApiKeyTypes.UpsertApiKeyDTO,
sharedContext?: Context
): Promise<ApiKeyTypes.ApiKeyDTO>
@InjectManager("baseRepository_")
async upsert(
data: ApiKeyTypes.UpsertApiKeyDTO | ApiKeyTypes.UpsertApiKeyDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<ApiKeyTypes.ApiKeyDTO | ApiKeyTypes.ApiKeyDTO[]> {
const input = Array.isArray(data) ? data : [data]
const forUpdate = input.filter(
(apiKey): apiKey is UpdateApiKeyInput => !!apiKey.id
)
const forCreate = input.filter(
(apiKey): apiKey is ApiKeyTypes.CreateApiKeyDTO => !apiKey.id
)
const operations: Promise<ApiKeyTypes.ApiKeyDTO[]>[] = []
if (forCreate.length) {
const op = async () => {
const [createdApiKeys, generatedTokens] = await this.create_(
forCreate,
sharedContext
)
const serializedResponse = await this.baseRepository_.serialize<
ApiKeyTypes.ApiKeyDTO[]
>(createdApiKeys, {
populate: true,
})
return serializedResponse.map(
(key) =>
({
...key,
token:
generatedTokens.find((t) => t.hashedToken === key.token)
?.rawToken ?? key.token,
salt: undefined,
} as ApiKeyTypes.ApiKeyDTO)
)
}
operations.push(op())
}
if (forUpdate.length) {
const op = async () => {
const updateResp = await this.update_(forUpdate, sharedContext)
return await this.baseRepository_.serialize<ApiKeyTypes.ApiKeyDTO[]>(
updateResp
)
}
operations.push(op())
}
const result = (await promiseAll(operations)).flat()
return Array.isArray(data) ? result : result[0]
}
async update(
id: string,
data: Omit<ApiKeyTypes.UpdateApiKeyDTO, "id">,
data: ApiKeyTypes.UpdateApiKeyDTO,
sharedContext?: Context
): Promise<ApiKeyTypes.ApiKeyDTO>
async update(
data: ApiKeyTypes.UpdateApiKeyDTO[]
selector: FilterableApiKeyProps,
data: ApiKeyTypes.UpdateApiKeyDTO,
sharedContext?: Context
): Promise<ApiKeyTypes.ApiKeyDTO[]>
@InjectManager("baseRepository_")
async update(
idOrSelectorOrData:
| string
| FilterableApiKeyProps
| ApiKeyTypes.UpdateApiKeyDTO[],
data?: Omit<ApiKeyTypes.UpdateApiKeyDTO, "id">,
idOrSelector: string | FilterableApiKeyProps,
data: ApiKeyTypes.UpdateApiKeyDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<ApiKeyTypes.ApiKeyDTO[] | ApiKeyTypes.ApiKeyDTO> {
const updatedApiKeys = await this.update_(
idOrSelectorOrData,
let normalizedInput = await this.normalizeUpdateInput_<UpdateApiKeyInput>(
idOrSelector,
data,
sharedContext
)
const updatedApiKeys = await this.update_(normalizedInput, sharedContext)
const serializedResponse = await this.baseRepository_.serialize<
ApiKeyTypes.ApiKeyDTO[]
>(updatedApiKeys.map(omitToken), {
populate: true,
})
return isString(idOrSelectorOrData)
? serializedResponse[0]
: serializedResponse
return isString(idOrSelector) ? serializedResponse[0] : serializedResponse
}
@InjectTransactionManager("baseRepository_")
protected async update_(
idOrSelectorOrData:
| string
| FilterableApiKeyProps
| ApiKeyTypes.UpdateApiKeyDTO[],
data?: Omit<ApiKeyTypes.UpdateApiKeyDTO, "id">,
normalizedInput: UpdateApiKeyInput[],
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
const normalizedInput =
await this.normalizeUpdateInput_<ApiKeyTypes.UpdateApiKeyDTO>(
idOrSelectorOrData,
data,
sharedContext
)
const updateRequest = normalizedInput.map((k) => ({
id: k.id,
title: k.title,
@@ -258,33 +314,28 @@ export default class ApiKeyModuleService<TEntity extends ApiKey = ApiKey>
]
}
async revoke(
selector: FilterableApiKeyProps,
data: Omit<ApiKeyTypes.RevokeApiKeyDTO, "id">,
sharedContext?: Context
): Promise<ApiKeyTypes.ApiKeyDTO[]>
async revoke(
id: string,
data: Omit<ApiKeyTypes.RevokeApiKeyDTO, "id">,
data: ApiKeyTypes.RevokeApiKeyDTO,
sharedContext?: Context
): Promise<ApiKeyTypes.ApiKeyDTO>
async revoke(
data: ApiKeyTypes.RevokeApiKeyDTO[]
selector: FilterableApiKeyProps,
data: ApiKeyTypes.RevokeApiKeyDTO,
sharedContext?: Context
): Promise<ApiKeyTypes.ApiKeyDTO[]>
@InjectManager("baseRepository_")
async revoke(
idOrSelectorOrData:
| string
| FilterableApiKeyProps
| ApiKeyTypes.RevokeApiKeyDTO[],
data?: Omit<ApiKeyTypes.RevokeApiKeyDTO, "id">,
idOrSelector: string | FilterableApiKeyProps,
data: ApiKeyTypes.RevokeApiKeyDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<ApiKeyTypes.ApiKeyDTO[] | ApiKeyTypes.ApiKeyDTO> {
const revokedApiKeys = await this.revoke_(
idOrSelectorOrData,
const normalizedInput = await this.normalizeUpdateInput_<RevokeApiKeyInput>(
idOrSelector,
data,
sharedContext
)
const revokedApiKeys = await this.revoke_(normalizedInput, sharedContext)
const serializedResponse = await this.baseRepository_.serialize<
ApiKeyTypes.ApiKeyDTO[]
@@ -292,34 +343,28 @@ export default class ApiKeyModuleService<TEntity extends ApiKey = ApiKey>
populate: true,
})
return isString(idOrSelectorOrData)
? serializedResponse[0]
: serializedResponse
return isString(idOrSelector) ? serializedResponse[0] : serializedResponse
}
@InjectTransactionManager("baseRepository_")
async revoke_(
idOrSelectorOrData:
| string
| FilterableApiKeyProps
| ApiKeyTypes.RevokeApiKeyDTO[],
data?: Omit<ApiKeyTypes.RevokeApiKeyDTO, "id">,
normalizedInput: RevokeApiKeyInput[],
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
const normalizedInput =
await this.normalizeUpdateInput_<ApiKeyTypes.RevokeApiKeyDTO>(
idOrSelectorOrData,
data,
sharedContext
)
await this.validateRevokeApiKeys_(normalizedInput)
const updateRequest = normalizedInput.map((k) => ({
id: k.id,
revoked_at: new Date(),
revoked_by: k.revoked_by,
}))
const updateRequest = normalizedInput.map((k) => {
const revokedAt = new Date()
if (k.revoke_in && k.revoke_in > 0) {
revokedAt.setSeconds(revokedAt.getSeconds() + k.revoke_in)
}
return {
id: k.id,
revoked_at: revokedAt,
revoked_by: k.revoked_by,
}
})
const revokedApiKeys = await this.apiKeyService_.update(
updateRequest,
@@ -329,13 +374,63 @@ export default class ApiKeyModuleService<TEntity extends ApiKey = ApiKey>
return revokedApiKeys
}
// TODO: Implement
@InjectTransactionManager("baseRepository_")
authenticate(
id: string,
@InjectManager("baseRepository_")
async authenticate(
token: string,
@MedusaContext() sharedContext: Context = {}
): Promise<boolean> {
return Promise.resolve(false)
): Promise<ApiKeyTypes.ApiKeyDTO | false> {
const result = await this.authenticate_(token, sharedContext)
if (!result) {
return false
}
const serialized =
await this.baseRepository_.serialize<ApiKeyTypes.ApiKeyDTO>(result, {
populate: true,
})
return serialized
}
@InjectTransactionManager("baseRepository_")
protected async authenticate_(
token: string,
@MedusaContext() sharedContext: Context = {}
): Promise<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,
// 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: null },
sharedContext
)
const matches = await promiseAll(
secretKeys.map(async (dbKey) => {
const hashedInput = await ApiKeyModuleService.calculateHash(
token,
dbKey.salt
)
if (hashedInput === dbKey.token) {
return dbKey
}
return undefined
})
)
const matchedKeys = matches.filter((match) => !!match)
if (!matchedKeys.length) {
return false
}
return matchedKeys[0]!
}
protected async validateCreateApiKeys_(
@@ -359,9 +454,12 @@ export default class ApiKeyModuleService<TEntity extends ApiKey = ApiKey>
const dbSecretKeys = await this.apiKeyService_.list(
{
type: ApiKeyType.SECRET,
revoked_at: null,
$or: [
{ revoked_at: { $eq: null } },
{ revoked_at: { $gt: new Date() } },
],
},
{},
{ take: null },
sharedContext
)
@@ -374,22 +472,18 @@ export default class ApiKeyModuleService<TEntity extends ApiKey = ApiKey>
}
protected async normalizeUpdateInput_<T>(
idOrSelectorOrData: string | FilterableApiKeyProps | T[],
data?: Omit<T, "id">,
idOrSelector: string | FilterableApiKeyProps,
data: Omit<T, "id">,
sharedContext: Context = {}
): Promise<T[]> {
let normalizedInput: T[] = []
if (isString(idOrSelectorOrData)) {
normalizedInput = [{ id: idOrSelectorOrData, ...data } as T]
if (isString(idOrSelector)) {
normalizedInput = [{ id: idOrSelector, ...data } as T]
}
if (Array.isArray(idOrSelectorOrData)) {
normalizedInput = idOrSelectorOrData
}
if (isObject(idOrSelectorOrData)) {
if (isObject(idOrSelector)) {
const apiKeys = await this.apiKeyService_.list(
idOrSelectorOrData,
idOrSelector,
{},
sharedContext
)
@@ -407,7 +501,7 @@ export default class ApiKeyModuleService<TEntity extends ApiKey = ApiKey>
}
protected async validateRevokeApiKeys_(
data: ApiKeyTypes.RevokeApiKeyDTO[],
data: RevokeApiKeyInput[],
sharedContext: Context = {}
): Promise<void> {
if (!data.length) {
@@ -461,7 +555,7 @@ export default class ApiKeyModuleService<TEntity extends ApiKey = ApiKey>
protected static async generateSecretKey(): Promise<TokenDTO> {
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")
const hashed = await this.calculateHash(token, salt)
return {
rawToken: token,
@@ -470,6 +564,13 @@ export default class ApiKeyModuleService<TEntity extends ApiKey = ApiKey>
redacted: redactKey(token),
}
}
protected static async calculateHash(
token: string,
salt: string
): Promise<string> {
return ((await scrypt(token, salt, 64)) as Buffer).toString("hex")
}
}
// We are mutating the object here as what microORM relies on non-enumerable fields for serialization, among other things.