feat(api-key): Add CRUD functionalities to the api key module
This commit is contained in:
140
packages/api-key/src/migrations/.snapshot-medusa-api-key.json
Normal file
140
packages/api-key/src/migrations/.snapshot-medusa-api-key.json
Normal 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": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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);'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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("***")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user