WebView(React) + Spring Boot 구조에서 인증을 BFF(Backend for Frontend) 패턴으로 구현했다. 브라우저(WebView)는 토큰을 직접 들고 있지 않고, 백엔드가 OAuth2 + PKCE 흐름을 대신 수행한 뒤 세션 쿠키만 내려준다. IdP는 Auth0 Universal Login을 사용했다.
구현 후 팀에 공유할 기술 문서를 작성했는데, 다 쓰고 나서 검수해보니 빠진 게 세 가지 있었다. /auth/logout이 없었고, /auth/account에 “로그인된 세션이 있어야 한다”는 선행 조건이 적혀 있지 않았고, 마크다운 표가 깨져 있었다. 이 글은 그 누락을 채운 최종본을 기준으로, BFF 인증 엔드포인트 문서를 어떻게 구성해야 하는지 정리한 것이다.
왜 BFF인가
SPA나 WebView에서 OAuth2를 직접 하면 access token을 JS가 들고 있게 된다. XSS 한 번이면 토큰이 새고, WebView 환경에서는 저장소도 애매하다. BFF는 이 부담을 백엔드로 옮긴다.
- 토큰(access / refresh / id)은 백엔드 세션에만 저장
- 브라우저에는 HttpOnly, Secure, SameSite 쿠키만 전달
- 브라우저 → 백엔드 API 호출은 쿠키로 인증, 백엔드 → 외부 API는 세션의 access token으로 호출
PKCE는 authorization code를 가로채더라도 code_verifier 없이는 토큰으로 교환할 수 없게 하는 장치다. BFF가 confidential client라 client secret을 쓸 수 있더라도, PKCE를 함께 쓰는 것이 현재 권장 사항이다.
전체 흐름
WebView Spring Boot (BFF) Auth0
│ GET /auth/login │ │
├──────────────────────▶ │
│ │ state, code_verifier 생성 │
│ │ 세션에 저장 │
│ 302 → /authorize │ │
◀──────────────────────┤ │
│ GET /authorize?code_challenge=...&state=... │
├───────────────────────────────────────────────────▶
│ Universal Login (사용자 인증) │
│ 302 → /auth/callback?code=...&state=... │
◀───────────────────────────────────────────────────┤
│ GET /auth/callback │ │
├──────────────────────▶ │
│ │ state 검증 │
│ │ POST /oauth/token │
│ │ (code + code_verifier) │
│ ├────────────────────────────▶
│ ◀── access/refresh/id token ──┤
│ │ 세션에 토큰 저장 │
│ 302 → 원래 페이지 │ │
│ Set-Cookie: SESSION │ │
◀──────────────────────┤ │
엔드포인트 명세
GET /auth/login
로그인을 시작한다. 브라우저를 Auth0 Universal Login으로 리다이렉트한다.
| 항목 | 내용 |
|---|---|
| 인증 필요 | 아니오 |
| 쿼리 파라미터 | returnTo (선택) — 로그인 완료 후 돌아갈 경로. 오픈 리다이렉트 방지를 위해 같은 오리진의 상대 경로만 허용 |
| 동작 | state(CSRF 방지용 랜덤값), code_verifier, returnTo를 세션에 저장한 뒤 /authorize로 302 |
| 응답 | 302 Location: https://{tenant}.auth0.com/authorize?response_type=code&client_id=...&redirect_uri=.../auth/callback&scope=openid profile email offline_access&code_challenge=...&code_challenge_method=S256&state=... |
이미 로그인된 세션이 있으면 /authorize로 가지 않고 바로 returnTo로 302 한다. 그렇지 않으면 이미 로그인한 사용자가 새로고침할 때마다 Auth0를 왕복한다.
GET /auth/callback
Auth0가 인증 후 돌아오는 주소. 브라우저가 직접 호출하는 URL이 아니다.
| 항목 | 내용 |
|---|---|
| 인증 필요 | 아니오 (단, /auth/login에서 만든 세션이 있어야 함) |
| 쿼리 파라미터 | code, state — 정상 시 / error, error_description — 실패 시 |
| 동작 | 세션의 state와 비교 → 불일치면 400. 일치하면 code + code_verifier로 토큰 교환 → 세션에 저장 → 세션 ID 재발급(session fixation 방지) → returnTo로 302 |
| 응답 | 302 Location: {returnTo} + Set-Cookie: SESSION=...; HttpOnly; Secure; SameSite=Lax |
state 검증에 실패했거나 error 파라미터가 왔을 때 어떤 페이지로 보낼지도 문서에 적어야 한다. 이게 빠지면 프론트 쪽에서 에러 화면을 못 만든다.
GET /auth/account
현재 로그인한 사용자 정보를 반환한다.
| 항목 | 내용 |
|---|---|
| 인증 필요 | 예 — 유효한 세션 쿠키 필수 |
| 응답 (200) | { "sub": "...", "email": "...", "name": "...", "picture": "..." } — id token 클레임 기반 |
| 응답 (401) | 세션 없음 또는 만료. 프론트는 이 응답을 받으면 /auth/login으로 보낸다 |
처음 문서에서 빠져 있던 게 이 “인증 필요” 행이다. 엔드포인트만 적혀 있고 선행 조건이 없으면, 프론트 개발자는 앱 첫 진입에서 이걸 호출하고 401을 받은 뒤 “API가 깨졌다”고 생각한다. 401이 정상 응답의 일부라는 걸 명시해야 한다.
POST /auth/logout
세션을 종료한다. 처음 문서에 아예 없었던 엔드포인트다.
| 항목 | 내용 |
|---|---|
| 인증 필요 | 예 |
| 메서드 | POST — GET으로 두면 <img src="/auth/logout"> 한 줄로 로그아웃 CSRF가 가능하다 |
| 동작 | 백엔드 세션 무효화 → 세션 쿠키 삭제 → (선택) Auth0 /v2/logout으로 302 하여 IdP 세션도 종료 |
| 응답 | 302 Location: https://{tenant}.auth0.com/v2/logout?client_id=...&returnTo=... 또는 204 |
로그아웃은 두 단계라는 점이 문서에 있어야 한다. 백엔드 세션만 지우면 다음 /auth/login에서 Auth0가 사용자를 기억하고 있어 비밀번호 없이 다시 로그인된다. “완전 로그아웃”이 요구사항이면 IdP 로그아웃까지 가야 하고, 하이브리드 앱에서는 Auth0 로그아웃 후 returnTo가 앱으로 돌아오는 URL이어야 한다.
토큰 갱신 (내부 동작)
엔드포인트는 아니지만 문서에 있어야 한다. 백엔드가 외부 API를 호출하기 전에 세션의 access token 만료를 확인하고, 만료됐으면 refresh token으로 갱신한다. 브라우저는 이 과정을 모른다. refresh token마저 만료되면 세션을 지우고 API는 401을 돌려준다.
문서 검수에서 배운 것
기술 문서를 쓰고 나면 스펙(이 경우 OAuth2 / PKCE / BFF 패턴 문서)과 대조해서 빠진 게 없는지 확인하는 단계가 필요하다. 구현하면서 “당연히 아는” 것들이 문서에서 빠지기 쉽다.
- 모든 엔드포인트에 “인증 필요 여부” 행을 넣는다. 없으면 선행 조건을 모른다.
- 정상 흐름의 반대편(로그아웃, 에러 콜백)을 빠뜨리지 않는다. 로그인 문서만 있고 로그아웃 문서가 없는 경우가 정말 흔하다.
- “브라우저가 직접 호출하지 않는 URL”은 그렇다고 명시한다.
/auth/callback을 프론트에서 호출하려는 시도를 막는다. - 마크다운 표는 렌더링해서 확인한다. 셀 하나 깨지면 표 전체가 텍스트로 풀린다.
정리
| 엔드포인트 | 메서드 | 인증 | 역할 |
|---|---|---|---|
/auth/login | GET | 아니오 | PKCE 준비 후 Auth0로 리다이렉트 |
/auth/callback | GET | 아니오 | state 검증, 토큰 교환, 세션 생성 |
/auth/account | GET | 예 | 로그인 사용자 정보, 미로그인 시 401 |
/auth/logout | POST | 예 | 세션 종료 + (선택) IdP 로그아웃 |