chore: snapshot working tree before harness removal

미추적 파일과 미커밋 수정을 전부 담아 pre-harness-removal 태그의 복구
범위를 확보한다. .agents/skills/writing-natural-korean 9개와
korean-technical-blog-skills-bundle-v1 61개가 여기 포함된다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-07 14:19:24 +09:00
co-authored by Claude Opus 5
parent b101b6e717
commit 1099834617
85 changed files with 8547 additions and 114 deletions
+125 -86
View File
@@ -42,7 +42,9 @@
2. **애플리케이션 요청 구간:** 브라우저 입력, 중간 계층의 credential 변환, 보호 자원의 검증, 최종 응답
<!-- techviz:begin id=login-api-phase-split context-sha256=df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371 -->
<!-- techviz:generate id=login-api-phase-split -->
![로그인 구간과 애플리케이션 요청 구간을 나누어 AP2 mediator·브라우저, AP3 BFF, AP4 oauth2-proxy·Nginx의 책임 배치를 비교한 다이어그램.](assets/login-api-phase-split/login-api-phase-split.svg)
<details>
@@ -53,23 +55,24 @@
</details>
[Editable source](assets/login-api-phase-split/login-api-phase-split.drawio) · [Grounded VizSpec](.techviz/login-api-phase-split/spec.json)
<!-- techviz:end id=login-api-phase-split -->
### 같은 사용자를 나타내도 데이터의 의미는 다르다
네 예제의 응답에는 모두 `regular-user`가 있었습니다. 처음에는 같은 사용자 이름이니 같은 인증 정보라고 묶어도 될 것처럼 보였습니다. 그런데 값이 들어오는 곳을 확인해 보니 어떤 때는 JWT 안의 claim이었고 어떤 때는 Nginx가 만든 header였습니다. Claim은 token 안에 들어 있는 사용자 정보 항목입니다. 둘을 모두 인증 정보라고 쓰면 JWT를 검증한 것인지, Nginx가 만든 header를 확인한 것인지 구분할 수 없었습니다.
| 데이터 | 만든 주체 | 주된 소비자 | 의미 |
|---|---|---|---|
| authorization code | Keycloak | OAuth client | 짧게 사용되는 code 교환 입력 |
| PKCE verifier | AP1 SPA, AP3 BFF, AP4 oauth2-proxy | Keycloak token endpoint | authorization request를 시작한 client와 code 교환 주체를 연결 |
| access token | Keycloak | Resource Server | API 요청을 인증·인가하는 Bearer credential |
| refresh token | Keycloak | AP1 SPA, AP2 mediator, AP3 BFF 등 해당 소유자 | 새 access token을 얻는 장기 credential |
| server session 식별 cookie | mediator 또는 BFF | 같은 server-side login state의 소유자 | 브라우저 요청을 HttpSession 인증 상태에 연결 |
| proxy session cookie | oauth2-proxy | oauth2-proxy의 auth endpoint | AP4의 minimal client-side session 상태를 다음 auth subrequest에 제시 |
| CSRF token | AP3 BFF | AP3 BFF | cookie가 자동 첨부되는 상태 변경 요청의 의도 확인 |
| identity header | oauth2-proxy 결과를 받은 Nginx | AP4 upstream | edge가 확인한 사용자 identity의 투영 |
| internal auth token | AP4 배포 설정 | AP4 upstream | 허용된 edge를 거쳤다는 추가 신뢰 신호 |
| 데이터 | 만든 주체 | 주된 소비자 | 의미 |
| -------------------------- | ---------------------------------- | --------------------------------------------- | -------------------------------------------------------------------- |
| authorization code | Keycloak | OAuth client | 짧게 사용되는 code 교환 입력 |
| PKCE verifier | AP1 SPA, AP3 BFF, AP4 oauth2-proxy | Keycloak token endpoint | authorization request를 시작한 client와 code 교환 주체를 연결 |
| access token | Keycloak | Resource Server | API 요청을 인증·인가하는 Bearer credential |
| refresh token | Keycloak | AP1 SPA, AP2 mediator, AP3 BFF 등 해당 소유자 | 새 access token을 얻는 장기 credential |
| server session 식별 cookie | mediator 또는 BFF | 같은 server-side login state의 소유자 | 브라우저 요청을 HttpSession 인증 상태에 연결 |
| proxy session cookie | oauth2-proxy | oauth2-proxy의 auth endpoint | AP4의 minimal client-side session 상태를 다음 auth subrequest에 제시 |
| CSRF token | AP3 BFF | AP3 BFF | cookie가 자동 첨부되는 상태 변경 요청의 의도 확인 |
| identity header | oauth2-proxy 결과를 받은 Nginx | AP4 upstream | edge가 확인한 사용자 identity의 투영 |
| internal auth token | AP4 배포 설정 | AP4 upstream | 허용된 edge를 거쳤다는 추가 신뢰 신호 |
예를 들어 access token의 `preferred_username`과 AP4의 `X-Auth-Request-User`에는 모두 `regular-user`가 들어갈 수 있었습니다. 화면에서 보이는 이름은 같지만 실제 검증 방식은 다릅니다. Resource Server는 JWT의 서명과 issuer, audience를 확인했습니다. AP4 upstream은 요청이 신뢰할 수 있는 edge를 거쳤는지, 내부 인증값도 맞는지 확인했습니다.
@@ -80,7 +83,9 @@ AP3와 AP4를 처음 보았을 때는 JavaScript가 OAuth token을 받지 않으
그래서 이 글에서 “브라우저에 없다”는 표현은 애플리케이션이 사용하는 OAuth token에만 쓰기로 했습니다. IdP의 SSO 상태까지 없다는 뜻은 아닙니다. 반대로 AP1이 Web Storage에 token을 쓰지 않는다고 JavaScript에서 token이 사라지는 것도 아니었습니다. Access·refresh·ID token은 실행 중 memory에 있었습니다. 악성 script는 같은 화면에서 fetch를 가로채거나 사용자를 대신해 API를 부를 수 있었습니다. Memory-only로 줄어드는 것은 새로고침 뒤에도 남는 복사본이지 실행 중 XSS의 권한은 아니었습니다.
<!-- techviz:begin id=credential-custody-map context-sha256=df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371 -->
<!-- techviz:generate id=credential-custody-map -->
![AP1부터 AP4까지 OAuth credential 소유자, 브라우저 credential, 보관 모델과 현재 입증된 운영 범위를 같은 네 축으로 정렬한 비교 다이어그램.](assets/credential-custody-map/credential-custody-map.svg)
<details>
@@ -91,6 +96,7 @@ AP3와 AP4를 처음 보았을 때는 JavaScript가 OAuth token을 받지 않으
</details>
[Editable source](assets/credential-custody-map/credential-custody-map.drawio) · [Grounded VizSpec](.techviz/credential-custody-map/spec.json)
<!-- techviz:end id=credential-custody-map -->
### 현재 구현은 운영 참조 아키텍처가 아니라 관찰 가능한 학습 환경이다
@@ -115,32 +121,34 @@ AP3와 AP4를 처음 보았을 때는 JavaScript가 OAuth token을 받지 않으
처음에는 네 패턴을 설명하는 용어부터 비교했습니다. 그런데 용어만 나란히 놓으니 실제로 누가 code를 바꾸고 API를 부르는지 잘 보이지 않았습니다. 그래서 로그인과 API 요청을 맡는 구성요소를 같은 표에 놓았습니다.
| 비교 축 | AP1 · SPA direct | AP2 · token mediator | AP3 · BFF | AP4 · edge forward-auth |
|---|---|---|---|---|
| OAuth client | 브라우저의 public SPA | Spring mediator | Spring BFF | oauth2-proxy |
| client 종류 | public | confidential | confidential | confidential |
| code 교환 주체 | 브라우저 | mediator | BFF | oauth2-proxy |
| PKCE | S256 | 현재 client 등록·흐름에서 명시적 AP1/AP3/AP4 가드레일과 동일하게 주장하지 않음 | S256 | S256 |
| refresh token 소유자 | 브라우저 JavaScript memory | mediator의 authorized client | BFF의 authorized client | 지속 보관 근거 없음: oauth2-proxy가 code/token 교환은 하지만 minimal cookie에는 access·refresh·ID token을 저장하지 않고 refresh lifecycle도 검증되지 않음 |
| access token이 JavaScript 응답에 포함되는가 | 포함 | 포함 | 미포함 | 미포함 |
| API를 호출하는 주체 | 브라우저 | 브라우저 | BFF | Nginx가 upstream 요청을 연결 |
| 보호 자원이 받는 credential | Bearer JWT | Bearer JWT | BFF가 붙인 Bearer JWT | user·email header + internal token |
| 애플리케이션 측 로그인 상태 | server session 없음 | `AP2_SESSION` + authorized client | `AP3_SESSION` + authorized client | minimal client-side `AP4_SESSION`을 사용하는 proxy 경계 |
| 새로 필요한 핵심 방어 | browser token 수명주기·XSS 피해 축소 | access 응답 제한·CORS·server state 운영 | CSRF·session scale-out·token-at-rest | network isolation·header overwrite·service identity |
| 비교 축 | AP1 · SPA direct | AP2 · token mediator | AP3 · BFF | AP4 · edge forward-auth |
| ------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| OAuth client | 브라우저의 public SPA | Spring mediator | Spring BFF | oauth2-proxy |
| client 종류 | public | confidential | confidential | confidential |
| code 교환 주체 | 브라우저 | mediator | BFF | oauth2-proxy |
| PKCE | S256 | 현재 client 등록·흐름에서 명시적 AP1/AP3/AP4 가드레일과 동일하게 주장하지 않음 | S256 | S256 |
| refresh token 소유자 | 브라우저 JavaScript memory | mediator의 authorized client | BFF의 authorized client | 지속 보관 근거 없음: oauth2-proxy가 code/token 교환은 하지만 minimal cookie에는 access·refresh·ID token을 저장하지 않고 refresh lifecycle도 검증되지 않음 |
| access token이 JavaScript 응답에 포함되는가 | 포함 | 포함 | 미포함 | 미포함 |
| API를 호출하는 주체 | 브라우저 | 브라우저 | BFF | Nginx가 upstream 요청을 연결 |
| 보호 자원이 받는 credential | Bearer JWT | Bearer JWT | BFF가 붙인 Bearer JWT | user·email header + internal token |
| 애플리케이션 측 로그인 상태 | server session 없음 | `AP2_SESSION` + authorized client | `AP3_SESSION` + authorized client | minimal client-side`AP4_SESSION`을 사용하는 proxy 경계 |
| 새로 필요한 핵심 방어 | browser token 수명주기·XSS 피해 축소 | access 응답 제한·CORS·server state 운영 | CSRF·session scale-out·token-at-rest | network isolation·header overwrite·service identity |
AP2의 PKCE 칸은 다른 패턴과 똑같이 채우지 않았습니다. “Authorization Code를 쓴다”와 “현재 구현이 PKCE S256까지 같은 방식으로 고정했다”는 서로 다른 주장이었기 때문입니다. 저는 코드와 설정에서 확인한 범위보다 넓혀 네 패턴을 억지로 대칭적으로 만들지 않았습니다.
그다음에는 로그인 뒤 요청 한 번에서 실제로 움직이는 데이터를 적었습니다.
| 패턴 | 브라우저가 보내는 입력 | 중간 계층이 조회·생성하는 데이터 | 보호 자원의 실제 입력 | 브라우저가 받는 출력 |
|---|---|---|---|---|
| AP1 | `Authorization: Bearer <access_token>` | 없음 | 동일 Bearer JWT | `/api/me` JSON |
| AP2 | 먼저 `AP2_SESSION`, 다음에 Bearer access token | mediator가 authorized client에서 access token을 읽어 JSON으로 반환 | 브라우저가 다시 만든 Bearer JWT | token JSON, 이어서 `/api/me` JSON |
| AP3 | `AP3_SESSION`; POST에는 `X-XSRF-TOKEN` 추가 | BFF가 authorized client에서 access token을 읽고 downstream Bearer header 생성 | BFF가 보낸 Bearer JWT | BFF가 중계한 JSON |
| AP4 | `AP4_SESSION` | Nginx auth subrequest, oauth2-proxy의 user·email 결과, 배포 secret | 정제된 identity header + internal token | `/edge/me`가 만든 identity JSON |
| 패턴 | 브라우저가 보내는 입력 | 중간 계층이 조회·생성하는 데이터 | 보호 자원의 실제 입력 | 브라우저가 받는 출력 |
| ---- | ----------------------------------------------- | ----------------------------------------------------------------------------- | --------------------------------------- | ---------------------------------- |
| AP1 | `Authorization: Bearer <access_token>` | 없음 | 동일 Bearer JWT | `/api/me` JSON |
| AP2 | 먼저`AP2_SESSION`, 다음에 Bearer access token | mediator가 authorized client에서 access token을 읽어 JSON으로 반환 | 브라우저가 다시 만든 Bearer JWT | token JSON, 이어서`/api/me` JSON |
| AP3 | `AP3_SESSION`; POST에는 `X-XSRF-TOKEN` 추가 | BFF가 authorized client에서 access token을 읽고 downstream Bearer header 생성 | BFF가 보낸 Bearer JWT | BFF가 중계한 JSON |
| AP4 | `AP4_SESSION` | Nginx auth subrequest, oauth2-proxy의 user·email 결과, 배포 secret | 정제된 identity header + internal token | `/edge/me`가 만든 identity JSON |
<!-- techviz:begin id=four-pattern-request-boundaries context-sha256=df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371 -->
<!-- techviz:generate id=four-pattern-request-boundaries -->
![AP1, AP2, AP3, AP4의 브라우저 입력, 중간 변환, 보호 자원 credential과 브라우저 출력을 같은 네 축으로 비교한 다이어그램.](assets/four-pattern-request-boundaries/four-pattern-request-boundaries.svg)
<details>
@@ -151,6 +159,7 @@ AP2의 PKCE 칸은 다른 패턴과 똑같이 채우지 않았습니다. “Auth
</details>
[Editable source](assets/four-pattern-request-boundaries/four-pattern-request-boundaries.drawio) · [Grounded VizSpec](.techviz/four-pattern-request-boundaries/spec.json)
<!-- techviz:end id=four-pattern-request-boundaries -->
### AP1에서 막히는 지점: protocol 투명성과 browser credential
@@ -194,7 +203,9 @@ Refresh token만 mediator로 옮기는 AP2나 모든 token을 BFF에 맡기는 A
대신 token을 Local Storage나 Session Storage에 복사하지 않았습니다. Access token은 300초만 유효하게 두고 refresh token rotation과 reuse 0을 사용했습니다. Resource Server는 issuer나 audience가 다르면 401을 반환하게 했습니다. 그래도 실행 중인 XSS는 같은 origin의 사용자 권한을 쓸 수 있었고, 이미 발급된 access JWT는 만료될 때까지 유효했습니다.
<!-- techviz:begin id=ap1-direct-architecture context-sha256=df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371 -->
<!-- techviz:generate id=ap1-direct-architecture -->
![SPA, Keycloak, 브라우저 JavaScript memory, Resource Server가 왼쪽에서 오른쪽으로 연결된 AP1 직접 인증 아키텍처.](assets/ap1-direct-architecture/ap1-direct-architecture.svg)
<details>
@@ -205,6 +216,7 @@ Refresh token만 mediator로 옮기는 AP2나 모든 token을 BFF에 맡기는 A
</details>
[Editable source](assets/ap1-direct-architecture/ap1-direct-architecture.drawio) · [Grounded VizSpec](.techviz/ap1-direct-architecture/spec.json)
<!-- techviz:end id=ap1-direct-architecture -->
### AP2: refresh credential은 서버에, 직접 API 호출은 브라우저에 둔다
@@ -218,7 +230,9 @@ Mediator는 Spring `oauth2Login`으로 code를 교환한 뒤 access·refresh tok
AP2에서는 노출 범위를 줄이려고 CORS origin과 method를 좁히고 refresh token을 응답에서 뺐습니다. Session cookie에는 HttpOnly와 SameSite를 설정했고 Resource Server는 audience를 검증하게 했습니다. 다만 access token 전달 횟수 제한, durable store, logout, 만료 뒤 실제 refresh 동작은 아직 검증하지 못했습니다.
<!-- techviz:begin id=ap2-mediator-architecture context-sha256=df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371 -->
<!-- techviz:generate id=ap2-mediator-architecture -->
![브라우저가 Spring mediator에서 access token만 받아 Resource Server를 직접 호출하고 refresh token은 authorized-client store에 남기는 AP2 split-custody 아키텍처.](assets/ap2-mediator-architecture/ap2-mediator-architecture.svg)
<details>
@@ -229,6 +243,7 @@ AP2에서는 노출 범위를 줄이려고 CORS origin과 method를 좁히고 re
</details>
[Editable source](assets/ap2-mediator-architecture/ap2-mediator-architecture.drawio) · [Grounded VizSpec](.techviz/ap2-mediator-architecture/spec.json)
<!-- techviz:end id=ap2-mediator-architecture -->
### AP3: browser token 비노출과 application-owned session을 맞바꾼다
@@ -242,7 +257,9 @@ AP2에서는 refresh token을 server로 옮겼지만 access token은 여전히
이 비교를 통해 stateless Resource Server와 OAuth 흐름을 직접 보는 일이 더 중요하면 AP1이 맞다고 판단했습니다. 브라우저의 직접 API 호출을 남겨야 한다면 AP2를 선택할 수 있었습니다.
<!-- techviz:begin id=ap3-bff-architecture context-sha256=df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371 -->
<!-- techviz:generate id=ap3-bff-architecture -->
![Browser session zone과 server-side BFF zone 사이에서 AP3_SESSION이 downstream Bearer 요청으로 바뀌는 BFF 아키텍처.](assets/ap3-bff-architecture/ap3-bff-architecture.svg)
<details>
@@ -253,6 +270,7 @@ AP2에서는 refresh token을 server로 옮겼지만 access token은 여전히
</details>
[Editable source](assets/ap3-bff-architecture/ap3-bff-architecture.drawio) · [Grounded VizSpec](.techviz/ap3-bff-architecture/spec.json)
<!-- techviz:end id=ap3-bff-architecture -->
### AP4: OAuth를 모르는 upstream 앞에서 신뢰 경로를 만든다
@@ -266,7 +284,9 @@ AP2에서는 refresh token을 server로 옮겼지만 access token은 여전히
대신 proxy session과 identity header를 믿을 조건이 핵심 인프라가 되었습니다. 현재 예제에서는 App과 oauth2-proxy의 host port를 닫고, internal auth location을 정확히 일치시켰습니다. 브라우저가 보낸 동명 header는 Nginx 값으로 덮어썼고, 단일 trusted proxy IP와 upstream internal-token 검증도 함께 두었습니다. 운영에서는 shared secret을 secret manager에서 주입하고 교체하거나 mTLS·workload identity로 더 강하게 묶어야 합니다. 현재는 user와 email만 전달하므로 role이나 다른 claim이 필요하면 allowlist와 직렬화 규칙, 크기 제한, upstream 검증 계약을 새로 정해야 합니다.
<!-- techviz:begin id=ap4-edge-trust-architecture context-sha256=df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371 -->
<!-- techviz:generate id=ap4-edge-trust-architecture -->
![외부 브라우저 zone과 Nginx, oauth2-proxy, Spring upstream이 있는 AP4 deployment path를 나눈 edge trust 아키텍처.](assets/ap4-edge-trust-architecture/ap4-edge-trust-architecture.svg)
<details>
@@ -277,6 +297,7 @@ AP2에서는 refresh token을 server로 옮겼지만 access token은 여전히
</details>
[Editable source](assets/ap4-edge-trust-architecture/ap4-edge-trust-architecture.drawio) · [Grounded VizSpec](.techviz/ap4-edge-trust-architecture/spec.json)
<!-- techviz:end id=ap4-edge-trust-architecture -->
## 선택이 코드와 흐름에 반영되는 방식
@@ -391,12 +412,12 @@ Serialized user는 `InMemoryWebStorage`에 있고 module 변수 `currentUser`도
Callback 처리가 끝나면 브라우저에는 다음 값이 남습니다.
| 위치 | 남는 데이터 | reload 뒤 |
|---|---|---|
| JavaScript memory | `User`, access·refresh·ID token, expiry, profile | 사라짐 |
| Session Storage | redirect transaction용 state와 verifier | callback 완료 뒤 제거되는 것이 계약 |
| Local Storage | 애플리케이션이 쓰지 않음 | 해당 없음 |
| Keycloak origin cookie | IdP SSO 상태가 존재할 수 있음 | AP1 app memory와 별개 |
| 위치 | 남는 데이터 | reload 뒤 |
| ---------------------- | ---------------------------------------------------- | ----------------------------------- |
| JavaScript memory | `User`, access·refresh·ID token, expiry, profile | 사라짐 |
| Session Storage | redirect transaction용 state와 verifier | callback 완료 뒤 제거되는 것이 계약 |
| Local Storage | 애플리케이션이 쓰지 않음 | 해당 없음 |
| Keycloak origin cookie | IdP SSO 상태가 존재할 수 있음 | AP1 app memory와 별개 |
Memory user가 사라진다고 Keycloak SSO까지 로그아웃되는 것은 아닙니다. Reload 뒤 애플리케이션 token 상태를 포기했다는 말과 IdP session을 제거했다는 말은 구분해야 합니다.
@@ -477,14 +498,14 @@ SPA는 이 JSON을 다시 화면용 object로 조립합니다.
**4단계 — 실패와 수명주기를 같은 흐름에서 읽는다**
| 입력 또는 사건 | 최초 거부 지점 | 관측 가능한 결과 | 보장하지 않는 세부 |
|---|---|---|---|
| Bearer 없음 | Spring Security | `/api/me` 401 | exact error body |
| 잘못된 audience | custom audience validator | 401 | UI용 JSON error 모양 |
| 잘못된 issuer | issuer validator | 401 | UI용 JSON error 모양 |
| regular user가 `/api/admin` 호출 | authority decision | 403 | 공통 error envelope |
| callback query의 `error` | oidc-client-ts callback, app catch | unauthenticated UI와 error message | exact provider error schema |
| app memory user 없음 또는 expired | `callProtectedApi()` local guard | network call 없이 login-required JSON | 자동 재로그인 |
| 입력 또는 사건 | 최초 거부 지점 | 관측 가능한 결과 | 보장하지 않는 세부 |
| --------------------------------- | ---------------------------------- | ------------------------------------- | --------------------------- |
| Bearer 없음 | Spring Security | `/api/me` 401 | exact error body |
| 잘못된 audience | custom audience validator | 401 | UI용 JSON error 모양 |
| 잘못된 issuer | issuer validator | 401 | UI용 JSON error 모양 |
| regular user가`/api/admin` 호출 | authority decision | 403 | 공통 error envelope |
| callback query의`error` | oidc-client-ts callback, app catch | unauthenticated UI와 error message | exact provider error schema |
| app memory user 없음 또는 expired | `callProtectedApi()` local guard | network call 없이 login-required JSON | 자동 재로그인 |
SPA는 non-2xx 응답에서도 `response.ok`을 확인하기 전에 `response.json()`을 시도합니다. Spring의 401 body가 비어 있거나 JSON이 아니면 의도한 “보호 API가 401을 반환했다”는 message보다 JSON parse error가 먼저 보일 수 있습니다. Negative E2E는 UI button 경로가 아니라 별도 Node fetch로 status만 확인하므로 이 failure UX는 현재 고정되어 있지 않습니다.
@@ -493,7 +514,9 @@ Refresh와 logout도 서로 다른 효과를 가집니다. Realm은 access token
`automaticSilentRenew=true`도 구성되어 있지만, browser가 실제 expiry를 기다려 silent renewal을 완료하고 새 `User`를 memory에 저장하는 경로는 acceptance test가 아닙니다. Manual refresh helper로 검증하는 것과 app runtime의 automatic renewal을 같은 결과로 간주하지 않습니다.
<!-- techviz:begin id=ap1-browser-bearer-flow context-sha256=df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371 -->
<!-- techviz:generate id=ap1-browser-bearer-flow -->
![브라우저 SPA, Keycloak, Resource Server 사이에서 authorization request, callback, token 교환, Bearer API 호출과 JSON 응답이 이어지는 순서도.](assets/ap1-browser-bearer-flow/ap1-browser-bearer-flow.svg)
<details>
@@ -504,6 +527,7 @@ Refresh와 logout도 서로 다른 효과를 가집니다. Realm은 access token
</details>
[Editable source](assets/ap1-browser-bearer-flow/ap1-browser-bearer-flow.drawio) · [Grounded VizSpec](.techviz/ap1-browser-bearer-flow/spec.json)
<!-- techviz:end id=ap1-browser-bearer-flow -->
### AP2 완주: server의 authorized client가 browser Bearer가 되기까지
@@ -744,20 +768,22 @@ authorization code
**6단계 — AP2의 실패와 공백을 endpoint별로 구분한다**
| 상황 | 현재 경계의 결과 | 확인된 것 | 아직 고정되지 않은 것 |
|---|---|---|---|
| 미인증 `/token/boundary` 또는 `/token/access` | controller 이전 login entry point | UI는 redirect와 401 양쪽을 처리 | exact redirect/401 contract |
| 인증됨, boundary 조회에 authorized client 없음 | boolean false를 담은 200 | controller branch | token 복구 UX |
| 인증됨, access endpoint에 client/token 없음 | 401 | controller status와 reason | exact JSON error body |
| anonymous `/api/me` | 401 | backend test contract | error envelope |
| foreign audience | `invalid_token` validation result | validator test contract | AP2 browser E2E의 401 |
| access token expiry | manager가 refresh 가능한 provider를 가짐 | configuration | real refresh success·failure |
| mediator restart 또는 replica 이동 | process-local state에 영향 | 구현상 저장소 경계 | recovery/failover contract |
| 상황 | 현재 경계의 결과 | 확인된 것 | 아직 고정되지 않은 것 |
| ------------------------------------------------ | ---------------------------------------- | ------------------------------- | ----------------------------- |
| 미인증`/token/boundary` 또는 `/token/access` | controller 이전 login entry point | UI는 redirect와 401 양쪽을 처리 | exact redirect/401 contract |
| 인증됨, boundary 조회에 authorized client 없음 | boolean false를 담은 200 | controller branch | token 복구 UX |
| 인증됨, access endpoint에 client/token 없음 | 401 | controller status와 reason | exact JSON error body |
| anonymous`/api/me` | 401 | backend test contract | error envelope |
| foreign audience | `invalid_token` validation result | validator test contract | AP2 browser E2E의 401 |
| access token expiry | manager가 refresh 가능한 provider를 가짐 | configuration | real refresh success·failure |
| mediator restart 또는 replica 이동 | process-local state에 영향 | 구현상 저장소 경계 | recovery/failover contract |
AP2는 refresh credential을 browser 밖으로 옮깁니다. 하지만 logout 시 session과 authorized client를 함께 삭제하는 code, token-at-rest encryption, shared durable store, handoff replay rejection은 구현되어 있지 않습니다. 따라서 AP2가 refresh token을 브라우저에 보내지 않는다는 점까지만 확인했습니다. 이를 운영 환경에 바로 쓸 수 있다고 말할 수는 없습니다.
<!-- techviz:begin id=ap2-mediator-handoff-flow context-sha256=df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371 -->
<!-- techviz:generate id=ap2-mediator-handoff-flow -->
![브라우저, Spring mediator, authorized-client store, Resource Server 사이에서 AP2_SESSION 요청, access-only 응답, 브라우저 Bearer 호출과 JSON 응답이 이어지는 순서도.](assets/ap2-mediator-handoff-flow/ap2-mediator-handoff-flow.svg)
<details>
@@ -768,6 +794,7 @@ AP2는 refresh credential을 browser 밖으로 옮깁니다. 하지만 logout
</details>
[Editable source](assets/ap2-mediator-handoff-flow/ap2-mediator-handoff-flow.drawio) · [Grounded VizSpec](.techviz/ap2-mediator-handoff-flow/spec.json)
<!-- techviz:end id=ap2-mediator-handoff-flow -->
### AP3 완주: session cookie가 BFF의 downstream Bearer가 되기까지
@@ -984,7 +1011,9 @@ POST X-XSRF-TOKEN = same raw token
이 구분을 놓치면 “CSRF JSON에서 받은 token을 그대로 header에 복사한다”는 잘못된 구현 설명이 됩니다. 실제 SPA의 data source는 cookie입니다.
<!-- techviz:begin id=ap3-csrf-boundary context-sha256=df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371 -->
<!-- techviz:generate id=ap3-csrf-boundary -->
![BFF CSRF endpoint가 raw XSRF cookie와 masked JSON token으로 분기하고, SPA가 raw cookie만 실제 POST header 값으로 사용해 Spring CSRF filter에 제출하는 데이터 흐름.](assets/ap3-csrf-boundary/ap3-csrf-boundary.svg)
<details>
@@ -995,6 +1024,7 @@ POST X-XSRF-TOKEN = same raw token
</details>
[Editable source](assets/ap3-csrf-boundary/ap3-csrf-boundary.drawio) · [Grounded VizSpec](.techviz/ap3-csrf-boundary/spec.json)
<!-- techviz:end id=ap3-csrf-boundary -->
**5단계 — form input이 process-global preference가 되기까지**
@@ -1034,19 +1064,21 @@ Controller보다 먼저 Spring CSRF filter가 repository의 expected token과 su
**6단계 — CSRF와 SameSite 실패를 별도 방어선으로 읽는다**
| 입력 | Cookie 동작 | CSRF 동작 | 결과 |
|---|---|---|---|
| same-origin, AP3 session, CSRF header 없음 | session cookie 첨부 | filter가 token 부재 거부 | 403 |
| same-origin, matching raw cookie/header | session cookie 첨부 | token 일치 | controller 200 |
| 다른 port지만 same-site인 요청, header 없음 | session cookie가 실릴 수 있음 | token 부재 거부 | 403 |
| `127.0.0.1`에서 `localhost`로 cross-site POST | SameSite=Lax로 `AP3_SESSION`이 request header에서 제외되는지 확인 | 이 test는 이후 server 처리를 고정하지 않음 | 최종 status/body가 아니라 cookie omission이 acceptance point |
| 입력 | Cookie 동작 | CSRF 동작 | 결과 |
| ------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------ | ------------------------------------------------------------ |
| same-origin, AP3 session, CSRF header 없음 | session cookie 첨부 | filter가 token 부재 거부 | 403 |
| same-origin, matching raw cookie/header | session cookie 첨부 | token 일치 | controller 200 |
| 다른 port지만 same-site인 요청, header 없음 | session cookie가 실릴 수 있음 | token 부재 거부 | 403 |
| `127.0.0.1`에서 `localhost`로 cross-site POST | SameSite=Lax로`AP3_SESSION`이 request header에서 제외되는지 확인 | 이 test는 이후 server 처리를 고정하지 않음 | 최종 status/body가 아니라 cookie omission이 acceptance point |
SameSite는 cookie의 cross-site 전송을 제한하는 browser 정책이고 CSRF token은 state-changing request의 의도를 server가 검증하는 application protocol입니다. Port가 달라도 site 계산상 같은 경우가 있으므로 둘은 서로 대체할 수 없습니다.
JavaScript가 OAuth token을 받지 않는다고 XSS가 무해해지는 것도 아닙니다. Same-origin 악성 script는 피해자 session으로 BFF endpoint를 호출하고 readable XSRF cookie도 읽을 수 있습니다. AP3가 줄이는 것은 access·refresh token 원문이 browser script에서 유출되어 다른 client나 직접 API 호출에 재사용되는 반경입니다. CSP, output encoding, dependency integrity와 application authorization은 여전히 별도 방어선입니다.
<!-- techviz:begin id=ap3-bff-session-flow context-sha256=df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371 -->
<!-- techviz:generate id=ap3-bff-session-flow -->
![브라우저, BFF, authorized-client store, Resource Server 사이에서 AP3_SESSION 요청, server-held token 조회, downstream Bearer 호출과 중계 JSON이 이어지는 순서도.](assets/ap3-bff-session-flow/ap3-bff-session-flow.svg)
<details>
@@ -1057,6 +1089,7 @@ JavaScript가 OAuth token을 받지 않는다고 XSS가 무해해지는 것도
</details>
[Editable source](assets/ap3-bff-session-flow/ap3-bff-session-flow.drawio) · [Grounded VizSpec](.techviz/ap3-bff-session-flow/spec.json)
<!-- techviz:end id=ap3-bff-session-flow -->
### AP4 완주: proxy session이 trusted identity JSON이 되기까지
@@ -1081,14 +1114,14 @@ auth_request /oauth2/auth;
`location = /oauth2/auth``internal`입니다. Nginx가 만드는 subrequest만 들어갈 수 있고 browser가 같은 URL을 직접 호출하면 정상 auth endpoint로 사용할 수 없습니다. Subrequest는 body를 보내지 않고 `Content-Length`를 비웁니다. 대신 원래 요청의 문맥을 header로 바꿉니다.
| Nginx가 만드는 auth input | 값의 출처 |
|---|---|
| `X-Original-URL` | scheme, host와 original request URI |
| `X-Real-IP` | client address |
| `X-Forwarded-For` | proxy chain |
| `X-Forwarded-Host` | original host |
| `X-Forwarded-Proto` | original scheme |
| `X-Forwarded-Uri` | original request URI |
| Nginx가 만드는 auth input | 값의 출처 |
| --------------------------- | --------------------------------------- |
| `X-Original-URL` | scheme, host와 original request URI |
| `X-Real-IP` | client address |
| `X-Forwarded-For` | proxy chain |
| `X-Forwarded-Host` | original host |
| `X-Forwarded-Proto` | original scheme |
| `X-Forwarded-Uri` | original request URI |
| `Cookie: AP4_SESSION=...` | browser에 cookie가 있을 때 원래 request |
미인증 상태에서 oauth2-proxy의 auth endpoint가 401을 반환하면 general `/` location은 `@oauth2_signin`으로 이동해 다음 redirect를 만듭니다.
@@ -1235,14 +1268,14 @@ AP1·AP2·AP3의 Resource Server는 JWT의 issuer와 audience를 직접 확인
**5단계 — AP4의 401, 302와 404는 경로별로 다르다**
| 외부 입력 | 인증 상태 | 최초 결정 지점 | 결과 |
|---|---|---|---|
| `GET /` | 미인증 | general location의 auth 401 error page | `/oauth2/start`로 302 |
| `GET /api/edge` | 미인증 | exact API location의 auth 401 error page | redirect 없는 401 `{"error":"authentication required"}` |
| `GET /oauth2/auth` | 무관 | `internal` exact location | 외부에서는 404 |
| `GET /` + spoofed identity headers | 정상 AP4 session | Nginx header overwrite | 실제 authenticated user로 200 |
| internal `/edge/me` + user header만 | edge token 없음 | controller | 401 trusted-edge error |
| internal `/edge/me` + wrong token | token mismatch | controller | 401 trusted-edge error |
| 외부 입력 | 인증 상태 | 최초 결정 지점 | 결과 |
| ------------------------------------ | ---------------- | ---------------------------------------- | -------------------------------------------------------- |
| `GET /` | 미인증 | general location의 auth 401 error page | `/oauth2/start`로 302 |
| `GET /api/edge` | 미인증 | exact API location의 auth 401 error page | redirect 없는 401`{"error":"authentication required"}` |
| `GET /oauth2/auth` | 무관 | `internal` exact location | 외부에서는 404 |
| `GET /` + spoofed identity headers | 정상 AP4 session | Nginx header overwrite | 실제 authenticated user로 200 |
| internal`/edge/me` + user header만 | edge token 없음 | controller | 401 trusted-edge error |
| internal`/edge/me` + wrong token | token mismatch | controller | 401 trusted-edge error |
Redirect 없는 JSON 401은 정확히 `/api/edge` 예시 path에만 구성되어 있습니다. “AP4의 모든 API path가 JSON 401을 반환한다”고 일반화하면 안 됩니다. 다른 path는 현재 general location의 login redirect 규칙을 따릅니다.
@@ -1262,7 +1295,9 @@ App과 oauth2-proxy의 host port가 닫혀 있다는 사실도 중요합니다.
AP4가 authentication gate를 중앙화했다고 application authorization까지 자동으로 완성되는 것은 아닙니다. 현재 `/edge/me`도 role decision을 하지 않습니다.
<!-- techviz:begin id=ap4-edge-forward-auth-flow context-sha256=df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371 -->
<!-- techviz:generate id=ap4-edge-forward-auth-flow -->
![브라우저, Nginx, oauth2-proxy, Spring upstream 사이에서 AP4_SESSION 검증, identity header 덮어쓰기, internal token 검증과 JSON 응답이 이어지는 순서도.](assets/ap4-edge-forward-auth-flow/ap4-edge-forward-auth-flow.svg)
<details>
@@ -1273,6 +1308,7 @@ AP4가 authentication gate를 중앙화했다고 application authorization까지
</details>
[Editable source](assets/ap4-edge-forward-auth-flow/ap4-edge-forward-auth-flow.drawio) · [Grounded VizSpec](.techviz/ap4-edge-forward-auth-flow/spec.json)
<!-- techviz:end id=ap4-edge-forward-auth-flow -->
### Google login이 들어와도 네 애플리케이션 경계는 바뀌지 않는다
@@ -1304,12 +1340,12 @@ AP1 Resource Server가 신뢰하는 issuer도 Keycloak이고, AP2·AP3가 교환
아래 표는 최신 실행 성적표가 아니라 커밋된 자동 테스트가 확인하도록 정의한 acceptance contract입니다. Pattern별 verify flow는 stack을 다시 만들기 전에 Docker volume을 삭제하므로, 보존해야 할 local realm과 database가 있는 환경에서 그대로 실행해서는 안 됩니다.
| 패턴 | 테스트가 만드는 핵심 입력 | 기대 output | 지키려는 경계 |
|---|---|---|---|
| AP1 | S256 authorization request, 실제 login, Bearer `/api/me`, 동일 정상 JWT를 expected issuer·audience가 다른 diagnostic server에 제출 | 정상 200, diagnostic server 401, runtime fetch hook에서 access token 관측, persistent Web Storage에 access token 없음 | Browser가 token owner라는 사실과 Resource Server validation |
| AP2 | Login session으로 boundary/access GET, 반환 token으로 direct API GET | server access·refresh booleans true, refresh field 없음, access JSON 세 field, `no-store`, API 200 | Refresh custody는 server, access credential은 browser |
| AP3 | Session-only `/bff/api/me`, CSRF 없는 POST, matching header POST, cross-site POST | browser token count 0, downstream JSON 200, 403/200 분리, SameSite cookie omission | BFF token custody와 cookie-authenticated state-change protection |
| AP4 | Cookie 없는 `/``/api/edge`, 정상 session, spoofed headers, external auth endpoint, direct app/proxy ports | root 302, exact API 401, 실제 user 200, auth endpoint 404, internal ports inaccessible | Edge만 trusted identity input을 만들 수 있는 path |
| 패턴 | 테스트가 만드는 핵심 입력 | 기대 output | 지키려는 경계 |
| ---- | ------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| AP1 | S256 authorization request, 실제 login, Bearer`/api/me`, 동일 정상 JWT를 expected issuer·audience가 다른 diagnostic server에 제출 | 정상 200, diagnostic server 401, runtime fetch hook에서 access token 관측, persistent Web Storage에 access token 없음 | Browser가 token owner라는 사실과 Resource Server validation |
| AP2 | Login session으로 boundary/access GET, 반환 token으로 direct API GET | server access·refresh booleans true, refresh field 없음, access JSON 세 field,`no-store`, API 200 | Refresh custody는 server, access credential은 browser |
| AP3 | Session-only`/bff/api/me`, CSRF 없는 POST, matching header POST, cross-site POST | browser token count 0, downstream JSON 200, 403/200 분리, SameSite cookie omission | BFF token custody와 cookie-authenticated state-change protection |
| AP4 | Cookie 없는`/``/api/edge`, 정상 session, spoofed headers, external auth endpoint, direct app/proxy ports | root 302, exact API 401, 실제 user 200, auth endpoint 404, internal ports inaccessible | Edge만 trusted identity input을 만들 수 있는 path |
### AP1 검증을 단계별로 읽는 법
@@ -1409,12 +1445,12 @@ Spoofing test는 authenticated browser가 `X-Auth-Request-User: spoofed-admin`,
네 패턴을 모두 실행하고 나니 AP1에서 AP4로 갈수록 브라우저에 OAuth token이 덜 보이는 것은 맞았습니다. 처음에는 번호가 높을수록 더 나은 패턴처럼 보였습니다. 그런데 token을 브라우저에서 치울 때마다 그 일을 다른 곳이 맡았습니다. AP3에는 server session과 CSRF가 생겼고, AP4에는 proxy session과 identity header를 믿을 조건이 생겼습니다. 그래서 번호 순서 대신 브라우저와 BFF, edge 중 누가 token과 session을 관리하는지로 비교했습니다.
| 패턴 | 얻는 것 | 잃거나 추가하는 것 | 잘 맞는 조건 | 피해야 할 조건 |
|---|---|---|---|---|
| AP1 | protocol 가시성, stateless Resource Server, direct API | browser token lifecycle, XSS 시 token·권한 악용, reload state 포기 | public SPA가 API를 직접 불러야 하고 token-in-browser를 수용 | browser token 자체가 정책상 금지 |
| AP2 | client secret·refresh token server custody, 기존 Bearer API 유지 | access token 노출과 server state를 동시에 운영 | direct browser-to-API가 실제 요구이며 refresh credential만 분리 | one-time handoff나 tokenless browser가 요구 |
| AP3 | OAuth token 비노출, application-owned fan-out과 session | CSRF, shared session/token store, BFF latency와 장애 지점 | backend가 API composition과 사용자 session을 소유 | stateless direct API와 독립 client가 핵심 |
| AP4 | OAuth 비인지 upstream 앞의 공통 login gate | proxy session, network·header trust, claim projection 계약 | 기존 upstream 변경이 어렵고 edge policy를 강제 가능 | backend direct path나 header overwrite를 닫을 수 없음 |
| 패턴 | 얻는 것 | 잃거나 추가하는 것 | 잘 맞는 조건 | 피해야 할 조건 |
| ---- | ----------------------------------------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------------------------- |
| AP1 | protocol 가시성, stateless Resource Server, direct API | browser token lifecycle, XSS 시 token·권한 악용, reload state 포기 | public SPA가 API를 직접 불러야 하고 token-in-browser를 수용 | browser token 자체가 정책상 금지 |
| AP2 | client secret·refresh token server custody, 기존 Bearer API 유지 | access token 노출과 server state를 동시에 운영 | direct browser-to-API가 실제 요구이며 refresh credential만 분리 | one-time handoff나 tokenless browser가 요구 |
| AP3 | OAuth token 비노출, application-owned fan-out과 session | CSRF, shared session/token store, BFF latency와 장애 지점 | backend가 API composition과 사용자 session을 소유 | stateless direct API와 독립 client가 핵심 |
| AP4 | OAuth 비인지 upstream 앞의 공통 login gate | proxy session, network·header trust, claim projection 계약 | 기존 upstream 변경이 어렵고 edge policy를 강제 가능 | backend direct path나 header overwrite를 닫을 수 없음 |
### AP1을 적용하거나 떠날 기준
@@ -1461,7 +1497,9 @@ AP3에서 AP4로 옮기는 일은 한 단계 업그레이드가 아니었습니
반대로 AP4의 upstream이 더 많은 claim과 애플리케이션 흐름을 요구하기 시작하면 BFF로 돌아갈 수 있었습니다. Header 종류를 계속 늘리는 것보다 API 조합 책임을 애플리케이션에 돌려주는 편이 명확할 수 있었습니다. 저는 어느 쪽으로 옮길지를 번호로 판단하지 않았습니다. 새로 일을 맡는 곳이 state와 검증을 감당할 수 있는지를 보았습니다.
<!-- techviz:begin id=credential-contract-migration context-sha256=df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371 -->
<!-- techviz:generate id=credential-contract-migration -->
![AP1에서 AP2, AP2에서 AP3, AP3에서 AP4, AP4에서 AP3로 이동할 때 호출 계약, 소유권, 브라우저 계약, 운영 책임과 전환 성격을 같은 다섯 축으로 비교한 네 항목.](assets/credential-contract-migration/credential-contract-migration.svg)
<details>
@@ -1472,6 +1510,7 @@ AP3에서 AP4로 옮기는 일은 한 단계 업그레이드가 아니었습니
</details>
[Editable source](assets/credential-contract-migration/credential-contract-migration.drawio) · [Grounded VizSpec](.techviz/credential-contract-migration/spec.json)
<!-- techviz:end id=credential-contract-migration -->
## 결국 지키려던 것은 무엇이었나