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 메모리에만 둔다.
|
|
|
|
그래서 새로고침 뒤에는 토큰이 사라진다. 다만 페이지가 열려 있는 동안에는 JavaScript가 토큰을 그대로 다루고, Resource Server를 부를 때도 액세스 토큰이 `Authorization` 헤더에 실린다. memory-only는 실행 중 XSS(Cross-Site Scripting, 사이트 간 스크립팅)가 같은 origin의 사용자 권한으로 API를 부르는 것을 막지 못한다.
|
|
|
|
## 관계
|
|
|
|
- **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까지 막을 수 있는지였다.
|
|
|
|
그래서 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>
|
|
```
|
|
|
|
API를 부르는 동안에는 액세스 토큰이 요청의 `Authorization` 헤더에도 실린다. 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`의 실제 갱신 경로 |
|
|
|
|
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이 아니라 absolute URL인 `http://localhost:8081/api/me`를 부른다. 그래서 지금 요청은 브라우저에서 Resource Server로 곧장 나가 CORS allowlist를 거치고, 상대 URL로 Nginx를 통해 불렀다면 이 CORS 경로는 지나지 않았을 것이다.
|
|
|
|
<!-- body:end -->
|