feat(api-key): Add CRUD functionalities to the api key module
This commit is contained in:
@@ -1 +1,14 @@
|
||||
// noop
|
||||
import { CreateApiKeyDTO } from "@types"
|
||||
import { ApiKeyType } from "@medusajs/utils"
|
||||
|
||||
export const createSecretKeyFixture: CreateApiKeyDTO = {
|
||||
title: "Secret key",
|
||||
type: ApiKeyType.SECRET,
|
||||
created_by: "test",
|
||||
}
|
||||
|
||||
export const createPublishableKeyFixture: CreateApiKeyDTO = {
|
||||
title: "Test API Key",
|
||||
type: ApiKeyType.PUBLISHABLE,
|
||||
created_by: "test",
|
||||
}
|
||||
|
||||
@@ -1,19 +1,266 @@
|
||||
import crypto from "crypto"
|
||||
import { Modules } from "@medusajs/modules-sdk"
|
||||
import { IApiKeyModuleService } from "@medusajs/types"
|
||||
import { ApiKeyType } from "@medusajs/utils"
|
||||
import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils"
|
||||
import {
|
||||
createSecretKeyFixture,
|
||||
createPublishableKeyFixture,
|
||||
} from "../__fixtures__"
|
||||
|
||||
jest.setTimeout(100000)
|
||||
|
||||
const mockPublishableKeyBytes = () => {
|
||||
jest.spyOn(crypto, "randomBytes").mockImplementationOnce(() => {
|
||||
return Buffer.from(
|
||||
"44de31ebcf085fa423fc584aa854067025e937a79edb565f472404345f0f23be",
|
||||
"hex"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const mockSecretKeyBytes = () => {
|
||||
jest
|
||||
.spyOn(crypto, "randomBytes")
|
||||
.mockImplementationOnce(() => {
|
||||
return Buffer.from(
|
||||
"44de31ebcf085fa423fc584aa854067025e937a79edb565f472404345f0f23be",
|
||||
"hex"
|
||||
)
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
return Buffer.from("44de31ebcf085fa423fc584aa8540670", "hex")
|
||||
})
|
||||
}
|
||||
|
||||
moduleIntegrationTestRunner({
|
||||
moduleName: Modules.API_KEY,
|
||||
testSuite: ({
|
||||
MikroOrmWrapper,
|
||||
service,
|
||||
}: SuiteOptions<IApiKeyModuleService>) => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe("API Key Module Service", () => {
|
||||
describe("noop", () => {
|
||||
it("should run", function () {
|
||||
expect(true).toBe(true)
|
||||
describe("creating a publishable API key", () => {
|
||||
it("should create it successfully", async function () {
|
||||
mockPublishableKeyBytes()
|
||||
const apiKey = await service.create(createPublishableKeyFixture)
|
||||
|
||||
expect(apiKey).toEqual(
|
||||
expect.objectContaining({
|
||||
title: "Test API Key",
|
||||
type: ApiKeyType.PUBLISHABLE,
|
||||
salt: "",
|
||||
created_by: "test",
|
||||
last_used_at: null,
|
||||
revoked_by: null,
|
||||
revoked_at: null,
|
||||
redacted: "pk_44d***3be",
|
||||
token:
|
||||
"pk_44de31ebcf085fa423fc584aa854067025e937a79edb565f472404345f0f23be",
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("creating a secret API key", () => {
|
||||
it("should get created successfully", async function () {
|
||||
mockSecretKeyBytes()
|
||||
const apiKey = await service.create(createSecretKeyFixture)
|
||||
|
||||
expect(apiKey).toEqual(
|
||||
expect.objectContaining({
|
||||
title: "Secret key",
|
||||
type: ApiKeyType.SECRET,
|
||||
salt: "44de31ebcf085fa423fc584aa8540670",
|
||||
created_by: "test",
|
||||
last_used_at: null,
|
||||
revoked_by: null,
|
||||
revoked_at: null,
|
||||
redacted: "sk_44d***3be",
|
||||
token:
|
||||
"sk_44de31ebcf085fa423fc584aa854067025e937a79edb565f472404345f0f23be",
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("should only allow creating one active token", async function () {
|
||||
expect(
|
||||
service.create([createSecretKeyFixture, createSecretKeyFixture])
|
||||
).rejects.toThrow(
|
||||
"You can only create one secret key at a time. You tried to create 2 secret keys."
|
||||
)
|
||||
|
||||
await service.create(createSecretKeyFixture)
|
||||
const err = await service
|
||||
.create(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."
|
||||
)
|
||||
})
|
||||
|
||||
it("should allow for at most two tokens, where one is revoked", async function () {
|
||||
const firstApiKey = await service.create(createSecretKeyFixture)
|
||||
await service.revoke({
|
||||
id: firstApiKey.id,
|
||||
revoked_by: "test",
|
||||
})
|
||||
|
||||
await service.create(createSecretKeyFixture)
|
||||
const err = await service
|
||||
.create(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."
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("revoking API keys", () => {
|
||||
it("should have the revoked at and revoked by set when a key is revoked", async function () {
|
||||
const firstApiKey = await service.create(createSecretKeyFixture)
|
||||
const revokedKey = await service.revoke({
|
||||
id: firstApiKey.id,
|
||||
revoked_by: "test",
|
||||
})
|
||||
|
||||
expect(revokedKey).toEqual(
|
||||
expect.objectContaining({
|
||||
revoked_by: "test",
|
||||
revoked_at: expect.any(Date),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("should not allow revoking an already revoked API key", async function () {
|
||||
const firstApiKey = await service.create(createSecretKeyFixture)
|
||||
await service.revoke({
|
||||
id: firstApiKey.id,
|
||||
revoked_by: "test",
|
||||
})
|
||||
|
||||
const err = await service
|
||||
.revoke({
|
||||
id: firstApiKey.id,
|
||||
revoked_by: "test2",
|
||||
})
|
||||
.catch((e) => e)
|
||||
|
||||
expect(err.message).toEqual(
|
||||
`There are 1 secret keys that are already revoked.`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("updating an API key", () => {
|
||||
it("should update the name successfully", async function () {
|
||||
const createdApiKey = await service.create(createSecretKeyFixture)
|
||||
|
||||
const updatedApiKey = await service.update({
|
||||
id: createdApiKey.id,
|
||||
title: "New Name",
|
||||
})
|
||||
expect(updatedApiKey.title).toEqual("New Name")
|
||||
})
|
||||
|
||||
it("should not reflect any updates on other fields", async function () {
|
||||
const createdApiKey = await service.create(createSecretKeyFixture)
|
||||
|
||||
const updatedApiKey = await service.update({
|
||||
id: createdApiKey.id,
|
||||
title: createdApiKey.title,
|
||||
revoked_by: "test",
|
||||
revoked_at: new Date(),
|
||||
last_used_at: new Date(),
|
||||
})
|
||||
|
||||
// These should not be returned on an update
|
||||
createdApiKey.token = ""
|
||||
createdApiKey.salt = ""
|
||||
expect(createdApiKey).toEqual(updatedApiKey)
|
||||
})
|
||||
})
|
||||
|
||||
describe("deleting API keys", () => {
|
||||
it("should successfully delete existing api keys", async function () {
|
||||
const createdApiKeys = await service.create([
|
||||
createPublishableKeyFixture,
|
||||
createSecretKeyFixture,
|
||||
])
|
||||
await service.delete([createdApiKeys[0].id, createdApiKeys[1].id])
|
||||
|
||||
const apiKeysInDatabase = await service.list()
|
||||
expect(apiKeysInDatabase).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("retrieving API keys", () => {
|
||||
it("should successfully return all existing api keys", async function () {
|
||||
await service.create([
|
||||
createPublishableKeyFixture,
|
||||
createSecretKeyFixture,
|
||||
])
|
||||
|
||||
const apiKeysInDatabase = await service.list()
|
||||
expect(apiKeysInDatabase).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("should not return the token and salt for secret keys when listing", async function () {
|
||||
await service.create([createSecretKeyFixture])
|
||||
|
||||
const apiKeysInDatabase = await service.list()
|
||||
expect(apiKeysInDatabase).toHaveLength(1)
|
||||
expect(apiKeysInDatabase[0].token).toBeFalsy()
|
||||
expect(apiKeysInDatabase[0].salt).toBeFalsy()
|
||||
})
|
||||
|
||||
it("should return the token for publishable keys when listing", async function () {
|
||||
await service.create([createPublishableKeyFixture])
|
||||
|
||||
const apiKeysInDatabase = await service.list()
|
||||
expect(apiKeysInDatabase).toHaveLength(1)
|
||||
expect(apiKeysInDatabase[0].token).toBeTruthy()
|
||||
expect(apiKeysInDatabase[0].salt).toBeFalsy()
|
||||
})
|
||||
|
||||
it("should not return the token and salt for secret keys when listing and counting", async function () {
|
||||
await service.create([createSecretKeyFixture])
|
||||
|
||||
const [apiKeysInDatabase] = await service.listAndCount()
|
||||
expect(apiKeysInDatabase).toHaveLength(1)
|
||||
expect(apiKeysInDatabase[0].token).toBeFalsy()
|
||||
expect(apiKeysInDatabase[0].salt).toBeFalsy()
|
||||
})
|
||||
|
||||
it("should return the token for publishable keys when listing and counting", async function () {
|
||||
await service.create([createPublishableKeyFixture])
|
||||
|
||||
const [apiKeysInDatabase] = await service.listAndCount()
|
||||
expect(apiKeysInDatabase).toHaveLength(1)
|
||||
expect(apiKeysInDatabase[0].token).toBeTruthy()
|
||||
expect(apiKeysInDatabase[0].salt).toBeFalsy()
|
||||
})
|
||||
|
||||
it("should not return the token and salt for secret keys when retrieving", async function () {
|
||||
const [createdApiKey] = await service.create([createSecretKeyFixture])
|
||||
|
||||
const apiKeyInDatabase = await service.retrieve(createdApiKey.id)
|
||||
expect(apiKeyInDatabase.token).toBeFalsy()
|
||||
expect(apiKeyInDatabase.salt).toBeFalsy()
|
||||
})
|
||||
|
||||
it("should return the token for publishable keys when retrieving", async function () {
|
||||
const [createdApiKey] = await service.create([
|
||||
createPublishableKeyFixture,
|
||||
])
|
||||
|
||||
const apiKeyInDatabase = await service.retrieve(createdApiKey.id)
|
||||
expect(apiKeyInDatabase.token).toBeTruthy()
|
||||
expect(apiKeyInDatabase.salt).toBeFalsy()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import { BaseFilterable } from "../../dal"
|
||||
|
||||
// TODO:
|
||||
export interface ApiKeyDTO {}
|
||||
export type ApiKeyType = "secret" | "publishable"
|
||||
export interface ApiKeyDTO {
|
||||
id: string
|
||||
token: string
|
||||
redacted: string
|
||||
title: string
|
||||
type: ApiKeyType
|
||||
last_used_at: Date | null
|
||||
created_by: string
|
||||
created_at: Date
|
||||
revoked_by: string | null
|
||||
revoked_at: Date | null
|
||||
}
|
||||
|
||||
// TODO:
|
||||
export interface FilterableApiKeyProps
|
||||
extends BaseFilterable<FilterableApiKeyProps> {}
|
||||
extends BaseFilterable<FilterableApiKeyProps> {
|
||||
id?: string | string[]
|
||||
title?: string | string[]
|
||||
type?: ApiKeyType
|
||||
}
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
// TODO:
|
||||
export interface CreateApiKeyDTO {}
|
||||
import { ApiKeyType } from "../common"
|
||||
|
||||
// TODO:
|
||||
export interface UpdateApiKeyDTO {}
|
||||
export interface CreateApiKeyDTO {
|
||||
title: string
|
||||
type: ApiKeyType
|
||||
created_by: string
|
||||
// We could add revoked_at as a parameter (or expires_at that gets mapped to revoked_at internally) in order to support expiring tokens
|
||||
}
|
||||
|
||||
export interface UpdateApiKeyDTO {
|
||||
id: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export interface RevokeApiKeyDTO {
|
||||
id: string
|
||||
revoked_by: string
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { IModuleService } from "../modules-sdk"
|
||||
import { ApiKeyDTO, FilterableApiKeyProps } from "./common"
|
||||
import { FindConfig } from "../common"
|
||||
import { Context } from "../shared-context"
|
||||
import { CreateApiKeyDTO, UpdateApiKeyDTO } from "./mutations"
|
||||
import { CreateApiKeyDTO, RevokeApiKeyDTO, UpdateApiKeyDTO } from "./mutations"
|
||||
|
||||
export interface IApiKeyModuleService extends IModuleService {
|
||||
/**
|
||||
@@ -67,10 +67,11 @@ export interface IApiKeyModuleService extends IModuleService {
|
||||
|
||||
/**
|
||||
* Revokes an api key
|
||||
* @param id
|
||||
* @param data
|
||||
* @param sharedContext
|
||||
*/
|
||||
revoke(id: string, sharedContext?: Context): Promise<void>
|
||||
revoke(data: RevokeApiKeyDTO[], sharedContext?: Context): Promise<ApiKeyDTO[]>
|
||||
revoke(data: RevokeApiKeyDTO, sharedContext?: Context): Promise<ApiKeyDTO>
|
||||
|
||||
/**
|
||||
* Check the validity of an api key
|
||||
|
||||
15
packages/utils/src/api-key/api-key-type.ts
Normal file
15
packages/utils/src/api-key/api-key-type.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* @enum
|
||||
*
|
||||
* The API key's type.
|
||||
*/
|
||||
export enum ApiKeyType {
|
||||
/**
|
||||
* Publishable key that is tied to eg. a sales channel
|
||||
*/
|
||||
PUBLISHABLE = "publishable",
|
||||
/**
|
||||
* Secret key that allows access to the admin API
|
||||
*/
|
||||
SECRET = "secret",
|
||||
}
|
||||
1
packages/utils/src/api-key/index.ts
Normal file
1
packages/utils/src/api-key/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./api-key-type"
|
||||
@@ -11,3 +11,4 @@ export * as ProductUtils from "./product"
|
||||
export * as PromotionUtils from "./promotion"
|
||||
export * as SearchUtils from "./search"
|
||||
export * as ShippingProfileUtils from "./shipping"
|
||||
export * as ApiKeyUtils from "./api-key"
|
||||
|
||||
@@ -19,5 +19,6 @@ export * from "./search"
|
||||
export * from "./shipping"
|
||||
export * from "./totals"
|
||||
export * from "./totals/big-number"
|
||||
export * from "./api-key"
|
||||
|
||||
export const MedusaModuleType = Symbol.for("MedusaModule")
|
||||
|
||||
Reference in New Issue
Block a user