diff --git a/.changeset/humble-sides-matter.md b/.changeset/humble-sides-matter.md new file mode 100644 index 0000000000..11038b6eb2 --- /dev/null +++ b/.changeset/humble-sides-matter.md @@ -0,0 +1,7 @@ +--- +"@medusajs/auth": patch +"@medusajs/types": patch +"@medusajs/utils": patch +--- + +add Medusa Cloud auth provider diff --git a/.eslintrc.js b/.eslintrc.js index 986b8571b0..eeb08c293f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -256,6 +256,7 @@ module.exports = { }, globals: { __BASE__: "readonly", + __AUTH_TYPE__: "readonly", }, env: { browser: true, diff --git a/integration-tests/http/__tests__/cloud/admin/cloud-auth-users.spec.ts b/integration-tests/http/__tests__/cloud/admin/cloud-auth-users.spec.ts new file mode 100644 index 0000000000..52f76a66ba --- /dev/null +++ b/integration-tests/http/__tests__/cloud/admin/cloud-auth-users.spec.ts @@ -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) + }) + }) + }, +}) diff --git a/packages/admin/admin-bundler/src/utils/config.ts b/packages/admin/admin-bundler/src/utils/config.ts index 48ea17a0b9..f92faf9b51 100644 --- a/packages/admin/admin-bundler/src/utils/config.ts +++ b/packages/admin/admin-bundler/src/utils/config.ts @@ -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, diff --git a/packages/admin/dashboard/src/hooks/api/cloud.tsx b/packages/admin/dashboard/src/hooks/api/cloud.tsx new file mode 100644 index 0000000000..841aec5c76 --- /dev/null +++ b/packages/admin/dashboard/src/hooks/api/cloud.tsx @@ -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 +) => { + return useMutation({ + mutationFn: async () => { + await sdk.client.fetch("/cloud/auth/users", { + method: "POST", + }) + }, + ...options, + }) +} diff --git a/packages/admin/dashboard/src/hooks/api/index.ts b/packages/admin/dashboard/src/hooks/api/index.ts index dd8fd63d96..dd087c4391 100644 --- a/packages/admin/dashboard/src/hooks/api/index.ts +++ b/packages/admin/dashboard/src/hooks/api/index.ts @@ -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" diff --git a/packages/admin/dashboard/src/i18n/translations/$schema.json b/packages/admin/dashboard/src/i18n/translations/$schema.json index 30c3a3d5bc..0bf56cd517 100644 --- a/packages/admin/dashboard/src/i18n/translations/$schema.json +++ b/packages/admin/dashboard/src/i18n/translations/$schema.json @@ -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 } diff --git a/packages/admin/dashboard/src/i18n/translations/en.json b/packages/admin/dashboard/src/i18n/translations/en.json index 53ca2e287b..49e5520542 100644 --- a/packages/admin/dashboard/src/i18n/translations/en.json +++ b/packages/admin/dashboard/src/i18n/translations/en.json @@ -3281,5 +3281,11 @@ "cancelText": "Cancel" } } + }, + "auth": { + "login": { + "authenticationFailed": "Authentication failed", + "cloud": "Log in with Medusa Cloud" + } } } diff --git a/packages/admin/dashboard/src/routes/login/components/cloud-auth-login.tsx b/packages/admin/dashboard/src/routes/login/components/cloud-auth-login.tsx new file mode 100644 index 0000000000..d9a01b9db9 --- /dev/null +++ b/packages/admin/dashboard/src/routes/login/components/cloud-auth-login.tsx @@ -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 ( + <> +
+ + + ) +} + +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 + } + + // 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 } +} diff --git a/packages/admin/dashboard/src/routes/login/login.tsx b/packages/admin/dashboard/src/routes/login/login.tsx index 482b8d404b..2a2ea2373a 100644 --- a/packages/admin/dashboard/src/routes/login/login.tsx +++ b/packages/admin/dashboard/src/routes/login/login.tsx @@ -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 (
@@ -150,7 +158,7 @@ export const Login = () => { - {getWidgets("login.after").map((Component, i) => { + {loginAfterWidgets.map((Component, i) => { return })}
diff --git a/packages/core/core-flows/src/auth/workflows/index.ts b/packages/core/core-flows/src/auth/workflows/index.ts index 074b811abd..433627856f 100644 --- a/packages/core/core-flows/src/auth/workflows/index.ts +++ b/packages/core/core-flows/src/auth/workflows/index.ts @@ -1 +1,2 @@ export * from "./generate-reset-password-token" +export * from "./set-auth-app-metadata" diff --git a/packages/core/core-flows/src/auth/workflows/set-auth-app-metadata.ts b/packages/core/core-flows/src/auth/workflows/set-auth-app-metadata.ts new file mode 100644 index 0000000000..22643a17a8 --- /dev/null +++ b/packages/core/core-flows/src/auth/workflows/set-auth-app-metadata.ts @@ -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) + } +) diff --git a/packages/core/framework/src/http/middlewares/authenticate-middleware.ts b/packages/core/framework/src/http/middlewares/authenticate-middleware.ts index 5cab158a2d..b8975d94d0 100644 --- a/packages/core/framework/src/http/middlewares/authenticate-middleware.ts +++ b/packages/core/framework/src/http/middlewares/authenticate-middleware.ts @@ -58,6 +58,7 @@ export const authenticate = ( actor_type: "api-key", auth_identity_id: "", app_metadata: {}, + user_metadata: {}, } return next() diff --git a/packages/core/framework/src/http/types.ts b/packages/core/framework/src/http/types.ts index 3b34fd9161..c9b8f9f5b3 100644 --- a/packages/core/framework/src/http/types.ts +++ b/packages/core/framework/src/http/types.ts @@ -198,6 +198,7 @@ export interface AuthContext { actor_type: string auth_identity_id: string app_metadata: Record + user_metadata: Record } export interface PublishableKeyContext { diff --git a/packages/core/types/src/common/config-module.ts b/packages/core/types/src/common/config-module.ts index 77667ab6b8..1dde30f1e1 100644 --- a/packages/core/types/src/common/config-module.ts +++ b/packages/core/types/src/common/config-module.ts @@ -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 } /** diff --git a/packages/core/utils/src/common/__tests__/define-config.spec.ts b/packages/core/utils/src/common/__tests__/define-config.spec.ts index 3d51e191c8..452d106bfa 100644 --- a/packages/core/utils/src/common/__tests__/define-config.spec.ts +++ b/packages/core/utils/src/common/__tests__/define-config.spec.ts @@ -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", diff --git a/packages/core/utils/src/common/define-config.ts b/packages/core/utils/src/common/define-config.ts index c49a26ca55..9cbff63bfe 100644 --- a/packages/core/utils/src/common/define-config.ts +++ b/packages/core/utils/src/common/define-config.ts @@ -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, - 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 } diff --git a/packages/medusa/src/api/auth/middlewares.ts b/packages/medusa/src/api/auth/middlewares.ts index cc7df3e92f..439f502988 100644 --- a/packages/medusa/src/api/auth/middlewares.ts +++ b/packages/medusa/src/api/auth/middlewares.ts @@ -11,7 +11,7 @@ export const authRoutesMiddlewares: MiddlewareRoute[] = [ { method: ["POST"], matcher: "/auth/session", - middlewares: [authenticate("*", "bearer")], + middlewares: [authenticate("*", "bearer", { allowUnregistered: true })], }, { method: ["DELETE"], diff --git a/packages/medusa/src/api/auth/utils/validate-token.ts b/packages/medusa/src/api/auth/utils/validate-token.ts index 477d4d81cb..4739b714df 100644 --- a/packages/medusa/src/api/auth/utils/validate-token.ts +++ b/packages/medusa/src/api/auth/utils/validate-token.ts @@ -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() diff --git a/packages/medusa/src/api/cloud/auth/route.ts b/packages/medusa/src/api/cloud/auth/route.ts new file mode 100644 index 0000000000..013793dcc7 --- /dev/null +++ b/packages/medusa/src/api/cloud/auth/route.ts @@ -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"), + }) +} diff --git a/packages/medusa/src/api/cloud/auth/users/route.ts b/packages/medusa/src/api/cloud/auth/users/route.ts new file mode 100644 index 0000000000..4daca5b257 --- /dev/null +++ b/packages/medusa/src/api/cloud/auth/users/route.ts @@ -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 }) +} diff --git a/packages/medusa/src/api/cloud/middlewares.ts b/packages/medusa/src/api/cloud/middlewares.ts new file mode 100644 index 0000000000..0291694b9d --- /dev/null +++ b/packages/medusa/src/api/cloud/middlewares.ts @@ -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, + }), + ], + }, +] diff --git a/packages/medusa/src/api/middlewares.ts b/packages/medusa/src/api/middlewares.ts index 35fb554d47..ab621ff228 100644 --- a/packages/medusa/src/api/middlewares.ts +++ b/packages/medusa/src/api/middlewares.ts @@ -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, ]) diff --git a/packages/modules/auth/integration-tests/__tests__/auth-module-service/medusa-cloud-auth.spec.ts b/packages/modules/auth/integration-tests/__tests__/auth-module-service/medusa-cloud-auth.spec.ts new file mode 100644 index 0000000000..f499007606 --- /dev/null +++ b/packages/modules/auth/integration-tests/__tests__/auth-module-service/medusa-cloud-auth.spec.ts @@ -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 = {}) => { + 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({ + 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({ + 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({ + 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" + ), + }) + }) + }), +}) diff --git a/packages/modules/auth/src/loaders/providers.ts b/packages/modules/auth/src/loaders/providers.ts index 522dd064a8..24de90d283 100644 --- a/packages/modules/auth/src/loaders/providers.ts +++ b/packages/modules/auth/src/loaders/providers.ts @@ -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 => { + if (validateCloudOptions(options?.cloud) && !options?.cloud?.disabled) { + await registrationFn(MedusaCloudAuthService, container, { + options: options?.cloud, + id: "cloud", + }) + } + await moduleProviderLoader({ container, providers: options?.providers || [], diff --git a/packages/modules/auth/src/providers/medusa-cloud-auth.ts b/packages/modules/auth/src/providers/medusa-cloud-auth.ts new file mode 100644 index 0000000000..78e9e13e41 --- /dev/null +++ b/packages/modules/auth/src/providers/medusa-cloud-auth.ts @@ -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 { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Medusa Cloud does not support registration. Use method `authenticate` instead." + ) + } + + async authenticate( + req: AuthenticationInput, + authIdentityService: AuthIdentityProviderService + ): Promise { + const query: Record = req.query ?? {} + const body: Record = 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 { + const query: Record = req.query ?? {} + const body: Record = 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 + } +} diff --git a/packages/modules/auth/src/types/index.ts b/packages/modules/auth/src/types/index.ts index 27a7edea6a..b049b3334d 100644 --- a/packages/modules/auth/src/types/index.ts +++ b/packages/modules/auth/src/types/index.ts @@ -30,4 +30,19 @@ export type AuthModuleOptions = Partial & { */ options?: Record }[] + /** + * 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 }