Files
document-haness/docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-browser-credential-boundary.md
T
DongHyeonkaandClaude Opus 5 ab59130196 chore: 이전 세션이 남긴 변경을 커밋한다
이번 파이프라인 작업과 무관하게 작업 트리에 남아 있던 것을 그대로 올린다.
사용자가 「전부 커밋」으로 정했고, 이번 작업과 섞이지 않게 커밋만 나눴다.

대부분은 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>
2026-09-17 11:02:02 +09:00

14 KiB

id, kind, slug, title, topic, topicName, project, status, version, verifiedOn, studio, public, assets, sourceRevision, source
id kind slug title topic topicName project status version verifiedOn studio public assets sourceRevision source
bf675775-4f3e-4744-8014-f0efff51422a CASE spa-browser-credential-boundary SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우 oauth-oidc-auth-boundary OAuth/OIDC 인증 경계 KeyCloak Patterns 게시 중 31 2026-08-22 https://hyeonworks.com/studio/documents/bf675775-4f3e-4744-8014-f0efff51422a/edit https://hyeonworks.com/cases/spa-browser-credential-boundary
key file
ap1-direct-architecture ../../../final/assets/ap1-direct-architecture/ap1-direct-architecture.svg
key file
ap1-browser-bearer-flow ../../../final/assets/ap1-browser-bearer-flow/ap1-browser-bearer-flow.svg
keycloak-patterns-lab@2026-08
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을 받는지도 확인한다.

본문

public client는 브라우저처럼 client secret을 안전하게 숨길 수 없는 애플리케이션이다. AP1의 SPA가 그런 클라이언트라 authorization code를 토큰으로 바꾸는 일까지 브라우저가 직접 하고, 받은 액세스·리프레시·ID 토큰은 InMemoryWebStorage를 써서 실행 중 메모리에만 둔다. 이 구성이 무엇을 줄이고 무엇은 줄이지 못하는지 확인했다.

SPA가 토큰을 다루는 위치

SPA, Keycloak, 브라우저 JavaScript memory, Resource Server가 왼쪽에서 오른쪽으로 연결된 AP1 직접 인증 아키텍처.

authorization code 교환, 토큰 보관, Authorization 헤더 조립까지 모두 브라우저에서 일어난다. 액세스·리프레시·ID 토큰은 JavaScript 메모리에 있고, Resource Server를 부를 때 쓸 Authorization 헤더도 같은 페이지에서 만든다.

그래서 이 페이지에서 악성 스크립트가 실행되면 JavaScript가 토큰을 다루는 경로에도 닿을 수 있다.

새로고침 전후에 브라우저에 남는 값

oidc-client-tsInMemoryWebStorage는 로그인 결과를 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 헤더에 넣는다.

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가 적용되는 구간

브라우저 SPA, Keycloak, Resource Server 사이에서 authorization request, callback, token 교환, Bearer API 호출과 JSON 응답이 이어지는 순서도.

PKCE(Proof Key for Code Exchange)를 쓰면 authorization request에는 code_challenge가 들어가고, authorization code를 토큰으로 교환할 때는 원본인 code_verifier를 함께 보낸다. 두 값이 맞아야 code를 교환할 수 있다.

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로 설정되어 있다.

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 경로는 지나지 않았을 것이다.