import { useAppStore, type User } from "../../store"; import { t } from "../../i18n"; import { apiFetch } from "../../net/api"; import { isDesktop, desktopRefresh, clearDesktopSession } from "../../net/desktop"; // ---- dev mode ------------------------------------------------------------- // Dev-mode "login": read ?dev_user=alice (or fall back to localStorage), // stamp the store with a synthetic User and the build-time dev token. // This bypasses Casdoor entirely. The middleware on the backend accepts // any X-Dev-User as long as the bearer matches CDROP_DEV_TOKEN. export function loginDev(searchParams: URLSearchParams): User { const devUser = searchParams.get("dev_user") ?? window.localStorage.getItem("cdrop.dev_user") ?? "dev-user"; window.localStorage.setItem("cdrop.dev_user", devUser); const token = import.meta.env.VITE_CDROP_DEV_TOKEN; if (!token) { throw new Error(t("errors.devTokenMissing")); } const user: User = { id: devUser, name: devUser }; useAppStore.getState().setAuth({ accessToken: token, user }); return user; } export function logout() { // Fire-and-forget 通知后端立刻把本设备从 Hub 踢掉 + 广播 presence,让对端 // 不必等 30s 宽限期。apiFetch 同步抓取 access_token 后才让出(fetch 已发出), // 即使紧随其后 clearAuth 也不会污染这次请求的 Authorization 头。失败忽略 // ——只是体验降级回宽限期路径,不影响登出本身。 void apiFetch("/api/me/disconnect", { method: "POST" }).catch(() => { /* ignore */ }); useAppStore.getState().clearAuth(); // 桌面端:删除 Go 侧持久化 session 文件,否则下次启动仍会注入已登录态。 if (isDesktop()) { clearDesktopSession(); } } // ---- prod (OIDC PKCE) ----------------------------------------------------- interface AuthConfig { auth_mode: "dev" | "prod"; authorize_url: string; client_id: string; redirect_uri: string; scopes: string; } let cachedConfig: AuthConfig | null = null; export async function fetchAuthConfig(): Promise { if (cachedConfig) { return cachedConfig; } const r = await fetch("/api/auth/config"); if (!r.ok) { throw new Error(`auth config ${r.status}`); } cachedConfig = await r.json() as AuthConfig; return cachedConfig; } const PKCE_VERIFIER_KEY = "cdrop.pkce_verifier"; const OAUTH_STATE_KEY = "cdrop.oauth_state"; // loginProd kicks off OIDC PKCE: generate verifier+challenge+state, stash them // in localStorage, navigate the browser to the provider's /authorize endpoint. // The callback page completes the flow. // // localStorage (not sessionStorage): a standalone PWA on iOS may run the // provider round-trip in a separate context and relaunch the PWA on the redirect // back, which wipes sessionStorage and breaks login. localStorage survives that. // The verifier is consumed + removed immediately on callback and is useless // without the matching auth code, so persisting it for the round-trip is fine. export async function loginProd(): Promise { const cfg = await fetchAuthConfig(); if (cfg.auth_mode !== "prod") { throw new Error(`server reports auth_mode=${cfg.auth_mode}, expected prod`); } if (!cfg.authorize_url || !cfg.client_id || !cfg.redirect_uri) { throw new Error("OIDC config incomplete (authorize_url / client_id / redirect_uri)"); } const verifier = generateRandomBase64url(32); const challenge = await sha256Base64url(verifier); const state = generateRandomBase64url(16); localStorage.setItem(PKCE_VERIFIER_KEY, verifier); localStorage.setItem(OAUTH_STATE_KEY, state); const params = new URLSearchParams({ client_id: cfg.client_id, redirect_uri: cfg.redirect_uri, response_type: "code", scope: cfg.scopes || "openid profile email", state, code_challenge: challenge, code_challenge_method: "S256", }); window.location.href = `${cfg.authorize_url}?${params}`; } interface ExchangeResp { access_token: string; refresh_token: string; expires_in: number; user: { id: string; name: string; avatar?: string }; } // completeOAuthLogin runs on /oauth/callback after the provider redirects back. // Verifies state (CSRF), trades code+verifier for tokens via the backend // proxy, and stamps the store with the user identity. // // Idempotent:若 store 中已有 user + accessToken(典型场景:useEffect 在 React // 渲染管线中重入,首次已成功 setAuth + 消耗 localStorage,二次入场不能再被 // "PKCE verifier missing" / "state mismatch" 当作失败处理)→ 当作 noop 直接 // resolve,由上层 navigate 接管。 export async function completeOAuthLogin(code: string, state: string): Promise { const cur = useAppStore.getState(); if (cur.user && cur.accessToken) { return; } const expectedState = localStorage.getItem(OAUTH_STATE_KEY); if (!expectedState || state !== expectedState) { throw new Error("OAuth state mismatch (possible CSRF or stale tab)"); } const verifier = localStorage.getItem(PKCE_VERIFIER_KEY); if (!verifier) { throw new Error("PKCE verifier missing — restart the login flow"); } const r = await fetch("/api/auth/exchange", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code, code_verifier: verifier }), }); if (!r.ok) { const text = await r.text().catch(() => r.statusText); throw new Error(`token exchange ${r.status}: ${text}`); } const data = await r.json() as ExchangeResp; localStorage.removeItem(PKCE_VERIFIER_KEY); localStorage.removeItem(OAUTH_STATE_KEY); useAppStore.getState().setAuth({ accessToken: data.access_token, refreshToken: data.refresh_token, user: { id: data.user.id, name: data.user.name, avatar: data.user.avatar }, }); } // refreshTokens proxies to /api/auth/refresh. Returns true on success. // api.ts calls this lazily when a request comes back 401. export async function refreshTokens(): Promise { const store = useAppStore.getState(); if (store.authMode !== "prod") { return false; } // 桌面端:refresh 完全在 Go 内部完成(refresh_token 只在 Go,JS 不持有),故 // 无需 store.refreshToken,直接无参调用。 if (isDesktop()) { const res = await desktopRefresh(); if (!res?.access_token) { return false; } const cur = useAppStore.getState(); if (!cur.user) { return false; } cur.setAuth({ accessToken: res.access_token, user: cur.user }); return true; } const refreshToken = store.refreshToken; if (!refreshToken) { return false; } const r = await fetch("/api/auth/refresh", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token: refreshToken }), }); if (!r.ok) { return false; } const data = await r.json() as { access_token: string; refresh_token?: string }; if (!data.access_token) { return false; } const cur = useAppStore.getState(); if (!cur.user) { return false; } cur.setAuth({ accessToken: data.access_token, refreshToken: data.refresh_token ?? refreshToken, user: cur.user, }); return true; } // ---- PKCE helpers --------------------------------------------------------- function generateRandomBase64url(numBytes: number): string { const arr = new Uint8Array(numBytes); crypto.getRandomValues(arr); return base64urlEncode(arr); } async function sha256Base64url(input: string): Promise { const buf = new TextEncoder().encode(input); const hash = await crypto.subtle.digest("SHA-256", buf); return base64urlEncode(new Uint8Array(hash)); } function base64urlEncode(bytes: Uint8Array): string { let s = ""; for (let i = 0; i < bytes.length; i += 1) { s += String.fromCharCode(bytes[i]); } return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); }