fix(auth): resolve token refresh and persistence issues

- Fix cookie expiration timezone to use GMT (RFC 6265 compliance)
- Use Connect RPC client for token refresh instead of fetch
- Fix error code checking (numeric Code.Unauthenticated instead of string)
- Prevent infinite redirect loop when already on /auth page
- Fix protobuf Timestamp conversion using timestampDate helper
- Store access token in sessionStorage to avoid unnecessary refreshes on page reload
- Add refresh token cookie fallback for attachment authentication
- Improve error handling with proper type checking

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Johnny 2025-12-18 20:56:54 +08:00
parent 7932f6d0d0
commit 50606a850e
7 changed files with 136 additions and 35 deletions

View File

@ -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

View File

@ -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
}

View File

@ -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);
}
};

View File

@ -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}

View File

@ -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<void> | 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<void> {
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<void> {
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],
});

View File

@ -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 (
<div className="p-4 py-24 w-full h-full flex justify-center items-center">

View File

@ -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}