mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
Merge pull request #289 from ArnasDon/fix/middleware-preserve-refreshed-cookies
fix(auth): preserve refreshed Supabase cookies across middleware redirects
This commit is contained in:
commit
12438dca78
113
src/middleware.test.ts
Normal file
113
src/middleware.test.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
// --- Scenario knobs the mock reads -----------------------------------------
|
||||||
|
// `mockUser` — what getUser() resolves to (a refreshed session ⇒ user,
|
||||||
|
// or null for the logged-out path).
|
||||||
|
// `refreshedCookies` — cookies Supabase writes via setAll() during getUser(),
|
||||||
|
// i.e. the freshly *rotated* auth token. The whole point
|
||||||
|
// of the test is that these must survive onto whatever
|
||||||
|
// response the middleware returns — including redirects.
|
||||||
|
let mockUser: { id: string } | null = null;
|
||||||
|
let refreshedCookies: Array<{
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
options: Record<string, unknown>;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
vi.mock("@supabase/ssr", () => ({
|
||||||
|
createServerClient: (
|
||||||
|
_url: string,
|
||||||
|
_key: string,
|
||||||
|
opts: {
|
||||||
|
cookies: { setAll: (c: typeof refreshedCookies) => void };
|
||||||
|
},
|
||||||
|
) => ({
|
||||||
|
auth: {
|
||||||
|
// Mirrors real auth-js: an expired access token is transparently
|
||||||
|
// refreshed inside getUser(), which rotates the refresh token and
|
||||||
|
// pushes the new cookies through setAll() before resolving.
|
||||||
|
getUser: async () => {
|
||||||
|
if (refreshedCookies.length) opts.cookies.setAll(refreshedCookies);
|
||||||
|
return { data: { user: mockUser } };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Imported after the mock is registered.
|
||||||
|
const { middleware } = await import("./middleware");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL = "https://test.supabase.co";
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = "anon-key";
|
||||||
|
mockUser = null;
|
||||||
|
refreshedCookies = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
const ROTATED = {
|
||||||
|
name: "sb-test-auth-token",
|
||||||
|
value: "rotated-refresh-token",
|
||||||
|
options: { path: "/", httpOnly: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("middleware — refreshed auth cookies survive redirects", () => {
|
||||||
|
it("carries the rotated token when redirecting a signed-in user off /login", async () => {
|
||||||
|
mockUser = { id: "user-1" };
|
||||||
|
refreshedCookies = [ROTATED];
|
||||||
|
|
||||||
|
const res = await middleware(
|
||||||
|
new NextRequest("https://app.test/login"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Redirect to /dashboard…
|
||||||
|
expect(res.status).toBe(307);
|
||||||
|
expect(res.headers.get("location")).toContain("/dashboard");
|
||||||
|
// …and the rotated cookie MUST ride along, otherwise the browser keeps
|
||||||
|
// replaying the now-consumed refresh token and the session wedges until
|
||||||
|
// the user manually clears cookies.
|
||||||
|
expect(res.cookies.get(ROTATED.name)?.value).toBe(ROTATED.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the rotated token when redirecting an unauth user to /login", async () => {
|
||||||
|
mockUser = null;
|
||||||
|
// Even on the logged-out path getUser() may emit cookie writes (e.g.
|
||||||
|
// clearing a dead session); those must not be dropped on the redirect.
|
||||||
|
refreshedCookies = [{ ...ROTATED, value: "cleared" }];
|
||||||
|
|
||||||
|
const res = await middleware(
|
||||||
|
new NextRequest("https://app.test/dashboard"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(307);
|
||||||
|
expect(res.headers.get("location")).toContain("/login");
|
||||||
|
expect(res.cookies.get(ROTATED.name)?.value).toBe("cleared");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects a signed-in user with an invite token to /join/<token>", async () => {
|
||||||
|
mockUser = { id: "user-1" };
|
||||||
|
refreshedCookies = [ROTATED];
|
||||||
|
|
||||||
|
const res = await middleware(
|
||||||
|
new NextRequest("https://app.test/login?invite=abc123"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.headers.get("location")).toContain("/join/abc123");
|
||||||
|
expect(res.cookies.get(ROTATED.name)?.value).toBe(ROTATED.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes through (no redirect) for a signed-in user on a protected page", async () => {
|
||||||
|
mockUser = { id: "user-1" };
|
||||||
|
refreshedCookies = [ROTATED];
|
||||||
|
|
||||||
|
const res = await middleware(
|
||||||
|
new NextRequest("https://app.test/dashboard"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// No redirect — the normal NextResponse.next() already carries cookies.
|
||||||
|
expect(res.headers.get("location")).toBeNull();
|
||||||
|
expect(res.cookies.get(ROTATED.name)?.value).toBe(ROTATED.value);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -25,6 +25,23 @@ export async function middleware(request: NextRequest) {
|
|||||||
|
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
|
||||||
|
// getUser() transparently refreshes an expired access token, which
|
||||||
|
// ROTATES the refresh token and writes the new cookies onto
|
||||||
|
// `supabaseResponse` via setAll() above. Any response we return in
|
||||||
|
// place of `supabaseResponse` (every redirect / JSON branch below)
|
||||||
|
// is a fresh object that does NOT carry those Set-Cookie headers, so
|
||||||
|
// the rotated token never reaches the browser. The next request then
|
||||||
|
// replays the old, now-consumed refresh token, the refresh fails, and
|
||||||
|
// the session wedges — the user gets a broken reload after idling and
|
||||||
|
// can only recover by manually clearing cookies (issue #288). Copy the
|
||||||
|
// refreshed cookies onto whatever response we hand back to fix that.
|
||||||
|
const withRefreshedCookies = <T extends NextResponse>(response: T): T => {
|
||||||
|
supabaseResponse.cookies.getAll().forEach((cookie) => {
|
||||||
|
response.cookies.set(cookie)
|
||||||
|
})
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
// Auth pages - redirect to dashboard if already logged in.
|
// Auth pages - redirect to dashboard if already logged in.
|
||||||
// Exception: when an invite token is in the query string we
|
// Exception: when an invite token is in the query string we
|
||||||
// send the already-signed-in user to /join/<token> instead so
|
// send the already-signed-in user to /join/<token> instead so
|
||||||
@ -49,7 +66,7 @@ export async function middleware(request: NextRequest) {
|
|||||||
url.pathname = '/dashboard'
|
url.pathname = '/dashboard'
|
||||||
url.search = ''
|
url.search = ''
|
||||||
}
|
}
|
||||||
return NextResponse.redirect(url)
|
return withRefreshedCookies(NextResponse.redirect(url))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Protected pages - redirect to login if not authenticated
|
// Protected pages - redirect to login if not authenticated
|
||||||
@ -57,13 +74,15 @@ export async function middleware(request: NextRequest) {
|
|||||||
if (!user && protectedPaths.some(path => request.nextUrl.pathname.startsWith(path))) {
|
if (!user && protectedPaths.some(path => request.nextUrl.pathname.startsWith(path))) {
|
||||||
const url = request.nextUrl.clone()
|
const url = request.nextUrl.clone()
|
||||||
url.pathname = '/login'
|
url.pathname = '/login'
|
||||||
return NextResponse.redirect(url)
|
return withRefreshedCookies(NextResponse.redirect(url))
|
||||||
}
|
}
|
||||||
|
|
||||||
// API routes that need auth (not webhooks)
|
// API routes that need auth (not webhooks)
|
||||||
if (!user && request.nextUrl.pathname.startsWith('/api/whatsapp/') &&
|
if (!user && request.nextUrl.pathname.startsWith('/api/whatsapp/') &&
|
||||||
!request.nextUrl.pathname.includes('/webhook')) {
|
!request.nextUrl.pathname.includes('/webhook')) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return withRefreshedCookies(
|
||||||
|
NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return supabaseResponse
|
return supabaseResponse
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user