201 lines
10 KiB
Markdown
201 lines
10 KiB
Markdown
---
|
|
id: bf675775-4f3e-4744-8014-f0efff51422a
|
|
kind: CASE
|
|
slug: spa-browser-credential-boundary
|
|
title: SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계
|
|
topic: OAuth/OIDC 인증 경계
|
|
project: KeyCloak Patterns
|
|
status: 게시 중
|
|
version: 25
|
|
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"
|
|
---
|
|
|
|
# SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계
|
|
|
|
SPA를 public OAuth client로 구성해 authorization code를 직접 교환하고, access·refresh·ID token은 JavaScript memory에 보관했다. Web Storage에는 token을 저장하지 않았고, 실행 중인 script가 같은 JavaScript 실행 영역의 token과 API 호출에 접근할 수 있는지도 함께 확인했다.
|
|
|
|
## 관계
|
|
|
|
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
|
|
브라우저가 authorization endpoint와 token endpoint를 직접 호출하는 흐름을 코드와 network 요청으로 확인했다.
|
|
- **Public Client와 Confidential Client 구분 기준**
|
|
SPA는 client secret을 안전하게 보관할 수 없어 public client로 등록했고, Authorization Code Flow에는 PKCE를 적용했다.
|
|
- **OAuth Token과 Application Session을 구분하는 기준**
|
|
JavaScript memory의 OAuth token과 Keycloak 도메인의 SSO cookie가 서로 다른 상태라는 점을 확인했다.
|
|
- **인증 구조를 보안 성숙도 단계로 취급하지 않는다**
|
|
이 Case의 SPA 구성을 다른 패턴보다 낮은 단계로 해석하지 않도록 별도의 결정 기록에서 기준을 정했다.
|
|
|
|
## 문제
|
|
|
|
token을 Web Storage에 저장하지 않고 JavaScript memory에만 보관했을 때 XSS 경계가 어떻게 달라지는지 확인할 필요가 있었다.
|
|
|
|
AP1은 SPA가 public client가 되어 authorization code를 직접 교환하고 access·refresh·ID token을 JavaScript memory에 두는 구성이다.
|
|
|
|
확인할 내용은 세 가지였다. 브라우저가 어떤 credential을 직접 다루는지, PKCE가 어떤 공격을 막는지, memory-only 보관으로 제한할 수 있는 위험이 무엇인지였다.
|
|
|
|
## 결론
|
|
|
|
memory-only 보관은 token을 Web Storage에 지속적으로 저장하지 않는 방법이다. 실행 중 XSS가 같은 JavaScript 실행 영역에 접근하는 문제까지 해결하지는 않는다.
|
|
|
|
실행 중 악성 script는 같은 화면에서 fetch를 가로채거나 사용자를 대신해 API를 부를 수 있다.
|
|
token 원문은 memory에만 있는 것이 아니라 요청마다 Authorization 헤더에도 실리기 때문에 노출된다.
|
|
|
|
Resource Server는 STATELESS로 동작하고 별도 session이나 denylist를 두지 않았다. 이미 발급된 self-contained JWT는 logout만으로 즉시 무효화되지 않으므로 짧은 만료 시간과 refresh token rotation을 사용하고, Resource Server에서는 issuer와 audience를 검증한다.
|
|
|
|
PKCE는 훔친 authorization code의 교환을 막을 뿐이지 발급된 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·refresh·ID token이 비어 있지 않은지 확인.
|
|
|
|
3. 브라우저 fetch를 hook해 /api/me 호출의 Authorization header에서 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 -->
|
|
|
|
## 브라우저가 직접 다루는 credential
|
|
|
|
:::evidence key="ap1-custody-v3-6e0376d2" alt="브라우저 실행 영역 안에 code 교환, access·refresh·ID token 보관, Authorization 헤더 조립 세 상자가 들어 있고 그 영역 전체가 실행 중 XSS가 닿는 범위로 표시된 그림. Keycloak과 Resource Server는 그 밖에 있다." caption=" " zoom="true"
|
|
:::
|
|
|
|
code 교환, token 보관, `Authorization` 헤더 조립이 모두 같은 브라우저 실행 영역에 있다. 이 영역에서 악성 script가 실행되면 세 지점 모두 영향을 받는다.
|
|
|
|
## 새로고침 전후의 브라우저 상태
|
|
|
|
oidc-client-ts의 `InMemoryWebStorage`는 로그인 결과를 브라우저의 영구 저장소가 아니라 실행 중 memory에만 둔다.
|
|
새로고침하면 JavaScript memory의 `User`와 token이 초기화되고, Local Storage와 Session Storage에서는 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과 별개 |
|
|
|
|
memory user가 사라진다고 Keycloak SSO까지 로그아웃되는 것은 아니다.
|
|
|
|
## memory-only가 줄이는 위험
|
|
|
|
저장 위치만으로 XSS 경계를 설명할 수는 없다. 같은 origin에서 악성 script가 실행되면 JavaScript memory와 fetch 호출 모두 같은 실행 영역에 있기 때문이다.
|
|
|
|
| 위협 | memory-only가 막아주나 |
|
|
|---|---|
|
|
| 새로고침 뒤에도 남는 token 복사본 | 막아준다 |
|
|
| 실행 중 script가 fetch를 가로채기 | 막아주지 않는다 |
|
|
| 실행 중 script가 사용자 대신 API 호출 | 막아주지 않는다 |
|
|
| network 요청 헤더에 실린 access token | 막아주지 않는다 |
|
|
| 이미 발급된 access JWT의 만료 전 유효성 | 막아주지 않는다 |
|
|
|
|
네 번째 줄이 이 코드에서 access token이 외부 요청으로 나가는 지점이다. SPA는 요청마다 이 헤더를 만든다.
|
|
|
|
```http label="브라우저가 Resource Server를 직접 부를 때"
|
|
GET http://localhost:8081/api/me
|
|
Authorization: Bearer <access-token>
|
|
```
|
|
|
|
token 원문은 memory에도 있고 network 헤더에도 실린다.
|
|
|
|
Resource Server가 `SessionCreationPolicy.STATELESS`라서 서버에 지울 session이 없다.
|
|
이미 발급된 self-contained JWT를 logout 순간에 즉시 없앨 방법이 없고, logout은 Keycloak SSO 종료와 애플리케이션 user 제거를 다룰 뿐 access JWT를 deny-list에서 관리하지 않는다.
|
|
|
|
이 구성에서는 access token의 만료 시간을 짧게 두어 노출됐을 때 사용할 수 있는 시간을 제한한다.
|
|
access token : 300초
|
|
refresh token rotation, 재사용 허용 : x
|
|
issuer·audience : 검증
|
|
|
|
Local Storage나 Session Storage로 옮기면 새로고침은 편해지지만 노출 시간이 길어진다.
|
|
HttpOnly cookie로 옮기는 일은 저장 위치만 바꾸는 작업이 아니다.
|
|
server가 session이나 token 중계를 맡는 구조가 필요하다.
|
|
|
|
## PKCE가 막는 구간
|
|
|
|
PKCE(Proof Key for Code Exchange)는 authorization request에 `code_challenge`를 싣고, code를 token으로 바꿀 때 원본인 `code_verifier`를 같이 보내게 한다. 둘이 맞아야 교환이 끝난다.
|
|
|
|
```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`가 Authorization Code Flow를 쓴다는 뜻이고, `code_challenge`와 `code_challenge_method=S256`이 PKCE 사용을 나타낸다.
|
|
막는 구간은 code 교환까지다. 이미 발급된 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의 파라미터를 보는 것이지 PKCE 교환이 성립하는 것을 보는 것이 아니다.**
|
|
|
|
:::warning
|
|
|
|
SPA는 non-2xx 응답에서도 `response.ok`을 확인하기 전에 `response.json()`을 시도한다. 401 body가 비어 있거나 JSON이 아니면 의도한 오류 처리보다 JSON parse error가 먼저 발생한다.
|
|
|
|
:::
|
|
|
|
## 추가로 설정에서 확인해야될 것
|
|
|
|
local realm의 redirect allowlist는
|
|
`http://localhost:8088/*`와 `http://127.0.0.1:8088/*` wildcard다.
|
|
SPA : `/OAuth2callback.html`만 o,
|
|
exact callback만 허용하는 운영적 측면 또는 잘못된 redirect를 거부하는 검사는 x
|
|
|
|
frontend Nginx에도 `/api/` proxy가 있지만 SPA는 상대 URL이 아니라 absolute `http://localhost:8081/api/me`를 사용한다. 브라우저는 8088에서 8081로 cross-origin 요청을 보내므로 Resource Server의 CORS allowlist가 실제 요청에 적용된다.
|
|
상대 URL을 썼다면 이 경계에서 확인되는 부분은 없었을 것이다.
|
|
|
|
<!-- body:end -->
|