Implementa el flujo completo de OAuth social de Medusa para el storefront: boton "Continuar con Google" en account-dialog, ruta proxy que arranca el flujo (GET /api/customer/google/start) y pagina de callback en /auth/google/callback (coincide con GOOGLE_CALLBACK_URL del secret) que resuelve el code/state contra el backend, decodifica el JWT para distinguir cliente nuevo vs existente, crea el customer si hace falta y refresca el token a sesion final -- mismo patron de store/customer-store.ts que login/register por email. Flujo verificado en vivo contra el backend ya desplegado (GET /auth/customer/google, /auth/customer/google/callback con state invalido, /auth/token/refresh con bearer invalido) antes de escribir el codigo, no solo contra la documentacion.
168 lines
6.3 KiB
TypeScript
168 lines
6.3 KiB
TypeScript
// Cuentas de cliente reales conectadas a Medusa (Store API), mismo patron
|
|
// que medusa-cart.ts -- server-side, nunca cacheado (auth es estado
|
|
// mutable por sesion, no catalogo).
|
|
//
|
|
// Flujo verificado en vivo (2026-08-29) antes de escribir este archivo,
|
|
// no asumido contra documentacion: registro (auth/customer/emailpass/
|
|
// register) da un token de registro; ese token crea el customer real
|
|
// (POST /store/customers); pero ese token de registro NO sirve como
|
|
// sesion del customer -- hay que loguear de nuevo para obtener el token
|
|
// de sesion real. Mensajes de error exactos confirmados: registro con
|
|
// correo repetido -> 401 "Identity with email already exists"; login con
|
|
// credenciales invalidas -> 401 "Invalid email or password".
|
|
|
|
export interface CustomerSummary {
|
|
token: string;
|
|
id: string;
|
|
email: string;
|
|
firstName: string | null;
|
|
lastName: string | null;
|
|
}
|
|
|
|
function backendUrl(): string {
|
|
const value = process.env.MEDUSA_BACKEND_URL;
|
|
if (!value) {
|
|
throw new Error("MEDUSA_BACKEND_URL no está configurado.");
|
|
}
|
|
return value.replace(/\/$/, "");
|
|
}
|
|
|
|
async function medusaAuthFetch(
|
|
path: string,
|
|
init?: { method?: string; body?: unknown; token?: string },
|
|
): Promise<Record<string, unknown>> {
|
|
const publishableKey = process.env.MEDUSA_PUBLISHABLE_KEY;
|
|
|
|
const response = await fetch(`${backendUrl()}${path}`, {
|
|
method: init?.method ?? "GET",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(publishableKey ? { "x-publishable-api-key": publishableKey } : {}),
|
|
...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}),
|
|
},
|
|
body: init?.body ? JSON.stringify(init.body) : undefined,
|
|
cache: "no-store",
|
|
});
|
|
|
|
const data = await response.json().catch(() => ({}));
|
|
if (!response.ok) {
|
|
const message = typeof data.message === "string" ? data.message : `Error ${response.status}`;
|
|
throw new Error(message);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
type MedusaCustomer = {
|
|
id: string;
|
|
email: string;
|
|
first_name?: string | null;
|
|
last_name?: string | null;
|
|
};
|
|
|
|
export async function getCurrentCustomer(token: string): Promise<CustomerSummary> {
|
|
const data = await medusaAuthFetch("/store/customers/me", { token });
|
|
const customer = data.customer as MedusaCustomer;
|
|
return {
|
|
token,
|
|
id: customer.id,
|
|
email: customer.email,
|
|
firstName: customer.first_name ?? null,
|
|
lastName: customer.last_name ?? null,
|
|
};
|
|
}
|
|
|
|
export async function loginCustomer(email: string, password: string): Promise<CustomerSummary> {
|
|
let data: Record<string, unknown>;
|
|
try {
|
|
data = await medusaAuthFetch("/auth/customer/emailpass", {
|
|
method: "POST",
|
|
body: { email, password },
|
|
});
|
|
} catch (error) {
|
|
if ((error as Error).message === "Invalid email or password") {
|
|
throw new Error("Correo o contraseña incorrectos.");
|
|
}
|
|
throw error;
|
|
}
|
|
return getCurrentCustomer(data.token as string);
|
|
}
|
|
|
|
// Flujo Google verificado en vivo (2026-08-29) contra el pod real, no
|
|
// solo contra la doc: GET /auth/customer/google funciona igual que el
|
|
// POST documentado (sin body, no hace falta callback_url -- ya viene de
|
|
// GOOGLE_CALLBACK_URL en el modulo); la respuesta no trae Set-Cookie, o
|
|
// sea que el "state" que Google reenvia en el callback es autocontenido
|
|
// (firmado), no depende de sesion de servidor -- confirmado con un
|
|
// callback con code/state inventados: 401 "No state provided, or
|
|
// session expired" (mismo mensaje que con state ausente, no un error de
|
|
// sesion real). auth/token/refresh confirmado que exige Bearer valido
|
|
// (401 "Unauthorized" con uno invalido, no 404).
|
|
function decodeJwtPayload(token: string): Record<string, unknown> {
|
|
const payload = token.split(".")[1] ?? "";
|
|
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
|
|
const json = Buffer.from(base64, "base64").toString("utf-8");
|
|
return JSON.parse(json || "{}");
|
|
}
|
|
|
|
export async function getGoogleAuthUrl(): Promise<string> {
|
|
const data = await medusaAuthFetch("/auth/customer/google");
|
|
return data.location as string;
|
|
}
|
|
|
|
export async function handleGoogleCallback(code: string, state: string): Promise<CustomerSummary> {
|
|
const query = new URLSearchParams({ code, state }).toString();
|
|
const data = await medusaAuthFetch(`/auth/customer/google/callback?${query}`, { method: "POST" });
|
|
const initialToken = data.token as string;
|
|
const payload = decodeJwtPayload(initialToken);
|
|
const actorId = payload.actor_id as string | undefined;
|
|
|
|
if (!actorId) {
|
|
// Primer login con Google -- todavia no existe el customer real.
|
|
const userMetadata = (payload.user_metadata as Record<string, unknown> | undefined) ?? {};
|
|
const email = userMetadata.email as string;
|
|
const firstName = (userMetadata.given_name as string) ?? (userMetadata.name as string) ?? email.split("@")[0];
|
|
const lastName = (userMetadata.family_name as string) ?? "";
|
|
await medusaAuthFetch("/store/customers", {
|
|
method: "POST",
|
|
token: initialToken,
|
|
body: { email, first_name: firstName, last_name: lastName },
|
|
});
|
|
}
|
|
|
|
// El token del callback (o el de recien crear el customer) todavia no
|
|
// sirve como sesion final -- hay que refrescarlo, tanto para usuarios
|
|
// nuevos como para los que ya existian (confirmado contra la doc: los
|
|
// dos casos convergen en el mismo paso de refresh).
|
|
const refreshed = await medusaAuthFetch("/auth/token/refresh", { method: "POST", token: initialToken });
|
|
return getCurrentCustomer(refreshed.token as string);
|
|
}
|
|
|
|
export async function registerCustomer(
|
|
email: string,
|
|
password: string,
|
|
firstName: string,
|
|
lastName: string,
|
|
): Promise<CustomerSummary> {
|
|
let data: Record<string, unknown>;
|
|
try {
|
|
data = await medusaAuthFetch("/auth/customer/emailpass/register", {
|
|
method: "POST",
|
|
body: { email, password },
|
|
});
|
|
} catch (error) {
|
|
if ((error as Error).message === "Identity with email already exists") {
|
|
throw new Error("Ese correo ya está registrado — inicia sesión en vez de crear una cuenta nueva.");
|
|
}
|
|
throw error;
|
|
}
|
|
await medusaAuthFetch("/store/customers", {
|
|
method: "POST",
|
|
token: data.token as string,
|
|
body: { email, first_name: firstName, last_name: lastName },
|
|
});
|
|
// El token de registro crea el customer pero no queda como sesion real
|
|
// -- se loguea de nuevo para obtener el token de sesion (confirmado en
|
|
// vivo, no documentado explicitamente).
|
|
return loginCustomer(email, password);
|
|
}
|