feat: add Medusa Cloud OAuth provider (#14395)

* feat: add Medusa Cloud OAuth provider

* add Cloud login button

* fetch whether cloud auth is enabled through api

* allow unregistered to get session

* handle existing users

* address PR comments

* prevent double execution

* a few more fixes

* fix callback url

* fix spelling

* refresh session

* 200 instead of 201

* only allow cloud identities to create user

* fix condition
This commit is contained in:
Pedro Guzman
2025-12-30 17:30:10 +01:00
committed by GitHub
parent 499dec6d31
commit 001923da2b
27 changed files with 1327 additions and 23 deletions

View File

@@ -0,0 +1,7 @@
---
"@medusajs/auth": patch
"@medusajs/types": patch
"@medusajs/utils": patch
---
add Medusa Cloud auth provider

View File

@@ -256,6 +256,7 @@ module.exports = {
},
globals: {
__BASE__: "readonly",
__AUTH_TYPE__: "readonly",
},
env: {
browser: true,

View File

@@ -0,0 +1,225 @@
import {
AuthIdentityDTO,
IAuthModuleService,
UserDTO,
} from "@medusajs/framework/types"
import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import jwt from "jsonwebtoken"
import {
adminHeaders,
createAdminUser,
} from "../../../../helpers/create-admin-user"
jest.setTimeout(100000)
medusaIntegrationTestRunner({
testSuite: ({ getContainer, api, dbConnection }) => {
let authModule: IAuthModuleService
let jwtSecret: string
let existingUser: UserDTO
let existingAuthIdentity: AuthIdentityDTO
beforeEach(async () => {
const container = getContainer()
authModule = container.resolve(Modules.AUTH)
const config = container.resolve(ContainerRegistrationKeys.CONFIG_MODULE)
jwtSecret = config.projectConfig.http.jwtSecret!.toString()
const adminUser = await createAdminUser(
dbConnection,
adminHeaders,
getContainer()
)
existingUser = adminUser.user
existingAuthIdentity = adminUser.authIdentity
})
describe("POST /cloud/auth/users", () => {
it("should create a new user when user doesn't exist", async () => {
// Create an auth identity (simulating cloud auth callback)
const authIdentity = await authModule.createAuthIdentities({
provider_identities: [
{
provider: "cloud",
entity_id: "cloud-user-123",
user_metadata: {
email: "john@doe.com",
given_name: "John",
family_name: "Doe",
},
},
],
})
// Generate a token for this auth identity (without actor_id since user doesn't exist yet)
const token = jwt.sign(
{
actor_id: "",
actor_type: "user",
auth_identity_id: authIdentity.id,
user_metadata: {
email: "john@doe.com",
given_name: "John",
family_name: "Doe",
},
},
jwtSecret,
{ expiresIn: "1d" }
)
// Call the endpoint to create the user
const createUserResponse = await api.post(
"/cloud/auth/users",
{},
{ headers: { authorization: `Bearer ${token}` } }
)
expect(createUserResponse.status).toEqual(200)
expect(createUserResponse.data.user).toMatchObject({
id: expect.any(String),
email: "john@doe.com",
first_name: "John",
last_name: "Doe",
})
const createdUserId = createUserResponse.data.user.id
expect(createdUserId).not.toEqual(existingUser.id)
// Refresh the token to get updated actor_id which should be the user's id
const refreshResponse = await api.post(
"/auth/token/refresh",
{},
{ headers: { authorization: `Bearer ${token}` } }
)
expect(refreshResponse.status).toEqual(200)
const refreshedToken = refreshResponse.data.token
expect(jwt.decode(refreshResponse.data.token)).toMatchObject({
actor_type: "user",
auth_identity_id: authIdentity.id,
actor_id: createdUserId,
})
// Verify the user was created
const meResponse = await api.get("/admin/users/me", {
headers: {
authorization: `Bearer ${refreshedToken}`,
},
})
expect(meResponse.status).toEqual(200)
expect(meResponse.data.user).toMatchObject({
id: createdUserId,
email: "john@doe.com",
first_name: "John",
last_name: "Doe",
})
})
it("should link existing user to auth identity when user with same email already exists", async () => {
// Create an auth identity (simulating cloud auth callback)
const authIdentity = await authModule.createAuthIdentities({
provider_identities: [
{
provider: "cloud",
entity_id: "cloud-user-123",
user_metadata: {
email: existingUser.email,
given_name: "John",
family_name: "Doe",
},
},
],
})
// Generate a token for this auth identity (without actor_id since user doesn't exist yet)
const token = jwt.sign(
{
actor_id: "",
actor_type: "user",
auth_identity_id: authIdentity.id,
user_metadata: {
email: existingUser.email,
given_name: "John",
family_name: "Doe",
},
},
jwtSecret,
{ expiresIn: "1d" }
)
// Call the endpoint to create the user
const createUserResponse = await api.post(
"/cloud/auth/users",
{},
{ headers: { authorization: `Bearer ${token}` } }
)
expect(createUserResponse.status).toEqual(200)
expect(createUserResponse.data.user).toMatchObject({
id: existingUser.id,
email: existingUser.email,
})
// Refresh the token to get updated actor_id, which should be the user id
const refreshResponse = await api.post(
"/auth/token/refresh",
{},
{ headers: { authorization: `Bearer ${token}` } }
)
expect(refreshResponse.status).toEqual(200)
expect(jwt.decode(refreshResponse.data.token)).toMatchObject({
actor_type: "user",
auth_identity_id: authIdentity.id,
actor_id: existingUser.id,
})
// Verify the previous auth identity is still linked to the user
const updatedAuthIdentity = await authModule.retrieveAuthIdentity(
existingAuthIdentity.id
)
expect(updatedAuthIdentity.app_metadata?.user_id).toEqual(
existingUser.id
)
})
it("should not allow non-cloud identities to create a user", async () => {
// Create an auth identity
const authIdentity = await authModule.createAuthIdentities({
provider_identities: [
{
provider: "github",
entity_id: "github-user-123",
user_metadata: {
email: "john@doe.com",
given_name: "John",
family_name: "Doe",
},
},
],
})
// Generate a token for this auth identity (without actor_id since user doesn't exist yet)
const token = jwt.sign(
{
actor_id: "",
actor_type: "user",
auth_identity_id: authIdentity.id,
user_metadata: {
email: "john@doe.com",
given_name: "John",
family_name: "Doe",
},
},
jwtSecret,
{ expiresIn: "1d" }
)
// Call the endpoint to create the user
const createUserResponse = await api.post(
"/cloud/auth/users",
{},
{ headers: { authorization: `Bearer ${token}` } }
).catch((error) => error.response)
expect(createUserResponse.status).toEqual(401)
})
})
},
})

View File

@@ -24,7 +24,8 @@ export async function getViteConfig(
const backendUrl = options.backendUrl ?? ""
const storefrontUrl = options.storefrontUrl ?? ""
const authType = process.env.ADMIN_AUTH_TYPE ?? undefined
const jwtTokenStorageKey = process.env.ADMIN_JWT_TOKEN_STORAGE_KEY ?? undefined
const jwtTokenStorageKey =
process.env.ADMIN_JWT_TOKEN_STORAGE_KEY ?? undefined
const baseConfig: InlineConfig = {
root,

View File

@@ -0,0 +1,41 @@
import { FetchError } from "@medusajs/js-sdk"
import {
UseMutationOptions,
UseQueryOptions,
useMutation,
useQuery,
} from "@tanstack/react-query"
import { sdk } from "../../lib/client"
export const cloudQueryKeys = {
all: ["cloud"] as const,
auth: () => [...cloudQueryKeys.all, "auth"] as const,
}
export const useCloudAuthEnabled = (
options?: Omit<
UseQueryOptions<{ enabled: boolean }, FetchError>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: cloudQueryKeys.auth(),
queryFn: async () => {
return await sdk.client.fetch<{ enabled: boolean }>("/cloud/auth")
},
...options,
})
}
export const useCreateCloudAuthUser = (
options?: UseMutationOptions<void, FetchError>
) => {
return useMutation({
mutationFn: async () => {
await sdk.client.fetch("/cloud/auth/users", {
method: "POST",
})
},
...options,
})
}

View File

@@ -2,6 +2,7 @@ export * from "./api-keys"
export * from "./auth"
export * from "./campaigns"
export * from "./categories"
export * from "./cloud"
export * from "./collections"
export * from "./currencies"
export * from "./customer-groups"
@@ -26,8 +27,8 @@ export * from "./refund-reasons"
export * from "./regions"
export * from "./reservations"
export * from "./sales-channels"
export * from "./shipping-options"
export * from "./shipping-option-types"
export * from "./shipping-options"
export * from "./shipping-profiles"
export * from "./stock-locations"
export * from "./store"

View File

@@ -12242,6 +12242,26 @@
"prompts"
],
"additionalProperties": false
},
"auth": {
"type": "object",
"properties": {
"login": {
"type": "object",
"properties": {
"authenticationFailed": {
"type": "string"
},
"cloud": {
"type": "string"
}
},
"required": ["authenticationFailed", "cloud"],
"additionalProperties": false
}
},
"required": ["login"],
"additionalProperties": false
}
},
"required": [
@@ -12299,7 +12319,8 @@
"labels",
"fields",
"dateTime",
"views"
"views",
"auth"
],
"additionalProperties": false
}

View File

@@ -3281,5 +3281,11 @@
"cancelText": "Cancel"
}
}
},
"auth": {
"login": {
"authenticationFailed": "Authentication failed",
"cloud": "Log in with Medusa Cloud"
}
}
}

View File

@@ -0,0 +1,114 @@
import { Button, toast } from "@medusajs/ui"
import { useMutation } from "@tanstack/react-query"
import { useEffect, useRef } from "react"
import { useTranslation } from "react-i18next"
import { decodeToken } from "react-jwt"
import { useNavigate, useSearchParams } from "react-router-dom"
import { useCreateCloudAuthUser } from "../../../hooks/api/cloud"
import { sdk } from "../../../lib/client"
const CLOUD_AUTH_PROVIDER = "cloud"
export const CloudAuthLogin = () => {
const { t } = useTranslation()
const [searchParams] = useSearchParams()
const { handleCallback, isCallbackPending } = useAuthCallback(searchParams)
// Check if we're returning from the OAuth callback
const hasCallbackParams =
searchParams.get("auth_provider") === CLOUD_AUTH_PROVIDER &&
searchParams.has("code") &&
searchParams.has("state")
const callbackInitiated = useRef(false) // ref to prevent duplicate calls in React strict mode and other unmounting+mounting scenarios
useEffect(() => {
if (hasCallbackParams && !callbackInitiated.current) {
callbackInitiated.current = true
handleCallback()
}
}, [hasCallbackParams, handleCallback])
const handleCloudLogin = async () => {
try {
const result = await sdk.auth.login("user", CLOUD_AUTH_PROVIDER, {
// in case the admin is on a different domain, or the backend URL is set to just "/" which won't work for the callback
callback_url: `${window.location.origin}${window.location.pathname}?auth_provider=${CLOUD_AUTH_PROVIDER}`,
})
if (typeof result === "object" && result.location) {
// Redirect to Medusa Cloud for authentication
window.location.href = result.location
return
}
throw new Error("Unexpected login response")
} catch {
toast.error(t("auth.login.authenticationFailed"))
}
}
return (
<>
<hr className="bg-ui-border-base my-4" />
<Button
variant="secondary"
onClick={handleCloudLogin}
className="w-full"
disabled={isCallbackPending}
isLoading={isCallbackPending}
>
{t("auth.login.cloud")}
</Button>
</>
)
}
const useAuthCallback = (searchParams: URLSearchParams) => {
const { t } = useTranslation()
const navigate = useNavigate()
const { mutateAsync: createCloudAuthUser } = useCreateCloudAuthUser()
const { mutateAsync: handleCallback, isPending: isCallbackPending } =
useMutation({
mutationFn: async () => {
let token: string
try {
const query = Object.fromEntries(searchParams)
delete query.auth_provider // BE doesn't need this
token = await sdk.auth.callback("user", CLOUD_AUTH_PROVIDER, query)
} catch (error) {
throw new Error("Authentication callback failed")
}
const decodedToken = decodeToken(token) as {
actor_id: string
user_metadata: Record<string, unknown>
}
// If user doesn't exist, create it
if (!decodedToken?.actor_id) {
await createCloudAuthUser()
// Refresh token to get the updated token with actor_id
const refreshedToken = await sdk.auth.refresh({
Authorization: `Bearer ${token}`, // passing it manually in case the auth type is session
})
if (!refreshedToken) {
throw new Error("Failed to refresh token after user creation")
}
}
return true
},
onSuccess: () => {
navigate("/")
},
onError: () => {
toast.error(t("auth.login.authenticationFailed"))
},
})
return { handleCallback, isCallbackPending }
}

View File

@@ -8,8 +8,10 @@ import * as z from "zod"
import { Form } from "../../components/common/form"
import AvatarBox from "../../components/common/logo-box/avatar-box"
import { useSignInWithEmailPass } from "../../hooks/api"
import { useCloudAuthEnabled } from "../../hooks/api/cloud"
import { isFetchError } from "../../lib/is-fetch-error"
import { useExtension } from "../../providers/extension-provider"
import { CloudAuthLogin } from "./components/cloud-auth-login"
const LoginSchema = z.object({
email: z.string().email(),
@@ -21,6 +23,7 @@ export const Login = () => {
const location = useLocation()
const navigate = useNavigate()
const { getWidgets } = useExtension()
const { data: cloudAuth } = useCloudAuthEnabled()
const from = location.state?.from?.pathname || "/orders"
@@ -70,6 +73,11 @@ export const Login = () => {
form.formState.errors.email?.message ||
form.formState.errors.password?.message
const loginAfterWidgets = [...getWidgets("login.after")] // cloning to avoid mutating the original array below
if (cloudAuth?.enabled) {
loginAfterWidgets.push(CloudAuthLogin)
}
return (
<div className="bg-ui-bg-subtle flex min-h-dvh w-dvw items-center justify-center">
<div className="m-4 flex w-full max-w-[280px] flex-col items-center">
@@ -150,7 +158,7 @@ export const Login = () => {
</Button>
</form>
</Form>
{getWidgets("login.after").map((Component, i) => {
{loginAfterWidgets.map((Component, i) => {
return <Component key={i} />
})}
</div>

View File

@@ -1 +1,2 @@
export * from "./generate-reset-password-token"
export * from "./set-auth-app-metadata"

View File

@@ -0,0 +1,53 @@
import {
createWorkflow,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import {
setAuthAppMetadataStep,
SetAuthAppMetadataStepInput,
} from "../steps/set-auth-app-metadata"
export const setAuthAppMetadataWorkflowId = "set-auth-app-metadata-workflow"
/**
* This workflow sets the `app_metadata` property of an auth identity. This is useful to
* associate a user (whether it's an admin user or customer) with an auth identity
* that allows them to authenticate into Medusa.
*
* You can learn more about auth identites in
* [this documentation](https://docs.medusajs.com/resources/commerce-modules/auth/auth-identity-and-actor-types).
*
* To use this for a custom actor type, check out [this guide](https://docs.medusajs.com/resources/commerce-modules/auth/create-actor-type)
* that explains how to create a custom `manager` actor type and manage its users.
*
* @example
* To associate an auth identity with an actor type (user, customer, or other actor types):
*
* ```ts
* const { result } = await setAuthAppMetadataWorkflow(container).run({
* input: {
* authIdentityId: "au_1234",
* actorType: "user", // or `customer`, or custom type
* value: "user_123"
* }
* })
* ```
*
* To remove the association with an actor type, such as when deleting the user:
*
* ```ts
* const { result } = await setAuthAppMetadataWorkflow(container).run({
* input: {
* authIdentityId: "au_1234",
* actorType: "user", // or `customer`, or custom type
* value: null
* }
* })
* ```
*/
export const setAuthAppMetadataWorkflow = createWorkflow(
setAuthAppMetadataWorkflowId,
(input: SetAuthAppMetadataStepInput) => {
const authIdentity = setAuthAppMetadataStep(input)
return new WorkflowResponse(authIdentity)
}
)

View File

@@ -58,6 +58,7 @@ export const authenticate = (
actor_type: "api-key",
auth_identity_id: "",
app_metadata: {},
user_metadata: {},
}
return next()

View File

@@ -198,6 +198,7 @@ export interface AuthContext {
actor_type: string
auth_identity_id: string
app_metadata: Record<string, unknown>
user_metadata: Record<string, unknown>
}
export interface PublishableKeyContext {

View File

@@ -262,6 +262,22 @@ export type MedusaCloudOptions = {
* The endpoint of the Medusa Cloud email service.
*/
emailsEndpoint?: string
/**
* The authorization endpoint of the Medusa Cloud OAuth service.
*/
oauthAuthorizeEndpoint?: string
/**
* The token endpoint of the Medusa Cloud OAuth token service.
*/
oauthTokenEndpoint?: string
/**
* The callback URL for the Medusa Cloud OAuth service. If not provided, it will be set to `${AdminOptions.backendUrl}/auth/user/cloud/callback`.
*/
oauthCallbackUrl?: string
/**
* Whether the Medusa Cloud OAuth service is disabled.
*/
oauthDisabled?: boolean
}
/**

View File

@@ -2013,18 +2013,23 @@ describe("defineConfig", function () {
it("should add cloud options to the project config and relevant modules if the environment variables are set", function () {
const originalEnv = { ...process.env }
process.env.MEDUSA_BACKEND_URL = "test-backend-url"
process.env.MEDUSA_CLOUD_ENVIRONMENT_HANDLE = "test-environment"
process.env.MEDUSA_CLOUD_API_KEY = "test-api-key"
process.env.MEDUSA_CLOUD_EMAILS_ENDPOINT = "test-emails-endpoint"
process.env.MEDUSA_CLOUD_PAYMENTS_ENDPOINT = "test-payments-endpoint"
process.env.MEDUSA_CLOUD_WEBHOOK_SECRET = "test-webhook-secret"
process.env.MEDUSA_CLOUD_OAUTH_AUTHORIZE_ENDPOINT =
"test-oauth-authorize-endpoint"
process.env.MEDUSA_CLOUD_OAUTH_TOKEN_ENDPOINT = "test-oauth-token-endpoint"
process.env.MEDUSA_CLOUD_OAUTH_DISABLED = "true"
const config = defineConfig()
process.env = { ...originalEnv }
expect(config).toMatchInlineSnapshot(`
{
"admin": {
"backendUrl": "/",
"backendUrl": "test-backend-url",
"path": "/app",
},
"featureFlags": {},
@@ -2035,6 +2040,15 @@ describe("defineConfig", function () {
},
"auth": {
"options": {
"cloud": {
"api_key": "test-api-key",
"callback_url": "test-backend-url/app/login?auth_provider=cloud",
"disabled": true,
"environment_handle": "test-environment",
"oauth_authorize_endpoint": "test-oauth-authorize-endpoint",
"oauth_token_endpoint": "test-oauth-token-endpoint",
"sandbox_handle": undefined,
},
"providers": [
{
"id": "emailpass",
@@ -2176,6 +2190,10 @@ describe("defineConfig", function () {
"apiKey": "test-api-key",
"emailsEndpoint": "test-emails-endpoint",
"environmentHandle": "test-environment",
"oauthAuthorizeEndpoint": "test-oauth-authorize-endpoint",
"oauthCallbackUrl": undefined,
"oauthDisabled": true,
"oauthTokenEndpoint": "test-oauth-token-endpoint",
"paymentsEndpoint": "test-payments-endpoint",
"sandboxHandle": undefined,
"webhookSecret": "test-webhook-secret",
@@ -2205,20 +2223,25 @@ describe("defineConfig", function () {
`)
})
it("should add cloud options to the project config and relevant modules if the environment varianbles is set for a sandbox", function () {
it("should add cloud options to the project config and relevant modules if the environment variable is set for a sandbox", function () {
const originalEnv = { ...process.env }
process.env.MEDUSA_BACKEND_URL = "test-backend-url"
process.env.MEDUSA_CLOUD_SANDBOX_HANDLE = "test-sandbox"
process.env.MEDUSA_CLOUD_API_KEY = "test-api-key"
process.env.MEDUSA_CLOUD_EMAILS_ENDPOINT = "test-emails-endpoint"
process.env.MEDUSA_CLOUD_PAYMENTS_ENDPOINT = "test-payments-endpoint"
process.env.MEDUSA_CLOUD_WEBHOOK_SECRET = "test-webhook-secret"
process.env.MEDUSA_CLOUD_OAUTH_AUTHORIZE_ENDPOINT =
"test-oauth-authorize-endpoint"
process.env.MEDUSA_CLOUD_OAUTH_TOKEN_ENDPOINT = "test-oauth-token-endpoint"
process.env.MEDUSA_CLOUD_OAUTH_DISABLED = "true"
const config = defineConfig()
process.env = { ...originalEnv }
expect(config).toMatchInlineSnapshot(`
{
"admin": {
"backendUrl": "/",
"backendUrl": "test-backend-url",
"path": "/app",
},
"featureFlags": {},
@@ -2229,6 +2252,15 @@ describe("defineConfig", function () {
},
"auth": {
"options": {
"cloud": {
"api_key": "test-api-key",
"callback_url": "test-backend-url/app/login?auth_provider=cloud",
"disabled": true,
"environment_handle": undefined,
"oauth_authorize_endpoint": "test-oauth-authorize-endpoint",
"oauth_token_endpoint": "test-oauth-token-endpoint",
"sandbox_handle": "test-sandbox",
},
"providers": [
{
"id": "emailpass",
@@ -2370,6 +2402,10 @@ describe("defineConfig", function () {
"apiKey": "test-api-key",
"emailsEndpoint": "test-emails-endpoint",
"environmentHandle": undefined,
"oauthAuthorizeEndpoint": "test-oauth-authorize-endpoint",
"oauthCallbackUrl": undefined,
"oauthDisabled": true,
"oauthTokenEndpoint": "test-oauth-token-endpoint",
"paymentsEndpoint": "test-payments-endpoint",
"sandboxHandle": "test-sandbox",
"webhookSecret": "test-webhook-secret",
@@ -2415,6 +2451,9 @@ describe("defineConfig", function () {
webhookSecret: "overriden-webhook-secret",
emailsEndpoint: "overriden-emails-endpoint",
paymentsEndpoint: "overriden-payments-endpoint",
oauthAuthorizeEndpoint: "overriden-oauth-authorize-endpoint",
oauthTokenEndpoint: "overriden-oauth-token-endpoint",
oauthDisabled: true,
},
},
})
@@ -2434,6 +2473,15 @@ describe("defineConfig", function () {
},
"auth": {
"options": {
"cloud": {
"api_key": "overriden-api-key",
"callback_url": "//app/login?auth_provider=cloud",
"disabled": true,
"environment_handle": "overriden-environment",
"oauth_authorize_endpoint": "overriden-oauth-authorize-endpoint",
"oauth_token_endpoint": "overriden-oauth-token-endpoint",
"sandbox_handle": undefined,
},
"providers": [
{
"id": "emailpass",
@@ -2575,6 +2623,10 @@ describe("defineConfig", function () {
"apiKey": "overriden-api-key",
"emailsEndpoint": "overriden-emails-endpoint",
"environmentHandle": "overriden-environment",
"oauthAuthorizeEndpoint": "overriden-oauth-authorize-endpoint",
"oauthCallbackUrl": undefined,
"oauthDisabled": true,
"oauthTokenEndpoint": "overriden-oauth-token-endpoint",
"paymentsEndpoint": "overriden-payments-endpoint",
"sandboxHandle": undefined,
"webhookSecret": "overriden-webhook-secret",

View File

@@ -1,4 +1,5 @@
import {
AdminOptions,
ConfigModule,
InputConfig,
InputConfigModules,
@@ -50,7 +51,7 @@ export function defineConfig(config: InputConfig = {}): ConfigModule {
const projectConfig = normalizeProjectConfig(config.projectConfig, options)
const adminConfig = normalizeAdminConfig(config.admin)
const modules = resolveModules(config.modules, options, config.projectConfig)
applyCloudOptionsToModules(modules, projectConfig?.cloud)
applyCloudOptionsToModules(modules, projectConfig?.cloud, adminConfig)
const plugins = resolvePlugins(config.plugins, options)
return {
@@ -378,6 +379,11 @@ function normalizeProjectConfig(
webhookSecret: process.env.MEDUSA_CLOUD_WEBHOOK_SECRET,
emailsEndpoint: process.env.MEDUSA_CLOUD_EMAILS_ENDPOINT,
paymentsEndpoint: process.env.MEDUSA_CLOUD_PAYMENTS_ENDPOINT,
oauthAuthorizeEndpoint: process.env.MEDUSA_CLOUD_OAUTH_AUTHORIZE_ENDPOINT,
oauthTokenEndpoint: process.env.MEDUSA_CLOUD_OAUTH_TOKEN_ENDPOINT,
oauthCallbackUrl: process.env.MEDUSA_CLOUD_OAUTH_CALLBACK_URL,
oauthDisabled:
process.env.MEDUSA_CLOUD_OAUTH_DISABLED === "true" ? true : undefined,
...cloud,
}
const hasCloudOptions = Object.values(mergedCloudOptions).some(
@@ -449,6 +455,21 @@ function normalizeProjectConfig(
...restOfProjectConfig,
} satisfies ConfigModule["projectConfig"]
if (
isCloud &&
!mergedCloudOptions.oauthDisabled &&
mergedCloudOptions.oauthAuthorizeEndpoint &&
mergedCloudOptions.oauthTokenEndpoint
) {
const userAuthMethods = config.http.authMethodsPerActor?.user ?? [
"emailpass",
]
config.http.authMethodsPerActor = {
...config.http.authMethodsPerActor,
user: userAuthMethods.concat("cloud"),
}
}
return config
}
@@ -468,7 +489,8 @@ function normalizeAdminConfig(
function applyCloudOptionsToModules(
modules: Exclude<ConfigModule["modules"], undefined>,
config?: MedusaCloudOptions
config?: MedusaCloudOptions,
adminConfig?: AdminOptions
) {
if (!config) {
return
@@ -504,6 +526,24 @@ function applyCloudOptionsToModules(
...(module.options ?? {}),
}
break
case Modules.AUTH:
let callbackUrl = config.oauthCallbackUrl
if (!callbackUrl && adminConfig?.backendUrl) {
callbackUrl = `${adminConfig?.backendUrl}${adminConfig?.path}/login?auth_provider=cloud`
}
module.options = {
cloud: {
oauth_authorize_endpoint: config.oauthAuthorizeEndpoint,
oauth_token_endpoint: config.oauthTokenEndpoint,
environment_handle: config.environmentHandle,
sandbox_handle: config.sandboxHandle,
api_key: config.apiKey,
callback_url: callbackUrl,
disabled: config.oauthDisabled,
},
...(module.options ?? {}),
}
break
default:
break
}

View File

@@ -11,7 +11,7 @@ export const authRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["POST"],
matcher: "/auth/session",
middlewares: [authenticate("*", "bearer")],
middlewares: [authenticate("*", "bearer", { allowUnregistered: true })],
},
{
method: ["DELETE"],

View File

@@ -64,7 +64,12 @@ export const validateToken = () => {
provider: auth_provider,
},
{
select: ["provider_metadata", "auth_identity_id", "entity_id"],
select: [
"provider_metadata",
"auth_identity_id",
"entity_id",
"user_metadata",
],
}
)
@@ -77,6 +82,7 @@ export const validateToken = () => {
auth_identity_id: providerIdentity.auth_identity_id!,
actor_id: providerIdentity.entity_id,
app_metadata: {},
user_metadata: providerIdentity.user_metadata ?? {},
}
return next()

View File

@@ -0,0 +1,11 @@
import { MedusaRequest, MedusaResponse } from "@medusajs/framework"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const config = req.scope.resolve(ContainerRegistrationKeys.CONFIG_MODULE)
res.status(200).json({
enabled:
!!config.projectConfig.http.authMethodsPerActor?.user?.includes("cloud"),
})
}

View File

@@ -0,0 +1,103 @@
import {
createUserAccountWorkflow,
setAuthAppMetadataWorkflow,
} from "@medusajs/core-flows"
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http"
import {
ContainerRegistrationKeys,
MedusaError,
} from "@medusajs/framework/utils"
export const POST = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) => {
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
// If user already exists for this auth identity, reject
if (req.auth_context.actor_id) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"The user is already registered and cannot create a new account."
)
}
if (!req.auth_context.user_metadata.email) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Email is required to create a user account."
)
}
// Check that the auth identity is from Medusa Cloud
const providerIdentities = await query
.graph({
entity: "auth_identity",
fields: ["id", "provider_identities.provider"],
filters: {
id: req.auth_context.auth_identity_id,
},
})
.then((result) => result.data[0]?.provider_identities)
if (
providerIdentities?.length !== 1 ||
providerIdentities[0].provider !== "cloud"
) {
throw new MedusaError(
MedusaError.Types.UNAUTHORIZED,
"Only cloud identities can create a user account"
)
}
// Check if a user already exists
const user = await query
.graph({
entity: "user",
fields: ["id"],
filters: {
email: req.auth_context.user_metadata.email,
},
})
.then((result) => result.data[0])
if (user) {
await setAuthAppMetadataWorkflow(req.scope).run({
input: {
authIdentityId: req.auth_context.auth_identity_id,
actorType: "user",
value: user.id,
},
})
const updatedUser = await query
.graph({
entity: "user",
fields: ["*"],
filters: {
id: user.id,
},
})
.then((result) => result.data[0])
res.status(200).json({ user: updatedUser })
return
}
const { result: createdUser } = await createUserAccountWorkflow(
req.scope
).run({
input: {
authIdentityId: req.auth_context.auth_identity_id,
userData: {
email: req.auth_context.user_metadata.email as string,
first_name: req.auth_context.user_metadata.given_name as string,
last_name: req.auth_context.user_metadata.family_name as string,
},
},
})
res.status(200).json({ user: createdUser })
}

View File

@@ -0,0 +1,19 @@
import { authenticate, MiddlewareRoute } from "@medusajs/framework/http"
export const cloudRoutesMiddlewares: MiddlewareRoute[] = [
{
matcher: "/cloud/auth",
method: ["GET"],
middlewares: [],
},
{
matcher: "/cloud/auth/users",
method: ["POST"],
middlewares: [
// Allow users who are authenticated but don't yet have an actor (user record)
authenticate("user", ["session", "bearer"], {
allowUnregistered: true,
}),
],
},
]

View File

@@ -37,16 +37,21 @@ import { adminShippingOptionRoutesMiddlewares } from "./admin/shipping-options/m
import { adminShippingProfilesMiddlewares } from "./admin/shipping-profiles/middlewares"
import { adminStockLocationRoutesMiddlewares } from "./admin/stock-locations/middlewares"
import { adminStoreRoutesMiddlewares } from "./admin/stores/middlewares"
import { adminTaxProviderRoutesMiddlewares } from "./admin/tax-providers/middlewares"
import { adminTaxRateRoutesMiddlewares } from "./admin/tax-rates/middlewares"
import { adminTaxRegionRoutesMiddlewares } from "./admin/tax-regions/middlewares"
import { adminTaxProviderRoutesMiddlewares } from "./admin/tax-providers/middlewares"
import { adminUploadRoutesMiddlewares } from "./admin/uploads/middlewares"
import { adminUserRoutesMiddlewares } from "./admin/users/middlewares"
import { viewConfigurationRoutesMiddlewares } from "./admin/views/[entity]/configurations/middlewares"
import { columnRoutesMiddlewares } from "./admin/views/[entity]/columns/middlewares"
import { viewConfigurationRoutesMiddlewares } from "./admin/views/[entity]/configurations/middlewares"
import { adminWorkflowsExecutionsMiddlewares } from "./admin/workflows-executions/middlewares"
import { authRoutesMiddlewares } from "./auth/middlewares"
import { adminIndexRoutesMiddlewares } from "./admin/index/middlewares"
import { adminLocalesRoutesMiddlewares } from "./admin/locales/middlewares"
import { adminShippingOptionTypeRoutesMiddlewares } from "./admin/shipping-option-types/middlewares"
import { adminTranslationsRoutesMiddlewares } from "./admin/translations/middlewares"
import { cloudRoutesMiddlewares } from "./cloud/middlewares"
import { hooksRoutesMiddlewares } from "./hooks/middlewares"
import { storeCartRoutesMiddlewares } from "./store/carts/middlewares"
import { storeCollectionRoutesMiddlewares } from "./store/collections/middlewares"
@@ -64,10 +69,6 @@ import { storeProductRoutesMiddlewares } from "./store/products/middlewares"
import { storeRegionRoutesMiddlewares } from "./store/regions/middlewares"
import { storeReturnReasonRoutesMiddlewares } from "./store/return-reasons/middlewares"
import { storeShippingOptionRoutesMiddlewares } from "./store/shipping-options/middlewares"
import { adminShippingOptionTypeRoutesMiddlewares } from "./admin/shipping-option-types/middlewares"
import { adminIndexRoutesMiddlewares } from "./admin/index/middlewares"
import { adminLocalesRoutesMiddlewares } from "./admin/locales/middlewares"
import { adminTranslationsRoutesMiddlewares } from "./admin/translations/middlewares"
export default defineMiddlewares([
...storeRoutesMiddlewares,
@@ -140,4 +141,5 @@ export default defineMiddlewares([
...viewConfigurationRoutesMiddlewares,
...columnRoutesMiddlewares,
...adminIndexRoutesMiddlewares,
...cloudRoutesMiddlewares,
])

View File

@@ -0,0 +1,321 @@
import { Modules } from "@medusajs/framework/utils"
import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
import { IAuthModuleService } from "@medusajs/types"
import jwt from "jsonwebtoken"
jest.setTimeout(30000)
const createMockIdToken = (payload: Record<string, any> = {}) => {
return jwt.sign(
{
sub: "user-123",
email: "john@doe.com",
email_verified: true,
name: "John Doe",
given_name: "John",
family_name: "Doe",
...payload,
},
"test-secret"
)
}
const mockTokenFetch = jest.fn()
global.fetch = mockTokenFetch
const mockCache = new Map()
const inMemoryCache = {
get: async (key: string) => mockCache.get(key) ?? null,
set: async (key: string, data: any, ttl?: number) => {
mockCache.set(key, data)
},
invalidate: async (key: string) => {
mockCache.delete(key)
},
clear: async () => {
mockCache.clear()
},
}
moduleIntegrationTestRunner<IAuthModuleService>({
moduleName: Modules.AUTH,
moduleOptions: {
cloud: {
oauth_authorize_endpoint: "https://medusa.cloud/oauth/authorize",
oauth_token_endpoint: "https://medusa.cloud/oauth/token",
api_key: "test-api-key",
callback_url: "https://store.app/oauth/callback",
environment_handle: "test-environment",
},
},
moduleDependencies: [Modules.CACHE],
injectedDependencies: {
[Modules.CACHE]: inMemoryCache,
},
testSuite: ({ service }) =>
describe("Medusa Cloud Auth provider", () => {
afterEach(() => {
mockCache.clear()
mockTokenFetch.mockReset()
})
describe("authenticate", () => {
it("should redirect to authorization URL with default callback URL", async () => {
const response = await service.authenticate("cloud", {
query: {},
body: {},
})
expect(response.success).toBe(true)
expect(response.location).toBeDefined()
const query = new URL(response.location!).searchParams
expect(query.size).toBe(5)
expect(query.get("redirect_uri")).toBe(
"https://store.app/oauth/callback"
)
expect(query.get("client_id")).toBe("test-environment")
expect(query.get("response_type")).toBe("code")
expect(query.get("scope")).toBe("email profile openid")
expect(query.get("state")?.length).toBeGreaterThan(0)
})
it("should redirect to authorization URL with overriden callback URL", async () => {
const response = await service.authenticate("cloud", {
query: {},
body: {
callback_url: "https://overriden-callback.app/oauth/callback",
},
})
expect(response.success).toBe(true)
expect(response.location).toBeDefined()
const query = new URL(response.location!).searchParams
expect(query.size).toBe(5)
expect(query.get("redirect_uri")).toBe(
"https://overriden-callback.app/oauth/callback"
)
expect(query.get("client_id")).toBe("test-environment")
expect(query.get("response_type")).toBe("code")
expect(query.get("scope")).toBe("email profile openid")
expect(query.get("state")?.length).toBeGreaterThan(0)
})
})
describe("validateCallback", () => {
let state: string
beforeEach(async () => {
const response = await service.authenticate("cloud", {
query: {},
body: {},
})
expect(response.success).toBe(true)
expect(response.location).toBeDefined()
const query = new URL(response.location!).searchParams
state = query.get("state")!
mockTokenFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
id_token: createMockIdToken(),
}),
})
})
it("should validate a valid callback", async () => {
const response = await service.validateCallback("cloud", {
query: {
code: "code1",
state: state,
},
})
expect(response).toMatchObject({
success: true,
authIdentity: {
provider_identities: [
{
entity_id: "user-123",
provider: "cloud",
user_metadata: {
email: "john@doe.com",
given_name: "John",
family_name: "Doe",
name: "John Doe",
},
},
],
},
})
expect(mockTokenFetch.mock.calls[0][0]).toBe(
"https://medusa.cloud/oauth/token"
)
expect(mockTokenFetch.mock.calls[0][1].method).toBe("POST")
expect(mockTokenFetch.mock.calls[0][1].headers).toEqual({
"Content-Type": "application/x-www-form-urlencoded",
})
const body = mockTokenFetch.mock.calls[0][1].body as URLSearchParams
expect(body.get("client_id")).toBe("test-environment")
expect(body.get("client_secret")).toBe("test-api-key")
expect(body.get("code")).toBe("code1")
expect(body.get("redirect_uri")).toBe(
"https://store.app/oauth/callback"
)
expect(body.get("grant_type")).toBe("authorization_code")
})
it("should return an error if the code is not provided", async () => {
const response = await service.validateCallback("cloud", {
query: {
state: state,
},
})
expect(response.success).toBe(false)
expect(response.error).toBe("No code provided")
})
it("should return an error if the state is not provided", async () => {
const response = await service.validateCallback("cloud", {
query: {
code: "code1",
},
})
expect(response.success).toBe(false)
expect(response.error).toBe("No state provided, or session expired")
})
it("should return an error if the state doesn't match the stored state", async () => {
const response = await service.validateCallback("cloud", {
query: {
code: "code1",
state: "other-state",
},
})
expect(response.success).toBe(false)
expect(response.error).toBe("No state provided, or session expired")
})
it("should return an error if the token exchange does not return an id_token", async () => {
mockTokenFetch.mockReset()
mockTokenFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({}),
})
const response = await service.validateCallback("cloud", {
query: {
code: "code1",
state: state,
},
})
expect(response.success).toBe(false)
expect(response.error).toBe("No id_token")
})
it("should return an error if the token exchange does not return a valid JWT id_token", async () => {
mockTokenFetch.mockReset()
mockTokenFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ id_token: "invalid-jwt-token" }),
})
const response = await service.validateCallback("cloud", {
query: {
code: "code1",
state: state,
},
})
expect(response.success).toBe(false)
expect(response.error).toBe("The id_token is not a valid JWT")
})
it("should return an error if the email is not verified", async () => {
mockTokenFetch.mockReset()
mockTokenFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
id_token: createMockIdToken({ email_verified: false }),
}),
})
const response = await service.validateCallback("cloud", {
query: {
code: "code1",
state: state,
},
})
expect(response.success).toBe(false)
expect(response.error).toBe(
"Email not verified, cannot proceed with authentication"
)
})
})
}),
})
moduleIntegrationTestRunner<IAuthModuleService>({
moduleName: Modules.AUTH,
moduleOptions: {},
moduleDependencies: [Modules.CACHE],
injectedDependencies: {
[Modules.CACHE]: inMemoryCache,
},
testSuite: ({ service }) =>
describe("Medusa Cloud Auth provider - when cloud options are not provided", () => {
it("should not enable Medusa Cloud Email provider", async () => {
const error = await service
.authenticate("cloud", {
query: {},
body: {},
})
.catch((e) => e)
expect(error).toEqual({
success: false,
error: expect.stringContaining(
"Unable to retrieve the auth provider with id: cloud"
),
})
})
}),
})
moduleIntegrationTestRunner<IAuthModuleService>({
moduleName: Modules.AUTH,
moduleOptions: {
cloud: {
oauth_authorize_endpoint: "https://medusa.cloud/oauth/authorize",
oauth_token_endpoint: "https://medusa.cloud/oauth/token",
api_key: "test-api-key",
callback_url: "https://store.app/oauth/callback",
environment_handle: "test-environment",
disabled: true,
},
},
moduleDependencies: [Modules.CACHE],
injectedDependencies: {
[Modules.CACHE]: inMemoryCache,
},
testSuite: ({ service }) =>
describe("Medusa Cloud Auth provider - when cloud auth is disabled", () => {
it("should not enable Medusa Cloud Email provider", async () => {
const error = await service
.authenticate("cloud", {
query: {},
body: {},
})
.catch((e) => e)
expect(error).toEqual({
success: false,
error: expect.stringContaining(
"Unable to retrieve the auth provider with id: cloud"
),
})
})
}),
})

View File

@@ -1,14 +1,38 @@
import {
LoaderOptions,
ModuleProvider,
ModulesSdkTypes,
} from "@medusajs/framework/types"
import { asFunction, asValue, Lifetime } from "@medusajs/framework/awilix"
import { moduleProviderLoader } from "@medusajs/framework/modules-sdk"
import { LoaderOptions, ModulesSdkTypes } from "@medusajs/framework/types"
import {
AuthIdentifiersRegistrationName,
AuthModuleOptions,
AuthProviderRegistrationPrefix,
} from "@types"
import { MedusaCloudAuthService } from "../providers/medusa-cloud-auth"
const validateCloudOptions = (options: AuthModuleOptions["cloud"]) => {
const {
oauth_authorize_endpoint,
oauth_token_endpoint,
environment_handle,
sandbox_handle,
api_key,
callback_url,
} = options ?? {}
if (!environment_handle && !sandbox_handle) {
return false
}
if (
!oauth_authorize_endpoint ||
!oauth_token_endpoint ||
!api_key ||
!callback_url
) {
return false
}
return true
}
const registrationFn = async (klass, container, pluginOptions) => {
container.register({
@@ -33,8 +57,16 @@ export default async ({
(
| ModulesSdkTypes.ModuleServiceInitializeOptions
| ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
) & { providers: ModuleProvider[] }
) &
AuthModuleOptions
>): Promise<void> => {
if (validateCloudOptions(options?.cloud) && !options?.cloud?.disabled) {
await registrationFn(MedusaCloudAuthService, container, {
options: options?.cloud,
id: "cloud",
})
}
await moduleProviderLoader({
container,
providers: options?.providers || [],

View File

@@ -0,0 +1,206 @@
import {
AuthenticationInput,
AuthenticationResponse,
AuthIdentityProviderService,
Logger,
} from "@medusajs/framework/types"
import {
AbstractAuthModuleProvider,
MedusaError,
} from "@medusajs/framework/utils"
import { MedusaCloudAuthProviderOptions } from "@types"
import crypto from "crypto"
import jwt, { type JwtPayload } from "jsonwebtoken"
type InjectedDependencies = {
logger: Logger
}
export class MedusaCloudAuthService extends AbstractAuthModuleProvider {
static identifier = "cloud"
static DISPLAY_NAME = "Medusa Cloud Authentication"
protected config_: MedusaCloudAuthProviderOptions
protected logger_: Logger
constructor(
{ logger }: InjectedDependencies,
options: MedusaCloudAuthProviderOptions
) {
// @ts-ignore
super(...arguments)
this.config_ = options
this.logger_ = logger
}
async register(_): Promise<AuthenticationResponse> {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Medusa Cloud does not support registration. Use method `authenticate` instead."
)
}
async authenticate(
req: AuthenticationInput,
authIdentityService: AuthIdentityProviderService
): Promise<AuthenticationResponse> {
const query: Record<string, string> = req.query ?? {}
const body: Record<string, string> = req.body ?? {}
if (query.error) {
return {
success: false,
error: `${query.error}`,
}
}
const stateKey = crypto.randomBytes(32).toString("hex")
const state = {
callback_url: body?.callback_url ?? this.config_.callback_url,
}
await authIdentityService.setState(stateKey, state)
return this.getRedirect(this.getClientId(), state.callback_url, stateKey)
}
async validateCallback(
req: AuthenticationInput,
authIdentityService: AuthIdentityProviderService
): Promise<AuthenticationResponse> {
const query: Record<string, string> = req.query ?? {}
const body: Record<string, string> = req.body ?? {}
if (query.error) {
return {
success: false,
error: `${query.error}`,
}
}
const code = query?.code ?? body?.code
if (!code) {
return { success: false, error: "No code provided" }
}
const state = await authIdentityService.getState(query?.state as string)
if (!state) {
return { success: false, error: "No state provided, or session expired" }
}
const clientId = this.getClientId()
try {
const response = await fetch(this.config_.oauth_token_endpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: clientId,
client_secret: this.config_.api_key,
code,
redirect_uri: state.callback_url as string,
grant_type: "authorization_code",
}),
}).then((r) => {
if (!r.ok) {
this.logger_.warn(
`Could not exchange token, ${r.status}, ${
r.statusText
}: response: ${JSON.stringify(r)}`
)
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Could not exchange token, ${r.status}, ${r.statusText}`
)
}
return r.json()
})
const { authIdentity, success, error } = await this.verify_(
response.id_token as string,
authIdentityService
)
return {
success,
authIdentity,
error,
}
} catch (error) {
return { success: false, error: error.message }
}
}
async verify_(
idToken: string | undefined,
authIdentityService: AuthIdentityProviderService
) {
if (!idToken) {
return { success: false, error: "No id_token" }
}
const jwtData = jwt.decode(idToken, {
complete: true,
}) as JwtPayload
if (!jwtData) {
return { success: false, error: "The id_token is not a valid JWT" }
}
const payload = jwtData.payload
if (!payload.email_verified) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Email not verified, cannot proceed with authentication"
)
}
const entity_id = payload.sub
const userMetadata = {
name: payload.name,
email: payload.email,
picture: payload.picture,
given_name: payload.given_name,
family_name: payload.family_name,
}
let authIdentity
try {
authIdentity = await authIdentityService.retrieve({
entity_id,
})
} catch (error) {
if (error.type === MedusaError.Types.NOT_FOUND) {
const createdAuthIdentity = await authIdentityService.create({
entity_id,
user_metadata: userMetadata,
})
authIdentity = createdAuthIdentity
} else {
return { success: false, error: error.message }
}
}
return {
success: true,
authIdentity,
}
}
private getRedirect(clientId: string, callbackUrl: string, stateKey: string) {
const authUrl = new URL(this.config_.oauth_authorize_endpoint)
authUrl.searchParams.set("redirect_uri", callbackUrl)
authUrl.searchParams.set("client_id", clientId)
authUrl.searchParams.set("response_type", "code")
authUrl.searchParams.set("scope", "email profile openid")
authUrl.searchParams.set("state", stateKey)
return { success: true, location: authUrl.toString() }
}
private getClientId() {
return this.config_.environment_handle || this.config_.sandbox_handle
}
}

View File

@@ -30,4 +30,19 @@ export type AuthModuleOptions = Partial<ModuleServiceInitializeOptions> & {
*/
options?: Record<string, unknown>
}[]
/**
* Options for the default Medusa Cloud Auth provider
* @private
*/
cloud?: MedusaCloudAuthProviderOptions
}
export interface MedusaCloudAuthProviderOptions {
oauth_authorize_endpoint: string
oauth_token_endpoint: string
environment_handle: string
sandbox_handle: string
api_key: string
callback_url: string
disabled: boolean
}