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:
1 parent
ca463ae9a9
commit
690e8c2e09
13 files changed
+355
-118
No files matched your search
@@ -108,4 +108,29 @@ describe("API Keys - Admin", () => {
|
|||||||
expect(deleted.status).toEqual(200)
|
expect(deleted.status).toEqual(200)
|
||||||
expect(listedApiKeys.data.apiKeys).toHaveLength(0)
|
expect(listedApiKeys.data.apiKeys).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.skip("can use a secret api key for authentication", async () => {
|
||||||
|
const api = useApi() as any
|
||||||
|
const created = await api.post(
|
||||||
|
`/admin/api-keys`,
|
||||||
|
{
|
||||||
|
title: "Test Secret Key",
|
||||||
|
type: ApiKeyType.SECRET,
|
||||||
|
},
|
||||||
|
adminHeaders
|
||||||
|
)
|
||||||
|
|
||||||
|
const createdRegion = await api.post(
|
||||||
|
`/admin/regions`,
|
||||||
|
{
|
||||||
|
name: "Test Region",
|
||||||
|
currency_code: "usd",
|
||||||
|
countries: ["us", "ca"],
|
||||||
|
},
|
||||||
|
{ headers: { Authorization: `Bearer ${created.token}` } }
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(createdRegion.status).toEqual(200)
|
||||||
|
expect(createdRegion.data.region.name).toEqual("Test Region")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -137,6 +137,27 @@ moduleIntegrationTestRunner({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should be able to revoke a key in the future", async function () {
|
||||||
|
const now = Date.parse("2021-01-01T00:00:00Z")
|
||||||
|
const hourInSec = 3600
|
||||||
|
jest.useFakeTimers().setSystemTime(now)
|
||||||
|
|
||||||
|
const createdKey = await service.create(createSecretKeyFixture)
|
||||||
|
const revokedKey = await service.revoke(createdKey.id, {
|
||||||
|
revoked_by: "test",
|
||||||
|
revoke_in: hourInSec,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(revokedKey).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
revoked_by: "test",
|
||||||
|
revoked_at: new Date(now + hourInSec * 1000),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
it("should do nothing if the revokal list is empty", async function () {
|
it("should do nothing if the revokal list is empty", async function () {
|
||||||
const firstApiKey = await service.create(createSecretKeyFixture)
|
const firstApiKey = await service.create(createSecretKeyFixture)
|
||||||
let revokedKeys = await service.revoke([])
|
let revokedKeys = await service.revoke([])
|
||||||
@@ -204,6 +225,54 @@ moduleIntegrationTestRunner({
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("authenticating with API keys", () => {
|
||||||
|
it("should authenticate a secret key successfully", async function () {
|
||||||
|
const createdApiKey = await service.create(createSecretKeyFixture)
|
||||||
|
const authenticated = await service.authenticate(createdApiKey.token)
|
||||||
|
|
||||||
|
expect(authenticated).toBeTruthy()
|
||||||
|
expect(authenticated.title).toEqual(createSecretKeyFixture.title)
|
||||||
|
})
|
||||||
|
it("should authenticate with a token to be revoked in the future", async function () {
|
||||||
|
const createdApiKey = await service.create(createSecretKeyFixture)
|
||||||
|
|
||||||
|
// We simulate setting the revoked_at in the future here
|
||||||
|
jest.useFakeTimers().setSystemTime(new Date().setFullYear(3000))
|
||||||
|
await service.revoke(createdApiKey.id, {
|
||||||
|
revoked_by: "test",
|
||||||
|
})
|
||||||
|
jest.useRealTimers()
|
||||||
|
|
||||||
|
const authenticated = await service.authenticate(createdApiKey.token)
|
||||||
|
expect(authenticated).toBeTruthy()
|
||||||
|
expect(authenticated.title).toEqual(createdApiKey.title)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not authenticate a publishable key", async function () {
|
||||||
|
const createdApiKey = await service.create(
|
||||||
|
createPublishableKeyFixture
|
||||||
|
)
|
||||||
|
const authenticated = await service.authenticate(createdApiKey.token)
|
||||||
|
|
||||||
|
expect(authenticated).toBeFalsy()
|
||||||
|
})
|
||||||
|
it("should not authenticate with a non-existent token", async function () {
|
||||||
|
const createdApiKey = await service.create(createSecretKeyFixture)
|
||||||
|
const authenticated = await service.authenticate("some-token")
|
||||||
|
|
||||||
|
expect(authenticated).toBeFalsy()
|
||||||
|
})
|
||||||
|
it("should not authenticate with a revoked token", async function () {
|
||||||
|
const createdApiKey = await service.create(createSecretKeyFixture)
|
||||||
|
await service.revoke(createdApiKey.id, {
|
||||||
|
revoked_by: "test",
|
||||||
|
})
|
||||||
|
const authenticated = await service.authenticate(createdApiKey.token)
|
||||||
|
|
||||||
|
expect(authenticated).toBeFalsy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("retrieving API keys", () => {
|
describe("retrieving API keys", () => {
|
||||||
it("should successfully return all existing api keys", async function () {
|
it("should successfully return all existing api keys", async function () {
|
||||||
await service.create([
|
await service.create([
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ import {
|
|||||||
} from "@medusajs/types"
|
} from "@medusajs/types"
|
||||||
import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config"
|
import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config"
|
||||||
import { ApiKey } from "@models"
|
import { ApiKey } from "@models"
|
||||||
import { CreateApiKeyDTO, TokenDTO } from "@types"
|
import {
|
||||||
|
CreateApiKeyDTO,
|
||||||
|
RevokeApiKeyInput,
|
||||||
|
TokenDTO,
|
||||||
|
UpdateApiKeyInput,
|
||||||
|
} from "@types"
|
||||||
import {
|
import {
|
||||||
ApiKeyType,
|
ApiKeyType,
|
||||||
InjectManager,
|
InjectManager,
|
||||||
@@ -23,6 +28,7 @@ import {
|
|||||||
ModulesSdkUtils,
|
ModulesSdkUtils,
|
||||||
isObject,
|
isObject,
|
||||||
isString,
|
isString,
|
||||||
|
promiseAll,
|
||||||
} from "@medusajs/utils"
|
} from "@medusajs/utils"
|
||||||
|
|
||||||
const scrypt = util.promisify(crypto.scrypt)
|
const scrypt = util.promisify(crypto.scrypt)
|
||||||
@@ -132,61 +138,111 @@ export default class ApiKeyModuleService<TEntity extends ApiKey = ApiKey>
|
|||||||
return [createdApiKeys, generatedTokens]
|
return [createdApiKeys, generatedTokens]
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(
|
async upsert(
|
||||||
selector: FilterableApiKeyProps,
|
data: ApiKeyTypes.UpsertApiKeyDTO[],
|
||||||
data: Omit<ApiKeyTypes.UpdateApiKeyDTO, "id">,
|
|
||||||
sharedContext?: Context
|
sharedContext?: Context
|
||||||
): Promise<ApiKeyTypes.ApiKeyDTO[]>
|
): 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(
|
async update(
|
||||||
id: string,
|
id: string,
|
||||||
data: Omit<ApiKeyTypes.UpdateApiKeyDTO, "id">,
|
data: ApiKeyTypes.UpdateApiKeyDTO,
|
||||||
sharedContext?: Context
|
sharedContext?: Context
|
||||||
): Promise<ApiKeyTypes.ApiKeyDTO>
|
): Promise<ApiKeyTypes.ApiKeyDTO>
|
||||||
async update(
|
async update(
|
||||||
data: ApiKeyTypes.UpdateApiKeyDTO[]
|
selector: FilterableApiKeyProps,
|
||||||
|
data: ApiKeyTypes.UpdateApiKeyDTO,
|
||||||
|
sharedContext?: Context
|
||||||
): Promise<ApiKeyTypes.ApiKeyDTO[]>
|
): Promise<ApiKeyTypes.ApiKeyDTO[]>
|
||||||
|
|
||||||
@InjectManager("baseRepository_")
|
@InjectManager("baseRepository_")
|
||||||
async update(
|
async update(
|
||||||
idOrSelectorOrData:
|
idOrSelector: string | FilterableApiKeyProps,
|
||||||
| string
|
data: ApiKeyTypes.UpdateApiKeyDTO,
|
||||||
| FilterableApiKeyProps
|
|
||||||
| ApiKeyTypes.UpdateApiKeyDTO[],
|
|
||||||
data?: Omit<ApiKeyTypes.UpdateApiKeyDTO, "id">,
|
|
||||||
@MedusaContext() sharedContext: Context = {}
|
@MedusaContext() sharedContext: Context = {}
|
||||||
): Promise<ApiKeyTypes.ApiKeyDTO[] | ApiKeyTypes.ApiKeyDTO> {
|
): Promise<ApiKeyTypes.ApiKeyDTO[] | ApiKeyTypes.ApiKeyDTO> {
|
||||||
const updatedApiKeys = await this.update_(
|
let normalizedInput = await this.normalizeUpdateInput_<UpdateApiKeyInput>(
|
||||||
idOrSelectorOrData,
|
idOrSelector,
|
||||||
data,
|
data,
|
||||||
sharedContext
|
sharedContext
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const updatedApiKeys = await this.update_(normalizedInput, sharedContext)
|
||||||
|
|
||||||
const serializedResponse = await this.baseRepository_.serialize<
|
const serializedResponse = await this.baseRepository_.serialize<
|
||||||
ApiKeyTypes.ApiKeyDTO[]
|
ApiKeyTypes.ApiKeyDTO[]
|
||||||
>(updatedApiKeys.map(omitToken), {
|
>(updatedApiKeys.map(omitToken), {
|
||||||
populate: true,
|
populate: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
return isString(idOrSelectorOrData)
|
return isString(idOrSelector) ? serializedResponse[0] : serializedResponse
|
||||||
? serializedResponse[0]
|
|
||||||
: serializedResponse
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@InjectTransactionManager("baseRepository_")
|
@InjectTransactionManager("baseRepository_")
|
||||||
protected async update_(
|
protected async update_(
|
||||||
idOrSelectorOrData:
|
normalizedInput: UpdateApiKeyInput[],
|
||||||
| string
|
|
||||||
| FilterableApiKeyProps
|
|
||||||
| ApiKeyTypes.UpdateApiKeyDTO[],
|
|
||||||
data?: Omit<ApiKeyTypes.UpdateApiKeyDTO, "id">,
|
|
||||||
@MedusaContext() sharedContext: Context = {}
|
@MedusaContext() sharedContext: Context = {}
|
||||||
): Promise<TEntity[]> {
|
): Promise<TEntity[]> {
|
||||||
const normalizedInput =
|
|
||||||
await this.normalizeUpdateInput_<ApiKeyTypes.UpdateApiKeyDTO>(
|
|
||||||
idOrSelectorOrData,
|
|
||||||
data,
|
|
||||||
sharedContext
|
|
||||||
)
|
|
||||||
|
|
||||||
const updateRequest = normalizedInput.map((k) => ({
|
const updateRequest = normalizedInput.map((k) => ({
|
||||||
id: k.id,
|
id: k.id,
|
||||||
title: k.title,
|
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(
|
async revoke(
|
||||||
id: string,
|
id: string,
|
||||||
data: Omit<ApiKeyTypes.RevokeApiKeyDTO, "id">,
|
data: ApiKeyTypes.RevokeApiKeyDTO,
|
||||||
sharedContext?: Context
|
sharedContext?: Context
|
||||||
): Promise<ApiKeyTypes.ApiKeyDTO>
|
): Promise<ApiKeyTypes.ApiKeyDTO>
|
||||||
async revoke(
|
async revoke(
|
||||||
data: ApiKeyTypes.RevokeApiKeyDTO[]
|
selector: FilterableApiKeyProps,
|
||||||
|
data: ApiKeyTypes.RevokeApiKeyDTO,
|
||||||
|
sharedContext?: Context
|
||||||
): Promise<ApiKeyTypes.ApiKeyDTO[]>
|
): Promise<ApiKeyTypes.ApiKeyDTO[]>
|
||||||
@InjectManager("baseRepository_")
|
@InjectManager("baseRepository_")
|
||||||
async revoke(
|
async revoke(
|
||||||
idOrSelectorOrData:
|
idOrSelector: string | FilterableApiKeyProps,
|
||||||
| string
|
data: ApiKeyTypes.RevokeApiKeyDTO,
|
||||||
| FilterableApiKeyProps
|
|
||||||
| ApiKeyTypes.RevokeApiKeyDTO[],
|
|
||||||
data?: Omit<ApiKeyTypes.RevokeApiKeyDTO, "id">,
|
|
||||||
@MedusaContext() sharedContext: Context = {}
|
@MedusaContext() sharedContext: Context = {}
|
||||||
): Promise<ApiKeyTypes.ApiKeyDTO[] | ApiKeyTypes.ApiKeyDTO> {
|
): Promise<ApiKeyTypes.ApiKeyDTO[] | ApiKeyTypes.ApiKeyDTO> {
|
||||||
const revokedApiKeys = await this.revoke_(
|
const normalizedInput = await this.normalizeUpdateInput_<RevokeApiKeyInput>(
|
||||||
idOrSelectorOrData,
|
idOrSelector,
|
||||||
data,
|
data,
|
||||||
sharedContext
|
sharedContext
|
||||||
)
|
)
|
||||||
|
const revokedApiKeys = await this.revoke_(normalizedInput, sharedContext)
|
||||||
|
|
||||||
const serializedResponse = await this.baseRepository_.serialize<
|
const serializedResponse = await this.baseRepository_.serialize<
|
||||||
ApiKeyTypes.ApiKeyDTO[]
|
ApiKeyTypes.ApiKeyDTO[]
|
||||||
@@ -292,34 +343,28 @@ export default class ApiKeyModuleService<TEntity extends ApiKey = ApiKey>
|
|||||||
populate: true,
|
populate: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
return isString(idOrSelectorOrData)
|
return isString(idOrSelector) ? serializedResponse[0] : serializedResponse
|
||||||
? serializedResponse[0]
|
|
||||||
: serializedResponse
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@InjectTransactionManager("baseRepository_")
|
@InjectTransactionManager("baseRepository_")
|
||||||
async revoke_(
|
async revoke_(
|
||||||
idOrSelectorOrData:
|
normalizedInput: RevokeApiKeyInput[],
|
||||||
| string
|
|
||||||
| FilterableApiKeyProps
|
|
||||||
| ApiKeyTypes.RevokeApiKeyDTO[],
|
|
||||||
data?: Omit<ApiKeyTypes.RevokeApiKeyDTO, "id">,
|
|
||||||
@MedusaContext() sharedContext: Context = {}
|
@MedusaContext() sharedContext: Context = {}
|
||||||
): Promise<TEntity[]> {
|
): Promise<TEntity[]> {
|
||||||
const normalizedInput =
|
|
||||||
await this.normalizeUpdateInput_<ApiKeyTypes.RevokeApiKeyDTO>(
|
|
||||||
idOrSelectorOrData,
|
|
||||||
data,
|
|
||||||
sharedContext
|
|
||||||
)
|
|
||||||
|
|
||||||
await this.validateRevokeApiKeys_(normalizedInput)
|
await this.validateRevokeApiKeys_(normalizedInput)
|
||||||
|
|
||||||
const updateRequest = normalizedInput.map((k) => ({
|
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,
|
id: k.id,
|
||||||
revoked_at: new Date(),
|
revoked_at: revokedAt,
|
||||||
revoked_by: k.revoked_by,
|
revoked_by: k.revoked_by,
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const revokedApiKeys = await this.apiKeyService_.update(
|
const revokedApiKeys = await this.apiKeyService_.update(
|
||||||
updateRequest,
|
updateRequest,
|
||||||
@@ -329,13 +374,63 @@ export default class ApiKeyModuleService<TEntity extends ApiKey = ApiKey>
|
|||||||
return revokedApiKeys
|
return revokedApiKeys
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement
|
@InjectManager("baseRepository_")
|
||||||
@InjectTransactionManager("baseRepository_")
|
async authenticate(
|
||||||
authenticate(
|
token: string,
|
||||||
id: string,
|
|
||||||
@MedusaContext() sharedContext: Context = {}
|
@MedusaContext() sharedContext: Context = {}
|
||||||
): Promise<boolean> {
|
): Promise<ApiKeyTypes.ApiKeyDTO | false> {
|
||||||
return Promise.resolve(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_(
|
protected async validateCreateApiKeys_(
|
||||||
@@ -359,9 +454,12 @@ export default class ApiKeyModuleService<TEntity extends ApiKey = ApiKey>
|
|||||||
const dbSecretKeys = await this.apiKeyService_.list(
|
const dbSecretKeys = await this.apiKeyService_.list(
|
||||||
{
|
{
|
||||||
type: ApiKeyType.SECRET,
|
type: ApiKeyType.SECRET,
|
||||||
revoked_at: null,
|
$or: [
|
||||||
|
{ revoked_at: { $eq: null } },
|
||||||
|
{ revoked_at: { $gt: new Date() } },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{},
|
{ take: null },
|
||||||
sharedContext
|
sharedContext
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -374,22 +472,18 @@ export default class ApiKeyModuleService<TEntity extends ApiKey = ApiKey>
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected async normalizeUpdateInput_<T>(
|
protected async normalizeUpdateInput_<T>(
|
||||||
idOrSelectorOrData: string | FilterableApiKeyProps | T[],
|
idOrSelector: string | FilterableApiKeyProps,
|
||||||
data?: Omit<T, "id">,
|
data: Omit<T, "id">,
|
||||||
sharedContext: Context = {}
|
sharedContext: Context = {}
|
||||||
): Promise<T[]> {
|
): Promise<T[]> {
|
||||||
let normalizedInput: T[] = []
|
let normalizedInput: T[] = []
|
||||||
if (isString(idOrSelectorOrData)) {
|
if (isString(idOrSelector)) {
|
||||||
normalizedInput = [{ id: idOrSelectorOrData, ...data } as T]
|
normalizedInput = [{ id: idOrSelector, ...data } as T]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(idOrSelectorOrData)) {
|
if (isObject(idOrSelector)) {
|
||||||
normalizedInput = idOrSelectorOrData
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isObject(idOrSelectorOrData)) {
|
|
||||||
const apiKeys = await this.apiKeyService_.list(
|
const apiKeys = await this.apiKeyService_.list(
|
||||||
idOrSelectorOrData,
|
idOrSelector,
|
||||||
{},
|
{},
|
||||||
sharedContext
|
sharedContext
|
||||||
)
|
)
|
||||||
@@ -407,7 +501,7 @@ export default class ApiKeyModuleService<TEntity extends ApiKey = ApiKey>
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected async validateRevokeApiKeys_(
|
protected async validateRevokeApiKeys_(
|
||||||
data: ApiKeyTypes.RevokeApiKeyDTO[],
|
data: RevokeApiKeyInput[],
|
||||||
sharedContext: Context = {}
|
sharedContext: Context = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!data.length) {
|
if (!data.length) {
|
||||||
@@ -461,7 +555,7 @@ export default class ApiKeyModuleService<TEntity extends ApiKey = ApiKey>
|
|||||||
protected static async generateSecretKey(): Promise<TokenDTO> {
|
protected static async generateSecretKey(): Promise<TokenDTO> {
|
||||||
const token = "sk_" + crypto.randomBytes(32).toString("hex")
|
const token = "sk_" + crypto.randomBytes(32).toString("hex")
|
||||||
const salt = crypto.randomBytes(16).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 {
|
return {
|
||||||
rawToken: token,
|
rawToken: token,
|
||||||
@@ -470,6 +564,13 @@ export default class ApiKeyModuleService<TEntity extends ApiKey = ApiKey>
|
|||||||
redacted: redactKey(token),
|
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.
|
// We are mutating the object here as what microORM relies on non-enumerable fields for serialization, among other things.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ApiKeyType } from "@medusajs/types"
|
import { ApiKeyType, RevokeApiKeyDTO, UpdateApiKeyDTO } from "@medusajs/types"
|
||||||
import { IEventBusModuleService, Logger } from "@medusajs/types"
|
import { IEventBusModuleService, Logger } from "@medusajs/types"
|
||||||
|
|
||||||
export type InitializeModuleInjectableDependencies = {
|
export type InitializeModuleInjectableDependencies = {
|
||||||
@@ -21,3 +21,6 @@ export type TokenDTO = {
|
|||||||
salt: string
|
salt: string
|
||||||
redacted: string
|
redacted: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UpdateApiKeyInput = UpdateApiKeyDTO & { id: string }
|
||||||
|
export type RevokeApiKeyInput = RevokeApiKeyDTO & { id: string }
|
||||||
@@ -4,12 +4,11 @@ import {
|
|||||||
IApiKeyModuleService,
|
IApiKeyModuleService,
|
||||||
RevokeApiKeyDTO,
|
RevokeApiKeyDTO,
|
||||||
} from "@medusajs/types"
|
} from "@medusajs/types"
|
||||||
import { getSelectsAndRelationsFromObjectArray } from "@medusajs/utils"
|
|
||||||
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
|
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
|
||||||
|
|
||||||
type RevokeApiKeysStepInput = {
|
type RevokeApiKeysStepInput = {
|
||||||
selector: FilterableApiKeyProps
|
selector: FilterableApiKeyProps
|
||||||
revoke: Omit<RevokeApiKeyDTO, "id">
|
revoke: RevokeApiKeyDTO
|
||||||
}
|
}
|
||||||
|
|
||||||
export const revokeApiKeysStepId = "revoke-api-keys"
|
export const revokeApiKeysStepId = "revoke-api-keys"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { StepResponse, createStep } from "@medusajs/workflows-sdk"
|
|||||||
|
|
||||||
type UpdateApiKeysStepInput = {
|
type UpdateApiKeysStepInput = {
|
||||||
selector: FilterableApiKeyProps
|
selector: FilterableApiKeyProps
|
||||||
update: Omit<UpdateApiKeyDTO, "id">
|
update: UpdateApiKeyDTO
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateApiKeysStepId = "update-api-keys"
|
export const updateApiKeysStepId = "update-api-keys"
|
||||||
@@ -41,7 +41,7 @@ export const updateApiKeysStep = createStep(
|
|||||||
ModuleRegistrationName.API_KEY
|
ModuleRegistrationName.API_KEY
|
||||||
)
|
)
|
||||||
|
|
||||||
await service.update(
|
await service.upsert(
|
||||||
prevData.map((r) => ({
|
prevData.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
title: r.title,
|
title: r.title,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { revokeApiKeysStep } from "../steps"
|
|||||||
|
|
||||||
type RevokeApiKeysStepInput = {
|
type RevokeApiKeysStepInput = {
|
||||||
selector: FilterableApiKeyProps
|
selector: FilterableApiKeyProps
|
||||||
revoke: Omit<RevokeApiKeyDTO, "id">
|
revoke: RevokeApiKeyDTO
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkflowInput = RevokeApiKeysStepInput
|
type WorkflowInput = RevokeApiKeysStepInput
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { updateApiKeysStep } from "../steps"
|
|||||||
|
|
||||||
type UpdateApiKeysStepInput = {
|
type UpdateApiKeysStepInput = {
|
||||||
selector: FilterableApiKeyProps
|
selector: FilterableApiKeyProps
|
||||||
update: Omit<UpdateApiKeyDTO, "id">
|
update: UpdateApiKeyDTO
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkflowInput = UpdateApiKeysStepInput
|
type WorkflowInput = UpdateApiKeysStepInput
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const POST = async (
|
|||||||
input: {
|
input: {
|
||||||
selector: { id: req.params.id },
|
selector: { id: req.params.id },
|
||||||
revoke: {
|
revoke: {
|
||||||
|
...(req.validatedBody as Omit<RevokeApiKeyDTO, "revoked_by">),
|
||||||
revoked_by: req.auth.actor_id,
|
revoked_by: req.auth.actor_id,
|
||||||
} as RevokeApiKeyDTO,
|
} as RevokeApiKeyDTO,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export const POST = async (
|
|||||||
const { result, errors } = await updateApiKeysWorkflow(req.scope).run({
|
const { result, errors } = await updateApiKeysWorkflow(req.scope).run({
|
||||||
input: {
|
input: {
|
||||||
selector: { id: req.params.id },
|
selector: { id: req.params.id },
|
||||||
update: req.validatedBody,
|
update: req.validatedBody as UpdateApiKeyDTO,
|
||||||
},
|
},
|
||||||
throwOnError: false,
|
throwOnError: false,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Type } from "class-transformer"
|
|||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
@@ -65,6 +66,10 @@ export class AdminPostApiKeysApiKeyReq {
|
|||||||
title: string
|
title: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AdminRevokeApiKeysApiKeyReq {}
|
export class AdminRevokeApiKeysApiKeyReq {
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
revoke_in?: number
|
||||||
|
}
|
||||||
|
|
||||||
export class AdminDeleteApiKeysApiKeyReq {}
|
export class AdminDeleteApiKeysApiKeyReq {}
|
||||||
@@ -7,12 +7,18 @@ export interface CreateApiKeyDTO {
|
|||||||
// We could add revoked_at as a parameter (or expires_at that gets mapped to revoked_at internally) in order to support expiring tokens
|
// 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 UpsertApiKeyDTO {
|
||||||
|
id?: string
|
||||||
|
title?: string
|
||||||
|
type?: ApiKeyType
|
||||||
|
created_by?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface UpdateApiKeyDTO {
|
export interface UpdateApiKeyDTO {
|
||||||
id: string
|
|
||||||
title?: string
|
title?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RevokeApiKeyDTO {
|
export interface RevokeApiKeyDTO {
|
||||||
id: string
|
|
||||||
revoked_by: string
|
revoked_by: string
|
||||||
|
revoke_in?: number // Seconds after which the token should be considered revoked
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,12 @@ import { IModuleService } from "../modules-sdk"
|
|||||||
import { ApiKeyDTO, FilterableApiKeyProps } from "./common"
|
import { ApiKeyDTO, FilterableApiKeyProps } from "./common"
|
||||||
import { FindConfig } from "../common"
|
import { FindConfig } from "../common"
|
||||||
import { Context } from "../shared-context"
|
import { Context } from "../shared-context"
|
||||||
import { CreateApiKeyDTO, RevokeApiKeyDTO, UpdateApiKeyDTO } from "./mutations"
|
import {
|
||||||
|
CreateApiKeyDTO,
|
||||||
|
RevokeApiKeyDTO,
|
||||||
|
UpdateApiKeyDTO,
|
||||||
|
UpsertApiKeyDTO,
|
||||||
|
} from "./mutations"
|
||||||
|
|
||||||
export interface IApiKeyModuleService extends IModuleService {
|
export interface IApiKeyModuleService extends IModuleService {
|
||||||
/**
|
/**
|
||||||
@@ -14,32 +19,57 @@ export interface IApiKeyModuleService extends IModuleService {
|
|||||||
create(data: CreateApiKeyDTO, sharedContext?: Context): Promise<ApiKeyDTO>
|
create(data: CreateApiKeyDTO, sharedContext?: Context): Promise<ApiKeyDTO>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update an api key
|
* This method updates existing API keys, or creates new ones if they don't exist.
|
||||||
* @param selector
|
*
|
||||||
* @param data
|
* @param {UpsertApiKeyDTO[]} data - The attributes to update or create for each API key.
|
||||||
* @param sharedContext
|
* @returns {Promise<ApiKeyDTO[]>} The updated and created API keys.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* {example-code}
|
||||||
*/
|
*/
|
||||||
update(
|
upsert(data: UpsertApiKeyDTO[], sharedContext?: Context): Promise<ApiKeyDTO[]>
|
||||||
selector: FilterableApiKeyProps,
|
|
||||||
data: Omit<UpdateApiKeyDTO, "id">,
|
|
||||||
sharedContext?: Context
|
|
||||||
): Promise<ApiKeyDTO[]>
|
|
||||||
/**
|
/**
|
||||||
* Update an api key
|
* This method updates an existing API key, or creates a new one if it doesn't exist.
|
||||||
* @param id
|
*
|
||||||
* @param data
|
* @param {UpsertApiKeyDTO} data - The attributes to update or create for the API key.
|
||||||
* @param sharedContext
|
* @returns {Promise<ApiKeyDTO>} The updated or created API key.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* {example-code}
|
||||||
|
*/
|
||||||
|
upsert(data: UpsertApiKeyDTO, sharedContext?: Context): Promise<ApiKeyDTO>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method updates an existing API key.
|
||||||
|
*
|
||||||
|
* @param {string} id - The API key's ID.
|
||||||
|
* @param {UpdateApiKeyDTO} data - The details to update in the API key.
|
||||||
|
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
|
||||||
|
* @returns {Promise<ApiKeyDTO>} The updated API key.
|
||||||
*/
|
*/
|
||||||
update(
|
update(
|
||||||
id: string,
|
id: string,
|
||||||
data: Omit<UpdateApiKeyDTO, "id">,
|
data: UpdateApiKeyDTO,
|
||||||
sharedContext?: Context
|
sharedContext?: Context
|
||||||
): Promise<ApiKeyDTO>
|
): Promise<ApiKeyDTO>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update an api key
|
* This method updates existing API keys.
|
||||||
* @param data
|
*
|
||||||
|
* @param {FilterableApiKeyProps} selector - The filters to specify which API keys should be updated.
|
||||||
|
* @param {UpdateApiKeyDTO} data - The details to update in the API keys.
|
||||||
|
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
|
||||||
|
* @returns {Promise<ApiKeyDTO[]>} The updated API keys.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* {example-code}
|
||||||
*/
|
*/
|
||||||
update(data: UpdateApiKeyDTO[]): Promise<ApiKeyDTO[]>
|
update(
|
||||||
|
selector: FilterableApiKeyProps,
|
||||||
|
data: UpdateApiKeyDTO,
|
||||||
|
sharedContext?: Context
|
||||||
|
): Promise<ApiKeyDTO[]>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete an api key
|
* Delete an api key
|
||||||
@@ -93,7 +123,7 @@ export interface IApiKeyModuleService extends IModuleService {
|
|||||||
*/
|
*/
|
||||||
revoke(
|
revoke(
|
||||||
selector: FilterableApiKeyProps,
|
selector: FilterableApiKeyProps,
|
||||||
data: Omit<RevokeApiKeyDTO, "id">,
|
data: RevokeApiKeyDTO,
|
||||||
sharedContext?: Context
|
sharedContext?: Context
|
||||||
): Promise<ApiKeyDTO[]>
|
): Promise<ApiKeyDTO[]>
|
||||||
/**
|
/**
|
||||||
@@ -104,19 +134,17 @@ export interface IApiKeyModuleService extends IModuleService {
|
|||||||
*/
|
*/
|
||||||
revoke(
|
revoke(
|
||||||
id: string,
|
id: string,
|
||||||
data: Omit<RevokeApiKeyDTO, "id">,
|
data: RevokeApiKeyDTO,
|
||||||
sharedContext?: Context
|
sharedContext?: Context
|
||||||
): Promise<ApiKeyDTO>
|
): Promise<ApiKeyDTO>
|
||||||
/**
|
|
||||||
* Revokes an api key
|
|
||||||
* @param data
|
|
||||||
*/
|
|
||||||
revoke(data: RevokeApiKeyDTO[]): Promise<ApiKeyDTO[]>
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check the validity of an api key
|
* Check the validity of an api key
|
||||||
* @param id
|
* @param token
|
||||||
* @param sharedContext
|
* @param sharedContext
|
||||||
*/
|
*/
|
||||||
authenticate(id: string, sharedContext?: Context): Promise<boolean>
|
authenticate(
|
||||||
|
token: string,
|
||||||
|
sharedContext?: Context
|
||||||
|
): Promise<ApiKeyDTO | false>
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user