Files
document-haness/docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-ap3-bff-session-csrf.md
T
DongHyeonkaandClaude Fable 5.1 4d50bb939a docs(keycloak): adopt the decomposition contract, fix the redirect URI, strip evaluative prose
- 계약 채택 — 독자 질문, 후보 29건(PROMOTE 24 · MERGE_INTO 4 · KEEP_IN_SSOT 1). 게시 중
  17건은 전부 유지. 저장소 keycloak-pattern 은 패턴 넷이 브랜치로 갈라져 있어 revisions 로
  tip 넷을 적었다. keycloak-session-store 는 같은 저장소 @ cdac9b8
- 게시된 기록의 redirect_uri 가 SSOT·코드와 달랐다 — OAuth2callback.html → callback.html
  (frontend/src/app.js 에서 확인). 계약 title 이 기록과 다른 7건도 기록 쪽으로 맞췄다
- 미작성 1건 작성 — 패턴 검증을 실제로 돌릴 때의 안전한 순서(Reference)
- 리뷰 100건 반영 — 설명 뒤에 붙은 평가·차례 예고·독자 오해 가정·작성 지시를 지웠다.
  삭제가 남긴 조각 4건을 고치고, 원래부터 잘려 있던 로컬 미리보기 라벨 1건도 닫았다

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:39:20 +09:00

24 KiB

id, kind, slug, title, topic, topicName, project, status, version, verifiedOn, studio, public, assets, sourceRevision, source
id kind slug title topic topicName project status version verifiedOn studio public assets sourceRevision source
d85bd6af-7599-4ef7-9407-6609927d5b5c CASE bff-session-csrf-responsibility BFF에서 Browser Token을 제거하고 Session과 CSRF를 처리한 방식 oauth-oidc-auth-boundary OAuth/OIDC 인증 경계 KeyCloak Patterns 게시 중 30 2026-08-25 https://hyeonworks.com/studio/documents/d85bd6af-7599-4ef7-9407-6609927d5b5c/edit https://hyeonworks.com/cases/bff-session-csrf-responsibility
key file
ap3-bff-custody-82fa18bd ../../../final/assets/tech-log-studio/ap3-bff-custody.svg
key file
ap3-csrf-split-501dd1f7 ../../../final/assets/tech-log-studio/ap3-csrf-split.svg
keycloak-patterns-lab@2026-08
final/document.md#검토한-선택지와-막힌-지점-ap3
final/document.md#선택의-이유와-지킨-경계-ap3
final/document.md#결정이-지켜지는지-확인하는-방법-ap3

BFF에서 Browser Token을 제거하고 Session과 CSRF를 처리한 방식

BFF 구조에서는 브라우저가 access token도 refresh token도 받지 않는다. confidential client인 BFF 서버가 code를 교환해 두 토큰을 server-side authorized client에 넣어 두고, 로그인이 끝난 브라우저는 HttpOnly가 붙은 AP3_SESSION 쿠키 하나로 BFF를 부른다.

브라우저가 토큰 대신 세션 쿠키를 받게 되면서 상태를 바꾸는 요청에도 쿠키를 자동으로 붙이게 됐고, 그래서 그 요청이 사용자가 보던 화면에서 시작한 것인지 가려낼 CSRF 검증이 새로 필요해졌다. 이때 쓰는 XSRF-TOKEN 값은 스크립트가 읽어 헤더에 다시 넣어야 해서 이 쿠키에는 HttpOnly를 붙이지 않았다. 지금 구현에서 확인한 것은 여기까지이고, 재시작한 뒤 로그인을 유지하는 것과 여러 replica가 세션을 함께 쓰는 것은 아직 구현하지 않았다.

관계

  • BFF 인증 구조 설계 기준 그 기준이 함께 설계하라고 적은 항목 가운데 지금 구현에 들어가 있는 것은 CSRF 검증 하나이고, 나머지는 아직 없다.
  • OAuth Token과 Application Session을 구분하는 기준 AP3_SESSION은 브라우저와 BFF 사이에서, access token은 BFF와 Resource Server 사이에서 쓰이고, XSRF-TOKEN은 상태를 바꾸는 요청에서만 대조된다.
  • OAuth/OIDC 인증 패턴 선택 기준 이 구조를 고르면 브라우저에서 OAuth token이 사라지는 대신 BFF가 세션과 토큰을 든 보안 구성요소가 되고, 상태 변경 요청마다 CSRF 검증이 붙는다.
  • BFF가 OAuth Token을 관리하는 조건 그 결정이 적은 대로 BFF가 code를 교환해 두 토큰을 보관하고 Resource Server도 BFF가 부르는지 이 구성에서 확인했다.
  • 서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가 지금 세션과 authorized client는 한 프로세스의 메모리에 있어서, 인스턴스를 늘리면 그 상태를 어디에 둘지부터 정해야 한다.
  • BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가 HttpSession은 session ID로 찾고 authorized client는 client registration 이름과 principal name으로 찾아서, 두 상태를 꼭 같은 저장소에 둬야 하는 것은 아니다.

문제

브라우저에 OAuth token을 두지 않으려면 code 교환과 token 보관을 서버가 맡아야 한다. BFF 구조에서는 confidential client인 BFF 서버가 code를 교환하고, access token과 refresh token을 server-side authorized client에 넣는다. 브라우저가 받는 것은 HttpOnly가 붙은 AP3_SESSION 쿠키다.

브라우저가 토큰 대신 이 세션 쿠키를 받으면서 이후 요청마다 쿠키가 자동으로 붙는다. 상태를 바꾸는 요청에서도 마찬가지라, 세션 쿠키만 봐서는 그 요청이 사용자가 보던 화면에서 시작한 것인지 알 수 없다. 그래서 상태 변경 요청에는 CSRF 검증이 새로 붙는다.

서버가 들고 있게 된 상태에도 둘 곳이 필요하다. 지금 session과 authorized client는 모두 memory에 있어서, BFF를 재시작하거나 요청이 다른 replica로 가는 경우까지 처리하려면 이 상태를 어디에 저장할지 따로 정해야 한다.

결론

브라우저가 들고 있는 자격 증명은 cookie 2개다.

AP3_SESSION : HttpOnly, JavaScript 읽기 x XSRF-TOKEN : JavaScript 읽기 o

AP3_SESSION은 브라우저가 BFF로 요청을 보낼 때마다 자동으로 실린다. 상태를 바꾸는 요청에서는 XSRF-TOKEN의 값을 X-XSRF-TOKEN 헤더에도 넣어야 하고 BFF가 그 둘을 대조한다. 스크립트가 쿠키 값을 읽어 헤더에 넣어야 하기 때문에 XSRF-TOKEN에는 HttpOnly를 붙이지 않았다.

토큰이 브라우저에서 사라졌다고 XSS까지 없어지지는 않는다. 같은 출처에서 실행되는 악성 script는 AP3_SESSION을 직접 읽지 못하지만 브라우저가 session cookie를 붙인 채 BFF를 부르게 만들 수 있고, XSRF-TOKEN은 아예 읽을 수 있다. 이 구조가 줄이는 것은 브라우저로 새어 나간 access token과 refresh token 원문을 다른 client에서 쓰거나 Resource Server로 바로 보내는 재사용이다.

브라우저에서 덜어낸 일 가운데 지금 BFF에 들어가 있는 것은 CSRF 검증 하나다. 나머지 일곱은 아직 없다.

재시작 뒤 로그인 유지 : x replica 공유 session : x 저장 token 암호화 : x logout : x downstream 오류 변환 : x timeout : x 경로별 인가 : x

검증 환경

Keycloak 26.7.0

realms
client : confidential, client_secret_basic
PKCE S256 : o
provider : authorization-code, refresh-token

store : memory
CSRF : o
HTTP : o

재현 조건

  1. UI에서 로그인하고 authorization request를 확인한다.

    client_id : bff-confidential
    code_challenge_method : S256

  2. 브라우저 요청 목록에 Keycloak token endpoint와 8081 직접 호출이 없는지 확인한다.

  3. cookie가 AP3_SESSION이며 HttpOnly와 SameSite=Lax인지 확인하고 Web Storage가 비어 있는지 확인한다.

  4. /bff/token-boundary를 호출한다.

    accessTokenStoredOnServer : true
    refreshTokenStoredOnServer : true
    browserTokenCount : 0
    csrfProtectionEnabled : true

  5. /bff/api/me가 200이고 downstream 응답에 username과 audience가 있는지 확인한다.

  6. GET /bff/csrf를 호출해 XSRF-TOKEN cookie와 token metadata를 받는지 확인한다. 응답 본문의 token과 cookie 값이 같은 문자열이 아닌지도 확인한다.

  7. session cookie는 있지만 CSRF 헤더가 없는 POST /bff/api/preferences가 403인지 확인한다.

  8. cookie의 raw 값을 X-XSRF-TOKEN에 넣은 같은 POST가 200이고 theme이 dark인지 확인한다.

  9. 127.0.0.1에서 localhost로 보내는 cross-site POST에서 AP3_SESSION이 요청에 실리지 않는지 확인한다.

본문

BFF(Backend For Frontend)는 화면에 필요한 API를 브라우저 대신 호출하고 결과만 돌려주는 서버이고, 여기서는 로그인과 토큰 보관까지 이 서버가 맡는다. 브라우저에서 OAuth 토큰을 걷어내면 브라우저가 무엇을 들고 있게 되고 서버는 무엇을 더 맡게 되는지 확인했다. 그다음 상태를 바꾸는 요청에서 새로 필요해진 검사와 아직 구현하지 않은 항목을 적는다.

브라우저가 들고 있는 자격 증명은 쿠키 2개다

:::evidence key="ap3-bff-custody-82fa18bd" alt="브라우저 안에 HttpOnly AP3_SESSION과 JavaScript가 읽을 수 있는 XSRF-TOKEN이 있고 OAuth token 칸은 점선으로 비어 있는 그림. BFF의 authorized client가 access token과 refresh token을 들고 있으며 Resource Server로 가는 Authorization Bearer 화살표는 BFF 아래에서 시작한다. 브라우저 실행 영역 전체가 실행 중 XSS가 닿는 범위로 표시돼 있다." caption="" zoom="true" :::

로그인은 브라우저가 BFF의 /oauth2/authorization/keycloak을 여는 것으로 시작한다. 인가 요청에 실리는 클라이언트는 bff-confidential이고 code_challenge_methodS256이다. 가로챈 authorization code를 그대로 바꿔 가지 못하도록 PKCE(Proof Key for Code Exchange)를 함께 걸었다. 다만 그 코드를 토큰으로 바꾸는 쪽은 브라우저가 아니다. BFF가 서버끼리 통신하면서 client_secret_basic으로 토큰 엔드포인트를 부르고, 받은 액세스 토큰과 리프레시 토큰은 OAuth2AuthorizedClientService가 관리하는 authorized client에 저장된다. 브라우저가 받는 것은 /로 돌아가는 리다이렉트와 AP3_SESSION 쿠키뿐이다.

AP3_SESSION에는 HttpOnly가 붙어 있어 브라우저에서 실행되는 스크립트가 값을 읽을 수 없고, 대신 BFF로 요청을 보낼 때마다 브라우저가 알아서 붙인다. XSRF-TOKENHttpOnly를 붙이지 않은 것은 상태를 바꾸는 요청에서 스크립트가 이 값을 읽어 X-XSRF-TOKEN 헤더에 다시 넣어야 하기 때문이다. 액세스 토큰과 리프레시 토큰 2개는 어느 응답에도 실리지 않는다.

무엇 브라우저에 있나 JavaScript가 읽나
AP3_SESSION o x
XSRF-TOKEN o o
access token x x
refresh token x x

토큰 원문이 브라우저에서 사라졌다고 XSS(Cross-Site Scripting)까지 없어지는 것은 아니다. 같은 출처에서 실행되는 악성 스크립트는 AP3_SESSION을 직접 읽지 못해도 브라우저가 쿠키를 붙인 채 BFF를 부르게 만들 수 있고, 스크립트가 읽을 수 있는 XSRF-TOKEN에도 접근한다. 이 구조가 줄이는 것은 액세스 토큰과 리프레시 토큰 원문이 브라우저 스크립트에서 새어 나가 다른 클라이언트나 보호 자원 서버 직접 호출에 다시 쓰이는 범위다.

여기서 확인한 쿠키 속성은 HTTPS가 아니라 HTTP로 띄운 구성에서 본 값이다. 쿠키 속성과 리다이렉트를 눈으로 확인하려고 HTTP를 쓰는 설정을 뒀다.

쿠키 하나로 시작한 요청이 Bearer 요청이 된다

브라우저가 /bff/api/me를 부를 때 요청에 붙는 자격 증명은 쿠키뿐이라, Authorization 헤더도 없고 브라우저 코드에는 액세스 토큰을 담는 변수도 없다.

GET http://localhost:8083/bff/api/me
Accept: application/json
Cookie: AP3_SESSION=<opaque-session-id>

AP3_SESSION 안에 토큰이 들어 있는 것은 아니다. 이 쿠키로 HttpSession을 찾고, 거기 저장된 SecurityContext에서 지금 로그인한 사용자의 Authentication을 꺼낸 다음, 그 이름으로 토큰 저장소를 조회한다.

AP3_SESSION
  → HttpSession
  → SecurityContext
  → Authentication.getName()
  → ("keycloak", principal name)
  → OAuth2AuthorizedClientService
  → access token + refresh token

이 조회를 하는 코드가 BffController.currentUser(Authentication)인데, 클라이언트 등록 이름 "keycloak"과 현재 Authentication으로 OAuth2AuthorizeRequest를 만들어 OAuth2AuthorizedClientManager.authorize()에 넘긴다. 여기에 주입된 manager는 AuthorizedClientServiceOAuth2AuthorizedClientManager이고, 코드 교환과 refresh를 각각 맡는 provider 2개를 함께 쓴다. 그래서 액세스 토큰이 만료됐고 리프레시 토큰이 있으면 서버끼리 갱신을 시도할 수 있다. 저장소에 그 항목이 없거나 쓸 수 있는 액세스 토큰이 없으면 여기서 401이 되고, 이유로는 No authorized Keycloak client is available가 붙는다.

액세스 토큰을 얻으면 BFF의 RestClient가 보호 자원 서버로 보낼 요청을 새로 조립한다.

GET http://app:8081/api/me
Authorization: Bearer <server-held-access-token>

브라우저가 보낸 AP3_SESSION은 이 요청에 실리지 않는다. BFF가 세션을 애플리케이션 쪽 자격 증명으로 소비하고, 보호 자원 서버가 이해하는 Bearer 자격 증명으로 바꾸기 때문이다. 보호 자원 서버는 AP1·AP2와 같은 무상태 JWT 경로에서 서명과 issuer, 시각, keycloak-pattern-api audience를 확인한 뒤 subject·username·issuer·audience 필드 4개짜리 JSON을 돌려주고, BFF는 그 응답을 그대로 브라우저에 중계한다. 그래서 AP3_SESSION은 브라우저와 BFF 사이에서만 쓰이고 Bearer 액세스 토큰은 BFF와 보호 자원 서버 사이에서만 쓰인다.

:::warning

Compose는 학습 편의를 위해 보호 자원 서버의 8081을 호스트 포트로도 내보낸다. 테스트가 확인하는 것은 AP3 화면이 8081을 직접 부르지 않는다는 것뿐이고, 모든 클라이언트가 BFF만 지나도록 네트워크로 강제했다는 뜻은 아니다.

:::

browserTokenCount: 0이 증명하지 못하는 것

/bff/token-boundary는 서버가 토큰을 들고 있는지를 필드 6개짜리 JSON으로 알려 준다.

{
  "pattern": "AP3-backend-for-frontend",
  "principal": "regular-user",
  "accessTokenStoredOnServer": true,
  "refreshTokenStoredOnServer": true,
  "browserTokenCount": 0,
  "csrfProtectionEnabled": true
}

이 가운데 browserTokenCount: 0은 브라우저를 검사해서 센 값이 아니라 컨트롤러가 응답에 그대로 써 넣는 고정값이다. 그래서 이 필드 하나로는 브라우저에 토큰이 없다는 것을 보일 수 없어 브라우저 쪽을 따로 확인했다. 로그인 뒤 개발자 도구에서 요청 목록을 봤을 때 Keycloak 토큰 엔드포인트 호출이 없었고, 보호 자원 서버의 8081을 직접 부르는 요청도 없었다. localStoragesessionStorage 어디에도 accessToken·refreshToken 문자열이 없었다.

self-report          /bff/token-boundary → browserTokenCount: 0
external observation 브라우저 network    → token endpoint 없음
                     Web Storage         → token 문자열 없음

앞의 것은 서버가 자기 상태를 스스로 보고한 값이고 뒤의 것은 브라우저에서 관찰한 값이다. 이 엔드포인트는 OAuth2AuthorizedClientManager.authorize()를 부르지 않고 OAuth2AuthorizedClientService에서 저장된 항목을 직접 조회한다. 그래서 여기를 호출하는 것만으로 액세스 토큰이 갱신되지는 않고, 인증된 세션인데 그 항목이 없으면 서버 보관 여부를 알리는 값 2개가 false인 200이 나올 수도 있다. 어느 경우에도 토큰 원문은 이 응답에 들어가지 않는다.

쿠키가 자동으로 붙는 요청을 CSRF 토큰으로 가른다

브라우저는 BFF로 가는 요청마다 세션 쿠키를 자동으로 붙이고 상태를 바꾸는 POST에서도 마찬가지라서, 세션 쿠키만 확인해서는 그 요청이 사용자가 보던 화면에서 시작한 것인지 알 수 없다. CSRF(Cross-Site Request Forgery) 토큰은 그 요청을 받아들일지 서버가 판단하려고 상태 변경 요청에서만 따로 대조하는 값이다.

POST를 보내기 전에 /bff/csrf를 부르면 응답 헤더로 XSRF-TOKEN 쿠키를 받는다.

HTTP/1.1 200 OK
Cache-Control: no-store
Pragma: no-cache
Set-Cookie: XSRF-TOKEN=<raw-csrf-token>; Path=/

같은 응답의 본문에도 CSRF 정보가 들어 있는데, 본문의 token은 쿠키에 들어간 값과 같은 문자열이 아니다.

{
  "headerName": "X-XSRF-TOKEN",
  "parameterName": "_csrf",
  "token": "<xor-masked-csrf-token>"
}

:::evidence key="ap3-csrf-split-501dd1f7" alt="BFF의 CSRF endpoint 하나에서 두 갈래가 갈리는 그림. 위쪽은 raw token이 담긴 XSRF-TOKEN cookie, 아래쪽은 가려진 token과 headerName이 담긴 JSON body다. 두 갈래가 POST 조립 단계로 모이지만 실제 X-XSRF-TOKEN 값은 cookie의 raw token이고 JSON에서는 headerName만 쓴다. 마지막으로 CSRF filter가 대조한다." caption="" zoom="true" :::

값이 갈리는 것은 쿠키와 응답 본문을 서로 다른 구성요소가 채우기 때문이다. 쿠키는 CookieCsrfTokenRepository.withHttpOnlyFalse()가 만들면서 원래 값을 그대로 넣는 반면, 본문에 실리는 값은 XorCsrfTokenRequestAttributeHandler가 요청 속성으로 노출하는 토큰이라 XOR와 Base64로 가려진 상태다. 그래서 브라우저 쪽 코드는 본문의 token을 헤더 값으로 쓰지 않고, 본문에서는 headerName만 읽은 다음 실제 값은 document.cookie에서 XSRF-TOKEN의 raw 값을 꺼내 그 헤더에 넣는다.

body.token          masked token
cookie XSRF-TOKEN   raw token
X-XSRF-TOKEN        raw token

같은 CSRF 자격 증명이 쿠키와 응답 본문과 요청 헤더 세 곳에 실리는데, 그중 같은 문자열인 것은 쿠키와 요청 헤더 2개다. 그래서 정상 요청은 세션 쿠키와 XSRF-TOKEN 쿠키에 더해, 쿠키에서 읽은 값을 담은 X-XSRF-TOKEN 헤더를 함께 싣는다.

POST http://localhost:8083/bff/api/preferences
Content-Type: application/x-www-form-urlencoded
Cookie: AP3_SESSION=<opaque-session-id>; XSRF-TOKEN=<raw-csrf-token>
X-XSRF-TOKEN: <same-raw-csrf-token>

theme=dark

컨트롤러보다 Spring CSRF 필터가 먼저 돈다. 필터가 저장소의 기대값과 제출된 헤더를 비교해서 헤더가 없거나 값이 다르면 컨트롤러는 실행되지 않고 403이 되고, 통과한 요청만 theme 파라미터에 값을 바인딩해 컨트롤러로 넘어간다. 가려진 본문 값과 raw 쿠키 값을 함께 맞추는 코드가 SpaCsrfTokenRequestHandler인데, 기대하는 헤더가 있으면 plain resolver로 제출된 raw 값을 읽고 없으면 XOR resolver 경로를 쓴다.

{
  "updated": true,
  "theme": "dark",
  "principal": "regular-user"
}

:::note

응답 본문의 token을 가리는 것은 BREACH 완화를 위한 처리다. HTTP 응답 압축 크기의 차이를 이용해 응답 안의 비밀값을 추측하는 것을 어렵게 하기 위해 응답에 노출되는 token 형태를 매번 다르게 만든다.

:::

SameSite와 CSRF 토큰이 각각 막는 요청

요청 4개로 두 방어선이 각각 어디서 걸리는지 확인했다. 같은 출처에서 온 요청과 포트만 다른 same-site 요청에는 세션 쿠키가 그대로 실렸고, X-XSRF-TOKEN 헤더가 없었기 때문에 둘 다 403이 됐다. 쿠키에서 읽은 값을 헤더에 넣은 same-origin 요청만 200이 됐다. 127.0.0.1에서 localhost로 보낸 cross-site POST에서는 AP3_SESSION 자체가 요청에 실리지 않았고, 이 요청에서 확인한 것은 최종 상태 코드가 아니라 쿠키가 빠졌다는 사실이다.

입력 막는 것 응답
same-origin, 헤더 없음 CSRF token 403
same-site 다른 port, 헤더 없음 CSRF token 403
cross-site POST SameSite cookie 누락
same-origin, 값 일치 통과 200

SameSite는 쿠키를 cross-site 요청에 실을지 브라우저가 정하는 정책이고, CSRF 토큰은 그 요청을 받아들일지 서버가 정하는 검사다. 포트가 달라도 site 계산상 같은 site가 되는 경우가 있어서, 쿠키가 빠지는 것만으로는 same-site 요청까지 막지 못한다.

세션과 토큰을 서버가 들면 무엇을 더 해야 하나

이 구성을 실행했을 때 브라우저 쪽 JavaScript가 받는 응답에는 OAuth 토큰이 없었다. BFF 코드를 따라가 보니 BFF가 세션에서 authorized client를 찾아 액세스 토큰을 붙이고 내부 API를 대신 부르고 있었다.

브라우저가 토큰을 들지 않게 되면서 BFF가 새로 맡는 일 가운데 지금 구현에 들어 있는 것은 상태 변경 요청의 CSRF 검증뿐이다. 재시작 뒤 로그인 유지, 레플리카가 함께 쓰는 세션, 저장한 토큰 암호화, 로그아웃 때 세션과 authorized client 삭제, downstream 오류를 화면 오류로 바꾸는 처리, 타임아웃·재시도·서킷 브레이커, 경로별 인가 7개는 아직 없다.

지금 HttpSessionOAuth2AuthorizedClientService는 한 프로세스의 메모리를 쓰기 때문에, 재시작 뒤 로그인을 유지하는 것과 여러 레플리카가 같은 세션을 쓰는 것은 이 상태를 어디에 둘지 정해야 처리할 수 있다. 토큰을 찾는 키도 세션 ID가 아니라 클라이언트 등록 이름과 principal 이름이다. 그래서 같은 사용자가 여러 브라우저 세션에서 로그인하면 그 세션들이 같은 항목을 함께 쓰거나, 나중에 로그인한 쪽이 덮어쓸 수 있다.

CSRF 검증을 눈으로 보려고 둔 theme 값도 사용자별 저장소에 들어가지 않는다. 컨트롤러 안의 AtomicReference 1개에 들어가기 때문에 한 사용자가 dark로 바꾸면 같은 프로세스의 다른 사용자도 그 값을 읽고, 재시작하면 기본값 system으로 돌아간다. 실제 사용자 상태로 쓰려면 인증된 subject를 키로 하는 저장소와 인가 경계로 옮겨야 한다.

커밋된 테스트가 확인하도록 정의한 17개

이 기록에서 확인했다고 적은 것은 마지막 실행 결과가 아니라 커밋된 자동 테스트가 확인하도록 정의한 계약이다. 항목은 17개이고 그중 10개를 확인했다.

인가 요청에는 bff-confidential 클라이언트와 S256 challenge가 있어야 하고, 로그인 뒤 브라우저 요청 목록에는 Keycloak 토큰 엔드포인트 호출과 8081 직접 호출이 없어야 한다. 쿠키는 AP3_SESSION이고 HttpOnlySameSite=Lax가 붙어 있어야 하며 Web Storage는 비어 있어야 한다. /bff/token-boundary의 서버 보관 값 2개는 true여야 하고 /bff/api/me는 200과 함께 username과 기대한 audience를 돌려줘야 한다. CSRF 헤더가 없는 POST는 403, 쿠키에서 읽은 값을 헤더에 넣은 POST는 200, cross-site POST에서는 쿠키가 빠져야 한다.

나머지 7개는 이 계약 밖이라, theme 값의 사용자별 격리와 영속성, 여러 레플리카가 함께 쓰는 세션 저장소, 저장한 토큰 암호화, 로그아웃, downstream 401을 어떤 모양으로 전달하는지, 타임아웃과 경로별 인가는 확인하지 않았다.

이 구조를 고를 때 함께 오는 서버 상태와 CSRF 검증

이 구조로 온 이유는 앞선 Mediator 구조가 리프레시 토큰만 서버로 옮기고 액세스 토큰은 브라우저 응답에 그대로 내려보냈기 때문이다. 액세스 토큰까지 브라우저에서 없애려면 코드 교환뿐 아니라 API 호출도 서버가 대신해야 했고, 그 몫을 BFF가 맡았다.

브라우저가 OAuth 토큰을 받으면 안 되고 백엔드가 화면에 필요한 여러 API를 조합해야 한다면 이 구조를 고른다. 다운스트림 API가 늘어나도 브라우저는 BFF 하나만 알면 되고, 토큰 갱신과 제공자마다 다른 처리도 서버 안에 둔다. OAuth 흐름을 브라우저에서 직접 확인하는 것이 목적이면 SPA(Single Page Application) 구조가, 브라우저의 보호 자원 서버 직접 호출을 유지해야 한다면 Mediator가 맞는다.

대신 BFF는 요청을 넘겨 주기만 하는 프록시가 아니라 로그인 상태와 토큰을 든 보안 구성요소가 됐고, 화면의 모든 요청이 이곳을 지나므로 지연과 장애 지점도 여기로 모인다. 지금 구현은 그 상태를 한 프로세스 메모리에 두고 검증 1개만 걸어 둔 단계라, 세션과 authorized client를 어디에 둘지와 저장한 토큰을 어떻게 암호화할지는 앞으로 정해야 한다.