이번 파이프라인 작업과 무관하게 작업 트리에 남아 있던 것을 그대로 올린다. 사용자가 「전부 커밋」으로 정했고, 이번 작업과 섞이지 않게 커밋만 나눴다. 대부분은 clean-architecture-backend-template 의 그림 정본 재배치다 — final/assets/diagrams/<이름>/ 에 있던 것이 CLAUDE.md 가 적은 배치인 final/assets/<이름>/ 로 옮겨졌고 .techviz/<이름>/ 이 함께 들어왔다. 삽입 줄의 대부분(3.15M)이 그 .techviz context.json 이다. 그 밖에 ca-tmpl·document-haness 의 정리, .claude/agents/ 열한 개, writing-practitioner-guides 스킬, .playwright-mcp 세션 산출물, scripts/check-ssot-facts.py 와 그 시험이 들어 있다. 이 커밋의 내용은 내가 만든 것이 아니라 이전 세션이 남긴 것이고 검증하지 않았다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
227 lines
14 KiB
Markdown
227 lines
14 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-direct-architecture
|
|
file: ../../../final/assets/ap1-direct-architecture/ap1-direct-architecture.svg
|
|
- key: ap1-browser-bearer-flow
|
|
file: ../../../final/assets/ap1-browser-bearer-flow/ap1-browser-bearer-flow.svg
|
|
sourceRevision: keycloak-patterns-lab@2026-08
|
|
source:
|
|
- final/document.md#검토한-선택지와-막힌-지점-ap1
|
|
- final/document.md#선택의-이유와-지킨-경계-ap1
|
|
- final/document.md#선택이-코드와-흐름에-반영되는-방식-ap1-완주
|
|
- final/document.md#결정이-지켜지는지-확인하는-방법-ap1
|
|
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap1
|
|
---
|
|
|
|
# SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우
|
|
|
|
AP1에서는 SPA(Single Page Application)를 public OAuth client로 구성하고 authorization code도 브라우저에서 직접 교환한다. 발급받은 액세스 토큰, 리프레시 토큰, ID 토큰은 Web Storage에 저장하지 않고 JavaScript 메모리에만 둔다.
|
|
|
|
## 관계
|
|
|
|
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
|
|
SPA에서 authorization request를 보내고 authorization code를 받은 뒤 토큰을 교환하는 과정을 직접 확인한 내용이다.
|
|
- **Public Client와 Confidential Client 구분 기준**
|
|
SPA는 client secret을 안전하게 숨길 수 없기 때문에 public client로 구성했고 PKCE S256을 사용했다.
|
|
- **OAuth Token과 Application Session을 구분하는 기준**
|
|
JavaScript 메모리에 있는 토큰과 Keycloak의 SSO 쿠키는 서로 다른 상태여서, 새로고침으로 SPA의 토큰이 없어져도 Keycloak의 SSO까지 끝나는 것은 아니다.
|
|
|
|
## 문제
|
|
|
|
AP1에서는 토큰을 Local Storage나 Session Storage에 저장하지 않고 JavaScript 메모리에만 둔다.
|
|
|
|
확인하고 싶었던 것은 토큰을 Web Storage에 쓰지 않는 것만으로 실행 중 XSS(Cross-Site Scripting, 사이트 간 스크립팅)까지 막을 수 있는지였다.
|
|
|
|
그래서 SPA가 authorization code를 직접 교환한 뒤 토큰을 어디에 들고 있는지, API를 부를 때 액세스 토큰이 어느 곳을 지나는지 따라갔다. PKCE가 실제로 어느 구간에 적용되는지도 같이 봤다.
|
|
|
|
## 결론
|
|
|
|
memory-only로 보관하면 새로고침 뒤에는 액세스·리프레시·ID 토큰이 모두 사라진다.
|
|
|
|
다만 페이지가 실행 중일 때는 JavaScript가 토큰을 다루므로, 악성 스크립트가 같은 페이지에서 실행되면 fetch를 가로채거나 사용자를 대신해 API를 부를 수 있다. 액세스 토큰은 JavaScript 메모리에만 있는 값도 아니어서 Resource Server 요청의 Authorization 헤더에도 실린다.
|
|
|
|
Resource Server는 SessionCreationPolicy.STATELESS로 동작해 서버에서 지울 애플리케이션 세션이 없고, 이미 발급된 self-contained JWT를 logout과 동시에 없애는 처리도 넣지 않았다.
|
|
|
|
그 대신 현재 구성은 액세스 토큰 수명을 300초로 두고 리프레시 토큰 rotation을 쓰며, Resource Server에서 issuer와 audience도 확인한다.
|
|
|
|
PKCE는 authorization code를 토큰으로 교환하는 구간에 쓰는 것이지, 이미 발급된 액세스 토큰을 브라우저에서 숨겨 주는 기능은 아니다.
|
|
|
|
## 검증 환경
|
|
|
|
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/callback.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. 토큰 응답의 액세스·리프레시·ID 토큰이 비어 있지 않은지 확인한다.
|
|
|
|
3. 브라우저 fetch를 가로채 /api/me 요청의 Authorization 헤더에서 Bearer 액세스 토큰을 확인한다.
|
|
|
|
4. Local Storage와 Session Storage에 액세스 토큰 문자열이 남지 않는지 확인한다.
|
|
|
|
5. 같은 정상 JWT를 expected issuer와 audience가 다른 진단용 서버 두 곳에 보내고 401이 반환되는지 확인한다.
|
|
|
|
6. 리프레시 토큰으로 새 토큰을 받은 뒤 이전 리프레시 토큰이 거부되는지 확인한다. revocation 뒤에는 갱신이 실패하는지, 이미 발급된 access JWT는 만료 전까지 200을 받는지도 확인한다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
public client는 브라우저처럼 client secret을 안전하게 숨길 수 없는 애플리케이션이다. AP1의 SPA가 그런 클라이언트라 authorization code를 토큰으로 바꾸는 일까지 브라우저가 직접 하고, 받은 액세스·리프레시·ID 토큰은 `InMemoryWebStorage`를 써서 실행 중 메모리에만 둔다. 이 구성이 무엇을 줄이고 무엇은 줄이지 못하는지 확인했다.
|
|
|
|
## SPA가 토큰을 다루는 위치
|
|
|
|

|
|
|
|
authorization code 교환, 토큰 보관, `Authorization` 헤더 조립까지 모두 브라우저에서 일어난다. 액세스·리프레시·ID 토큰은 JavaScript 메모리에 있고, Resource Server를 부를 때 쓸 `Authorization` 헤더도 같은 페이지에서 만든다.
|
|
|
|
그래서 이 페이지에서 악성 스크립트가 실행되면 JavaScript가 토큰을 다루는 경로에도 닿을 수 있다.
|
|
|
|
## 새로고침 전후에 브라우저에 남는 값
|
|
|
|
`oidc-client-ts`의 `InMemoryWebStorage`는 로그인 결과를 Local Storage나 Session Storage가 아니라 실행 중 메모리에만 둔다. 그래서 새로고침하면 메모리에 있던 로그인 정보와 토큰이 사라진다.
|
|
|
|
리프레시 토큰만 서버로 옮기거나 모든 토큰을 서버가 맡는 쪽도 검토했다. 다만 그렇게 하면 브라우저가 code를 교환하고 토큰의 수명을 관리하는 모습이 가려지기 때문에, 그 과정을 그대로 보려고 액세스·리프레시·ID 토큰을 JavaScript 메모리에 두었다. 그 대신 새로고침 뒤에는 인증 상태를 복구하지 못한다.
|
|
|
|
| 위치 | 새로고침 전 | 새로고침 뒤 |
|
|
|---|---|---|
|
|
| JavaScript 메모리 | `User`, 액세스·리프레시·ID 토큰, 만료 시각, 프로필 | 사라짐 |
|
|
| Session Storage | redirect transaction용 state와 verifier | callback 완료 뒤 제거 |
|
|
| Local Storage | 해당 없음 | 해당 없음 |
|
|
| Keycloak origin 쿠키 | IdP의 SSO 상태가 존재할 수 있음 | 애플리케이션 메모리와 별개 |
|
|
|
|
JavaScript 메모리의 `User`가 사라지는 것과 Keycloak의 SSO 세션이 끝나는 것은 다른 사건이다. SPA가 들고 있던 토큰이 없어져도 Keycloak SSO 쿠키가 그대로면 다음 authorization request에서 기존 로그인 상태가 다시 쓰일 수 있다.
|
|
|
|
## memory-only가 줄이는 것과 줄이지 못하는 것
|
|
|
|
액세스·리프레시·ID 토큰은 실행 중 메모리에 있고, 악성 스크립트는 같은 화면에서 fetch를 가로채거나 사용자를 대신해 API를 부를 수 있다. memory-only로 줄어드는 것은 새로고침 뒤에도 남는 복사본이지 실행 중 XSS가 할 수 있는 일이 아니다.
|
|
|
|
| 위협 | memory-only가 막아주나 |
|
|
|---|---|
|
|
| 새로고침 뒤에도 남는 토큰 복사본 | 막아준다 |
|
|
| 실행 중 스크립트가 fetch를 가로채는 것 | 막아주지 않는다 |
|
|
| 실행 중 스크립트가 사용자 대신 API를 부르는 것 | 막아주지 않는다 |
|
|
| 요청 헤더에 실리는 액세스 토큰 | 막아주지 않는다 |
|
|
| 이미 발급된 access JWT의 만료 전 유효성 | 막아주지 않는다 |
|
|
|
|
SPA가 Resource Server를 부를 때는 액세스 토큰을 `Authorization` 헤더에 넣는다.
|
|
|
|
```http label="브라우저가 Resource Server를 직접 부를 때"
|
|
GET http://localhost:8081/api/me
|
|
Authorization: Bearer <access-token>
|
|
```
|
|
|
|
그래서 액세스 토큰은 JavaScript 메모리 안에만 머무르지 않고, API를 부르는 동안 요청 헤더에도 실린다. Resource Server가 돌려주는 것은 `subject`·`username`·`issuer`·`audience` 네 필드다.
|
|
|
|
Resource Server는 `SessionCreationPolicy.STATELESS`로 설정되어 있어 서버에서 지울 애플리케이션 세션이 없고, 이미 발급된 self-contained JWT를 logout 시점에 곧바로 무효화하는 처리도 넣지 않았다. logout은 Keycloak SSO 종료와 SPA의 사용자 제거까지만 하고, 발급된 access JWT를 deny-list로 따로 관리하지는 않는다.
|
|
|
|
즉시 없앨 수단이 없으니 짧은 수명과 검증이 가드레일이 된다.
|
|
|
|
액세스 토큰 : 300초
|
|
리프레시 토큰 rotation, 재사용 허용 : x
|
|
issuer·audience : 검증
|
|
|
|
저장 위치를 옮기는 선택지는 저마다 다른 것을 요구한다. Local Storage나 Session Storage에 토큰을 저장하면 새로고침 뒤에도 값을 다시 읽을 수 있지만 브라우저 저장소에 토큰 복사본이 생긴다. HttpOnly 쿠키로 옮기려면 저장 위치만 바꿔서는 안 되고, 브라우저가 액세스 토큰을 꺼내 Resource Server로 직접 보내는 지금 방식 대신 서버가 세션이나 토큰 중계를 맡는 구조가 있어야 한다.
|
|
|
|
실행 중 스크립트 자체의 위험은 CSP(Content Security Policy)와 의존성 무결성 검사로 따로 줄인다.
|
|
|
|
## PKCE가 적용되는 구간
|
|
|
|

|
|
|
|
PKCE(Proof Key for Code Exchange)를 쓰면 authorization request에는 `code_challenge`가 들어가고, authorization code를 토큰으로 교환할 때는 원본인 `code_verifier`를 함께 보낸다. 두 값이 맞아야 code를 교환할 수 있다.
|
|
|
|
```text label="oidc-client-ts가 만드는 authorization request의 핵심 query"
|
|
response_type=code
|
|
client_id=spa-public
|
|
redirect_uri=http://localhost:8088/callback.html
|
|
scope=openid profile email
|
|
state=<opaque-state>
|
|
code_challenge=<opaque-challenge>
|
|
code_challenge_method=S256
|
|
```
|
|
|
|
이 구성에서는 `response_type=code`를 쓰고, authorization request에 `code_challenge_method=S256`과 비어 있지 않은 `code_challenge`가 들어가는 것을 확인했다.
|
|
|
|
PKCE가 걸리는 구간은 authorization code를 토큰으로 교환하는 곳까지다. 토큰이 발급된 뒤에 브라우저가 액세스 토큰을 쓰지 못하게 막아 주는 기능은 아니다.
|
|
|
|
## 테스트에서 확인한 범위
|
|
|
|
여기서 확인했다고 적은 것은 마지막 실행 결과가 아니라 커밋된 테스트가 확인하도록 정의한 항목이다.
|
|
|
|
| 테스트에 있나 | 항목 |
|
|
|---|---|
|
|
| o | authorization request의 `response_type=code`, S256 method, 비어 있지 않은 challenge |
|
|
| o | 토큰 응답에 비어 있지 않은 액세스·리프레시·ID 토큰 |
|
|
| o | `/api/me` 200과 decode한 액세스 토큰의 audience 포함 |
|
|
| o | 브라우저 fetch를 가로채 Authorization 헤더의 Bearer 토큰 관측 |
|
|
| o | Local Storage와 Session Storage에 액세스 토큰 문자열 없음 |
|
|
| o | 리프레시 토큰 rotation — 새 토큰 발급, 이전 토큰 거부, revocation 뒤 갱신 실패 |
|
|
| 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`의 실제 갱신 경로 |
|
|
|
|
표에서 fetch를 가로채 Bearer 토큰을 본 항목은 노출의 한계를 일부러 재현한 것이다. Local Storage와 Session Storage에 토큰 문자열이 없다는 항목과 함께 봐야, 브라우저 저장소에는 없지만 실행 중 JavaScript 경계에는 있다는 것이 확인된다.
|
|
|
|
authorization request 쪽은 `response_type=code`, S256 method, 비어 있지 않은 challenge까지 봤지만, token request body에 실제로 들어간 `code_verifier`, `client_id`, `redirect_uri`, code 값이 서로 어떻게 대조됐는지는 아직 확인하지 않았다. 구현이 의도한 PKCE 순서와 테스트가 실제로 붙잡은 필드를 같은 증거로 쓸 수 없다.
|
|
|
|
서명이 깨진 JWT와 만료된 JWT도 전용 테스트로 넣지 않았다. 단위 테스트에서 합성 JWT를 주입해 컨트롤러가 200을 내는 것은 봤지만, 그것이 실제 Nimbus 서명 검증과 issuer 검증을 지났다는 증거는 아니다.
|
|
|
|
:::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은 `/callback.html` 하나인데 등록된 목록은 그보다 넓다.
|
|
|
|
SPA : `/callback.html`만 o
|
|
exact callback만 허용하는 운영 가드레일, 잘못된 redirect를 거부하는 검사 : x
|
|
|
|
frontend Nginx에도 `/api/` proxy가 있지만 SPA는 상대 URL이 아니라 절대 URL인 `http://localhost:8081/api/me`를 부른다. 그래서 지금 요청은 브라우저에서 Resource Server로 곧장 나가 CORS allowlist를 거친다. 상대 URL로 Nginx를 통해 불렀다면 이 CORS 경로는 지나지 않았을 것이다.
|
|
|
|
<!-- body:end -->
|