diff --git a/server/router/api/v1/auth_service.go b/server/router/api/v1/auth_service.go index c63f4da52..1e60f421a 100644 --- a/server/router/api/v1/auth_service.go +++ b/server/router/api/v1/auth_service.go @@ -339,7 +339,9 @@ func (*APIV1Service) buildRefreshTokenCookie(ctx context.Context, refreshToken s if expireTime.IsZero() { attrs = append(attrs, "Expires=Thu, 01 Jan 1970 00:00:00 GMT") } else { - attrs = append(attrs, "Expires="+expireTime.Format(time.RFC1123)) + // RFC 6265 requires cookie expiration dates to use GMT timezone + // Convert to UTC and format with explicit "GMT" to ensure browser compatibility + attrs = append(attrs, "Expires="+expireTime.UTC().Format("Mon, 02 Jan 2006 15:04:05 GMT")) } // Try to determine if the request is HTTPS by checking the origin header diff --git a/server/router/fileserver/fileserver.go b/server/router/fileserver/fileserver.go index 6bc9e1c09..6630ec601 100644 --- a/server/router/fileserver/fileserver.go +++ b/server/router/fileserver/fileserver.go @@ -287,10 +287,10 @@ func (s *FileServerService) checkAttachmentPermission(ctx context.Context, c ech } // getCurrentUser retrieves the current authenticated user from the Echo context. -// It checks Bearer tokens for authentication (Access Token V2 or PAT). +// Authentication priority: Bearer token (Access Token V2 or PAT) > Refresh token cookie. // Uses the shared Authenticator for consistent authentication logic. func (s *FileServerService) getCurrentUser(ctx context.Context, c echo.Context) (*store.User, error) { - // Try Bearer token authentication + // Try Bearer token authentication first authHeader := c.Request().Header.Get("Authorization") if authHeader != "" { token := auth.ExtractBearerToken(authHeader) @@ -317,6 +317,20 @@ func (s *FileServerService) getCurrentUser(ctx context.Context, c echo.Context) } } + // Fallback: Try refresh token cookie authentication + // This allows protected attachments to load even when access token has expired, + // as long as the user has a valid refresh token cookie. + cookieHeader := c.Request().Header.Get("Cookie") + if cookieHeader != "" { + refreshToken := auth.ExtractRefreshTokenFromCookie(cookieHeader) + if refreshToken != "" { + user, _, err := s.authenticator.AuthenticateByRefreshToken(ctx, refreshToken) + if err == nil && user != nil { + return user, nil + } + } + } + // No valid authentication found return nil, nil } diff --git a/web/src/auth-state.ts b/web/src/auth-state.ts index f0524ba0e..d070bc992 100644 --- a/web/src/auth-state.ts +++ b/web/src/auth-state.ts @@ -1,12 +1,57 @@ -// In-memory storage for access token (not persisted for security) +// Access token storage using sessionStorage for persistence across page refreshes +// sessionStorage is cleared when the tab/window is closed, providing reasonable security +// while avoiding unnecessary token refreshes on page reload let accessToken: string | null = null; let tokenExpiresAt: Date | null = null; -export const getAccessToken = (): string | null => accessToken; +const SESSION_TOKEN_KEY = "memos_access_token"; +const SESSION_EXPIRES_KEY = "memos_token_expires_at"; + +export const getAccessToken = (): string | null => { + // If not in memory, try to restore from sessionStorage + if (!accessToken) { + try { + const storedToken = sessionStorage.getItem(SESSION_TOKEN_KEY); + const storedExpires = sessionStorage.getItem(SESSION_EXPIRES_KEY); + + if (storedToken && storedExpires) { + const expiresAt = new Date(storedExpires); + // Only restore if token hasn't expired + if (expiresAt > new Date()) { + accessToken = storedToken; + tokenExpiresAt = expiresAt; + } else { + // Token expired, clean up sessionStorage + sessionStorage.removeItem(SESSION_TOKEN_KEY); + sessionStorage.removeItem(SESSION_EXPIRES_KEY); + } + } + } catch (e) { + // sessionStorage might not be available (e.g., in some privacy modes) + console.warn("Failed to access sessionStorage:", e); + } + } + return accessToken; +}; export const setAccessToken = (token: string | null, expiresAt?: Date): void => { accessToken = token; tokenExpiresAt = expiresAt || null; + + try { + if (token && expiresAt) { + // Store in sessionStorage for persistence across page refreshes + sessionStorage.setItem(SESSION_TOKEN_KEY, token); + sessionStorage.setItem(SESSION_EXPIRES_KEY, expiresAt.toISOString()); + } else { + // Clear sessionStorage if token is being cleared + sessionStorage.removeItem(SESSION_TOKEN_KEY); + sessionStorage.removeItem(SESSION_EXPIRES_KEY); + } + } catch (e) { + // sessionStorage might not be available (e.g., in some privacy modes) + console.warn("Failed to write to sessionStorage:", e); + } }; export const isTokenExpired = (): boolean => { @@ -18,4 +63,11 @@ export const isTokenExpired = (): boolean => { export const clearAccessToken = (): void => { accessToken = null; tokenExpiresAt = null; + + try { + sessionStorage.removeItem(SESSION_TOKEN_KEY); + sessionStorage.removeItem(SESSION_EXPIRES_KEY); + } catch (e) { + console.warn("Failed to clear sessionStorage:", e); + } }; diff --git a/web/src/components/PasswordSignInForm.tsx b/web/src/components/PasswordSignInForm.tsx index 62f1d7381..e12cef8f6 100644 --- a/web/src/components/PasswordSignInForm.tsx +++ b/web/src/components/PasswordSignInForm.tsx @@ -1,5 +1,4 @@ import { timestampDate } from "@bufbuild/protobuf/wkt"; -import { ConnectError } from "@connectrpc/connect"; import { LoaderIcon } from "lucide-react"; import { observer } from "mobx-react-lite"; import { useState } from "react"; @@ -59,9 +58,10 @@ const PasswordSignInForm = observer(() => { } await initialUserStore(); navigateTo("/"); - } catch (error: any) { + } catch (error: unknown) { console.error(error); - toast.error((error as ConnectError).message || "Failed to sign in."); + const message = error instanceof Error ? error.message : "Failed to sign in."; + toast.error(message); } actionBtnLoadingState.setFinish(); }; @@ -92,7 +92,7 @@ const PasswordSignInForm = observer(() => { readOnly={actionBtnLoadingState.isLoading} placeholder={t("common.password")} value={password} - autoComplete="password" + autoComplete="current-password" autoCapitalize="off" spellCheck={false} onChange={handlePasswordInputChanged} diff --git a/web/src/connect.ts b/web/src/connect.ts index 6d2a45de7..7f098ab89 100644 --- a/web/src/connect.ts +++ b/web/src/connect.ts @@ -1,4 +1,5 @@ -import { createClient, Interceptor } from "@connectrpc/connect"; +import { timestampDate } from "@bufbuild/protobuf/wkt"; +import { Code, ConnectError, createClient, type Interceptor } from "@connectrpc/connect"; import { createConnectTransport } from "@connectrpc/connect-web"; import { getAccessToken, setAccessToken } from "./auth-state"; import { ActivityService } from "./types/proto/api/v1/activity_service_pb"; @@ -13,7 +14,13 @@ import { UserService } from "./types/proto/api/v1/user_service_pb"; let isRefreshing = false; let refreshPromise: Promise | null = null; -// Auth interceptor that attaches access token and handles 401 errors by refreshing +/** + * Authentication interceptor that: + * 1. Attaches access token to outgoing requests + * 2. Handles 401 Unauthenticated errors by refreshing the token + * 3. Retries the original request with the new token + * 4. Redirects to login if refresh fails + */ const authInterceptor: Interceptor = (next) => async (req) => { // Add access token to request if available const token = getAccessToken(); @@ -23,9 +30,9 @@ const authInterceptor: Interceptor = (next) => async (req) => { try { return await next(req); - } catch (error: any) { - // Handle unauthenticated error - try to refresh token - if (error.code === "unauthenticated" && !req.header.get("X-Retry")) { + } catch (error) { + // Only handle ConnectError with Unauthenticated code + if (error instanceof ConnectError && error.code === Code.Unauthenticated && !req.header.get("X-Retry")) { // Prevent concurrent refresh attempts if (!isRefreshing) { isRefreshing = true; @@ -47,8 +54,10 @@ const authInterceptor: Interceptor = (next) => async (req) => { } catch (refreshError) { isRefreshing = false; refreshPromise = null; - // Refresh failed - redirect to login - window.location.href = "/auth"; + // Refresh failed - redirect to login (only if not already there) + if (!window.location.pathname.startsWith("/auth")) { + window.location.href = "/auth"; + } throw refreshError; } } @@ -56,24 +65,48 @@ const authInterceptor: Interceptor = (next) => async (req) => { } }; -async function refreshAccessToken(): Promise { - const response = await fetch("/api/v1/auth/refresh", { - method: "POST", - credentials: "include", // Include HttpOnly cookies with refresh token +/** + * Custom fetch that includes credentials for cookie handling. + * Required for HttpOnly refresh token cookie to be sent/received. + */ +const fetchWithCredentials: typeof globalThis.fetch = (input, init) => { + return globalThis.fetch(input, { + ...init, + credentials: "include", }); +}; - if (!response.ok) { - throw new Error("Failed to refresh token"); - } +/** + * Separate transport for refresh token operations. + * Uses no auth interceptor to avoid circular dependency when the main + * interceptor triggers a refresh. + */ +const refreshTransport = createConnectTransport({ + baseUrl: window.location.origin, + useBinaryFormat: true, + fetch: fetchWithCredentials, + interceptors: [], // No interceptors to avoid recursion +}); - const data = await response.json(); - setAccessToken(data.accessToken, new Date(data.expiresAt)); +// Dedicated auth client for refresh operations only +const refreshAuthClient = createClient(AuthService, refreshTransport); + +/** + * Refreshes the access token using the HttpOnly refresh token cookie. + * Called automatically by the auth interceptor when requests fail with 401. + */ +async function refreshAccessToken(): Promise { + const response = await refreshAuthClient.refreshToken({}); + setAccessToken(response.accessToken, response.expiresAt ? timestampDate(response.expiresAt) : undefined); } +/** + * Main transport for all API requests. + */ const transport = createConnectTransport({ baseUrl: window.location.origin, - // Use binary protobuf format for better performance (smaller payloads, faster serialization) useBinaryFormat: true, + fetch: fetchWithCredentials, interceptors: [authInterceptor], }); diff --git a/web/src/pages/AuthCallback.tsx b/web/src/pages/AuthCallback.tsx index 26e4e79ec..1acd11be0 100644 --- a/web/src/pages/AuthCallback.tsx +++ b/web/src/pages/AuthCallback.tsx @@ -1,5 +1,4 @@ import { timestampDate } from "@bufbuild/protobuf/wkt"; -import { ConnectError } from "@connectrpc/connect"; import { LoaderIcon } from "lucide-react"; import { observer } from "mobx-react-lite"; import { useEffect, useState } from "react"; @@ -95,15 +94,16 @@ const AuthCallback = observer(() => { await initialUserStore(); // Redirect to return URL if specified, otherwise home navigateTo(returnUrl || "/"); - } catch (error: any) { + } catch (error: unknown) { console.error(error); + const message = error instanceof Error ? error.message : "Failed to authenticate."; setState({ loading: false, - errorMessage: (error as ConnectError).message, + errorMessage: message, }); } })(); - }, [searchParams]); + }, [searchParams, navigateTo]); return (
diff --git a/web/src/pages/SignUp.tsx b/web/src/pages/SignUp.tsx index 9dfdea0f6..07eb60349 100644 --- a/web/src/pages/SignUp.tsx +++ b/web/src/pages/SignUp.tsx @@ -1,6 +1,5 @@ import { create } from "@bufbuild/protobuf"; import { timestampDate } from "@bufbuild/protobuf/wkt"; -import { ConnectError } from "@connectrpc/connect"; import { LoaderIcon } from "lucide-react"; import { observer } from "mobx-react-lite"; import { useState } from "react"; @@ -15,7 +14,7 @@ import useLoading from "@/hooks/useLoading"; import useNavigateTo from "@/hooks/useNavigateTo"; import { instanceStore } from "@/store"; import { initialUserStore } from "@/store/user"; -import { User, User_Role, UserSchema } from "@/types/proto/api/v1/user_service_pb"; +import { User_Role, UserSchema } from "@/types/proto/api/v1/user_service_pb"; import { useTranslate } from "@/utils/i18n"; const SignUp = observer(() => { @@ -70,9 +69,10 @@ const SignUp = observer(() => { } await initialUserStore(); navigateTo("/"); - } catch (error: any) { + } catch (error: unknown) { console.error(error); - toast.error((error as ConnectError).message || "Sign up failed"); + const message = error instanceof Error ? error.message : "Sign up failed"; + toast.error(message); } actionBtnLoadingState.setFinish(); }; @@ -112,7 +112,7 @@ const SignUp = observer(() => { readOnly={actionBtnLoadingState.isLoading} placeholder={t("common.password")} value={password} - autoComplete="password" + autoComplete="new-password" autoCapitalize="off" spellCheck={false} onChange={handlePasswordInputChanged}