- 계약 채택 — 독자 질문, 후보 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>
6.8 KiB
id, kind, slug, title, topic, topicName, project, status, version, basisVersion, studio, sourceRevision, source
| id | kind | slug | title | topic | topicName | project | status | version | basisVersion | studio | sourceRevision | source | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 75c6c657-3e03-47a0-a9d0-5637fce9dd3f | CONCEPT | authorization-code-and-pkce | Authorization Code와 PKCE가 보호하는 구간 | oauth-oidc-auth-boundary | OAuth/OIDC 인증 경계 | KeyCloak Patterns | 게시 전 | 4 | Keycloak 26.7.0 · oidc-client-ts | https://hyeonworks.com/studio/documents/75c6c657-3e03-47a0-a9d0-5637fce9dd3f/edit | keycloak-patterns-lab@2026-08 |
|
Authorization Code와 PKCE가 보호하는 구간
authorization code는 로그인을 마친 사용자가 애플리케이션으로 돌아올 때 잠시 들고 오는 교환용 값이다. 이 code를 액세스 토큰으로 바꾸는 구간을 PKCE가 보호한다. authorization request에는 code_challenge를 담아 보내고, token request에는 그 원본인 code_verifier를 보내 두 값이 대응하는지 확인한다. 두 값이 맞아야 교환이 끝난다.
관계
- Authorization Code Flow의 Endpoint와 Credential 이동 기준 이 개념을 endpoint별 기준으로 정리한 기록이다.
- Public Client와 Confidential Client 구분 기준 클라이언트 종류에 따라 token endpoint의 인증 방식이 달라진다.
- SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계 브라우저가 code를 직접 교환한 구성이다.
본문
code를 한 번 더 교환하는 이유
로그인 한 번에 요청은 두 번 오간다. 브라우저가 먼저 Keycloak으로 이동하고, 로그인이 끝나면 authorization code를 들고 redirect URI로 돌아온다. 이 code로는 아직 API를 부를 수 없다. OAuth 클라이언트가 code를 token endpoint에 제출해야 액세스 토큰을 받는다.
authorization request는 브라우저 전체가 옮겨 가는 요청(full-page navigation)이어서 그 URL이 주소창과 히스토리와 Authorization Server 접근 로그에 남는다. 그래서 이 요청에는 그런 곳에 남아도 되는 값만 code로 싣고, 액세스 토큰은 별도 요청의 body로 받는다. 교환을 둘로 나눈 덕분에 액세스 토큰이 브라우저 주소창을 지나지 않는다.
PKCE 값이 어디서 만들어져 어디서 확인되는지, 두 요청을 순서대로 본다.
authorization request에 들어가는 challenge
oidc-client-ts가 만드는 요청의 핵심 모양은 다음과 같다.
GET http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/auth
?client_id=spa-public
&redirect_uri=http%3A%2F%2Flocalhost%3A8088%2Fcallback.html
&response_type=code
&scope=openid%20profile%20email
&state=<opaque-state>
&code_challenge=<opaque-challenge>
&code_challenge_method=S256
response_type=code는 Authorization Code Flow를 쓴다는 뜻이고, code_challenge와 code_challenge_method=S256은 PKCE를 쓴다는 뜻이다. state와 challenge 값은 요청마다 달라진다.
state와 PKCE verifier는 Keycloak에 갔다가 돌아오는 사이에도 있어야 해서 브라우저가 들고 있는다. AP1은 이 둘을 Session Storage에 두고 Keycloak 왕복을 건넌다.
token request가 제출하는 verifier
callback으로 돌아온 code는 다음 요청으로 교환된다.
POST http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&client_id=spa-public
&code=<authorization-code>
&redirect_uri=http://localhost:8088/callback.html
&code_verifier=<original-verifier>
code_verifier는 authorization request를 시작할 때 만든 원본 값이다. Authorization Server는 challenge와 이 verifier가 대응하는지 확인한 뒤에 교환을 끝낸다. 이 확인이 authorization request를 시작한 클라이언트와 code를 교환하는 주체를 이어 준다.
다만 커밋된 AP1 브라우저 테스트가 직접 보는 것은 authorization request의 challenge와 token request의 endpoint, grant_type=authorization_code까지다. body에 실린 code_verifier와 client_id, redirect_uri, code 값을 하나씩 비교하지는 않는다.
S256과 plain의 차이
verifier에서 challenge를 만드는 방법은 두 가지다. plain은 verifier를 그대로 challenge로 보내는데, 중간에서 challenge를 본 쪽은 그 값을 그대로 verifier로 쓸 수 있다. S256은 verifier에 SHA-256을 적용한 값을 challenge로 보내기 때문에 challenge만으로는 verifier를 되돌릴 수 없다.
| method | challenge 값 | 중간에서 challenge를 본 경우 |
|---|---|---|
plain |
verifier 그대로 | 그대로 verifier로 쓸 수 있다 |
S256 |
verifier의 SHA-256 | verifier를 되돌릴 수 없다 |
AP1 realm은 S256을 요구한다. AP1 코드에는 createPkcePair()라는 손으로 만든 보조 함수도 있다. 이 함수는 무작위 32바이트를 패딩 없는 Base64URL verifier로 바꾸고, 거기에 SHA-256을 적용한 challenge와 "S256"을 함께 반환한다. 다만 로그인을 시작하는 signinRedirect()는 이 함수를 부르지 않는다. 화면의 PKCE 데모 버튼이 쓰는 코드이고, 실제 로그인은 버전을 고정한 oidc-client-ts가 수행한다.
PKCE가 막지 않는 것
PKCE는 탈취된 authorization code의 교환을 어렵게 한다. 이미 발급된 액세스 토큰을 숨겨 주지는 않는다. 브라우저가 토큰을 직접 다루는 구성에서 실행 중인 악성 스크립트가 Bearer 토큰을 읽거나 사용자 권한으로 API를 부르는 것은 PKCE가 막는 문제가 아니다.
state는 PKCE 값과 하는 일이 다르다. state는 돌아온 callback이 브라우저가 처음 시작한 트랜잭션의 것인지 대조하고, verifier는 code를 교환하는 주체를 authorization request를 시작한 클라이언트에 묶는다.
클라이언트 종류에 따라 달라지는 인증
spa-public은 secret이 없는 public client다. token endpoint에서 클라이언트 인증을 하지 않고 PKCE만 사용한다.
confidential client는 여기에 클라이언트 인증을 더한다. AP3의 bff-confidential은 client_secret_basic으로 자기 클라이언트를 인증하면서 PKCE S256도 함께 쓴다. Spring Security에서는 authorization request를 만드는 resolver에 OAuth2AuthorizationRequestCustomizers.withPkce()를 장착한다. 그러면 프레임워크가 state와 verifier를 만든다.
클라이언트 설정에 S256을 강제하는 속성이 없고, 테스트도 authorization request의 challenge를 검사하지 않는다. Authorization Code Flow를 쓴다는 것까지는 확인했고, PKCE S256이 고정됐는지는 확인하지 않았다.