Sessions and Tokens — From the Browser to the Mesh
Keeping tokens out of the browser with a BFF
한국어 원문으로 표시합니다.
목표
한 파드 안에 발급자(8312)·하류 API(8311)·BFF(8310)를 세우고, access token 이 브라우저가 볼 수 있는 곳에 한 번도 나타나지 않으며 BFF 를 거친 호출만 하류에서 통한다는 것을 실제 HTTP 요청으로 증명한다.
왜 중요한가
SPA 가 토큰을 직접 쥐면 그 페이지에서 도는 스크립트 하나만 오염돼도 토큰이 밖으로 나가고, 공격자는 만료까지 그 토큰을 쓴다. RFC 10017(OAuth 2.0 for Browser-Based Applications) 6.1절의 BFF 는 토큰을 서버 쪽 세션에만 두고 브라우저에는 HttpOnly 세션 쿠키만 준다. BFF 는 기밀 클라이언트로서 코드를 토큰으로 바꾸고, 하류로 넘길 때 쿠키를 떼고 Bearer 토큰을 붙인다. 이 구조는 한 곳만 느슨해도 무너진다 — 세션 확인 응답에 토큰을 싣거나, 하류가 토큰 없는 요청을 받아 주거나, 로그아웃이 토큰을 폐기하지 않으면 BFF 는 이름만 남는다. 이 실습은 그 세 틈을 각각 요청으로 확인하게 한다.
단계
/root/st/bff/app.py하나로 발급자(127.0.0.1:8312), 하류 API(127.0.0.1:8311), BFF(127.0.0.1:8310)를 띄운다. 세 곳 모두GET /health가 200 과{"ok":true}를 준다.GET /login→ 발급자POST /authorize→ BFF/callback순서로 로그인하고, 콜백이 내려 준Set-Cookie한 줄을/root/st/bff/cookie.txt에 저장한다. 쿠키는__Host-Http-bff이름에HttpOnly,Secure,SameSite=Strict,Path=/를 갖는다.- Redis 의
bff:sess:<세션ID>에access_token이 있고 그 값이 브라우저가 받은 응답의 헤더·본문 어디에도 없는지/root/st/bff/exposure.txt에 적는다. - bob 으로 로그인한 쿠키로
GET /bff/api/me를 불러 BFF 가 Bearer 를 붙여 하류에서 200 과 sub=bob 을 받고, 하류에 쿠키가 넘어가지 않는지/root/st/bff/proxy.txt에 적는다. - 하류
/me를 토큰 없이, 세션 쿠키만, 엉터리 Bearer 로 직접 불러 모두 401 과WWW-Authenticate: Bearer인지/root/st/bff/direct.txt에 적는다. POST /bff/api/notes가 올바른X-CSRF-Token일 때만 201 이고 없거나 틀리면 403 인지/root/st/bff/csrf.txt에 적는다.curl -X POST로 로그아웃한 뒤 BFF 세션이 사라지고 옛 access token 이 하류에서 401 인지/root/st/bff/logout.txt에 적는다./root/st/bff/e2e.sh로 전체 흐름을 시험해/root/st/bff/e2e.out에 여섯 줄을 남긴다.
참고
- 서버는
setsid nohup python3 /root/st/bff/app.py > /root/st/bff/app.log 2>&1 &로 띄우고, 코드를 고쳤다면pkill -f /root/st/bff/app.py로 내린 뒤 다시 띄웁니다. - 실습 파드에는 python-multipart 가 없어 폼 본문은
urllib.parse.parse_qs로 직접 읽습니다. - 실습은 흐름을 줄이려고 PKCE 와 발급자의 로그인 화면을 생략했습니다. 실무에서 쓰는 발급자라면 그 설정부터 확인하세요.
- 흔한 실수 1: 세션 확인 응답이나 JS 가 읽는 쿠키로 토큰을 한 번 더 내려 주는 것 — 토큰을 브라우저에서 치운 의미가 사라집니다.
- 흔한 실수 2: 로그아웃에서 BFF 세션만 지우는 것 — 새어 나간 적 없는 토큰이라도 발급자 쪽에서는 만료까지 살아 있습니다.
발급자·하류 API·BFF 띄우기
/root/st/bff/app.py 하나로 발급자(127.0.0.1:8312), 하류 API(127.0.0.1:8311), BFF(127.0.0.1:8310)를 띄운다. 세 곳 모두 GET /health 가 200 과 {"ok":true} 를 준다.
한 프로세스 안에서 ThreadingHTTPServer 셋을 스레드로 돌리면 띄우고 내리기가 한 번에 끝납니다. 서버는 백그라운드로 띄우고 세 포트가 모두 응답할 때까지 폴링하세요.
브라우저가 받는 것은 세션 쿠키 하나
GET /login → 발급자 POST /authorize(폼 user=alice&pw=wonderland) → BFF /callback 순서로 로그인하고, 콜백이 내려 준 Set-Cookie 한 줄을 /root/st/bff/cookie.txt 에 저장한다. 쿠키 이름은 __Host-Http-bff 이고 HttpOnly, Secure, SameSite=Strict, Path=/ 를 가지며 Domain 은 없다.
curl -w '%{redirect_url}' 로 302 의 Location 을 받아 다음 요청에 넘기면 브라우저처럼 흐름을 밟을 수 있습니다. 발급자에 계정을 보내는 요청은 POST 입니다. RFC 10017 6.1.3.2 가 HttpOnly 와 Secure 를 MUST 로 둡니다.
토큰은 BFF 세션에만 있다
로그인 뒤 Redis 의 bff:sess:<세션ID>(JSON)에 access_token 이 있고, 그 값이 콜백 응답과 GET /bff/session 응답의 헤더·본문 어디에도 없는지 대조해 /root/st/bff/exposure.txt 에 token_in_store=yes, token_in_body=no, token_in_cookie=no 로 적는다. GET /bff/session 은 auth·user·csrf 만 준다.
세션 ID 는 쿠키 병에서, 진짜 토큰은 redis-cli GET 으로 꺼냅니다. 그 문자열을 grep -F 로 응답 파일들에서 찾아보세요. 세션 확인 응답에 편의상 토큰을 싣는 순간 BFF 는 이름만 남습니다.
BFF 가 Bearer 를 붙여 하류를 부른다
bob/builder 로 로그인한 쿠키로 GET /bff/api/me 를 부르면 BFF 가 쿠키를 떼고 Authorization: Bearer <토큰> 을 붙여 하류 GET /me 로 넘겨 200 과 {"sub":"bob"} 을 받는다. 하류 GET /inspect 는 cookie_forwarded 로 쿠키가 넘어왔는지 알려 준다. 결과를 /root/st/bff/proxy.txt 에 me=200, sub=bob, cookie_forwarded=False 로 적는다.
BFF 는 /bff/api/ 뒤의 경로를 하류로 옮기고, 요청 헤더를 통째로 복사하지 말고 Authorization 을 새로 만드세요. 하류가 토큰 없는 요청을 401 로 막아야 BFF 경유 200 이 곧 BFF 가 토큰을 붙였다는 증거가 됩니다.
하류를 직접 부르면 401
BFF 를 건너뛰고 하류 GET http://127.0.0.1:8311/me 를 토큰 없이, BFF 세션 쿠키만 들고, 엉터리 Bearer 값으로 각각 불러 모두 401 이고 WWW-Authenticate: Bearer 가 붙는지 /root/st/bff/direct.txt 에 no_token=401, cookie_only=401, bogus_bearer=401, www_authenticate=Bearer 로 적는다. 토큰 없는 POST /notes 도 401 이어야 한다.
하류는 쿠키를 보지 않고 Authorization 헤더만 봅니다. 토큰이 살아 있는지는 발급자의 introspection 에 물어 판단하세요. RFC 6750 3절이 401 응답에 붙일 헤더를 정합니다.
BFF 의 쓰기는 CSRF 토큰으로 지킨다
GET /bff/session 의 csrf 값을 X-CSRF-Token 헤더로 보낼 때만 POST /bff/api/notes(폼 text=...)가 하류로 넘어가 201 이 되고, 헤더가 없거나 틀리면 403 이며 하류에 기록되지 않는지 /root/st/bff/csrf.txt 에 no_header=403, wrong=403, right=201 로 적는다.
브라우저는 쿠키를 자동으로 싣기 때문에 쿠키만으로는 위조 요청과 구별이 안 됩니다. 세션에 CSRF 토큰을 함께 넣어 두고 hmac.compare_digest 로 비교하세요. 거절은 하류를 부르기 전에 해야 합니다.
로그아웃은 세션과 토큰을 함께 끝낸다
로그인 뒤 curl -X POST 로 POST /logout(쿠키와 X-CSRF-Token 포함)을 부르고, 같은 쿠키의 GET /bff/session 이 auth False, Redis 의 bff:sess:<세션ID> 가 사라짐, 로그아웃 전에 꺼내 둔 access token 으로 하류 /me 를 직접 부르면 401 인지 /root/st/bff/logout.txt 에 session_auth=False, store_key_gone=yes, old_token_direct=401 로 적는다.
세션 키만 지우면 발급자 쪽 토큰은 만료까지 살아 있습니다. 로그아웃에서 BFF 가 발급자의 폐기 엔드포인트(RFC 7009)를 먼저 부르세요. -X POST 를 빼면 curl 은 GET 을 보냅니다.
전체 흐름 한 번에 증명
/root/st/bff/e2e.sh 로 로그인부터 로그아웃까지 시험해 /root/st/bff/e2e.out 에 cookie_httponly=yes, token_exposed=no, via_bff=200, direct_no_token=401, csrf_missing=403, after_logout_token=401 여섯 줄을 남긴다. 채점기는 e2e.sh 를 다시 돌려 같은 결과가 나오는지도 본다.
앞 스텝들을 한 스크립트로 이어 붙입니다. 스크립트는 자기가 만든 임시 파일을 지우고, 결과는 표준 출력으로만 내면 다시 돌려도 안전합니다.