From 96cfc90e17058728f2732fd60cb23e31f3bc9f40 Mon Sep 17 00:00:00 2001 From: DeathKaioken Date: Sun, 18 Jan 2026 21:37:18 +0100 Subject: [PATCH] fix:maybe --- middleware.ts | 3 +- src/app/api/login/route.ts | 69 +++++++++++++++++++++++++++++++++ src/app/api/logout/route.ts | 40 +++++++++++++++++++ src/app/api/refresh/route.ts | 67 ++++++++++++++++++++++++++++++++ src/app/login/hooks/useLogin.ts | 10 ++--- src/app/store/authStore.ts | 13 ++++--- 6 files changed, 189 insertions(+), 13 deletions(-) create mode 100644 src/app/api/login/route.ts create mode 100644 src/app/api/logout/route.ts create mode 100644 src/app/api/refresh/route.ts diff --git a/middleware.ts b/middleware.ts index 6d72d89..0a119ef 100644 --- a/middleware.ts +++ b/middleware.ts @@ -6,9 +6,8 @@ */ import { NextRequest, NextResponse } from 'next/server' -// Move accessToken to HttpOnly cookie in future for better security // Backend sets 'refreshToken' cookie on login; use it as auth presence -const AUTH_COOKIES = ['refreshToken'] +const AUTH_COOKIES = ['refreshToken', '__Secure-refreshToken'] export function middleware(req: NextRequest) { const { pathname } = req.nextUrl diff --git a/src/app/api/login/route.ts b/src/app/api/login/route.ts new file mode 100644 index 0000000..30c45fa --- /dev/null +++ b/src/app/api/login/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from 'next/server' + +export const runtime = 'nodejs' + +function getSharedCookieDomain(req: NextRequest): string | undefined { + const host = req.headers.get('host') ?? '' + return host.endsWith('profit-planet.partners') ? '.profit-planet.partners' : undefined +} + +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] : [] +} + +function parseRefreshCookie(setCookie: string) { + // refreshToken=VALUE; Max-Age=...; Expires=...; SameSite=...; ... + const m = setCookie.match(/^refreshToken=([^;]*)/i) + if (!m) return null + const value = m[1] ?? '' + + const maxAgeMatch = setCookie.match(/;\s*max-age=(\d+)/i) + const expiresMatch = setCookie.match(/;\s*expires=([^;]+)/i) + const sameSiteMatch = setCookie.match(/;\s*samesite=(lax|strict|none)/i) + + return { + value, + maxAge: maxAgeMatch ? Number(maxAgeMatch[1]) : undefined, + expires: expiresMatch ? new Date(expiresMatch[1]) : undefined, + sameSite: (sameSiteMatch?.[1]?.toLowerCase() as 'lax' | 'strict' | 'none' | undefined) ?? undefined, + } +} + +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 body = await req.json().catch(() => null) + + const apiRes = await fetch(`${apiBase}/api/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body ?? {}), + cache: 'no-store', + }) + + const data = await apiRes.json().catch(() => null) + const out = NextResponse.json(data, { status: apiRes.status }) + + const setCookies = readSetCookies(apiRes) + const refreshSetCookie = setCookies.find((c) => /^refreshToken=/i.test(c)) + if (refreshSetCookie) { + const parsed = parseRefreshCookie(refreshSetCookie) + if (parsed) { + out.cookies.set('refreshToken', parsed.value, { + domain: getSharedCookieDomain(req), + path: '/', + httpOnly: true, + secure: true, + sameSite: parsed.sameSite ?? 'lax', + ...(parsed.maxAge !== undefined ? { maxAge: parsed.maxAge } : {}), + ...(parsed.maxAge === undefined && parsed.expires ? { expires: parsed.expires } : {}), + }) + } + } + + return out +} diff --git a/src/app/api/logout/route.ts b/src/app/api/logout/route.ts new file mode 100644 index 0000000..17b7784 --- /dev/null +++ b/src/app/api/logout/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from 'next/server' + +export const runtime = 'nodejs' + +function getSharedCookieDomain(req: NextRequest): string | undefined { + const host = req.headers.get('host') ?? '' + return host.endsWith('profit-planet.partners') ? '.profit-planet.partners' : undefined +} + +export async function POST(req: NextRequest) { + const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL + const cookie = req.headers.get('cookie') ?? '' + + // Best-effort: tell backend to revoke refresh token (if endpoint exists) + let data: any = { success: true } + let status = 200 + if (apiBase) { + const apiRes = await fetch(`${apiBase}/api/logout`, { + method: 'POST', + headers: { cookie, 'Content-Type': 'application/json' }, + cache: 'no-store', + }).catch(() => null) + + if (apiRes) { + status = apiRes.status + data = await apiRes.json().catch(() => data) + } + } + + const out = NextResponse.json(data, { status }) + out.cookies.set('refreshToken', '', { + domain: getSharedCookieDomain(req), + path: '/', + httpOnly: true, + secure: true, + sameSite: 'lax', + maxAge: 0, + }) + return out +} diff --git a/src/app/api/refresh/route.ts b/src/app/api/refresh/route.ts new file mode 100644 index 0000000..fe3e35f --- /dev/null +++ b/src/app/api/refresh/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from 'next/server' + +export const runtime = 'nodejs' + +function getSharedCookieDomain(req: NextRequest): string | undefined { + const host = req.headers.get('host') ?? '' + return host.endsWith('profit-planet.partners') ? '.profit-planet.partners' : undefined +} + +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] : [] +} + +function parseRefreshCookie(setCookie: string) { + const m = setCookie.match(/^refreshToken=([^;]*)/i) + if (!m) return null + const value = m[1] ?? '' + + const maxAgeMatch = setCookie.match(/;\s*max-age=(\d+)/i) + const expiresMatch = setCookie.match(/;\s*expires=([^;]+)/i) + const sameSiteMatch = setCookie.match(/;\s*samesite=(lax|strict|none)/i) + + return { + value, + maxAge: maxAgeMatch ? Number(maxAgeMatch[1]) : undefined, + expires: expiresMatch ? new Date(expiresMatch[1]) : undefined, + sameSite: (sameSiteMatch?.[1]?.toLowerCase() as 'lax' | 'strict' | 'none' | undefined) ?? undefined, + } +} + +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') ?? '' + + const apiRes = await fetch(`${apiBase}/api/refresh`, { + method: 'POST', + headers: { cookie }, + cache: 'no-store', + }) + + const data = await apiRes.json().catch(() => null) + const out = NextResponse.json(data, { status: apiRes.status }) + + const setCookies = readSetCookies(apiRes) + const refreshSetCookie = setCookies.find((c) => /^refreshToken=/i.test(c)) + if (refreshSetCookie) { + const parsed = parseRefreshCookie(refreshSetCookie) + if (parsed) { + out.cookies.set('refreshToken', parsed.value, { + domain: getSharedCookieDomain(req), + path: '/', + httpOnly: true, + secure: true, + sameSite: parsed.sameSite ?? 'lax', + ...(parsed.maxAge !== undefined ? { maxAge: parsed.maxAge } : {}), + ...(parsed.maxAge === undefined && parsed.expires ? { expires: parsed.expires } : {}), + }) + } + } + + return out +} diff --git a/src/app/login/hooks/useLogin.ts b/src/app/login/hooks/useLogin.ts index 43727af..ce25e29 100644 --- a/src/app/login/hooks/useLogin.ts +++ b/src/app/login/hooks/useLogin.ts @@ -28,16 +28,16 @@ export function useLogin() { rememberMe: credentials.rememberMe }) - // Make actual API call to backend - const loginUrl = `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/login` - console.log('Calling login API:', loginUrl) - + // Call same-origin BFF route so it can set Domain=.profit-planet.partners cookie + const loginUrl = `/api/login` + console.log('Calling login API (BFF):', loginUrl) + const response = await fetch(loginUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', }, - credentials: 'include', // Include cookies for refresh token + credentials: 'include', body: JSON.stringify({ email: credentials.email, password: credentials.password, diff --git a/src/app/store/authStore.ts b/src/app/store/authStore.ts index da30408..ce6c8de 100644 --- a/src/app/store/authStore.ts +++ b/src/app/store/authStore.ts @@ -139,8 +139,10 @@ const useAuthStore = create((set, get) => ({ logout: async () => { log("🚪 Zustand: Logging out — revoking refresh token on server"); try { - const logoutUrl = `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/logout`; + // Use same-origin BFF route so it can clear Domain=.profit-planet.partners cookie + const logoutUrl = `/api/logout`; log("🌐 Zustand: Calling logout endpoint:", logoutUrl); + const res = await fetch(logoutUrl, { method: "POST", credentials: "include", @@ -148,15 +150,13 @@ const useAuthStore = create((set, get) => ({ "Content-Type": "application/json" } }); + log("📡 Logout response status:", res.status); try { const body = await res.json().catch(() => null); log("📦 Logout response body:", body); } catch {} - // Attempt to clear refreshToken cookie client-side (will only work if not httpOnly) - if (typeof window !== 'undefined') { - document.cookie = "refreshToken=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure; SameSite=Strict"; - } + // (No client-side cookie clearing; refreshToken is HttpOnly) } catch (error) { log("❌ Error calling logout endpoint:", error); } finally { @@ -187,7 +187,8 @@ const useAuthStore = create((set, get) => ({ const p = (async () => { set({ isRefreshing: true }); try { - const refreshUrl = `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/refresh`; + // Use same-origin BFF route so it can (re)issue shared refreshToken cookie + const refreshUrl = `/api/refresh`; log("🌐 Zustand: Calling refresh endpoint:", refreshUrl); const res = await fetch(refreshUrl, {