Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions apps/frontend/libs/auth/authOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions apps/frontend/libs/auth/reissueAccessToken.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Response>((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)
})
})
50 changes: 50 additions & 0 deletions apps/frontend/libs/auth/reissueAccessToken.ts
Original file line number Diff line number Diff line change
@@ -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<string, Promise<ReissuedTokens>>()

const requestReissue = async (
refreshToken: string
): Promise<ReissuedTokens> => {
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)
}
Comment on lines +15 to +30

export const reissueAccessToken = (
refreshToken: string
): Promise<ReissuedTokens> => {
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
}
160 changes: 89 additions & 71 deletions apps/frontend/middleware.ts
Original file line number Diff line number Diff line change
@@ -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)))
}
Comment on lines +56 to +72

export const middleware = async (req: NextRequest) => {
const token = await getToken({
req,
Expand All @@ -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: {
Expand All @@ -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)$).*)'
]
}
5 changes: 1 addition & 4 deletions apps/frontend/mocks/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
8 changes: 5 additions & 3 deletions apps/frontend/types/next-auth.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,20 @@ interface UserData {
username: string
role: string
}
interface Token {
interface AccessToken {
accessToken: string
refreshToken: string
accessTokenExpires: number
}
interface Token extends AccessToken {
refreshToken: string
refreshTokenExpires: number
}

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' {
Expand Down
Loading