diff --git a/backend/package.json b/backend/package.json index 8f2e6f34..dcceddfe 100644 --- a/backend/package.json +++ b/backend/package.json @@ -33,7 +33,7 @@ "@huggingface/transformers": "latest", "@nestjs-modules/mailer": "^2.0.2", "@nestjs/apollo": "^12.2.0", - "@nestjs/axios": "^3.0.3", + "@nestjs/axios": "^3.1.3", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.2.3", "@nestjs/core": "^10.0.0", @@ -48,8 +48,8 @@ "@types/normalize-path": "^3.0.2", "@types/toposort": "^2.0.7", "archiver": "^7.0.1", + "axios": "^1.8.3", "aws-sdk": "^2.1692.0", - "axios": "^1.7.7", "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 3446c2b1..95b18d85 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -217,7 +217,7 @@ export class AuthService { ); const refreshTokenEntity = await this.createRefreshToken(user); - this.jwtCacheService.storeAccessToken(refreshTokenEntity.token); + this.jwtCacheService.storeAccessToken(accessToken); return { accessToken, diff --git a/frontend/src/api/ChatStreamAPI.ts b/frontend/src/api/ChatStreamAPI.ts index 1caff4c9..6444eb95 100644 --- a/frontend/src/api/ChatStreamAPI.ts +++ b/frontend/src/api/ChatStreamAPI.ts @@ -1,4 +1,5 @@ import { ChatInputType } from '@/graphql/type'; +import authenticatedFetch from '@/lib/authenticatedFetch'; export const startChatStream = async ( input: ChatInputType, @@ -9,7 +10,7 @@ export const startChatStream = async ( throw new Error('Not authenticated'); } const { chatId, message, model } = input; - const response = await fetch('/api/chat', { + const response = await authenticatedFetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/frontend/src/components/chat/code-engine/responsive-toolbar.tsx b/frontend/src/components/chat/code-engine/responsive-toolbar.tsx index d6c23809..f711b537 100644 --- a/frontend/src/components/chat/code-engine/responsive-toolbar.tsx +++ b/frontend/src/components/chat/code-engine/responsive-toolbar.tsx @@ -17,6 +17,7 @@ import { logger } from '@/app/log/logger'; import { useMutation, useQuery, gql } from '@apollo/client'; import { toast } from 'sonner'; import { SYNC_PROJECT_TO_GITHUB, GET_PROJECT } from '../../../graphql/request'; +import { authenticatedFetch } from '@/lib/authenticatedFetch'; interface ResponsiveToolbarProps { isLoading: boolean; @@ -222,9 +223,14 @@ const ResponsiveToolbar = ({ } // Fetch with credentials to ensure auth is included - const response = await fetch(downloadUrl, { + // const response = await fetch(downloadUrl, { + // method: 'GET', + // headers: headers, + // }); + + // Use authenticatedFetch which handles token refresh + const response = await authenticatedFetch(downloadUrl, { method: 'GET', - headers: headers, }); if (!response.ok) { diff --git a/frontend/src/lib/authenticatedFetch.ts b/frontend/src/lib/authenticatedFetch.ts new file mode 100644 index 00000000..e99969b2 --- /dev/null +++ b/frontend/src/lib/authenticatedFetch.ts @@ -0,0 +1,182 @@ +import { LocalStore } from '@/lib/storage'; +import { client } from '@/lib/client'; +import { REFRESH_TOKEN_MUTATION } from '@/graphql/mutations/auth'; +import { gql } from '@apollo/client'; + +// Prevent multiple simultaneous refresh attempts +let isRefreshing = false; +let refreshPromise: Promise | null = null; + +/** + * Refreshes the access token using the refresh token + * @returns Promise that resolves to the new token or null if refresh failed + */ +export const refreshAccessToken = async (): Promise => { + // If a refresh is already in progress, return that promise + if (isRefreshing && refreshPromise) { + return refreshPromise; + } + + isRefreshing = true; + refreshPromise = (async () => { + try { + const refreshToken = localStorage.getItem(LocalStore.refreshToken); + if (!refreshToken) { + return null; + } + + // Use Apollo client to refresh the token + const result = await client.mutate({ + mutation: REFRESH_TOKEN_MUTATION, + variables: { refreshToken }, + }); + + if (result.data?.refreshToken?.accessToken) { + const newAccessToken = result.data.refreshToken.accessToken; + const newRefreshToken = + result.data.refreshToken.refreshToken || refreshToken; + + localStorage.setItem(LocalStore.accessToken, newAccessToken); + localStorage.setItem(LocalStore.refreshToken, newRefreshToken); + + console.log('Token refreshed successfully'); + return newAccessToken; + } + + return null; + } catch (error) { + console.error('Error refreshing token:', error); + return null; + } finally { + isRefreshing = false; + refreshPromise = null; + } + })(); + + return refreshPromise; +}; + +/** + * Fetch wrapper that handles authentication and token refresh + * @param url The URL to fetch + * @param options Fetch options + * @param retryOnAuth Whether to retry on 401 errors (default: true) + * @returns Response from the fetch request + */ +export const authenticatedFetch = async ( + url: string, + options: RequestInit = {}, + retryOnAuth: boolean = true +): Promise => { + // Get current token + const token = localStorage.getItem(LocalStore.accessToken); + + // Setup headers with authentication + const headers = new Headers(options.headers || {}); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + + // Make the request + const response = await fetch(url, { + ...options, + headers, + }); + + // If we get a 401 and we should retry, attempt to refresh the token + if (response.status === 401 && retryOnAuth) { + const newToken = await refreshAccessToken(); + + if (newToken) { + // Update the authorization header with the new token + headers.set('Authorization', `Bearer ${newToken}`); + + // Retry the request with the new token + return fetch(url, { + ...options, + headers, + }); + } else { + // If refresh failed, redirect to home/login + if (typeof window !== 'undefined') { + localStorage.removeItem(LocalStore.accessToken); + localStorage.removeItem(LocalStore.refreshToken); + window.location.href = '/'; + } + } + } + + return response; +}; + +/** + * Processes a streaming response from a server-sent events endpoint + * @param response Fetch Response object (must be a streaming response) + * @param onChunk Optional callback to process each chunk as it arrives + * @returns Promise with the full aggregated content + */ +export const processStreamResponse = async ( + response: Response, + onChunk?: (chunk: string) => void +): Promise => { + if (!response.body) { + throw new Error('Response has no body'); + } + + const reader = response.body.getReader(); + let fullContent = ''; + let isStreamDone = false; + + try { + // More explicit condition than while(true) + while (!isStreamDone) { + const { done, value } = await reader.read(); + + if (done) { + isStreamDone = true; + continue; + } + + const text = new TextDecoder().decode(value); + const lines = text.split('\n\n'); + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.slice(6).trim(); + + // Additional exit condition + if (data === '[DONE]') { + isStreamDone = true; + break; + } + + try { + const parsed = JSON.parse(data); + if (parsed.content) { + fullContent += parsed.content; + if (onChunk) { + onChunk(parsed.content); + } + } + } catch (e) { + console.error('Error parsing SSE data:', e); + } + } + } + } + + return fullContent; + } catch (error) { + console.error('Error reading stream:', error); + throw error; + } finally { + // Ensure we clean up the reader if we exit due to an error + if (!isStreamDone) { + reader + .cancel() + .catch((e) => console.error('Error cancelling reader:', e)); + } + } +}; + +export default authenticatedFetch; diff --git a/frontend/src/lib/client.ts b/frontend/src/lib/client.ts index af8fc07c..d220cd44 100644 --- a/frontend/src/lib/client.ts +++ b/frontend/src/lib/client.ts @@ -6,6 +6,8 @@ import { ApolloLink, from, split, + Observable, + FetchResult, } from '@apollo/client'; import { onError } from '@apollo/client/link/error'; import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; @@ -14,6 +16,7 @@ import { getMainDefinition } from '@apollo/client/utilities'; import createUploadLink from 'apollo-upload-client/createUploadLink.mjs'; import { LocalStore } from '@/lib/storage'; import { logger } from '@/app/log/logger'; +import { REFRESH_TOKEN_MUTATION } from '@/graphql/mutations/auth'; // Create the upload link as the terminating link const uploadLink = createUploadLink({ @@ -72,6 +75,142 @@ const authMiddleware = new ApolloLink((operation, forward) => { return forward(operation); }); +// Refresh Token Handling +const refreshToken = async (): Promise => { + try { + const refreshToken = localStorage.getItem(LocalStore.refreshToken); + if (!refreshToken) { + return null; + } + + console.debug('start refreshToken mutate'); + + // Use the main client for the refresh token request + // The tokenRefreshLink will skip refresh attempts for this operation + const result = await client.mutate({ + mutation: REFRESH_TOKEN_MUTATION, + variables: { refreshToken }, + }); + + if (result.data?.refreshToken?.accessToken) { + const newAccessToken = result.data.refreshToken.accessToken; + const newRefreshToken = + result.data.refreshToken.refreshToken || refreshToken; + + localStorage.setItem(LocalStore.accessToken, newAccessToken); + localStorage.setItem(LocalStore.refreshToken, newRefreshToken); + + logger.info('Token refreshed successfully'); + return newAccessToken; + } + + return null; + } catch (error) { + logger.error('Error refreshing token:', error); + return null; + } +}; + +// Handle token expiration and refresh +const tokenRefreshLink = onError( + ({ graphQLErrors, networkError, operation, forward }) => { + if (graphQLErrors) { + for (const err of graphQLErrors) { + // Check for auth errors (adjust this check based on your API's error structure) + const isAuthError = + err.extensions?.code === 'UNAUTHENTICATED' || + err.message.includes('Unauthorized') || + err.message.includes('token expired'); + + // Don't try to refresh if this operation is the refresh token mutation + // This prevents infinite refresh loops + const operationName = operation.operationName; + const path = err.path; + const isRefreshTokenOperation = + operationName === 'RefreshToken' || + (path && path.includes('refreshToken')); + + if (isAuthError && !isRefreshTokenOperation) { + logger.info('Auth error detected, attempting token refresh'); + + // Return a new observable to handle the token refresh + return new Observable((observer) => { + // Attempt to refresh the token + (async () => { + try { + const newToken = await refreshToken(); + + if (!newToken) { + // If refresh fails, clear tokens and redirect + localStorage.removeItem(LocalStore.accessToken); + localStorage.removeItem(LocalStore.refreshToken); + + // Redirect to home/login page when running in browser + if (typeof window !== 'undefined') { + logger.warn( + 'Token refresh failed, redirecting to home page' + ); + window.location.href = '/'; + } + + // Complete the observer with the original error + observer.error(err); + observer.complete(); + return; + } + + // Retry the operation with the new token + // Clone the operation with the new token + const oldHeaders = operation.getContext().headers; + operation.setContext({ + headers: { + ...oldHeaders, + Authorization: `Bearer ${newToken}`, + }, + }); + + // Retry the request + forward(operation).subscribe({ + next: observer.next.bind(observer), + error: observer.error.bind(observer), + complete: observer.complete.bind(observer), + }); + } catch (error) { + logger.error('Error in token refresh flow:', error); + observer.error(error); + observer.complete(); + } + })(); + }); + } + } + } + + if (networkError) { + logger.error(`[Network error]: ${networkError}`); + + // For network errors related to authentication endpoints, handle logout + const networkErrorOperation = operation.operationName; + if ( + networkErrorOperation === 'RefreshToken' || + networkErrorOperation === 'Login' || + networkErrorOperation === 'ValidateToken' + ) { + // Only redirect for auth-related network errors + if (typeof window !== 'undefined') { + localStorage.removeItem(LocalStore.accessToken); + localStorage.removeItem(LocalStore.refreshToken); + + logger.warn( + 'Network error during authentication, redirecting to home' + ); + window.location.href = '/'; + } + } + } + } +); + // Error Link const errorLink = onError(({ graphQLErrors, networkError }) => { if (graphQLErrors) { @@ -88,6 +227,7 @@ const errorLink = onError(({ graphQLErrors, networkError }) => { // Build the HTTP link chain const httpLinkWithMiddleware = from([ + tokenRefreshLink, // Add token refresh link first errorLink, requestLoggingMiddleware, authMiddleware, diff --git a/frontend/src/providers/AuthProvider.tsx b/frontend/src/providers/AuthProvider.tsx index b4c5f8ad..6c8e4572 100644 --- a/frontend/src/providers/AuthProvider.tsx +++ b/frontend/src/providers/AuthProvider.tsx @@ -14,6 +14,7 @@ import { LocalStore } from '@/lib/storage'; import { LoadingPage } from '@/components/global-loading'; import { User } from '@/graphql/type'; import { logger } from '@/app/log/logger'; +import { useRouter } from 'next/navigation'; interface AuthContextValue { isAuthorized: boolean; @@ -44,6 +45,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [isAuthorized, setIsAuthorized] = useState(false); const [isLoading, setIsLoading] = useState(true); const [user, setUser] = useState(null); + const router = useRouter(); const [checkToken] = useLazyQuery<{ checkToken: boolean }>(CHECK_TOKEN_QUERY); const [refreshTokenMutation] = useMutation(REFRESH_TOKEN_MUTATION); @@ -142,7 +144,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setUser(null); localStorage.removeItem(LocalStore.accessToken); localStorage.removeItem(LocalStore.refreshToken); - }, []); + + // Redirect to home page after logout + if (typeof window !== 'undefined') { + router.push('/'); + } + }, [router]); useEffect(() => { async function initAuth() { @@ -159,12 +166,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { let isValid = await validateToken(); - // 如果验证失败,再试图刷新 + // If validation fails, try to refresh if (!isValid) { isValid = (await refreshAccessToken()) ? true : false; } - // 最终判断 + // Final decision if (isValid) { setIsAuthorized(true); await fetchUserInfo(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d6b7858..9e678c42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,7 +52,7 @@ importers: specifier: ^12.2.0 version: 12.2.2(@apollo/server@4.11.3)(@nestjs/common@10.4.15)(@nestjs/core@10.4.15)(@nestjs/graphql@12.2.2)(graphql@16.10.0) '@nestjs/axios': - specifier: ^3.0.3 + specifier: ^3.1.3 version: 3.1.3(@nestjs/common@10.4.15)(axios@1.8.4)(rxjs@7.8.2) '@nestjs/common': specifier: ^10.0.0 @@ -100,7 +100,7 @@ importers: specifier: ^2.1692.0 version: 2.1692.0 axios: - specifier: ^1.7.7 + specifier: ^1.8.3 version: 1.8.4(debug@4.4.0) bcrypt: specifier: ^5.1.1 @@ -11561,7 +11561,7 @@ packages: postcss: ^8.1.0 dependencies: browserslist: 4.24.4 - caniuse-lite: 1.0.30001706 + caniuse-lite: 1.0.30001707 fraction.js: 4.3.7 normalize-range: 0.1.2 picocolors: 1.1.1 @@ -11993,7 +11993,7 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true dependencies: - caniuse-lite: 1.0.30001706 + caniuse-lite: 1.0.30001707 electron-to-chromium: 1.5.123 node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.24.4) @@ -12177,13 +12177,13 @@ packages: resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} dependencies: browserslist: 4.24.4 - caniuse-lite: 1.0.30001706 + caniuse-lite: 1.0.30001707 lodash.memoize: 4.1.2 lodash.uniq: 4.5.0 dev: false - /caniuse-lite@1.0.30001706: - resolution: {integrity: sha512-3ZczoTApMAZwPKYWmwVbQMFpXBDds3/0VciVoUwPUbldlYyVLmRVuRs/PcUZtHpbLRpzzDvrvnFuREsGt6lUug==} + /caniuse-lite@1.0.30001707: + resolution: {integrity: sha512-3qtRjw/HQSMlDWf+X79N206fepf4SOOU6SQLMaq/0KkZLmSjPxAkBOQQ+FxbHKfHmYLZFfdWsO3KA90ceHPSnw==} /capital-case@1.0.4: resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==} @@ -20057,7 +20057,7 @@ packages: '@playwright/test': 1.51.1 '@swc/helpers': 0.5.5 busboy: 1.6.0 - caniuse-lite: 1.0.30001706 + caniuse-lite: 1.0.30001707 graceful-fs: 4.2.11 postcss: 8.4.31 react: 18.3.1