winofy-front/src/lib/auth/gooyal.ts
Afra ‌ 6a44887111 feat: auth flow (Gooyal OTP/OAuth2) + customer home page against live staging API
- Session stored as an encrypted (JWE) httpOnly cookie; access/refresh tokens
  never reach the client, client_secret never leaves the server.
- winofyFetch: server-side API client matching FRONTEND_GUIDE.md's plain-success
  / wrapped-error (§4.1-4.2) convention, with auto token refresh.
- Login page (phone -> OTP) wired to Gooyal accounts staging; request_otp
  verified working end-to-end. Token exchange returns invalid_client with the
  client_id/secret currently on hand — needs a fix from whoever issued them.
- proxy.ts (Next 16's renamed middleware) gates seller/cart/checkout/orders
  routes, verified redirecting unauthenticated requests to /login.
- Customer home page renders real store/category data fetched live from
  winofy-staging.winsoo.ir.
2026-08-15 14:56:52 +03:30

100 lines
3.1 KiB
TypeScript

import "server-only";
const ACCOUNTS_BASE_URL = process.env.GOOYAL_ACCOUNTS_BASE_URL!;
const CLIENT_ID = process.env.GOOYAL_CLIENT_ID!;
const CLIENT_SECRET = process.env.GOOYAL_CLIENT_SECRET!;
const SCOPE = process.env.GOOYAL_OAUTH_SCOPE!;
function basicAuthHeader() {
return "Basic " + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64");
}
const ERROR_MESSAGES_FA: Record<string, string> = {
invalid_grant: "کد تایید نامعتبر یا منقضی شده است.",
invalid_client: "خطا در پیکربندی سرویس احراز هویت. لطفاً بعداً تلاش کنید.",
invalid_request: "درخواست نامعتبر است.",
unsupported_grant_type: "خطا در پیکربندی سرویس احراز هویت.",
};
export class GooyalAuthError extends Error {
constructor(
public status: number,
public code: string,
description?: string,
) {
super(ERROR_MESSAGES_FA[code] ?? description ?? code);
}
}
export interface RequestOtpResult {
phone_number: string;
otp_expire: string;
ttl: number;
}
export async function requestOtp(phoneNumber: string): Promise<RequestOtpResult> {
const res = await fetch(`${ACCOUNTS_BASE_URL}/users/api/request_otp/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone_number: phoneNumber }),
cache: "no-store",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new GooyalAuthError(res.status, data.error ?? "request_otp_failed", data.error_description ?? data.detail);
}
return data;
}
export interface GooyalTokenResponse {
access_token: string;
refresh_token?: string;
token_type: string;
expires_in: number;
scope: string;
}
export async function exchangeOtpForToken(phoneNumber: string, otp: string): Promise<GooyalTokenResponse> {
const body = new URLSearchParams({
grant_type: "password",
username: phoneNumber,
password: otp,
scope: SCOPE,
auth_fields: "phone_number:otp",
});
const res = await fetch(`${ACCOUNTS_BASE_URL}/oauth2/token/`, {
method: "POST",
headers: {
Authorization: basicAuthHeader(),
"Content-Type": "application/x-www-form-urlencoded",
},
body: body.toString(),
cache: "no-store",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new GooyalAuthError(res.status, data.error ?? "token_exchange_failed", data.error_description);
}
return data;
}
export async function refreshAccessToken(refreshToken: string): Promise<GooyalTokenResponse> {
const body = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
});
const res = await fetch(`${ACCOUNTS_BASE_URL}/oauth2/token/`, {
method: "POST",
headers: {
Authorization: basicAuthHeader(),
"Content-Type": "application/x-www-form-urlencoded",
},
body: body.toString(),
cache: "no-store",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new GooyalAuthError(res.status, data.error ?? "refresh_failed", data.error_description);
}
return data;
}