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

Since there is quite a bit of code here already, I'll do the middleware changes in a separate PR
This commit is contained in:
Stevche Radevski
2024-02-27 10:37:32 +00:00
committed by GitHub
parent ca463ae9a9
commit 690e8c2e09
13 changed files with 357 additions and 120 deletions
@@ -108,4 +108,29 @@ describe("API Keys - Admin", () => {
expect(deleted.status).toEqual(200)
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 () {
const firstApiKey = await service.create(createSecretKeyFixture)
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", () => {
it("should successfully return all existing api keys", async function () {
await service.create([
@@ -13,7 +13,12 @@ import {
} from "@medusajs/types"
import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config"
import { ApiKey } from "@models"
import { CreateApiKeyDTO, TokenDTO } from "@types"
import {
CreateApiKeyDTO,
RevokeApiKeyInput,
TokenDTO,
UpdateApiKeyInput,
} from "@types"
import {
ApiKeyType,
InjectManager,
@@ -23,6 +28,7 @@ import {
ModulesSdkUtils,
isObject,
isString,
promiseAll,
} from "@medusajs/utils"
const scrypt = util.promisify(crypto.scrypt)
@@ -132,61 +138,111 @@ export default class ApiKeyModuleService<TEntity extends ApiKey = ApiKey>
return [createdApiKeys, generatedTokens]
}
async update(
selector: FilterableApiKeyProps,
data: Omit<ApiKeyTypes.UpdateApiKeyDTO, "id">,
async upsert(
data: ApiKeyTypes.UpsertApiKeyDTO[],
sharedContext?: Context
): Promise<ApiKeyTypes.ApiKeyDTO[]>
async upsert(
data: ApiKeyTypes.UpsertApiKeyDTO,
sharedContext?: Context
): Promise<ApiKeyTypes.ApiKeyDTO>
@InjectManager("baseRepository_")
async upsert(
data: ApiKeyTypes.UpsertApiKeyDTO | ApiKeyTypes.UpsertApiKeyDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<ApiKeyTypes.ApiKeyDTO | ApiKeyTypes.ApiKeyDTO[]> {
const input = Array.isArray(data) ? data : [data]
const forUpdate = input.filter(
(apiKey): apiKey is UpdateApiKeyInput => !!apiKey.id
)
const forCreate = input.filter(
(apiKey): apiKey is ApiKeyTypes.CreateApiKeyDTO => !apiKey.id
)
const operations: Promise<ApiKeyTypes.ApiKeyDTO[]>[] = []
if (forCreate.length) {
const op = async () => {
const [createdApiKeys, generatedTokens] = await this.create_(
forCreate,
sharedContext
)
const serializedResponse = await this.baseRepository_.serialize<
ApiKeyTypes.ApiKeyDTO[]
>(createdApiKeys, {
populate: true,
})
return serializedResponse.map(
(key) =>
({
...key,
token:
generatedTokens.find((t) => t.hashedToken === key.token)
?.rawToken ?? key.token,
salt: undefined,
} as ApiKeyTypes.ApiKeyDTO)
)
}
operations.push(op())
}
if (forUpdate.length) {
const op = async () => {
const updateResp = await this.update_(forUpdate, sharedContext)
return await this.baseRepository_.serialize<ApiKeyTypes.ApiKeyDTO[]>(
updateResp
)
}
operations.push(op())
}
const result = (await promiseAll(operations)).flat()
return Array.isArray(data) ? result : result[0]
}
async update(
id: string,
data: Omit<ApiKeyTypes.UpdateApiKeyDTO, "id">,
data: ApiKeyTypes.UpdateApiKeyDTO,
sharedContext?: Context
): Promise<ApiKeyTypes.ApiKeyDTO>
async update(
data: ApiKeyTypes.UpdateApiKeyDTO[]
selector: FilterableApiKeyProps,
data: ApiKeyTypes.UpdateApiKeyDTO,
sharedContext?: Context
): Promise<ApiKeyTypes.ApiKeyDTO[]>
@InjectManager("baseRepository_")
async update(
idOrSelectorOrData:
| string
| FilterableApiKeyProps
| ApiKeyTypes.UpdateApiKeyDTO[],
data?: Omit<ApiKeyTypes.UpdateApiKeyDTO, "id">,
idOrSelector: string | FilterableApiKeyProps,
data: ApiKeyTypes.UpdateApiKeyDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<ApiKeyTypes.ApiKeyDTO[] | ApiKeyTypes.ApiKeyDTO> {
const updatedApiKeys = await this.update_(
idOrSelectorOrData,
let normalizedInput = await this.normalizeUpdateInput_<UpdateApiKeyInput>(
idOrSelector,
data,
sharedContext
)
const updatedApiKeys = await this.update_(normalizedInput, sharedContext)
const serializedResponse = await this.baseRepository_.serialize<
ApiKeyTypes.ApiKeyDTO[]
>(updatedApiKeys.map(omitToken), {
populate: true,
})
return isString(idOrSelectorOrData)
? serializedResponse[0]
: serializedResponse
return isString(idOrSelector) ? serializedResponse[0] : serializedResponse
}
@InjectTransactionManager("baseRepository_")
protected async update_(
idOrSelectorOrData:
| string
| FilterableApiKeyProps
| ApiKeyTypes.UpdateApiKeyDTO[],
data?: Omit<ApiKeyTypes.UpdateApiKeyDTO, "id">,
normalizedInput: UpdateApiKeyInput[],
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
const normalizedInput =
await this.normalizeUpdateInput_<ApiKeyTypes.UpdateApiKeyDTO>(
idOrSelectorOrData,
data,
sharedContext
)
const updateRequest = normalizedInput.map((k) => ({
id: k.id,
title: k.title,
@@ -258,33 +314,28 @@ export default class ApiKeyModuleService<TEntity extends ApiKey = ApiKey>
]
}
async revoke(
selector: FilterableApiKeyProps,
data: Omit<ApiKeyTypes.RevokeApiKeyDTO, "id">,
sharedContext?: Context
): Promise<ApiKeyTypes.ApiKeyDTO[]>
async revoke(
id: string,
data: Omit<ApiKeyTypes.RevokeApiKeyDTO, "id">,
data: ApiKeyTypes.RevokeApiKeyDTO,
sharedContext?: Context
): Promise<ApiKeyTypes.ApiKeyDTO>
async revoke(
data: ApiKeyTypes.RevokeApiKeyDTO[]
selector: FilterableApiKeyProps,
data: ApiKeyTypes.RevokeApiKeyDTO,
sharedContext?: Context
): Promise<ApiKeyTypes.ApiKeyDTO[]>
@InjectManager("baseRepository_")
async revoke(
idOrSelectorOrData:
| string
| FilterableApiKeyProps
| ApiKeyTypes.RevokeApiKeyDTO[],
data?: Omit<ApiKeyTypes.RevokeApiKeyDTO, "id">,
idOrSelector: string | FilterableApiKeyProps,
data: ApiKeyTypes.RevokeApiKeyDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<ApiKeyTypes.ApiKeyDTO[] | ApiKeyTypes.ApiKeyDTO> {
const revokedApiKeys = await this.revoke_(
idOrSelectorOrData,
const normalizedInput = await this.normalizeUpdateInput_<RevokeApiKeyInput>(
idOrSelector,
data,
sharedContext
)
const revokedApiKeys = await this.revoke_(normalizedInput, sharedContext)
const serializedResponse = await this.baseRepository_.serialize<
ApiKeyTypes.ApiKeyDTO[]
@@ -292,34 +343,28 @@ export default class ApiKeyModuleService<TEntity extends ApiKey = ApiKey>
populate: true,
})
return isString(idOrSelectorOrData)
? serializedResponse[0]
: serializedResponse
return isString(idOrSelector) ? serializedResponse[0] : serializedResponse
}
@InjectTransactionManager("baseRepository_")
async revoke_(
idOrSelectorOrData:
| string
| FilterableApiKeyProps
| ApiKeyTypes.RevokeApiKeyDTO[],
data?: Omit<ApiKeyTypes.RevokeApiKeyDTO, "id">,
normalizedInput: RevokeApiKeyInput[],
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
const normalizedInput =
await this.normalizeUpdateInput_<ApiKeyTypes.RevokeApiKeyDTO>(
idOrSelectorOrData,
data,
sharedContext
)
await this.validateRevokeApiKeys_(normalizedInput)
const updateRequest = normalizedInput.map((k) => ({
id: k.id,
revoked_at: new Date(),
revoked_by: k.revoked_by,
}))
const updateRequest = normalizedInput.map((k) => {
const revokedAt = new Date()
if (k.revoke_in && k.revoke_in > 0) {
revokedAt.setSeconds(revokedAt.getSeconds() + k.revoke_in)
}
return {
id: k.id,
revoked_at: revokedAt,
revoked_by: k.revoked_by,
}
})
const revokedApiKeys = await this.apiKeyService_.update(
updateRequest,
@@ -329,13 +374,63 @@ export default class ApiKeyModuleService<TEntity extends ApiKey = ApiKey>
return revokedApiKeys
}
// TODO: Implement
@InjectTransactionManager("baseRepository_")
authenticate(
id: string,
@InjectManager("baseRepository_")
async authenticate(
token: string,
@MedusaContext() sharedContext: Context = {}
): Promise<boolean> {
return Promise.resolve(false)
): Promise<ApiKeyTypes.ApiKeyDTO | false> {
const result = await this.authenticate_(token, sharedContext)
if (!result) {
return false
}
const serialized =
await this.baseRepository_.serialize<ApiKeyTypes.ApiKeyDTO>(result, {
populate: true,
})
return serialized
}
@InjectTransactionManager("baseRepository_")
protected async authenticate_(
token: string,
@MedusaContext() sharedContext: Context = {}
): Promise<ApiKey | false> {
// Since we only allow up to 2 active tokens, getitng the list and checking each token isn't an issue.
// We can always filter on the redacted key if we add support for an arbitrary number of tokens.
const secretKeys = await this.apiKeyService_.list(
{
type: ApiKeyType.SECRET,
// If the revoke date is set in the future, it means the key is still valid.
$or: [
{ revoked_at: { $eq: null } },
{ revoked_at: { $gt: new Date() } },
],
},
{ take: null },
sharedContext
)
const matches = await promiseAll(
secretKeys.map(async (dbKey) => {
const hashedInput = await ApiKeyModuleService.calculateHash(
token,
dbKey.salt
)
if (hashedInput === dbKey.token) {
return dbKey
}
return undefined
})
)
const matchedKeys = matches.filter((match) => !!match)
if (!matchedKeys.length) {
return false
}
return matchedKeys[0]!
}
protected async validateCreateApiKeys_(
@@ -359,9 +454,12 @@ export default class ApiKeyModuleService<TEntity extends ApiKey = ApiKey>
const dbSecretKeys = await this.apiKeyService_.list(
{
type: ApiKeyType.SECRET,
revoked_at: null,
$or: [
{ revoked_at: { $eq: null } },
{ revoked_at: { $gt: new Date() } },
],
},
{},
{ take: null },
sharedContext
)
@@ -374,22 +472,18 @@ export default class ApiKeyModuleService<TEntity extends ApiKey = ApiKey>
}
protected async normalizeUpdateInput_<T>(
idOrSelectorOrData: string | FilterableApiKeyProps | T[],
data?: Omit<T, "id">,
idOrSelector: string | FilterableApiKeyProps,
data: Omit<T, "id">,
sharedContext: Context = {}
): Promise<T[]> {
let normalizedInput: T[] = []
if (isString(idOrSelectorOrData)) {
normalizedInput = [{ id: idOrSelectorOrData, ...data } as T]
if (isString(idOrSelector)) {
normalizedInput = [{ id: idOrSelector, ...data } as T]
}
if (Array.isArray(idOrSelectorOrData)) {
normalizedInput = idOrSelectorOrData
}
if (isObject(idOrSelectorOrData)) {
if (isObject(idOrSelector)) {
const apiKeys = await this.apiKeyService_.list(
idOrSelectorOrData,
idOrSelector,
{},
sharedContext
)
@@ -407,7 +501,7 @@ export default class ApiKeyModuleService<TEntity extends ApiKey = ApiKey>
}
protected async validateRevokeApiKeys_(
data: ApiKeyTypes.RevokeApiKeyDTO[],
data: RevokeApiKeyInput[],
sharedContext: Context = {}
): Promise<void> {
if (!data.length) {
@@ -461,7 +555,7 @@ export default class ApiKeyModuleService<TEntity extends ApiKey = ApiKey>
protected static async generateSecretKey(): Promise<TokenDTO> {
const token = "sk_" + crypto.randomBytes(32).toString("hex")
const salt = crypto.randomBytes(16).toString("hex")
const hashed = ((await scrypt(token, salt, 64)) as Buffer).toString("hex")
const hashed = await this.calculateHash(token, salt)
return {
rawToken: token,
@@ -470,6 +564,13 @@ export default class ApiKeyModuleService<TEntity extends ApiKey = ApiKey>
redacted: redactKey(token),
}
}
protected static async calculateHash(
token: string,
salt: string
): Promise<string> {
return ((await scrypt(token, salt, 64)) as Buffer).toString("hex")
}
}
// We are mutating the object here as what microORM relies on non-enumerable fields for serialization, among other things.
+4 -1
View File
@@ -1,4 +1,4 @@
import { ApiKeyType } from "@medusajs/types"
import { ApiKeyType, RevokeApiKeyDTO, UpdateApiKeyDTO } from "@medusajs/types"
import { IEventBusModuleService, Logger } from "@medusajs/types"
export type InitializeModuleInjectableDependencies = {
@@ -21,3 +21,6 @@ export type TokenDTO = {
salt: string
redacted: string
}
export type UpdateApiKeyInput = UpdateApiKeyDTO & { id: string }
export type RevokeApiKeyInput = RevokeApiKeyDTO & { id: string }
@@ -4,12 +4,11 @@ import {
IApiKeyModuleService,
RevokeApiKeyDTO,
} from "@medusajs/types"
import { getSelectsAndRelationsFromObjectArray } from "@medusajs/utils"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
type RevokeApiKeysStepInput = {
selector: FilterableApiKeyProps
revoke: Omit<RevokeApiKeyDTO, "id">
revoke: RevokeApiKeyDTO
}
export const revokeApiKeysStepId = "revoke-api-keys"
@@ -9,7 +9,7 @@ import { StepResponse, createStep } from "@medusajs/workflows-sdk"
type UpdateApiKeysStepInput = {
selector: FilterableApiKeyProps
update: Omit<UpdateApiKeyDTO, "id">
update: UpdateApiKeyDTO
}
export const updateApiKeysStepId = "update-api-keys"
@@ -41,7 +41,7 @@ export const updateApiKeysStep = createStep(
ModuleRegistrationName.API_KEY
)
await service.update(
await service.upsert(
prevData.map((r) => ({
id: r.id,
title: r.title,
@@ -8,7 +8,7 @@ import { revokeApiKeysStep } from "../steps"
type RevokeApiKeysStepInput = {
selector: FilterableApiKeyProps
revoke: Omit<RevokeApiKeyDTO, "id">
revoke: RevokeApiKeyDTO
}
type WorkflowInput = RevokeApiKeysStepInput
@@ -8,7 +8,7 @@ import { updateApiKeysStep } from "../steps"
type UpdateApiKeysStepInput = {
selector: FilterableApiKeyProps
update: Omit<UpdateApiKeyDTO, "id">
update: UpdateApiKeyDTO
}
type WorkflowInput = UpdateApiKeysStepInput
@@ -16,6 +16,7 @@ export const POST = async (
input: {
selector: { id: req.params.id },
revoke: {
...(req.validatedBody as Omit<RevokeApiKeyDTO, "revoked_by">),
revoked_by: req.auth.actor_id,
} as RevokeApiKeyDTO,
},
@@ -37,7 +37,7 @@ export const POST = async (
const { result, errors } = await updateApiKeysWorkflow(req.scope).run({
input: {
selector: { id: req.params.id },
update: req.validatedBody,
update: req.validatedBody as UpdateApiKeyDTO,
},
throwOnError: false,
})
@@ -3,6 +3,7 @@ import { Type } from "class-transformer"
import {
IsArray,
IsEnum,
IsNumber,
IsOptional,
IsString,
ValidateNested,
@@ -65,6 +66,10 @@ export class AdminPostApiKeysApiKeyReq {
title: string
}
export class AdminRevokeApiKeysApiKeyReq {}
export class AdminRevokeApiKeysApiKeyReq {
@IsOptional()
@IsNumber()
revoke_in?: number
}
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
}
export interface UpsertApiKeyDTO {
id?: string
title?: string
type?: ApiKeyType
created_by?: string
}
export interface UpdateApiKeyDTO {
id: string
title?: string
}
export interface RevokeApiKeyDTO {
id: string
revoked_by: string
revoke_in?: number // Seconds after which the token should be considered revoked
}
+55 -27
View File
@@ -2,7 +2,12 @@ import { IModuleService } from "../modules-sdk"
import { ApiKeyDTO, FilterableApiKeyProps } from "./common"
import { FindConfig } from "../common"
import { Context } from "../shared-context"
import { CreateApiKeyDTO, RevokeApiKeyDTO, UpdateApiKeyDTO } from "./mutations"
import {
CreateApiKeyDTO,
RevokeApiKeyDTO,
UpdateApiKeyDTO,
UpsertApiKeyDTO,
} from "./mutations"
export interface IApiKeyModuleService extends IModuleService {
/**
@@ -14,32 +19,57 @@ export interface IApiKeyModuleService extends IModuleService {
create(data: CreateApiKeyDTO, sharedContext?: Context): Promise<ApiKeyDTO>
/**
* Update an api key
* @param selector
* @param data
* @param sharedContext
* This method updates existing API keys, or creates new ones if they don't exist.
*
* @param {UpsertApiKeyDTO[]} data - The attributes to update or create for each API key.
* @returns {Promise<ApiKeyDTO[]>} The updated and created API keys.
*
* @example
* {example-code}
*/
update(
selector: FilterableApiKeyProps,
data: Omit<UpdateApiKeyDTO, "id">,
sharedContext?: Context
): Promise<ApiKeyDTO[]>
upsert(data: UpsertApiKeyDTO[], sharedContext?: Context): Promise<ApiKeyDTO[]>
/**
* Update an api key
* @param id
* @param data
* @param sharedContext
* This method updates an existing API key, or creates a new one if it doesn't exist.
*
* @param {UpsertApiKeyDTO} data - The attributes to update or create for the API key.
* @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(
id: string,
data: Omit<UpdateApiKeyDTO, "id">,
data: UpdateApiKeyDTO,
sharedContext?: Context
): Promise<ApiKeyDTO>
/**
* Update an api key
* @param data
* This method updates existing API keys.
*
* @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
@@ -93,7 +123,7 @@ export interface IApiKeyModuleService extends IModuleService {
*/
revoke(
selector: FilterableApiKeyProps,
data: Omit<RevokeApiKeyDTO, "id">,
data: RevokeApiKeyDTO,
sharedContext?: Context
): Promise<ApiKeyDTO[]>
/**
@@ -104,19 +134,17 @@ export interface IApiKeyModuleService extends IModuleService {
*/
revoke(
id: string,
data: Omit<RevokeApiKeyDTO, "id">,
data: RevokeApiKeyDTO,
sharedContext?: Context
): Promise<ApiKeyDTO>
/**
* Revokes an api key
* @param data
*/
revoke(data: RevokeApiKeyDTO[]): Promise<ApiKeyDTO[]>
/**
* Check the validity of an api key
* @param id
* @param token
* @param sharedContext
*/
authenticate(id: string, sharedContext?: Context): Promise<boolean>
authenticate(
token: string,
sharedContext?: Context
): Promise<ApiKeyDTO | false>
}