feat: add Medusa Cloud OAuth provider (#14395)

* feat: add Medusa Cloud OAuth provider

* add Cloud login button

* fetch whether cloud auth is enabled through api

* allow unregistered to get session

* handle existing users

* address PR comments

* prevent double execution

* a few more fixes

* fix callback url

* fix spelling

* refresh session

* 200 instead of 201

* only allow cloud identities to create user

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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