From 28b9bc6ec5432775fd055a1bcda28ab80ff7d69e Mon Sep 17 00:00:00 2001 From: Mingyu Date: Wed, 29 Jul 2026 17:35:15 +0900 Subject: [PATCH 1/2] fix(fe): prevent refresh token exposure --- apps/frontend/libs/auth/authOptions.ts | 4 +--- apps/frontend/mocks/handlers.ts | 5 +---- apps/frontend/types/next-auth.d.ts | 8 +++++--- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/apps/frontend/libs/auth/authOptions.ts b/apps/frontend/libs/auth/authOptions.ts index 2176399d74..1c3ec12b66 100644 --- a/apps/frontend/libs/auth/authOptions.ts +++ b/apps/frontend/libs/auth/authOptions.ts @@ -41,9 +41,7 @@ export const authOptions: NextAuthOptions = { session.token = { accessToken: token.accessToken, - refreshToken: token.refreshToken, - accessTokenExpires: token.accessTokenExpires, - refreshTokenExpires: token.refreshTokenExpires + accessTokenExpires: token.accessTokenExpires } return session diff --git a/apps/frontend/mocks/handlers.ts b/apps/frontend/mocks/handlers.ts index ef26c5c264..0463879990 100644 --- a/apps/frontend/mocks/handlers.ts +++ b/apps/frontend/mocks/handlers.ts @@ -72,10 +72,7 @@ export const handlers = [ token: { accessToken: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsInVzZXJuYW1lIjoiYWRtaW4iLCJpYXQiOjE3MjQwNDM1OTYsImV4cCI6MTcyNDA0NTM5NiwiaXNzIjoic2trdWRpbmcuZGV2In0.QC-f78V536WuKT2lxqvpi4tnPa4BIpnxxoCaQIwedEw', - refreshToken: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsInVzZXJuYW1lIjoiYWRtaW4iLCJpYXQiOjE3MjQwNDM1OTYsImV4cCI6MTcyNDEyOTk5NiwiaXNzIjoic2trdWRpbmcuZGV2In0.ph7UFnW5nF8cQ7Zk_OjUbE7prHkIAbT8F3vsTRnDTaA', - accessTokenExpires: Date.now() + 1000, - refreshTokenExpires: Date.now() + accessTokenExpires: Date.now() + 1000 } } diff --git a/apps/frontend/types/next-auth.d.ts b/apps/frontend/types/next-auth.d.ts index 474ebb85b1..3ec57fe697 100644 --- a/apps/frontend/types/next-auth.d.ts +++ b/apps/frontend/types/next-auth.d.ts @@ -4,10 +4,12 @@ interface UserData { username: string role: string } -interface Token { +interface AccessToken { accessToken: string - refreshToken: string accessTokenExpires: number +} +interface Token extends AccessToken { + refreshToken: string refreshTokenExpires: number } @@ -15,7 +17,7 @@ declare module 'next-auth' { interface User extends DefaultUser, UserData, Token {} interface Session extends DefaultSession { user: UserData - token: Token + token: AccessToken } } declare module 'next-auth/jwt' { From a7a44f47a41c9e7fb3b8a07cd3f0f16dd5259ffa Mon Sep 17 00:00:00 2001 From: Mingyu Date: Wed, 29 Jul 2026 17:35:31 +0900 Subject: [PATCH 2/2] fix(fe): harden token reissue flow --- .../libs/auth/reissueAccessToken.spec.ts | 39 +++++ apps/frontend/libs/auth/reissueAccessToken.ts | 50 ++++++ apps/frontend/middleware.ts | 160 ++++++++++-------- 3 files changed, 178 insertions(+), 71 deletions(-) create mode 100644 apps/frontend/libs/auth/reissueAccessToken.spec.ts create mode 100644 apps/frontend/libs/auth/reissueAccessToken.ts diff --git a/apps/frontend/libs/auth/reissueAccessToken.spec.ts b/apps/frontend/libs/auth/reissueAccessToken.spec.ts new file mode 100644 index 0000000000..5961543219 --- /dev/null +++ b/apps/frontend/libs/auth/reissueAccessToken.spec.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { reissueAccessToken } from './reissueAccessToken' + +const accessToken = + 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MjI5NDM3NzB9.signature' +const refreshToken = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MjMwMjgzNzB9.signature' + +const createReissueResponse = () => + new Response(undefined, { + headers: { + authorization: accessToken, + 'set-cookie': `refresh_token=${refreshToken}; Expires=Wed, 07 Aug 2024 10:59:30 GMT` + } + }) + +describe('reissueAccessToken', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('shares one request for concurrent reissues with the same token', async () => { + let resolveResponse: ((response: Response) => void) | undefined + const response = new Promise((resolve) => { + resolveResponse = resolve + }) + const fetchMock = vi.fn(() => response) + vi.stubGlobal('fetch', fetchMock) + + const first = reissueAccessToken('same-refresh-token') + const second = reissueAccessToken('same-refresh-token') + resolveResponse?.(createReissueResponse()) + + const [firstResult, secondResult] = await Promise.all([first, second]) + + expect(fetchMock).toHaveBeenCalledOnce() + expect(firstResult).toEqual(secondResult) + }) +}) diff --git a/apps/frontend/libs/auth/reissueAccessToken.ts b/apps/frontend/libs/auth/reissueAccessToken.ts new file mode 100644 index 0000000000..2210992b29 --- /dev/null +++ b/apps/frontend/libs/auth/reissueAccessToken.ts @@ -0,0 +1,50 @@ +import { baseUrl } from '../constants' +import { getJWTFromResponse } from './getJWTFromResponse' + +export interface ReissuedTokens { + accessToken: string + refreshToken: string + accessTokenExpires: number + refreshTokenExpires: number +} + +// Coalesces concurrent refreshes handled by the same server instance. +// Cross-instance concurrency must still be handled idempotently by the backend. +const pendingReissues = new Map>() + +const requestReissue = async ( + refreshToken: string +): Promise => { + const response = await fetch(`${baseUrl}/auth/reissue`, { + headers: { + cookie: `refresh_token=${refreshToken}` + }, + cache: 'no-store' + }) + + if (!response.ok) { + throw new Error('Failed to reissue token') + } + + return getJWTFromResponse(response) +} + +export const reissueAccessToken = ( + refreshToken: string +): Promise => { + const pending = pendingReissues.get(refreshToken) + if (pending) { + return pending + } + + const reissue = (async () => { + try { + return await requestReissue(refreshToken) + } finally { + pendingReissues.delete(refreshToken) + } + })() + + pendingReissues.set(refreshToken, reissue) + return reissue +} diff --git a/apps/frontend/middleware.ts b/apps/frontend/middleware.ts index 014740b333..e328fa69a3 100644 --- a/apps/frontend/middleware.ts +++ b/apps/frontend/middleware.ts @@ -1,12 +1,76 @@ import { encode, getToken } from 'next-auth/jwt' +import type { JWT } from 'next-auth/jwt' import { NextResponse, type NextRequest } from 'next/server' -import { getJWTFromResponse } from './libs/auth/getJWTFromResponse' -import { baseUrl } from './libs/constants' +import { reissueAccessToken } from './libs/auth/reissueAccessToken' const sessionCookieName = process.env.NEXTAUTH_URL?.startsWith('https://') ? '__Secure-next-auth.session-token' : 'next-auth.session-token' +const sessionCookieOptions = { + maxAge: 24 * 60 * 60, + secure: + process.env.APP_ENV === 'production' || process.env.APP_ENV === 'stage', + httpOnly: true, + sameSite: 'lax' as const, + path: '/' +} + +const isProtectedCoursePath = (pathname: string) => + /^\/course\/.+/.test(pathname) + +const createLoginUrl = (req: NextRequest) => { + const loginUrl = new URL('/login', req.url) + loginUrl.searchParams.set('redirectUrl', req.nextUrl.pathname) + return loginUrl +} + +const logPwaAccess = (req: NextRequest, token: JWT | null) => { + if (req.nextUrl.searchParams.get('isPWA') !== 'true') { + return + } + + console.log( + JSON.stringify({ + event: 'PWA', + timestamp: new Date().toISOString(), + path: req.nextUrl.pathname, + user: { + username: token?.username, + name: token?.name, + role: token?.role + }, + ip: req.headers.get('x-real-ip'), + userAgent: req.headers.get('user-agent'), + referer: req.headers.get('referer') + }) + ) +} + +const clearSession = (req: NextRequest, response: NextResponse) => { + req.cookies.delete(sessionCookieName) + response.cookies.delete(sessionCookieName) + return response +} + +const handleReissueFailure = (req: NextRequest) => { + const { pathname } = req.nextUrl + const isAuthRequest = pathname.startsWith('/next-auth/api/auth/') + + if (pathname === '/login' || isAuthRequest) { + return clearSession( + req, + NextResponse.next({ + request: { + headers: new Headers(req.headers) + } + }) + ) + } + + return clearSession(req, NextResponse.redirect(createLoginUrl(req))) +} + export const middleware = async (req: NextRequest) => { const token = await getToken({ req, @@ -15,65 +79,25 @@ export const middleware = async (req: NextRequest) => { const { pathname } = req.nextUrl - const isCourseDetailPath = /^\/course\/.+/.test(pathname) - - if (isCourseDetailPath && !token) { - const loginUrl = new URL('/login', req.url) - loginUrl.searchParams.set('redirectUrl', pathname) - return NextResponse.redirect(loginUrl) + if (isProtectedCoursePath(pathname) && !token) { + return NextResponse.redirect(createLoginUrl(req)) } - if (req.nextUrl.searchParams.get('isPWA') === 'true') { - console.log( - JSON.stringify( - { - event: 'PWA', - timestamp: new Date().toISOString(), - path: pathname, - user: { - username: token?.username, - name: token?.name, - role: token?.role - }, - ip: req.headers.get('x-real-ip'), - userAgent: req.headers.get('user-agent'), - referer: req.headers.get('referer') - }, - null, - 0 - ) - ) - } + logPwaAccess(req, token) if (token && token.accessTokenExpires <= Date.now()) { - // Handle unauthorized access to admin page - // if ( - // req.nextUrl.pathname.startsWith('/admin') && - // (!token || token.role === 'User') - // ) { - // return NextResponse.redirect(new URL('/', req.url)) - // } - - // Handle reissue of access token - try { - const reissueRes = await fetch(`${baseUrl}/auth/reissue`, { - headers: { - cookie: `refresh_token=${token.refreshToken}` - }, - cache: 'no-store' - }) - - if (!reissueRes.ok) { - throw new Error('Failed to reissue token') - } + if (token.refreshTokenExpires <= Date.now()) { + return handleReissueFailure(req) + } - // If reissue is successful, update session token. + try { const { accessToken, refreshToken, accessTokenExpires, refreshTokenExpires - } = getJWTFromResponse(reissueRes) + } = await reissueAccessToken(token.refreshToken) + const newToken = await encode({ secret: process.env.NEXTAUTH_SECRET as string, token: { @@ -87,34 +111,28 @@ export const middleware = async (req: NextRequest) => { }) req.cookies.set(sessionCookieName, newToken) - const reissuedResponse = NextResponse.next({ request: { headers: new Headers(req.headers) } }) - reissuedResponse.cookies.set(sessionCookieName, newToken, { - maxAge: 24 * 60 * 60, - secure: - process.env.APP_ENV === 'production' || - process.env.APP_ENV === 'stage', - httpOnly: true, - sameSite: 'lax' - }) + reissuedResponse.cookies.set( + sessionCookieName, + newToken, + sessionCookieOptions + ) return reissuedResponse } catch { - // If reissue is failed, delete session token. - req.cookies.delete(sessionCookieName) - - const deletedResponse = NextResponse.next({ - request: { - headers: new Headers(req.headers) - } - }) - deletedResponse.cookies.delete(sessionCookieName) - - return deletedResponse + return handleReissueFailure(req) } } + + return NextResponse.next() +} + +export const config = { + matcher: [ + '/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|woff|woff2)$).*)' + ] }