From 753bd93ba18b1ef41b4b0faec065204344ca5454 Mon Sep 17 00:00:00 2001 From: Stevche Radevski Date: Tue, 27 Feb 2024 14:44:37 +0100 Subject: [PATCH] feat(api-key): Add api-key authentication to middleware (#6521) Also did a bit of a cleanup on the auth middleware. There should be no behavioral changes, just moved code around. --- .../__tests__/api-key/admin/api-key.spec.ts | 69 ++++++- .../src/api-v2/admin/regions/middlewares.ts | 2 +- .../src/utils/authenticate-middleware.ts | 187 ++++++++++++++---- 3 files changed, 210 insertions(+), 48 deletions(-) diff --git a/integration-tests/plugins/__tests__/api-key/admin/api-key.spec.ts b/integration-tests/plugins/__tests__/api-key/admin/api-key.spec.ts index ebc9a315cd..f4252e8ba2 100644 --- a/integration-tests/plugins/__tests__/api-key/admin/api-key.spec.ts +++ b/integration-tests/plugins/__tests__/api-key/admin/api-key.spec.ts @@ -1,9 +1,8 @@ import { initDb, useDb } from "../../../../environment-helpers/use-db" import { ApiKeyType } from "@medusajs/utils" -import { IApiKeyModuleService } from "@medusajs/types" +import { IApiKeyModuleService, IRegionModuleService } from "@medusajs/types" import { ModuleRegistrationName } from "@medusajs/modules-sdk" -import adminSeeder from "../../../../helpers/admin-seeder" import { createAdminUser } from "../../../helpers/create-admin-user" import { getContainer } from "../../../../environment-helpers/use-container" import path from "path" @@ -22,6 +21,7 @@ describe("API Keys - Admin", () => { let appContainer let shutdownServer let service: IApiKeyModuleService + let regionService: IRegionModuleService beforeAll(async () => { const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) @@ -29,6 +29,7 @@ describe("API Keys - Admin", () => { shutdownServer = await startBootstrapApp({ cwd, env }) appContainer = getContainer() service = appContainer.resolve(ModuleRegistrationName.API_KEY) + regionService = appContainer.resolve(ModuleRegistrationName.REGION) }) afterAll(async () => { @@ -39,6 +40,9 @@ describe("API Keys - Admin", () => { beforeEach(async () => { await createAdminUser(dbConnection, adminHeaders) + + // Used for testing cross-module authentication checks + await regionService.createDefaultCountriesAndCurrencies() }) afterEach(async () => { @@ -109,7 +113,7 @@ describe("API Keys - Admin", () => { expect(listedApiKeys.data.apiKeys).toHaveLength(0) }) - it.skip("can use a secret api key for authentication", async () => { + it("can use a secret api key for authentication", async () => { const api = useApi() as any const created = await api.post( `/admin/api-keys`, @@ -127,10 +131,67 @@ describe("API Keys - Admin", () => { currency_code: "usd", countries: ["us", "ca"], }, - { headers: { Authorization: `Bearer ${created.token}` } } + { + auth: { + username: created.data.apiKey.token, + }, + } ) expect(createdRegion.status).toEqual(200) expect(createdRegion.data.region.name).toEqual("Test Region") }) + + it("falls back to other mode of authentication when an api key is not valid", async () => { + const api = useApi() as any + const created = await api.post( + `/admin/api-keys`, + { + title: "Test Secret Key", + type: ApiKeyType.SECRET, + }, + adminHeaders + ) + + await api.post( + `/admin/api-keys/${created.data.apiKey.id}/revoke`, + {}, + adminHeaders + ) + + const err = await api + .post( + `/admin/regions`, + { + name: "Test Region", + currency_code: "usd", + countries: ["us", "ca"], + }, + { + auth: { + username: created.data.apiKey.token, + }, + } + ) + .catch((e) => e.message) + + const createdRegion = await api.post( + `/admin/regions`, + { + name: "Test Region", + currency_code: "usd", + countries: ["us", "ca"], + }, + { + auth: { + username: created.data.apiKey.token, + }, + ...adminHeaders, + } + ) + + expect(err).toEqual("Request failed with status code 401") + expect(createdRegion.status).toEqual(200) + expect(createdRegion.data.region.name).toEqual("Test Region") + }) }) diff --git a/packages/medusa/src/api-v2/admin/regions/middlewares.ts b/packages/medusa/src/api-v2/admin/regions/middlewares.ts index 87b7578297..dbd08bcf68 100644 --- a/packages/medusa/src/api-v2/admin/regions/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/regions/middlewares.ts @@ -15,7 +15,7 @@ export const adminRegionRoutesMiddlewares: MiddlewareRoute[] = [ { method: ["ALL"], matcher: "/admin/regions*", - middlewares: [authenticate("admin", ["bearer", "session"])], + middlewares: [authenticate("admin", ["bearer", "session", "api-key"])], }, { method: ["GET"], diff --git a/packages/medusa/src/utils/authenticate-middleware.ts b/packages/medusa/src/utils/authenticate-middleware.ts index 6ac036cfdf..5f068da96e 100644 --- a/packages/medusa/src/utils/authenticate-middleware.ts +++ b/packages/medusa/src/utils/authenticate-middleware.ts @@ -1,4 +1,4 @@ -import { AuthUserDTO, IUserModuleService } from "@medusajs/types" +import { AuthUserDTO } from "@medusajs/types" import { AuthenticatedMedusaRequest, MedusaRequest, @@ -7,19 +7,22 @@ import { import { NextFunction, RequestHandler } from "express" import jwt, { JwtPayload } from "jsonwebtoken" -import { StringChain } from "lodash" import { stringEqualsOrRegexMatch } from "@medusajs/utils" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IApiKeyModuleService } from "@medusajs/types" +import { ApiKeyDTO } from "@medusajs/types" const SESSION_AUTH = "session" const BEARER_AUTH = "bearer" +const API_KEY_AUTH = "api-key" + +type AuthType = typeof SESSION_AUTH | typeof BEARER_AUTH | typeof API_KEY_AUTH type MedusaSession = { auth_user: AuthUserDTO scope: string } -type AuthType = "session" | "bearer" - export const authenticate = ( authScope: string | RegExp, authType: AuthType | AuthType[], @@ -32,50 +35,39 @@ export const authenticate = ( ): Promise => { const authTypes = Array.isArray(authType) ? authType : [authType] - // @ts-ignore - const session: MedusaSession = req.session || {} - - let authUser: AuthUserDTO | null = null - if (authTypes.includes(SESSION_AUTH)) { - if ( - session.auth_user && - stringEqualsOrRegexMatch(authScope, session.auth_user.scope) - ) { - authUser = session.auth_user - } - } - - if (!authUser && authTypes.includes(BEARER_AUTH)) { - const authHeader = req.headers.authorization - - if (authHeader) { - const re = /(\S+)\s+(\S+)/ - const matches = authHeader.match(re) - - // TODO: figure out how to obtain token (and store correct data in token) - if (matches) { - const tokenType = matches[1] - const token = matches[2] - if (tokenType.toLowerCase() === BEARER_AUTH) { - // get config jwt secret - // verify token and set authUser - const { jwt_secret } = - req.scope.resolve("configModule").projectConfig - - const verified = jwt.verify(token, jwt_secret) as JwtPayload - - if (stringEqualsOrRegexMatch(authScope, verified.scope)) { - authUser = verified as AuthUserDTO - } - } + // We only allow authenticating using a secret API key on the admin + if (authTypes.includes(API_KEY_AUTH) && isAdminScope(authScope)) { + const apiKey = await getApiKeyInfo(req) + if (apiKey) { + ;(req as AuthenticatedMedusaRequest).auth = { + actor_id: apiKey.id, + auth_user_id: "", + app_metadata: {}, + // TODO: Add more limited scope once we have support for it in the API key module + scope: "admin", } + + return next() } } - const isMedusaScope = - stringEqualsOrRegexMatch(authScope, "admin") || - stringEqualsOrRegexMatch(authScope, "store") + let authUser: AuthUserDTO | null = getAuthUserFromSession( + req.session, + authTypes, + authScope + ) + if (!authUser) { + const { jwt_secret } = req.scope.resolve("configModule").projectConfig + authUser = getAuthUserFromJwtToken( + req.headers.authorization, + jwt_secret, + authTypes, + authScope + ) + } + + const isMedusaScope = isAdminScope(authScope) || isStoreScope(authScope) const isRegistered = !isMedusaScope || (authUser?.app_metadata?.user_id && @@ -104,6 +96,107 @@ export const authenticate = ( } } +const getApiKeyInfo = async (req: MedusaRequest): Promise => { + const authHeader = req.headers.authorization + if (!authHeader) { + return null + } + + const [tokenType, token] = authHeader.split(" ") + if (tokenType.toLowerCase() !== "basic" || !token) { + return null + } + + // The token could have been base64 encoded, we want to decode it first. + let normalizedToken = token + if (!token.startsWith("sk_")) { + normalizedToken = Buffer.from(token, "base64").toString("utf-8") + } + + // Basic auth is defined as a username:password set, and since the token is set to the username we need to trim the colon + if (normalizedToken.endsWith(":")) { + normalizedToken = normalizedToken.slice(0, -1) + } + + // Secret tokens start with 'sk_', and if it doesn't it could be a user JWT or a malformed token + if (!normalizedToken.startsWith("sk_")) { + return null + } + + const apiKeyModule = req.scope.resolve( + ModuleRegistrationName.API_KEY + ) as IApiKeyModuleService + try { + const apiKey = await apiKeyModule.authenticate(normalizedToken) + if (!apiKey) { + return null + } + + return apiKey + } catch (error) { + console.error(error) + return null + } +} + +const getAuthUserFromSession = ( + session: Partial = {}, + authTypes: AuthType[], + authScope: string | RegExp +): AuthUserDTO | null => { + if (!authTypes.includes(SESSION_AUTH)) { + return null + } + + if ( + session.auth_user && + stringEqualsOrRegexMatch(authScope, session.auth_user.scope) + ) { + return session.auth_user + } + + return null +} + +const getAuthUserFromJwtToken = ( + authHeader: string | undefined, + jwtSecret: string, + authTypes: AuthType[], + authScope: string | RegExp +): AuthUserDTO | null => { + if (!authTypes.includes(BEARER_AUTH)) { + return null + } + + if (!authHeader) { + return null + } + + const re = /(\S+)\s+(\S+)/ + const matches = authHeader.match(re) + + // TODO: figure out how to obtain token (and store correct data in token) + if (matches) { + const tokenType = matches[1] + const token = matches[2] + if (tokenType.toLowerCase() === BEARER_AUTH) { + // get config jwt secret + // verify token and set authUser + try { + const verified = jwt.verify(token, jwtSecret) as JwtPayload + if (stringEqualsOrRegexMatch(authScope, verified.scope)) { + return verified as AuthUserDTO + } + } catch (err) { + console.error(err) + return null + } + } + } + + return null +} + const getActorId = ( authUser: AuthUserDTO, scope: string | RegExp @@ -118,3 +211,11 @@ const getActorId = ( return undefined } + +const isAdminScope = (authScope: string | RegExp): boolean => { + return stringEqualsOrRegexMatch(authScope, "admin") +} + +const isStoreScope = (authScope: string | RegExp): boolean => { + return stringEqualsOrRegexMatch(authScope, "store") +}