client credentials とトークン交換(STS を自分で立てる)
한국어 원문으로 표시합니다.
목표
Keycloak 에서 client credentials 로 서비스 토큰을 받아 확인하고, Keycloak 26.0.7 에 없는 표준 토큰 교환(RFC 8693)을 로컬 STS 로 직접 세워 사용자 토큰을 하류 전용 위임 토큰으로 바꾼 뒤, 하류가 aud 와 act 를 검사하는지와 위조 토큰이 거절되는지를 실제 요청으로 증명한다.
왜 중요한가
서비스가 사용자를 대신해 다른 서비스를 부를 때, 받은 사용자 토큰을 그대로 넘기면 하류는 aud 검사를 꺼야 받아 줄 수 있고, 누가 대신 불렀는지도 모르며, 권한도 줄지 않는다. 한 곳에서 샌 토큰이 체인 전체의 열쇠가 된다. 서비스 자기 이름의 토큰(client credentials)만 쓰면 이번에는 누구를 위한 요청인지가 사라진다. 토큰 교환은 사용자 신원은 잇되 audience 를 하류로 좁히고 act 에 행위자를 남겨 이 둘을 함께 푼다. 이때 STS 가 subject_token 을 검증하지 않으면 위조 토큰을 자기 서명으로 세탁해 주는 기계가 되고, 하류가 aud 와 act 를 보지 않으면 교환을 한 의미가 없다. Keycloak 은 26.2 부터 표준 교환을 정식 지원하므로, 버전이 모자라면 무엇을 직접 해야 하는지도 이 실습에서 본다.
단계
- Keycloak 이 꺼져 있으면
lab-start-keycloak으로 켜고,/root/st/exchange/wait.sh로http://127.0.0.1:8080/realms/labhub가 200 이 될 때까지 최대 240초 기다린 뒤/root/st/exchange/ready.txt에ready_seconds=<정수>를 적는다. - 기밀 클라이언트
api-svc로 client credentials 토큰을 받아/root/st/exchange/svc_token.txt에 저장하고, 서명을 검증한 뒤/root/st/exchange/svc_claims.txt에azp=,aud=,username=,roles=,refresh_token=을 적는다. - Keycloak 에 token-exchange grant 로 요청한 결과를
/root/st/exchange/kc_exchange.txt에kc_exchange_error=,token_exchange_advertised=로 적고, 로컬 STS/root/st/exchange/sts.py를 127.0.0.1:8308 에 띄운다(/health,/jwks, 서명 키/root/st/exchange/sts_key.pem). - dev1 토큰을 subject_token, api-svc 토큰을 actor_token 으로
audience=orders-api교환을 받아/root/st/exchange/exchanged_token.txt에 두고, 검증한 값을/root/st/exchange/exchanged.txt에 적는다. 도우미는/root/st/exchange/tokens.sh로 만든다. - 하류 서비스
/root/st/exchange/downstream.py를 127.0.0.1:8309 에 띄우고, 네 가지 토큰으로GET /orders를 부른 상태코드를/root/st/exchange/downstream.txt에 적는다. - 위조·변조·서명 없는 subject_token, actor_token 누락, 모르는 audience, 정상 대조군을 STS 에 보내
/root/st/exchange/reject.txt에 적는다. /root/st/exchange/e2e.sh로 전체 흐름을 시험해/root/st/exchange/e2e.out에 일곱 줄을 남긴다.
참고
- 렐름 정보(토큰 URL, 클라이언트, 사용자, 비밀)는
cat /opt/fixtures/kc/realm-info.env로 봅니다. 비밀을 파일이나 코드에 옮겨 적지 마세요. - 토큰 타입 식별자는
urn:ietf:params:oauth:token-type:access_token, grant 는urn:ietf:params:oauth:grant-type:token-exchange입니다. - 이 이미지에는 python-multipart 가 없습니다. STS 는
http.server로 짜고 form 은urllib.parse.parse_qs로 읽습니다. - 흔한 실수 1: STS 가
jwt.decode(..., options={"verify_signature": False})로 subject_token 을 읽기만 하는 것 — 6단계의 위조 토큰이 전부 통과합니다. - 흔한 실수 2: 하류가 서명만 보고 aud 와 act 를 보지 않는 것 — Keycloak 원 토큰이나 다른 하류용 토큰까지 받아 줍니다.
Keycloak 준비될 때까지 기다리기
Keycloak 이 꺼져 있으면 lab-start-keycloak 으로 켜고, /root/st/exchange/wait.sh 로 http://127.0.0.1:8080/realms/labhub 가 200 이 될 때까지 최대 240초 기다린 뒤 걸린 시간을 /root/st/exchange/ready.txt 에 ready_seconds=<정수> 로 적는다.
Keycloak 은 JVM 이라 40~90초가 걸립니다. 고정 sleep 대신 상태코드를 폴링하는 루프를 쓰세요. lab-status 로 무엇이 떠 있는지 볼 수 있습니다.
client credentials 로 서비스 토큰 받기
기밀 클라이언트 api-svc 로 grant_type=client_credentials 토큰을 받아 /root/st/exchange/svc_token.txt 첫 줄에 저장하고, 렐름 JWKS 로 서명을 검증한 뒤 /root/st/exchange/svc_claims.txt 에 azp=, aud=(쉼표로 이음), username=, roles=, refresh_token=present|absent 를 적는다. 클라이언트 비밀은 /opt/fixtures/kc/realm-info.env 의 KC_CONFIDENTIAL_SECRET 에서 읽고 파일에 옮겨 적지 않는다.
client credentials 는 사용자 없이 서비스 자기 이름으로 받는 토큰입니다(RFC 6749 4.4). 토큰 엔드포인트는 realm-info.env 의 KC_TOKEN_URL 입니다. 누구의 토큰인지는 azp 와 preferred_username 이 말해 줍니다. 응답 JSON 에 refresh_token 이 있었는지도 확인하세요.
Keycloak 의 한계 확인하고 로컬 STS 띄우기
Keycloak 에 grant_type=urn:ietf:params:oauth:grant-type:token-exchange 로 요청해 돌아온 error 값을 kc_exchange_error=, discovery 문서의 grant_types_supported 에 그 grant 가 있는지를 token_exchange_advertised=yes|no 로 /root/st/exchange/kc_exchange.txt 에 적는다. 그다음 로컬 STS /root/st/exchange/sts.py 를 127.0.0.1:8308 에 띄운다. GET /health 는 {"ok":true}, GET /jwks 는 kid 가 있는 RSA 공개키를 주고, 서명 개인키는 /root/st/exchange/sts_key.pem 에 둔다. POST /token 은 RFC 8693 교환을 처리한다(4·6단계에서 채점).
이 파드의 Keycloak 은 26.0.7 이고 표준 토큰 교환(V2)은 26.2 부터입니다. discovery 주소는 발급자 뒤에 /.well-known/openid-configuration 입니다. STS 는 subject_token 을 Keycloak JWKS 로 검증해야 하고(PyJWT 의 PyJWKClient), form 본문은 parse_qs 로 읽습니다.
사용자 토큰을 orders-api 전용 토큰으로 교환
dev1 의 사용자 토큰(web-app, password grant)을 subject_token 으로, api-svc 의 client credentials 토큰을 actor_token 으로 STS 에 POST /token(audience=orders-api) 해 교환 토큰을 받는다. 새 토큰은 원 사용자의 sub 를 잇고, act 에 행위자의 sub·client_id 를 담고, roles 는 원 사용자 역할 중 그 하류에 필요한 것만 담고, 수명은 원 토큰(900초)보다 짧아야 한다. 토큰을 /root/st/exchange/exchanged_token.txt 에 두고 STS 공개키로 검증해 /root/st/exchange/exchanged.txt 에 issued_token_type=, iss=, aud=, sub_matches=yes|no, act_client_id=, roles=, ttl= 을 적는다. 발급·교환 도우미는 /root/st/exchange/tokens.sh 로 만들어 이후 단계에서 쓴다.
RFC 8693 에서 subject_token 과 subject_token_type 은 필수이고, 응답에는 issued_token_type 이 반드시 있어야 합니다. 토큰 타입 식별자는 urn:ietf:params:oauth:token-type:access_token 입니다. curl 은 --data-urlencode 로 값을 넘기세요.
하류가 aud 와 act 를 검사하게 하기
하류 서비스 /root/st/exchange/downstream.py 를 127.0.0.1:8309 에 띄운다. GET /orders 는 STS 가 서명했고 aud=orders-api 이며 act.client_id 가 api-svc 인 토큰에만 200 과 {"user":..., "actor":...} 를 주고, 나머지는 모두 401 을 준다. 네 요청의 상태코드를 /root/st/exchange/downstream.txt 에 exchanged=(정상 교환 토큰), keycloak_direct=(Keycloak 원 사용자 토큰), other_aud=(billing-api 용 교환 토큰), no_act=(STS 키로 서명했지만 act 가 없는 토큰) 로 적는다.
PyJWT 의 jwt.decode 에 audience 와 issuer 를 주면 aud·iss 검사를 함께 합니다. act 는 PyJWT 가 모르는 클레임이라 직접 확인해야 합니다. RFC 8693 은 접근 제어에 가장 바깥 act 만 쓰라고 합니다.
위조 subject_token 과 모르는 audience 거절
STS 가 검증에 실패하는 요청을 거절하는지 시험해 /root/st/exchange/reject.txt 에 적는다. tampered=(서명은 그대로 두고 sub 만 바꾼 토큰), forged_key=(다른 RSA 키로 서명하고 kid 만 Keycloak 것을 흉내 낸 토큰), alg_none=(서명 없는 토큰), no_actor=(actor_token 없이) 는 상태코드를, bad_audience= 는 audience=admin-api 일 때의 error 값을, control= 은 정상 요청의 상태코드를 적는다.
RFC 8693 은 subject_token 이 무효면 invalid_request, 요청한 audience 로 발급할 수 없으면 invalid_target 을 쓰라고 합니다. 서명 검증 없이 디코드만 하는 STS, algorithms 를 못박지 않은 STS 가 여기서 드러납니다. 채점기는 렐름 키로 서명된 만료 토큰과 aud 가 다른 토큰도 보냅니다.
전체 흐름 한 번에 증명
/root/st/exchange/e2e.sh 로 전체 흐름을 한 번에 시험해 /root/st/exchange/e2e.out 에 keycloak_exchange=(Keycloak 의 교환 거절 error), exchange=, act=, downstream=, direct_keycloak=, forged_subject=(서명을 망가뜨린 subject_token 의 상태코드), bad_audience= 일곱 줄을 남긴다.
앞 단계들을 tokens.sh 도우미로 이어 붙이면 됩니다. 모든 값은 파일에 손으로 적지 말고 요청 결과로 얻으세요. 채점기는 파일과 함께 교환→하류 흐름을 한 번 더 직접 확인합니다.