Skip to content
Open
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
46 changes: 46 additions & 0 deletions docs/qa-auth-cookies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# QA 인증 쿠키 이름 분리

같은 도메인 구조를 유지하면서 QA는 `qa_access_token` / `qa_refresh_token`만 사용한다. 운영은 `access_token` / `refresh_token`을 유지한다. 부모 Domain `.uttae.app` 쿠키는 QA 호스트에도 전송된다. 이 변경은 이름 선택 충돌을 줄이며, 별도 등록 도메인 수준의 격리·토큰 발급자 격리·전체 보안 문제 해결을 제공하지 않는다.

## FE 설정

| 키 | QA 값 | 미설정 기본값 / 용도 |
| --- | --- | --- |
| `AUTH_ACCESS_COOKIE_NAME` | `qa_access_token` | `access_token` |
| `AUTH_REFRESH_COOKIE_NAME` | `qa_refresh_token` | `refresh_token` |
| `API_BASE_URL` | `https://api.qa.uttae.app` | 기본값 없음, 서버 BFF/세션/약관 조회 |
| `NEXT_PUBLIC_API_BASE_URL` | `https://api.qa.uttae.app` | 기본값 없음, 브라우저 직접 API/WS |
| `NEXT_PUBLIC_GOOGLE_REDIRECT_URI` | `https://qa.uttae.app/auth/callback` | 기존 OAuth 설정과 일치 필요 |

새 쿠키 이름 두 값은 함께 설정한다. 둘 다 없으면 운영/일반 dev 기본값을 사용한다. 하나만 있거나 빈 값·중복·잘못된 쿠키 이름이면 인증 처리를 실패시킨다. `NODE_ENV=production`은 QA의 Next 프로덕션 빌드에도 적용되므로 환경 선택에 사용하지 않는다. 호스트 자동 판별이나 운영 이름 fallback은 없다. QA에 두 값을 모두 누락하면 기본값을 사용하므로 등록 시 반드시 확인한다.

두 이름은 서버 요청 처리에서 읽는 비밀이 아닌 설정이다. `NEXT_PUBLIC_` 접두사를 붙이거나 `next.config`의 `env`에 넣지 않는다. `API_BASE_URL`은 기존대로 서버 모듈 초기화 시 읽으므로 새 서버 인스턴스에도 제공해야 한다. 빌드의 route 수집에도 기존 필수 변수를 제공한다. `NEXT_PUBLIC_*`는 `next build` 때 번들에 고정되므로 QA용 값으로 빌드해야 한다. 운영 빌드를 QA로 단순 이동하지 않는다. [Next.js 환경 변수 문서](https://nextjs.org/docs/app/guides/environment-variables).

Vercel에서는 실제 QA 도메인을 담당하는 프로젝트/환경/브랜치 범위에 값을 등록하고 새 배포를 생성해야 한다. 환경 변수 수정은 기존 배포를 바꾸지 않는다. QA가 Vercel의 Production 환경을 쓰는 별도 프로젝트인지 Preview/Custom 환경인지는 운영 담당이 실제 연결을 확인한다. 전체 Preview나 운영 프로젝트에 무조건 등록하지 않는다. 이 PR은 등록·배포를 수행하지 않는다. [Vercel 환경 변수 관리](https://vercel.com/docs/environment-variables/managing-environment-variables).

## 경로별 계약

- proxy의 보호 경로/로그인/초대 진입과 `/api/auth/session`: 선택한 이름만 서버 검증에 전달하고, 해당 refresh 이름으로만 회전한다. refresh 후 일시 장애 때 새 쿠키를 보존하는 기존 정책도 유지한다.
- `/api/auth/refresh`, `/api/auth/logout`: 선택한 두 이름만 BE로 전달한다. 운영-only QA 요청은 운영 refresh를 전달하거나 폐기하지 않는다.
- Google 로그인/가입/재동의 및 세션·refresh·logout 응답: 선택한 두 이름의 Set-Cookie만 전달한다. Domain/Path/Expires/Max-Age/HttpOnly/Secure/SameSite는 변환하지 않는다. 다른 환경 이름과 무관한 쿠키는 인증 BFF에서 중계하지 않는다. `getSetCookie`가 없는 구형 응답의 합쳐진 헤더는 거부한다.
- 브라우저 직접 API, 초대 API, `DELETE /users/me`, SockJS/WS 핸드셰이크는 기존 credentials 흐름을 유지한다. 브라우저가 부모 도메인 쿠키도 전송하므로 **BE가 QA 이름만 선택해야 한다**. FE만으로 직접 요청을 격리할 수 없다. WS 세션 복구는 기존 BFF refresh/session 경로를 사용한다.
- 조회 캐시·이벤트 갱신·화면·재연결 정책은 변경하지 않는다.

## BE 동기화 및 전환

BE 별도 변경의 계약은 `cookie.access-name=qa_access_token`, `cookie.refresh-name=qa_refresh_token`, QA Domain `.qa.uttae.app`, 기존 Path 유지다. 운영은 기본 이름과 `.uttae.app`을 유지한다. 실제 BE PR의 최종 키·QA 프로필·로그아웃/탈퇴 만료 Domain/Path와 직접 API/WS 적용을 배포 전에 확인한다. 새 secret이나 서명 키/DB 비밀번호 회전은 필요하지 않다.

1. FE/BE 변경과 QA 설정을 함께 준비한다. QA 로그인·사용을 일시 중지한 전환 창에서 BE QA 이름 적용 후, 같은 이름으로 구성한 FE 새 배포를 연결한다. 운영 설정은 변경하지 않는다.
2. 기존 QA 로그인은 새 이름을 갖지 않으므로 QA 재로그인이 필요하다. 운영 쿠키를 복사하거나 fallback하지 않는다.
3. 활성화 전 운영-only QA 비인증, 동시 쿠키 양쪽 순서, 로그인/가입/재동의, refresh, WS 재연결, logout/탈퇴가 QA 쿠키만 만료시키는지 확인한다. 실제 계정·통합 검증은 별도 소유 로컬 QA에서 수행한다.

| 혼합 상태 | 영향 |
| --- | --- |
| 새 FE QA 이름 + 구 BE 기본 이름 | BFF가 운영 이름을 제거하고 구 BE 발급 쿠키를 버리므로 로그인/세션/refresh 불능 가능. 직접 API/WS는 구 BE이므로 격리 보장 없음. 지원되는 운영 상태가 아님. |
| 구 FE + 새 BE QA 이름 | 초기 세션은 원시 Cookie 전달로 동작할 수 있으나 자동 refresh는 구 FE의 기본 refresh 이름 검사로 누락되거나 부모 운영 쿠키 존재 여부에 좌우됨. 지속 로그인 보장 없음. |
| 새 FE 기본 이름 + 새 BE QA 이름 | FE가 QA 이름을 전달/중계하지 않아 인증 불능. FE 두 변수 등록 필요. |
| 양쪽 새 버전 + 일치한 QA 설정 | 의도한 이름 분리. 직접 API/WS/탈퇴의 최종 검증은 BE 통합 증거 필요. |

롤백은 QA 사용을 중지하고 FE/BE와 설정을 호환되는 조합으로 함께 되돌리는 방식으로 계획한다. 가능하면 새 QA 이름을 유지하는 수정 버전을 사용한다. 구 버전/기본 이름으로 되돌리면 원래 이름 충돌 위험도 되돌아오므로 격리 완료로 취급하지 말고 QA를 재개하지 않는다. 한쪽만 롤백하거나 운영 이름 fallback을 추가하지 않는다. 운영 세션/refresh를 폐기하지 않는다.

이 구현은 legacy QA 쿠키를 자동 삭제하지 않는다. 필요 시 운영 담당이 기존 QA 발급의 정확한 Domain/Path를 먼저 확인한 후 그 범위만 정리한다. `.uttae.app` 쿠키 만료, 광범위한 Clear-Site-Data, 운영 refresh 무효화는 금지한다. 소유 범위를 확인할 수 없다면 삭제하지 않는다.
4 changes: 2 additions & 2 deletions src/app/api/auth/logout/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";

import { forwardBackendSetCookies } from "@/lib/auth-cookies";
import { authCookieHeader, forwardBackendSetCookies } from "@/lib/auth-cookies";
import { requiredEnv } from "@/lib/required-env";

const API_BASE = requiredEnv("API_BASE_URL");
Expand All @@ -10,7 +10,7 @@ export async function POST(request: NextRequest) {
method: "POST",
headers: {
"Content-Type": "application/json",
cookie: request.headers.get("cookie") ?? "",
cookie: authCookieHeader(request.headers.get("cookie")),
},
});

Expand Down
4 changes: 2 additions & 2 deletions src/app/api/auth/refresh/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";

import { forwardBackendSetCookies } from "@/lib/auth-cookies";
import { authCookieHeader, forwardBackendSetCookies } from "@/lib/auth-cookies";
import { requiredEnv } from "@/lib/required-env";

const API_BASE = requiredEnv("API_BASE_URL");
Expand All @@ -10,7 +10,7 @@ export async function POST(request: NextRequest) {
method: "POST",
headers: {
"Content-Type": "application/json",
cookie: request.headers.get("cookie") ?? "",
cookie: authCookieHeader(request.headers.get("cookie")),
},
});

Expand Down
115 changes: 115 additions & 0 deletions src/lib/auth-cookie-isolation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { NextRequest, NextResponse } from "next/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

vi.hoisted(() => { process.env.API_BASE_URL = "http://backend.test"; });
import { sessionCheckResponse, verifySessionWithOptionalRefresh } from "./auth-server";
import { forwardBackendSetCookies } from "./auth-cookies";
import { POST as refresh } from "@/app/api/auth/refresh/route";
import { POST as login } from "@/app/api/auth/google/route";
import { POST as signup } from "@/app/api/auth/google/signup/route";
import { POST as reaccept } from "@/app/api/auth/google/reaccept/route";
import { POST as logout } from "@/app/api/auth/logout/route";

// All cookie values here are synthetic fixtures, never real credentials.
const prod = "access_token=prod-access; refresh_token=prod-refresh";
const qa = "qa_access_token=qa-access; qa_refresh_token=qa-refresh";
const rotated = [
"qa_access_token=qa-new; Domain=.qa.uttae.app; Path=/; HttpOnly; Secure; SameSite=None",
"qa_refresh_token=qa-new-refresh; Domain=.qa.uttae.app; Path=/; HttpOnly; Secure; SameSite=None",
];
const fetchMock = vi.fn<typeof fetch>();
function response(code: number, cookies: string[] = []) {
const headers = new Headers();
cookies.forEach(cookie => headers.append("set-cookie", cookie));
return new Response(null, { status: code, headers });
}
function request(cookie: string) {
return new NextRequest("https://qa.uttae.app/api/auth/session", { headers: { cookie } });
}
beforeEach(() => {
vi.stubEnv("AUTH_ACCESS_COOKIE_NAME", "qa_access_token");
vi.stubEnv("AUTH_REFRESH_COOKIE_NAME", "qa_refresh_token");
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => { vi.unstubAllEnvs(); vi.unstubAllGlobals(); });

describe("QA auth cookie boundary", () => {
it("rejects production-only cookies without contacting even an old backend", async () => {
fetchMock.mockResolvedValue(response(200));
const res = await sessionCheckResponse(request(prod));
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ authenticated: false });
expect(fetchMock).not.toHaveBeenCalled();
});
it.each([`${prod}; ${qa}`, `${qa}; ${prod}`])("forwards only QA names in either header order: %s", async cookie => {
fetchMock.mockResolvedValue(response(200));
expect((await verifySessionWithOptionalRefresh(cookie)).ok).toBe(true);
expect(fetchMock.mock.calls[0][1]?.headers).toEqual({ cookie: qa });
});
it("refreshes with QA-only refresh and retries using the rotated QA cookies", async () => {
fetchMock.mockResolvedValueOnce(response(401)).mockResolvedValueOnce(response(200, rotated)).mockResolvedValueOnce(response(200));
const res = await sessionCheckResponse(request("qa_refresh_token=qa-refresh"));
expect(res.status).toBe(200);
expect(res.headers.getSetCookie()).toEqual(rotated);
expect(fetchMock.mock.calls[1][0]).toBe("http://backend.test/auth/refresh");
expect(fetchMock.mock.calls[2][1]?.headers).toEqual({ cookie: "qa_refresh_token=qa-new-refresh; qa_access_token=qa-new" });
});
it("does not refresh QA access with a production refresh cookie", async () => {
fetchMock.mockResolvedValue(response(401));
expect((await verifySessionWithOptionalRefresh("qa_access_token=expired; refresh_token=prod-refresh")).ok).toBe(false);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it.each([refresh, logout])("filters request and deletion cookies at the BFF boundary", async handler => {
const deletion = "qa_refresh_token=; Domain=.qa.uttae.app; Path=/; Max-Age=0; HttpOnly";
fetchMock.mockResolvedValue(response(200, [deletion, "refresh_token=; Domain=.uttae.app; Path=/; Max-Age=0"]));
const res = await handler(request(`${prod}; ${qa}`));
expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ cookie: qa });
expect(res.headers.getSetCookie()).toEqual([deletion]);
});
it("never relays old-backend production issuance or deletion through QA", () => {
const res = NextResponse.json({});
forwardBackendSetCookies(res, response(200, [...rotated, "access_token=old; Domain=.uttae.app; Path=/", "refresh_token=; Domain=.uttae.app; Max-Age=0"]));
expect(res.headers.getSetCookie()).toEqual(rotated);
});
it("rejects ambiguous combined Set-Cookie when getSetCookie is unavailable", () => {
const backend = response(200, [rotated[0], "refresh_token=; Domain=.uttae.app; Max-Age=0"]);
Object.defineProperty(backend.headers, "getSetCookie", { value: undefined });
const res = NextResponse.json({});
forwardBackendSetCookies(res, backend);
expect(res.headers.getSetCookie()).toEqual([]);
});
it.each([503, "network"])("preserves QA rotation when retry is unavailable: %s", async failure => {
fetchMock.mockResolvedValueOnce(response(401)).mockResolvedValueOnce(response(200, [...rotated, "access_token=prod-new; Domain=.uttae.app; Path=/"]));
if (failure === "network") fetchMock.mockRejectedValueOnce(new TypeError("fetch failed"));
else fetchMock.mockResolvedValueOnce(response(failure));
const res = await sessionCheckResponse(request(`${prod}; ${qa}`));
expect(res.status).toBe(503);
expect(res.headers.getSetCookie()).toEqual(rotated);
});
it.each([login, signup, reaccept])("relays only QA issuance on login/signup/reaccept", async handler => {
fetchMock.mockResolvedValue(response(200, [...rotated, "access_token=prod-access; Domain=.uttae.app; Path=/"]));
const res = await handler(new NextRequest("https://qa.uttae.app/api/auth/google", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: "synthetic-code", signupToken: "synthetic-signup", reacceptanceToken: "synthetic-reaccept", agreementsAccepted: true }),
}));
expect(res.headers.getSetCookie()).toEqual(rotated);
});
it.each([
[undefined, "qa_refresh_token"], ["qa_access_token", undefined],
["", "qa_refresh_token"], ["same", "same"], ["bad;name", "refresh"],
])("fails closed for incomplete or invalid name configuration", async (access, refreshName) => {
vi.stubEnv("AUTH_ACCESS_COOKIE_NAME", access);
vi.stubEnv("AUTH_REFRESH_COOKIE_NAME", refreshName);
await expect(verifySessionWithOptionalRefresh(prod)).rejects.toThrow("auth cookie names");
expect(fetchMock).not.toHaveBeenCalled();
});
it.each(["production", "development"])("keeps default %s names and excludes QA cookies", async mode => {
vi.stubEnv("NODE_ENV", mode);
vi.stubEnv("AUTH_ACCESS_COOKIE_NAME", undefined);
vi.stubEnv("AUTH_REFRESH_COOKIE_NAME", undefined);
fetchMock.mockResolvedValue(response(200));
expect((await verifySessionWithOptionalRefresh(`${qa}; ${prod}`)).ok).toBe(true);
expect(fetchMock.mock.calls[0][1]?.headers).toEqual({ cookie: prod });
});
});
44 changes: 42 additions & 2 deletions src/lib/auth-cookies.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,42 @@
import type { NextResponse } from "next/server";

/** Server-only configuration; intentionally not NEXT_PUBLIC build-time values. */
export function authCookieNames(): { access: string; refresh: string } {
const access = process.env.AUTH_ACCESS_COOKIE_NAME;
const refresh = process.env.AUTH_REFRESH_COOKIE_NAME;
if (access === undefined && refresh === undefined) {
return { access: "access_token", refresh: "refresh_token" };
}
const cookieName = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
if (!access || !refresh || access === refresh ||
!cookieName.test(access) || !cookieName.test(refresh)) {
throw new Error("Invalid auth cookie names: configure both distinct cookie names or neither");
}
return { access, refresh };
}

/** Only the selected environment's auth cookies may reach the backend. */
export function authCookieHeader(cookieHeader: string | null): string {
const { access, refresh } = authCookieNames();
return (cookieHeader ?? "")
.split(";")
.map((part) => part.trim())
.filter((part) => {
const eq = part.indexOf("=");
return eq > 0 && [access, refresh].includes(part.slice(0, eq));
})
.join("; ");
}

/** Fetch `Response`의 Set-Cookie 목록 (Node 18+ `getSetCookie`) */
export function getSetCookieHeaders(res: Response): string[] {
if (typeof res.headers.getSetCookie === "function") {
return res.headers.getSetCookie();
}
const single = res.headers.get("set-cookie");
// A combined header cannot safely be authorized by its first cookie name.
// Expires dates contain commas, but not a new name=value after the comma.
if (single && /,\s*[^;,\s]+=/.test(single)) return [];
return single ? [single] : [];
}

Expand All @@ -18,10 +49,19 @@ export function appendSetCookies(
}
}

/** BFF → 브라우저: 백엔드 Set-Cookie 전부 전달 */
/** Preserve attributes verbatim, but never relay another environment's auth cookies. */
export function getAuthSetCookieHeaders(res: Response): string[] {
const { access, refresh } = authCookieNames();
return getSetCookieHeaders(res).filter((cookie) => {
const eq = cookie.indexOf("=");
return eq > 0 && [access, refresh].includes(cookie.slice(0, eq).trim());
});
}

/** BFF → 브라우저: 선택한 인증 쿠키만 전달 */
export function forwardBackendSetCookies(
res: NextResponse,
backendRes: Response,
): void {
appendSetCookies(res, getSetCookieHeaders(backendRes));
appendSetCookies(res, getAuthSetCookieHeaders(backendRes));
}
13 changes: 9 additions & 4 deletions src/lib/auth-server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { NextRequest, NextResponse } from "next/server";

import { appendSetCookies, getSetCookieHeaders } from "@/lib/auth-cookies";
import {
appendSetCookies,
authCookieHeader,
authCookieNames,
getAuthSetCookieHeaders,
} from "@/lib/auth-cookies";
import { requiredEnv } from "@/lib/required-env";

const API_BASE = requiredEnv("API_BASE_URL");
Expand Down Expand Up @@ -35,7 +40,7 @@ function mergeSetCookiesIntoCookieHeader(
}

function cookieHeaderHasRefreshToken(cookieHeader: string): boolean {
return parseCookieHeader(cookieHeader).has("refresh_token");
return parseCookieHeader(cookieHeader).has(authCookieNames().refresh);
}

type SessionVerifyResult = {
Expand Down Expand Up @@ -112,7 +117,7 @@ function warnSessionFetchFailed(label: string): void {
export async function verifySessionWithOptionalRefresh(
cookieHeader: string | null,
): Promise<SessionVerifyResult> {
const cookie = cookieHeader?.trim() ?? "";
const cookie = authCookieHeader(cookieHeader);
if (!cookie.length) {
return unauthenticatedResult();
}
Expand Down Expand Up @@ -145,7 +150,7 @@ export async function verifySessionWithOptionalRefresh(
return unauthenticatedResult();
}

const setCookies = getSetCookieHeaders(refreshRes);
const setCookies = getAuthSetCookieHeaders(refreshRes);
const retryCookie = mergeSetCookiesIntoCookieHeader(cookie, setCookies);
const retryMe = await fetchUsersMe(retryCookie);
// refresh가 성공했으면 이전 refresh_token은 이미 폐기됐다. 재확인이 장애로 실패해도
Expand Down
Loading