Commit b9d8c77c authored by Vũ Hoàng Anh's avatar Vũ Hoàng Anh

fix(auth): stabilize refresh flow and add auth route aliases

parent f6c0445a
...@@ -3,9 +3,11 @@ ...@@ -3,9 +3,11 @@
// while avoiding unnecessary token refreshes on page reload // while avoiding unnecessary token refreshes on page reload
let accessToken: string | null = null; let accessToken: string | null = null;
let tokenExpiresAt: Date | null = null; let tokenExpiresAt: Date | null = null;
let refreshToken: string | null = null;
const SESSION_TOKEN_KEY = "memos_access_token"; const SESSION_TOKEN_KEY = "memos_access_token";
const SESSION_EXPIRES_KEY = "memos_token_expires_at"; const SESSION_EXPIRES_KEY = "memos_token_expires_at";
const SESSION_REFRESH_KEY = "memos_refresh_token";
export const getAccessToken = (): string | null => { export const getAccessToken = (): string | null => {
// If not in memory, try to restore from sessionStorage // If not in memory, try to restore from sessionStorage
...@@ -34,6 +36,21 @@ export const getAccessToken = (): string | null => { ...@@ -34,6 +36,21 @@ export const getAccessToken = (): string | null => {
return accessToken; return accessToken;
}; };
export const getRefreshToken = (): string | null => {
if (!refreshToken) {
try {
refreshToken = sessionStorage.getItem(SESSION_REFRESH_KEY);
} catch (e) {
console.warn("Failed to access sessionStorage:", e);
}
}
return refreshToken;
};
export const hasRefreshToken = (): boolean => {
return !!getRefreshToken();
};
export const setAccessToken = (token: string | null, expiresAt?: Date): void => { export const setAccessToken = (token: string | null, expiresAt?: Date): void => {
accessToken = token; accessToken = token;
tokenExpiresAt = expiresAt || null; tokenExpiresAt = expiresAt || null;
...@@ -54,6 +71,20 @@ export const setAccessToken = (token: string | null, expiresAt?: Date): void => ...@@ -54,6 +71,20 @@ export const setAccessToken = (token: string | null, expiresAt?: Date): void =>
} }
}; };
export const setRefreshToken = (token: string | null): void => {
refreshToken = token;
try {
if (token) {
sessionStorage.setItem(SESSION_REFRESH_KEY, token);
} else {
sessionStorage.removeItem(SESSION_REFRESH_KEY);
}
} catch (e) {
console.warn("Failed to write to sessionStorage:", e);
}
};
export const isTokenExpired = (): boolean => { export const isTokenExpired = (): boolean => {
if (!tokenExpiresAt) return true; if (!tokenExpiresAt) return true;
// Consider expired 30 seconds before actual expiry for safety // Consider expired 30 seconds before actual expiry for safety
...@@ -63,10 +94,12 @@ export const isTokenExpired = (): boolean => { ...@@ -63,10 +94,12 @@ export const isTokenExpired = (): boolean => {
export const clearAccessToken = (): void => { export const clearAccessToken = (): void => {
accessToken = null; accessToken = null;
tokenExpiresAt = null; tokenExpiresAt = null;
refreshToken = null;
try { try {
sessionStorage.removeItem(SESSION_TOKEN_KEY); sessionStorage.removeItem(SESSION_TOKEN_KEY);
sessionStorage.removeItem(SESSION_EXPIRES_KEY); sessionStorage.removeItem(SESSION_EXPIRES_KEY);
sessionStorage.removeItem(SESSION_REFRESH_KEY);
} catch (e) { } catch (e) {
console.warn("Failed to clear sessionStorage:", e); console.warn("Failed to clear sessionStorage:", e);
} }
......
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from "react"; import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { clearAccessToken, getAccessToken } from "@/auth-state"; import { clearAccessToken, getAccessToken, hasRefreshToken } from "@/auth-state";
import { authServiceClient, shortcutServiceClient, userServiceClient } from "@/service"; import { authServiceClient, shortcutServiceClient, userServiceClient } from "@/service";
import { userKeys } from "@/hooks/useUserQueries"; import { userKeys } from "@/hooks/useUserQueries";
import { memoKeys } from "@/hooks/useMemoQueries"; import { memoKeys } from "@/hooks/useMemoQueries";
...@@ -59,9 +59,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { ...@@ -59,9 +59,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// - Prefer Clerk session (SSO) // - Prefer Clerk session (SSO)
// - Fallback to legacy memo access token store // - Fallback to legacy memo access token store
const legacyToken = getAccessToken(); const legacyToken = getAccessToken();
const hasToken = !!(legacyToken); const hasToken = !!legacyToken;
const canRefresh = hasRefreshToken();
if (!hasToken) { if (!hasToken && !canRefresh) {
// No token means user is not logged in - skip API call // No token means user is not logged in - skip API call
clearAccessToken(); clearAccessToken();
setState({ setState({
...@@ -75,23 +76,16 @@ export function AuthProvider({ children }: { children: ReactNode }) { ...@@ -75,23 +76,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return; return;
} }
// We hit our new backend me endpoint manually because authServiceClient might still be rigid const { user } = await authServiceClient.getCurrentUser({});
// Or we can just use fetch. We'll use fetch to make it completely explicit for the new API. const currentUser = user;
const res = await fetch("/api/v1/auth/me", {
headers: {
Authorization: `Bearer ${legacyToken}`,
},
});
if (!res.ok) { if (!currentUser) {
throw new Error("401 Unauthorized"); throw new Error("401 Unauthorized");
} }
const currentUser = await res.json();
// Normalize: ensure currentUser has a name field (for compatibility with proto types) // Normalize: ensure currentUser has a name field (for compatibility with proto types)
if (!currentUser.name) { if (!currentUser.name) {
currentUser.name = `users/${currentUser.id}`; currentUser.name = `users/${currentUser.username || "unknown"}`;
} }
// Skip fetchUserSettings for now — it requires proto-based API that hasn't been migrated // Skip fetchUserSettings for now — it requires proto-based API that hasn't been migrated
...@@ -126,7 +120,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { ...@@ -126,7 +120,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} }
} }
if (isUnauthorized) {
clearAccessToken(); clearAccessToken();
}
setState({ setState({
currentUser: undefined, currentUser: undefined,
userGeneralSetting: undefined, userGeneralSetting: undefined,
......
...@@ -3,7 +3,7 @@ import { FieldMaskSchema } from "@bufbuild/protobuf/wkt"; ...@@ -3,7 +3,7 @@ import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { authServiceClient, shortcutServiceClient, userServiceClient } from "@/service"; import { authServiceClient, shortcutServiceClient, userServiceClient } from "@/service";
import { buildUserSettingName } from "@/helpers/resource-names"; import { buildUserSettingName } from "@/helpers/resource-names";
import { getAccessToken } from "@/auth-state"; import { getAccessToken, hasRefreshToken } from "@/auth-state";
import { useWorkspace } from "@/contexts/WorkspaceContext"; import { useWorkspace } from "@/contexts/WorkspaceContext";
import { User, UserSetting, UserSetting_GeneralSetting, UserSetting_Key, UserSettingSchema } from "@/types/proto/api/v1/user_service_pb"; import { User, UserSetting, UserSetting_GeneralSetting, UserSetting_Key, UserSettingSchema } from "@/types/proto/api/v1/user_service_pb";
...@@ -28,8 +28,9 @@ export function useCurrentUserQuery() { ...@@ -28,8 +28,9 @@ export function useCurrentUserQuery() {
// Avoid hitting /auth/me when we clearly have no token/session yet. // Avoid hitting /auth/me when we clearly have no token/session yet.
// This prevents unnecessary 401s during initial load as Clerk boots up. // This prevents unnecessary 401s during initial load as Clerk boots up.
const legacyToken = getAccessToken(); const legacyToken = getAccessToken();
const canRefresh = hasRefreshToken();
if (!legacyToken) { if (!legacyToken && !canRefresh) {
// No session at all -> treat as not logged in without calling backend. // No session at all -> treat as not logged in without calling backend.
return null; return null;
} }
......
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { setAccessToken } from "@/auth-state"; import { setAccessToken, setRefreshToken } from "@/auth-state";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import "./Auth.css"; import "./Auth.css";
...@@ -49,6 +49,7 @@ const AuthPage = () => { ...@@ -49,6 +49,7 @@ const AuthPage = () => {
// Set token with expiry (1 day) so it persists in sessionStorage across tab switches // Set token with expiry (1 day) so it persists in sessionStorage across tab switches
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
setAccessToken(data.access_token, expiresAt); setAccessToken(data.access_token, expiresAt);
setRefreshToken(data.refresh_token ?? null);
toast.success(isSignup ? "Account created successfully" : "Signed in successfully"); toast.success(isSignup ? "Account created successfully" : "Signed in successfully");
await initialize(); // Refresh auth state await initialize(); // Refresh auth state
navigate("/app"); navigate("/app");
......
...@@ -57,22 +57,23 @@ const router = createBrowserRouter([ ...@@ -57,22 +57,23 @@ const router = createBrowserRouter([
{ path: "landing", element: <LazyRoute component={Landing} /> }, { path: "landing", element: <LazyRoute component={Landing} /> },
// Auth page (separate from main app - no sidebar) // Auth page (separate from main app - no sidebar)
{ path: Routes.AUTH, element: <LazyRoute component={AuthPage} /> }, { path: Routes.AUTH, element: <LazyRoute component={AuthPage} /> },
// Backward-compatible auth aliases to avoid accidental 404s.
{ path: "app/auth", element: <LazyRoute component={AuthPage} /> },
{ path: "sign-in", element: <LazyRoute component={AuthPage} /> },
{ path: "signin", element: <LazyRoute component={AuthPage} /> },
{ {
path: Routes.ROOT, path: Routes.ROOT,
element: <RootLayout />, element: (
<ProtectedRoute>
<RootLayout />
</ProtectedRoute>
),
children: [ children: [
{ {
element: <MainLayout />, element: <MainLayout />,
children: [ children: [
{ path: "", element: <Home /> }, { path: "", element: <Home /> },
{ { path: "explore", element: <LazyRoute component={Explore} /> },
path: "explore",
element: (
<ProtectedRoute>
<LazyRoute component={Explore} />
</ProtectedRoute>
),
},
{ path: "u/:username", element: <LazyRoute component={UserProfile} /> }, { path: "u/:username", element: <LazyRoute component={UserProfile} /> },
], ],
}, },
......
import { redirectOnAuthFailure } from "@/utils/auth-redirect"; import { redirectOnAuthFailure } from "@/utils/auth-redirect";
import { getAccessToken } from "@/auth-state"; import { getAccessToken, getRefreshToken, setAccessToken, clearAccessToken } from "@/auth-state";
import type { RequestOptions } from "./types"; import type { RequestOptions } from "./types";
// API origin - always use relative URLs so requests go through Vite proxy (no CORS). // API origin - always use relative URLs so requests go through Vite proxy (no CORS).
...@@ -7,6 +7,7 @@ import type { RequestOptions } from "./types"; ...@@ -7,6 +7,7 @@ import type { RequestOptions } from "./types";
// NEVER set VITE_API_BASE_URL - that causes direct cross-origin calls which break CORS. // NEVER set VITE_API_BASE_URL - that causes direct cross-origin calls which break CORS.
export const API_ORIGIN: string = ""; export const API_ORIGIN: string = "";
export const API_BASE = `/api/v1`; export const API_BASE = `/api/v1`;
let refreshInFlight: Promise<string | null> | null = null;
const parseBody = async (response: Response): Promise<unknown> => { const parseBody = async (response: Response): Promise<unknown> => {
const contentType = response.headers.get("content-type") || ""; const contentType = response.headers.get("content-type") || "";
...@@ -30,8 +31,72 @@ const parseBody = async (response: Response): Promise<unknown> => { ...@@ -30,8 +31,72 @@ const parseBody = async (response: Response): Promise<unknown> => {
} }
}; };
const getAccessTokenExpiry = (token: string): Date | undefined => {
try {
const payloadBase64 = token.split(".")[1];
if (!payloadBase64) return undefined;
const normalized = payloadBase64.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
const json = atob(padded);
const payload = JSON.parse(json) as { exp?: number };
if (!payload.exp) return undefined;
return new Date(payload.exp * 1000);
} catch {
return undefined;
}
};
const refreshAccessToken = async (): Promise<string | null> => {
if (refreshInFlight) {
return refreshInFlight;
}
refreshInFlight = (async () => {
const refreshToken = getRefreshToken();
if (!refreshToken) {
return null;
}
const response = await fetch(`${API_BASE}/auth/refresh`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
clearAccessToken();
}
return null;
}
const data = (await parseBody(response)) as { access_token?: string } | null;
const newAccessToken = data?.access_token;
if (!newAccessToken) {
return null;
}
const expiresAt = getAccessTokenExpiry(newAccessToken) ?? new Date(Date.now() + 24 * 60 * 60 * 1000);
setAccessToken(newAccessToken, expiresAt);
return newAccessToken;
})();
try {
return await refreshInFlight;
} finally {
refreshInFlight = null;
}
};
export const fetchJson = async <T>(path: string, options: RequestOptions = {}): Promise<T> => { export const fetchJson = async <T>(path: string, options: RequestOptions = {}): Promise<T> => {
const token = getAccessToken(); let token = getAccessToken();
const isRefreshEndpoint = path.includes("/auth/refresh");
if (!token && !isRefreshEndpoint) {
token = await refreshAccessToken();
}
const headers = new Headers(options.headers); const headers = new Headers(options.headers);
if (token) { if (token) {
headers.set("Authorization", `Bearer ${token}`); headers.set("Authorization", `Bearer ${token}`);
...@@ -54,6 +119,32 @@ export const fetchJson = async <T>(path: string, options: RequestOptions = {}): ...@@ -54,6 +119,32 @@ export const fetchJson = async <T>(path: string, options: RequestOptions = {}):
}); });
if (!response.ok) { if (!response.ok) {
if ((response.status === 401 || response.status === 403) && !isRefreshEndpoint) {
const refreshedAccessToken = await refreshAccessToken();
if (refreshedAccessToken) {
const retryHeaders = new Headers(options.headers);
retryHeaders.set("Authorization", `Bearer ${refreshedAccessToken}`);
if (body !== undefined && body !== null && !isFormData && typeof body !== "string") {
retryHeaders.set("Content-Type", "application/json");
}
const retryResponse = await fetch(`${API_BASE}${path}`, {
method: options.method ?? (body ? "POST" : "GET"),
headers: retryHeaders,
body: body as BodyInit | null | undefined,
});
if (retryResponse.ok) {
if (retryResponse.status === 204) {
return undefined as T;
}
const retryData = await parseBody(retryResponse);
return retryData as T;
}
}
}
// Don't redirect on 401 for /auth/me - it's expected when not logged in // Don't redirect on 401 for /auth/me - it's expected when not logged in
const isAuthMeEndpoint = path.includes("/auth/me"); const isAuthMeEndpoint = path.includes("/auth/me");
if ((response.status === 401 || response.status === 403) && !isAuthMeEndpoint) { if ((response.status === 401 || response.status === 403) && !isAuthMeEndpoint) {
......
...@@ -7,8 +7,38 @@ import { fetchJson } from "./apiClient"; ...@@ -7,8 +7,38 @@ import { fetchJson } from "./apiClient";
export const authServiceClient = { export const authServiceClient = {
async getCurrentUser(_request?: unknown): Promise<GetCurrentUserResponse> { async getCurrentUser(_request?: unknown): Promise<GetCurrentUserResponse> {
void _request; void _request;
const data = await fetchJson<GetCurrentUserResponse>("/auth/me", { method: "GET" }); const data = await fetchJson<unknown>("/auth/me", { method: "GET" });
return data;
// Support both response formats:
// 1) Connect/proto: { user: {...} }
// 2) Legacy REST: {...user fields...}
if (data && typeof data === "object" && "user" in data) {
return data as GetCurrentUserResponse;
}
if (data && typeof data === "object") {
const legacyUser = data as Record<string, unknown>;
const id = String(legacyUser.id ?? "");
const username = String(legacyUser.username ?? "");
const email = String(legacyUser.email ?? "");
const name = String(legacyUser.name ?? (id ? `users/${id}` : ""));
return {
user: {
name,
role: 1,
username,
email,
displayName: String(legacyUser.displayName ?? username),
avatarUrl: String(legacyUser.avatarUrl ?? ""),
description: String(legacyUser.description ?? ""),
password: "",
state: 1,
},
} as GetCurrentUserResponse;
}
throw new Error("Invalid /auth/me response");
}, },
}; };
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment