498 lines
40 KiB
YAML
498 lines
40 KiB
YAML
- 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 <access-token> ``` `/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=<opaque-state> code_challenge=<opaque-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 <access-token>"
|
|
- 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=<opaque-state> code_challenge=<opaque-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으로 저장했습니다. |