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>
171 lines
5.4 KiB
TypeScript
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()}`;
|
|
}
|