TorbatYar/frontend/lib/auth.ts
2026-07-28 20:39:10 +03:30

360 lines
12 KiB
TypeScript

/**
* مدیریت SSO — ارتباط با Identity Service و Keycloak.
* توکن‌ها روی دامنهٔ پایه (.torbatyar.ir) در cookie ذخیره می‌شوند
* تا بین apex و ساب‌دامین tenant مشترک باشند؛ روی localhost از sessionStorage.
*
* Access token توسط Keycloak صادر می‌شود؛ refresh_token نشست را تا سقف SSO تمدید می‌کند.
*/
const IDENTITY_API =
typeof window !== "undefined"
? "/api/identity"
: process.env.IDENTITY_SERVICE_URL ||
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<string | null> | 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<string | null> {
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<string | null> {
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<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();
}
/** ذخیره مسیر/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<void> {
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<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 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()}`;
}