본문으로 건너뛰기

Next.js API 프록시 인증 문제

이 페이지에서는 lumie-frontend/app/api/[...path]/route.ts, src/shared/api/base.tsproxy.ts의 인증에 민감한 부분을 다룹니다.

증상

  • localhost에서 로그인이 성공한 것으로 보이지만 Chromium은 세션 쿠키를 유지하지 않습니다.
  • Safari는 토큰을 새로 고치지만 여전히 401 응답을 반복합니다.
  • 동일한 자격 증명이 정식 호스트에서는 동작하지만 커스텀 도메인 로그인은 403을 반환합니다.
  • 인증된 API 호출은 브라우저 디코딩 오류로 인해 localhost 프록시를 통해서만 실패합니다.
  • 새로 고침 토큰이 여전히 유효하더라도 1시간 액세스 토큰이 만료되는 즉시 탐색이 다시 로그인으로 돌아갑니다.

진단

의도된 런타임 계약은 다음과 같습니다.

  • lumie-frontend/src/shared/config/env.tsENV.API_URL/api로 고정하므로 브라우저 코드는 상대 경로 /api를 호출합니다.
  • lumie-frontend/app/api/[...path]/route.ts는 해당 요청을 NEXT_PUBLIC_API_BASE로 전달하고, localhost용 쿠키를 다시 쓰며, 브라우저 전용 헤더를 제거하고, 커스텀 도메인 요청에는 테넌트 컨텍스트를 삽입합니다.
  • lumie-frontend/src/shared/api/base.ts는 클라이언트 측 단일 실행 refresh 흐름을 수행합니다.
  • lumie-frontend/proxy.ts는 페이지 라우팅 인증을 처리하며, 이제 탐색 허용 여부를 판단할 때 유효한 액세스 토큰뿐 아니라 유효한 refresh 토큰도 허용합니다.

이 계층 중 하나라도 우회되거나 일부만 되돌아가면 인증 실패가 바로 드러납니다.

근본 원인

Chromium에서 로컬호스트 쿠키가 사라짐

프록시는 localhost에서 Set-Cookie를 재생하기 전에 DomainSecure를 제거합니다. 그 부분이 꼭 필요하지만 충분하지는 않습니다. Secure가 제거된 후에도 SameSite=None가 남아 있으면 Chromium은 자동으로 쿠키를 거부합니다.

app/api/[...path]/route.ts의 현재 수정은 Secure를 제거한 뒤 SameSite=NoneSameSite=Lax로 다시 씁니다. 프록시 모드 인증은 localhost 기준 자사 요청이므로 이 방식이 동작합니다.

Safari 새로 고침 루프

src/shared/api/base.ts는 이제 tryRefreshToken() 안에서 refresh 응답 본문을 소비합니다. 이 읽기가 없으면 Safari가 /api/v1/refresh에서 200을 받더라도 Set-Cookie 적용이 늦어져, 재시도 요청이 여전히 만료된 액세스 토큰을 보낼 수 있습니다.

커스텀 도메인 로그인 403

프록시는 서버 간 홉에서 브라우저 Origin 헤더를 전달하면 안 됩니다. 이제 app/api/[...path]/route.tsorigin을 제거하고, 커스텀 도메인의 /api/v1/login 또는 /api/v1/register 요청에 대해 테넌트를 확인한 뒤 전달 전에 X-Tenant-Slug를 설정합니다.

인증된 JSON 응답의 디코딩 실패

Node fetch 구현은 업스트림 gzip 또는 Brotli 본문을 투명하게 압축 해제하지만, 프록시 응답에는 원래 Content-EncodingContent-Length 헤더가 남을 수 있습니다. 이 헤더를 그대로 전달하면 브라우저가 두 번째 디코딩을 시도합니다. app/api/[...path]/route.ts는 이제 프록시 응답을 반환하기 전에 두 헤더를 모두 제거합니다.

첫 번째 만료된 액세스 토큰 탐색 시 세션 손실

이전 proxy.ts는 단기 액세스 토큰만으로 경로 접근을 판단했습니다. 현재 readRoutingRole() 구현은 lumie_access_token이 없을 때 lumie_refresh_token을 대체 신호로 사용합니다. 덕분에 첫 번째 API 401에서 src/shared/api/base.ts의 클라이언트 refresh 흐름이 액세스 토큰을 갱신할 때까지 페이지가 마운트된 상태로 남습니다.

수정

  • /api를 기준으로 브라우저 API 호출을 유지합니다. next.config.ts에서 제거된 /api 다시 쓰기를 복원하지 마십시오.
  • 프록시 쿠키 다시 쓰기를 함께 유지합니다. Domain를 제거하고, Secure를 제거하고, 로컬 호스트 프록시 모드를 위해 SameSite=NoneSameSite=Lax로 다운그레이드합니다.
  • 프록시 인증 쓰기 요청에서는 origin을 제거하고, 커스텀 도메인 로그인 및 등록 요청의 테넌트 확인을 유지합니다.
  • tryRefreshToken()에서 refresh 응답 본문을 계속 소비합니다.
  • 경로 인증과 API refresh 책임을 분리합니다. proxy.ts는 탐색을 계속할 수 있는지 판단하고, src/shared/api/base.ts는 실제 토큰 refresh를 수행합니다.

예방 및 검증

  • lumie-frontend/src/shared/config/env.ts가 여전히 브라우저 트래픽을 /api로 가리키는지 확인합니다.
  • lumie-frontend/next.config.ts/api/:path*가 아닌 /ws/:path*만 다시 작성하는지 확인하세요.
  • lumie-frontend/app/api/[...path]/route.ts가 여전히 origin, content-encodingcontent-length를 제거하고 여전히 localhost에 대해 Set-Cookie를 다시 작성하는지 확인합니다.
  • 결과를 해석하기 전에 lumie-frontend/src/shared/api/base.ts가 여전히 refresh 응답 본문을 읽는지 확인하세요.
  • 라우팅 역할을 계산할 때 lumie-frontend/proxy.ts가 여전히 lumie_access_token에서 lumie_refresh_token으로 대체되는지 확인합니다.

운영 세부사항

프록시는 브라우저 규칙과 backend 인증 규칙 사이의 compatibility boundary입니다. 대부분의 회귀는 이를 단순 pass-through로 취급할 때 발생합니다.

관심사브라우저 쪽 규칙upstream 쪽 규칙
localhost cookieChromium과 Safari가 받아들일 수 있어야 합니다.backend는 production-oriented cookie attribute를 낼 수 있습니다.
custom domain브라우저 origin은 학교 domain입니다.backend에는 raw browser origin이 아니라 tenant context가 필요합니다.
compressed response브라우저는 proxy가 이미 decode한 body를 받습니다.upstream은 원래 compression header를 보고할 수 있습니다.
만료된 access tokenrefresh token이 유효하면 page는 mounted 상태로 남아야 합니다.API client가 refresh와 retry를 수행합니다.

debugging은 이 경계를 따라야 합니다. 브라우저 관점에서는 유효한 요청이어도 server-to-server hop 전에 header 조정이 필요할 수 있습니다.

복구 절차

  1. 실패가 보이는 브라우저에서 재현합니다. Chromium과 Safari는 서로 다른 cookie timing 실패를 드러냅니다.
  2. proxied response header, 특히 Set-Cookie, Content-Encoding, Content-Length를 봅니다.
  3. browser call이 /api를 사용하고 frontend proxy를 우회하지 않는지 확인합니다.
  4. custom-domain login에서는 forwarding 전에 X-Tenant-Slug가 주입되고 origin이 제거되는지 확인합니다.
  5. 첫 navigation expiry에서는 refresh token이 남아 있고 proxy.ts가 page mount를 허용하는지 확인합니다.
  6. 그 다음 backend auth log를 봅니다. backend 401도 frontend proxy mutation 회귀가 원인일 수 있습니다.

검증 매트릭스

이 영역을 건드릴 때는 각 행에서 하나 이상 확인합니다.

시나리오기대 결과
Chromium localhost loginrefresh 후에도 session cookie가 유지됩니다.
Safari refresh첫 만료 access token이 refresh를 유발하고 retry가 성공합니다.
Custom-domain loginlogin/register 요청에 resolved tenant slug가 포함되고 403이 아닙니다.
Gzip 또는 Brotli API responsebrowser가 double-decode 오류를 내지 않습니다.
Access token 만료, refresh token 유효protected page가 즉시 login으로 redirect되지 않습니다.

피해야 할 패턴

  • app route proxy가 auth mutation을 소유하는데 next.config.ts에 broad /api rewrite를 복원하는 것.
  • custom-domain CORS 증상을 해결하려고 browser Origin을 upstream으로 전달하는 것.
  • proxy가 이미 decoded body를 받은 뒤 upstream Content-Encoding을 그대로 반환하는 것.
  • page-routing auth와 API token refresh를 같은 책임으로 취급하는 것.