TorbatYar/frontend/lib/auth.ts
Mortezakoohjani 800b0ba2c5 Deploy TorbatYar for torbatyar.ir with nginx multi-tenant routing.
Wire production domain, CORS for tenant subdomains, celery volume mounts, and nginx reverse proxy configs for apex, API, identity, auth, and wildcard tenants.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 21:43:33 +03:30

171 lines
5.4 KiB
TypeScript

/**
* مدیریت SSO — ارتباط با Identity Service و Keycloak.
* PKCE کاملاً سمت سرور (Identity Service) مدیریت می‌شود تا به origin/مرورگر
* وابسته نباشد. توکن‌ها فقط در sessionStorage نگهداری می‌شوند.
*/
const IDENTITY_API =
process.env.NEXT_PUBLIC_IDENTITY_API_URL || "http://localhost:8001";
const TOKEN_KEY = "superapp_access_token";
const REFRESH_KEY = "superapp_refresh_token";
export interface AuthConfig {
issuer: string;
client_id: string;
authorization_endpoint: string;
token_endpoint: string;
logout_endpoint: string;
callback_url: string;
}
export interface LoginUrlResponse {
authorization_url: string;
state: string;
}
export interface TokenBundle {
access_token: string;
refresh_token?: string;
expires_in: number;
token_type: string;
}
export function getStoredToken(): string | null {
if (typeof window === "undefined") return null;
return sessionStorage.getItem(TOKEN_KEY);
}
export function storeTokens(tokens: TokenBundle): void {
sessionStorage.setItem(TOKEN_KEY, tokens.access_token);
if (tokens.refresh_token) {
sessionStorage.setItem(REFRESH_KEY, tokens.refresh_token);
}
}
export function clearTokens(): void {
sessionStorage.removeItem(TOKEN_KEY);
sessionStorage.removeItem(REFRESH_KEY);
}
export async function fetchAuthConfig(): Promise<AuthConfig> {
let res: Response;
try {
res = await fetch(`${IDENTITY_API}/api/v1/auth/config`);
} catch {
throw new Error(
`اتصال به سرویس احراز هویت برقرار نشد (${IDENTITY_API}). مطمئن شوید identity-access-service در حال اجراست.`
);
}
if (!res.ok) throw new Error("دریافت پیکربندی SSO ناموفق بود");
return res.json();
}
const POST_LOGIN_REDIRECT_KEY = "superapp_post_login_redirect";
export function setPostLoginRedirect(path: string): void {
if (typeof window === "undefined") return;
sessionStorage.setItem(POST_LOGIN_REDIRECT_KEY, path);
}
export function consumePostLoginRedirect(fallback = "/dashboard"): string {
if (typeof window === "undefined") return fallback;
const path = sessionStorage.getItem(POST_LOGIN_REDIRECT_KEY) || fallback;
sessionStorage.removeItem(POST_LOGIN_REDIRECT_KEY);
return path;
}
export async function loginWithRedirect(redirectPath?: string): Promise<void> {
if (redirectPath) setPostLoginRedirect(redirectPath);
const qs = redirectPath ? `?redirect=${encodeURIComponent(redirectPath)}` : "";
window.location.href = `/login${qs}`;
}
/** آدرس ورود (شامل state و PKCE challenge) را از سرور می‌گیرد. */
export async function fetchLoginUrl(): Promise<string> {
let res: Response;
try {
res = await fetch(`${IDENTITY_API}/api/v1/auth/login-url`);
} catch {
throw new Error(
`اتصال به سرویس احراز هویت برقرار نشد (${IDENTITY_API}). مطمئن شوید identity-access-service در حال اجراست.`
);
}
if (!res.ok) throw new Error("ساخت آدرس ورود ناموفق بود");
const data: LoginUrlResponse = await res.json();
return data.authorization_url;
}
export async function redeemHandoff(handoffCode: string): Promise<TokenBundle> {
const res = await fetch(`${IDENTITY_API}/api/v1/auth/session/redeem`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ handoff_code: handoffCode }),
});
if (!res.ok) {
let detail = "";
try {
const body = await res.json();
detail = body?.error?.message || body?.detail || "";
} catch {
/* ignore */
}
throw new Error(detail || "تبدیل session ناموفق بود");
}
return res.json();
}
export async function exchangeCode(
code: string,
redirectUri: string,
state: string | null
): Promise<TokenBundle> {
const res = await fetch(`${IDENTITY_API}/api/v1/auth/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code,
redirect_uri: redirectUri,
state,
}),
});
if (!res.ok) {
let detail = "";
try {
const body = await res.json();
detail = body?.error?.message || body?.detail || "";
} catch {
/* ignore */
}
throw new Error(
detail ? `تبدیل code به token ناموفق بود: ${detail}` : "تبدیل code به token ناموفق بود"
);
}
return res.json();
}
export async function fetchMe(token: string) {
const res = await fetch(`${IDENTITY_API}/api/v1/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error("دریافت اطلاعات کاربر ناموفق بود");
return res.json() as Promise<{
user_id: string;
email: string | null;
username: string | null;
mobile: string | null;
mobile_verified: boolean;
requires_mobile_verification: boolean;
roles: string[];
}>;
}
export async function logout(config: AuthConfig): Promise<void> {
clearTokens();
const params = new URLSearchParams({
client_id: config.client_id,
post_logout_redirect_uri: window.location.origin,
});
window.location.href = `${config.logout_endpoint}?${params.toString()}`;
}