/** * مدیریت SSO — ارتباط با Identity Service و Keycloak. * توکن‌ها روی دامنهٔ پایه (.torbatyar.ir) در cookie ذخیره می‌شوند * تا بین apex و ساب‌دامین tenant مشترک باشند؛ روی localhost از sessionStorage. * * Access token توسط Keycloak صادر می‌شود؛ refresh_token نشست را تا سقف SSO تمدید می‌کند. */ 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"; const EXPIRES_AT_KEY = "superapp_access_expires_at"; const POST_LOGIN_REDIRECT_KEY = "superapp_post_login_redirect"; /** Cookie lifetime for access token storage (value is replaced on each refresh). */ const ACCESS_COOKIE_MAX_AGE = 60 * 60 * 24 * 14; // 14d storage; JWT exp still enforced const REFRESH_COOKIE_MAX_AGE = 60 * 60 * 24 * 14; // 14d /** Refresh ~2 minutes before JWT exp to avoid mid-request expiry. */ const REFRESH_SKEW_MS = 120_000; 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; } function cookieDomain(): string | null { if (typeof window === "undefined") return null; const host = window.location.hostname; if (host === "localhost" || host === "127.0.0.1") return null; const base = process.env.NEXT_PUBLIC_PLATFORM_BASE_DOMAIN || "torbatyar.ir"; if (host === base || host.endsWith(`.${base}`)) return `.${base}`; return null; } function setCookie(name: string, value: string, maxAge: number): void { if (typeof document === "undefined") return; const domain = cookieDomain(); const parts = [ `${encodeURIComponent(name)}=${encodeURIComponent(value)}`, "Path=/", `Max-Age=${maxAge}`, "SameSite=Lax", ]; if (domain) { parts.push(`Domain=${domain}`); if (window.location.protocol === "https:") parts.push("Secure"); } document.cookie = parts.join("; "); } function getCookie(name: string): string | null { if (typeof document === "undefined") return null; const prefix = `${encodeURIComponent(name)}=`; for (const part of document.cookie.split(";")) { const item = part.trim(); if (item.startsWith(prefix)) { return decodeURIComponent(item.slice(prefix.length)); } } return null; } function deleteCookie(name: string): void { if (typeof document === "undefined") return; const domain = cookieDomain(); const base = `${encodeURIComponent(name)}=; Path=/; Max-Age=0`; document.cookie = base; if (domain) { document.cookie = `${base}; Domain=${domain}`; } } function readJwtExpMs(token: string): number | null { try { const payload = token.split(".")[1]; if (!payload) return null; const json = JSON.parse( atob(payload.replace(/-/g, "+").replace(/_/g, "/")) ) as { exp?: number }; return typeof json.exp === "number" ? json.exp * 1000 : null; } catch { return null; } } export function getStoredToken(): string | null { if (typeof window === "undefined") return null; return getCookie(TOKEN_KEY) || sessionStorage.getItem(TOKEN_KEY); } export function getStoredRefreshToken(): string | null { if (typeof window === "undefined") return null; return getCookie(REFRESH_KEY) || sessionStorage.getItem(REFRESH_KEY); } export function storeTokens(tokens: TokenBundle): void { const jwtExp = readJwtExpMs(tokens.access_token); const expiresAt = jwtExp ?? Date.now() + Math.max(30, tokens.expires_in || 300) * 1000; sessionStorage.setItem(TOKEN_KEY, tokens.access_token); sessionStorage.setItem(EXPIRES_AT_KEY, String(expiresAt)); // Cookie must outlive JWT so we can refresh after access token expires. setCookie(TOKEN_KEY, tokens.access_token, ACCESS_COOKIE_MAX_AGE); setCookie(EXPIRES_AT_KEY, String(expiresAt), ACCESS_COOKIE_MAX_AGE); if (tokens.refresh_token) { sessionStorage.setItem(REFRESH_KEY, tokens.refresh_token); setCookie(REFRESH_KEY, tokens.refresh_token, REFRESH_COOKIE_MAX_AGE); } } export function clearTokens(): void { sessionStorage.removeItem(TOKEN_KEY); sessionStorage.removeItem(REFRESH_KEY); sessionStorage.removeItem(EXPIRES_AT_KEY); deleteCookie(TOKEN_KEY); deleteCookie(REFRESH_KEY); deleteCookie(EXPIRES_AT_KEY); } let refreshInFlight: Promise | null = null; /** Prefer JWT `exp` claim — source of truth for Keycloak tokens. */ function accessExpiresAt(token?: string | null): number | null { const t = token ?? getStoredToken(); const fromJwt = t ? readJwtExpMs(t) : null; if (fromJwt) return fromJwt; const raw = getCookie(EXPIRES_AT_KEY) || sessionStorage.getItem(EXPIRES_AT_KEY); if (raw) { const n = Number(raw); if (!Number.isNaN(n)) return n; } return null; } function isExpired(token: string | null, skewMs = 0): boolean { if (!token) return true; const exp = accessExpiresAt(token); if (!exp) return false; return Date.now() >= exp - skewMs; } function needsRefresh(token: string | null): boolean { return isExpired(token, REFRESH_SKEW_MS); } /** Refresh access token via Identity BFF. Returns new access token or null. */ export async function refreshSession(options?: { force?: boolean }): Promise { const refresh = getStoredRefreshToken(); if (!refresh) return null; if (!options?.force) { const current = getStoredToken(); if (current && !needsRefresh(current)) return current; } if (refreshInFlight) return refreshInFlight; refreshInFlight = (async () => { try { const res = await fetch(`${IDENTITY_API}/api/v1/auth/refresh`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token: refresh }), }); if (!res.ok) { // Refresh token itself expired / revoked — clear so UI can re-login cleanly. if (res.status === 401 || res.status === 400) { clearTokens(); } return null; } const bundle = (await res.json()) as TokenBundle; storeTokens(bundle); return bundle.access_token; } catch { return null; } finally { refreshInFlight = null; } })(); return refreshInFlight; } /** * Return a usable access token, refreshing when near/after expiry. * Never returns a JWT that is already past exp. */ export async function getValidAccessToken(): Promise { const token = getStoredToken(); if (token && !needsRefresh(token)) return token; if (getStoredRefreshToken()) { const refreshed = await refreshSession({ force: isExpired(token) }); if (refreshed && !isExpired(refreshed)) return refreshed; } // Last resort: non-expired access without refresh token if (token && !isExpired(token)) return token; return null; } export async function fetchAuthConfig(): Promise { 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(); } /** ذخیره مسیر/URL بازگشت بعد از لاگین (cookie مشترک بین ساب‌دامین‌ها). */ export function setPostLoginRedirect(pathOrUrl: string): void { if (typeof window === "undefined") return; sessionStorage.setItem(POST_LOGIN_REDIRECT_KEY, pathOrUrl); setCookie(POST_LOGIN_REDIRECT_KEY, pathOrUrl, 60 * 30); } export function consumePostLoginRedirect(fallback = "/dashboard"): string { if (typeof window === "undefined") return fallback; const fromSession = sessionStorage.getItem(POST_LOGIN_REDIRECT_KEY); const fromCookie = getCookie(POST_LOGIN_REDIRECT_KEY); sessionStorage.removeItem(POST_LOGIN_REDIRECT_KEY); deleteCookie(POST_LOGIN_REDIRECT_KEY); return fromSession || fromCookie || fallback; } export async function loginWithRedirect(redirectPath?: string): Promise { const returnTo = redirectPath || (typeof window !== "undefined" ? window.location.href : "/dashboard"); setPostLoginRedirect(returnTo); const base = process.env.NEXT_PUBLIC_PLATFORM_BASE_DOMAIN || "torbatyar.ir"; const protocol = typeof window !== "undefined" ? window.location.protocol : "https:"; const apex = `${protocol}//${base}`; const host = typeof window !== "undefined" ? window.location.hostname : ""; if (host && host !== base && host !== `www.${base}` && !host.includes("localhost")) { window.location.href = `${apex}/login?redirect=${encodeURIComponent(returnTo)}`; return; } const qs = `?redirect=${encodeURIComponent(returnTo)}`; window.location.href = `/login${qs}`; } export async function fetchLoginUrl(): Promise { 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 { 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 { 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 { clearTokens(); const base = process.env.NEXT_PUBLIC_PLATFORM_BASE_DOMAIN || "torbatyar.ir"; const protocol = window.location.protocol; const postLogout = window.location.hostname.endsWith(base) || window.location.hostname === base ? `${protocol}//${base}` : window.location.origin; const params = new URLSearchParams({ client_id: config.client_id, post_logout_redirect_uri: postLogout, }); window.location.href = `${config.logout_endpoint}?${params.toString()}`; }