diff --git a/src/app/api/login/route.ts b/src/app/api/login/route.ts index 30c45fa..6e27c1f 100644 --- a/src/app/api/login/route.ts +++ b/src/app/api/login/route.ts @@ -7,15 +7,32 @@ function getSharedCookieDomain(req: NextRequest): string | undefined { return host.endsWith('profit-planet.partners') ? '.profit-planet.partners' : undefined } +function splitSetCookieHeader(header: string): string[] { + const parts: string[] = [] + let start = 0 + let inExpires = false + for (let i = 0; i < header.length; i++) { + const lower = header.slice(i, i + 8).toLowerCase() + if (lower === 'expires=') inExpires = true + if (inExpires && header[i] === ';') inExpires = false + if (!inExpires && header[i] === ',') { + parts.push(header.slice(start, i).trim()) + start = i + 1 + } + } + const last = header.slice(start).trim() + if (last) parts.push(last) + return parts +} + function readSetCookies(res: Response): string[] { const anyHeaders = res.headers as any if (typeof anyHeaders.getSetCookie === 'function') return anyHeaders.getSetCookie() const single = res.headers.get('set-cookie') - return single ? [single] : [] + return single ? splitSetCookieHeader(single) : [] } function parseRefreshCookie(setCookie: string) { - // refreshToken=VALUE; Max-Age=...; Expires=...; SameSite=...; ... const m = setCookie.match(/^refreshToken=([^;]*)/i) if (!m) return null const value = m[1] ?? '' @@ -52,7 +69,23 @@ export async function POST(req: NextRequest) { const refreshSetCookie = setCookies.find((c) => /^refreshToken=/i.test(c)) if (refreshSetCookie) { const parsed = parseRefreshCookie(refreshSetCookie) - if (parsed) { + if (parsed?.value) { + // Clear host-only duplicates first. + out.cookies.set('refreshToken', '', { + path: '/', + httpOnly: true, + secure: true, + sameSite: 'lax', + maxAge: 0, + }) + out.cookies.set('__Secure-refreshToken', '', { + path: '/', + httpOnly: true, + secure: true, + sameSite: 'lax', + maxAge: 0, + }) + out.cookies.set('refreshToken', parsed.value, { domain: getSharedCookieDomain(req), path: '/', diff --git a/src/app/api/logout/route.ts b/src/app/api/logout/route.ts index 17b7784..4dc70b3 100644 --- a/src/app/api/logout/route.ts +++ b/src/app/api/logout/route.ts @@ -28,6 +28,12 @@ export async function POST(req: NextRequest) { } const out = NextResponse.json(data, { status }) + + // Clear host-only variants + out.cookies.set('refreshToken', '', { path: '/', httpOnly: true, secure: true, sameSite: 'lax', maxAge: 0 }) + out.cookies.set('__Secure-refreshToken', '', { path: '/', httpOnly: true, secure: true, sameSite: 'lax', maxAge: 0 }) + + // Clear shared-domain variant out.cookies.set('refreshToken', '', { domain: getSharedCookieDomain(req), path: '/', @@ -36,5 +42,6 @@ export async function POST(req: NextRequest) { sameSite: 'lax', maxAge: 0, }) + return out } diff --git a/src/app/api/refresh/route.ts b/src/app/api/refresh/route.ts index fe3e35f..21e62c3 100644 --- a/src/app/api/refresh/route.ts +++ b/src/app/api/refresh/route.ts @@ -7,11 +7,30 @@ function getSharedCookieDomain(req: NextRequest): string | undefined { return host.endsWith('profit-planet.partners') ? '.profit-planet.partners' : undefined } +function splitSetCookieHeader(header: string): string[] { + // Handles "Expires=Wed, 21 Oct ..." commas. + const parts: string[] = [] + let start = 0 + let inExpires = false + for (let i = 0; i < header.length; i++) { + const lower = header.slice(i, i + 8).toLowerCase() + if (lower === 'expires=') inExpires = true + if (inExpires && header[i] === ';') inExpires = false + if (!inExpires && header[i] === ',') { + parts.push(header.slice(start, i).trim()) + start = i + 1 + } + } + const last = header.slice(start).trim() + if (last) parts.push(last) + return parts +} + function readSetCookies(res: Response): string[] { const anyHeaders = res.headers as any if (typeof anyHeaders.getSetCookie === 'function') return anyHeaders.getSetCookie() const single = res.headers.get('set-cookie') - return single ? [single] : [] + return single ? splitSetCookieHeader(single) : [] } function parseRefreshCookie(setCookie: string) { @@ -35,11 +54,24 @@ export async function POST(req: NextRequest) { const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL if (!apiBase) return NextResponse.json({ message: 'Missing NEXT_PUBLIC_API_BASE_URL' }, { status: 500 }) - const cookie = req.headers.get('cookie') ?? '' + // Prefer a single parsed cookie value to avoid "refreshToken=old; refreshToken=new" ambiguity. + const rt = + req.cookies.get('refreshToken')?.value ?? + req.cookies.get('__Secure-refreshToken')?.value ?? + '' + + const headers: Record = {} + if (rt) { + headers.cookie = `refreshToken=${rt}` + } else { + // fallback (best-effort) + const cookie = req.headers.get('cookie') ?? '' + if (cookie) headers.cookie = cookie + } const apiRes = await fetch(`${apiBase}/api/refresh`, { method: 'POST', - headers: { cookie }, + headers, cache: 'no-store', }) @@ -50,7 +82,24 @@ export async function POST(req: NextRequest) { const refreshSetCookie = setCookies.find((c) => /^refreshToken=/i.test(c)) if (refreshSetCookie) { const parsed = parseRefreshCookie(refreshSetCookie) - if (parsed) { + if (parsed?.value) { + // Clear any host-only variants on the frontend host to prevent duplicates. + out.cookies.set('refreshToken', '', { + path: '/', + httpOnly: true, + secure: true, + sameSite: 'lax', + maxAge: 0, + }) + out.cookies.set('__Secure-refreshToken', '', { + path: '/', + httpOnly: true, + secure: true, + sameSite: 'lax', + maxAge: 0, + }) + + // Set the shared-domain cookie used across subdomains. out.cookies.set('refreshToken', parsed.value, { domain: getSharedCookieDomain(req), path: '/',