Keycloak and Enterprise Identity
Adding Token Verification Middleware to a FastAPI App
한국어 원문으로 표시합니다.
목표
FastAPI 앱에 JWT 검증 미들웨어를 직접 붙여, 토큰 없음·만료·잘못된 대상·권한 부족을 각각 올바른 상태 코드로 구분해 처리한다.
왜 중요한가
토큰 검증 미들웨어는 대부분 라이브러리로 해결하지만, 옵션 하나를 잘못 주면 조용히 뚫립니다. 그리고 상태 코드 구분도 자주 틀립니다 — 토큰이 없거나 유효하지 않으면 401 이고, 토큰은 유효한데 권한이 부족하면 403 입니다. 이 둘을 뭉개면 클라이언트가 "다시 로그인해야 하는가"와 "권한을 요청해야 하는가"를 구별하지 못합니다. 3번 스텝의 JWKS 캐시도 실무에서 중요합니다. 매 요청 인증 서버에 공개키를 물으면 로컬 검증의 이점이 사라집니다. 하지만 무한정 캐시하면 키 회전 직후 모든 검증이 실패합니다. 캐시하되 kid 를 못 찾으면 한 번 갱신하는 것이 표준적 구현이고, 이 실습에서 그것을 직접 만듭니다.
단계
/root/app/main.py를 127.0.0.1:8160 에 띄운다. 검증 대상은 사전 임포트된 렐름labhub이고 기대aud는lab-api이다. 사용자는dev1(order-reader)과admin1(order-admin)이며 비밀번호는/opt/fixtures/kc/realm-info.env에 있다.GET /public이 인증 없이 200 을 준다.GET /me를 토큰 없이 호출하면 401 이고 응답에WWW-Authenticate헤더가 있어야 한다.- JWKS 를 메모리에 캐시한다.
GET /_debug/jwks가{"cached":true,"keys":<n>,"fetches":<n>}를 주고,/me를 5번 호출한 뒤에도fetches가 2 이하여야 한다. 이 스텝부터dev1의 액세스 토큰을/root/app/token.txt에,admin1의 것을/root/app/admin_token.txt에 한 줄씩 저장해 둔다. - 유효한 토큰으로
GET /me를 호출하면 200 과{"sub":"<값>","username":"dev1"}를 준다. - 만료된 토큰으로 호출하면 401 이고 본문에
expired가 들어 있어야 한다. 만료 토큰은/opt/fixtures/kc/expired.jwt에 있다. aud가 다른 토큰으로 호출하면 401 이고 본문에audience가 들어 있어야 한다. 그런 토큰은/opt/fixtures/kc/wrongaud.jwt에 있다.
5·6번의 실패 케이스 토큰은 파드가 뜰 때 /opt/fixtures/kc/gen-tokens.py 가 만듭니다. 두 파일이 안 보이면 그 스크립트를 직접 한 번 실행하세요 — 픽스처 디렉터리가 읽기 전용이면 /tmp/lab-kc/ 아래에 만들어집니다.
7. GET /admin 은 order-admin 롤이 있어야 200 이고, 없으면 403 이다. 401 이 아니라 403 이어야 한다.
8. /root/app/e2e.sh 로 다섯 케이스(토큰 없음, 유효, 만료, 잘못된 aud, 권한 부족)를 순서대로 시험해 /root/app/e2e.out 에 no_token=401 valid=200 expired=401 bad_aud=401 forbidden=403 을 적는다.
참고
- 401 은 '누구인지 모르겠다', 403 은 '누구인지는 알지만 권한이 없다'입니다.
- JWKS 캐시는 TTL 을 두되
kid미스 시 즉시 갱신하는 것이 표준입니다. - 시계 오차 허용(leeway)은 몇 초 이내로 아주 작게 둡니다.
- 흔한 실수 1: 권한 부족에 401 을 주는 것 — 클라이언트가 불필요하게 재로그인합니다.
- 흔한 실수 2: 검증 라이브러리에 기대
issuer와audience를 넘기지 않는 것 — 서명만 맞으면 통과합니다.
앱 띄우고 공개 엔드포인트 확인하기
/root/app/main.py 를 127.0.0.1:8160 에 띄운다. 검증 대상은 사전 임포트된 렐름 labhub 이고 기대 aud 는 lab-api 이다. 사용자는 dev1(order-reader)과 admin1(order-admin)이며 비밀번호는 /opt/fixtures/kc/realm-info.env 에 있다. GET /public 이 인증 없이 200 을 준다.
인증이 필요 없는 경로도 하나 있어야 헬스체크가 됩니다.
토큰 없으면 401 주기
GET /me 를 토큰 없이 호출하면 401 이고 응답에 WWW-Authenticate 헤더가 있어야 한다.
401 에는 어떤 인증이 필요한지 알려 주는 헤더를 함께 줘야 합니다.
JWKS 캐시 구현하기
JWKS 를 메모리에 캐시한다. GET /_debug/jwks 가 {"cached":true,"keys":<n>,"fetches":<n>} 를 주고, /me 를 5번 호출한 뒤에도 fetches 가 2 이하여야 한다. 이 스텝부터 dev1 의 액세스 토큰을 /root/app/token.txt 에, admin1 의 것을 /root/app/admin_token.txt 에 한 줄씩 저장해 둔다.
매 요청 인증 서버에 물으면 그것이 병목입니다. 캐시하되 키를 못 찾으면 갱신하세요.
유효 토큰 통과시키기
유효한 토큰으로 GET /me 를 호출하면 200 과 {"sub":"<값>","username":"dev1"} 를 준다.
서명 검증과 클레임 검사를 모두 해야 합니다. 응답에 주체 식별자를 담으세요.
만료 토큰 거부하기
만료된 토큰으로 호출하면 401 이고 본문에 expired 가 들어 있어야 한다. 만료 토큰은 /opt/fixtures/kc/expired.jwt 에 있다.
만료 시각이 지난 토큰으로 시험합니다. 시계 오차를 위한 여유는 아주 작게 두세요.
잘못된 대상 토큰 거부하기
aud 가 다른 토큰으로 호출하면 401 이고 본문에 audience 가 들어 있어야 한다. 그런 토큰은 /opt/fixtures/kc/wrongaud.jwt 에 있다.
같은 서버가 발급했어도 우리 API 를 위한 것이 아니면 거부해야 합니다.
롤 기반 인가 붙이기
GET /admin 은 order-admin 롤이 있어야 200 이고, 없으면 403 이다. 401 이 아니라 403 이어야 한다.
인증은 진입점에서, 인가는 자원 지점에서 합니다. 권한이 없으면 401 이 아니라 403 입니다.
다섯 가지 케이스 한 번에 검증하기
/root/app/e2e.sh 로 다섯 케이스(토큰 없음, 유효, 만료, 잘못된 aud, 권한 부족)를 순서대로 시험해 /root/app/e2e.out 에 no_token=401 valid=200 expired=401 bad_aud=401 forbidden=403 을 적는다.
앞의 케이스들을 한 스크립트로 묶어 기대 상태 코드와 비교합니다.