13 lines
9.5 KiB
JSON
13 lines
9.5 KiB
JSON
{
|
|
"kind": "CASE",
|
|
"title": "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계",
|
|
"slug": "spa-browser-credential-boundary",
|
|
"summary": "SPA를 public OAuth client로 구성해 authorization code를 직접 교환하고, access·refresh·ID token은 JavaScript memory에 보관했다. Web Storage에는 token을 저장하지 않았고, 실행 중인 script가 같은 JavaScript 실행 영역의 token과 API 호출에 접근할 수 있는지도 함께 확인했다.",
|
|
"problem": "token을 Web Storage에 저장하지 않고 JavaScript memory에만 보관했을 때 XSS 경계가 어떻게 달라지는지 확인할 필요가 있었다.\n\nAP1은 SPA가 public client가 되어 authorization code를 직접 교환하고 access·refresh·ID token을 JavaScript memory에 두는 구성이다. \n\n확인할 내용은 세 가지였다. 브라우저가 어떤 credential을 직접 다루는지, PKCE가 어떤 공격을 막는지, memory-only 보관으로 제한할 수 있는 위험이 무엇인지였다.",
|
|
"conclusion": "memory-only 보관은 token을 Web Storage에 지속적으로 저장하지 않는 방법이다. 실행 중 XSS가 같은 JavaScript 실행 영역에 접근하는 문제까지 해결하지는 않는다.\n\n실행 중 악성 script는 같은 화면에서 fetch를 가로채거나 사용자를 대신해 API를 부를 수 있다. \ntoken 원문은 memory에만 있는 것이 아니라 요청마다 Authorization 헤더에도 실리기 때문에 노출된다.\n\nResource Server는 STATELESS로 동작하고 별도 session이나 denylist를 두지 않았다. 이미 발급된 self-contained JWT는 logout만으로 즉시 무효화되지 않으므로 짧은 만료 시간과 refresh token rotation을 사용하고, Resource Server에서는 issuer와 audience를 검증한다.\n \nPKCE는 훔친 authorization code의 교환을 막을 뿐이지 발급된 access token을 숨기지 않는다.",
|
|
"environment": "Keycloak 26.7.0\n\nrealms 설정\npublic-client, standard flow : o \nimplicit flow, direct grant : x\nauthority : http://localhost:8080/realms/keycloak-patterns\nredirect_uri : http://localhost:8088/OAuth2callback.html\nscope : openid profile email\nuserStore : InMemoryWebStorage\nstateStore : sessionStorage\nautomaticSilentRenew : true\n\nResource Server\nSessionCreationPolicy.STATELESS \nCSRF x \nCORS allowlist : localhost:8088, 127.0.0.1:8088, GET·OPTIONS, Authorization·Content-Type\n\nHTTPS : x \nHTTP : o",
|
|
"reproduction": "1. SPA를 열고 로그인후 Keycloak authorization request의 response_type=code, code_challenge_method=S256, 비어 있지 않은 code_challenge를 확인.\n\n2. token 응답에 access·refresh·ID token이 비어 있지 않은지 확인.\n\n3. 브라우저 fetch를 hook해 /api/me 호출의 Authorization header에서 Bearer access token을 확인.\n\n4. Local Storage와 Session Storage에 access token substring이 남지 않는지 확인.\n\n5. 같은 정상 JWT를 expected issuer·audience가 다른 diagnostic server 두 곳에 제출해 401을 확인.\n\n6. refresh token으로 새 token을 받고 이전 refresh token이 거부되는지, revocation 뒤 refresh가 실패하는지, 이미 발급된 access JWT가 만료 전까지 200인지 확인.",
|
|
"lastVerifiedOn": "2026-08-22",
|
|
"bodyMarkdown": "## 브라우저가 직접 다루는 credential\n\n:::evidence key=\"ap1-custody-v3-6e0376d2\" alt=\"브라우저 실행 영역 안에 code 교환, access·refresh·ID token 보관, Authorization 헤더 조립 세 상자가 들어 있고 그 영역 전체가 실행 중 XSS가 닿는 범위로 표시된 그림. Keycloak과 Resource Server는 그 밖에 있다.\" caption=\" \" zoom=\"true\"\n:::\n\ncode 교환, token 보관, `Authorization` 헤더 조립이 모두 같은 브라우저 실행 영역에 있다. 이 영역에서 악성 script가 실행되면 세 지점 모두 영향을 받는다.\n\n## 새로고침 전후의 브라우저 상태\n\noidc-client-ts의 `InMemoryWebStorage`는 로그인 결과를 브라우저의 영구 저장소가 아니라 실행 중 memory에만 둔다. \n새로고침하면 JavaScript memory의 `User`와 token이 초기화되고, Local Storage와 Session Storage에서는 token 복사본을 확인하지 못했다.\n\n아래 표는 새로고침 전후로 브라우저에서 확인되는 상태를 정리한 것이다.\n\n| 위치 | reload 전 | reload 후 |\n|---|---|---|\n| JavaScript memory | `User`, access·refresh·ID token, expiry, profile | 사라짐 |\n| Session Storage | redirect transaction용 state와 verifier | callback 완료 뒤 제거 |\n| Local Storage | 해당 없음 | 해당 없음 |\n| Keycloak origin cookie | IdP의 SSO 상태가 존재할 수 있음 | application과 별개 |\n\nmemory user가 사라진다고 Keycloak SSO까지 로그아웃되는 것은 아니다.\n\n## memory-only가 줄이는 위험\n\n저장 위치만으로 XSS 경계를 설명할 수는 없다. 같은 origin에서 악성 script가 실행되면 JavaScript memory와 fetch 호출 모두 같은 실행 영역에 있기 때문이다.\n\n| 위협 | memory-only가 막아주나 |\n|---|---|\n| 새로고침 뒤에도 남는 token 복사본 | 막아준다 |\n| 실행 중 script가 fetch를 가로채기 | 막아주지 않는다 |\n| 실행 중 script가 사용자 대신 API 호출 | 막아주지 않는다 |\n| network 요청 헤더에 실린 access token | 막아주지 않는다 |\n| 이미 발급된 access JWT의 만료 전 유효성 | 막아주지 않는다 |\n\n네 번째 줄이 이 코드에서 access token이 외부 요청으로 나가는 지점이다. SPA는 요청마다 이 헤더를 만든다.\n\n```http label=\"브라우저가 Resource Server를 직접 부를 때\"\nGET http://localhost:8081/api/me\nAuthorization: Bearer <access-token>\n```\n\ntoken 원문은 memory에도 있고 network 헤더에도 실린다.\n\nResource Server가 `SessionCreationPolicy.STATELESS`라서 서버에 지울 session이 없다. \n이미 발급된 self-contained JWT를 logout 순간에 즉시 없앨 방법이 없고, logout은 Keycloak SSO 종료와 애플리케이션 user 제거를 다룰 뿐 access JWT를 deny-list에서 관리하지 않는다. \n\n이 구성에서는 access token의 만료 시간을 짧게 두어 노출됐을 때 사용할 수 있는 시간을 제한한다.\naccess token : 300초\nrefresh token rotation, 재사용 허용 : x\nissuer·audience : 검증\n\nLocal Storage나 Session Storage로 옮기면 새로고침은 편해지지만 노출 시간이 길어진다. \nHttpOnly cookie로 옮기는 일은 저장 위치만 바꾸는 작업이 아니다. \nserver가 session이나 token 중계를 맡는 구조가 필요하다.\n\n## PKCE가 막는 구간\n\nPKCE(Proof Key for Code Exchange)는 authorization request에 `code_challenge`를 싣고, code를 token으로 바꿀 때 원본인 `code_verifier`를 같이 보내게 한다. 둘이 맞아야 교환이 끝난다.\n\n```text label=\"oidc-client-ts가 만드는 authorization request의 핵심 query\"\nresponse_type=code\nclient_id=spa-public\nredirect_uri=http://localhost:8088/OAuth2callback.html\nscope=openid profile email\nstate=<opaque-state>\ncode_challenge=<opaque-challenge>\ncode_challenge_method=S256\n```\n\n`response_type=code`가 Authorization Code Flow를 쓴다는 뜻이고, `code_challenge`와 `code_challenge_method=S256`이 PKCE 사용을 나타낸다.\n막는 구간은 code 교환까지다. 이미 발급된 access token은 막아주지 않는다.\n\n## 확인한 것과 확인하지 않은 것\n\n아래는 **커밋된 테스트가 확인하도록 정의한 부분**이다. 왼쪽이 정의 여부, 오른쪽이 정의 내용.\n\n| 정의 여부 | 정의 내용 |\n|---|---|\n| o | authorization request의 `response_type=code`, S256 method, 비어 있지 않은 challenge |\n| o | token 응답에 비어 있지 않은 access·refresh·ID token |\n| o | `/api/me` 200과 decoded access token의 audience 포함 |\n| o | 브라우저 fetch를 가로채 Authorization 헤더의 Bearer token 관측 |\n| o | Local Storage와 Session Storage에 access token substring 없음 |\n| o | refresh rotation — 새 token 발급, 이전 token 거부, revocation 뒤 refresh 실패 |\n| o | issuer나 audience가 다른 진단용 서버 두 곳의 401 |\n| x | token request body의 `code_verifier`·`client_id`·`redirect_uri`·code 값 대조 |\n| x | 서명이 깨진 JWT, 만료된 JWT |\n| x | 브라우저 간 요청(CORS)의 preflight 응답 |\n| x | callback에 error가 실려 돌아왔을 때의 화면 |\n| x | `automaticSilentRenew`의 실제 갱신 경로 |\n\n첫 줄과 여덟째 줄을 같이 보자. \n**authorization request의 파라미터를 보는 것이지 PKCE 교환이 성립하는 것을 보는 것이 아니다.**\n\n:::warning\n\nSPA는 non-2xx 응답에서도 `response.ok`을 확인하기 전에 `response.json()`을 시도한다. 401 body가 비어 있거나 JSON이 아니면 의도한 오류 처리보다 JSON parse error가 먼저 발생한다.\n\n:::\n\n## 추가로 설정에서 확인해야될 것\n\nlocal realm의 redirect allowlist는 \n`http://localhost:8088/*`와 `http://127.0.0.1:8088/*` wildcard다. \nSPA : `/OAuth2callback.html`만 o, \nexact callback만 허용하는 운영적 측면 또는 잘못된 redirect를 거부하는 검사는 x\n\nfrontend Nginx에도 `/api/` proxy가 있지만 SPA는 상대 URL이 아니라 absolute `http://localhost:8081/api/me`를 사용한다. 브라우저는 8088에서 8081로 cross-origin 요청을 보내므로 Resource Server의 CORS allowlist가 실제 요청에 적용된다. \n상대 URL을 썼다면 이 경계에서 확인되는 부분은 없었을 것이다."
|
|
}
|