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:
7
.changeset/humble-sides-matter.md
Normal file
7
.changeset/humble-sides-matter.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@medusajs/auth": patch
|
||||
"@medusajs/types": patch
|
||||
"@medusajs/utils": patch
|
||||
---
|
||||
|
||||
add Medusa Cloud auth provider
|
||||
@@ -256,6 +256,7 @@ module.exports = {
|
||||
},
|
||||
globals: {
|
||||
__BASE__: "readonly",
|
||||
__AUTH_TYPE__: "readonly",
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
41
packages/admin/dashboard/src/hooks/api/cloud.tsx
Normal file
41
packages/admin/dashboard/src/hooks/api/cloud.tsx
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -3281,5 +3281,11 @@
|
||||
"cancelText": "Cancel"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"login": {
|
||||
"authenticationFailed": "Authentication failed",
|
||||
"cloud": "Log in with Medusa Cloud"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./generate-reset-password-token"
|
||||
export * from "./set-auth-app-metadata"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
@@ -58,6 +58,7 @@ export const authenticate = (
|
||||
actor_type: "api-key",
|
||||
auth_identity_id: "",
|
||||
app_metadata: {},
|
||||
user_metadata: {},
|
||||
}
|
||||
|
||||
return next()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export const authRoutesMiddlewares: MiddlewareRoute[] = [
|
||||
{
|
||||
method: ["POST"],
|
||||
matcher: "/auth/session",
|
||||
middlewares: [authenticate("*", "bearer")],
|
||||
middlewares: [authenticate("*", "bearer", { allowUnregistered: true })],
|
||||
},
|
||||
{
|
||||
method: ["DELETE"],
|
||||
|
||||
@@ -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()
|
||||
|
||||
11
packages/medusa/src/api/cloud/auth/route.ts
Normal file
11
packages/medusa/src/api/cloud/auth/route.ts
Normal 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"),
|
||||
})
|
||||
}
|
||||
103
packages/medusa/src/api/cloud/auth/users/route.ts
Normal file
103
packages/medusa/src/api/cloud/auth/users/route.ts
Normal 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 })
|
||||
}
|
||||
19
packages/medusa/src/api/cloud/middlewares.ts
Normal file
19
packages/medusa/src/api/cloud/middlewares.ts
Normal 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,
|
||||
}),
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -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,
|
||||
])
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -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 || [],
|
||||
|
||||
206
packages/modules/auth/src/providers/medusa-cloud-auth.ts
Normal file
206
packages/modules/auth/src/providers/medusa-cloud-auth.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user