feat(api-key): Add CRUD functionalities to the api key module

This commit is contained in:
Stevche Radevski
2024-02-21 11:19:22 +01:00
parent e0750bae40
commit c99ca5cc22
14 changed files with 812 additions and 42 deletions

View File

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

View File

@@ -0,0 +1,12 @@
import { Migration } from "@mikro-orm/migrations"
export class InitialSetup20240220155605 extends Migration {
async up(): Promise<void> {
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);'
)
}
}

View File

@@ -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() {

View File

@@ -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<TEntity extends ApiKey = ApiKey>
data: ApiKeyTypes.CreateApiKeyDTO | ApiKeyTypes.CreateApiKeyDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<ApiKeyTypes.ApiKeyDTO | ApiKeyTypes.ApiKeyDTO[]> {
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<TEntity | TEntity[]> {
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<TEntity extends ApiKey = ApiKey>
data: ApiKeyTypes.UpdateApiKeyDTO[] | ApiKeyTypes.UpdateApiKeyDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<ApiKeyTypes.ApiKeyDTO[] | ApiKeyTypes.ApiKeyDTO> {
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<TEntity[] | TEntity> {
return []
): Promise<TEntity[]> {
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<ApiKeyTypes.ApiKeyDTO>,
sharedContext?: Context
): Promise<ApiKeyTypes.ApiKeyDTO> {
const apiKey = await this.apiKeyService_.retrieve(id, config, sharedContext)
return await this.baseRepository_.serialize<ApiKeyTypes.ApiKeyDTO>(
omitToken(apiKey),
{
populate: true,
}
)
}
@InjectManager("baseRepository_")
async list(
filters?: ApiKeyTypes.FilterableApiKeyProps,
config?: FindConfig<ApiKeyTypes.ApiKeyDTO>,
sharedContext?: Context
): Promise<ApiKeyTypes.ApiKeyDTO[]> {
const apiKeys = await this.apiKeyService_.list(
filters,
config,
sharedContext
)
return this.baseRepository_.serialize<ApiKeyTypes.ApiKeyDTO[]>(
apiKeys.map(omitToken),
{
populate: true,
}
)
}
@InjectManager("baseRepository_")
async listAndCount(
filters?: ApiKeyTypes.FilterableApiKeyProps,
config?: FindConfig<ApiKeyTypes.ApiKeyDTO>,
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<ApiKeyTypes.ApiKeyDTO[]>(
withoutToken,
{
populate: true,
}
),
count,
]
}
async revoke(
data: ApiKeyTypes.RevokeApiKeyDTO[],
sharedContext?: Context
): Promise<ApiKeyTypes.ApiKeyDTO[]>
async revoke(
data: ApiKeyTypes.RevokeApiKeyDTO,
sharedContext?: Context
): Promise<ApiKeyTypes.ApiKeyDTO>
@InjectManager("baseRepository_")
async revoke(
data: ApiKeyTypes.RevokeApiKeyDTO[] | ApiKeyTypes.RevokeApiKeyDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<ApiKeyTypes.ApiKeyDTO[] | ApiKeyTypes.ApiKeyDTO> {
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<void> {
return
): Promise<TEntity[]> {
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<TEntity extends ApiKey = ApiKey>
): Promise<boolean> {
return Promise.resolve(false)
}
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 > 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<void> {
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<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")
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("***")
}

View File

@@ -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
}