Files
document-haness/.run/keycloak-four-patterns/records/case-browser-credential-boundary.body.md
T

111 lines
6.1 KiB
Markdown

## 브라우저가 직접 다루는 credential
:::evidence key="ap1-custody-v3-6e0376d2" alt="브라우저 실행 영역 안에 code 교환, access·refresh·ID token 보관, Authorization 헤더 조립 세 상자가 들어 있고 그 영역 전체가 실행 중 XSS가 닿는 범위로 표시된 그림. Keycloak과 Resource Server는 그 밖에 있다." caption=" " zoom="true"
:::
code 교환, token 보관, `Authorization` 헤더 조립이 모두 같은 브라우저 실행 영역에 있다. 이 영역에서 악성 script가 실행되면 세 지점 모두 영향을 받는다.
## 새로고침 전후의 브라우저 상태
oidc-client-ts의 `InMemoryWebStorage`는 로그인 결과를 브라우저의 영구 저장소가 아니라 실행 중 memory에만 둔다.
새로고침하면 JavaScript memory의 `User`와 token이 초기화되고, Local Storage와 Session Storage에서는 token 복사본을 확인하지 못했다.
아래 표는 새로고침 전후로 브라우저에서 확인되는 상태를 정리한 것이다.
| 위치 | reload 전 | reload 후 |
|---|---|---|
| JavaScript memory | `User`, access·refresh·ID token, expiry, profile | 사라짐 |
| Session Storage | redirect transaction용 state와 verifier | callback 완료 뒤 제거 |
| Local Storage | 해당 없음 | 해당 없음 |
| Keycloak origin cookie | IdP의 SSO 상태가 존재할 수 있음 | application과 별개 |
memory user가 사라진다고 Keycloak SSO까지 로그아웃되는 것은 아니다.
## memory-only가 줄이는 위험
저장 위치만으로 XSS 경계를 설명할 수는 없다. 같은 origin에서 악성 script가 실행되면 JavaScript memory와 fetch 호출 모두 같은 실행 영역에 있기 때문이다.
| 위협 | memory-only가 막아주나 |
|---|---|
| 새로고침 뒤에도 남는 token 복사본 | 막아준다 |
| 실행 중 script가 fetch를 가로채기 | 막아주지 않는다 |
| 실행 중 script가 사용자 대신 API 호출 | 막아주지 않는다 |
| network 요청 헤더에 실린 access token | 막아주지 않는다 |
| 이미 발급된 access JWT의 만료 전 유효성 | 막아주지 않는다 |
네 번째 줄이 이 코드에서 access token이 외부 요청으로 나가는 지점이다. SPA는 요청마다 이 헤더를 만든다.
```http label="브라우저가 Resource Server를 직접 부를 때"
GET http://localhost:8081/api/me
Authorization: Bearer <access-token>
```
token 원문은 memory에도 있고 network 헤더에도 실린다.
Resource Server가 `SessionCreationPolicy.STATELESS`라서 서버에 지울 session이 없다.
이미 발급된 self-contained JWT를 logout 순간에 즉시 없앨 방법이 없고, logout은 Keycloak SSO 종료와 애플리케이션 user 제거를 다룰 뿐 access JWT를 deny-list에서 관리하지 않는다.
이 구성에서는 access token의 만료 시간을 짧게 두어 노출됐을 때 사용할 수 있는 시간을 제한한다.
access token : 300초
refresh token rotation, 재사용 허용 : x
issuer·audience : 검증
Local Storage나 Session Storage로 옮기면 새로고침은 편해지지만 노출 시간이 길어진다.
HttpOnly cookie로 옮기는 일은 저장 위치만 바꾸는 작업이 아니다.
server가 session이나 token 중계를 맡는 구조가 필요하다.
## PKCE가 막는 구간
PKCE(Proof Key for Code Exchange)는 authorization request에 `code_challenge`를 싣고, code를 token으로 바꿀 때 원본인 `code_verifier`를 같이 보내게 한다. 둘이 맞아야 교환이 끝난다.
```text label="oidc-client-ts가 만드는 authorization request의 핵심 query"
response_type=code
client_id=spa-public
redirect_uri=http://localhost:8088/OAuth2callback.html
scope=openid profile email
state=<opaque-state>
code_challenge=<opaque-challenge>
code_challenge_method=S256
```
`response_type=code`가 Authorization Code Flow를 쓴다는 뜻이고, `code_challenge`와 `code_challenge_method=S256`이 PKCE 사용을 나타낸다.
막는 구간은 code 교환까지다. 이미 발급된 access token은 막아주지 않는다.
## 확인한 것과 확인하지 않은 것
아래는 **커밋된 테스트가 확인하도록 정의한 부분**이다. 왼쪽이 정의 여부, 오른쪽이 정의 내용.
| 정의 여부 | 정의 내용 |
|---|---|
| o | authorization request의 `response_type=code`, S256 method, 비어 있지 않은 challenge |
| o | token 응답에 비어 있지 않은 access·refresh·ID token |
| o | `/api/me` 200과 decoded access token의 audience 포함 |
| o | 브라우저 fetch를 가로채 Authorization 헤더의 Bearer token 관측 |
| o | Local Storage와 Session Storage에 access token substring 없음 |
| o | refresh rotation — 새 token 발급, 이전 token 거부, revocation 뒤 refresh 실패 |
| o | issuer나 audience가 다른 진단용 서버 두 곳의 401 |
| x | token request body의 `code_verifier`·`client_id`·`redirect_uri`·code 값 대조 |
| x | 서명이 깨진 JWT, 만료된 JWT |
| x | 브라우저 간 요청(CORS)의 preflight 응답 |
| x | callback에 error가 실려 돌아왔을 때의 화면 |
| x | `automaticSilentRenew`의 실제 갱신 경로 |
첫 줄과 여덟째 줄을 같이 보자.
**authorization request의 파라미터를 보는 것이지 PKCE 교환이 성립하는 것을 보는 것이 아니다.**
:::warning
SPA는 non-2xx 응답에서도 `response.ok`을 확인하기 전에 `response.json()`을 시도한다. 401 body가 비어 있거나 JSON이 아니면 의도한 오류 처리보다 JSON parse error가 먼저 발생한다.
:::
## 추가로 설정에서 확인해야될 것
local realm의 redirect allowlist는
`http://localhost:8088/*`와 `http://127.0.0.1:8088/*` wildcard다.
SPA : `/OAuth2callback.html`만 o,
exact callback만 허용하는 운영적 측면 또는 잘못된 redirect를 거부하는 검사는 x
frontend Nginx에도 `/api/` proxy가 있지만 SPA는 상대 URL이 아니라 absolute `http://localhost:8081/api/me`를 사용한다. 브라우저는 8088에서 8081로 cross-origin 요청을 보내므로 Resource Server의 CORS allowlist가 실제 요청에 적용된다.
상대 URL을 썼다면 이 경계에서 확인되는 부분은 없었을 것이다.