- 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.
100 lines
3.1 KiB
TypeScript
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;
|
|
}
|