Sessions and Tokens — From the Browser to the Mesh
Token revocation — introspection, revocation, blocklist
한국어 원문으로 표시합니다.
목표
Keycloak 이 발급한 토큰을 RFC 7009 로 폐기하고 RFC 7662 introspection 으로 그 결과를 확인한 뒤, 리소스 서버에서 로컬 검증·introspection·jti 차단목록 세 방식이 폐기된 토큰에 어떻게 반응하는지 실제 요청으로 증명한다.
왜 중요한가
서명된 JWT 는 리소스 서버가 혼자 검증하므로, 발급자에서 세션을 끝내도 API 는 그 사실을 모르고 토큰은 만료까지 통과한다. RFC 7009 도 자기 완결 토큰의 즉시 폐기에는 별도 연동이 필요하고 대안은 짧은 수명이라고 적는다. 그래서 설계자는 세 가지 중에서 고른다 — 매번 발급자에게 묻는 introspection(즉시, 대신 왕복과 가용성 결합), 리소스 서버의 차단목록(즉시, 대신 상태 저장소), 짧은 TTL(단순, 대신 최대 수명만큼 늦음). 이 실습은 세 방식을 한 서버에 나란히 두고 폐기된 토큰 하나가 각각에서 200 인지 401 인지, 그리고 그 대가가 몇 밀리초인지를 직접 재게 한다.
단계
- Keycloak 이 꺼져 있으면
lab-start-keycloak으로 켜고,/root/st/revoke/wait.sh로http://127.0.0.1:8080/realms/labhub가 200 이 될 때까지 기다린다(최대 240초). 걸린 시간을/root/st/revoke/ready.txt에ready_seconds=<정수>로 적는다. /opt/fixtures/kc/realm-info.env를 source 해서 공개 클라이언트web-app의 비밀번호 그랜트(사용자dev1)로 토큰을 받고, 응답 본문 JSON 전체를/root/st/revoke/token.json에 저장한다./root/st/revoke/introspect.sh <토큰>이 introspection 엔드포인트를api-svc자격으로 불러 응답 JSON 을 그대로 출력하게 만든다. 비밀은 env 에서 읽는다. 새 토큰은active=true, 가짜는active=false./root/st/revoke/revoke.sh <refresh 토큰>이 RFC 7009 폐기 엔드포인트에client_id=web-app,token,token_type_hint=refresh_token을 보내게 만들고, 폐기 전후 결과를/root/st/revoke/revoke.txt에before=true,after=false,refresh_after=400으로 적는다./root/st/revoke/api.py를 127.0.0.1:8307 에 띄운다(/health, JWKS 로만 보는/local/me, 요청마다 introspection 하는/introspect/me)./root/st/revoke/bench.py로 두 경로를 10번 이상 재서/root/st/revoke/latency.txt에n,local_ms,introspect_ms,access_ttl,max_exposure_s를 적는다.api.py에GET /guarded/me(차단목록revoked:jti:<jti>확인)와POST /logout(jti 를 남은 수명만큼 차단목록에 올리고 refresh 를 Keycloak 에서 폐기)을 더하고 다시 띄운다./root/st/revoke/e2e.sh로 전체 흐름을 시험해/root/st/revoke/e2e.out에 일곱 줄을 남긴다.
참고
- 렐름 주소·클라이언트·사용자·비밀은
lab-env또는cat /opt/fixtures/kc/realm-info.env로 봅니다. 스크립트에는 값을 적지 말고source합니다. - 폐기 엔드포인트는 무효 토큰에도 200 을 줍니다. 먹혔는지는 introspection 으로 확인합니다.
- 흔한 실수 1: 액세스 토큰만 폐기하는 것 — 이 Keycloak 에서는 refresh 가 살아 있어 새 액세스 토큰이 계속 나옵니다. 로그아웃은 refresh 를 폐기해야 합니다.
- 흔한 실수 2: 차단목록 항목에 TTL 을 걸지 않는 것 — 만료된 토큰까지 영원히 쌓입니다. 남은 수명만큼만 기억하면 됩니다.
Keycloak 켜고 렐름 준비 기다리기
Keycloak 이 꺼져 있으면 lab-start-keycloak 으로 켜고, /root/st/revoke/wait.sh 로 http://127.0.0.1:8080/realms/labhub 가 200 이 될 때까지 기다린다(최대 240초). 걸린 시간을 /root/st/revoke/ready.txt 에 ready_seconds=<정수> 로 적는다.
Keycloak 은 JVM 이라 포트가 열린 뒤에도 렐름 적재까지 시간이 더 걸립니다. 고정 sleep 대신 while 루프로 상태 코드를 폴링하세요. 켜져 있는지는 lab-status 로 봅니다.
web-app 으로 dev1 토큰쌍 받기
/opt/fixtures/kc/realm-info.env 를 source 해서 공개 클라이언트 web-app 의 비밀번호 그랜트(grant_type=password, 사용자 dev1)로 토큰을 받고, 응답 본문 JSON 전체를 /root/st/revoke/token.json 에 저장한다. access_token·refresh_token·expires_in 이 있어야 한다.
토큰 엔드포인트는 env 의 KC_TOKEN_URL 입니다. 공개 클라이언트는 비밀 없이 client_id 만 보냅니다. 비밀번호에 특수문자가 있으니 --data-urlencode 를 쓰세요.
introspection 으로 active 확인
/root/st/revoke/introspect.sh <토큰> 이 RFC 7662 introspection 엔드포인트(/realms/labhub/protocol/openid-connect/token/introspect)를 기밀 클라이언트 api-svc 자격으로 불러 응답 JSON 을 그대로 출력하게 만든다. 비밀은 파일에 적지 말고 realm-info.env 에서 읽는다. 새 토큰은 active=true, 가짜 문자열은 active=false 가 나와야 한다.
introspection 은 호출자 인증이 필수입니다(토큰 스캐닝 방지). curl -u 로 Basic 인증을 주고 token 을 form 으로 POST 합니다. 비활성 토큰에는 active 말고 아무 정보도 오지 않는 것이 정상입니다.
RFC 7009 로 refresh 토큰 폐기
/root/st/revoke/revoke.sh <refresh 토큰> 이 RFC 7009 폐기 엔드포인트(/realms/labhub/protocol/openid-connect/revoke)에 client_id=web-app, token, token_type_hint=refresh_token 을 보내게 만든다. 새 토큰쌍으로 폐기 전후 access 토큰의 introspection 과 폐기 뒤 refresh 그랜트 결과를 /root/st/revoke/revoke.txt 에 before=true, after=false, refresh_after=400 으로 적는다.
폐기 엔드포인트는 성공이든 무효 토큰이든 200 이라 응답 코드만으로는 먹혔는지 알 수 없습니다. 결과는 introspection 으로 확인하세요. 공개 클라이언트라도 client_id 를 빠뜨리면 거절됩니다.
로컬 검증 vs introspection — 폐기와 지연
/root/st/revoke/api.py 를 127.0.0.1:8307 에 띄운다. GET /health 는 200, GET /local/me 는 Bearer 토큰을 JWKS 로만 검증(서명·exp·iss·aud=lab-api)하고, GET /introspect/me 는 요청마다 introspection 의 active 로 판정한다(둘 다 성공 200, 실패 401). 그리고 /root/st/revoke/bench.py 로 두 경로를 같은 토큰으로 N 번 이상(10 이상) 재서 /root/st/revoke/latency.txt 에 n=, local_ms=, introspect_ms=, access_ttl=(토큰의 exp-iat), max_exposure_s=(로컬 검증만 할 때 폐기된 토큰이 살 수 있는 최대 초)를 적는다.
PyJWT 의 PyJWKClient 가 JWKS 를 받아 캐시합니다. 로컬 경로는 발급자에게 묻지 않으니 폐기된 토큰도 만료까지 200 이 나옵니다 — 이 스텝에서는 그게 맞는 결과이고, 그 창의 길이가 곧 토큰 수명입니다. introspection 결과를 캐시하면 폐기가 늦게 반영됩니다.
jti 차단목록과 로그아웃
/root/st/revoke/api.py 에 GET /guarded/me(로컬 검증 뒤 Redis 키 revoked:jti:<jti> 가 있으면 401)와 POST /logout(Bearer 토큰의 jti 를 revoked:jti:<jti> 로 토큰의 남은 수명만큼 TTL 을 걸어 저장하고, form 본문의 refresh_token 을 RFC 7009 로 Keycloak 에서 폐기한 뒤 200)을 더하고 다시 띄운다. 로그아웃 뒤 같은 토큰으로 /guarded/me 가 401 이어야 한다.
TTL 은 exp 에서 지금 시각을 뺀 값입니다. 만료된 토큰은 서명 검증에서 어차피 거절되니 그 뒤로 기억할 이유가 없습니다. 차단목록만으로는 Keycloak 쪽 세션이 살아 있어 refresh 로 새 토큰을 받을 수 있으니 폐기 요청도 함께 보내세요. 코드를 고쳤으면 서버를 내렸다 다시 띄워야 합니다.
폐기 전략을 한 흐름으로 증명
/root/st/revoke/e2e.sh 로 새 토큰을 받아 introspection → /guarded/me → POST /logout → /guarded/me·/local/me → introspection → refresh 그랜트 순으로 시험해 /root/st/revoke/e2e.out 에 introspect_before=true, guarded_before=200, logout=200, guarded_after=401, local_after=200, introspect_after=false, refresh_after=400 을 한 줄씩 남긴다.
앞 스텝의 introspect.sh 와 서버를 그대로 씁니다. 채점기는 파일만 보지 않고 e2e.sh 를 새로 한 번 더 돌려 같은 일곱 줄이 나오는지 봅니다. local_after 가 200 인 것이 짧은 TTL 이 메우는 창입니다.