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 @@
// while avoiding unnecessary token refreshes on page reload
let accessToken: string | null = null;
let tokenExpiresAt: Date | null = null;
let refreshToken: string | null = null;
const SESSION_TOKEN_KEY = "memos_access_token";
const SESSION_EXPIRES_KEY = "memos_token_expires_at";
const SESSION_REFRESH_KEY = "memos_refresh_token";
export const getAccessToken = (): string | null => {
// If not in memory, try to restore from sessionStorage
......@@ -34,6 +36,21 @@ export const getAccessToken = (): string | null => {
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 => {
accessToken = token;
tokenExpiresAt = expiresAt || null;
......@@ -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 => {
if (!tokenExpiresAt) return true;
// Consider expired 30 seconds before actual expiry for safety
......@@ -63,10 +94,12 @@ export const isTokenExpired = (): boolean => {
export const clearAccessToken = (): void => {
accessToken = null;
tokenExpiresAt = null;
refreshToken = null;
try {
sessionStorage.removeItem(SESSION_TOKEN_KEY);
sessionStorage.removeItem(SESSION_EXPIRES_KEY);
sessionStorage.removeItem(SESSION_REFRESH_KEY);
} catch (e) {
console.warn("Failed to clear sessionStorage:", e);
}
......
import { useQueryClient } from "@tanstack/react-query";
import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from "react";
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 { userKeys } from "@/hooks/useUserQueries";
import { memoKeys } from "@/hooks/useMemoQueries";
......@@ -59,9 +59,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// - Prefer Clerk session (SSO)
// - Fallback to legacy memo access token store
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
clearAccessToken();
setState({
......@@ -75,23 +76,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return;
}
// We hit our new backend me endpoint manually because authServiceClient might still be rigid
// Or we can just use fetch. We'll use fetch to make it completely explicit for the new API.
const res = await fetch("/api/v1/auth/me", {
headers: {
Authorization: `Bearer ${legacyToken}`,
},
});
const { user } = await authServiceClient.getCurrentUser({});
const currentUser = user;
if (!res.ok) {
if (!currentUser) {
throw new Error("401 Unauthorized");
}
const currentUser = await res.json();
// Normalize: ensure currentUser has a name field (for compatibility with proto types)
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
......@@ -126,7 +120,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
}
clearAccessToken();
if (isUnauthorized) {
clearAccessToken();
}
setState({
currentUser: undefined,
userGeneralSetting: undefined,
......
......@@ -3,7 +3,7 @@ import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { authServiceClient, shortcutServiceClient, userServiceClient } from "@/service";
import { buildUserSettingName } from "@/helpers/resource-names";
import { getAccessToken } from "@/auth-state";
import { getAccessToken, hasRefreshToken } from "@/auth-state";
import { useWorkspace } from "@/contexts/WorkspaceContext";
import { User, UserSetting, UserSetting_GeneralSetting, UserSetting_Key, UserSettingSchema } from "@/types/proto/api/v1/user_service_pb";
......@@ -28,8 +28,9 @@ export function useCurrentUserQuery() {
// Avoid hitting /auth/me when we clearly have no token/session yet.
// This prevents unnecessary 401s during initial load as Clerk boots up.
const legacyToken = getAccessToken();
const canRefresh = hasRefreshToken();
if (!legacyToken) {
if (!legacyToken && !canRefresh) {
// No session at all -> treat as not logged in without calling backend.
return null;
}
......
import { useState, useEffect } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import toast from "react-hot-toast";
import { setAccessToken } from "@/auth-state";
import { setAccessToken, setRefreshToken } from "@/auth-state";
import { useAuth } from "@/contexts/AuthContext";
import "./Auth.css";
......@@ -49,6 +49,7 @@ const AuthPage = () => {
// Set token with expiry (1 day) so it persists in sessionStorage across tab switches
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
setAccessToken(data.access_token, expiresAt);
setRefreshToken(data.refresh_token ?? null);
toast.success(isSignup ? "Account created successfully" : "Signed in successfully");
await initialize(); // Refresh auth state
navigate("/app");
......
......@@ -57,22 +57,23 @@ const router = createBrowserRouter([
{ path: "landing", element: <LazyRoute component={Landing} /> },
// Auth page (separate from main app - no sidebar)
{ 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,
element: <RootLayout />,
element: (
<ProtectedRoute>
<RootLayout />
</ProtectedRoute>
),
children: [
{
element: <MainLayout />,
children: [
{ path: "", element: <Home /> },
{
path: "explore",
element: (
<ProtectedRoute>
<LazyRoute component={Explore} />
</ProtectedRoute>
),
},
{ path: "explore", element: <LazyRoute component={Explore} /> },
{ path: "u/:username", element: <LazyRoute component={UserProfile} /> },
],
},
......
import { redirectOnAuthFailure } from "@/utils/auth-redirect";
import { getAccessToken } from "@/auth-state";
import { getAccessToken, getRefreshToken, setAccessToken, clearAccessToken } from "@/auth-state";
import type { RequestOptions } from "./types";
// API origin - always use relative URLs so requests go through Vite proxy (no CORS).
......@@ -7,6 +7,7 @@ import type { RequestOptions } from "./types";
// NEVER set VITE_API_BASE_URL - that causes direct cross-origin calls which break CORS.
export const API_ORIGIN: string = "";
export const API_BASE = `/api/v1`;
let refreshInFlight: Promise<string | null> | null = null;
const parseBody = async (response: Response): Promise<unknown> => {
const contentType = response.headers.get("content-type") || "";
......@@ -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> => {
const token = getAccessToken();
let token = getAccessToken();
const isRefreshEndpoint = path.includes("/auth/refresh");
if (!token && !isRefreshEndpoint) {
token = await refreshAccessToken();
}
const headers = new Headers(options.headers);
if (token) {
headers.set("Authorization", `Bearer ${token}`);
......@@ -54,6 +119,32 @@ export const fetchJson = async <T>(path: string, options: RequestOptions = {}):
});
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
const isAuthMeEndpoint = path.includes("/auth/me");
if ((response.status === 401 || response.status === 403) && !isAuthMeEndpoint) {
......
......@@ -7,8 +7,38 @@ import { fetchJson } from "./apiClient";
export const authServiceClient = {
async getCurrentUser(_request?: unknown): Promise<GetCurrentUserResponse> {
void _request;
const data = await fetchJson<GetCurrentUserResponse>("/auth/me", { method: "GET" });
return data;
const data = await fetchJson<unknown>("/auth/me", { method: "GET" });
// 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