The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.
Follows the import procedure in README.md.
source/ the originating repository verbatim — 78 documents, 28 SVGs,
8 manifests, plus .source-revision recording the commit
final/ the SSOT
document.md 729 lines written from the 29 experiment documents, not
concatenated: what was predicted, what was measured, and
where the measurement itself was wrong
evidence/raw 125 outputs, flattened to <experiment>__<file> because
the originals collided (01-baseline.txt appeared three
times) and the audit only globs the top level
evidence/meta one per raw file; command and exitCode are null and the
README says why rather than inventing them
evidence/browser 22 captures
assets/ three diagrams through techviz
.techviz/ their VizSpecs
A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.
Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.
verify-pipeline.py passes. audit-records.py reports no issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
224 lines
12 KiB
Markdown
224 lines
12 KiB
Markdown
---
|
|
id: bf675775-4f3e-4744-8014-f0efff51422a
|
|
kind: CASE
|
|
slug: spa-browser-credential-boundary
|
|
title: SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우
|
|
topic: oauth-oidc-auth-boundary
|
|
topicName: OAuth/OIDC 인증 경계
|
|
project: KeyCloak Patterns
|
|
status: 게시 중
|
|
version: 31
|
|
verifiedOn: 2026-08-22
|
|
studio: "https://hyeonworks.com/studio/documents/bf675775-4f3e-4744-8014-f0efff51422a/edit"
|
|
public: "https://hyeonworks.com/cases/spa-browser-credential-boundary"
|
|
assets:
|
|
- key: ap1-custody-v3-6e0376d2
|
|
file: ../../../final/assets/tech-log-studio/ap1-credential-custody.svg
|
|
---
|
|
|
|
# SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우
|
|
|
|
AP1에서는 SPA를 public OAuth client로 구성하고 authorization code도 브라우저에서 직접 교환한다. 발급받은 access token, refresh token, ID token은 Web Storage에 저장하지 않고 JavaScript memory에만 둔다.
|
|
|
|
이렇게 하면 새로고침 뒤에는 token이 남지 않는다. 하지만 페이지가 열려 있는 동안에는 JavaScript에서 token을 사용하고 있고, Resource Server를 호출할 때도 access token을 `Authorization` 헤더에 넣는다. 실행 중 XSS가 발생했을 때 영향을 받는 부분은 그대로 남아 있다.
|
|
|
|
## 관계
|
|
|
|
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
|
|
SPA에서 authorization request를 보내고 authorization code를 받은 뒤 token을 교환하는 과정을 직접 확인한 내용이다.
|
|
- **Public Client와 Confidential Client 구분 기준**
|
|
SPA는 client secret을 안전하게 숨길 수 없기 때문에 public client로 구성했고 PKCE S256을 사용했다.
|
|
- **OAuth Token과 Application Session을 구분하는 기준**
|
|
JavaScript memory에 있는 token과 Keycloak의 SSO cookie는 서로 다른 상태다. 새로고침 뒤 SPA의 token이 없어져도 Keycloak의 SSO 상태는 남아 있을 수 있다.
|
|
|
|
## 문제
|
|
|
|
AP1에서는 token을 Local Storage나 Session Storage에 저장하지 않고 JavaScript memory에만 둔다.
|
|
|
|
확인하고 싶었던 부분은 token을 Web Storage에 저장하지 않는 것만으로 실행 중 XSS까지 막을 수 있는지였다.
|
|
|
|
SPA가 authorization code를 직접 교환한 뒤 token을 어디에 가지고 있는지, API를 호출할 때 access token이 어디를 지나는지 확인했다. PKCE도 실제로 어느 구간에 적용되는지 같이 봤다.
|
|
|
|
## 결론
|
|
|
|
memory-only로 보관하면 새로고침 뒤에는 access token, refresh token, ID token이 남지 않는다.
|
|
|
|
하지만 페이지가 실행 중일 때는 JavaScript에서 token을 사용한다. 악성 script가 같은 페이지에서 실행되면 fetch를 가로채거나 사용자를 대신해서 API를 호출할 수 있다. access token도 JavaScript memory에만 있는 것이 아니라 Resource Server 요청의 Authorization 헤더에 들어간다.
|
|
|
|
Resource Server는 SessionCreationPolicy.STATELESS로 동작한다. 서버에서 삭제할 application session이 없고, 이미 발급된 self-contained JWT를 logout과 동시에 없애는 처리도 없다.
|
|
|
|
현재 구성에서는 access token 수명을 300초로 두고 refresh token rotation을 사용한다. Resource Server에서는 issuer와 audience도 확인한다.
|
|
|
|
PKCE는 authorization code를 token으로 교환하는 구간에 사용한다. 이미 발급된 access token을 브라우저에서 숨겨주는 기능은 아니다.
|
|
|
|
## 검증 환경
|
|
|
|
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
|
|
|
|
## 재현 조건
|
|
|
|
1. SPA를 열고 로그인한 뒤 Keycloak authorization request에서 response_type=code, code_challenge_method=S256, 비어 있지 않은 code_challenge를 확인한다.
|
|
|
|
2. token 응답의 access token, refresh token, ID token이 비어 있지 않은지 확인한다.
|
|
|
|
3. 브라우저 fetch를 hook하고 /api/me 요청의 Authorization 헤더에서 Bearer access token을 확인한다.
|
|
|
|
4. Local Storage와 Session Storage에 access token substring이 남지 않는지 확인한다.
|
|
|
|
5. 같은 정상 JWT를 expected issuer와 audience가 다른 diagnostic server 두 곳에 보내고 401이 반환되는지 확인한다.
|
|
|
|
6. refresh token으로 새 token을 받은 뒤 이전 refresh token이 거부되는지 확인한다. revocation 뒤에는 refresh가 실패하는지, 이미 발급된 access JWT는 만료 전까지 200을 받는지도 확인한다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
## SPA에서 Token을 처리하는 위치
|
|
|
|
:::evidence key="ap1-custody-v3-6e0376d2" alt="브라우저 실행 영역 안에 code 교환, access·refresh·ID token 보관, Authorization 헤더 조립 세 상자가 들어 있고 그 영역 전체가 실행 중 XSS가 닿는 범위로 표시된 그림. Keycloak과 Resource Server는 그 밖에 있다." caption=" " zoom="true"
|
|
:::
|
|
|
|
authorization code 교환, token 보관, `Authorization` 헤더 생성까지 모두 브라우저에서 처리한다.
|
|
|
|
access token, refresh token, ID token도 JavaScript memory에 있고 Resource Server를 호출할 때 사용할 `Authorization` 헤더도 같은 페이지에서 만든다.
|
|
|
|
그래서 이 페이지에서 악성 script가 실행되면 JavaScript가 token을 사용하는 부분에도 접근할 수 있다.
|
|
|
|
## 새로고침 전후에 브라우저에 남는 값
|
|
|
|
`oidc-client-ts`의 `InMemoryWebStorage`를 사용해서 로그인 결과를 Local Storage나 Session Storage에 저장하지 않고 실행 중 memory에만 둔다.
|
|
|
|
새로고침하면 memory에 있던 로그인 정보와 token은 사라진다.
|
|
|
|
| 위치 | reload 전 | reload 후 |
|
|
|---|---|---|
|
|
| JavaScript memory | `User`, access·refresh·ID token, expiry, profile | 사라짐 |
|
|
| Session Storage | redirect transaction용 state와 verifier | callback 완료 뒤 제거 |
|
|
| Local Storage | 해당 없음 | 해당 없음 |
|
|
| Keycloak origin cookie | IdP의 SSO 상태가 존재할 수 있음 | application과 별개 |
|
|
|
|
JavaScript memory에 있던 `User`가 사라지는 것과 Keycloak의 SSO session이 끝나는 것은 별개다.
|
|
|
|
SPA에서 가지고 있던 token이 사라져도 Keycloak SSO cookie가 남아 있으면 이후 authorization request에서 기존 로그인 상태가 다시 사용될 수 있다.
|
|
|
|
## Memory-only로 막을 수 있는 범위
|
|
|
|
memory-only로 바꾼 뒤 어떤 값이 없어지고 어떤 부분은 그대로 남는지 확인했다.
|
|
|
|
| 위협 | memory-only가 막아주나 |
|
|
|---|---|
|
|
| 새로고침 뒤에도 남는 token 복사본 | 막아준다 |
|
|
| 실행 중 script가 fetch를 가로채기 | 막아주지 않는다 |
|
|
| 실행 중 script가 사용자 대신 API 호출 | 막아주지 않는다 |
|
|
| network 요청 헤더에 실린 access token | 막아주지 않는다 |
|
|
| 이미 발급된 access JWT의 만료 전 유효성 | 막아주지 않는다 |
|
|
|
|
Resource Server를 호출할 때 SPA에서 access token을 `Authorization` 헤더에 넣는다.
|
|
|
|
```http label="브라우저가 Resource Server를 직접 부를 때"
|
|
GET http://localhost:8081/api/me
|
|
Authorization: Bearer <access-token>
|
|
```
|
|
|
|
그래서 access token은 JavaScript memory에만 존재하는 값은 아니다. API를 호출하는 동안에는 network 요청의 `Authorization` 헤더에도 들어간다.
|
|
|
|
Resource Server는 `SessionCreationPolicy.STATELESS`로 설정되어 있어서 서버에서 삭제할 application session이 없다.
|
|
|
|
이미 발급된 self-contained JWT를 logout 시점에 바로 무효화하는 처리도 넣지 않았다. logout에서는 Keycloak SSO 종료와 SPA의 user 제거를 처리하고, 발급된 access JWT를 deny-list로 따로 관리하지 않는다.
|
|
|
|
현재 access token 수명은 300초다.
|
|
|
|
access token : 300초
|
|
refresh token rotation, 재사용 허용 : x
|
|
issuer·audience : 검증
|
|
|
|
Local Storage나 Session Storage에 token을 저장하면 새로고침 이후에도 값을 다시 읽을 수 있지만, 브라우저 저장소에도 token이 남게 된다.
|
|
|
|
HttpOnly cookie를 사용하려면 현재 SPA처럼 브라우저에서 access token을 꺼내 Resource Server로 직접 보내는 방식과는 달라진다. server가 session이나 token 전달을 맡는 구조가 필요하다.
|
|
|
|
## PKCE가 적용되는 구간
|
|
|
|
PKCE(Proof Key for Code Exchange)를 사용할 때 authorization request에는 `code_challenge`가 들어가고, authorization code를 token으로 교환할 때는 원본인 `code_verifier`를 함께 보낸다. 두 값이 맞아야 code를 교환할 수 있다.
|
|
|
|
```text label="oidc-client-ts가 만드는 authorization request의 핵심 query"
|
|
response_type=code
|
|
client_id=spa-public
|
|
redirect_uri=http://localhost:8088/OAuth2callback.html
|
|
scope=openid profile email
|
|
state=<opaque-state>
|
|
code_challenge=<opaque-challenge>
|
|
code_challenge_method=S256
|
|
```
|
|
|
|
이번 설정에서는 `response_type=code`를 사용하고 `code_challenge_method=S256`과 비어 있지 않은 `code_challenge`가 authorization request에 들어가는 것을 확인했다.
|
|
|
|
PKCE가 적용되는 곳은 authorization code를 token으로 교환하는 구간이다. token이 발급된 이후 access token을 브라우저에서 사용하지 못하게 하는 기능은 아니다.
|
|
|
|
## 테스트에서 확인한 범위
|
|
|
|
커밋된 테스트에서 확인하도록 만들어 둔 항목은 다음과 같다.
|
|
|
|
| 정의 여부 | 정의 내용 |
|
|
|---|---|
|
|
| o | authorization request의 `response_type=code`, S256 method, 비어 있지 않은 challenge |
|
|
| o | token 응답에 비어 있지 않은 access·refresh·ID token |
|
|
| o | `/api/me` 200과 decoded access token의 audience 포함 |
|
|
| o | 브라우저 fetch를 가로채 Authorization 헤더의 Bearer token 관측 |
|
|
| o | Local Storage와 Session Storage에 access token substring 없음 |
|
|
| o | refresh rotation — 새 token 발급, 이전 token 거부, revocation 뒤 refresh 실패 |
|
|
| o | issuer나 audience가 다른 진단용 서버 두 곳의 401 |
|
|
| x | token request body의 `code_verifier`·`client_id`·`redirect_uri`·code 값 대조 |
|
|
| x | 서명이 깨진 JWT, 만료된 JWT |
|
|
| x | 브라우저 간 요청(CORS)의 preflight 응답 |
|
|
| x | callback에 error가 실려 돌아왔을 때의 화면 |
|
|
| x | `automaticSilentRenew`의 실제 갱신 경로 |
|
|
|
|
authorization request에서는 `response_type=code`, S256 method, 비어 있지 않은 challenge까지 확인했다.
|
|
|
|
하지만 token request body에서 실제 `code_verifier`, `client_id`, `redirect_uri`, code 값이 어떻게 전달됐고 서로 대조됐는지는 아직 확인하지 않았다.
|
|
|
|
:::warning
|
|
|
|
SPA는 non-2xx 응답에서도 `response.ok`을 확인하기 전에 `response.json()`을 시도한다. 401 body가 비어 있거나 JSON이 아니면 의도한 메시지 대신 JSON parse error가 먼저 노출되게 된다.
|
|
|
|
:::
|
|
|
|
## Redirect URI와 CORS에서 아직 확인하지 않은 부분
|
|
|
|
local realm의 redirect allowlist는 다음과 같이 wildcard로 설정되어 있다.
|
|
|
|
```text
|
|
http://localhost:8088/*
|
|
http://127.0.0.1:8088/*
|
|
```
|
|
|
|
SPA에서 실제 사용하는 callback은 `/OAuth2callback.html`이다.
|
|
|
|
SPA : `/OAuth2callback.html`만 o
|
|
exact callback만 허용하는 운영적 측면 또는 잘못된 redirect를 거부하는 검사는 x
|
|
|
|
현재 설정에서는 wildcard가 허용되어 있기 때문에 exact callback만 허용했을 때 잘못된 redirect가 거부되는지는 아직 확인하지 않았다.
|
|
|
|
frontend Nginx에도 `/api/` proxy가 있지만 SPA에서는 상대 URL을 사용하지 않고 absolute URL인 `http://localhost:8081/api/me`를 호출한다.
|
|
|
|
그래서 현재 요청은 브라우저에서 Resource Server로 직접 나가고 CORS allowlist를 거친다. 상대 URL을 사용해서 Nginx를 통해 호출했다면 현재와 같은 CORS 경로는 지나지 않았을 것이다.
|
|
|
|
<!-- body:end --> |