Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(api-key): Add api-key authentication to middleware #6521

Merged
merged 2 commits into from
Feb 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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()
}
sradevski marked this conversation as resolved.
Show resolved Hide resolved
}

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")
}
Loading