787 lines
76 KiB
YAML
787 lines
76 KiB
YAML
- generic [ref=f23e3]:
|
|
- link "본문으로 건너뛰기" [ref=f23e4] [cursor=pointer]:
|
|
- /url: "#main-content"
|
|
- banner [ref=f23e5]:
|
|
- generic [ref=f23e6]:
|
|
- link "TechLog Studio" [ref=f23e7] [cursor=pointer]:
|
|
- /url: /studio
|
|
- text: TechLog
|
|
- generic [ref=f23e8]: Studio
|
|
- navigation "Studio 주 탐색" [ref=f23e10]:
|
|
- link "작업본" [ref=f23e11] [cursor=pointer]:
|
|
- /url: /studio/documents
|
|
- link "게시 기록" [ref=f23e12] [cursor=pointer]:
|
|
- /url: /studio/publications
|
|
- link "새 문서" [ref=f23e13] [cursor=pointer]:
|
|
- /url: /studio/documents/new
|
|
- link "주제·프로젝트" [ref=f23e14] [cursor=pointer]:
|
|
- /url: /studio/taxonomy
|
|
- link "릴리즈" [ref=f23e15] [cursor=pointer]:
|
|
- /url: /studio/releases
|
|
- link "공개 사이트 보기" [ref=f23e16] [cursor=pointer]:
|
|
- /url: /
|
|
- button "로그아웃" [ref=f23e17]
|
|
- main [ref=f23e18]:
|
|
- generic [ref=f23e19]:
|
|
- generic [ref=f23e20]:
|
|
- region [ref=f23e21]:
|
|
- generic [ref=f23e22]:
|
|
- paragraph [ref=f23e23]: CASE · VERSION 28
|
|
- heading "문서 편집" [level=1] [ref=f23e24]
|
|
- paragraph [ref=f23e25]: SPA가 Authorization Code를 직접 교환하고 Resource Server를 호출한 구조
|
|
- region [ref=f23e26]:
|
|
- generic [ref=f23e27]:
|
|
- paragraph [ref=f23e28]: DOCUMENT
|
|
- heading "기본 정보" [level=2] [ref=f23e29]
|
|
- generic [ref=f23e30]:
|
|
- generic [ref=f23e31]:
|
|
- generic [ref=f23e32]: 제목
|
|
- textbox "제목" [ref=f23e33]: SPA가 Authorization Code를 직접 교환하고 Resource Server를 호출한 구조
|
|
- generic [ref=f23e34]:
|
|
- generic [ref=f23e35]: slug
|
|
- textbox "slug" [ref=f23e36]:
|
|
- /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)
|
|
- text: spa-browser-credential-boundary
|
|
- generic [ref=f23e37]:
|
|
- generic [ref=f23e38]: 요약
|
|
- textbox "요약" [ref=f23e39]: SPA를 public client spa-public으로 등록해 Authorization Code와 PKCE S256을 브라우저에서 수행했다. access·refresh·ID token은 oidc-client-ts의 InMemoryWebStorage에 두고, access token으로 Resource Server의 /api/me를 직접 호출했다. Resource Server는 session 없이 JWT의 서명과 issuer, 시간, audience를 검증했다.
|
|
- generic [ref=f23e40]:
|
|
- generic [ref=f23e41]: Topic
|
|
- combobox "Topic" [ref=f23e42]:
|
|
- option "선택하지 않음"
|
|
- option "OAuth/OIDC 인증 경계" [selected]
|
|
- generic [ref=f23e43]:
|
|
- generic [ref=f23e44]: Project
|
|
- combobox "Project" [ref=f23e45]:
|
|
- option "미지정"
|
|
- option "Backend Clean Architecture"
|
|
- option "KeyCloak Patterns" [selected]
|
|
- option "Liner N + 1문제"
|
|
- group "관계" [ref=f23e46]:
|
|
- generic [ref=f23e48]:
|
|
- generic [ref=f23e49]:
|
|
- generic [ref=f23e50]: 관계 1 대상
|
|
- combobox "관계 1 대상" [ref=f23e51]:
|
|
- 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에서 OAuth Token을 JavaScript Memory에 보관한 경우"
|
|
- generic [ref=f23e52]:
|
|
- generic [ref=f23e53]: 관계 1 이유
|
|
- textbox "관계 1 이유" [ref=f23e54]: 브라우저가 authorization endpoint와 token endpoint를 직접 호출한 구성이다.
|
|
- generic [ref=f23e55]:
|
|
- button "위로" [disabled] [ref=f23e56]
|
|
- button "아래로" [ref=f23e57]
|
|
- button "삭제" [ref=f23e58]
|
|
- generic [ref=f23e59]:
|
|
- generic [ref=f23e60]:
|
|
- generic [ref=f23e61]: 관계 2 대상
|
|
- combobox "관계 2 대상" [ref=f23e62]:
|
|
- 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에서 OAuth Token을 JavaScript Memory에 보관한 경우"
|
|
- generic [ref=f23e63]:
|
|
- generic [ref=f23e64]: 관계 2 이유
|
|
- textbox "관계 2 이유" [ref=f23e65]: AP1에서 SPA를 public client로 구성한 이유와 연결된다.
|
|
- generic [ref=f23e66]:
|
|
- button "위로" [ref=f23e67]
|
|
- button "아래로" [ref=f23e68]
|
|
- button "삭제" [ref=f23e69]
|
|
- generic [ref=f23e70]:
|
|
- generic [ref=f23e71]:
|
|
- generic [ref=f23e72]: 관계 3 대상
|
|
- combobox "관계 3 대상" [ref=f23e73]:
|
|
- 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에서 OAuth Token을 JavaScript Memory에 보관한 경우"
|
|
- generic [ref=f23e74]:
|
|
- generic [ref=f23e75]: 관계 3 이유
|
|
- textbox "관계 3 이유" [ref=f23e76]: JavaScript memory의 token과 Keycloak 도메인의 SSO cookie를 구분하는 기준이다.
|
|
- generic [ref=f23e77]:
|
|
- button "위로" [ref=f23e78]
|
|
- button "아래로" [disabled] [ref=f23e79]
|
|
- button "삭제" [ref=f23e80]
|
|
- button "관계 추가" [ref=f23e81]
|
|
- region [ref=f23e82]:
|
|
- generic [ref=f23e83]:
|
|
- paragraph [ref=f23e84]: CASE
|
|
- heading "문제와 검증" [level=2] [ref=f23e85]
|
|
- generic [ref=f23e86]:
|
|
- generic [ref=f23e87]:
|
|
- generic [ref=f23e88]: 문제
|
|
- textbox "문제" [ref=f23e89]: 브라우저와 Resource Server 사이에 중계할 server를 두지 않았다. authorization code가 token으로 바뀌고 그 token이 API 요청에 들어가는 과정을 코드와 network에서 그대로 보려는 구성이었다. 이 구성에서는 verifier와 access·refresh token, logout 요청이 모두 JavaScript가 실행되는 곳을 지난다. 브라우저가 실제로 무엇을 들고 있는지, Resource Server가 그 token의 무엇을 확인하는지를 한 요청을 따라가며 확인했다.
|
|
- generic [ref=f23e90]:
|
|
- generic [ref=f23e91]: 결론
|
|
- textbox "결론" [ref=f23e92]: 브라우저가 code 교환과 token 보관, API 호출을 모두 수행했다. Local Storage와 Session Storage에서는 access token 문자열을 확인하지 못했고, 브라우저 fetch를 hook했을 때는 /api/me 요청의 Bearer access token을 관측했다. memory-only는 이 두 관측을 함께 놓고 읽어야 한다. Resource Server는 SessionCreationPolicy.STATELESS로 동작하고 요청마다 JWT를 검증했다. 서명과 issuer, 시간에 더해 keycloak-pattern-api audience를 확인했고, issuer나 audience가 다른 진단용 서버 두 곳은 같은 정상 JWT를 401로 거부했다. realm role은 ROLE_ prefix가 붙은 authority로 바뀌지만 /api/me는 authenticated만 요구하므로 role이 없어도 통과한다. role 차이는 /api/admin에서 403과 200으로 나타난다. 테스트가 확인한 범위는 authorization request의 challenge와 token request의 grant_type까지다. token request body의 code_verifier 대조, 서명이 깨진 JWT, CORS preflight, automaticSilentRenew의 실제 갱신 경로는 확인하지 않았다.
|
|
- generic [ref=f23e93]:
|
|
- generic [ref=f23e94]: 검증 환경
|
|
- textbox "검증 환경" [ref=f23e95]: "Keycloak 26.7.0 realm 설정 public-client, standard flow : o implicit flow, direct grant : x PKCE : S256 요구 access token : 300초 refresh token rotation, 재사용 허용 : x redirect allowlist : http://localhost:8088/*, http://127.0.0.1:8088/* UserManager authority : http://localhost:8080/realms/keycloak-patterns client_id : spa-public redirect_uri : http://localhost:8088/callback.html post_logout_uri : http://localhost:8088/ response_type : code 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 expected issuer : http://localhost:8080/realms/keycloak-patterns JWK URL : http://keycloak:8080/.../certs audience : keycloak-pattern-api HTTPS : x HTTP : o"
|
|
- generic [ref=f23e96]:
|
|
- generic [ref=f23e97]: 재현 조건
|
|
- textbox "재현 조건" [ref=f23e98]: 1. SPA를 열고 로그인후 Keycloak authorization request의 response_type=code, code_challenge_method=S256, 비어 있지 않은 code_challenge를 확인. 2. token request를 intercept해 authorization-code grant인지, 응답에 access·refresh·ID token이 비어 있지 않은지 확인. 3. 브라우저 fetch를 hook해 /api/me 호출의 Authorization header에서 Bearer access token을 확인. 4. Local Storage와 Session Storage에 access token substring이 남지 않는지 확인. 5. /api/me 200과 decoded access token의 audience에 keycloak-pattern-api가 있는지 확인. 6. 같은 정상 JWT를 expected issuer·audience가 다른 diagnostic server 두 곳에 제출해 401을 확인. 7. regular user로 /api/admin을 호출해 403, admin user로 호출해 200을 확인. 8. refresh token으로 새 token을 받고 이전 refresh token이 거부되는지, revocation 뒤 refresh가 실패하는지, 이미 발급된 access JWT가 만료 전까지 200인지 확인.
|
|
- generic [ref=f23e99]:
|
|
- generic [ref=f23e100]: 마지막 검증일
|
|
- textbox "마지막 검증일" [ref=f23e101]: 2026-08-22
|
|
- generic [ref=f23e102]:
|
|
- generic [ref=f23e103]: 본문 Markdown
|
|
- textbox "본문 Markdown" [ref=f23e104]: "## AP1 구성 브라우저가 OAuth를 직접 수행하는 모습을 코드와 network에서 보려고 SPA를 public client로 등록했다. public client는 브라우저처럼 client secret을 숨길 수 없는 애플리케이션이다. `spa-public`에는 Authorization Code와 PKCE S256을 쓰고 implicit flow와 direct access grant는 껐다. :::evidence key=\"ap1-custody-v3-6e0376d2\" alt=\"브라우저 실행 영역 안에 code 교환, access·refresh·ID token 보관, Authorization 헤더 조립 세 상자가 들어 있고 그 영역 전체가 실행 중 XSS가 닿는 범위로 표시된 그림. Keycloak과 Resource Server는 그 밖에 있다.\" caption=\" \" zoom=\"true\" ::: `GET http://localhost:8088/`을 열면 frontend Nginx가 SPA shell을 반환한다. 실제 `callback.html` 파일은 없지만 존재하지 않는 경로를 `index.html`로 fallback하는 설정 때문에 `/callback.html`도 같은 shell을 연다. JavaScript module은 `UserManager`를 만들면서 다음 값을 고정한다. ```text label=\"UserManager 설정\" authority = http://localhost:8080/realms/keycloak-patterns client_id = spa-public redirect_uri = http://localhost:8088/callback.html post_logout_uri = http://localhost:8088/ response_type = code scope = openid profile email userStore = InMemoryWebStorage stateStore = sessionStorage automaticSilentRenew = true ``` `userStore`와 `stateStore`는 목적이 다르다. `userStore`는 로그인 뒤 `User`와 token set을 보관하고, `stateStore`는 redirect를 건너야 하는 authorization transaction을 보관한다. AP1은 `User`를 memory에 두고 `state`와 PKCE verifier는 Session Storage로 Keycloak 왕복을 건넌다. ## Authorization Request 사용자가 `#login`을 누르면 local handler가 token endpoint를 직접 부르지 않고 `userManager.signinRedirect()`를 호출한다. authorization URL은 oidc-client-ts가 만든다. ```http label=\"signinRedirect()가 만드는 authorization request\" GET http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/auth ?client_id=spa-public &redirect_uri=http%3A%2F%2Flocalhost%3A8088%2Fcallback.html &response_type=code &scope=openid%20profile%20email &state=<opaque-state> &code_challenge=<opaque-challenge> &code_challenge_method=S256 ``` 브라우저의 출력은 Keycloak로 향하는 full-page navigation이다. `state`와 challenge 값은 요청마다 달라진다. 커밋된 browser test가 직접 확인하도록 정의한 query는 `response_type=code`, `code_challenge_method=S256`, 비어 있지 않은 `code_challenge`다. AP1에는 `createPkcePair()`라는 수동 helper도 있다. 32 random bytes를 padding 없는 Base64URL verifier로 바꾸고 SHA-256을 적용한 challenge와 `\"S256\"`을 반환한다. 실제 `signinRedirect()`는 이 helper를 호출하지 않는다. helper는 UI의 PKCE demo button에서 길이를 보여 주는 코드이고 로그인은 pinned oidc-client-ts가 수행한다. demo에서 나온 43자 verifier를 실제 library token request의 verifier 길이로 설명할 수 없다. ## Callback과 Token 교환 Keycloak에서 인증이 끝나면 브라우저는 callback을 받는다. ```http label=\"Keycloak이 돌려주는 callback\" GET http://localhost:8088/callback.html ?code=<authorization-code> &state=<opaque-state> ``` SPA는 path가 `/callback.html`이고 query에 `code` 또는 `error`가 있을 때 callback 경로로 판단한다. `finishSigninCallback()`이 `userManager.signinRedirectCallback()`을 호출하고, library가 저장했던 transaction state와 callback state를 대조한다. 성공 경로에서 브라우저가 보내는 token request의 의도는 다음과 같다. ```http label=\"브라우저가 보내는 token request\" POST http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code &client_id=spa-public &code=<authorization-code> &redirect_uri=http://localhost:8088/callback.html &code_verifier=<original-verifier> ``` `spa-public`은 secret이 없는 public client다. Keycloak 등록은 standard flow만 켜고 implicit flow와 direct grant를 끄며 S256을 요구한다. SPA는 `/callback.html`을 쓰지만 local realm의 redirect allowlist는 `http://localhost:8088/*`와 `http://127.0.0.1:8088/*` wildcard다. exact callback만 허용하는 운영 가드나 invalid redirect negative test는 현재 fixture가 입증하지 않는다. token endpoint의 출력에서 현재 browser test가 관측하도록 정의한 것은 비어 있지 않은 `access_token`, `refresh_token`, `id_token`이다. `expires_in` 같은 추가 field의 exact response는 이 기록의 계약으로 고정하지 않는다. 테스트가 확인하는 범위도 나눠서 읽어야 한다. authorization request의 challenge와 token request endpoint, `grant_type=authorization_code`는 직접 본다. token request body에 들어간 `code_verifier`, `client_id`, `redirect_uri`, code 값을 하나씩 비교하지는 않는다. 구현이 의도한 PKCE 순서와 테스트가 실제로 붙잡은 field는 서로 다른 범위다. ## Token 저장 library는 응답을 `User`로 만든다. 애플리케이션이 읽는 논리적 데이터는 다음과 같다. ```text label=\"oidc-client-ts가 만드는 User\" User ├─ profile.sub ├─ profile.preferred_username ├─ access_token ├─ refresh_token ├─ id_token ├─ expires_at └─ expired ``` serialized user는 `InMemoryWebStorage`에 있고 module 변수 `currentUser`도 같은 live user를 가리킨다. callback이 끝나면 SPA는 `history.replaceState(..., \"/\")`로 code와 state query를 주소창에서 지운다. token 원문은 화면에 표시하지 않고 파생 metadata만 렌더한다. ```json label=\"화면에 렌더하는 token boundary metadata\" { \"subject\": \"<keycloak-sub>\", \"username\": \"regular-user\", \"expiresAt\": \"<ISO-8601-instant>\", \"accessTokenHeldBy\": \"browser memory\", \"refreshTokenHeldBy\": \"browser memory\" } ``` callback 처리가 끝나면 브라우저에는 다음 값이 남는다. | 위치 | 남는 데이터 | reload 뒤 | |---|---|---| | JavaScript memory | `User`, access·refresh·ID token, expiry, profile | 초기화 | | Session Storage | redirect transaction용 state와 verifier | callback 완료 뒤 제거되는 것이 계약 | | Local Storage | 애플리케이션이 쓰지 않음 | 해당 없음 | | Keycloak origin cookie | IdP SSO 상태가 존재할 수 있음 | AP1 app memory와 별개 | reload 뒤 애플리케이션 token 상태를 포기했다는 말과 IdP session을 제거했다는 말은 구분해야 한다. Keycloak origin의 SSO cookie는 SPA의 `InMemoryWebStorage`와 별도로 유지된다. ## Resource Server 직접 호출 사용자가 `#call-api`를 누르면 `callProtectedApi()`가 실행된다. `currentUser`가 없거나 `expired`이면 network request를 만들지 않고 local UI error를 출력한다. ```json label=\"로그인 상태가 없을 때의 local UI error\" {\"error\":\"로그인이 필요합니다.\"} ``` 유효한 user라면 애플리케이션 코드가 명시하는 request shape는 다음과 같다. ```http label=\"브라우저가 Resource Server를 직접 부를 때\" GET http://localhost:8081/api/me Authorization: Bearer <access-token> ``` 이 URL은 frontend와 origin이 다르고 `Authorization` header를 쓴다. 브라우저의 direct API call을 성립시키려고 Spring CORS allowlist에는 frontend origin인 `localhost:8088`과 `127.0.0.1:8088`, `GET`·`OPTIONS`, `Authorization`·`Content-Type`만 둔다. 허용 목록 밖의 browser cross-origin 요청은 CORS 검사를 통과하지 못한다. 이것은 browser-origin 경계이지 API의 network-level 접근 통제가 아니다. browser standard상 preflight가 생기는 경로지만 커밋된 E2E는 preflight response를 assert하지 않고 최종 200만 확인한다. 구현에 헷갈리기 쉬운 차이가 하나 있다. frontend Nginx에도 `/api/` proxy가 있지만 SPA는 상대 URL `/api/me`가 아니라 absolute `http://localhost:8081/api/me`를 쓴다. 현재 happy path는 Nginx proxy가 아니라 브라우저가 host에 공개된 Resource Server를 직접 호출한다. 브라우저 fetch를 hook하면 이 요청의 Bearer access token을 관측할 수 있다. 같은 테스트에서 Local Storage와 Session Storage에는 access token substring이 남지 않는 것도 확인한다. 두 결과를 함께 놓아야 persistent storage에는 없지만 실행 중 JavaScript 경계에는 있다는 설계가 검증된다. ## Resource Server의 JWT 검증 Spring 쪽 입력은 raw Bearer string이다. 요청마다 Bearer JWT로 인증하고 application session을 만들지 않으려고 `SecurityConfig.apiSecurity()`는 CORS를 켜고 CSRF를 끄며 `SessionCreationPolicy.STATELESS`를 선택한다. 이미 발급된 self-contained JWT를 logout 순간에 server session처럼 없앨 수는 없고, 짧은 TTL과 validator가 그 자리를 채운다. Spring OAuth2 Resource Server가 header를 추출하고 JWT authentication provider를 거쳐 configured decoder를 호출한다. repository code가 Spring 내부 filter를 직접 만들지 않으므로 DSL이 설치하는 framework integration과 custom bean 경계를 나눠 읽어야 한다. custom code의 변환 순서는 다음과 같다. ```text label=\"raw Bearer JWT가 authenticated principal이 되기까지\" raw Bearer JWT → NimbusJwtDecoder(JWK signature) → default issuer + timestamp validators → AudienceValidator(\"keycloak-pattern-api\") → validated Jwt → KeycloakRealmRoleConverter → authenticated principal + ROLE_* authorities ``` 외부 issuer와 내부 JWK URL도 다르다. expected issuer는 token 안의 browser-visible 값인 `http://localhost:8080/realms/keycloak-patterns`이고, 공개키를 가져오는 JWK URL은 container network의 `http://keycloak:8080/.../certs`다. 같은 realm을 가리키지만 하나는 claim 검증 기준이고 하나는 network access 경로다. `AudienceValidator`는 `jwt.getAudience()`에 `keycloak-pattern-api`가 포함됐는지 확인한다. 누락되면 `invalid_token` 결과를 만든다. `KeycloakRealmRoleConverter`는 `realm_access.roles`의 string에 `ROLE_` prefix를 붙인다. `user-role`은 `ROLE_user-role`이 된다. 이 예제의 `/api/me`는 특정 role을 요구하지 않고 `.authenticated()`만 요구한다. role claim이 없어서 converter 결과가 빈 list여도 JWT가 유효하면 `/api/me`는 통과할 수 있다. `admin-role`의 효과는 `/api/admin`에서 나타난다. regular user는 403, admin user는 200을 기대하는 별도 acceptance contract가 있다. `ApiController.currentUser(Jwt)`는 검증을 마친 JWT에서 값을 꺼내 사용자 JSON을 만든다. ```json label=\"ApiController가 반환하는 사용자 JSON\" { \"subject\": \"<keycloak-user-sub>\", \"username\": \"regular-user\", \"issuer\": \"http://localhost:8080/realms/keycloak-patterns\", \"audience\": [\"<possibly-other-audiences>\", \"keycloak-pattern-api\"] } ``` controller output에는 `subject`, `username`, `issuer`, `audience` 네 field가 있다. subject의 실제 UUID와 audience 배열 전체는 동적이다. 현재 browser E2E는 UI의 `httpStatus: 200`과 decoded access token의 expected audience 포함만 확인한다. `username`은 controller code와 synthetic MockMvc contract에 나타나지만 live AP1 E2E가 직접 assert하지 않는다. 한 요청을 지나면서 같은 로그인 정보가 `token response → oidc-client-ts User → Authorization header → validated Jwt → controller Map → UI wrapper` 순서로 모양을 바꾼다. 이 과정에서 access token 원문은 browser memory에도 있고 network header에도 들어간다. 거부 지점은 입력마다 다르다. | 입력 또는 사건 | 최초 거부 지점 | 관측 가능한 결과 | 보장하지 않는 세부 | |---|---|---|---| | Bearer 없음 | Spring Security | `/api/me` 401 | exact error body | | 잘못된 audience | custom audience validator | 401 | UI용 JSON error 모양 | | 잘못된 issuer | issuer validator | 401 | UI용 JSON error 모양 | | regular user가 `/api/admin` 호출 | authority decision | 403 | 공통 error envelope | | callback query의 `error` | oidc-client-ts callback, app catch | unauthenticated UI와 error message | exact provider error schema | | app memory user 없음 또는 expired | `callProtectedApi()` local guard | network call 없이 login-required JSON | 자동 재로그인 | :::warning SPA는 non-2xx 응답에서도 `response.ok`을 확인하기 전에 `response.json()`을 시도한다. Spring의 401 body가 비어 있거나 JSON이 아니면 의도한 오류 message보다 JSON parse error가 먼저 보일 수 있다. negative E2E는 UI button 경로가 아니라 별도 Node fetch로 status만 확인하므로 이 failure UX는 고정되어 있지 않다. ::: ## Token 수명주기 realm은 access token 수명을 300초로 두고 refresh token rotation과 reuse 0을 쓴다. 커밋된 E2E는 refresh token을 직접 사용해 새 refresh token을 받고 이전 token이 거부되는지 확인하도록 정의한다. revocation 뒤 refresh는 실패해야 하지만, 이미 발급된 self-contained access JWT는 expiry 전까지 API에서 유효할 수 있다. logout은 Keycloak SSO 종료와 app user 제거를 다루고 access JWT 즉시 deny-list와 같은 효과를 보장하지 않는다. `automaticSilentRenew=true`도 구성돼 있지만, 브라우저가 실제 expiry를 기다려 silent renewal을 마치고 새 `User`를 memory에 저장하는 경로는 acceptance test가 아니다. manual refresh helper로 검증하는 것과 app runtime의 automatic renewal은 같은 결과가 아니다. ## 확인한 것과 확인하지 않은 것 커밋된 테스트가 확인하도록 정의한 부분이다. | 정의 여부 | 정의 내용 | |---|---| | o | authorization request의 `response_type=code`, S256 method, 비어 있지 않은 challenge | | o | token request intercept — authorization-code grant, 응답의 access·refresh·ID token 존재 | | o | `/api/me` 200과 decoded access token의 audience에 `keycloak-pattern-api` 포함 | | o | 브라우저 fetch를 hook해 API 호출의 Bearer access token 관측 | | o | Local Storage와 Session Storage에 access token substring 없음 | | o | issuer나 audience가 다른 진단용 Resource Server 두 곳의 401 | | o | regular user의 `/api/admin` 403, admin user 200 | | o | refresh rotation — 새 refresh token 발급, 이전 refresh token 거부, revocation 뒤 refresh 거부 | | o | 이미 발급된 access token이 만료 전까지 200 | | x | token request body의 `code_verifier`·`client_id`·`redirect_uri`·code 값 대조 | | x | 서명이 깨진 JWT, 만료된 JWT 전용 E2E | | x | CORS preflight 응답 | | x | callback에 error가 실려 돌아왔을 때의 화면 | | x | `automaticSilentRenew`의 실제 갱신 경로 | | x | exact SSO cookie flags | | x | 등록되지 않은 redirect를 거부하는 negative test | unit test에서 synthetic JWT를 주입해 controller 200을 확인하는 것은 실제 Nimbus signature와 issuer validation을 통과했다는 증거가 아니다."
|
|
- group [ref=f23e105]:
|
|
- paragraph [ref=f23e106]: EVIDENCE
|
|
- heading "본문에 Asset 삽입" [level=3] [ref=f23e107]
|
|
- paragraph [ref=f23e108]: 목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.
|
|
- generic [ref=f23e109]:
|
|
- generic [ref=f23e110]:
|
|
- generic [ref=f23e111]: 업로드 종류
|
|
- combobox "업로드 종류" [ref=f23e112]:
|
|
- option "이미지" [selected]
|
|
- option "다이어그램"
|
|
- option "첨부파일"
|
|
- button "Asset 업로드" [ref=f23e113]
|
|
- generic [ref=f23e114]:
|
|
- search [ref=f23e115]:
|
|
- generic [ref=f23e116]: Asset 검색
|
|
- generic [ref=f23e117]:
|
|
- searchbox "Asset 검색" [ref=f23e118]
|
|
- button "검색" [ref=f23e119]
|
|
- generic [ref=f23e120]:
|
|
- checkbox "삽입할 때 크게 보기 허용" [checked] [ref=f23e121]
|
|
- generic [ref=f23e122]: 삽입할 때 크게 보기 허용
|
|
- status [ref=f23e123]: 삽입할 수 있는 Asset 8개
|
|
- list [ref=f23e124]:
|
|
- listitem [ref=f23e125]:
|
|
- button "ap4-edge-trust-1cff2399" [ref=f23e126]
|
|
- button "삭제" [ref=f23e127]
|
|
- listitem [ref=f23e128]:
|
|
- button "ap3-csrf-split-501dd1f7" [ref=f23e129]
|
|
- button "삭제" [ref=f23e130]
|
|
- listitem [ref=f23e131]:
|
|
- button "ap3-bff-custody-82fa18bd" [ref=f23e132]
|
|
- button "삭제" [ref=f23e133]
|
|
- listitem [ref=f23e134]:
|
|
- button "ap2-split-custody-779cb791" [ref=f23e135]
|
|
- button "삭제" [ref=f23e136]
|
|
- listitem [ref=f23e137]:
|
|
- button "ap1-custody-v3-6e0376d2" [ref=f23e138]
|
|
- button "삭제" [ref=f23e139]
|
|
- listitem [ref=f23e140]:
|
|
- button "ap1-custody-v2-e110bd98" [ref=f23e141]
|
|
- button "삭제" [ref=f23e142]
|
|
- listitem [ref=f23e143]:
|
|
- button "ap1-credential-custody-f5e0c027" [ref=f23e144]
|
|
- button "삭제" [ref=f23e145]
|
|
- listitem [ref=f23e146]:
|
|
- button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f23e147]
|
|
- button "삭제" [ref=f23e148]
|
|
- region [ref=f23e149]:
|
|
- generic [ref=f23e150]:
|
|
- paragraph [ref=f23e151]: LIVE
|
|
- heading "즉시 미리보기" [level=2] [ref=f23e152]
|
|
- generic [ref=f23e155]:
|
|
- generic [ref=f23e156]:
|
|
- navigation "문서 경로" [ref=f23e157]:
|
|
- link "Case" [ref=f23e158] [cursor=pointer]:
|
|
- /url: /explore/cases
|
|
- generic [ref=f23e159]: /
|
|
- generic [ref=f23e160]: OAuth/OIDC 인증 경계
|
|
- generic [ref=f23e161]: /
|
|
- link "KeyCloak Patterns" [ref=f23e162] [cursor=pointer]:
|
|
- /url: /projects/keycloak-patterns
|
|
- heading "SPA가 Authorization Code를 직접 교환하고 Resource Server를 호출한 구조" [level=1] [ref=f23e163]
|
|
- paragraph [ref=f23e164]: SPA를 public client spa-public으로 등록해 Authorization Code와 PKCE S256을 브라우저에서 수행했다. access·refresh·ID token은 oidc-client-ts의 InMemoryWebStorage에 두고, access token으로 Resource Server의 /api/me를 직접 호출했다. Resource Server는 session 없이 JWT의 서명과 issuer, 시간, audience를 검증했다.
|
|
- region "문제와 결론" [ref=f23e165]:
|
|
- generic [ref=f23e166]:
|
|
- paragraph [ref=f23e167]: 문제
|
|
- paragraph [ref=f23e168]: 브라우저와 Resource Server 사이에 중계할 server를 두지 않았다. authorization code가 token으로 바뀌고 그 token이 API 요청에 들어가는 과정을 코드와 network에서 그대로 보려는 구성이었다.이 구성에서는 verifier와 access·refresh token, logout 요청이 모두 JavaScript가 실행되는 곳을 지난다. 브라우저가 실제로 무엇을 들고 있는지, Resource Server가 그 token의 무엇을 확인하는지를 한 요청을 따라가며 확인했다.
|
|
- generic [ref=f23e169]:
|
|
- paragraph [ref=f23e170]: 결론
|
|
- paragraph [ref=f23e171]: 브라우저가 code 교환과 token 보관, API 호출을 모두 수행했다. Local Storage와 Session Storage에서는 access token 문자열을 확인하지 못했고, 브라우저 fetch를 hook했을 때는 /api/me 요청의 Bearer access token을 관측했다. memory-only는 이 두 관측을 함께 놓고 읽어야 한다.Resource Server는 SessionCreationPolicy.STATELESS로 동작하고 요청마다 JWT를 검증했다. 서명과 issuer, 시간에 더해 keycloak-pattern-api audience를 확인했고, issuer나 audience가 다른 진단용 서버 두 곳은 같은 정상 JWT를 401로 거부했다. realm role은 ROLE_ prefix가 붙은 authority로 바뀌지만 /api/me는 authenticated만 요구하므로 role이 없어도 통과한다. role 차이는 /api/admin에서 403과 200으로 나타난다.테스트가 확인한 범위는 authorization request의 challenge와 token request의 grant_type까지다. token request body의 code_verifier 대조, 서명이 깨진 JWT, CORS preflight, automaticSilentRenew의 실제 갱신 경로는 확인하지 않았다.
|
|
- generic [ref=f23e172]:
|
|
- generic [ref=f23e173]:
|
|
- term [ref=f23e174]: 검증 환경
|
|
- definition [ref=f23e175]: "Keycloak 26.7.0realm 설정public-client, standard flow : o implicit flow, direct grant : xPKCE : S256 요구access token : 300초refresh token rotation, 재사용 허용 : xredirect allowlist : http://localhost:8088/*, http://127.0.0.1:8088/*UserManagerauthority : http://localhost:8080/realms/keycloak-patternsclient_id : spa-publicredirect_uri : http://localhost:8088/callback.htmlpost_logout_uri : http://localhost:8088/response_type : codescope : openid profile emailuserStore : InMemoryWebStoragestateStore : sessionStorageautomaticSilentRenew : trueResource ServerSessionCreationPolicy.STATELESS CSRF x CORS allowlist : localhost:8088, 127.0.0.1:8088, GET·OPTIONS, Authorization·Content-Typeexpected issuer : http://localhost:8080/realms/keycloak-patternsJWK URL : http://keycloak:8080/.../certsaudience : keycloak-pattern-apiHTTPS : x HTTP : o"
|
|
- generic [ref=f23e176]:
|
|
- term [ref=f23e177]: 검증 데이터
|
|
- definition [ref=f23e178]: 1. SPA를 열고 로그인후 Keycloak authorization request의 response_type=code, code_challenge_method=S256, 비어 있지 않은 code_challenge를 확인.2. token request를 intercept해 authorization-code grant인지, 응답에 access·refresh·ID token이 비어 있지 않은지 확인.3. 브라우저 fetch를 hook해 /api/me 호출의 Authorization header에서 Bearer access token을 확인.4. Local Storage와 Session Storage에 access token substring이 남지 않는지 확인.5. /api/me 200과 decoded access token의 audience에 keycloak-pattern-api가 있는지 확인.6. 같은 정상 JWT를 expected issuer·audience가 다른 diagnostic server 두 곳에 제출해 401을 확인.7. regular user로 /api/admin을 호출해 403, admin user로 호출해 200을 확인.8. refresh token으로 새 token을 받고 이전 refresh token이 거부되는지, revocation 뒤 refresh가 실패하는지, 이미 발급된 access JWT가 만료 전까지 200인지 확인.
|
|
- generic [ref=f23e179]:
|
|
- term [ref=f23e180]: 기록
|
|
- definition [ref=f23e181]: 게시 2026.08.23 · 마지막 검증 2026.08.22
|
|
- group [ref=f23e183]:
|
|
- generic "목차 · AP1 구성" [ref=f23e184] [cursor=pointer]
|
|
- article [ref=f23e186]:
|
|
- region [ref=f23e187]:
|
|
- heading [level=2] [ref=f23e188]:
|
|
- link "AP1 구성 바로가기" [ref=f23e189] [cursor=pointer]:
|
|
- /url: "#ap1-구성"
|
|
- text: AP1 구성
|
|
- generic [ref=f23e190]: "#"
|
|
- paragraph [ref=f23e191]:
|
|
- text: 브라우저가 OAuth를 직접 수행하는 모습을 코드와 network에서 보려고 SPA를 public client로 등록했다. public client는 브라우저처럼 client secret을 숨길 수 없는 애플리케이션이다.
|
|
- code [ref=f23e192]: spa-public
|
|
- text: 에는 Authorization Code와 PKCE S256을 쓰고 implicit flow와 direct access grant는 껐다.
|
|
- figure [ref=f23e193]:
|
|
- button "ap1-custody-v3-6e0376d2 이미지 크게 보기" [ref=f23e194]:
|
|
- img "브라우저 실행 영역 안에 code 교환, access·refresh·ID token 보관, Authorization 헤더 조립 세 상자가 들어 있고 그 영역 전체가 실행 중 XSS가 닿는 범위로 표시된 그림. Keycloak과 Resource Server는 그 밖에 있다." [ref=f23e195]
|
|
- generic [ref=f23e196]: 크게 보기
|
|
- generic [ref=f23e197]: 브라우저 실행 영역 안에 code 교환, access·refresh·ID token 보관, Authorization 헤더 조립 세 상자가 들어 있고 그 영역 전체가 실행 중 XSS가 닿는 범위로 표시된 그림. Keycloak과 Resource Server는 그 밖에 있다.
|
|
- paragraph [ref=f23e198]:
|
|
- code [ref=f23e199]: GET http://localhost:8088/
|
|
- text: 을 열면 frontend Nginx가 SPA shell을 반환한다. 실제
|
|
- code [ref=f23e200]: callback.html
|
|
- text: 파일은 없지만 존재하지 않는 경로를
|
|
- code [ref=f23e201]: index.html
|
|
- text: 로 fallback하는 설정 때문에
|
|
- code [ref=f23e202]: /callback.html
|
|
- text: 도 같은 shell을 연다. JavaScript module은
|
|
- code [ref=f23e203]: UserManager
|
|
- text: 를 만들면서 다음 값을 고정한다.
|
|
- figure "TEXT ·UserManager 설정 코드 복사" [ref=f23e204]:
|
|
- generic [ref=f23e205]:
|
|
- generic [ref=f23e206]: TEXT
|
|
- generic [ref=f23e207]: ·UserManager 설정
|
|
- button "코드 복사" [ref=f23e208] [cursor=pointer]: 복사
|
|
- region "UserManager 설정 코드" [ref=f23e209]:
|
|
- code [ref=f23e210]: authority = http://localhost:8080/realms/keycloak-patterns client_id = spa-public redirect_uri = http://localhost:8088/callback.html post_logout_uri = http://localhost:8088/ response_type = code scope = openid profile email userStore = InMemoryWebStorage stateStore = sessionStorage automaticSilentRenew = true
|
|
- paragraph [ref=f23e212]:
|
|
- code [ref=f23e213]: userStore
|
|
- text: 와
|
|
- code [ref=f23e214]: stateStore
|
|
- text: 는 목적이 다르다.
|
|
- code [ref=f23e215]: userStore
|
|
- text: 는 로그인 뒤
|
|
- code [ref=f23e216]: User
|
|
- text: 와 token set을 보관하고,
|
|
- code [ref=f23e217]: stateStore
|
|
- text: 는 redirect를 건너야 하는 authorization transaction을 보관한다. AP1은
|
|
- code [ref=f23e218]: User
|
|
- text: 를 memory에 두고
|
|
- code [ref=f23e219]: state
|
|
- text: 와 PKCE verifier는 Session Storage로 Keycloak 왕복을 건넌다.
|
|
- region [ref=f23e220]:
|
|
- heading [level=2] [ref=f23e221]:
|
|
- link "Authorization Request 바로가기" [ref=f23e222] [cursor=pointer]:
|
|
- /url: "#authorization-request"
|
|
- text: Authorization Request
|
|
- generic [ref=f23e223]: "#"
|
|
- paragraph [ref=f23e224]:
|
|
- text: 사용자가
|
|
- code [ref=f23e225]: "#login"
|
|
- text: 을 누르면 local handler가 token endpoint를 직접 부르지 않고
|
|
- code [ref=f23e226]: userManager.signinRedirect()
|
|
- text: 를 호출한다. authorization URL은 oidc-client-ts가 만든다.
|
|
- figure "HTTP ·signinRedirect()가 만드는 authorization request 코드 복사" [ref=f23e227]:
|
|
- generic [ref=f23e228]:
|
|
- generic [ref=f23e229]: HTTP
|
|
- generic [ref=f23e230]: ·signinRedirect()가 만드는 authorization request
|
|
- button "코드 복사" [ref=f23e231] [cursor=pointer]: 복사
|
|
- region "signinRedirect()가 만드는 authorization request 코드" [ref=f23e232]:
|
|
- code [ref=f23e233]: GET http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/auth ?client_id=spa-public &redirect_uri=http%3A%2F%2Flocalhost%3A8088%2Fcallback.html &response_type=code &scope=openid%20profile%20email &state=<opaque-state> &code_challenge=<opaque-challenge> &code_challenge_method=S256
|
|
- paragraph [ref=f23e235]:
|
|
- text: 브라우저의 출력은 Keycloak로 향하는 full-page navigation이다.
|
|
- code [ref=f23e236]: state
|
|
- text: 와 challenge 값은 요청마다 달라진다. 커밋된 browser test가 직접 확인하도록 정의한 query는
|
|
- code [ref=f23e237]: response_type=code
|
|
- text: ","
|
|
- code [ref=f23e238]: code_challenge_method=S256
|
|
- text: ", 비어 있지 않은"
|
|
- code [ref=f23e239]: code_challenge
|
|
- text: 다.
|
|
- paragraph [ref=f23e240]:
|
|
- text: AP1에는
|
|
- code [ref=f23e241]: createPkcePair()
|
|
- text: 라는 수동 helper도 있다. 32 random bytes를 padding 없는 Base64URL verifier로 바꾸고 SHA-256을 적용한 challenge와
|
|
- code [ref=f23e242]: "\"S256\""
|
|
- text: 을 반환한다. 실제
|
|
- code [ref=f23e243]: signinRedirect()
|
|
- text: 는 이 helper를 호출하지 않는다. helper는 UI의 PKCE demo button에서 길이를 보여 주는 코드이고 로그인은 pinned oidc-client-ts가 수행한다. demo에서 나온 43자 verifier를 실제 library token request의 verifier 길이로 설명할 수 없다.
|
|
- region [ref=f23e244]:
|
|
- heading [level=2] [ref=f23e245]:
|
|
- link "Callback과 Token 교환 바로가기" [ref=f23e246] [cursor=pointer]:
|
|
- /url: "#callback과-token-교환"
|
|
- text: Callback과 Token 교환
|
|
- generic [ref=f23e247]: "#"
|
|
- paragraph [ref=f23e248]: Keycloak에서 인증이 끝나면 브라우저는 callback을 받는다.
|
|
- figure "HTTP ·Keycloak이 돌려주는 callback 코드 복사" [ref=f23e249]:
|
|
- generic [ref=f23e250]:
|
|
- generic [ref=f23e251]: HTTP
|
|
- generic [ref=f23e252]: ·Keycloak이 돌려주는 callback
|
|
- button "코드 복사" [ref=f23e253] [cursor=pointer]: 복사
|
|
- region "Keycloak이 돌려주는 callback 코드" [ref=f23e254]:
|
|
- code [ref=f23e255]: GET http://localhost:8088/callback.html ?code=<authorization-code> &state=<opaque-state>
|
|
- paragraph [ref=f23e257]:
|
|
- text: SPA는 path가
|
|
- code [ref=f23e258]: /callback.html
|
|
- text: 이고 query에
|
|
- code [ref=f23e259]: code
|
|
- text: 또는
|
|
- code [ref=f23e260]: error
|
|
- text: 가 있을 때 callback 경로로 판단한다.
|
|
- code [ref=f23e261]: finishSigninCallback()
|
|
- text: 이
|
|
- code [ref=f23e262]: userManager.signinRedirectCallback()
|
|
- text: 을 호출하고, library가 저장했던 transaction state와 callback state를 대조한다. 성공 경로에서 브라우저가 보내는 token request의 의도는 다음과 같다.
|
|
- figure "HTTP ·브라우저가 보내는 token request 코드 복사" [ref=f23e263]:
|
|
- generic [ref=f23e264]:
|
|
- generic [ref=f23e265]: HTTP
|
|
- generic [ref=f23e266]: ·브라우저가 보내는 token request
|
|
- button "코드 복사" [ref=f23e267] [cursor=pointer]: 복사
|
|
- region "브라우저가 보내는 token request 코드" [ref=f23e268]:
|
|
- code [ref=f23e269]: "POST http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code &client_id=spa-public &code=<authorization-code> &redirect_uri=http://localhost:8088/callback.html &code_verifier=<original-verifier>"
|
|
- paragraph [ref=f23e271]:
|
|
- code [ref=f23e272]: spa-public
|
|
- text: 은 secret이 없는 public client다. Keycloak 등록은 standard flow만 켜고 implicit flow와 direct grant를 끄며 S256을 요구한다. SPA는
|
|
- code [ref=f23e273]: /callback.html
|
|
- text: 을 쓰지만 local realm의 redirect allowlist는
|
|
- code [ref=f23e274]: http://localhost:8088/*
|
|
- text: 와
|
|
- code [ref=f23e275]: http://127.0.0.1:8088/*
|
|
- text: wildcard다. exact callback만 허용하는 운영 가드나 invalid redirect negative test는 현재 fixture가 입증하지 않는다.
|
|
- paragraph [ref=f23e276]:
|
|
- text: token endpoint의 출력에서 현재 browser test가 관측하도록 정의한 것은 비어 있지 않은
|
|
- code [ref=f23e277]: access_token
|
|
- text: ","
|
|
- code [ref=f23e278]: refresh_token
|
|
- text: ","
|
|
- code [ref=f23e279]: id_token
|
|
- text: 이다.
|
|
- code [ref=f23e280]: expires_in
|
|
- text: 같은 추가 field의 exact response는 이 기록의 계약으로 고정하지 않는다.
|
|
- paragraph [ref=f23e281]:
|
|
- text: 테스트가 확인하는 범위도 나눠서 읽어야 한다. authorization request의 challenge와 token request endpoint,
|
|
- code [ref=f23e282]: grant_type=authorization_code
|
|
- text: 는 직접 본다. token request body에 들어간
|
|
- code [ref=f23e283]: code_verifier
|
|
- text: ","
|
|
- code [ref=f23e284]: client_id
|
|
- text: ","
|
|
- code [ref=f23e285]: redirect_uri
|
|
- text: ", code 값을 하나씩 비교하지는 않는다. 구현이 의도한 PKCE 순서와 테스트가 실제로 붙잡은 field는 서로 다른 범위다."
|
|
- region [ref=f23e286]:
|
|
- heading [level=2] [ref=f23e287]:
|
|
- link "Token 저장 바로가기" [ref=f23e288] [cursor=pointer]:
|
|
- /url: "#token-저장"
|
|
- text: Token 저장
|
|
- generic [ref=f23e289]: "#"
|
|
- paragraph [ref=f23e290]:
|
|
- text: library는 응답을
|
|
- code [ref=f23e291]: User
|
|
- text: 로 만든다. 애플리케이션이 읽는 논리적 데이터는 다음과 같다.
|
|
- figure "TEXT ·oidc-client-ts가 만드는 User 코드 복사" [ref=f23e292]:
|
|
- generic [ref=f23e293]:
|
|
- generic [ref=f23e294]: TEXT
|
|
- generic [ref=f23e295]: ·oidc-client-ts가 만드는 User
|
|
- button "코드 복사" [ref=f23e296] [cursor=pointer]: 복사
|
|
- region "oidc-client-ts가 만드는 User 코드" [ref=f23e297]:
|
|
- code [ref=f23e298]: User ├─ profile.sub ├─ profile.preferred_username ├─ access_token ├─ refresh_token ├─ id_token ├─ expires_at └─ expired
|
|
- paragraph [ref=f23e300]:
|
|
- text: serialized user는
|
|
- code [ref=f23e301]: InMemoryWebStorage
|
|
- text: 에 있고 module 변수
|
|
- code [ref=f23e302]: currentUser
|
|
- text: 도 같은 live user를 가리킨다. callback이 끝나면 SPA는
|
|
- code [ref=f23e303]: history.replaceState(..., "/")
|
|
- text: 로 code와 state query를 주소창에서 지운다. token 원문은 화면에 표시하지 않고 파생 metadata만 렌더한다.
|
|
- figure "JSON ·화면에 렌더하는 token boundary metadata 코드 복사" [ref=f23e304]:
|
|
- generic [ref=f23e305]:
|
|
- generic [ref=f23e306]: JSON
|
|
- generic [ref=f23e307]: ·화면에 렌더하는 token boundary metadata
|
|
- button "코드 복사" [ref=f23e308] [cursor=pointer]: 복사
|
|
- region "화면에 렌더하는 token boundary metadata 코드" [ref=f23e309]:
|
|
- code [ref=f23e310]: "{ \"subject\": \"<keycloak-sub>\", \"username\": \"regular-user\", \"expiresAt\": \"<ISO-8601-instant>\", \"accessTokenHeldBy\": \"browser memory\", \"refreshTokenHeldBy\": \"browser memory\" }"
|
|
- paragraph [ref=f23e312]: callback 처리가 끝나면 브라우저에는 다음 값이 남는다.
|
|
- region "표" [ref=f23e313]:
|
|
- table [ref=f23e314]:
|
|
- caption [ref=f23e315]
|
|
- rowgroup [ref=f23e316]:
|
|
- row [ref=f23e317]:
|
|
- columnheader "위치" [ref=f23e318]
|
|
- columnheader "남는 데이터" [ref=f23e319]
|
|
- columnheader "reload 뒤" [ref=f23e320]
|
|
- rowgroup [ref=f23e321]:
|
|
- row [ref=f23e322]:
|
|
- cell "JavaScript memory" [ref=f23e323]
|
|
- cell [ref=f23e324]:
|
|
- code [ref=f23e325]: User
|
|
- text: ", access·refresh·ID token, expiry, profile"
|
|
- cell "초기화" [ref=f23e326]
|
|
- row [ref=f23e327]:
|
|
- cell "Session Storage" [ref=f23e328]
|
|
- cell "redirect transaction용 state와 verifier" [ref=f23e329]
|
|
- cell "callback 완료 뒤 제거되는 것이 계약" [ref=f23e330]
|
|
- row [ref=f23e331]:
|
|
- cell "Local Storage" [ref=f23e332]
|
|
- cell "애플리케이션이 쓰지 않음" [ref=f23e333]
|
|
- cell "해당 없음" [ref=f23e334]
|
|
- row [ref=f23e335]:
|
|
- cell "Keycloak origin cookie" [ref=f23e336]
|
|
- cell "IdP SSO 상태가 존재할 수 있음" [ref=f23e337]
|
|
- cell "AP1 app memory와 별개" [ref=f23e338]
|
|
- paragraph [ref=f23e339]:
|
|
- text: reload 뒤 애플리케이션 token 상태를 포기했다는 말과 IdP session을 제거했다는 말은 구분해야 한다. Keycloak origin의 SSO cookie는 SPA의
|
|
- code [ref=f23e340]: InMemoryWebStorage
|
|
- text: 와 별도로 유지된다.
|
|
- region [ref=f23e341]:
|
|
- heading [level=2] [ref=f23e342]:
|
|
- link "Resource Server 직접 호출 바로가기" [ref=f23e343] [cursor=pointer]:
|
|
- /url: "#resource-server-직접-호출"
|
|
- text: Resource Server 직접 호출
|
|
- generic [ref=f23e344]: "#"
|
|
- paragraph [ref=f23e345]:
|
|
- text: 사용자가
|
|
- code [ref=f23e346]: "#call-api"
|
|
- text: 를 누르면
|
|
- code [ref=f23e347]: callProtectedApi()
|
|
- text: 가 실행된다.
|
|
- code [ref=f23e348]: currentUser
|
|
- text: 가 없거나
|
|
- code [ref=f23e349]: expired
|
|
- text: 이면 network request를 만들지 않고 local UI error를 출력한다.
|
|
- figure "JSON ·로그인 상태가 없을 때의 local UI error 코드 복사" [ref=f23e350]:
|
|
- generic [ref=f23e351]:
|
|
- generic [ref=f23e352]: JSON
|
|
- generic [ref=f23e353]: ·로그인 상태가 없을 때의 local UI error
|
|
- button "코드 복사" [ref=f23e354] [cursor=pointer]: 복사
|
|
- region "로그인 상태가 없을 때의 local UI error 코드" [ref=f23e355]:
|
|
- code [ref=f23e356]: "{\"error\":\"로그인이 필요합니다.\"}"
|
|
- paragraph [ref=f23e358]: 유효한 user라면 애플리케이션 코드가 명시하는 request shape는 다음과 같다.
|
|
- figure "HTTP ·브라우저가 Resource Server를 직접 부를 때 코드 복사" [ref=f23e359]:
|
|
- generic [ref=f23e360]:
|
|
- generic [ref=f23e361]: HTTP
|
|
- generic [ref=f23e362]: ·브라우저가 Resource Server를 직접 부를 때
|
|
- button "코드 복사" [ref=f23e363] [cursor=pointer]: 복사
|
|
- region "브라우저가 Resource Server를 직접 부를 때 코드" [ref=f23e364]:
|
|
- code [ref=f23e365]: "GET http://localhost:8081/api/me Authorization: Bearer <access-token>"
|
|
- paragraph [ref=f23e367]:
|
|
- text: 이 URL은 frontend와 origin이 다르고
|
|
- code [ref=f23e368]: Authorization
|
|
- text: header를 쓴다. 브라우저의 direct API call을 성립시키려고 Spring CORS allowlist에는 frontend origin인
|
|
- code [ref=f23e369]: localhost:8088
|
|
- text: 과
|
|
- code [ref=f23e370]: 127.0.0.1:8088
|
|
- text: ","
|
|
- code [ref=f23e371]: GET
|
|
- text: ·
|
|
- code [ref=f23e372]: OPTIONS
|
|
- text: ","
|
|
- code [ref=f23e373]: Authorization
|
|
- text: ·
|
|
- code [ref=f23e374]: Content-Type
|
|
- text: 만 둔다. 허용 목록 밖의 browser cross-origin 요청은 CORS 검사를 통과하지 못한다. 이것은 browser-origin 경계이지 API의 network-level 접근 통제가 아니다. browser standard상 preflight가 생기는 경로지만 커밋된 E2E는 preflight response를 assert하지 않고 최종 200만 확인한다.
|
|
- paragraph [ref=f23e375]:
|
|
- text: 구현에 헷갈리기 쉬운 차이가 하나 있다. frontend Nginx에도
|
|
- code [ref=f23e376]: /api/
|
|
- text: proxy가 있지만 SPA는 상대 URL
|
|
- code [ref=f23e377]: /api/me
|
|
- text: 가 아니라 absolute
|
|
- code [ref=f23e378]: http://localhost:8081/api/me
|
|
- text: 를 쓴다. 현재 happy path는 Nginx proxy가 아니라 브라우저가 host에 공개된 Resource Server를 직접 호출한다.
|
|
- paragraph [ref=f23e379]: 브라우저 fetch를 hook하면 이 요청의 Bearer access token을 관측할 수 있다. 같은 테스트에서 Local Storage와 Session Storage에는 access token substring이 남지 않는 것도 확인한다. 두 결과를 함께 놓아야 persistent storage에는 없지만 실행 중 JavaScript 경계에는 있다는 설계가 검증된다.
|
|
- region [ref=f23e380]:
|
|
- heading [level=2] [ref=f23e381]:
|
|
- link "Resource Server의 JWT 검증 바로가기" [ref=f23e382] [cursor=pointer]:
|
|
- /url: "#resource-server의-jwt-검증"
|
|
- text: Resource Server의 JWT 검증
|
|
- generic [ref=f23e383]: "#"
|
|
- paragraph [ref=f23e384]:
|
|
- text: Spring 쪽 입력은 raw Bearer string이다. 요청마다 Bearer JWT로 인증하고 application session을 만들지 않으려고
|
|
- code [ref=f23e385]: SecurityConfig.apiSecurity()
|
|
- text: 는 CORS를 켜고 CSRF를 끄며
|
|
- code [ref=f23e386]: SessionCreationPolicy.STATELESS
|
|
- text: 를 선택한다. 이미 발급된 self-contained JWT를 logout 순간에 server session처럼 없앨 수는 없고, 짧은 TTL과 validator가 그 자리를 채운다. Spring OAuth2 Resource Server가 header를 추출하고 JWT authentication provider를 거쳐 configured decoder를 호출한다. repository code가 Spring 내부 filter를 직접 만들지 않으므로 DSL이 설치하는 framework integration과 custom bean 경계를 나눠 읽어야 한다.
|
|
- paragraph [ref=f23e387]: custom code의 변환 순서는 다음과 같다.
|
|
- figure "TEXT ·raw Bearer JWT가 authenticated principal이 되기까지 코드 복사" [ref=f23e388]:
|
|
- generic [ref=f23e389]:
|
|
- generic [ref=f23e390]: TEXT
|
|
- generic [ref=f23e391]: ·raw Bearer JWT가 authenticated principal이 되기까지
|
|
- button "코드 복사" [ref=f23e392] [cursor=pointer]: 복사
|
|
- region "raw Bearer JWT가 authenticated principal이 되기까지 코드" [ref=f23e393]:
|
|
- code [ref=f23e394]: raw Bearer JWT → NimbusJwtDecoder(JWK signature) → default issuer + timestamp validators → AudienceValidator("keycloak-pattern-api") → validated Jwt → KeycloakRealmRoleConverter → authenticated principal + ROLE_* authorities
|
|
- paragraph [ref=f23e396]:
|
|
- text: 외부 issuer와 내부 JWK URL도 다르다. expected issuer는 token 안의 browser-visible 값인
|
|
- code [ref=f23e397]: http://localhost:8080/realms/keycloak-patterns
|
|
- text: 이고, 공개키를 가져오는 JWK URL은 container network의
|
|
- code [ref=f23e398]: http://keycloak:8080/.../certs
|
|
- text: 다. 같은 realm을 가리키지만 하나는 claim 검증 기준이고 하나는 network access 경로다.
|
|
- paragraph [ref=f23e399]:
|
|
- code [ref=f23e400]: AudienceValidator
|
|
- text: 는
|
|
- code [ref=f23e401]: jwt.getAudience()
|
|
- text: 에
|
|
- code [ref=f23e402]: keycloak-pattern-api
|
|
- text: 가 포함됐는지 확인한다. 누락되면
|
|
- code [ref=f23e403]: invalid_token
|
|
- text: 결과를 만든다.
|
|
- code [ref=f23e404]: KeycloakRealmRoleConverter
|
|
- text: 는
|
|
- code [ref=f23e405]: realm_access.roles
|
|
- text: 의 string에
|
|
- code [ref=f23e406]: ROLE_
|
|
- text: prefix를 붙인다.
|
|
- code [ref=f23e407]: user-role
|
|
- text: 은
|
|
- code [ref=f23e408]: ROLE_user-role
|
|
- text: 이 된다.
|
|
- paragraph [ref=f23e409]:
|
|
- text: 이 예제의
|
|
- code [ref=f23e410]: /api/me
|
|
- text: 는 특정 role을 요구하지 않고
|
|
- code [ref=f23e411]: .authenticated()
|
|
- text: 만 요구한다. role claim이 없어서 converter 결과가 빈 list여도 JWT가 유효하면
|
|
- code [ref=f23e412]: /api/me
|
|
- text: 는 통과할 수 있다.
|
|
- code [ref=f23e413]: admin-role
|
|
- text: 의 효과는
|
|
- code [ref=f23e414]: /api/admin
|
|
- text: 에서 나타난다. regular user는 403, admin user는 200을 기대하는 별도 acceptance contract가 있다.
|
|
- paragraph [ref=f23e415]:
|
|
- code [ref=f23e416]: ApiController.currentUser(Jwt)
|
|
- text: 는 검증을 마친 JWT에서 값을 꺼내 사용자 JSON을 만든다.
|
|
- figure "JSON ·ApiController가 반환하는 사용자 JSON 코드 복사" [ref=f23e417]:
|
|
- generic [ref=f23e418]:
|
|
- generic [ref=f23e419]: JSON
|
|
- generic [ref=f23e420]: ·ApiController가 반환하는 사용자 JSON
|
|
- button "코드 복사" [ref=f23e421] [cursor=pointer]: 복사
|
|
- region "ApiController가 반환하는 사용자 JSON 코드" [ref=f23e422]:
|
|
- code [ref=f23e423]: "{ \"subject\": \"<keycloak-user-sub>\", \"username\": \"regular-user\", \"issuer\": \"http://localhost:8080/realms/keycloak-patterns\", \"audience\": [\"<possibly-other-audiences>\", \"keycloak-pattern-api\"] }"
|
|
- paragraph [ref=f23e425]:
|
|
- text: controller output에는
|
|
- code [ref=f23e426]: subject
|
|
- text: ","
|
|
- code [ref=f23e427]: username
|
|
- text: ","
|
|
- code [ref=f23e428]: issuer
|
|
- text: ","
|
|
- code [ref=f23e429]: audience
|
|
- text: 네 field가 있다. subject의 실제 UUID와 audience 배열 전체는 동적이다. 현재 browser E2E는 UI의
|
|
- code [ref=f23e430]: "httpStatus: 200"
|
|
- text: 과 decoded access token의 expected audience 포함만 확인한다.
|
|
- code [ref=f23e431]: username
|
|
- text: 은 controller code와 synthetic MockMvc contract에 나타나지만 live AP1 E2E가 직접 assert하지 않는다.
|
|
- paragraph [ref=f23e432]:
|
|
- text: 한 요청을 지나면서 같은 로그인 정보가
|
|
- code [ref=f23e433]: token response → oidc-client-ts User → Authorization header → validated Jwt → controller Map → UI wrapper
|
|
- text: 순서로 모양을 바꾼다. 이 과정에서 access token 원문은 browser memory에도 있고 network header에도 들어간다.
|
|
- paragraph [ref=f23e434]: 거부 지점은 입력마다 다르다.
|
|
- region "표" [ref=f23e435]:
|
|
- table [ref=f23e436]:
|
|
- caption [ref=f23e437]
|
|
- rowgroup [ref=f23e438]:
|
|
- row [ref=f23e439]:
|
|
- columnheader "입력 또는 사건" [ref=f23e440]
|
|
- columnheader "최초 거부 지점" [ref=f23e441]
|
|
- columnheader "관측 가능한 결과" [ref=f23e442]
|
|
- columnheader "보장하지 않는 세부" [ref=f23e443]
|
|
- rowgroup [ref=f23e444]:
|
|
- row [ref=f23e445]:
|
|
- cell "Bearer 없음" [ref=f23e446]
|
|
- cell "Spring Security" [ref=f23e447]
|
|
- cell [ref=f23e448]:
|
|
- code [ref=f23e449]: /api/me
|
|
- text: "401"
|
|
- cell "exact error body" [ref=f23e450]
|
|
- row [ref=f23e451]:
|
|
- cell "잘못된 audience" [ref=f23e452]
|
|
- cell "custom audience validator" [ref=f23e453]
|
|
- cell "401" [ref=f23e454]
|
|
- cell "UI용 JSON error 모양" [ref=f23e455]
|
|
- row [ref=f23e456]:
|
|
- cell "잘못된 issuer" [ref=f23e457]
|
|
- cell "issuer validator" [ref=f23e458]
|
|
- cell "401" [ref=f23e459]
|
|
- cell "UI용 JSON error 모양" [ref=f23e460]
|
|
- row [ref=f23e461]:
|
|
- cell [ref=f23e462]:
|
|
- text: regular user가
|
|
- code [ref=f23e463]: /api/admin
|
|
- text: 호출
|
|
- cell "authority decision" [ref=f23e464]
|
|
- cell "403" [ref=f23e465]
|
|
- cell "공통 error envelope" [ref=f23e466]
|
|
- row [ref=f23e467]:
|
|
- cell [ref=f23e468]:
|
|
- text: callback query의
|
|
- code [ref=f23e469]: error
|
|
- cell "oidc-client-ts callback, app catch" [ref=f23e470]
|
|
- cell "unauthenticated UI와 error message" [ref=f23e471]
|
|
- cell "exact provider error schema" [ref=f23e472]
|
|
- row [ref=f23e473]:
|
|
- cell "app memory user 없음 또는 expired" [ref=f23e474]
|
|
- cell [ref=f23e475]:
|
|
- code [ref=f23e476]: callProtectedApi()
|
|
- text: local guard
|
|
- cell "network call 없이 login-required JSON" [ref=f23e477]
|
|
- cell "자동 재로그인" [ref=f23e478]
|
|
- complementary "주의" [ref=f23e479]:
|
|
- paragraph [ref=f23e480]: 주의
|
|
- paragraph [ref=f23e481]:
|
|
- text: SPA는 non-2xx 응답에서도
|
|
- code [ref=f23e482]: response.ok
|
|
- text: 을 확인하기 전에
|
|
- code [ref=f23e483]: response.json()
|
|
- text: 을 시도한다. Spring의 401 body가 비어 있거나 JSON이 아니면 의도한 오류 message보다 JSON parse error가 먼저 보일 수 있다. negative E2E는 UI button 경로가 아니라 별도 Node fetch로 status만 확인하므로 이 failure UX는 고정되어 있지 않다.
|
|
- region [ref=f23e484]:
|
|
- heading [level=2] [ref=f23e485]:
|
|
- link "Token 수명주기 바로가기" [ref=f23e486] [cursor=pointer]:
|
|
- /url: "#token-수명주기"
|
|
- text: Token 수명주기
|
|
- generic [ref=f23e487]: "#"
|
|
- paragraph [ref=f23e488]: realm은 access token 수명을 300초로 두고 refresh token rotation과 reuse 0을 쓴다. 커밋된 E2E는 refresh token을 직접 사용해 새 refresh token을 받고 이전 token이 거부되는지 확인하도록 정의한다. revocation 뒤 refresh는 실패해야 하지만, 이미 발급된 self-contained access JWT는 expiry 전까지 API에서 유효할 수 있다. logout은 Keycloak SSO 종료와 app user 제거를 다루고 access JWT 즉시 deny-list와 같은 효과를 보장하지 않는다.
|
|
- paragraph [ref=f23e489]:
|
|
- code [ref=f23e490]: automaticSilentRenew=true
|
|
- text: 도 구성돼 있지만, 브라우저가 실제 expiry를 기다려 silent renewal을 마치고 새
|
|
- code [ref=f23e491]: User
|
|
- text: 를 memory에 저장하는 경로는 acceptance test가 아니다. manual refresh helper로 검증하는 것과 app runtime의 automatic renewal은 같은 결과가 아니다.
|
|
- region [ref=f23e492]:
|
|
- heading [level=2] [ref=f23e493]:
|
|
- link "확인한 것과 확인하지 않은 것 바로가기" [ref=f23e494] [cursor=pointer]:
|
|
- /url: "#확인한-것과-확인하지-않은-것"
|
|
- text: 확인한 것과 확인하지 않은 것
|
|
- generic [ref=f23e495]: "#"
|
|
- paragraph [ref=f23e496]: 커밋된 테스트가 확인하도록 정의한 부분이다.
|
|
- region "표" [ref=f23e497]:
|
|
- table [ref=f23e498]:
|
|
- caption [ref=f23e499]
|
|
- rowgroup [ref=f23e500]:
|
|
- row [ref=f23e501]:
|
|
- columnheader "정의 여부" [ref=f23e502]
|
|
- columnheader "정의 내용" [ref=f23e503]
|
|
- rowgroup [ref=f23e504]:
|
|
- row [ref=f23e505]:
|
|
- cell "o" [ref=f23e506]
|
|
- cell [ref=f23e507]:
|
|
- text: authorization request의
|
|
- code [ref=f23e508]: response_type=code
|
|
- text: ", S256 method, 비어 있지 않은 challenge"
|
|
- row [ref=f23e509]:
|
|
- cell "o" [ref=f23e510]
|
|
- cell "token request intercept — authorization-code grant, 응답의 access·refresh·ID token 존재" [ref=f23e511]
|
|
- row [ref=f23e512]:
|
|
- cell "o" [ref=f23e513]
|
|
- cell [ref=f23e514]:
|
|
- code [ref=f23e515]: /api/me
|
|
- text: 200과 decoded access token의 audience에
|
|
- code [ref=f23e516]: keycloak-pattern-api
|
|
- text: 포함
|
|
- row [ref=f23e517]:
|
|
- cell "o" [ref=f23e518]
|
|
- cell "브라우저 fetch를 hook해 API 호출의 Bearer access token 관측" [ref=f23e519]
|
|
- row [ref=f23e520]:
|
|
- cell "o" [ref=f23e521]
|
|
- cell "Local Storage와 Session Storage에 access token substring 없음" [ref=f23e522]
|
|
- row [ref=f23e523]:
|
|
- cell "o" [ref=f23e524]
|
|
- cell "issuer나 audience가 다른 진단용 Resource Server 두 곳의 401" [ref=f23e525]
|
|
- row [ref=f23e526]:
|
|
- cell "o" [ref=f23e527]
|
|
- cell [ref=f23e528]:
|
|
- text: regular user의
|
|
- code [ref=f23e529]: /api/admin
|
|
- text: 403, admin user 200
|
|
- row [ref=f23e530]:
|
|
- cell "o" [ref=f23e531]
|
|
- cell "refresh rotation — 새 refresh token 발급, 이전 refresh token 거부, revocation 뒤 refresh 거부" [ref=f23e532]
|
|
- row [ref=f23e533]:
|
|
- cell "o" [ref=f23e534]
|
|
- cell "이미 발급된 access token이 만료 전까지 200" [ref=f23e535]
|
|
- row [ref=f23e536]:
|
|
- cell "x" [ref=f23e537]
|
|
- cell [ref=f23e538]:
|
|
- text: token request body의
|
|
- code [ref=f23e539]: code_verifier
|
|
- text: ·
|
|
- code [ref=f23e540]: client_id
|
|
- text: ·
|
|
- code [ref=f23e541]: redirect_uri
|
|
- text: ·code 값 대조
|
|
- row [ref=f23e542]:
|
|
- cell "x" [ref=f23e543]
|
|
- cell "서명이 깨진 JWT, 만료된 JWT 전용 E2E" [ref=f23e544]
|
|
- row [ref=f23e545]:
|
|
- cell "x" [ref=f23e546]
|
|
- cell "CORS preflight 응답" [ref=f23e547]
|
|
- row [ref=f23e548]:
|
|
- cell "x" [ref=f23e549]
|
|
- cell "callback에 error가 실려 돌아왔을 때의 화면" [ref=f23e550]
|
|
- row [ref=f23e551]:
|
|
- cell "x" [ref=f23e552]
|
|
- cell [ref=f23e553]:
|
|
- code [ref=f23e554]: automaticSilentRenew
|
|
- text: 의 실제 갱신 경로
|
|
- row [ref=f23e555]:
|
|
- cell "x" [ref=f23e556]
|
|
- cell "exact SSO cookie flags" [ref=f23e557]
|
|
- row [ref=f23e558]:
|
|
- cell "x" [ref=f23e559]
|
|
- cell "등록되지 않은 redirect를 거부하는 negative test" [ref=f23e560]
|
|
- paragraph [ref=f23e561]: unit test에서 synthetic JWT를 주입해 controller 200을 확인하는 것은 실제 Nimbus signature와 issuer validation을 통과했다는 증거가 아니다.
|
|
- region [ref=f23e562]:
|
|
- paragraph [ref=f23e563]: Explicit relations
|
|
- heading "이 기록의 연결" [level=2] [ref=f23e564]
|
|
- list [ref=f23e565]:
|
|
- listitem [ref=f23e566]:
|
|
- link "브라우저가 authorization endpoint와 token endpoint를 직접 호출한 구성이다. Authorization Code Flow의 Endpoint와 Credential 이동 기준" [ref=f23e567] [cursor=pointer]:
|
|
- /url: /references/authorization-code-endpoint-credential-movement
|
|
- generic [ref=f23e568]: 브라우저가 authorization endpoint와 token endpoint를 직접 호출한 구성이다.
|
|
- strong [ref=f23e569]: Authorization Code Flow의 Endpoint와 Credential 이동 기준
|
|
- generic [ref=f23e570]: ↗
|
|
- complementary [ref=f23e571]:
|
|
- heading "작업 상태" [level=2] [ref=f23e572]
|
|
- status "편집 상태" [ref=f23e573]: 저장됨
|
|
- generic [ref=f23e574]:
|
|
- generic [ref=f23e575]:
|
|
- term [ref=f23e576]: 저장 버전
|
|
- definition [ref=f23e577]: "28"
|
|
- generic [ref=f23e578]:
|
|
- term [ref=f23e579]: 종류
|
|
- definition [ref=f23e580]: CASE
|
|
- paragraph [ref=f23e581]: 불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.
|
|
- generic [ref=f23e582]:
|
|
- button "저장" [disabled] [ref=f23e583]
|
|
- button "게시" [ref=f23e584]
|
|
- paragraph [ref=f23e585]: 버전 28으로 저장했습니다. |