Files
document-haness/.playwright-mcp/page-2026-09-07T09-07-03-746Z.yml
T

720 lines
62 KiB
YAML

- generic [ref=f3e3]:
- link "본문으로 건너뛰기" [ref=f3e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f3e5]:
- generic [ref=f3e6]:
- link "TechLog Studio" [ref=f3e7] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f3e8]: Studio
- navigation "Studio 주 탐색" [ref=f3e10]:
- link "작업본" [ref=f3e11] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f3e12] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f3e13] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f3e14] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f3e15] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f3e16] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f3e17]
- main [ref=f3e18]:
- generic [ref=f3e19]:
- generic [ref=f3e20]:
- region [ref=f3e21]:
- generic [ref=f3e22]:
- paragraph [ref=f3e23]: CASE · VERSION 35
- heading "문서 편집" [level=1] [ref=f3e24]
- paragraph [ref=f3e25]: SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우
- region [ref=f3e26]:
- generic [ref=f3e27]:
- paragraph [ref=f3e28]: DOCUMENT
- heading "기본 정보" [level=2] [ref=f3e29]
- generic [ref=f3e30]:
- generic [ref=f3e31]:
- generic [ref=f3e32]: 제목
- textbox "제목" [ref=f3e33]: SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우
- generic [ref=f3e34]:
- generic [ref=f3e35]: slug
- textbox "slug" [ref=f3e36]:
- /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)
- text: spa-browser-credential-boundary
- generic [ref=f3e37]:
- generic [ref=f3e38]: 요약
- textbox "요약" [ref=f3e39]: "AP1에서는 SPA를 public OAuth client로 구성하고 authorization code도 브라우저에서 직접 교환한다. 발급받은 access token, refresh token, ID token은 Web Storage에 저장하지 않고 JavaScript memory에만 둔다. 이렇게 하면 새로고침 뒤에는 token이 남지 않는다. 하지만 페이지가 열려 있는 동안에는 JavaScript에서 token을 사용하고 있고, Resource Server를 호출할 때도 access token을 `Authorization` 헤더에 넣는다. 실행 중 XSS가 발생했을 때 영향을 받는 부분은 여전히 생각해야 된다."
- generic [aria-hidden] [ref=f3e40]: 목록 카드에는 약 90자까지 보입니다 · 347 / 2000
- generic [ref=f3e41]:
- generic [ref=f3e42]: Topic
- combobox "Topic" [ref=f3e43]:
- option "선택하지 않음"
- option "JPA 피드 조회 성능"
- option "OAuth/OIDC 인증 경계" [selected]
- generic [ref=f3e44]:
- generic [ref=f3e45]: Project
- combobox "Project" [ref=f3e46]:
- option "미지정"
- option "Backend Clean Architecture"
- option "KeyCloak Patterns" [selected]
- option "Liner N + 1문제"
- status [ref=f3e47]
- group "축 — 고르지 않으면 이 주제의 공통 기록이 됩니다" [ref=f3e48]:
- generic [ref=f3e50] [cursor=pointer]:
- checkbox "SPA" [checked] [ref=f3e51]
- generic [ref=f3e52]: SPA
- generic [ref=f3e53] [cursor=pointer]:
- checkbox "Mediator" [ref=f3e54]
- generic [ref=f3e55]: Mediator
- generic [ref=f3e56] [cursor=pointer]:
- checkbox "BFF" [ref=f3e57]
- generic [ref=f3e58]: BFF
- generic [ref=f3e59] [cursor=pointer]:
- checkbox "Forward-Auth" [ref=f3e60]
- generic [ref=f3e61]: Forward-Auth
- group "관계" [ref=f3e62]:
- generic [ref=f3e64]:
- generic [ref=f3e65]:
- generic [ref=f3e66]: 관계 1 대상
- combobox "관계 1 대상" [ref=f3e67]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
- option "Collection Fetch Join Pagination의 In-memory Paging"
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준" [selected]
- option "BFF 인증 구조 설계 기준"
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준"
- option "Fetch Type과 Fetch Strategy 구분"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준"
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준" [disabled]
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준" [disabled]
- option "Top-N-per-group 선택 기준"
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다"
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다"
- generic [ref=f3e68]:
- generic [ref=f3e69]: 관계 1 이유
- textbox "관계 1 이유" [ref=f3e70]: SPA에서 authorization request를 보내고 authorization code를 받은 뒤 token을 교환하는 과정을 직접 확인한 내용이다.
- generic [aria-hidden] [ref=f3e71]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f3e72]:
- button "위로" [disabled] [ref=f3e73]
- button "아래로" [ref=f3e74]
- button "삭제" [ref=f3e75]
- generic [ref=f3e76]:
- generic [ref=f3e77]:
- generic [ref=f3e78]: 관계 2 대상
- combobox "관계 2 대상" [ref=f3e79]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
- option "Collection Fetch Join Pagination의 In-memory Paging"
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준" [disabled]
- option "BFF 인증 구조 설계 기준"
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준"
- option "Fetch Type과 Fetch Strategy 구분"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준"
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준" [disabled]
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준" [selected]
- option "Top-N-per-group 선택 기준"
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다"
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다"
- generic [ref=f3e80]:
- generic [ref=f3e81]: 관계 2 이유
- textbox "관계 2 이유" [ref=f3e82]: SPA는 client secret을 안전하게 숨길 수 없기 때문에 public client로 구성했고 PKCE S256을 사용했다.
- generic [aria-hidden] [ref=f3e83]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f3e84]:
- button "위로" [ref=f3e85]
- button "아래로" [ref=f3e86]
- button "삭제" [ref=f3e87]
- generic [ref=f3e88]:
- generic [ref=f3e89]:
- generic [ref=f3e90]: 관계 3 대상
- combobox "관계 3 대상" [ref=f3e91]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
- option "Collection Fetch Join Pagination의 In-memory Paging"
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준" [disabled]
- option "BFF 인증 구조 설계 기준"
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준"
- option "Fetch Type과 Fetch Strategy 구분"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준"
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준" [selected]
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준" [disabled]
- option "Top-N-per-group 선택 기준"
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다"
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다"
- generic [ref=f3e92]:
- generic [ref=f3e93]: 관계 3 이유
- textbox "관계 3 이유" [ref=f3e94]: JavaScript memory에 있는 token과 Keycloak의 SSO cookie는 서로 다른 상태다. 새로고침 뒤 SPA의 token이 없어져도 Keycloak의 SSO 상태는 남아 있을 수 있다.
- generic [aria-hidden] [ref=f3e95]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f3e96]:
- button "위로" [ref=f3e97]
- button "아래로" [disabled] [ref=f3e98]
- button "삭제" [ref=f3e99]
- button "관계 추가" [ref=f3e100]
- region [ref=f3e101]:
- generic [ref=f3e102]:
- paragraph [ref=f3e103]: CASE
- heading "문제와 검증" [level=2] [ref=f3e104]
- generic [ref=f3e105]:
- generic [ref=f3e106]:
- generic [ref=f3e107]: 문제
- textbox "문제" [ref=f3e108]: AP1에서는 token을 Local Storage나 Session Storage에 저장하지 않고 JavaScript memory에만 둔다. 확인하고 싶었던 부분은 token을 Web Storage에 저장하지 않는 것만으로 실행 중 XSS까지 막을 수 있는지였다. SPA가 authorization code를 직접 교환한 뒤 token을 어디에 가지고 있는지, API를 호출할 때 access token이 어디를 지나는지 확인했다. PKCE도 실제로 어느 구간에 적용되는지 같이 봤다.
- generic [ref=f3e109]:
- generic [ref=f3e110]: 결론
- textbox "결론" [ref=f3e111]: "memory-only로 보관하면 새로고침 뒤에는 access token, refresh token, ID token이 남지 않는다. 하지만 페이지가 실행 중일 때는 JavaScript에서 token을 사용한다. 악성 script가 같은 페이지에서 실행되면 `fetch`를 가로채거나 사용자를 대신해서 API를 호출할 수 있다. access token도 JavaScript memory에만 있는 것이 아니라 Resource Server 요청의 `Authorization` 헤더에 들어간다. Resource Server는 `SessionCreationPolicy.STATELESS`로 동작한다. 서버에서 삭제할 application session이 없고, 이미 발급된 self-contained JWT를 logout과 동시에 없애는 처리도 없다. 현재 구성에서는 access token 수명을 300초로 두고 refresh token rotation을 사용한다. Resource Server에서는 issuer와 audience도 확인한다."
- generic [ref=f3e112]:
- generic [ref=f3e113]: 검증 환경
- textbox "검증 환경" [ref=f3e114]: "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=f3e115]:
- generic [ref=f3e116]: 재현 조건
- textbox "재현 조건" [ref=f3e117]: "1. SPA를 열고 로그인한 뒤 Keycloak authorization request에서 `response_type=code`, `code_challenge_method=S256`, 비어 있지 않은 `code_challenge`를 확인한다. 2. token 응답의 access token, refresh token, ID token이 비어 있지 않은지 확인한다. 3. 브라우저 `fetch`를 hook하고 `/api/me` 요청의 `Authorization` 헤더에서 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=f3e118]:
- generic [ref=f3e119]: 마지막 검증일
- textbox "마지막 검증일" [ref=f3e120]: 2026-08-22
- generic [ref=f3e121]:
- generic [ref=f3e122]: 본문 Markdown
- group "Markdown 삽입" [ref=f3e123]:
- button "코드" [ref=f3e124] [cursor=pointer]
- button "표" [ref=f3e125] [cursor=pointer]
- button "목록" [ref=f3e126] [cursor=pointer]
- textbox "본문 Markdown" [ref=f3e127]: ":::evidence key=\"ap1-direct-architecture-0adf4199\" alt=\"SPA, Keycloak, 브라우저 JavaScript memory, Resource Server가 왼쪽에서 오른쪽으로 연결된 AP1 직접 인증 아키텍처.\" caption=\"\" zoom=\"true\" ::: ## SPA에서 Token을 처리하는 위치 :::evidence key=\"ap1-custody-v3-6e0376d2\" alt=\"브라우저 실행 영역 안에 code 교환, access·refresh·ID token 보관, Authorization 헤더 조립 세 상자가 들어 있고 그 영역 전체가 실행 중 XSS가 닿는 범위로 표시된 그림. Keycloak과 Resource Server는 그 밖에 있다.\" caption=\" \" zoom=\"true\" ::: authorization code 교환, token 보관, `Authorization` 헤더 생성까지 모두 브라우저에서 처리한다. access token, refresh token, ID token도 JavaScript memory에 있고 Resource Server를 호출할 때 사용할 `Authorization` 헤더도 같은 페이지에서 만든다. 그래서 이 페이지에서 악성 script가 실행되면 JavaScript가 token을 사용하는 부분에도 접근할 수 있다. ## 새로고침 전후에 브라우저에 남는 값 `oidc-client-ts`의 `InMemoryWebStorage`를 사용해서 로그인 결과를 Local Storage나 Session Storage에 저장하지 않고 실행 중 memory에만 둔다. 새로고침하면 memory에 있던 로그인 정보와 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과 별개 | JavaScript memory에 있던 `User`가 사라지는 것과 Keycloak의 SSO session이 끝나는 것은 별개다. SPA에서 가지고 있던 token이 사라져도 Keycloak SSO cookie가 남아 있으면 이후 authorization request에서 기존 로그인 상태가 다시 사용될 수 있다. ## Memory-only로 막을 수 있는 범위 memory-only로 바꾼 뒤 어떤 값이 없어지고 어떤 부분은 그대로 남는지 확인했다. | 위협 | memory-only가 막아주나 | |---|---| | 새로고침 뒤에도 남는 token 복사본 | 막아준다 | | 실행 중 script가 fetch를 가로채기 | 막아주지 않는다 | | 실행 중 script가 사용자 대신 API 호출 | 막아주지 않는다 | | network 요청 헤더에 실린 access token | 막아주지 않는다 | | 이미 발급된 access JWT의 만료 전 유효성 | 막아주지 않는다 | Resource Server를 호출할 때 SPA에서 access token을 `Authorization` 헤더에 넣는다. ```http label=\"브라우저가 Resource Server를 직접 부를 때\" GET http://localhost:8081/api/me Authorization: Bearer <access-token> ``` 그래서 access token은 JavaScript memory에만 존재하는 값은 아니다. API를 호출하는 동안에는 network 요청의 `Authorization` 헤더에도 들어간다. Resource Server는 `SessionCreationPolicy.STATELESS`로 설정되어 있어서 서버에서 삭제할 application session이 없다. 이미 발급된 self-contained JWT를 logout 시점에 바로 무효화하는 처리도 넣지 않았다. logout에서는 Keycloak SSO 종료와 SPA의 user 제거를 처리하고, 발급된 access JWT를 deny-list로 따로 관리하지 않는다. 현재 access token 수명은 300초다. access token : 300초 refresh token rotation, 재사용 허용 : x issuer·audience : 검증 Local Storage나 Session Storage에 token을 저장하면 새로고침 이후에도 값을 다시 읽을 수 있지만, 브라우저 저장소에도 token이 남게 된다. HttpOnly cookie를 사용하려면 현재 SPA처럼 브라우저에서 access token을 꺼내 Resource Server로 직접 보내는 방식과는 달라진다. server가 session이나 token 전달을 맡는 구조가 필요하다. ## PKCE가 적용되는 구간 PKCE(Proof Key for Code Exchange)를 사용할 때 authorization request에는 `code_challenge`가 들어가고, authorization code를 token으로 교환할 때는 원본인 `code_verifier`를 함께 보낸다. 두 값이 맞아야 code를 교환할 수 있다. ```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`를 사용하고 `code_challenge_method=S256`과 비어 있지 않은 `code_challenge`가 authorization request에 들어가는 것을 확인했다. PKCE가 적용되는 곳은 authorization code를 token으로 교환하는 구간이다. token이 발급된 이후 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에서는 `response_type=code`, S256 method, 비어 있지 않은 challenge까지 확인했다. 하지만 token request body에서 실제 `code_verifier`, `client_id`, `redirect_uri`, code 값이 어떻게 전달됐고 서로 대조됐는지는 아직 확인하지 않았다. :::warning SPA는 non-2xx 응답에서도 `response.ok`을 확인하기 전에 `response.json()`을 시도한다. 401 body가 비어 있거나 JSON이 아니면 의도한 메시지 대신 JSON parse error가 먼저 노출되게 된다. ::: ## Redirect URI와 CORS에서 아직 확인하지 않은 부분 local realm의 redirect allowlist는 다음과 같이 wildcard로 설정되어 있다. ```text http://localhost:8088/* http://127.0.0.1:8088/* ``` SPA에서 실제 사용하는 callback은 `/OAuth2callback.html`이다. SPA : `/OAuth2callback.html`만 o exact callback만 허용하는 운영적 측면 또는 잘못된 redirect를 거부하는 검사는 x 현재 설정에서는 wildcard가 허용되어 있기 때문에 exact callback만 허용했을 때 잘못된 redirect가 거부되는지는 아직 확인하지 않았다. frontend Nginx에도 `/api/` proxy가 있지만 SPA에서는 상대 URL을 사용하지 않고 absolute URL인 `http://localhost:8081/api/me`를 호출한다. 그래서 현재 요청은 브라우저에서 Resource Server로 직접 나가고 CORS allowlist를 거친다. 상대 URL을 사용해서 Nginx를 통해 호출했다면 현재와 같은 CORS 경로는 지나지 않았을 것이다."
- group [ref=f3e128]:
- paragraph [ref=f3e129]: EVIDENCE
- heading "본문에 Asset 삽입" [level=3] [ref=f3e130]
- paragraph [ref=f3e131]: 목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.
- generic [ref=f3e132]:
- generic [ref=f3e133]:
- generic [ref=f3e134]: 업로드 종류
- combobox "업로드 종류" [ref=f3e135]:
- option "이미지"
- option "다이어그램" [selected]
- option "첨부파일"
- button "Asset 업로드" [ref=f3e136]
- generic [ref=f3e137]:
- search [ref=f3e138]:
- generic [ref=f3e139]: Asset 검색
- generic [ref=f3e140]:
- searchbox "Asset 검색" [ref=f3e141]
- button "검색" [active] [ref=f3e142]
- generic [ref=f3e143]:
- checkbox "삽입할 때 크게 보기 허용" [checked] [ref=f3e144]
- generic [ref=f3e145]: 삽입할 때 크게 보기 허용
- status [ref=f3e146]: 삽입할 수 있는 Asset 9개
- list [ref=f3e147]:
- listitem [ref=f3e148]:
- button "nplus1-query-fanout-644febe6" [ref=f3e149]
- button "삭제" [ref=f3e150]
- listitem [ref=f3e151]:
- button "ap4-edge-trust-1cff2399" [ref=f3e152]
- button "삭제" [ref=f3e153]
- listitem [ref=f3e154]:
- button "ap3-csrf-split-501dd1f7" [ref=f3e155]
- button "삭제" [ref=f3e156]
- listitem [ref=f3e157]:
- button "ap3-bff-custody-82fa18bd" [ref=f3e158]
- button "삭제" [ref=f3e159]
- listitem [ref=f3e160]:
- button "ap2-split-custody-779cb791" [ref=f3e161]
- button "삭제" [ref=f3e162]
- listitem [ref=f3e163]:
- button "ap1-custody-v3-6e0376d2" [ref=f3e164]
- button "삭제" [ref=f3e165]
- listitem [ref=f3e166]:
- button "ap1-custody-v2-e110bd98" [ref=f3e167]
- button "삭제" [ref=f3e168]
- listitem [ref=f3e169]:
- button "ap1-credential-custody-f5e0c027" [ref=f3e170]
- button "삭제" [ref=f3e171]
- listitem [ref=f3e172]:
- button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f3e173]
- button "삭제" [ref=f3e174]
- region [ref=f3e175]:
- generic [ref=f3e176]:
- paragraph [ref=f3e177]: LIVE
- heading "즉시 미리보기" [level=2] [ref=f3e178]
- generic [ref=f3e181]:
- generic [ref=f3e182]:
- navigation "문서 경로" [ref=f3e183]:
- link "검증 기록" [ref=f3e184] [cursor=pointer]:
- /url: /explore/cases
- generic [aria-hidden] [ref=f3e185]: /
- generic [ref=f3e186]: OAuth/OIDC 인증 경계
- generic [aria-hidden] [ref=f3e187]: /
- link "KeyCloak Patterns" [ref=f3e188] [cursor=pointer]:
- /url: /projects/keycloak-patterns
- heading "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우" [level=1] [ref=f3e189]
- paragraph [ref=f3e190]: AP1에서는 SPA를 public OAuth client로 구성하고 authorization code도 브라우저에서 직접 교환한다. 발급받은 access token, refresh token, ID token은 Web Storage에 저장하지 않고 JavaScript memory에만 둔다.
- paragraph [ref=f3e191]:
- text: 이렇게 하면 새로고침 뒤에는 token이 남지 않는다. 하지만 페이지가 열려 있는 동안에는 JavaScript에서 token을 사용하고 있고, Resource Server를 호출할 때도 access token을
- code [ref=f3e192]: Authorization
- text: 헤더에 넣는다. 실행 중 XSS가 발생했을 때 영향을 받는 부분은 여전히 생각해야 된다.
- region "문제와 결론" [ref=f3e193]:
- generic [ref=f3e194]:
- paragraph [ref=f3e195]: 문제
- paragraph [ref=f3e196]: AP1에서는 token을 Local Storage나 Session Storage에 저장하지 않고 JavaScript memory에만 둔다.
- paragraph [ref=f3e197]: 확인하고 싶었던 부분은 token을 Web Storage에 저장하지 않는 것만으로 실행 중 XSS까지 막을 수 있는지였다.
- paragraph [ref=f3e198]: SPA가 authorization code를 직접 교환한 뒤 token을 어디에 가지고 있는지, API를 호출할 때 access token이 어디를 지나는지 확인했다. PKCE도 실제로 어느 구간에 적용되는지 같이 봤다.
- generic [ref=f3e199]:
- paragraph [ref=f3e200]: 결론
- paragraph [ref=f3e201]: memory-only로 보관하면 새로고침 뒤에는 access token, refresh token, ID token이 남지 않는다.
- paragraph [ref=f3e202]:
- text: 하지만 페이지가 실행 중일 때는 JavaScript에서 token을 사용한다. 악성 script가 같은 페이지에서 실행되면
- code [ref=f3e203]: fetch
- text: 를 가로채거나 사용자를 대신해서 API를 호출할 수 있다. access token도 JavaScript memory에만 있는 것이 아니라 Resource Server 요청의
- code [ref=f3e204]: Authorization
- text: 헤더에 들어간다.
- paragraph [ref=f3e205]:
- text: Resource Server는
- code [ref=f3e206]: SessionCreationPolicy.STATELESS
- text: 로 동작한다. 서버에서 삭제할 application session이 없고, 이미 발급된 self-contained JWT를 logout과 동시에 없애는 처리도 없다.
- paragraph [ref=f3e207]: 현재 구성에서는 access token 수명을 300초로 두고 refresh token rotation을 사용한다. Resource Server에서는 issuer와 audience도 확인한다.
- generic [ref=f3e208]:
- generic [ref=f3e209]:
- term [ref=f3e210]: 검증 환경
- definition [ref=f3e211]:
- paragraph [ref=f3e212]: Keycloak 26.7.0
- paragraph [ref=f3e213]: "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"
- paragraph [ref=f3e214]: "Resource Server SessionCreationPolicy.STATELESS CSRF x CORS allowlist : localhost:8088, 127.0.0.1:8088, GET·OPTIONS, Authorization·Content-Type"
- paragraph [ref=f3e215]: "HTTPS : x HTTP : o"
- generic [ref=f3e216]:
- term [ref=f3e217]: 검증 데이터
- definition [ref=f3e218]:
- paragraph [ref=f3e219]:
- text: 1. SPA를 열고 로그인한 뒤 Keycloak authorization request에서
- code [ref=f3e220]: response_type=code
- text: ","
- code [ref=f3e221]: code_challenge_method=S256
- text: ", 비어 있지 않은"
- code [ref=f3e222]: code_challenge
- text: 를 확인한다.
- paragraph [ref=f3e223]: 2. token 응답의 access token, refresh token, ID token이 비어 있지 않은지 확인한다.
- paragraph [ref=f3e224]:
- text: 3. 브라우저
- code [ref=f3e225]: fetch
- text: 를 hook하고
- code [ref=f3e226]: /api/me
- text: 요청의
- code [ref=f3e227]: Authorization
- text: 헤더에서 Bearer access token을 확인한다.
- paragraph [ref=f3e228]: 4. Local Storage와 Session Storage에 access token substring이 남지 않는지 확인한다.
- paragraph [ref=f3e229]: 5. 같은 정상 JWT를 expected issuer와 audience가 다른 diagnostic server 두 곳에 보내고 401이 반환되는지 확인한다.
- paragraph [ref=f3e230]: 6. refresh token으로 새 token을 받은 뒤 이전 refresh token이 거부되는지 확인한다. revocation 뒤에는 refresh가 실패하는지, 이미 발급된 access JWT는 만료 전까지 200을 받는지도 확인한다.
- generic [ref=f3e231]:
- term [ref=f3e232]: 기록
- definition [ref=f3e233]: 게시 2026.08.23 · 마지막 검증 2026.08.22
- group [ref=f3e235]:
- generic "목차 · SPA에서 Token을 처리하는 위치" [ref=f3e236] [cursor=pointer]
- article [ref=f3e238]:
- figure [ref=f3e503]:
- button "ap1-direct-architecture-0adf4199 이미지 크게 보기" [ref=f3e504]:
- img "SPA, Keycloak, 브라우저 JavaScript memory, Resource Server가 왼쪽에서 오른쪽으로 연결된 AP1 직접 인증 아키텍처." [ref=f3e505]
- generic [ref=f3e506]: 크게 보기
- generic [ref=f3e507]: SPA, Keycloak, 브라우저 JavaScript memory, Resource Server가 왼쪽에서 오른쪽으로 연결된 AP1 직접 인증 아키텍처.
- region [ref=f3e239]:
- heading [level=2] [ref=f3e240]:
- link "SPA에서 Token을 처리하는 위치 바로가기" [ref=f3e241] [cursor=pointer]:
- /url: "#spa에서-token을-처리하는-위치"
- text: SPA에서 Token을 처리하는 위치
- generic [aria-hidden] [ref=f3e242]: "#"
- figure [ref=f3e243]:
- button "ap1-custody-v3-6e0376d2 이미지 크게 보기" [ref=f3e244]:
- img "브라우저 실행 영역 안에 code 교환, access·refresh·ID token 보관, Authorization 헤더 조립 세 상자가 들어 있고 그 영역 전체가 실행 중 XSS가 닿는 범위로 표시된 그림. Keycloak과 Resource Server는 그 밖에 있다." [ref=f3e245]
- generic [ref=f3e246]: 크게 보기
- generic [ref=f3e247]: 브라우저 실행 영역 안에 code 교환, access·refresh·ID token 보관, Authorization 헤더 조립 세 상자가 들어 있고 그 영역 전체가 실행 중 XSS가 닿는 범위로 표시된 그림. Keycloak과 Resource Server는 그 밖에 있다.
- paragraph [ref=f3e248]:
- text: authorization code 교환, token 보관,
- code [ref=f3e249]: Authorization
- text: 헤더 생성까지 모두 브라우저에서 처리한다.
- paragraph [ref=f3e250]:
- text: access token, refresh token, ID token도 JavaScript memory에 있고 Resource Server를 호출할 때 사용할
- code [ref=f3e251]: Authorization
- text: 헤더도 같은 페이지에서 만든다.
- paragraph [ref=f3e252]: 그래서 이 페이지에서 악성 script가 실행되면 JavaScript가 token을 사용하는 부분에도 접근할 수 있다.
- region [ref=f3e253]:
- heading [level=2] [ref=f3e254]:
- link "새로고침 전후에 브라우저에 남는 값 바로가기" [ref=f3e255] [cursor=pointer]:
- /url: "#새로고침-전후에-브라우저에-남는-값"
- text: 새로고침 전후에 브라우저에 남는 값
- generic [aria-hidden] [ref=f3e256]: "#"
- paragraph [ref=f3e257]:
- code [ref=f3e258]: oidc-client-ts
- text:
- code [ref=f3e259]: InMemoryWebStorage
- text: 를 사용해서 로그인 결과를 Local Storage나 Session Storage에 저장하지 않고 실행 중 memory에만 둔다.
- paragraph [ref=f3e260]: 새로고침하면 memory에 있던 로그인 정보와 token은 사라진다.
- region "표" [ref=f3e261]:
- table [ref=f3e262]:
- caption [ref=f3e263]
- rowgroup [ref=f3e264]:
- row [ref=f3e265]:
- columnheader "위치" [ref=f3e266]
- columnheader "reload 전" [ref=f3e267]
- columnheader "reload 후" [ref=f3e268]
- rowgroup [ref=f3e269]:
- row [ref=f3e270]:
- cell "JavaScript memory" [ref=f3e271]
- cell [ref=f3e272]:
- code [ref=f3e273]: User
- text: ", access·refresh·ID token, expiry, profile"
- cell "사라짐" [ref=f3e274]
- row [ref=f3e275]:
- cell "Session Storage" [ref=f3e276]
- cell "redirect transaction용 state와 verifier" [ref=f3e277]
- cell "callback 완료 뒤 제거" [ref=f3e278]
- row [ref=f3e279]:
- cell "Local Storage" [ref=f3e280]
- cell "해당 없음" [ref=f3e281]
- cell "해당 없음" [ref=f3e282]
- row [ref=f3e283]:
- cell "Keycloak origin cookie" [ref=f3e284]
- cell "IdP의 SSO 상태가 존재할 수 있음" [ref=f3e285]
- cell "application과 별개" [ref=f3e286]
- paragraph [ref=f3e287]:
- text: JavaScript memory에 있던
- code [ref=f3e288]: User
- text: 가 사라지는 것과 Keycloak의 SSO session이 끝나는 것은 별개다.
- paragraph [ref=f3e289]: SPA에서 가지고 있던 token이 사라져도 Keycloak SSO cookie가 남아 있으면 이후 authorization request에서 기존 로그인 상태가 다시 사용될 수 있다.
- region [ref=f3e290]:
- heading [level=2] [ref=f3e291]:
- link "Memory-only로 막을 수 있는 범위 바로가기" [ref=f3e292] [cursor=pointer]:
- /url: "#memory-only로-막을-수-있는-범위"
- text: Memory-only로 막을 수 있는 범위
- generic [aria-hidden] [ref=f3e293]: "#"
- paragraph [ref=f3e294]: memory-only로 바꾼 뒤 어떤 값이 없어지고 어떤 부분은 그대로 남는지 확인했다.
- region "표" [ref=f3e295]:
- table [ref=f3e296]:
- caption [ref=f3e297]
- rowgroup [ref=f3e298]:
- row [ref=f3e299]:
- columnheader "위협" [ref=f3e300]
- columnheader "memory-only가 막아주나" [ref=f3e301]
- rowgroup [ref=f3e302]:
- row [ref=f3e303]:
- cell "새로고침 뒤에도 남는 token 복사본" [ref=f3e304]
- cell "막아준다" [ref=f3e305]
- row [ref=f3e306]:
- cell "실행 중 script가 fetch를 가로채기" [ref=f3e307]
- cell "막아주지 않는다" [ref=f3e308]
- row [ref=f3e309]:
- cell "실행 중 script가 사용자 대신 API 호출" [ref=f3e310]
- cell "막아주지 않는다" [ref=f3e311]
- row [ref=f3e312]:
- cell "network 요청 헤더에 실린 access token" [ref=f3e313]
- cell "막아주지 않는다" [ref=f3e314]
- row [ref=f3e315]:
- cell "이미 발급된 access JWT의 만료 전 유효성" [ref=f3e316]
- cell "막아주지 않는다" [ref=f3e317]
- paragraph [ref=f3e318]:
- text: Resource Server를 호출할 때 SPA에서 access token을
- code [ref=f3e319]: Authorization
- text: 헤더에 넣는다.
- figure "HTTP ·브라우저가 Resource Server를 직접 부를 때 코드 복사" [ref=f3e320]:
- generic [ref=f3e321]:
- generic [ref=f3e322]: HTTP
- generic [ref=f3e323]: ·브라우저가 Resource Server를 직접 부를 때
- button "코드 복사" [ref=f3e324] [cursor=pointer]: 복사
- region "브라우저가 Resource Server를 직접 부를 때 코드" [ref=f3e325]:
- code [ref=f3e326]: "GET http://localhost:8081/api/me Authorization: Bearer <access-token>"
- paragraph [ref=f3e328]:
- text: 그래서 access token은 JavaScript memory에만 존재하는 값은 아니다. API를 호출하는 동안에는 network 요청의
- code [ref=f3e329]: Authorization
- text: 헤더에도 들어간다.
- paragraph [ref=f3e330]:
- text: Resource Server는
- code [ref=f3e331]: SessionCreationPolicy.STATELESS
- text: 로 설정되어 있어서 서버에서 삭제할 application session이 없다.
- paragraph [ref=f3e332]: 이미 발급된 self-contained JWT를 logout 시점에 바로 무효화하는 처리도 넣지 않았다. logout에서는 Keycloak SSO 종료와 SPA의 user 제거를 처리하고, 발급된 access JWT를 deny-list로 따로 관리하지 않는다.
- paragraph [ref=f3e333]: 현재 access token 수명은 300초다.
- paragraph [ref=f3e334]: "access token : 300초refresh token rotation, 재사용 허용 : xissuer·audience : 검증"
- paragraph [ref=f3e335]: Local Storage나 Session Storage에 token을 저장하면 새로고침 이후에도 값을 다시 읽을 수 있지만, 브라우저 저장소에도 token이 남게 된다.
- paragraph [ref=f3e336]: HttpOnly cookie를 사용하려면 현재 SPA처럼 브라우저에서 access token을 꺼내 Resource Server로 직접 보내는 방식과는 달라진다. server가 session이나 token 전달을 맡는 구조가 필요하다.
- region [ref=f3e337]:
- heading [level=2] [ref=f3e338]:
- link "PKCE가 적용되는 구간 바로가기" [ref=f3e339] [cursor=pointer]:
- /url: "#pkce가-적용되는-구간"
- text: PKCE가 적용되는 구간
- generic [aria-hidden] [ref=f3e340]: "#"
- paragraph [ref=f3e341]:
- text: PKCE(Proof Key for Code Exchange)를 사용할 때 authorization request에는
- code [ref=f3e342]: code_challenge
- text: 가 들어가고, authorization code를 token으로 교환할 때는 원본인
- code [ref=f3e343]: code_verifier
- text: 를 함께 보낸다. 두 값이 맞아야 code를 교환할 수 있다.
- figure "TEXT ·oidc-client-ts가 만드는 authorization request의 핵심 query 코드 복사" [ref=f3e344]:
- generic [ref=f3e345]:
- generic [ref=f3e346]: TEXT
- generic [ref=f3e347]: ·oidc-client-ts가 만드는 authorization request의 핵심 query
- button "코드 복사" [ref=f3e348] [cursor=pointer]: 복사
- region "oidc-client-ts가 만드는 authorization request의 핵심 query 코드" [ref=f3e349]:
- code [ref=f3e350]: 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=f3e352]:
- text: 이번 설정에서는
- code [ref=f3e353]: response_type=code
- text: 를 사용하고
- code [ref=f3e354]: code_challenge_method=S256
- text: 과 비어 있지 않은
- code [ref=f3e355]: code_challenge
- text: 가 authorization request에 들어가는 것을 확인했다.
- paragraph [ref=f3e356]: PKCE가 적용되는 곳은 authorization code를 token으로 교환하는 구간이다. token이 발급된 이후 access token을 브라우저에서 사용하지 못하게 하는 기능은 아니다.
- region [ref=f3e357]:
- heading [level=2] [ref=f3e358]:
- link "테스트에서 확인한 범위 바로가기" [ref=f3e359] [cursor=pointer]:
- /url: "#테스트에서-확인한-범위"
- text: 테스트에서 확인한 범위
- generic [aria-hidden] [ref=f3e360]: "#"
- paragraph [ref=f3e361]: 커밋된 테스트에서 확인하도록 만들어 둔 항목은 다음과 같다.
- region "표" [ref=f3e362]:
- table [ref=f3e363]:
- caption [ref=f3e364]
- rowgroup [ref=f3e365]:
- row [ref=f3e366]:
- columnheader "정의 여부" [ref=f3e367]
- columnheader "정의 내용" [ref=f3e368]
- rowgroup [ref=f3e369]:
- row [ref=f3e370]:
- cell "o" [ref=f3e371]
- cell [ref=f3e372]:
- text: authorization request의
- code [ref=f3e373]: response_type=code
- text: ", S256 method, 비어 있지 않은 challenge"
- row [ref=f3e374]:
- cell "o" [ref=f3e375]
- cell "token 응답에 비어 있지 않은 access·refresh·ID token" [ref=f3e376]
- row [ref=f3e377]:
- cell "o" [ref=f3e378]
- cell [ref=f3e379]:
- code [ref=f3e380]: /api/me
- text: 200과 decoded access token의 audience 포함
- row [ref=f3e381]:
- cell "o" [ref=f3e382]
- cell "브라우저 fetch를 가로채 Authorization 헤더의 Bearer token 관측" [ref=f3e383]
- row [ref=f3e384]:
- cell "o" [ref=f3e385]
- cell "Local Storage와 Session Storage에 access token substring 없음" [ref=f3e386]
- row [ref=f3e387]:
- cell "o" [ref=f3e388]
- cell "refresh rotation — 새 token 발급, 이전 token 거부, revocation 뒤 refresh 실패" [ref=f3e389]
- row [ref=f3e390]:
- cell "o" [ref=f3e391]
- cell "issuer나 audience가 다른 진단용 서버 두 곳의 401" [ref=f3e392]
- row [ref=f3e393]:
- cell "x" [ref=f3e394]
- cell [ref=f3e395]:
- text: token request body의
- code [ref=f3e396]: code_verifier
- text: ·
- code [ref=f3e397]: client_id
- text: ·
- code [ref=f3e398]: redirect_uri
- text: ·code 값 대조
- row [ref=f3e399]:
- cell "x" [ref=f3e400]
- cell "서명이 깨진 JWT, 만료된 JWT" [ref=f3e401]
- row [ref=f3e402]:
- cell "x" [ref=f3e403]
- cell "브라우저 간 요청(CORS)의 preflight 응답" [ref=f3e404]
- row [ref=f3e405]:
- cell "x" [ref=f3e406]
- cell "callback에 error가 실려 돌아왔을 때의 화면" [ref=f3e407]
- row [ref=f3e408]:
- cell "x" [ref=f3e409]
- cell [ref=f3e410]:
- code [ref=f3e411]: automaticSilentRenew
- text: 의 실제 갱신 경로
- paragraph [ref=f3e412]:
- text: authorization request에서는
- code [ref=f3e413]: response_type=code
- text: ", S256 method, 비어 있지 않은 challenge까지 확인했다."
- paragraph [ref=f3e414]:
- text: 하지만 token request body에서 실제
- code [ref=f3e415]: code_verifier
- text: ","
- code [ref=f3e416]: client_id
- text: ","
- code [ref=f3e417]: redirect_uri
- text: ", code 값이 어떻게 전달됐고 서로 대조됐는지는 아직 확인하지 않았다."
- complementary "주의" [ref=f3e418]:
- paragraph [ref=f3e419]: 주의
- paragraph [ref=f3e420]:
- text: SPA는 non-2xx 응답에서도
- code [ref=f3e421]: response.ok
- text: 을 확인하기 전에
- code [ref=f3e422]: response.json()
- text: 을 시도한다. 401 body가 비어 있거나 JSON이 아니면 의도한 메시지 대신 JSON parse error가 먼저 노출되게 된다.
- region [ref=f3e423]:
- heading [level=2] [ref=f3e424]:
- link "Redirect URI와 CORS에서 아직 확인하지 않은 부분 바로가기" [ref=f3e425] [cursor=pointer]:
- /url: "#redirect-uri와-cors에서-아직-확인하지-않은-부분"
- text: Redirect URI와 CORS에서 아직 확인하지 않은 부분
- generic [aria-hidden] [ref=f3e426]: "#"
- paragraph [ref=f3e427]: local realm의 redirect allowlist는 다음과 같이 wildcard로 설정되어 있다.
- figure "TEXT ·코드 코드 복사" [ref=f3e428]:
- generic [ref=f3e429]:
- generic [ref=f3e430]: TEXT
- generic [ref=f3e431]: ·코드
- button "코드 복사" [ref=f3e432] [cursor=pointer]: 복사
- region "코드 코드" [ref=f3e433]:
- code [ref=f3e434]: http://localhost:8088/* http://127.0.0.1:8088/*
- paragraph [ref=f3e436]:
- text: SPA에서 실제 사용하는 callback은
- code [ref=f3e437]: /OAuth2callback.html
- text: 이다.
- paragraph [ref=f3e438]:
- text: "SPA :"
- code [ref=f3e439]: /OAuth2callback.html
- text: 만 oexact callback만 허용하는 운영적 측면 또는 잘못된 redirect를 거부하는 검사는 x
- paragraph [ref=f3e440]: 현재 설정에서는 wildcard가 허용되어 있기 때문에 exact callback만 허용했을 때 잘못된 redirect가 거부되는지는 아직 확인하지 않았다.
- paragraph [ref=f3e441]:
- text: frontend Nginx에도
- code [ref=f3e442]: /api/
- text: proxy가 있지만 SPA에서는 상대 URL을 사용하지 않고 absolute URL인
- code [ref=f3e443]: http://localhost:8081/api/me
- text: 를 호출한다.
- paragraph [ref=f3e444]: 그래서 현재 요청은 브라우저에서 Resource Server로 직접 나가고 CORS allowlist를 거친다. 상대 URL을 사용해서 Nginx를 통해 호출했다면 현재와 같은 CORS 경로는 지나지 않았을 것이다.
- region [ref=f3e445]:
- paragraph [ref=f3e446]: Next
- heading "다음에 읽을 것" [level=2] [ref=f3e447]
- list [ref=f3e448]:
- listitem [ref=f3e449]:
- link "적용 기준 Authorization Code Flow의 Endpoint와 Credential 이동 기준" [ref=f3e450] [cursor=pointer]:
- /url: /references/authorization-code-endpoint-credential-movement
- generic [ref=f3e451]: 적용 기준
- generic [ref=f3e452]:
- strong [ref=f3e453]: Authorization Code Flow의 Endpoint와 Credential 이동 기준
- paragraph [aria-hidden] [ref=f3e454]: SPA에서 authorization request를 보내고 authorization code를 받은 뒤 token을 교환하는 과정을 직접 확인한 내용이다.
- generic [aria-hidden] [ref=f3e455]:
- listitem [ref=f3e456]:
- link "적용 기준 Public Client와 Confidential Client 구분 기준" [ref=f3e457] [cursor=pointer]:
- /url: /references/public-confidential-client-boundary
- generic [ref=f3e458]: 적용 기준
- generic [ref=f3e459]:
- strong [ref=f3e460]: Public Client와 Confidential Client 구분 기준
- paragraph [aria-hidden] [ref=f3e461]: SPA는 client secret을 안전하게 숨길 수 없기 때문에 public client로 구성했고 PKCE S256을 사용했다.
- generic [aria-hidden] [ref=f3e462]:
- listitem [ref=f3e463]:
- link "적용 기준 OAuth Token과 Application Session을 구분하는 기준" [ref=f3e464] [cursor=pointer]:
- /url: /references/oauth-token-application-session-boundary
- generic [ref=f3e465]: 적용 기준
- generic [ref=f3e466]:
- strong [ref=f3e467]: OAuth Token과 Application Session을 구분하는 기준
- paragraph [aria-hidden] [ref=f3e468]: JavaScript memory에 있는 token과 Keycloak의 SSO cookie는 서로 다른 상태다. 새로고침 뒤 SPA의 token이 없어져도 Keycloak의 SSO 상태는 남아 있을 수 있다.
- generic [aria-hidden] [ref=f3e469]:
- complementary [ref=f3e470]:
- heading "작업 상태" [level=2] [ref=f3e471]
- status "편집 상태" [ref=f3e472]: 저장되지 않음
- generic [ref=f3e473]:
- generic [ref=f3e474]:
- term [ref=f3e475]: 저장 버전
- definition [ref=f3e476]: "35"
- generic [ref=f3e477]:
- term [ref=f3e478]: 종류
- definition [ref=f3e479]: 검증 기록
- paragraph [ref=f3e480]: 불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.
- generic [ref=f3e481]:
- button "저장" [ref=f3e482]
- button "게시" [ref=f3e483]
- paragraph [ref=f3e484]