## 브라우저가 직접 다루는 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 ``` 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= code_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을 썼다면 이 경계에서 확인되는 부분은 없었을 것이다.