docs(keycloak): adopt the decomposition contract, fix the redirect URI, strip evaluative prose
- 계약 채택 — 독자 질문, 후보 29건(PROMOTE 24 · MERGE_INTO 4 · KEEP_IN_SSOT 1). 게시 중 17건은 전부 유지. 저장소 keycloak-pattern 은 패턴 넷이 브랜치로 갈라져 있어 revisions 로 tip 넷을 적었다. keycloak-session-store 는 같은 저장소 @ cdac9b8 - 게시된 기록의 redirect_uri 가 SSOT·코드와 달랐다 — OAuth2callback.html → callback.html (frontend/src/app.js 에서 확인). 계약 title 이 기록과 다른 7건도 기록 쪽으로 맞췄다 - 미작성 1건 작성 — 패턴 검증을 실제로 돌릴 때의 안전한 순서(Reference) - 리뷰 100건 반영 — 설명 뒤에 붙은 평가·차례 예고·독자 오해 가정·작성 지시를 지웠다. 삭제가 남긴 조각 4건을 고치고, 원래부터 잘려 있던 로컬 미리보기 라벨 1건도 닫았다 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
62520a4dce
commit
4d50bb939a
+46
-65
@@ -11,124 +11,105 @@ version: 33
|
||||
verifiedOn: 2026-08-30
|
||||
studio: "https://hyeonworks.com/studio/documents/66c18e42-116c-459f-86bd-b7e4bf394866/edit"
|
||||
public: "https://hyeonworks.com/references/oauth-token-application-session-boundary"
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#문제를-어렵게-만든-제약-같은-사용자를-나타내도
|
||||
- final/document.md#문제를-어렵게-만든-제약-브라우저에-없다
|
||||
---
|
||||
|
||||
# OAuth Token과 Application Session을 구분하는 기준
|
||||
|
||||
인증 과정에서 만들어지는 상태를 모두 하나의 `로그인 상태`로 보면 안 된다.
|
||||
IdP의 SSO Session, Access Token, Refresh Token, 애플리케이션의 Session Cookie, 인증 Proxy의 Session Cookie는
|
||||
각각 생성하는 주체와 사용하는 주체가 다르고 유효 시간도 서로 다르다.
|
||||
IdP의 SSO 세션, 액세스 토큰, 리프레시 토큰, 애플리케이션의 세션 쿠키, 인증 프록시의 세션 쿠키는 만드는 쪽과 쓰는 쪽이 각각 다르고 유효 시간도 서로 다르다.
|
||||
|
||||
예를 들어 Access Token이 만료되었다고 해서 애플리케이션 Session이나 IdP의 SSO Session까지 같이 만료된 건 아니다.
|
||||
반대로 애플리케이션 Session을 삭제했다고 해서 IdP의 SSO Session이나 이미 발급된 Token까지 사라지는 것도 아니다.
|
||||
액세스 토큰이 만료되었다고 해서 애플리케이션 세션이나 IdP의 SSO 세션까지 같이 만료된 것은 아니고, 애플리케이션 세션을 삭제했다고 해서 IdP의 SSO 세션이나 이미 발급된 토큰이 사라지는 것도 아니다.
|
||||
|
||||
그래서 어떤 Session이나 Token이 남아 있는지, 무엇이 만료되었는지, Logout할 때 어떤 상태를 삭제하거나 무효화해야 하는지를 각각 구분해서 확인한다.
|
||||
그래서 어떤 세션과 토큰이 아직 살아 있는지, 무엇이 만료되었는지, 로그아웃할 때 어떤 상태를 삭제하거나 무효화해야 하는지를 종류마다 나눠서 확인한다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
|
||||
JavaScript memory의 OAuth token과 Keycloak SSO session을 구분한 Case다.
|
||||
JavaScript 메모리의 OAuth 토큰과 Keycloak SSO 세션을 구분한 사례다.
|
||||
- **Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출**
|
||||
같은 요청 안에서 session cookie와 access token이 함께 움직인다.
|
||||
같은 요청 안에서 세션 쿠키와 액세스 토큰이 함께 움직인다.
|
||||
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
|
||||
BFF에서는 session cookie, JavaScript가 읽는 CSRF token, server-side OAuth token을 각각 다른 용도로 사용한다.
|
||||
BFF에서는 세션 쿠키, JavaScript가 읽는 CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조) 토큰, 서버 쪽 OAuth 토큰을 서로 다른 용도로 쓴다.
|
||||
- **Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유**
|
||||
Forward-Auth에서는 upstream이 JWT를 직접 검증하지 않고 proxy session을 기반으로 edge가 만든 identity header를 사용한다.
|
||||
Forward-Auth에서는 업스트림이 JWT를 직접 검증하지 않고, 프록시 세션을 근거로 엣지가 만든 사용자 정보 헤더를 쓴다.
|
||||
|
||||
## 목적
|
||||
|
||||
SPA, Mediator, BFF에서는 Resource Server가 Access Token을 검증한 뒤 JWT의 Claim에서 사용자 정보를 얻을 수 있다. 반면 Forward-Auth 구조에서는 애플리케이션이 인증 Proxy가 전달한 사용자 정보 Header를 사용한다.
|
||||
최종적으로 같은 사용자 이름이 나오더라도, 한쪽은 Access Token을 검증해서 얻은 값이고 다른 한쪽은 신뢰할 수 있는 Proxy가 전달한 값이다.
|
||||
SPA(Single Page Application, 단일 페이지 애플리케이션), Mediator, BFF(Backend For Frontend) 구성에서는 Resource Server가 액세스 토큰을 검증한 뒤 JWT의 클레임에서 사용자 정보를 얻을 수 있다. Forward-Auth 구성에서는 애플리케이션이 인증 프록시가 전달한 사용자 정보 헤더를 쓴다. 두 경로가 같은 사용자 이름을 내놓더라도 한쪽은 액세스 토큰을 검증해서 얻은 값이고, 다른 한쪽은 신뢰하기로 정한 프록시가 전달한 값이다.
|
||||
|
||||
Logout과 만료 처리에서도 같은 구분이 필요하다. IdP의 SSO Session, Access Token과 Refresh Token, Application Session은 서로 다른 주체가 관리하고 수명도 다르다. 따라서 Logout할 때 무엇을 삭제하거나 무효화할지, 특정 Credential이 만료되었을 때 어떤 상태를 계속 사용할 수 있는지를 각각 구분해서 설계한다.
|
||||
로그아웃과 만료 처리에서도 같은 구분이 필요하다. IdP의 SSO 세션, 액세스 토큰과 리프레시 토큰, 애플리케이션 세션은 서로 다른 주체가 관리하고 수명도 다르다. 그래서 로그아웃할 때 무엇을 삭제하거나 무효화할지, 어떤 자격 증명이 만료되었을 때 어떤 상태를 계속 쓸 수 있는지를 각각 나눠서 설계한다.
|
||||
|
||||
## 규칙
|
||||
|
||||
### 1. 인증 상태를 종류별로 구분해서 기록한다.
|
||||
### 1. 인증 상태를 종류별로 구분해서 기록한다
|
||||
|
||||
IdP SSO Session, OAuth Access Token, OAuth Refresh Token, Application Session Cookie, Proxy Session Cookie는 각각 생성하는 주체와 사용하는 목적이 다른 별개의 상태다.
|
||||
IdP SSO 세션, OAuth 액세스 토큰, OAuth 리프레시 토큰, 애플리케이션 세션 쿠키, 프록시 세션 쿠키는 만드는 주체와 쓰는 목적이 저마다 다른 별개의 상태다.
|
||||
|
||||
로그와 진단 정보에서도 어떤 상태를 확인한 것인지 구체적으로 기록한다.
|
||||
예를 들어 단순히 로그인 상태가 만료되었다고 남기는 대신 Application Session이 만료되었다, Access Token이 만료되었다, Proxy Session이 존재하지 않는다처럼 실제 Session이나 Token의 종류를 명시한다.
|
||||
로그와 진단 정보에도 어떤 상태를 확인한 것인지 구체적으로 적는다. 로그인 상태가 만료되었다고만 남기는 대신 애플리케이션 세션이 만료되었다, 액세스 토큰이 만료되었다, 프록시 세션이 없다처럼 실제 세션이나 토큰의 종류를 밝혀서 적는다.
|
||||
|
||||
이렇게 이름을 구분해야 장애를 분석하거나 Logout과 만료 동작을 확인할 때 어떤 상태가 남아 있고 어떤 상태를 삭제하거나 갱신해야 하는지 정확하게 판단할 수 있다.
|
||||
### 2. 발급하는 쪽과 사용하는 쪽으로 구분한다
|
||||
|
||||
### 2. 만든 주체와 주된 소비자로 구분한다
|
||||
액세스 토큰, 애플리케이션 세션 쿠키, 프록시 세션 쿠키는 각각 발급하는 주체와 사용하는 주체가 다르다.
|
||||
|
||||
Access Token, Application Session Cookie, Proxy Session Cookie는 각각 발급하는 주체와 사용하는 주체가 다르다.
|
||||
액세스 토큰은 IdP가 발급하고 Resource Server가 요청을 처리할 때 검증한다. 애플리케이션 세션 쿠키는 애플리케이션이 발급하고, 이후 브라우저가 보낸 쿠키로 애플리케이션이 자기 로그인 세션을 찾는 데 쓴다. 프록시 세션 쿠키는 인증 프록시가 발급하고, 이후 프록시가 인증 상태를 확인할 때 쓴다.
|
||||
|
||||
Access Token은 IdP가 발급하고 Resource Server가 요청을 처리할 때 검증한다.
|
||||
Application Session Cookie는 애플리케이션이 발급하고, 이후 브라우저가 보낸 Cookie를 이용해 애플리케이션이 자신의 로그인 Session을 찾는 데 사용한다.
|
||||
Proxy Session Cookie는 인증 Proxy가 발급하고, 이후 Proxy가 인증 상태를 확인할 때 사용한다.
|
||||
### 3. 같은 사용자라도 자격 증명마다 가리키는 상태가 다르다
|
||||
|
||||
이처럼 어떤 주체가 Credential을 발급했고, 요청을 처리할 때 어떤 주체가 이를 검증하는지가 다르다면 서로 다른 Credential과 인증 상태로 구분해서 다뤄야 한다.
|
||||
애플리케이션 세션 쿠키는 서버에 저장된 세션을 찾을 세션 ID를 브라우저에 전달하는 데 쓴다. 액세스 토큰과 리프레시 토큰은 쿠키 안에 들어 있지 않고 Authorized Client 같은 별도의 서버 저장소에 보관되므로, 세션 쿠키와 OAuth 토큰 저장소는 갈라서 봐야 한다.
|
||||
|
||||
### 3. 같은 사용자라도 Credential은 서로 다른 상태를 나타낸다
|
||||
|
||||
Application Session Cookie는 서버에 저장된 Session을 찾기 위한 session ID를 브라우저에 전달하는 데 사용한다.
|
||||
실제 Access Token과 Refresh Token은 Cookie 안에 들어 있는 것이 아니라 Authorized Client와 같은 별도의 서버 저장소에 보관된다. 따라서 Session Cookie와 OAuth Token 저장소는 서로 구분해서 봐야 한다.
|
||||
|
||||
Proxy Session Cookie는 반드시 같은 방식으로 동작하는 것은 아니다.
|
||||
별도의 서버 Session Store를 두지 않고, 인증 상태를 확인하는 데 필요한 정보를 Cookie 자체에 담은 뒤 Proxy가 Cookie의 유효성을 검증하는 방식으로 구성할 수도 있다.
|
||||
이 경우 Cookie는 서버에 저장된 Session을 조회하기 위한 session ID와는 역할이 다르다.
|
||||
프록시 세션 쿠키가 반드시 같은 방식으로 동작하지는 않는다. 서버에 세션 저장소를 따로 두지 않고 인증 상태를 확인하는 데 필요한 정보를 쿠키 자체에 담은 뒤, 프록시가 그 쿠키의 유효성을 검증하는 방식으로 구성할 수도 있다. 이때 쿠키는 서버에 저장된 세션을 조회하는 세션 ID와 역할이 다르다.
|
||||
|
||||
### 4. 브라우저에 없는 것을 범위까지 적는다
|
||||
|
||||
브라우저 JavaScript에 OAuth Token을 전달하지 않는 구조에서도 브라우저에 인증과 관련된 상태는 남아 있을 수 있다.
|
||||
예를 들어 BFF 구조에서는 애플리케이션의 HttpOnly Session Cookie가 유지될 수 있고, IdP에서는 자신의 도메인에 SSO Session Cookie를 유지할 수 있다.
|
||||
브라우저 JavaScript에 OAuth 토큰을 넘기지 않는 구조에서도 브라우저에는 인증과 관련된 상태가 있을 수 있다. BFF 구조에서는 애플리케이션의 HttpOnly 세션 쿠키가 유지될 수 있고, IdP는 자기 도메인에 SSO 세션 쿠키를 유지할 수 있다.
|
||||
|
||||
따라서 단순히 브라우저에 인증 정보가 없다거나 브라우저에 Credential이 없다고 표현하면 안 된다.
|
||||
브라우저 JavaScript에 Access Token과 Refresh Token을 노출하지 않는다처럼 무엇이 없고 어느 범위에서 접근할 수 없는지를 적는다.
|
||||
브라우저 JavaScript에 액세스 토큰과 리프레시 토큰을 노출하지 않는다처럼 무엇이 없고 어느 범위에서 접근할 수 없는지를 적는다.
|
||||
|
||||
### 5. 영구 저장과 메모리 보관을 구분한다.
|
||||
### 5. 영구 저장과 메모리 보관을 구분한다
|
||||
|
||||
OAuth Token을 JavaScript Memory에만 보관하면 Local Storage나 Session Storage와 같은 Web Storage에 Token을 지속적으로 저장하지 않을 수 있다.
|
||||
다만 실행 중인 브라우저 JavaScript에서도 Token에 접근할 수 없다는 뜻은 아니다.
|
||||
OAuth 토큰을 JavaScript 메모리에만 두면 Local Storage나 Session Storage 같은 Web Storage에 토큰을 계속 저장하지 않을 수 있다. 다만 실행 중인 브라우저 JavaScript에서 토큰에 접근할 수 없다는 뜻은 아니다.
|
||||
|
||||
애플리케이션이 Token Endpoint의 응답을 JavaScript로 받아 처리한다면, 실행 중에는 Token 값이 JavaScript가 다루는 메모리에 존재한다. 같은 Origin에서 악성 Script가 실행될 수 있는 상황에서는 Token 응답이나 애플리케이션이 Token을 처리하는 경로가 공격 대상이 될 수 있기 때문에, Web Storage에 Token이 저장되지 않을 뿐이고 XSS를 통해 Token에 접근할 여지는 남는다.
|
||||
애플리케이션이 Token Endpoint의 응답을 JavaScript로 받아 처리한다면 실행 중에는 토큰 값이 JavaScript가 다루는 메모리에 있다. 같은 Origin에서 악성 스크립트가 실행될 수 있는 상황이라면 토큰 응답이나 애플리케이션이 토큰을 처리하는 경로가 공격 대상이 될 수 있으므로, Web Storage에 토큰을 저장하지 않는 것만으로는 XSS(Cross-Site Scripting, 사이트 간 스크립팅)로 토큰에 접근하는 것까지 막지 못한다.
|
||||
|
||||
### 6. 로그아웃 범위를 상태별로 적는다
|
||||
|
||||
애플리케이션에서 Logout하는 것과 IdP의 SSO Session을 종료하는 것은 서로 다른 동작이다.
|
||||
애플리케이션 Session이나 Cookie를 삭제하더라도 IdP의 SSO Session은 그대로 남아 있을 수 있으며, 반대로 IdP Session을 종료하더라도 이미 발급된 Access Token의 처리 방식은 별도로 확인해야 한다.
|
||||
애플리케이션에서 로그아웃하는 것과 IdP의 SSO 세션을 끝내는 것은 서로 다른 동작이다. 애플리케이션 세션이나 쿠키를 삭제해도 IdP의 SSO 세션은 살아 있을 수 있고, 반대로 IdP 세션을 끝내도 이미 발급된 액세스 토큰을 어떻게 처리할지는 따로 확인해야 한다.
|
||||
|
||||
특히 Resource Server가 Self-contained JWT Access Token을 매 요청마다 IdP에 확인하지 않고 자체적으로 검증하는 구조에서는 이미 발급된 Token이 Logout과 동시에 자동으로 무효화되지는 않는다.
|
||||
Resource Server는 JWT의 서명과 만료 시간 등 필요한 Claim을 검증하고 Token이 아직 유효하면 요청을 받아들일 수 있다.
|
||||
특히 Resource Server가 Self-contained JWT 액세스 토큰을 요청마다 IdP에 물어보지 않고 스스로 검증하는 구조에서는, 이미 발급된 토큰이 로그아웃과 동시에 자동으로 무효가 되지는 않는다. Resource Server는 JWT의 서명과 만료 시간처럼 필요한 클레임을 검증하고, 토큰이 아직 유효하면 요청을 받아들일 수 있다.
|
||||
|
||||
따라서 Denylist처럼 이미 발급된 Token의 상태를 추가로 확인하는 방법을 사용하지 않는다면, 애플리케이션 Logout만으로 기존 Access Token을 즉시 사용할 수 없게 만들 수는 없다.
|
||||
이런 구조에서는 Access Token의 TTL을 짧게 설정해 Logout 이후에도 기존 Token을 사용할 수 있는 시간을 제한하고,
|
||||
Refresh Token과 Session은 각각의 저장 위치와 관리 주체에 맞게 별도로 종료하거나 제거한다.
|
||||
그래서 이미 발급된 토큰의 상태를 한 번 더 확인하는 차단 목록(Denylist) 같은 장치를 두지 않으면, 애플리케이션 로그아웃만으로 기존 액세스 토큰을 곧바로 못 쓰게 만들 수 없다. 이런 구조에서는 액세스 토큰의 TTL을 짧게 잡아 로그아웃 뒤에 기존 토큰을 쓸 수 있는 시간을 제한하고, 리프레시 토큰과 세션은 각각의 저장 위치와 관리 주체에 맞게 따로 끝내거나 지운다.
|
||||
|
||||
### 7. Logout 대상 credential을 구체적으로 적는다
|
||||
### 7. 로그아웃할 자격 증명을 구체적으로 적는다
|
||||
|
||||
SPA에서 JavaScript Memory에 보관하던 OAuth Token을 제거하더라도 Keycloak의 SSO Session까지 종료되는 것은 아니다. Keycloak의 SSO Session이 아직 유효하다면 이후 새로운 Authorization Request를 보냈을 때 사용자가 다시 아이디와 비밀번호를 입력하지 않고 인증 절차가 진행될 수 있다.
|
||||
SPA에서 JavaScript 메모리에 두었던 OAuth 토큰을 지워도 Keycloak의 SSO 세션까지 끝나지는 않는다. Keycloak의 SSO 세션이 아직 유효하다면 다음에 새 인가 요청(Authorization Request)을 보냈을 때 사용자가 아이디와 비밀번호를 다시 입력하지 않고 인증 절차가 진행될 수 있다. 브라우저를 새로고침해서 메모리에 있던 토큰이 사라진 경우도 로그아웃과 같지 않다.
|
||||
|
||||
따라서 Logout을 단순히 브라우저의 Token이나 Cookie를 삭제하는 동작으로만 정의하면 안 된다.
|
||||
어떤 수준까지 로그아웃할 것인지에 따라 애플리케이션 상태와 IdP의 SSO Session을 각각 어떻게 종료할지 정해야 한다.
|
||||
어느 수준까지 로그아웃할 것인지에 따라 애플리케이션 상태와 IdP의 SSO 세션을 각각 어떻게 끝낼지 정해야 한다.
|
||||
|
||||
Mediator나 BFF처럼 서버에서 Application Session과 Authorized Client를 함께 관리하는 구조에서는 두 상태의 정리 방법도 각각 명시한다.
|
||||
Application Session을 무효화하는 것과 Authorized Client에 저장된 Access Token 및 Refresh Token을 제거하는 것은 서로 다른 처리기 때문에, Logout 시 어떤 상태를 삭제하고 어떤 상태를 유지할지를 별도로 확인한다.
|
||||
Mediator나 BFF처럼 서버가 애플리케이션 세션과 Authorized Client를 함께 관리하는 구조에서는 두 상태를 정리하는 방법도 각각 적는다. 애플리케이션 세션을 무효화하는 것과 Authorized Client에 저장된 액세스 토큰과 리프레시 토큰을 지우는 것은 서로 다른 처리이므로, 로그아웃할 때 어떤 상태를 삭제하고 어떤 상태를 유지할지 따로 확인한다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
- 인증 상태를 표나 문서로 정리할 때
|
||||
- 로그아웃과 만료 동작을 설계할 때
|
||||
- 브라우저가 어떤 credential을 저장하거나 전송하는지 설명할 때
|
||||
- 브라우저가 어떤 자격 증명을 저장하거나 전송하는지 설명할 때
|
||||
- 여러 구조를 비교할 때
|
||||
|
||||
## 예외
|
||||
|
||||
- 하나의 요청 흐름 안에서 어떤 Session이나 Token을 의미하는지가 이미 명확한 경우에는 짧은 이름을 사용할 수 있다.
|
||||
다만 문서에서 처음 등장할 때는 전체 이름을 먼저 적어 어떤 상태를 의미하는지 명확하게 정의한다.
|
||||
이후 같은 문맥에서는 의미가 달라지지 않는 범위에서 Session, Access Token, Refresh Token처럼 줄여서 표현할 수 있다.
|
||||
- IdP를 쓰지 않고 애플리케이션이 자체 로그인만 하는 구조에는 SSO session과 access token, refresh token이 없다.
|
||||
- 하나의 요청 흐름 안에서 어떤 세션이나 토큰을 가리키는지 이미 분명하면 짧은 이름을 쓸 수 있다.
|
||||
다만 문서에 처음 나올 때는 전체 이름을 먼저 적어 어떤 상태를 가리키는지 분명하게 정의하고,
|
||||
이후 같은 문맥에서는 뜻이 달라지지 않는 범위에서 세션, 액세스 토큰, 리프레시 토큰처럼 줄여서 쓸 수 있다.
|
||||
- IdP를 쓰지 않고 애플리케이션이 자체 로그인만 하는 구조에는 SSO 세션과 액세스 토큰, 리프레시 토큰이 없다.
|
||||
|
||||
## 예시
|
||||
|
||||
- IdP SSO session : IdP 도메인의 cookie이고 애플리케이션 memory와 별개다
|
||||
- access token : IdP가 만들고 Resource Server가 서명과 issuer, audience를 검증한다
|
||||
- refresh token : 새 access token을 받는 장기 credential이다
|
||||
- 애플리케이션 session cookie : server-side 로그인 상태를 찾는 credential이다.
|
||||
- proxy session cookie : proxy의 auth endpoint에 제시하는 최소 상태다
|
||||
- CSRF token : cookie가 자동으로 붙는 상태 변경 요청의 의도를 확인한다
|
||||
- identity header : edge가 확인한 사용자 정보이고 JWT token이 아니다
|
||||
- IdP SSO 세션 : IdP 도메인의 쿠키이고 애플리케이션 메모리와 별개다
|
||||
- 액세스 토큰 : IdP가 만들고 Resource Server가 서명과 issuer, audience를 검증한다
|
||||
- 리프레시 토큰 : 새 액세스 토큰을 받는 장기 자격 증명이다
|
||||
- 애플리케이션 세션 쿠키 : 서버에 있는 로그인 상태를 찾는 자격 증명이다
|
||||
- 프록시 세션 쿠키 : 프록시의 인증 엔드포인트에 제시하는 최소 상태다
|
||||
- CSRF 토큰 : 쿠키가 자동으로 붙는 상태 변경 요청의 의도를 확인한다
|
||||
- 사용자 정보 헤더 : 엣지가 확인한 사용자 정보이고 JWT 토큰이 아니다
|
||||
|
||||
Reference in New Issue
Block a user