Skip to content

Commit

Permalink
feat(api-key): Add api-key authentication to middleware (#6521)
Browse files Browse the repository at this point in the history
Also did a bit of a cleanup on the auth middleware. There should be no behavioral changes, just moved code around.
  • Loading branch information
sradevski authored Feb 27, 2024
1 parent 3ee0f59 commit 753bd93
Show file tree
Hide file tree
Showing 3 changed files with 209 additions and 47 deletions.
69 changes: 65 additions & 4 deletions integration-tests/plugins/__tests__/api-key/admin/api-key.spec.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -22,13 +21,15 @@ describe("API Keys - Admin", () => {
let appContainer
let shutdownServer
let service: IApiKeyModuleService
let regionService: IRegionModuleService

beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
dbConnection = await initDb({ cwd, env } as any)
shutdownServer = await startBootstrapApp({ cwd, env })
appContainer = getContainer()
service = appContainer.resolve(ModuleRegistrationName.API_KEY)
regionService = appContainer.resolve(ModuleRegistrationName.REGION)
})

afterAll(async () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -109,7 +113,36 @@ 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`,
{
title: "Test Secret Key",
type: ApiKeyType.SECRET,
},
adminHeaders
)

const createdRegion = await api.post(
`/admin/regions`,
{
name: "Test Region",
currency_code: "usd",
countries: ["us", "ca"],
},
{
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`,
Expand All @@ -120,16 +153,44 @@ describe("API Keys - Admin", () => {
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"],
},
{ headers: { Authorization: `Bearer ${created.token}` } }
{
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")
})
Expand Down
2 changes: 1 addition & 1 deletion packages/medusa/src/api-v2/admin/regions/middlewares.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
185 changes: 143 additions & 42 deletions packages/medusa/src/utils/authenticate-middleware.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AuthUserDTO, IUserModuleService } from "@medusajs/types"
import { AuthUserDTO } from "@medusajs/types"
import {
AuthenticatedMedusaRequest,
MedusaRequest,
Expand All @@ -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[],
Expand All @@ -32,50 +35,39 @@ export const authenticate = (
): Promise<void> => {
const authTypes = Array.isArray(authType) ? authType : [authType]

// @ts-ignore
const session: MedusaSession = req.session || {}
// 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",
}

let authUser: AuthUserDTO | null = null
if (authTypes.includes(SESSION_AUTH)) {
if (
session.auth_user &&
stringEqualsOrRegexMatch(authScope, session.auth_user.scope)
) {
authUser = session.auth_user
return next()
}
}

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
}
}
}
}
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 =
stringEqualsOrRegexMatch(authScope, "admin") ||
stringEqualsOrRegexMatch(authScope, "store")

const isMedusaScope = isAdminScope(authScope) || isStoreScope(authScope)
const isRegistered =
!isMedusaScope ||
(authUser?.app_metadata?.user_id &&
Expand Down Expand Up @@ -104,6 +96,107 @@ export const authenticate = (
}
}

const getApiKeyInfo = async (req: MedusaRequest): Promise<ApiKeyDTO | null> => {
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<MedusaSession> = {},
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
Expand All @@ -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")
}

0 comments on commit 753bd93

Please sign in to comment.