Files
document-haness/.playwright-mcp/page-2026-08-26T11-50-19-565Z.yml

538 lines
48 KiB
YAML

- generic [ref=f18e3]:
- link "본문으로 건너뛰기" [ref=f18e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f18e5]:
- generic [ref=f18e6]:
- link "TechLog Studio" [ref=f18e7] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f18e8]: Studio
- navigation "Studio 주 탐색" [ref=f18e10]:
- link "작업본" [ref=f18e11] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f18e12] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f18e13] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f18e14] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f18e15] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f18e16] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f18e17]
- main [ref=f18e18]:
- generic [ref=f18e19]:
- generic [ref=f18e20]:
- region [ref=f18e21]:
- generic [ref=f18e22]:
- paragraph [ref=f18e23]: CASE · VERSION 26
- heading "문서 편집" [level=1] [ref=f18e24]
- paragraph [ref=f18e25]: SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계
- region [ref=f18e26]:
- generic [ref=f18e27]:
- paragraph [ref=f18e28]: DOCUMENT
- heading "기본 정보" [level=2] [ref=f18e29]
- generic [ref=f18e30]:
- generic [ref=f18e31]:
- generic [ref=f18e32]: 제목
- textbox "제목" [ref=f18e33]: SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계
- generic [ref=f18e34]:
- generic [ref=f18e35]: slug
- textbox "slug" [ref=f18e36]:
- /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)
- text: spa-browser-credential-boundary
- generic [ref=f18e37]:
- generic [ref=f18e38]: 요약
- textbox "요약" [ref=f18e39]: SPA를 public OAuth client로 구성해 authorization code를 직접 교환하고, access·refresh·ID token은 JavaScript memory에 보관했다. Web Storage에는 token을 저장하지 않았고, 실행 중인 script가 같은 JavaScript 실행 영역의 token과 API 호출에 접근할 수 있는지도 함께 확인했다.
- generic [ref=f18e40]:
- generic [ref=f18e41]: Topic
- combobox "Topic" [ref=f18e42]:
- option "선택하지 않음"
- option "OAuth/OIDC 인증 경계" [selected]
- generic [ref=f18e43]:
- generic [ref=f18e44]: Project
- combobox "Project" [ref=f18e45]:
- option "미지정"
- option "Backend Clean Architecture"
- option "KeyCloak Patterns" [selected]
- option "Liner N + 1문제"
- group "관계" [ref=f18e46]:
- generic [ref=f18e48]:
- generic [ref=f18e49]:
- generic [ref=f18e50]: 관계 1 대상
- combobox "관계 1 대상" [ref=f18e51]:
- option "대상 선택"
- option "인증 구조를 보안 성숙도 단계로 취급하지 않는다" [disabled]
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준" [selected]
- option "BFF 인증 구조 설계 기준"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP Federation을 별도의 인증 구조로 세지 않는다"
- option "외부 IdP Federation과 Application 인증 경계"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준" [disabled]
- option "Public Client와 Confidential Client 구분 기준" [disabled]
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계"
- generic [ref=f18e52]:
- generic [ref=f18e53]: 관계 1 이유
- textbox "관계 1 이유" [ref=f18e54]: 브라우저가 authorization endpoint와 token endpoint를 직접 호출하는 흐름을 코드와 network 요청으로 확인했다.
- generic [ref=f18e55]:
- button "위로" [disabled] [ref=f18e56]
- button "아래로" [ref=f18e57]
- button "삭제" [ref=f18e58]
- generic [ref=f18e59]:
- generic [ref=f18e60]:
- generic [ref=f18e61]: 관계 2 대상
- combobox "관계 2 대상" [ref=f18e62]:
- option "대상 선택"
- option "인증 구조를 보안 성숙도 단계로 취급하지 않는다" [disabled]
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준" [disabled]
- option "BFF 인증 구조 설계 기준"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP Federation을 별도의 인증 구조로 세지 않는다"
- option "외부 IdP Federation과 Application 인증 경계"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준" [disabled]
- option "Public Client와 Confidential Client 구분 기준" [selected]
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계"
- generic [ref=f18e63]:
- generic [ref=f18e64]: 관계 2 이유
- textbox "관계 2 이유" [ref=f18e65]: SPA는 client secret을 안전하게 보관할 수 없어 public client로 등록했고, Authorization Code Flow에는 PKCE를 적용했다.
- generic [ref=f18e66]:
- button "위로" [ref=f18e67]
- button "아래로" [ref=f18e68]
- button "삭제" [ref=f18e69]
- generic [ref=f18e70]:
- generic [ref=f18e71]:
- generic [ref=f18e72]: 관계 3 대상
- combobox "관계 3 대상" [ref=f18e73]:
- option "대상 선택"
- option "인증 구조를 보안 성숙도 단계로 취급하지 않는다" [disabled]
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준" [disabled]
- option "BFF 인증 구조 설계 기준"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP Federation을 별도의 인증 구조로 세지 않는다"
- option "외부 IdP Federation과 Application 인증 경계"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준" [selected]
- option "Public Client와 Confidential Client 구분 기준" [disabled]
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계"
- generic [ref=f18e74]:
- generic [ref=f18e75]: 관계 3 이유
- textbox "관계 3 이유" [ref=f18e76]: JavaScript memory의 OAuth token과 Keycloak 도메인의 SSO cookie가 서로 다른 상태라는 점을 확인했다.
- generic [ref=f18e77]:
- button "위로" [ref=f18e78]
- button "아래로" [ref=f18e79]
- button "삭제" [ref=f18e80]
- generic [ref=f18e81]:
- generic [ref=f18e82]:
- generic [ref=f18e83]: 관계 4 대상
- combobox "관계 4 대상" [ref=f18e84]:
- option "대상 선택"
- option "인증 구조를 보안 성숙도 단계로 취급하지 않는다" [selected]
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준" [disabled]
- option "BFF 인증 구조 설계 기준"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP Federation을 별도의 인증 구조로 세지 않는다"
- option "외부 IdP Federation과 Application 인증 경계"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준" [disabled]
- option "Public Client와 Confidential Client 구분 기준" [disabled]
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계"
- generic [ref=f18e85]:
- generic [ref=f18e86]: 관계 4 이유
- textbox "관계 4 이유" [ref=f18e87]: 이 Case의 SPA 구성을 다른 패턴보다 낮은 단계로 해석하지 않도록 별도의 결정 기록에서 기준을 정했다.
- generic [ref=f18e88]:
- button "위로" [ref=f18e89]
- button "아래로" [disabled] [ref=f18e90]
- button "삭제" [ref=f18e91]
- button "관계 추가" [ref=f18e92]
- region [ref=f18e93]:
- generic [ref=f18e94]:
- paragraph [ref=f18e95]: CASE
- heading "문제와 검증" [level=2] [ref=f18e96]
- generic [ref=f18e97]:
- generic [ref=f18e98]:
- generic [ref=f18e99]: 문제
- textbox "문제" [ref=f18e100]: token을 Web Storage에 저장하지 않고 JavaScript memory에만 보관했을 때 XSS 경계가 어떻게 달라지는지 확인할 필요가 있었다. AP1은 SPA가 public client가 되어 authorization code를 직접 교환하고 access·refresh·ID token을 JavaScript memory에 두는 구성이다. 확인할 내용은 세 가지였다. 브라우저가 어떤 credential을 직접 다루는지, PKCE가 어떤 공격을 막는지, memory-only 보관으로 제한할 수 있는 위험이 무엇인지였다.
- generic [ref=f18e101]:
- generic [ref=f18e102]: 결론
- textbox "결론" [ref=f18e103]: memory-only 보관은 token을 Web Storage에 지속적으로 저장하지 않는 방법이다. 실행 중 XSS가 같은 JavaScript 실행 영역에 접근하는 문제까지 해결하지는 않는다. 실행 중 악성 script는 같은 화면에서 fetch를 가로채거나 사용자를 대신해 API를 부를 수 있다. token 원문은 memory에만 있는 것이 아니라 요청마다 Authorization 헤더에도 실리기 때문에 노출된다. Resource Server는 STATELESS로 동작하고 별도 session이나 denylist를 두지 않았다. 이미 발급된 self-contained JWT는 logout만으로 즉시 무효화되지 않으므로 짧은 만료 시간과 refresh token rotation을 사용하고, Resource Server에서는 issuer와 audience를 검증한다. PKCE는 훔친 authorization code의 교환을 막을 뿐이지 발급된 access token을 숨기지 않는다.
- generic [ref=f18e104]:
- generic [ref=f18e105]: 검증 환경
- textbox "검증 환경" [ref=f18e106]: "Keycloak 26.7.0 realms 설정 public-client, standard flow : o implicit flow, direct grant : x authority : http://localhost:8080/realms/keycloak-patterns redirect_uri : http://localhost:8088/OAuth2callback.html scope : openid profile email userStore : InMemoryWebStorage stateStore : sessionStorage automaticSilentRenew : true Resource Server SessionCreationPolicy.STATELESS CSRF x CORS allowlist : localhost:8088, 127.0.0.1:8088, GET·OPTIONS, Authorization·Content-Type HTTPS : x HTTP : o"
- generic [ref=f18e107]:
- generic [ref=f18e108]: 재현 조건
- textbox "재현 조건" [ref=f18e109]: 1. SPA를 열고 로그인후 Keycloak authorization request의 response_type=code, code_challenge_method=S256, 비어 있지 않은 code_challenge를 확인. 2. token 응답에 access·refresh·ID token이 비어 있지 않은지 확인. 3. 브라우저 fetch를 hook해 /api/me 호출의 Authorization header에서 Bearer access token을 확인. 4. Local Storage와 Session Storage에 access token substring이 남지 않는지 확인. 5. 같은 정상 JWT를 expected issuer·audience가 다른 diagnostic server 두 곳에 제출해 401을 확인. 6. refresh token으로 새 token을 받고 이전 refresh token이 거부되는지, revocation 뒤 refresh가 실패하는지, 이미 발급된 access JWT가 만료 전까지 200인지 확인.
- generic [ref=f18e110]:
- generic [ref=f18e111]: 마지막 검증일
- textbox "마지막 검증일" [ref=f18e112]: 2026-08-22
- generic [ref=f18e113]:
- generic [ref=f18e114]: 본문 Markdown
- textbox "본문 Markdown" [ref=f18e115]: "## 브라우저가 직접 다루는 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을 썼다면 이 경계에서 확인되는 부분은 없었을 것이다."
- group [ref=f18e116]:
- paragraph [ref=f18e117]: EVIDENCE
- heading "본문에 Asset 삽입" [level=3] [ref=f18e118]
- paragraph [ref=f18e119]: 목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.
- generic [ref=f18e120]:
- generic [ref=f18e121]:
- generic [ref=f18e122]: 업로드 종류
- combobox "업로드 종류" [ref=f18e123]:
- option "이미지" [selected]
- option "다이어그램"
- option "첨부파일"
- button "Asset 업로드" [ref=f18e124]
- generic [ref=f18e125]:
- search [ref=f18e126]:
- generic [ref=f18e127]: Asset 검색
- generic [ref=f18e128]:
- searchbox "Asset 검색" [ref=f18e129]
- button "검색" [ref=f18e130]
- generic [ref=f18e131]:
- checkbox "삽입할 때 크게 보기 허용" [checked] [ref=f18e132]
- generic [ref=f18e133]: 삽입할 때 크게 보기 허용
- status [ref=f18e134]: 삽입할 수 있는 Asset 8개
- list [ref=f18e135]:
- listitem [ref=f18e136]:
- button "ap4-edge-trust-1cff2399" [ref=f18e137]
- button "삭제" [ref=f18e138]
- listitem [ref=f18e139]:
- button "ap3-csrf-split-501dd1f7" [ref=f18e140]
- button "삭제" [ref=f18e141]
- listitem [ref=f18e142]:
- button "ap3-bff-custody-82fa18bd" [ref=f18e143]
- button "삭제" [ref=f18e144]
- listitem [ref=f18e145]:
- button "ap2-split-custody-779cb791" [ref=f18e146]
- button "삭제" [ref=f18e147]
- listitem [ref=f18e148]:
- button "ap1-custody-v3-6e0376d2" [ref=f18e149]
- button "삭제" [ref=f18e150]
- listitem [ref=f18e151]:
- button "ap1-custody-v2-e110bd98" [ref=f18e152]
- button "삭제" [ref=f18e153]
- listitem [ref=f18e154]:
- button "ap1-credential-custody-f5e0c027" [ref=f18e155]
- button "삭제" [ref=f18e156]
- listitem [ref=f18e157]:
- button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f18e158]
- button "삭제" [ref=f18e159]
- region [ref=f18e160]:
- generic [ref=f18e161]:
- paragraph [ref=f18e162]: LIVE
- heading "즉시 미리보기" [level=2] [ref=f18e163]
- generic [ref=f18e166]:
- generic [ref=f18e167]:
- navigation "문서 경로" [ref=f18e168]:
- link "Case" [ref=f18e169] [cursor=pointer]:
- /url: /explore/cases
- generic [ref=f18e170]: /
- generic [ref=f18e171]: OAuth/OIDC 인증 경계
- generic [ref=f18e172]: /
- link "KeyCloak Patterns" [ref=f18e173] [cursor=pointer]:
- /url: /projects/keycloak-patterns
- heading "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계" [level=1] [ref=f18e174]
- paragraph [ref=f18e175]: SPA를 public OAuth client로 구성해 authorization code를 직접 교환하고, access·refresh·ID token은 JavaScript memory에 보관했다. Web Storage에는 token을 저장하지 않았고, 실행 중인 script가 같은 JavaScript 실행 영역의 token과 API 호출에 접근할 수 있는지도 함께 확인했다.
- region "문제와 결론" [ref=f18e176]:
- generic [ref=f18e177]:
- paragraph [ref=f18e178]: 문제
- paragraph [ref=f18e179]: token을 Web Storage에 저장하지 않고 JavaScript memory에만 보관했을 때 XSS 경계가 어떻게 달라지는지 확인할 필요가 있었다.AP1은 SPA가 public client가 되어 authorization code를 직접 교환하고 access·refresh·ID token을 JavaScript memory에 두는 구성이다. 확인할 내용은 세 가지였다. 브라우저가 어떤 credential을 직접 다루는지, PKCE가 어떤 공격을 막는지, memory-only 보관으로 제한할 수 있는 위험이 무엇인지였다.
- generic [ref=f18e180]:
- paragraph [ref=f18e181]: 결론
- paragraph [ref=f18e182]: memory-only 보관은 token을 Web Storage에 지속적으로 저장하지 않는 방법이다. 실행 중 XSS가 같은 JavaScript 실행 영역에 접근하는 문제까지 해결하지는 않는다.실행 중 악성 script는 같은 화면에서 fetch를 가로채거나 사용자를 대신해 API를 부를 수 있다. token 원문은 memory에만 있는 것이 아니라 요청마다 Authorization 헤더에도 실리기 때문에 노출된다.Resource Server는 STATELESS로 동작하고 별도 session이나 denylist를 두지 않았다. 이미 발급된 self-contained JWT는 logout만으로 즉시 무효화되지 않으므로 짧은 만료 시간과 refresh token rotation을 사용하고, Resource Server에서는 issuer와 audience를 검증한다. PKCE는 훔친 authorization code의 교환을 막을 뿐이지 발급된 access token을 숨기지 않는다.
- generic [ref=f18e183]:
- generic [ref=f18e184]:
- term [ref=f18e185]: 검증 환경
- definition [ref=f18e186]: "Keycloak 26.7.0realms 설정public-client, standard flow : o implicit flow, direct grant : xauthority : http://localhost:8080/realms/keycloak-patternsredirect_uri : http://localhost:8088/OAuth2callback.htmlscope : openid profile emailuserStore : InMemoryWebStoragestateStore : sessionStorageautomaticSilentRenew : trueResource ServerSessionCreationPolicy.STATELESS CSRF x CORS allowlist : localhost:8088, 127.0.0.1:8088, GET·OPTIONS, Authorization·Content-TypeHTTPS : x HTTP : o"
- generic [ref=f18e187]:
- term [ref=f18e188]: 검증 데이터
- definition [ref=f18e189]: 1. SPA를 열고 로그인후 Keycloak authorization request의 response_type=code, code_challenge_method=S256, 비어 있지 않은 code_challenge를 확인.2. token 응답에 access·refresh·ID token이 비어 있지 않은지 확인.3. 브라우저 fetch를 hook해 /api/me 호출의 Authorization header에서 Bearer access token을 확인.4. Local Storage와 Session Storage에 access token substring이 남지 않는지 확인.5. 같은 정상 JWT를 expected issuer·audience가 다른 diagnostic server 두 곳에 제출해 401을 확인.6. refresh token으로 새 token을 받고 이전 refresh token이 거부되는지, revocation 뒤 refresh가 실패하는지, 이미 발급된 access JWT가 만료 전까지 200인지 확인.
- generic [ref=f18e190]:
- term [ref=f18e191]: 기록
- definition [ref=f18e192]: 게시 2026.08.23 · 마지막 검증 2026.08.22
- group [ref=f18e194]:
- generic "목차 · 브라우저가 직접 다루는 credential" [ref=f18e195] [cursor=pointer]
- article [ref=f18e197]:
- region [ref=f18e198]:
- heading [level=2] [ref=f18e199]:
- link "브라우저가 직접 다루는 credential 바로가기" [ref=f18e200] [cursor=pointer]:
- /url: "#브라우저가-직접-다루는-credential"
- text: 브라우저가 직접 다루는 credential
- generic [ref=f18e201]: "#"
- figure [ref=f18e202]:
- button "ap1-custody-v3-6e0376d2 이미지 크게 보기" [ref=f18e203]:
- img "브라우저 실행 영역 안에 code 교환, access·refresh·ID token 보관, Authorization 헤더 조립 세 상자가 들어 있고 그 영역 전체가 실행 중 XSS가 닿는 범위로 표시된 그림. Keycloak과 Resource Server는 그 밖에 있다." [ref=f18e204]
- generic [ref=f18e205]: 크게 보기
- generic [ref=f18e206]: 브라우저 실행 영역 안에 code 교환, access·refresh·ID token 보관, Authorization 헤더 조립 세 상자가 들어 있고 그 영역 전체가 실행 중 XSS가 닿는 범위로 표시된 그림. Keycloak과 Resource Server는 그 밖에 있다.
- paragraph [ref=f18e207]:
- text: code 교환, token 보관,
- code [ref=f18e208]: Authorization
- text: 헤더 조립이 모두 같은 브라우저 실행 영역에 있다. 이 영역에서 악성 script가 실행되면 세 지점 모두 영향을 받는다.
- region [ref=f18e209]:
- heading [level=2] [ref=f18e210]:
- link "새로고침 전후의 브라우저 상태 바로가기" [ref=f18e211] [cursor=pointer]:
- /url: "#새로고침-전후의-브라우저-상태"
- text: 새로고침 전후의 브라우저 상태
- generic [ref=f18e212]: "#"
- paragraph [ref=f18e213]:
- text: oidc-client-ts의
- code [ref=f18e214]: InMemoryWebStorage
- text: 는 로그인 결과를 브라우저의 영구 저장소가 아니라 실행 중 memory에만 둔다.새로고침하면 JavaScript memory의
- code [ref=f18e215]: User
- text: 와 token이 초기화되고, Local Storage와 Session Storage에서는 token 복사본을 확인하지 못했다.
- paragraph [ref=f18e216]: 아래 표는 새로고침 전후로 브라우저에서 확인되는 상태를 정리한 것이다.
- region "표" [ref=f18e217]:
- table [ref=f18e218]:
- caption [ref=f18e219]
- rowgroup [ref=f18e220]:
- row [ref=f18e221]:
- columnheader "위치" [ref=f18e222]
- columnheader "reload 전" [ref=f18e223]
- columnheader "reload 후" [ref=f18e224]
- rowgroup [ref=f18e225]:
- row [ref=f18e226]:
- cell "JavaScript memory" [ref=f18e227]
- cell [ref=f18e228]:
- code [ref=f18e229]: User
- text: ", access·refresh·ID token, expiry, profile"
- cell "사라짐" [ref=f18e230]
- row [ref=f18e231]:
- cell "Session Storage" [ref=f18e232]
- cell "redirect transaction용 state와 verifier" [ref=f18e233]
- cell "callback 완료 뒤 제거" [ref=f18e234]
- row [ref=f18e235]:
- cell "Local Storage" [ref=f18e236]
- cell "해당 없음" [ref=f18e237]
- cell "해당 없음" [ref=f18e238]
- row [ref=f18e239]:
- cell "Keycloak origin cookie" [ref=f18e240]
- cell "IdP의 SSO 상태가 존재할 수 있음" [ref=f18e241]
- cell "application과 별개" [ref=f18e242]
- paragraph [ref=f18e243]: memory user가 사라진다고 Keycloak SSO까지 로그아웃되는 것은 아니다.
- region [ref=f18e244]:
- heading [level=2] [ref=f18e245]:
- link "memory-only가 줄이는 위험 바로가기" [ref=f18e246] [cursor=pointer]:
- /url: "#memory-only가-줄이는-위험"
- text: memory-only가 줄이는 위험
- generic [ref=f18e247]: "#"
- paragraph [ref=f18e248]: 저장 위치만으로 XSS 경계를 설명할 수는 없다. 같은 origin에서 악성 script가 실행되면 JavaScript memory와 fetch 호출 모두 같은 실행 영역에 있기 때문이다.
- region "표" [ref=f18e249]:
- table [ref=f18e250]:
- caption [ref=f18e251]
- rowgroup [ref=f18e252]:
- row [ref=f18e253]:
- columnheader "위협" [ref=f18e254]
- columnheader "memory-only가 막아주나" [ref=f18e255]
- rowgroup [ref=f18e256]:
- row [ref=f18e257]:
- cell "새로고침 뒤에도 남는 token 복사본" [ref=f18e258]
- cell "막아준다" [ref=f18e259]
- row [ref=f18e260]:
- cell "실행 중 script가 fetch를 가로채기" [ref=f18e261]
- cell "막아주지 않는다" [ref=f18e262]
- row [ref=f18e263]:
- cell "실행 중 script가 사용자 대신 API 호출" [ref=f18e264]
- cell "막아주지 않는다" [ref=f18e265]
- row [ref=f18e266]:
- cell "network 요청 헤더에 실린 access token" [ref=f18e267]
- cell "막아주지 않는다" [ref=f18e268]
- row [ref=f18e269]:
- cell "이미 발급된 access JWT의 만료 전 유효성" [ref=f18e270]
- cell "막아주지 않는다" [ref=f18e271]
- paragraph [ref=f18e272]: 네 번째 줄이 이 코드에서 access token이 외부 요청으로 나가는 지점이다. SPA는 요청마다 이 헤더를 만든다.
- figure "HTTP ·브라우저가 Resource Server를 직접 부를 때 코드 복사" [ref=f18e273]:
- generic [ref=f18e274]:
- generic [ref=f18e275]: HTTP
- generic [ref=f18e276]: ·브라우저가 Resource Server를 직접 부를 때
- button "코드 복사" [ref=f18e277] [cursor=pointer]: 복사
- region "브라우저가 Resource Server를 직접 부를 때 코드" [ref=f18e278]:
- code [ref=f18e279]: "GET http://localhost:8081/api/me Authorization: Bearer <access-token>"
- paragraph [ref=f18e281]: token 원문은 memory에도 있고 network 헤더에도 실린다.
- paragraph [ref=f18e282]:
- text: Resource Server가
- code [ref=f18e283]: SessionCreationPolicy.STATELESS
- text: 라서 서버에 지울 session이 없다.이미 발급된 self-contained JWT를 logout 순간에 즉시 없앨 방법이 없고, logout은 Keycloak SSO 종료와 애플리케이션 user 제거를 다룰 뿐 access JWT를 deny-list에서 관리하지 않는다.
- paragraph [ref=f18e284]: "이 구성에서는 access token의 만료 시간을 짧게 두어 노출됐을 때 사용할 수 있는 시간을 제한한다.access token : 300초refresh token rotation, 재사용 허용 : xissuer·audience : 검증"
- paragraph [ref=f18e285]: Local Storage나 Session Storage로 옮기면 새로고침은 편해지지만 노출 시간이 길어진다.HttpOnly cookie로 옮기는 일은 저장 위치만 바꾸는 작업이 아니다.server가 session이나 token 중계를 맡는 구조가 필요하다.
- region [ref=f18e286]:
- heading [level=2] [ref=f18e287]:
- link "PKCE가 막는 구간 바로가기" [ref=f18e288] [cursor=pointer]:
- /url: "#pkce가-막는-구간"
- text: PKCE가 막는 구간
- generic [ref=f18e289]: "#"
- paragraph [ref=f18e290]:
- text: PKCE(Proof Key for Code Exchange)는 authorization request에
- code [ref=f18e291]: code_challenge
- text: 를 싣고, code를 token으로 바꿀 때 원본인
- code [ref=f18e292]: code_verifier
- text: 를 같이 보내게 한다. 둘이 맞아야 교환이 끝난다.
- figure "TEXT ·oidc-client-ts가 만드는 authorization request의 핵심 query 코드 복사" [ref=f18e293]:
- generic [ref=f18e294]:
- generic [ref=f18e295]: TEXT
- generic [ref=f18e296]: ·oidc-client-ts가 만드는 authorization request의 핵심 query
- button "코드 복사" [ref=f18e297] [cursor=pointer]: 복사
- region "oidc-client-ts가 만드는 authorization request의 핵심 query 코드" [ref=f18e298]:
- code [ref=f18e299]: 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
- paragraph [ref=f18e301]:
- code [ref=f18e302]: response_type=code
- text: 가 Authorization Code Flow를 쓴다는 뜻이고,
- code [ref=f18e303]: code_challenge
- text:
- code [ref=f18e304]: code_challenge_method=S256
- text: 이 PKCE 사용을 나타낸다.막는 구간은 code 교환까지다. 이미 발급된 access token은 막아주지 않는다.
- region [ref=f18e305]:
- heading [level=2] [ref=f18e306]:
- link "확인한 것과 확인하지 않은 것 바로가기" [ref=f18e307] [cursor=pointer]:
- /url: "#확인한-것과-확인하지-않은-것"
- text: 확인한 것과 확인하지 않은 것
- generic [ref=f18e308]: "#"
- paragraph [ref=f18e309]:
- text: 아래는
- strong [ref=f18e310]: 커밋된 테스트가 확인하도록 정의한 부분
- text: 이다. 왼쪽이 정의 여부, 오른쪽이 정의 내용.
- region "표" [ref=f18e311]:
- table [ref=f18e312]:
- caption [ref=f18e313]
- rowgroup [ref=f18e314]:
- row [ref=f18e315]:
- columnheader "정의 여부" [ref=f18e316]
- columnheader "정의 내용" [ref=f18e317]
- rowgroup [ref=f18e318]:
- row [ref=f18e319]:
- cell "o" [ref=f18e320]
- cell [ref=f18e321]:
- text: authorization request의
- code [ref=f18e322]: response_type=code
- text: ", S256 method, 비어 있지 않은 challenge"
- row [ref=f18e323]:
- cell "o" [ref=f18e324]
- cell "token 응답에 비어 있지 않은 access·refresh·ID token" [ref=f18e325]
- row [ref=f18e326]:
- cell "o" [ref=f18e327]
- cell [ref=f18e328]:
- code [ref=f18e329]: /api/me
- text: 200과 decoded access token의 audience 포함
- row [ref=f18e330]:
- cell "o" [ref=f18e331]
- cell "브라우저 fetch를 가로채 Authorization 헤더의 Bearer token 관측" [ref=f18e332]
- row [ref=f18e333]:
- cell "o" [ref=f18e334]
- cell "Local Storage와 Session Storage에 access token substring 없음" [ref=f18e335]
- row [ref=f18e336]:
- cell "o" [ref=f18e337]
- cell "refresh rotation — 새 token 발급, 이전 token 거부, revocation 뒤 refresh 실패" [ref=f18e338]
- row [ref=f18e339]:
- cell "o" [ref=f18e340]
- cell "issuer나 audience가 다른 진단용 서버 두 곳의 401" [ref=f18e341]
- row [ref=f18e342]:
- cell "x" [ref=f18e343]
- cell [ref=f18e344]:
- text: token request body의
- code [ref=f18e345]: code_verifier
- text: ·
- code [ref=f18e346]: client_id
- text: ·
- code [ref=f18e347]: redirect_uri
- text: ·code 값 대조
- row [ref=f18e348]:
- cell "x" [ref=f18e349]
- cell "서명이 깨진 JWT, 만료된 JWT" [ref=f18e350]
- row [ref=f18e351]:
- cell "x" [ref=f18e352]
- cell "브라우저 간 요청(CORS)의 preflight 응답" [ref=f18e353]
- row [ref=f18e354]:
- cell "x" [ref=f18e355]
- cell "callback에 error가 실려 돌아왔을 때의 화면" [ref=f18e356]
- row [ref=f18e357]:
- cell "x" [ref=f18e358]
- cell [ref=f18e359]:
- code [ref=f18e360]: automaticSilentRenew
- text: 의 실제 갱신 경로
- paragraph [ref=f18e361]:
- text: 첫 줄과 여덟째 줄을 같이 보자.
- strong [ref=f18e362]: authorization request의 파라미터를 보는 것이지 PKCE 교환이 성립하는 것을 보는 것이 아니다.
- complementary "주의" [ref=f18e363]:
- paragraph [ref=f18e364]: 주의
- paragraph [ref=f18e365]:
- text: SPA는 non-2xx 응답에서도
- code [ref=f18e366]: response.ok
- text: 을 확인하기 전에
- code [ref=f18e367]: response.json()
- text: 을 시도한다. 401 body가 비어 있거나 JSON이 아니면 의도한 오류 처리보다 JSON parse error가 먼저 발생한다.
- region [ref=f18e368]:
- heading [level=2] [ref=f18e369]:
- link "추가로 설정에서 확인해야될 것 바로가기" [ref=f18e370] [cursor=pointer]:
- /url: "#추가로-설정에서-확인해야될-것"
- text: 추가로 설정에서 확인해야될 것
- generic [ref=f18e371]: "#"
- paragraph [ref=f18e372]:
- text: local realm의 redirect allowlist는
- code [ref=f18e373]: http://localhost:8088/*
- text:
- code [ref=f18e374]: http://127.0.0.1:8088/*
- text: "wildcard다.SPA :"
- code [ref=f18e375]: /OAuth2callback.html
- text: 만 o,exact callback만 허용하는 운영적 측면 또는 잘못된 redirect를 거부하는 검사는 x
- paragraph [ref=f18e376]:
- text: frontend Nginx에도
- code [ref=f18e377]: /api/
- text: proxy가 있지만 SPA는 상대 URL이 아니라 absolute
- code [ref=f18e378]: http://localhost:8081/api/me
- text: 를 사용한다. 브라우저는 8088에서 8081로 cross-origin 요청을 보내므로 Resource Server의 CORS allowlist가 실제 요청에 적용된다.상대 URL을 썼다면 이 경계에서 확인되는 부분은 없었을 것이다.
- region [ref=f18e379]:
- paragraph [ref=f18e380]: Explicit relations
- heading "이 기록의 연결" [level=2] [ref=f18e381]
- list [ref=f18e382]:
- listitem [ref=f18e383]:
- link "브라우저가 authorization endpoint와 token endpoint를 직접 호출하는 흐름을 코드와 network 요청으로 확인했다. Authorization Code Flow의 Endpoint와 Credential 이동 기준" [ref=f18e384] [cursor=pointer]:
- /url: /references/authorization-code-endpoint-credential-movement
- generic [ref=f18e385]: 브라우저가 authorization endpoint와 token endpoint를 직접 호출하는 흐름을 코드와 network 요청으로 확인했다.
- strong [ref=f18e386]: Authorization Code Flow의 Endpoint와 Credential 이동 기준
- generic [ref=f18e387]:
- complementary [ref=f18e388]:
- heading "작업 상태" [level=2] [ref=f18e389]
- status "편집 상태" [ref=f18e390]: 저장됨
- generic [ref=f18e391]:
- generic [ref=f18e392]:
- term [ref=f18e393]: 저장 버전
- definition [ref=f18e394]: "26"
- generic [ref=f18e395]:
- term [ref=f18e396]: 종류
- definition [ref=f18e397]: CASE
- paragraph [ref=f18e398]: 불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.
- generic [ref=f18e399]:
- button "저장" [disabled] [ref=f18e400]
- button "게시" [ref=f18e401]
- paragraph [ref=f18e402]: 버전 26으로 저장했습니다.