- generic [ref=f22e3]: - link "본문으로 건너뛰기" [ref=f22e4] [cursor=pointer]: - /url: "#main-content" - banner [ref=f22e5]: - generic [ref=f22e6]: - link "TechLog Studio" [ref=f22e7] [cursor=pointer]: - /url: /studio - text: TechLog - generic [ref=f22e8]: Studio - navigation "Studio 주 탐색" [ref=f22e10]: - link "작업본" [ref=f22e11] [cursor=pointer]: - /url: /studio/documents - link "게시 기록" [ref=f22e12] [cursor=pointer]: - /url: /studio/publications - link "새 문서" [ref=f22e13] [cursor=pointer]: - /url: /studio/documents/new - link "주제·프로젝트" [ref=f22e14] [cursor=pointer]: - /url: /studio/taxonomy - link "릴리즈" [ref=f22e15] [cursor=pointer]: - /url: /studio/releases - link "공개 사이트 보기" [ref=f22e16] [cursor=pointer]: - /url: / - button "로그아웃" [ref=f22e17] - main [ref=f22e18]: - generic [ref=f22e19]: - generic [ref=f22e20]: - region [ref=f22e21]: - generic [ref=f22e22]: - paragraph [ref=f22e23]: CASE · VERSION 27 - heading "문서 편집" [level=1] [ref=f22e24] - paragraph [ref=f22e25]: SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우 - region [ref=f22e26]: - generic [ref=f22e27]: - paragraph [ref=f22e28]: DOCUMENT - heading "기본 정보" [level=2] [ref=f22e29] - generic [ref=f22e30]: - generic [ref=f22e31]: - generic [ref=f22e32]: 제목 - textbox "제목" [ref=f22e33]: SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우 - generic [ref=f22e34]: - generic [ref=f22e35]: slug - textbox "slug" [ref=f22e36]: - /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈) - text: spa-browser-credential-boundary - generic [ref=f22e37]: - generic [ref=f22e38]: 요약 - textbox "요약" [ref=f22e39]: SPA는 access·refresh·ID token을 InMemoryWebStorage에 보관하도록 구성했다. Local Storage와 Session Storage에서는 access token 문자열을 찾지 못했고, /api/me를 호출할 때는 access token이 Authorization 헤더에 들어가는 것을 확인했다. - generic [ref=f22e40]: - generic [ref=f22e41]: Topic - combobox "Topic" [ref=f22e42]: - option "선택하지 않음" - option "OAuth/OIDC 인증 경계" [selected] - generic [ref=f22e43]: - generic [ref=f22e44]: Project - combobox "Project" [ref=f22e45]: - option "미지정" - option "Backend Clean Architecture" - option "KeyCloak Patterns" [selected] - option "Liner N + 1문제" - group "관계" [ref=f22e46]: - generic [ref=f22e48]: - generic [ref=f22e49]: - generic [ref=f22e50]: 관계 1 대상 - combobox "관계 1 대상" [ref=f22e51]: - option "대상 선택" - option "인증 구조를 보안 성숙도 단계로 취급하지 않는다" - option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가" - option "Authorization Code Flow의 Endpoint와 Credential 이동 기준" [selected] - option "BFF 인증 구조 설계 기준" - option "BFF가 OAuth Token을 관리하는 조건" - option "BFF에서 OAuth Token을 관리할 때 Session과 CSRF를 처리한 과정" - option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가" - 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 "Mediator가 Refresh Token을 관리하고 Access Token을 Browser에 전달하는 구조" - option "OAuth/OIDC 인증 패턴 선택 기준" - option "OAuth Token과 Application Session을 구분하는 기준" [disabled] - option "Public Client와 Confidential Client 구분 기준" [disabled] - option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가" - option "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계" - generic [ref=f22e52]: - generic [ref=f22e53]: 관계 1 이유 - textbox "관계 1 이유" [ref=f22e54]: 브라우저가 authorization endpoint와 token endpoint를 직접 호출한 구성이다. - generic [ref=f22e55]: - button "위로" [disabled] [ref=f22e56] - button "아래로" [ref=f22e57] - button "삭제" [ref=f22e58] - generic [ref=f22e59]: - generic [ref=f22e60]: - generic [ref=f22e61]: 관계 2 대상 - combobox "관계 2 대상" [ref=f22e62]: - option "대상 선택" - option "인증 구조를 보안 성숙도 단계로 취급하지 않는다" - option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가" - option "Authorization Code Flow의 Endpoint와 Credential 이동 기준" [disabled] - option "BFF 인증 구조 설계 기준" - option "BFF가 OAuth Token을 관리하는 조건" - option "BFF에서 OAuth Token을 관리할 때 Session과 CSRF를 처리한 과정" - option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가" - 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 "Mediator가 Refresh Token을 관리하고 Access Token을 Browser에 전달하는 구조" - option "OAuth/OIDC 인증 패턴 선택 기준" - option "OAuth Token과 Application Session을 구분하는 기준" [disabled] - option "Public Client와 Confidential Client 구분 기준" [selected] - option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가" - option "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계" - generic [ref=f22e63]: - generic [ref=f22e64]: 관계 2 이유 - textbox "관계 2 이유" [ref=f22e65]: AP1에서 SPA를 public client로 구성한 이유와 연결된다. - generic [ref=f22e66]: - button "위로" [ref=f22e67] - button "아래로" [ref=f22e68] - button "삭제" [ref=f22e69] - generic [ref=f22e70]: - generic [ref=f22e71]: - generic [ref=f22e72]: 관계 3 대상 - combobox "관계 3 대상" [ref=f22e73]: - option "대상 선택" - option "인증 구조를 보안 성숙도 단계로 취급하지 않는다" - option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가" - option "Authorization Code Flow의 Endpoint와 Credential 이동 기준" [disabled] - option "BFF 인증 구조 설계 기준" - option "BFF가 OAuth Token을 관리하는 조건" - option "BFF에서 OAuth Token을 관리할 때 Session과 CSRF를 처리한 과정" - option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가" - 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 "Mediator가 Refresh Token을 관리하고 Access Token을 Browser에 전달하는 구조" - option "OAuth/OIDC 인증 패턴 선택 기준" - option "OAuth Token과 Application Session을 구분하는 기준" [selected] - option "Public Client와 Confidential Client 구분 기준" [disabled] - option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가" - option "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계" - generic [ref=f22e74]: - generic [ref=f22e75]: 관계 3 이유 - textbox "관계 3 이유" [ref=f22e76]: JavaScript memory의 token과 Keycloak 도메인의 SSO cookie를 구분하는 기준이다. - generic [ref=f22e77]: - button "위로" [ref=f22e78] - button "아래로" [disabled] [ref=f22e79] - button "삭제" [ref=f22e80] - button "관계 추가" [ref=f22e81] - region [ref=f22e82]: - generic [ref=f22e83]: - paragraph [ref=f22e84]: CASE - heading "문제와 검증" [level=2] [ref=f22e85] - generic [ref=f22e86]: - generic [ref=f22e87]: - generic [ref=f22e88]: 문제 - textbox "문제" [ref=f22e89]: AP1에서는 oidc-client-ts의 InMemoryWebStorage를 사용했다. token을 Local Storage나 Session Storage에 저장하지 않으므로 브라우저에 token을 보관하는 문제가 해결된 것처럼 볼 수도 있었다. 실제로는 /api/me를 호출하려면 SPA가 access token을 직접 사용해야 한다. Web Storage에 저장하지 않는 것과 실행 중 JavaScript가 token을 사용하는 것을 같은 것으로 볼 수 있는지 확인했다. - generic [ref=f22e90]: - generic [ref=f22e91]: 결론 - textbox "결론" [ref=f22e92]: "Local Storage와 Session Storage에서는 access token 문자열을 확인하지 못했다. 로그인 중에는 access token이 JavaScript에서 처리되고 /api/me 요청의 Authorization: Bearer 헤더에도 사용됐다. 이번 구성에서 memory-only는 token을 Web Storage에 저장하지 않는다는 의미로 한정해서 쓴다. PKCE는 authorization request의 파라미터까지 확인했고, token request의 code_verifier 대조는 하지 않았다." - generic [ref=f22e93]: - generic [ref=f22e94]: 검증 환경 - textbox "검증 환경" [ref=f22e95]: "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=f22e96]: - generic [ref=f22e97]: 재현 조건 - textbox "재현 조건" [ref=f22e98]: 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=f22e99]: - generic [ref=f22e100]: 마지막 검증일 - textbox "마지막 검증일" [ref=f22e101]: 2026-08-22 - generic [ref=f22e102]: - generic [ref=f22e103]: 본문 Markdown - textbox "본문 Markdown" [ref=f22e104]: "## 브라우저가 직접 다루는 credential :::evidence key=\"ap1-custody-v3-6e0376d2\" alt=\"브라우저 실행 영역 안에 code 교환, access·refresh·ID token 보관, Authorization 헤더 조립 세 상자가 들어 있고 그 영역 전체가 실행 중 XSS가 닿는 범위로 표시된 그림. Keycloak과 Resource Server는 그 밖에 있다.\" caption=\" \" zoom=\"true\" ::: code 교환과 token 보관, API 요청 조립은 모두 SPA에서 처리한다. 테스트에서는 `fetch`를 hook했을 때 `/api/me` 요청에 사용되는 Bearer token을 확인할 수 있었다. ## 새로고침 전후의 브라우저 상태 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 | access token 문자열 없음 | access token 문자열 없음 | Keycloak origin의 SSO cookie는 SPA의 `InMemoryWebStorage`와 별도로 관리된다. ## Memory에 보관한 Token을 확인한 지점 | 확인 지점 | 결과 | |---|---| | Local Storage | access token 문자열 없음 | | Session Storage | access token 문자열 없음 | | token 응답 | access·refresh·ID token 확인 | | `/api/me` 요청 | `Authorization: Bearer` 확인 | | reload 후 `InMemoryWebStorage` | 기존 `User` 초기화 | ```http label=\"브라우저가 Resource Server를 직접 부를 때\" GET http://localhost:8081/api/me Authorization: Bearer ``` `/api/me` 요청에서는 access token이 `Authorization` 헤더에 들어갔다. 이 구성의 token 설정은 다음과 같다. access token : 300초 refresh token rotation, 재사용 허용 : x rotation과 revocation 동작은 별도 테스트에서 확인했다. ## Authorization Request에서 확인한 PKCE 설정 authorization request에는 `code_challenge`와 `code_challenge_method=S256`이 포함됐다. ```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 ``` 현재 테스트는 여기까지 확인한다. token request의 `code_verifier`는 캡처하지 않았기 때문에 challenge와 verifier의 대응까지 검증했다고 볼 수는 없다. ## 확인한 것과 확인하지 않은 것 커밋된 테스트가 확인하도록 정의한 부분이다. | 정의 여부 | 정의 내용 | |---|---| | 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`의 실제 갱신 경로 | ## 검증 범위에서 빠진 설정 local realm의 redirect URI에는 `http://localhost:8088/*`와 `http://127.0.0.1:8088/*`가 등록돼 있다. SPA가 실제로 사용하는 callback은 `/OAuth2callback.html`이지만, 등록되지 않은 callback을 거부하는 검사는 하지 않았다. frontend Nginx에도 `/api/` proxy가 있지만 SPA는 absolute `http://localhost:8081/api/me`를 사용한다. 브라우저는 8088에서 8081로 cross-origin 요청을 보내므로 이 요청에는 Resource Server의 CORS allowlist가 적용된다." - group [ref=f22e105]: - paragraph [ref=f22e106]: EVIDENCE - heading "본문에 Asset 삽입" [level=3] [ref=f22e107] - paragraph [ref=f22e108]: 목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다. - generic [ref=f22e109]: - generic [ref=f22e110]: - generic [ref=f22e111]: 업로드 종류 - combobox "업로드 종류" [ref=f22e112]: - option "이미지" [selected] - option "다이어그램" - option "첨부파일" - button "Asset 업로드" [ref=f22e113] - generic [ref=f22e114]: - search [ref=f22e115]: - generic [ref=f22e116]: Asset 검색 - generic [ref=f22e117]: - searchbox "Asset 검색" [ref=f22e118] - button "검색" [ref=f22e119] - generic [ref=f22e120]: - checkbox "삽입할 때 크게 보기 허용" [checked] [ref=f22e121] - generic [ref=f22e122]: 삽입할 때 크게 보기 허용 - status [ref=f22e123]: 삽입할 수 있는 Asset 8개 - list [ref=f22e124]: - listitem [ref=f22e125]: - button "ap4-edge-trust-1cff2399" [ref=f22e126] - button "삭제" [ref=f22e127] - listitem [ref=f22e128]: - button "ap3-csrf-split-501dd1f7" [ref=f22e129] - button "삭제" [ref=f22e130] - listitem [ref=f22e131]: - button "ap3-bff-custody-82fa18bd" [ref=f22e132] - button "삭제" [ref=f22e133] - listitem [ref=f22e134]: - button "ap2-split-custody-779cb791" [ref=f22e135] - button "삭제" [ref=f22e136] - listitem [ref=f22e137]: - button "ap1-custody-v3-6e0376d2" [ref=f22e138] - button "삭제" [ref=f22e139] - listitem [ref=f22e140]: - button "ap1-custody-v2-e110bd98" [ref=f22e141] - button "삭제" [ref=f22e142] - listitem [ref=f22e143]: - button "ap1-credential-custody-f5e0c027" [ref=f22e144] - button "삭제" [ref=f22e145] - listitem [ref=f22e146]: - button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f22e147] - button "삭제" [ref=f22e148] - region [ref=f22e149]: - generic [ref=f22e150]: - paragraph [ref=f22e151]: LIVE - heading "즉시 미리보기" [level=2] [ref=f22e152] - generic [ref=f22e155]: - generic [ref=f22e156]: - navigation "문서 경로" [ref=f22e157]: - link "Case" [ref=f22e158] [cursor=pointer]: - /url: /explore/cases - generic [ref=f22e159]: / - generic [ref=f22e160]: OAuth/OIDC 인증 경계 - generic [ref=f22e161]: / - link "KeyCloak Patterns" [ref=f22e162] [cursor=pointer]: - /url: /projects/keycloak-patterns - heading "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우" [level=1] [ref=f22e163] - paragraph [ref=f22e164]: SPA는 access·refresh·ID token을 InMemoryWebStorage에 보관하도록 구성했다. Local Storage와 Session Storage에서는 access token 문자열을 찾지 못했고, /api/me를 호출할 때는 access token이 Authorization 헤더에 들어가는 것을 확인했다. - region "문제와 결론" [ref=f22e165]: - generic [ref=f22e166]: - paragraph [ref=f22e167]: 문제 - paragraph [ref=f22e168]: AP1에서는 oidc-client-ts의 InMemoryWebStorage를 사용했다. token을 Local Storage나 Session Storage에 저장하지 않으므로 브라우저에 token을 보관하는 문제가 해결된 것처럼 볼 수도 있었다.실제로는 /api/me를 호출하려면 SPA가 access token을 직접 사용해야 한다. Web Storage에 저장하지 않는 것과 실행 중 JavaScript가 token을 사용하는 것을 같은 것으로 볼 수 있는지 확인했다. - generic [ref=f22e169]: - paragraph [ref=f22e170]: 결론 - paragraph [ref=f22e171]: "Local Storage와 Session Storage에서는 access token 문자열을 확인하지 못했다. 로그인 중에는 access token이 JavaScript에서 처리되고 /api/me 요청의 Authorization: Bearer 헤더에도 사용됐다.이번 구성에서 memory-only는 token을 Web Storage에 저장하지 않는다는 의미로 한정해서 쓴다.PKCE는 authorization request의 파라미터까지 확인했고, token request의 code_verifier 대조는 하지 않았다." - generic [ref=f22e172]: - generic [ref=f22e173]: - term [ref=f22e174]: 검증 환경 - definition [ref=f22e175]: "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=f22e176]: - term [ref=f22e177]: 검증 데이터 - definition [ref=f22e178]: 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=f22e179]: - term [ref=f22e180]: 기록 - definition [ref=f22e181]: 게시 2026.08.23 · 마지막 검증 2026.08.22 - group [ref=f22e183]: - generic "목차 · 브라우저가 직접 다루는 credential" [ref=f22e184] [cursor=pointer] - article [ref=f22e186]: - region [ref=f22e187]: - heading [level=2] [ref=f22e188]: - link "브라우저가 직접 다루는 credential 바로가기" [ref=f22e189] [cursor=pointer]: - /url: "#브라우저가-직접-다루는-credential" - text: 브라우저가 직접 다루는 credential - generic [ref=f22e190]: "#" - figure [ref=f22e191]: - button "ap1-custody-v3-6e0376d2 이미지 크게 보기" [ref=f22e192]: - img "브라우저 실행 영역 안에 code 교환, access·refresh·ID token 보관, Authorization 헤더 조립 세 상자가 들어 있고 그 영역 전체가 실행 중 XSS가 닿는 범위로 표시된 그림. Keycloak과 Resource Server는 그 밖에 있다." [ref=f22e193] - generic [ref=f22e194]: 크게 보기 - generic [ref=f22e195]: 브라우저 실행 영역 안에 code 교환, access·refresh·ID token 보관, Authorization 헤더 조립 세 상자가 들어 있고 그 영역 전체가 실행 중 XSS가 닿는 범위로 표시된 그림. Keycloak과 Resource Server는 그 밖에 있다. - paragraph [ref=f22e196]: - text: code 교환과 token 보관, API 요청 조립은 모두 SPA에서 처리한다. 테스트에서는 - code [ref=f22e197]: fetch - text: 를 hook했을 때 - code [ref=f22e198]: /api/me - text: 요청에 사용되는 Bearer token을 확인할 수 있었다. - region [ref=f22e199]: - heading [level=2] [ref=f22e200]: - link "새로고침 전후의 브라우저 상태 바로가기" [ref=f22e201] [cursor=pointer]: - /url: "#새로고침-전후의-브라우저-상태" - text: 새로고침 전후의 브라우저 상태 - generic [ref=f22e202]: "#" - paragraph [ref=f22e203]: - text: oidc-client-ts의 - code [ref=f22e204]: InMemoryWebStorage - text: 는 로그인 결과를 브라우저의 영구 저장소가 아니라 실행 중 memory에만 둔다.새로고침하면 JavaScript memory의 - code [ref=f22e205]: User - text: 와 token이 초기화되고, Local Storage와 Session Storage에서는 token 복사본을 확인하지 못했다. - region "표" [ref=f22e206]: - table [ref=f22e207]: - caption [ref=f22e208] - rowgroup [ref=f22e209]: - row [ref=f22e210]: - columnheader "위치" [ref=f22e211] - columnheader "reload 전" [ref=f22e212] - columnheader "reload 후" [ref=f22e213] - rowgroup [ref=f22e214]: - row [ref=f22e215]: - cell "JavaScript memory" [ref=f22e216] - cell [ref=f22e217]: - code [ref=f22e218]: User - text: ", access·refresh·ID token, expiry, profile" - cell "초기화" [ref=f22e219] - row [ref=f22e220]: - cell "Session Storage" [ref=f22e221] - cell "redirect transaction용 state와 verifier" [ref=f22e222] - cell "callback 완료 뒤 제거" [ref=f22e223] - row [ref=f22e224]: - cell "Local Storage" [ref=f22e225] - cell "access token 문자열 없음" [ref=f22e226] - cell "access token 문자열 없음" [ref=f22e227] - paragraph [ref=f22e228]: - text: Keycloak origin의 SSO cookie는 SPA의 - code [ref=f22e229]: InMemoryWebStorage - text: 와 별도로 관리된다. - region [ref=f22e230]: - heading [level=2] [ref=f22e231]: - link "Memory에 보관한 Token을 확인한 지점 바로가기" [ref=f22e232] [cursor=pointer]: - /url: "#memory에-보관한-token을-확인한-지점" - text: Memory에 보관한 Token을 확인한 지점 - generic [ref=f22e233]: "#" - region "표" [ref=f22e234]: - table [ref=f22e235]: - caption [ref=f22e236] - rowgroup [ref=f22e237]: - row [ref=f22e238]: - columnheader "확인 지점" [ref=f22e239] - columnheader "결과" [ref=f22e240] - rowgroup [ref=f22e241]: - row [ref=f22e242]: - cell "Local Storage" [ref=f22e243] - cell "access token 문자열 없음" [ref=f22e244] - row [ref=f22e245]: - cell "Session Storage" [ref=f22e246] - cell "access token 문자열 없음" [ref=f22e247] - row [ref=f22e248]: - cell "token 응답" [ref=f22e249] - cell "access·refresh·ID token 확인" [ref=f22e250] - row [ref=f22e251]: - cell [ref=f22e252]: - code [ref=f22e253]: /api/me - text: 요청 - cell [ref=f22e254]: - code [ref=f22e255]: "Authorization: Bearer" - text: 확인 - row [ref=f22e256]: - cell [ref=f22e257]: - text: reload 후 - code [ref=f22e258]: InMemoryWebStorage - cell [ref=f22e259]: - text: 기존 - code [ref=f22e260]: User - text: 초기화 - figure "HTTP ·브라우저가 Resource Server를 직접 부를 때 코드 복사" [ref=f22e261]: - generic [ref=f22e262]: - generic [ref=f22e263]: HTTP - generic [ref=f22e264]: ·브라우저가 Resource Server를 직접 부를 때 - button "코드 복사" [ref=f22e265] [cursor=pointer]: 복사 - region "브라우저가 Resource Server를 직접 부를 때 코드" [ref=f22e266]: - code [ref=f22e267]: "GET http://localhost:8081/api/me Authorization: Bearer " - paragraph [ref=f22e269]: - code [ref=f22e270]: /api/me - text: 요청에서는 access token이 - code [ref=f22e271]: Authorization - text: 헤더에 들어갔다. - paragraph [ref=f22e272]: "이 구성의 token 설정은 다음과 같다.access token : 300초refresh token rotation, 재사용 허용 : x" - paragraph [ref=f22e273]: rotation과 revocation 동작은 별도 테스트에서 확인했다. - region [ref=f22e274]: - heading [level=2] [ref=f22e275]: - link "Authorization Request에서 확인한 PKCE 설정 바로가기" [ref=f22e276] [cursor=pointer]: - /url: "#authorization-request에서-확인한-pkce-설정" - text: Authorization Request에서 확인한 PKCE 설정 - generic [ref=f22e277]: "#" - paragraph [ref=f22e278]: - text: authorization request에는 - code [ref=f22e279]: code_challenge - text: 와 - code [ref=f22e280]: code_challenge_method=S256 - text: 이 포함됐다. - figure "TEXT ·oidc-client-ts가 만드는 authorization request의 핵심 query 코드 복사" [ref=f22e281]: - generic [ref=f22e282]: - generic [ref=f22e283]: TEXT - generic [ref=f22e284]: ·oidc-client-ts가 만드는 authorization request의 핵심 query - button "코드 복사" [ref=f22e285] [cursor=pointer]: 복사 - region "oidc-client-ts가 만드는 authorization request의 핵심 query 코드" [ref=f22e286]: - code [ref=f22e287]: 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 - paragraph [ref=f22e289]: - text: 현재 테스트는 여기까지 확인한다. token request의 - code [ref=f22e290]: code_verifier - text: 는 캡처하지 않았기 때문에 challenge와 verifier의 대응까지 검증했다고 볼 수는 없다. - region [ref=f22e291]: - heading [level=2] [ref=f22e292]: - link "확인한 것과 확인하지 않은 것 바로가기" [ref=f22e293] [cursor=pointer]: - /url: "#확인한-것과-확인하지-않은-것" - text: 확인한 것과 확인하지 않은 것 - generic [ref=f22e294]: "#" - paragraph [ref=f22e295]: 커밋된 테스트가 확인하도록 정의한 부분이다. - region "표" [ref=f22e296]: - table [ref=f22e297]: - caption [ref=f22e298] - rowgroup [ref=f22e299]: - row [ref=f22e300]: - columnheader "정의 여부" [ref=f22e301] - columnheader "정의 내용" [ref=f22e302] - rowgroup [ref=f22e303]: - row [ref=f22e304]: - cell "o" [ref=f22e305] - cell [ref=f22e306]: - text: authorization request의 - code [ref=f22e307]: response_type=code - text: ", S256 method, 비어 있지 않은 challenge" - row [ref=f22e308]: - cell "o" [ref=f22e309] - cell "token 응답에 비어 있지 않은 access·refresh·ID token" [ref=f22e310] - row [ref=f22e311]: - cell "o" [ref=f22e312] - cell [ref=f22e313]: - code [ref=f22e314]: /api/me - text: 200과 decoded access token의 audience 포함 - row [ref=f22e315]: - cell "o" [ref=f22e316] - cell "브라우저 fetch를 가로채 Authorization 헤더의 Bearer token 관측" [ref=f22e317] - row [ref=f22e318]: - cell "o" [ref=f22e319] - cell "Local Storage와 Session Storage에 access token substring 없음" [ref=f22e320] - row [ref=f22e321]: - cell "o" [ref=f22e322] - cell "refresh rotation — 새 token 발급, 이전 token 거부, revocation 뒤 refresh 실패" [ref=f22e323] - row [ref=f22e324]: - cell "o" [ref=f22e325] - cell "issuer나 audience가 다른 진단용 서버 두 곳의 401" [ref=f22e326] - row [ref=f22e327]: - cell "x" [ref=f22e328] - cell [ref=f22e329]: - text: token request body의 - code [ref=f22e330]: code_verifier - text: · - code [ref=f22e331]: client_id - text: · - code [ref=f22e332]: redirect_uri - text: ·code 값 대조 - row [ref=f22e333]: - cell "x" [ref=f22e334] - cell "서명이 깨진 JWT, 만료된 JWT" [ref=f22e335] - row [ref=f22e336]: - cell "x" [ref=f22e337] - cell "브라우저 간 요청(CORS)의 preflight 응답" [ref=f22e338] - row [ref=f22e339]: - cell "x" [ref=f22e340] - cell "callback에 error가 실려 돌아왔을 때의 화면" [ref=f22e341] - row [ref=f22e342]: - cell "x" [ref=f22e343] - cell [ref=f22e344]: - code [ref=f22e345]: automaticSilentRenew - text: 의 실제 갱신 경로 - region [ref=f22e346]: - heading [level=2] [ref=f22e347]: - link "검증 범위에서 빠진 설정 바로가기" [ref=f22e348] [cursor=pointer]: - /url: "#검증-범위에서-빠진-설정" - text: 검증 범위에서 빠진 설정 - generic [ref=f22e349]: "#" - paragraph [ref=f22e350]: - text: local realm의 redirect URI에는 - code [ref=f22e351]: http://localhost:8088/* - text: 와 - code [ref=f22e352]: http://127.0.0.1:8088/* - text: 가 등록돼 있다.SPA가 실제로 사용하는 callback은 - code [ref=f22e353]: /OAuth2callback.html - text: 이지만, 등록되지 않은 callback을 거부하는 검사는 하지 않았다. - paragraph [ref=f22e354]: - text: frontend Nginx에도 - code [ref=f22e355]: /api/ - text: proxy가 있지만 SPA는 absolute - code [ref=f22e356]: http://localhost:8081/api/me - text: 를 사용한다.브라우저는 8088에서 8081로 cross-origin 요청을 보내므로 이 요청에는 Resource Server의 CORS allowlist가 적용된다. - region [ref=f22e357]: - paragraph [ref=f22e358]: Explicit relations - heading "이 기록의 연결" [level=2] [ref=f22e359] - list [ref=f22e360]: - listitem [ref=f22e361]: - link "브라우저가 authorization endpoint와 token endpoint를 직접 호출한 구성이다. Authorization Code Flow의 Endpoint와 Credential 이동 기준" [ref=f22e362] [cursor=pointer]: - /url: /references/authorization-code-endpoint-credential-movement - generic [ref=f22e363]: 브라우저가 authorization endpoint와 token endpoint를 직접 호출한 구성이다. - strong [ref=f22e364]: Authorization Code Flow의 Endpoint와 Credential 이동 기준 - generic [ref=f22e365]: ↗ - complementary [ref=f22e366]: - heading "작업 상태" [level=2] [ref=f22e367] - status "편집 상태" [ref=f22e368]: 저장됨 - generic [ref=f22e369]: - generic [ref=f22e370]: - term [ref=f22e371]: 저장 버전 - definition [ref=f22e372]: "27" - generic [ref=f22e373]: - term [ref=f22e374]: 종류 - definition [ref=f22e375]: CASE - paragraph [ref=f22e376]: 불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다. - generic [ref=f22e377]: - button "저장" [disabled] [ref=f22e378] - button "게시" [ref=f22e379] - paragraph [ref=f22e380]: 버전 27으로 저장했습니다.