1494 lines
116 KiB
Markdown
1494 lines
116 KiB
Markdown
# 브라우저 토큰에서 엣지 세션까지: Keycloak 인증 패턴 네 가지의 경계 설계
|
||
|
||
## 코드보다 먼저 드러난 문제
|
||
|
||
처음 네 예제를 실행했을 때는 모두 같은 화면으로 끝났습니다. 브라우저에서 보호된 페이지를 열고 Keycloak에 `regular-user`로 로그인하자 애플리케이션이 다음과 같은 사용자 JSON을 보여 주었습니다.
|
||
|
||
```json
|
||
{
|
||
"subject": "<keycloak-user-id>",
|
||
"username": "regular-user"
|
||
}
|
||
```
|
||
|
||
여기까지만 보고 처음에는 Keycloak 연결 방법이 네 가지라고 생각했습니다. 그런데 브라우저의 network 요청을 처음부터 따라가 보니 code와 token이 머무는 곳이 서로 달랐습니다. Authorization code는 로그인을 마친 사용자가 애플리케이션으로 돌아올 때 잠시 들고 오는 교환용 값입니다. 애플리케이션은 이 값을 API 호출에 쓸 access token으로 바꿉니다. 제가 먼저 확인한 것은 이 교환과 API 호출을 누가 맡는지였습니다.
|
||
|
||
- AP1에서는 브라우저가 authorization code를 token으로 바꾼 뒤 `Authorization: Bearer ...` header를 직접 만들어 API를 불렀습니다.
|
||
- AP2에서는 Spring mediator가 code를 교환하고 refresh token을 보관했습니다. 대신 현재 access token은 JSON으로 브라우저에 건넸고, 브라우저가 다시 Bearer header를 만들어 API를 불렀습니다.
|
||
- AP3에서는 BFF가 code 교환과 token 보관에 이어 API 호출까지 맡았습니다. BFF는 브라우저가 보낸 session cookie를 확인하고 Resource Server가 이해하는 Bearer 요청을 새로 만들었습니다.
|
||
- AP4에서는 oauth2-proxy가 로그인을 처리하고 Nginx가 session이 유효한지 확인했습니다. 확인을 마치면 Nginx가 사용자와 이메일 header를 만들어 Spring upstream에 보냈습니다.
|
||
|
||
여기서부터 패턴 이름만 보고 구조를 고르면 안 되겠다고 생각했습니다. 브라우저에서 token을 없애려고 BFF를 넣어도 session store와 CSRF 방어를 준비하지 않으면 다른 문제가 생겼습니다. 기존 애플리케이션을 거의 고치지 않으려고 forward-auth를 붙인 뒤에는 외부에서 위조할 수 있는 identity header를 그대로 믿을 수 있었습니다. 반대로 브라우저가 API를 직접 불러야 하는데 token을 숨기겠다는 이유만으로 BFF를 추가하면 전에는 없던 server state와 장애 지점이 생겼습니다.
|
||
|
||
그래서 저는 패턴 이름보다 요청 하나를 먼저 보기로 했습니다.
|
||
|
||
> 최초 HTTP 입력은 무엇인가? 그 입력을 어느 코드가 받는가? code·token·cookie·header는 어느 지점에서 다른 데이터로 변환되는가? 다음 홉은 무엇을 입력으로 받고, 최종 HTTP 출력은 누가 만드는가?
|
||
|
||
같은 질문을 로그인할 때와 로그인 뒤 API를 부를 때 각각 던졌습니다. OAuth client 이름만 확인해서는 부족했습니다. 실제 endpoint와 메서드, 중간에 생기는 데이터, 성공 응답과 실패 응답까지 따라가야 했습니다. 그렇게 살펴보니 번호가 높다고 더 안전한 구조는 아니었습니다. 실제로 달랐던 것은 code를 token으로 바꾸는 곳, token을 보관하는 곳, API를 부르는 곳이었습니다.
|
||
|
||
## 문제를 어렵게 만든 제약
|
||
|
||
### 로그인 흐름과 API 흐름은 같은 선이 아니다
|
||
|
||
처음에는 로그인 버튼을 한 번 누르니 요청도 한 번만 오갈 것이라고 생각했습니다. 실제로는 먼저 브라우저가 Keycloak으로 이동했고, 로그인이 끝나면 authorization code를 들고 redirect URI로 돌아왔습니다. 아직 이 code로 API를 부를 수는 없었습니다. OAuth client가 code를 token endpoint에 제출해야 access token을 받을 수 있었습니다.
|
||
|
||
이 과정을 따라가면서 PKCE가 어디에 쓰이는지도 확인했습니다. PKCE는 code 교환을 보호하는 장치입니다. 처음 Keycloak으로 갈 때 `code_challenge`를 보내고, code를 token으로 바꿀 때 원본인 `code_verifier`를 냅니다. 둘이 맞아야 교환이 끝납니다. Confidential client라면 여기에 client 인증도 더합니다.
|
||
|
||
그런데 로그인이 끝났다고 API 요청까지 같은 곳에서 처리되는 것은 아니었습니다. AP2에서는 mediator가 token을 받았지만 API는 브라우저가 불렀습니다. AP3에서는 BFF가 두 일을 모두 맡았습니다. AP4에서는 oauth2-proxy가 code 교환과 `AP4_SESSION` 검증을 했고, Nginx가 upstream 요청과 identity header를 만들었습니다.
|
||
|
||
그래서 `Browser → Keycloak → API`처럼 한 줄로 그려서는 흐름을 이해하기 어려웠습니다. 서로 다른 이동이 하나로 뭉쳤기 때문입니다. 저는 code가 오가는 로그인 구간과 credential로 API를 부르는 구간을 따로 나누었습니다.
|
||
|
||
1. **로그인 구간:** authorization request, callback, code 교환, 로그인 상태 생성
|
||
2. **애플리케이션 요청 구간:** 브라우저 입력, 중간 계층의 credential 변환, 보호 자원의 검증, 최종 응답
|
||
|
||
<!-- techviz:begin id=login-api-phase-split context-sha256=df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371 -->
|
||
<!-- techviz:generate id=login-api-phase-split -->
|
||

|
||
|
||
<details>
|
||
<summary>Diagram description</summary>
|
||
|
||
왼쪽 로그인 구간에는 Keycloak과 AP2 mediator, AP3 BFF, AP4 oauth2-proxy가 있습니다. Keycloak의 authorization code는 각 OAuth client 쪽으로 이동합니다. 오른쪽 애플리케이션 요청 구간에는 AP2 브라우저, AP3 BFF, AP4 Nginx와 보호 자원이 있습니다. AP2는 token을 받는 mediator와 API를 호출하는 브라우저가 갈리고, AP3는 같은 BFF가 token 발급과 API 호출을 소유합니다. AP4는 oauth2-proxy가 code 교환과 session 검증을 맡고 Nginx가 upstream 요청 연결과 identity header 조립을 맡습니다.
|
||
|
||
</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를 거쳤다는 추가 신뢰 신호 |
|
||
|
||
예를 들어 access token의 `preferred_username`과 AP4의 `X-Auth-Request-User`에는 모두 `regular-user`가 들어갈 수 있었습니다. 화면에서 보이는 이름은 같지만 실제 검증 방식은 다릅니다. Resource Server는 JWT의 서명과 issuer, audience를 확인했습니다. AP4 upstream은 요청이 신뢰할 수 있는 edge를 거쳤는지, 내부 인증값도 맞는지 확인했습니다.
|
||
|
||
### “브라우저에 없다”도 무엇이 없는지 구분해야 한다
|
||
|
||
AP3와 AP4를 처음 보았을 때는 JavaScript가 OAuth token을 받지 않으니 브라우저에 인증 상태가 없다고 생각하기 쉬웠습니다. 실제 브라우저를 확인해 보니 HttpOnly session cookie가 남았고 요청할 때마다 자동으로 붙었습니다. HttpOnly는 JavaScript가 cookie 값을 직접 읽지 못하게 하는 속성입니다. AP3에서는 상태를 바꾸는 요청을 확인하려고 JavaScript가 읽을 수 있는 `XSRF-TOKEN`도 따로 썼습니다. Keycloak 도메인의 SSO cookie도 존재할 수 있었습니다.
|
||
|
||
그래서 이 글에서 “브라우저에 없다”는 표현은 애플리케이션이 사용하는 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 -->
|
||

|
||
|
||
<details>
|
||
<summary>Diagram description</summary>
|
||
|
||
왼쪽부터 AP1, AP2, AP3, AP4를 읽습니다. 각 항목은 OAuth credential 소유자, 브라우저에 남는 애플리케이션 credential, 보관 모델, shared durability나 replica 운영에 관한 현재 근거를 같은 순서로 제시합니다. AP1의 access·refresh·ID token은 실행 중 JavaScript memory에 있고 persistent Web Storage 복사본만 줄입니다. AP2는 refresh token을 mediator가 소유하고 server session 식별 cookie를 사용하지만 shared durable store는 입증되지 않았습니다. AP3는 refresh token을 BFF가 소유하며 브라우저에는 HttpOnly session과 readable XSRF token이 남습니다. AP4는 minimal client-side proxy session을 사용하고 replica cookie secret 공유와 rotation은 검증되지 않았습니다.
|
||
|
||
</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 -->
|
||
|
||
### 현재 구현은 운영 참조 아키텍처가 아니라 관찰 가능한 학습 환경이다
|
||
|
||
제가 확인한 예제는 Keycloak 26.7.0과 oauth2-proxy 7.15.2를 한 대에서 실행하는 학습 환경이었습니다. Cookie 속성과 redirect를 눈으로 확인하려고 HTTPS가 아닌 HTTP를 쓰는 설정도 있었습니다. 코드를 실행해 보았다고 운영 환경까지 검증한 것은 아니었습니다.
|
||
|
||
특히 AP2와 AP3가 session과 authorized client를 여러 인스턴스에서 공유하는지는 확인하지 못했습니다. AP4는 별도 server-side session store 대신 최소 정보만 담은 client-side cookie를 썼습니다. 여러 replica가 같은 cookie secret을 어떻게 공유하고 바꿀지, 재인증은 어떻게 이어 갈지도 아직 검증하지 않았습니다.
|
||
|
||
그래서 문서에는 제가 코드와 테스트로 확인한 범위를 따로 적었습니다.
|
||
|
||
- 어느 endpoint와 handler가 요청을 받는가
|
||
- 어느 계층이 code를 교환하고 access·refresh token을 보유하는가
|
||
- 브라우저에 어떤 cookie 또는 token 응답이 도달하는가
|
||
- API로 전달되는 header와 최종 JSON 모양은 무엇인가
|
||
- 커밋된 자동 테스트가 어떤 acceptance contract를 선언하는가
|
||
|
||
처리량과 장애 복구 시간, session failover, secret rotation 절차는 이 목록에 넣지 못했습니다. 실제 Google 계정과 public HTTPS redirect가 성공하는지도 증명하지 않았습니다. 그래서 `BFF`나 `forward-auth`라는 이름만 보고 이런 운영 속성까지 갖췄다고 판단하지 않았습니다. 네 패턴은 실제로 확인한 범위 안에서만 비교했습니다.
|
||
|
||
## 검토한 선택지와 막힌 지점
|
||
|
||
### 책임과 데이터를 같은 표에 놓기
|
||
|
||
처음에는 네 패턴을 설명하는 용어부터 비교했습니다. 그런데 용어만 나란히 놓으니 실제로 누가 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 |
|
||
|
||
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 |
|
||
|
||
<!-- techviz:begin id=four-pattern-request-boundaries context-sha256=df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371 -->
|
||
<!-- techviz:generate id=four-pattern-request-boundaries -->
|
||

|
||
|
||
<details>
|
||
<summary>Diagram description</summary>
|
||
|
||
왼쪽부터 AP1 SPA direct, AP2 token mediator, AP3 BFF, AP4 edge forward-auth를 읽습니다. 각 항목은 브라우저가 보내는 입력, 중간 계층의 변환, 보호 자원이 실제로 받는 credential, 브라우저가 받는 출력을 같은 순서로 보여 줍니다. AP1과 AP2는 브라우저가 Bearer JWT로 API를 직접 호출하고, AP3는 BFF가 Bearer JWT를 조립하며, AP4는 Nginx가 인증 결과를 identity header와 internal token으로 바꿉니다.
|
||
|
||
</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
|
||
|
||
AP1을 처음 따라가 보니 브라우저와 Resource Server 사이를 대신해 줄 server가 없었습니다. 덕분에 authorization code가 token으로 바뀌고 API 요청에 들어가는 모습을 가장 가까이서 볼 수 있었습니다. 대신 verifier와 access·refresh token, logout 요청도 모두 JavaScript가 실행되는 곳을 지났습니다.
|
||
|
||
`InMemoryWebStorage`라는 이름도 이때 확인했습니다. Token을 브라우저의 영구 저장소가 아니라 실행 중 memory에만 두는 방식이었습니다. 새로고침 뒤 로그인 상태가 사라지는 대신 Web Storage에 오래 남는 복사본을 줄였습니다. 하지만 실행 중인 악성 script가 Bearer token을 보거나 사용자 권한으로 API를 부르는 문제까지 없어지지는 않았습니다. PKCE도 훔친 authorization code의 교환을 어렵게 할 뿐, 이미 발급된 access token을 XSS에서 숨겨 주지는 않았습니다.
|
||
|
||
Local Storage나 Session Storage에 token을 저장하면 새로고침은 편해지지만 노출 시간도 길어졌습니다. HttpOnly cookie로 옮기는 일도 저장 위치만 바꾸면 끝나는 작업이 아니었습니다. Server가 session이나 token 중계를 맡는 AP3 계열 구조가 필요했습니다. 그래서 저는 OAuth 흐름을 직접 관찰할 AP1을 남겼습니다. 대신 access token 수명을 짧게 두고 refresh rotation과 issuer·audience 검증을 함께 보았습니다.
|
||
|
||
### AP2에서 막히는 지점: access-only이지만 tokenless는 아니다
|
||
|
||
AP1을 보고 나니 refresh token만이라도 브라우저 밖으로 옮기면 어떨지 궁금했습니다. AP2에서는 Spring mediator가 client secret과 refresh token을 보관했고, 브라우저는 전처럼 Bearer header로 API를 직접 불렀습니다. 그런데 이 방식을 쓰려면 브라우저가 mediator 로그인에 쓰는 `AP2_SESSION`과 mediator가 돌려준 access token을 함께 다뤄야 했습니다. Server state를 운영하는 비용과 JavaScript에 access token이 보이는 문제가 둘 다 남았습니다.
|
||
|
||
처음에는 이 동작을 one-time handoff라고 부를 수 있을지 살펴보았습니다. 하지만 `/token/access`는 access token 원문과 token type, 만료 시각을 같은 session에 여러 번 반환할 수 있었습니다. 한 번 쓴 요청을 구분하는 nonce도 없었고, 전달한 token을 바로 지우지도 않았습니다. 그래서 이 예제가 보장하는 범위를 **access-only handoff**라고 적었습니다. Refresh token을 응답에서 빼는 것만 확인했을 뿐, access token을 한 번만 주는 기능은 없었습니다.
|
||
|
||
### AP3에서 막히는 지점: tokenless browser가 만드는 stateful backend
|
||
|
||
AP3를 실행했을 때는 JavaScript 응답에서 OAuth token이 보이지 않았습니다. 처음에는 token을 다루는 일도 함께 사라진 것처럼 보였습니다. 그런데 BFF 코드를 따라가 보니 실제로는 BFF가 session에서 authorized client를 찾고 access token을 붙여 내부 API를 대신 불렀습니다. BFF는 화면에 필요한 API를 브라우저 대신 조합하는 backend입니다.
|
||
|
||
브라우저는 Bearer header를 만들지 않았지만 session cookie를 요청마다 자동으로 보냈습니다. 그래서 값을 바꾸는 endpoint에는 사용자가 의도한 요청인지 확인할 CSRF token이 필요했습니다. Server 쪽에도 일이 늘었습니다. 재시작 뒤 로그인을 유지할 저장소, 여러 replica가 함께 쓸 session, 저장 token 암호화와 logout을 따로 설계해야 했습니다. 현재 예제는 아직 단일 인스턴스 memory만 사용합니다. 확인을 마치고 보니 `BFF`라는 이름만으로 이런 운영 문제가 해결되는 것은 아니었습니다.
|
||
|
||
### AP4에서 막히는 지점: token 대신 header를 믿는 조건
|
||
|
||
마지막으로 기존 애플리케이션에 OAuth 코드를 넣기 어려운 경우를 보았습니다. AP4에서는 앞단의 proxy가 로그인을 맡았습니다. 로그인에 성공하면 Nginx가 `X-Auth-Request-User`를 만들어 upstream에 보냈습니다. 그런데 upstream은 같은 이름의 header가 브라우저에서 온 것인지, 인증을 마친 edge가 만든 것인지 구분할 수 있어야 했습니다. Backend port가 외부에 열려 있거나 Nginx가 브라우저의 header를 그대로 넘기면 공격자가 인증된 사용자를 흉내 낼 수 있었습니다.
|
||
|
||
현재 예제에서는 이 문제를 세 겹으로 막았습니다. 외부에는 Nginx만 열고 app과 oauth2-proxy는 내부 network에 두었습니다. Nginx는 user·email·internal-token header를 언제나 자신이 만든 값으로 덮어썼습니다. 마지막으로 `/edge/me` controller가 user header와 internal token을 함께 확인했습니다.
|
||
|
||
다만 마지막 확인은 한 controller에만 들어 있었습니다. `/edge/**` 전체를 Spring Security filter가 보호하는 것은 아니었습니다. Endpoint가 늘어나면 같은 검사를 공통 filter나 security chain으로 옮겨야 했습니다.
|
||
|
||
## 선택의 이유와 지킨 경계
|
||
|
||
### AP1: OAuth와 JWT 계약을 가장 가까이서 관찰한다
|
||
|
||
저는 먼저 OAuth의 동작을 브라우저에서 그대로 보고 싶었습니다. 그래서 SPA가 public OAuth client가 되는 AP1부터 만들었습니다. Public client는 브라우저처럼 client secret을 안전하게 숨길 수 없는 애플리케이션입니다. 이 구조에서는 authorization code가 access token으로 바뀌고, 그 token으로 Resource Server를 부르는 과정을 코드와 network에서 함께 볼 수 있었습니다.
|
||
|
||
`spa-public`에는 Authorization Code와 PKCE S256을 사용했습니다. Implicit flow와 direct access grant는 껐습니다. API도 Keycloak 서명만 확인하고 끝내지 않았습니다. Token을 발급한 issuer와 유효 시간, 이 API를 위해 발급됐다는 `keycloak-pattern-api` audience를 함께 확인했습니다. Realm role은 Spring이 이해하는 `ROLE_` authority로 바꾸었습니다.
|
||
|
||
Refresh token만 mediator로 옮기는 AP2나 모든 token을 BFF에 맡기는 AP3도 검토했습니다. 다만 그렇게 하면 브라우저가 code를 교환하고 token의 수명을 관리하는 모습이 가려졌습니다. 저는 그 과정을 보여 주기 위해 AP1의 access·refresh·ID token을 JavaScript memory에 두었습니다. 그 대신 새로고침 뒤에는 인증 상태를 복구하지 못했습니다.
|
||
|
||
대신 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 -->
|
||

|
||
|
||
<details>
|
||
<summary>Diagram description</summary>
|
||
|
||
왼쪽의 public SPA가 Keycloak과 Authorization Code 및 PKCE S256 계약을 수행합니다. Keycloak token 응답의 access, refresh, ID token은 브라우저 JavaScript memory에 놓이며, 그중 access JWT가 오른쪽 Resource Server의 검증 입력이 됩니다. Resource Server는 issuer, 시간 제약과 keycloak-pattern-api audience를 검증합니다.
|
||
|
||
</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 호출은 브라우저에 둔다
|
||
|
||
AP1을 만들고 나니 refresh token까지 JavaScript memory에 있다는 점이 계속 걸렸습니다. 이 장기 credential은 server로 옮기되 브라우저가 Resource Server를 직접 부르는 방식은 남기고 싶었습니다. 그래서 AP2에는 confidential mediator를 두었습니다. Confidential client는 client secret을 server에서 보관할 수 있는 애플리케이션입니다.
|
||
|
||
Mediator는 Spring `oauth2Login`으로 code를 교환한 뒤 access·refresh token을 server-side authorized-client service에 저장했습니다. 브라우저에는 HttpOnly `AP2_SESSION`을 남기고, API를 부를 때 필요한 현재 access token만 별도 응답으로 주었습니다. 응답 필드는 `access_token`, `token_type`, `expires_at` 세 개로 제한했고 `Cache-Control: no-store`와 `Pragma: no-cache`도 붙였습니다. 브라우저는 이 값을 memory에서 읽어 Bearer header를 만들었습니다.
|
||
|
||
구현을 마치고 보니 이 선택은 두 비용을 함께 남겼습니다. Mediator session과 authorized-client 저장소를 운영해야 했고 access token은 여전히 JavaScript에 보였습니다. Server state를 둘 수 없다면 AP1이 더 단순했습니다. 브라우저에 access token까지 보내면 안 된다면 AP3가 더 직접적이었습니다.
|
||
|
||
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 -->
|
||

|
||
|
||
<details>
|
||
<summary>Diagram description</summary>
|
||
|
||
브라우저는 AP2_SESSION으로 confidential Spring mediator의 login state를 사용합니다. Mediator는 code를 교환한 뒤 access와 refresh token을 server-side authorized-client service에 저장합니다. 현재 access token의 값, type, expiry만 브라우저로 전달되고 refresh token은 server 경계에 남습니다. 브라우저는 memory에 있는 access token으로 Resource Server용 Bearer header를 만듭니다.
|
||
|
||
</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을 맞바꾼다
|
||
|
||
AP2에서는 refresh token을 server로 옮겼지만 access token은 여전히 브라우저 응답에 있었습니다. 이것까지 없애려면 server가 code 교환뿐 아니라 API 호출도 대신해야 했습니다. 그래서 AP3에서는 BFF가 그 역할을 맡게 했습니다. BFF는 화면에 필요한 API를 대신 호출하고 결과만 브라우저에 돌려주는 backend입니다.
|
||
|
||
`bff-confidential` client와 Spring `oauth2Login`이 code를 교환하면 access·refresh token은 server-side authorized client에 남았습니다. 브라우저에는 HttpOnly `AP3_SESSION`만 두었습니다. 브라우저가 `/bff/api/me`를 부르면 BFF가 현재 authorized client를 찾아 내부 Resource Server용 Bearer 요청을 새로 만들었습니다. 값을 바꾸는 요청에서는 `XSRF-TOKEN` cookie의 값을 `X-XSRF-TOKEN` header로 돌려보내게 했고 server가 둘이 맞는지 확인했습니다.
|
||
|
||
이렇게 하니 JavaScript에 OAuth token을 주지 않아도 되었습니다. 그런데 BFF가 stateful security component가 되었습니다. Session affinity나 shared store를 운영해야 했고, 모든 API 호출이 BFF를 거치면서 생기는 지연과 장애도 다뤄야 했습니다. CSRF와 logout, 저장 token 보호도 BFF의 일이 되었습니다. 현재 구현은 이 비용을 단일 인스턴스 memory와 한 개의 BFF 호출로만 보여 줍니다. Redis나 암호화 저장소까지 구현한 것은 아닙니다.
|
||
|
||
이 비교를 통해 stateless Resource Server와 OAuth 흐름을 직접 보는 일이 더 중요하면 AP1이 맞다고 판단했습니다. 브라우저의 직접 API 호출을 남겨야 한다면 AP2를 선택할 수 있었습니다.
|
||
|
||
<!-- techviz:begin id=ap3-bff-architecture context-sha256=df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371 -->
|
||
<!-- techviz:generate id=ap3-bff-architecture -->
|
||

|
||
|
||
<details>
|
||
<summary>Diagram description</summary>
|
||
|
||
왼쪽 browser session zone에는 OAuth token 없이 HttpOnly AP3_SESSION을 가진 브라우저가 있습니다. 오른쪽 server-side application zone에는 BFF, authorized-client store, 내부 Resource Server가 있습니다. 브라우저의 /bff/api/me 요청은 BFF에서 종료되고, BFF는 current authorized client를 조회해 server-held access token으로 Resource Server용 Bearer 요청을 조립합니다.
|
||
|
||
</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 앞에서 신뢰 경로를 만든다
|
||
|
||
마지막으로 기존 upstream에 OAuth client나 JWT 검증 코드를 넣기 어려운 경우를 다루었습니다. 여러 경로 앞에서 같은 로그인 정책을 적용하려면 edge가 인증을 대신해야 했습니다. AP4에서는 confidential `edge-proxy` client와 oauth2-proxy가 OIDC code 교환과 session 검증을 맡게 했고, Nginx가 인증 결과를 upstream 요청에 연결하게 했습니다.
|
||
|
||
브라우저가 요청하면 Nginx는 `auth_request`로 oauth2-proxy에 session이 유효한지 먼저 물었습니다. 성공하면 oauth2-proxy가 확인한 사용자와 이메일만 골라 upstream에 보냈습니다. Forward-auth는 이렇게 실제 요청을 넘기기 전에 별도의 인증 endpoint에 허용 여부를 묻는 방식입니다.
|
||
|
||
애플리케이션이 사용자별 API를 조합하고 세밀한 인가도 직접 맡아야 한다면 AP3가 더 자연스러웠습니다. Traefik ForwardAuth도 인증 판단을 맡길 수 있었지만 OIDC client나 session manager 자체는 아니었습니다. 현재 대안 설정에는 hardened upstream에 필요한 internal token 주입도 없었습니다. 저는 `auth_request`, 401 처리, header 추출과 덮어쓰기를 한 파일에서 확인할 수 있어서 Nginx를 사용했습니다.
|
||
|
||
대신 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 -->
|
||

|
||
|
||
<details>
|
||
<summary>Diagram description</summary>
|
||
|
||
왼쪽 외부 브라우저가 AP4_SESSION과 함께 AP4 deployment path의 공개 Nginx entry point를 호출합니다. 같은 deployment path에서 Nginx는 host 비공개 oauth2-proxy의 internal /oauth2/auth endpoint에 subrequest를 보내 session을 검증하고 user와 email 결과를 받습니다. 이어서 client가 보낸 동명 header를 덮어쓰고 Nginx-owned identity header와 internal token을 host 비공개 Spring upstream의 /edge/me로 전달합니다. Upstream은 user header와 internal token을 함께 확인하며 JWT를 직접 입력으로 받지 않습니다.
|
||
|
||
</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 -->
|
||
|
||
## 선택이 코드와 흐름에 반영되는 방식
|
||
|
||
### 추적 규칙: 요청 한 번을 네 칸으로 기록한다
|
||
|
||
선택 이유를 정리한 뒤에는 로그인 버튼을 누르고 JSON이 돌아올 때까지 요청 하나를 직접 따라갔습니다. 처음에는 code와 token, cookie가 계속 바뀌어 어디까지 왔는지 놓치기 쉬웠습니다. 그래서 각 예제를 네 칸으로 나누어 같은 순서로 기록했습니다.
|
||
|
||
1. **입력:** endpoint, method, query, cookie, header, body
|
||
2. **변환:** 실제 handler, configuration 또는 framework integration이 입력을 어떤 객체와 credential로 바꾸는가
|
||
3. **출력:** HTTP response 또는 다음 계층에 전달되는 object·header
|
||
4. **다음 홉:** 그 출력을 다음에는 누가 입력으로 받는가
|
||
|
||
실행할 때마다 달라지는 값은 `<authorization-code>`, `<access-token>`, `<session-id>`처럼 표시했습니다. 테스트가 응답 전체를 확인하지 않은 곳에서는 대표적인 모양만 보여 주었습니다. 다른 OAuth 구현에서 흔히 볼 수 있다는 이유만으로 검증하지 않은 field를 덧붙이지 않았습니다.
|
||
|
||
### AP1 완주: callback code가 브라우저 Bearer 요청이 되기까지
|
||
|
||
먼저 AP1의 로그인 버튼을 누른 뒤 브라우저에 돌아온 code가 어떻게 API 요청이 되는지 따라갔습니다.
|
||
|
||
**1단계 — SPA를 열고 OAuth transaction을 시작한다**
|
||
|
||
초기 입력은 다음 navigation입니다.
|
||
|
||
```http
|
||
GET http://localhost:8088/
|
||
```
|
||
|
||
Frontend Nginx는 SPA shell을 반환합니다. 별도의 실제 `callback.html` 파일은 없지만, 존재하지 않는 경로를 `index.html`로 fallback하는 설정 때문에 `/callback.html`도 같은 shell을 엽니다. JavaScript module은 `UserManager`를 만들면서 다음 값을 고정합니다.
|
||
|
||
```text
|
||
authority = http://localhost:8080/realms/keycloak-patterns
|
||
client_id = spa-public
|
||
redirect_uri = http://localhost:8088/callback.html
|
||
post_logout_uri = http://localhost:8088/
|
||
response_type = code
|
||
scope = openid profile email
|
||
userStore = InMemoryWebStorage
|
||
stateStore = sessionStorage
|
||
automaticSilentRenew = true
|
||
```
|
||
|
||
`userStore`와 `stateStore`를 구분해야 합니다. 전자는 로그인 뒤 `User`와 token set을 보관하는 곳이고 후자는 redirect를 건너야 하는 authorization transaction을 보관하는 곳입니다. AP1은 `User`를 memory에 두고, `state`와 PKCE verifier는 Session Storage를 이용해 Keycloak 왕복을 건넙니다.
|
||
|
||
사용자가 `#login`을 누르면 local handler가 인자를 조립해 token endpoint를 직접 부르는 것이 아니라 `userManager.signinRedirect()`를 호출합니다. oidc-client-ts가 authorization URL을 만듭니다. Effective request의 핵심 모양은 다음과 같습니다.
|
||
|
||
```http
|
||
GET http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/auth
|
||
?client_id=spa-public
|
||
&redirect_uri=http%3A%2F%2Flocalhost%3A8088%2Fcallback.html
|
||
&response_type=code
|
||
&scope=openid%20profile%20email
|
||
&state=<opaque-state>
|
||
&code_challenge=<opaque-challenge>
|
||
&code_challenge_method=S256
|
||
```
|
||
|
||
여기서 browser의 출력은 Keycloak로 향하는 full-page navigation입니다. `state`와 challenge 값은 요청마다 달라집니다. 커밋된 browser test가 직접 확인하도록 정의한 query는 `response_type=code`, `code_challenge_method=S256`, 비어 있지 않은 `code_challenge`입니다.
|
||
|
||
AP1에는 `createPkcePair()`라는 수동 helper도 있습니다. 이 함수는 32 random bytes를 padding 없는 Base64URL verifier로 바꾸고, SHA-256을 적용한 challenge와 `"S256"`을 반환합니다. 그러나 실제 `signinRedirect()`가 이 helper를 호출하지는 않습니다. Helper는 UI의 PKCE demo button에서 길이를 보여 주기 위한 코드이고 실제 로그인은 pinned oidc-client-ts가 수행합니다. 따라서 demo에서 나온 43자 verifier를 실제 library token request의 정확한 verifier 길이라고 설명해서는 안 됩니다.
|
||
|
||
**2단계 — callback 입력을 token set으로 바꾼다**
|
||
|
||
Keycloak에서 사용자가 인증되면 브라우저는 다음과 같은 callback을 받습니다.
|
||
|
||
```http
|
||
GET http://localhost:8088/callback.html
|
||
?code=<authorization-code>
|
||
&state=<opaque-state>
|
||
```
|
||
|
||
SPA는 path가 `/callback.html`이고 query에 `code` 또는 `error`가 있을 때 callback 경로로 판단합니다. `finishSigninCallback()`이 `userManager.signinRedirectCallback()`을 호출하고, library가 저장했던 transaction state와 callback state를 대조합니다. 성공 경로에서 browser가 보내는 token request의 의도는 다음과 같습니다.
|
||
|
||
```http
|
||
POST http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/token
|
||
Content-Type: application/x-www-form-urlencoded
|
||
|
||
grant_type=authorization_code
|
||
&client_id=spa-public
|
||
&code=<authorization-code>
|
||
&redirect_uri=http://localhost:8088/callback.html
|
||
&code_verifier=<original-verifier>
|
||
```
|
||
|
||
`spa-public`은 secret이 없는 public client입니다. Keycloak 등록은 standard flow만 켜고 implicit flow와 direct grant를 끄며 S256을 요구합니다. SPA는 `/callback.html`을 사용하지만 local realm의 redirect allowlist는 `http://localhost:8088/*`와 `http://127.0.0.1:8088/*` wildcard입니다. 따라서 exact callback만 허용하는 운영 가드레일이나 invalid redirect negative test까지 현재 fixture가 입증하는 것은 아닙니다. Token endpoint의 출력에서 현재 browser test가 관측하도록 정의한 것은 비어 있지 않은 `access_token`, `refresh_token`, `id_token`입니다. `expires_in` 같은 일반적인 추가 field의 exact response를 이 문서의 계약으로 고정하지 않습니다.
|
||
|
||
테스트가 여기서 확인하는 범위도 구분해야 합니다. Authorization request의 challenge와 token request endpoint, `grant_type=authorization_code`는 직접 봅니다. 하지만 token request body에 들어간 `code_verifier`, `client_id`, `redirect_uri`, code 값을 하나씩 비교하지는 않습니다. 따라서 구현이 의도한 PKCE 순서와 테스트가 실제로 붙잡은 field를 같은 증거로 취급하지 않습니다.
|
||
|
||
Library는 응답을 `User`로 만듭니다. 애플리케이션이 실제로 읽는 논리적 데이터는 다음과 같습니다.
|
||
|
||
```text
|
||
User
|
||
├─ profile.sub
|
||
├─ profile.preferred_username
|
||
├─ access_token
|
||
├─ refresh_token
|
||
├─ id_token
|
||
├─ expires_at
|
||
└─ expired
|
||
```
|
||
|
||
Serialized user는 `InMemoryWebStorage`에 있고 module 변수 `currentUser`도 같은 live user를 가리킵니다. Callback이 끝나면 SPA는 `history.replaceState(..., "/")`로 code와 state query를 주소창에서 제거합니다. Token 원문을 화면에 표시하지 않고 다음 파생 metadata만 렌더합니다.
|
||
|
||
```json
|
||
{
|
||
"subject": "<keycloak-sub>",
|
||
"username": "regular-user",
|
||
"expiresAt": "<ISO-8601-instant>",
|
||
"accessTokenHeldBy": "browser memory",
|
||
"refreshTokenHeldBy": "browser memory"
|
||
}
|
||
```
|
||
|
||
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와 별개 |
|
||
|
||
Memory user가 사라진다고 Keycloak SSO까지 로그아웃되는 것은 아닙니다. Reload 뒤 애플리케이션 token 상태를 포기했다는 말과 IdP session을 제거했다는 말은 구분해야 합니다.
|
||
|
||
**3단계 — JavaScript가 access token을 API input으로 바꾼다**
|
||
|
||
사용자가 `#call-api`를 누르면 `callProtectedApi()`가 실행됩니다. `currentUser`가 없거나 `expired`이면 network request를 만들지 않고 다음 local UI error를 출력합니다.
|
||
|
||
```json
|
||
{"error":"로그인이 필요합니다."}
|
||
```
|
||
|
||
유효한 user라면 애플리케이션 코드가 명시하는 핵심 request shape는 다음과 같습니다.
|
||
|
||
```http
|
||
GET http://localhost:8081/api/me
|
||
Authorization: Bearer <access-token>
|
||
```
|
||
|
||
이 URL은 frontend와 origin이 다르며 `Authorization` header를 사용합니다. Browser의 direct API call을 성립시키기 위해 Spring CORS allowlist에는 frontend origin인 `localhost:8088`과 `127.0.0.1:8088`, `GET`·`OPTIONS`, `Authorization`·`Content-Type`만 둡니다. 허용 목록 밖의 browser cross-origin 요청은 CORS 검사를 통과하지 못합니다. 이는 browser-origin 경계이지 API의 network-level 접근 통제는 아닙니다. Browser standard상 preflight가 생기는 경로지만 커밋된 E2E는 preflight response를 별도로 assert하지 않고 최종 200을 확인하도록 작성되어 있습니다.
|
||
|
||
구현에는 한 가지 헷갈리기 쉬운 차이가 있습니다. Frontend Nginx에도 `/api/` proxy가 있지만 SPA는 상대 URL `/api/me`가 아니라 absolute `http://localhost:8081/api/me`를 사용합니다. 따라서 현재 happy path는 Nginx proxy가 아니라 browser가 host에 공개된 Resource Server를 직접 호출합니다.
|
||
|
||
Spring 쪽 입력은 raw Bearer string입니다. 요청마다 Bearer JWT로 인증하고 application session을 만들지 않기 위해 `SecurityConfig.apiSecurity()`는 CORS를 켜고 CSRF를 끄며 `SessionCreationPolicy.STATELESS`를 선택합니다. 그 대가로 이미 발급된 self-contained JWT를 logout 순간에 server session처럼 즉시 없앨 수 없으며 짧은 TTL과 validator가 가드레일이 됩니다. Spring OAuth2 Resource Server가 header를 추출하고 JWT authentication provider를 거쳐 configured decoder를 호출합니다. Repository code가 Spring 내부 filter를 직접 생성하지는 않으므로, 이 흐름은 DSL이 설치하는 framework integration과 custom bean 경계를 나눠 읽어야 합니다.
|
||
|
||
Custom code의 변환 순서는 다음과 같습니다.
|
||
|
||
```text
|
||
raw Bearer JWT
|
||
→ NimbusJwtDecoder(JWK signature)
|
||
→ default issuer + timestamp validators
|
||
→ AudienceValidator("keycloak-pattern-api")
|
||
→ validated Jwt
|
||
→ KeycloakRealmRoleConverter
|
||
→ authenticated principal + ROLE_* authorities
|
||
```
|
||
|
||
외부 issuer와 내부 JWK URL도 구분됩니다. Expected issuer는 token 안의 browser-visible 값인 `http://localhost:8080/realms/keycloak-patterns`입니다. 공개키를 가져오는 JWK URL은 container network의 `http://keycloak:8080/.../certs`입니다. 같은 realm을 가리키지만 하나는 claim 검증 기준이고 하나는 network access 경로입니다.
|
||
|
||
`AudienceValidator`는 `jwt.getAudience()`에 `keycloak-pattern-api`가 포함됐는지 확인합니다. 누락되면 `invalid_token` 결과를 만듭니다. `KeycloakRealmRoleConverter`는 `realm_access.roles`의 string을 골라 `ROLE_` prefix를 붙입니다. 예를 들어 `user-role`은 `ROLE_user-role`이 됩니다.
|
||
|
||
그러나 이 worked example의 `/api/me`는 특정 role을 요구하지 않고 `.authenticated()`만 요구합니다. Role claim이 없어서 converter 결과가 빈 list여도 JWT가 유효하면 `/api/me` 자체는 통과할 수 있습니다. `admin-role`의 효과는 별도 `/api/admin` endpoint에서 나타납니다. Regular user는 403, admin user는 200을 기대하는 별도 acceptance contract가 있습니다.
|
||
|
||
마지막으로 `ApiController.currentUser(Jwt)`는 검증을 마친 JWT에서 필요한 값을 꺼내 사용자 JSON을 만듭니다.
|
||
|
||
```json
|
||
{
|
||
"subject": "<keycloak-user-sub>",
|
||
"username": "regular-user",
|
||
"issuer": "http://localhost:8080/realms/keycloak-patterns",
|
||
"audience": ["<possibly-other-audiences>", "keycloak-pattern-api"]
|
||
}
|
||
```
|
||
|
||
Controller output에는 정확히 `subject`, `username`, `issuer`, `audience` 네 field가 있습니다. Subject의 실제 UUID와 audience 배열 전체는 동적입니다. 현재 browser E2E는 UI의 `httpStatus: 200`과 decoded access token의 expected audience 포함만 확인하도록 작성되어 있습니다. `username`은 controller code와 synthetic MockMvc contract에 나타나지만 live AP1 E2E가 직접 assert하지 않습니다.
|
||
|
||
SPA는 이 JSON을 다시 화면용 object로 조립합니다.
|
||
|
||
```json
|
||
{
|
||
"httpStatus": 200,
|
||
"resourceServerResponse": {
|
||
"subject": "<keycloak-user-sub>",
|
||
"username": "regular-user",
|
||
"issuer": "http://localhost:8080/realms/keycloak-patterns",
|
||
"audience": ["<possibly-other-audiences>", "keycloak-pattern-api"]
|
||
},
|
||
"tokenBoundary": {
|
||
"subject": "<keycloak-user-sub>",
|
||
"username": "regular-user",
|
||
"expiresAt": "<ISO-8601-instant>",
|
||
"accessTokenHeldBy": "browser memory",
|
||
"refreshTokenHeldBy": "browser memory"
|
||
}
|
||
}
|
||
```
|
||
|
||
한 요청을 지나면서 같은 로그인 정보가 `token response → oidc-client-ts User → Authorization header → validated Jwt → controller Map → UI wrapper` 순서로 모양을 바꿉니다. 이 과정에서 access token 원문은 browser memory에도 있고 network header에도 실립니다. AP1에서는 이 노출을 감수하고 브라우저가 token을 직접 다루는 과정을 보여 주었습니다.
|
||
|
||
**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 | 자동 재로그인 |
|
||
|
||
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는 현재 고정되어 있지 않습니다.
|
||
|
||
Refresh와 logout도 서로 다른 효과를 가집니다. Realm은 access token 수명을 300초로 두고 refresh token rotation과 reuse 0을 사용합니다. 커밋된 E2E는 refresh token을 직접 사용해 새 refresh token을 받고 이전 token이 거부되는지 확인하도록 정의합니다. Revocation 뒤 refresh는 실패해야 하지만, 이미 발급된 self-contained access JWT는 expiry 전까지 API에서 계속 유효할 수 있습니다. Logout은 Keycloak SSO 종료와 app user 제거를 다루고, access JWT 즉시 deny-list와 같은 효과를 보장하지 않습니다.
|
||
|
||
`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 -->
|
||

|
||
|
||
<details>
|
||
<summary>Diagram description</summary>
|
||
|
||
브라우저 SPA가 S256 code challenge가 포함된 authorization request를 Keycloak에 보냅니다. Keycloak이 code와 state를 callback으로 돌려주면 SPA는 원래 verifier를 포함해 token endpoint에 code를 제출하고 access, refresh, ID token을 받습니다. 이어서 SPA가 access token을 Authorization Bearer header에 넣어 Resource Server의 /api/me를 직접 호출하고 사용자 JSON을 받습니다.
|
||
|
||
</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가 되기까지
|
||
|
||
AP1에서는 브라우저가 code를 직접 교환했습니다. AP2에서는 같은 지점부터 다시 시작해 Spring mediator가 받은 token 중 무엇을 브라우저에 건네는지 확인했습니다.
|
||
|
||
**1단계 — public UI에서 confidential login을 시작한다**
|
||
|
||
초기 입력은 다음과 같습니다.
|
||
|
||
```http
|
||
GET http://localhost:8082/
|
||
```
|
||
|
||
`/`, `/index.html`, `/app.js`는 인증 없이 열립니다. 사용자가 login button을 누르면 JavaScript는 다음 navigation만 수행합니다.
|
||
|
||
```javascript
|
||
window.location.assign("/oauth2/authorization/keycloak");
|
||
```
|
||
|
||
`/oauth2/authorization/keycloak`은 애플리케이션 controller가 아니라 Spring Security의 OAuth client endpoint입니다. Registration `keycloak`은 다음 값을 제공합니다.
|
||
|
||
```text
|
||
client_id = token-mediating-confidential
|
||
client_authentication = client_secret_basic
|
||
grant_type = authorization_code
|
||
scopes = openid profile email
|
||
callback = http://localhost:8082/login/oauth2/code/keycloak
|
||
authorization_uri = http://localhost:8080/.../auth
|
||
token_uri = http://keycloak:8080/.../token
|
||
principal claim = preferred_username
|
||
```
|
||
|
||
Browser는 Keycloak login page로 redirect되고 `regular-user` credentials를 제출합니다. Keycloak client 등록은 confidential, standard flow enabled, implicit와 direct grant disabled, exact callback으로 구성됩니다.
|
||
|
||
여기서 AP1·AP3·AP4와 억지로 대칭을 만들면 안 됩니다. AP2 client 설정에는 S256을 강제하는 속성이 없고 AP2 E2E도 authorization request의 challenge를 검사하지 않습니다. AP2는 Authorization Code confidential client라는 사실까지는 분명하지만, 현재 구현을 PKCE S256 검증 예시라고 설명할 근거는 없습니다.
|
||
|
||
Login을 시작할 때 Spring Security는 authorization request와 state를 HttpSession에 저장하고 browser에 그 transaction을 찾는 `AP2_SESSION`을 발급합니다. 이 cookie는 token 교환이 끝난 뒤에 처음 생기는 것이 아닙니다. Keycloak redirect를 건너 callback의 state를 원래 transaction과 연결하기 위해 먼저 사용됩니다.
|
||
|
||
**2단계 — callback을 session과 authorized client로 바꾼다**
|
||
|
||
성공 뒤 browser input은 다음 형태입니다.
|
||
|
||
```http
|
||
GET http://localhost:8082/login/oauth2/code/keycloak
|
||
?code=<authorization-code>
|
||
&state=<opaque-state>
|
||
Cookie: AP2_SESSION=<opaque-session-id>
|
||
```
|
||
|
||
Spring `oauth2Login`이 session에서 authorization transaction을 복원하고 callback state를 대조한 뒤 code를 처리합니다. Mediator는 server network에서 Keycloak token endpoint를 호출하며 `client_secret_basic`으로 자기 client를 인증합니다. On-wire 의미상 client ID와 secret은 HTTP Basic client authentication에 사용되고 code, redirect URI와 grant type은 token request에 들어갑니다. 현재 E2E는 실제 token request header와 body 전체를 캡처하지 않으므로 exact serialization까지 계약으로 삼지는 않습니다.
|
||
|
||
교환이 성공하면 기존 session transaction은 authenticated SecurityContext로 이어지고, 별도 authorized-client state에 token이 저장됩니다.
|
||
|
||
```text
|
||
AP2_SESSION
|
||
→ servlet HttpSession의 login SecurityContext
|
||
→ Authentication(principal name = preferred_username)
|
||
|
||
("keycloak", principal name)
|
||
→ OAuth2AuthorizedClientService
|
||
→ access token + refresh token
|
||
```
|
||
|
||
Application은 `OAuth2AuthorizedClientService` 구현을 직접 선언하지 않습니다. 현재 Spring Boot 자동구성이 선택하는 것은 in-memory service이고, Spring Session·Redis·JDBC token store 의존성도 없습니다. 따라서 `AP2_SESSION`으로 찾는 login state와 principal·registration으로 찾는 token state가 모두 process-local memory에 의존합니다.
|
||
|
||
Browser가 계속 제시하는 application credential은 OAuth token 값이 아니라 `AP2_SESSION=<opaque-session-id>` cookie입니다. 이 cookie는 authorization transaction 때부터 사용되고 성공 뒤 login SecurityContext를 찾습니다. 설정은 HttpOnly와 SameSite=Lax를 명시합니다. Cookie가 token map 자체를 직렬화한다고 설명해서는 안 됩니다. Actual access·refresh token은 별도 authorized-client service에 있습니다. 인증 시 session ID rotation이나 전체 `Set-Cookie` timing은 현재 E2E가 고정하지 않습니다.
|
||
|
||
`defaultSuccessUrl("/", true)` 때문에 성공 뒤 browser는 root로 돌아옵니다. Callback의 exact 302 chain과 실패 body는 test가 고정하지 않습니다.
|
||
|
||
**3단계 — `/token/boundary`가 server custody를 boolean으로 투영한다**
|
||
|
||
로그인 뒤 사용자가 “token boundary” 버튼을 누르면 JavaScript가 다음 요청을 보냅니다.
|
||
|
||
```http
|
||
GET http://localhost:8082/token/boundary
|
||
Accept: application/json
|
||
Cookie: AP2_SESSION=<opaque-session-id>
|
||
```
|
||
|
||
Spring Security가 session에서 `Authentication`을 복원한 뒤 `TokenBoundaryController.tokenBoundary()`가 호출됩니다. Controller는 다음 key로 server store를 조회합니다.
|
||
|
||
```text
|
||
client registration id = "keycloak"
|
||
principal name = authentication.getName()
|
||
```
|
||
|
||
Local user configuration에서는 principal name이 `preferred_username`이므로 정상 예시는 `regular-user`입니다. Authorized client 객체와 그 안의 access·refresh token 존재 여부를 boolean으로 바꿉니다. Token 원문은 읽어서 응답에 넣지 않습니다.
|
||
|
||
정상 output은 다음 다섯 field입니다.
|
||
|
||
```http
|
||
HTTP/1.1 200 OK
|
||
Cache-Control: no-store
|
||
Pragma: no-cache
|
||
Content-Type: application/json
|
||
```
|
||
|
||
```json
|
||
{
|
||
"pattern": "AP2-token-mediating-backend",
|
||
"principal": "regular-user",
|
||
"accessTokenStored": true,
|
||
"refreshTokenStored": true,
|
||
"browserReceivesRefreshToken": false
|
||
}
|
||
```
|
||
|
||
이 endpoint는 진단용 projection입니다. 인증된 session은 있지만 authorized client가 없다면 access·refresh boolean이 `false`인 200 응답을 만듭니다. “Token이 없으면 항상 401”이라고 설명하면 다음 endpoint와 혼동합니다.
|
||
|
||
**4단계 — `/token/access`가 server object를 raw token JSON으로 바꾼다**
|
||
|
||
API 호출 button은 먼저 다음 입력을 만듭니다.
|
||
|
||
```http
|
||
GET http://localhost:8082/token/access
|
||
Accept: application/json
|
||
Cookie: AP2_SESSION=<opaque-session-id>
|
||
```
|
||
|
||
`AccessTokenController.accessToken(Authentication)`의 변환은 구체적입니다.
|
||
|
||
1. `OAuth2AuthorizeRequest.withClientRegistrationId("keycloak")`를 시작합니다.
|
||
2. 현재 `Authentication`을 principal로 넣습니다.
|
||
3. `OAuth2AuthorizedClientManager.authorize(request)`를 호출합니다.
|
||
4. 반환된 authorized client에서 access token을 꺼냅니다.
|
||
5. 원문 token, type, expiry만 JSON으로 만듭니다.
|
||
|
||
Manager에는 authorization-code와 refresh-token provider가 구성되어 있습니다. 따라서 만료 상황에서 refresh를 시도할 수 있는 integration point는 있습니다. 그러나 access token 만료를 기다려 실제 refresh 성공과 rotated token 저장을 확인하는 E2E는 없습니다.
|
||
|
||
성공 output의 key 집합은 정확히 세 개입니다.
|
||
|
||
```http
|
||
HTTP/1.1 200 OK
|
||
Cache-Control: no-store
|
||
Pragma: no-cache
|
||
Content-Type: application/json
|
||
```
|
||
|
||
```json
|
||
{
|
||
"access_token": "<raw-keycloak-jwt>",
|
||
"token_type": "Bearer",
|
||
"expires_at": "<ISO-8601-instant>"
|
||
}
|
||
```
|
||
|
||
`refresh_token`은 없습니다. 하지만 access token은 분명히 HTTP response body에 있습니다. Authorized client나 access token이 없으면 controller가 다음 실패를 만듭니다.
|
||
|
||
```http
|
||
HTTP/1.1 401 Unauthorized
|
||
```
|
||
|
||
Reason은 `No authorized Keycloak client is available`이지만 Spring의 exact error body 모양은 별도 handler나 test로 고정되지 않았습니다.
|
||
|
||
이 endpoint에는 handoff ID, nonce, consume flag, 사용 후 delete, 재호출 거부가 없습니다. 같은 authenticated session은 현재 access token을 다시 요청할 수 있습니다. 그러므로 data flow는 다음처럼 써야 합니다.
|
||
|
||
```text
|
||
repeatable GET
|
||
→ current authorized client lookup/refresh opportunity
|
||
→ current raw access token response
|
||
```
|
||
|
||
“한 번만 교환 가능한 code”라고 바꾸어 말하면 안 됩니다.
|
||
|
||
**5단계 — browser가 access JSON을 Resource Server input으로 재조립한다**
|
||
|
||
JavaScript는 response를 지역 변수로 구조 분해합니다.
|
||
|
||
```javascript
|
||
const {
|
||
access_token: accessToken,
|
||
expires_at: expiresAt
|
||
} = await tokenResponse.json();
|
||
```
|
||
|
||
그 값을 Web Storage나 cookie에 쓰지 않고 바로 다음 요청 header로 넣습니다.
|
||
|
||
```http
|
||
GET http://localhost:8081/api/me
|
||
Accept: application/json
|
||
Authorization: Bearer <raw-keycloak-jwt>
|
||
Origin: http://localhost:8082
|
||
```
|
||
|
||
Raw access token은 짧은 시간이라도 세 경계를 지납니다.
|
||
|
||
```text
|
||
/token/access response body
|
||
→ JavaScript local variable
|
||
→ /api/me Authorization header
|
||
```
|
||
|
||
“Memory-only”는 persistent storage에 쓰지 않는다는 뜻입니다. 실행 중 script가 response나 local variable을 읽을 수 없다는 뜻은 아닙니다.
|
||
|
||
Resource Server는 AP1과 같은 JWT validation chain을 사용합니다. Session은 stateless이고 issuer, timestamp, JWK signature, `keycloak-pattern-api` audience를 검증합니다. AP2 browser의 direct Bearer 호출만 열기 위해 CORS allowlist는 AP2 UI origin과 `/api/**`의 `GET`·`OPTIONS`, `Authorization`·`Content-Type`으로 좁힙니다. 허용 목록 밖의 browser cross-origin 요청은 CORS 검사를 통과하지 못합니다. 이는 browser-origin 경계이지 API의 network-level 접근 통제는 아닙니다.
|
||
|
||
`ApiController.currentUser()`의 output도 네 field입니다.
|
||
|
||
```json
|
||
{
|
||
"subject": "<keycloak-user-sub>",
|
||
"username": "regular-user",
|
||
"issuer": "http://localhost:8080/realms/keycloak-patterns",
|
||
"audience": ["<possibly-other-audiences>", "keycloak-pattern-api"]
|
||
}
|
||
```
|
||
|
||
현재 E2E는 status 200, username, expected audience 포함을 확인하도록 정의합니다. SPA가 화면에 렌더하는 최종 object는 token 원문을 다시 노출하지 않고 boundary를 요약합니다.
|
||
|
||
```json
|
||
{
|
||
"accessTokenHeldInMemoryOnly": true,
|
||
"refreshTokenReceived": false,
|
||
"accessTokenExpiresAt": "<ISO-8601-instant>",
|
||
"resourceApiStatus": 200,
|
||
"resource": {
|
||
"subject": "<keycloak-user-sub>",
|
||
"username": "regular-user",
|
||
"issuer": "http://localhost:8080/realms/keycloak-patterns",
|
||
"audience": ["<possibly-other-audiences>", "keycloak-pattern-api"]
|
||
}
|
||
}
|
||
```
|
||
|
||
AP2 전체 변환을 한 줄로 압축하면 다음과 같습니다.
|
||
|
||
```text
|
||
authorization code
|
||
→ Spring oauth2Login
|
||
→ in-memory OAuth2AuthorizedClient(access + refresh)
|
||
→ /token/access(access only)
|
||
→ JavaScript local variable
|
||
→ browser-created Bearer header
|
||
→ validated Jwt
|
||
→ /api/me JSON
|
||
```
|
||
|
||
**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 |
|
||
|
||
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 -->
|
||

|
||
|
||
<details>
|
||
<summary>Diagram description</summary>
|
||
|
||
브라우저가 AP2_SESSION cookie와 함께 /token/access를 Spring mediator에 요청합니다. Mediator는 현재 principal과 keycloak registration으로 authorized-client store에서 token을 조회하고 access token, type, expiry만 응답합니다. 브라우저는 access token을 지역 변수로 받아 Authorization Bearer header를 만들고 Resource Server의 /api/me를 직접 호출한 뒤 사용자 JSON을 받습니다. Refresh token은 브라우저 응답에 포함되지 않습니다.
|
||
|
||
</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가 되기까지
|
||
|
||
AP2까지 확인하고 나니 access token도 브라우저 응답에서 없애면 요청이 어떻게 바뀌는지 궁금했습니다. AP3에서는 session cookie로 시작한 요청이 BFF 안에서 Bearer 요청으로 바뀌는 과정을 따라갔습니다.
|
||
|
||
**1단계 — BFF가 PKCE transaction과 confidential code 교환을 함께 소유한다**
|
||
|
||
브라우저는 먼저 BFF가 제공하는 UI를 엽니다.
|
||
|
||
```http
|
||
GET http://localhost:8083/
|
||
```
|
||
|
||
Login button의 local code는 AP2와 같은 모양입니다.
|
||
|
||
```javascript
|
||
window.location.assign("/oauth2/authorization/keycloak");
|
||
```
|
||
|
||
차이는 Spring Security 설정 안에 있습니다. `SecurityConfig.bffSecurity()`는 base URI `/oauth2/authorization`에 `DefaultOAuth2AuthorizationRequestResolver`를 만들고 `OAuth2AuthorizationRequestCustomizers.withPkce()`를 장착합니다. Framework resolver가 `keycloak` registration을 읽어 state와 verifier를 만들고 S256 challenge를 authorization request에 넣습니다.
|
||
|
||
Effective browser request는 다음과 같은 모양입니다.
|
||
|
||
```http
|
||
GET http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/auth
|
||
?response_type=code
|
||
&client_id=bff-confidential
|
||
&redirect_uri=http%3A%2F%2Flocalhost%3A8083%2Flogin%2Foauth2%2Fcode%2Fkeycloak
|
||
&scope=openid%20profile%20email
|
||
&state=<opaque-state>
|
||
&code_challenge=<opaque-challenge>
|
||
&code_challenge_method=S256
|
||
```
|
||
|
||
AP3 E2E는 client ID, S256 method와 nonempty challenge를 확인하도록 작성되어 있습니다. Authorization request와 PKCE verifier는 Spring의 session-mediated OAuth login transaction에 속합니다. Browser에는 그 session을 찾는 `AP3_SESSION` cookie가 생기지만 verifier나 client secret을 JavaScript 응답으로 주지는 않습니다.
|
||
|
||
Keycloak 인증 뒤 callback input은 다음과 같습니다.
|
||
|
||
```http
|
||
GET http://localhost:8083/login/oauth2/code/keycloak
|
||
?code=<authorization-code>
|
||
&state=<opaque-state>
|
||
Cookie: AP3_SESSION=<opaque-session-id>
|
||
```
|
||
|
||
Spring OAuth login filter가 saved authorization request를 읽고 state를 대조합니다. BFF는 server network에서 token endpoint에 `grant_type=authorization_code`, code, 동일 redirect URI와 verifier를 보냅니다. Client authentication은 `client_secret_basic`입니다. Token response의 access·refresh token은 `OAuth2AuthorizedClientService`에 저장됩니다. 검증된 ID token에서 구성된 OIDC principal은 `Authentication`이 되어 HttpSession의 `SecurityContext`에 연결됩니다.
|
||
|
||
이 단계에서 browser가 관측하는 출력은 root로 돌아가는 redirect와 `AP3_SESSION`입니다.
|
||
|
||
```text
|
||
Set-Cookie: AP3_SESSION=<opaque>; HttpOnly; SameSite=Lax
|
||
Location: /
|
||
```
|
||
|
||
Local YAML은 `Secure`, Domain과 만료를 별도로 고정하지 않습니다. HTTP 학습 환경의 관측값을 운영 cookie 기본값처럼 일반화해서는 안 됩니다.
|
||
|
||
Server state를 더 정확히 펼치면 다음 관계입니다.
|
||
|
||
```text
|
||
AP3_SESSION
|
||
→ HttpSession
|
||
→ SecurityContext
|
||
→ Authentication.getName()
|
||
→ ("keycloak", principal name)
|
||
→ OAuth2AuthorizedClientService
|
||
→ access token + refresh token
|
||
```
|
||
|
||
현재 store는 session ID마다 독립적인 token vault를 구현한 것이 아니라 registration과 principal name으로 authorized client를 찾는 application-level store입니다. 같은 principal이 여러 browser session에서 로그인하면 같은 entry를 공유하거나 덮어쓸 수 있습니다. Spring Session, Redis, JDBC repository, encrypted token store는 현재 구성에 없습니다.
|
||
|
||
**2단계 — `/bff/token-boundary`는 token 값을 내보내지 않고 server state를 설명한다**
|
||
|
||
브라우저 입력은 session cookie뿐입니다.
|
||
|
||
```http
|
||
GET http://localhost:8083/bff/token-boundary
|
||
Accept: application/json
|
||
Cookie: AP3_SESSION=<opaque-session-id>
|
||
```
|
||
|
||
Security filter가 `Authentication`을 복원한 뒤 `BffController.tokenBoundary(Authentication)`가 실행됩니다. Controller는 `("keycloak", authentication.getName())`으로 `OAuth2AuthorizedClientService`를 직접 조회합니다. 이 경로는 manager의 `authorize()`를 호출하지 않으므로 access token refresh를 수행하는 endpoint가 아닙니다. 객체와 token의 존재 여부만 boolean으로 바꿉니다.
|
||
|
||
정상 output은 다음과 같습니다.
|
||
|
||
```http
|
||
HTTP/1.1 200 OK
|
||
Cache-Control: no-store
|
||
Pragma: no-cache
|
||
Content-Type: application/json
|
||
```
|
||
|
||
```json
|
||
{
|
||
"pattern": "AP3-backend-for-frontend",
|
||
"principal": "regular-user",
|
||
"accessTokenStoredOnServer": true,
|
||
"refreshTokenStoredOnServer": true,
|
||
"browserTokenCount": 0,
|
||
"csrfProtectionEnabled": true
|
||
}
|
||
```
|
||
|
||
`browserTokenCount: 0`은 browser를 runtime에서 검사해 계산한 수치가 아니라 controller가 넣는 literal입니다. 이 field 하나가 token 비노출을 증명하지 않습니다. Browser network에 token endpoint call과 Resource Server 직접 call이 없는지, Web Storage가 비었는지를 E2E contract가 별도로 확인하도록 작성된 이유입니다.
|
||
|
||
AP2 boundary와 마찬가지로 authorized client가 없어도 인증된 session이라면 token boolean이 false인 200을 만들 수 있습니다. Token 원문은 어느 경우에도 이 response에 직렬화하지 않습니다.
|
||
|
||
**3단계 — `/bff/api/me`가 session input을 downstream Bearer로 바꾼다**
|
||
|
||
AP3 UI의 자신의 정보 조회는 다음 request 하나로 시작합니다.
|
||
|
||
```http
|
||
GET http://localhost:8083/bff/api/me
|
||
Accept: application/json
|
||
Cookie: AP3_SESSION=<opaque-session-id>
|
||
```
|
||
|
||
여기에 `Authorization` header는 없습니다. Browser code에는 access token local variable도 없습니다. 그다음 변환은 `BffController.currentUser(Authentication)` 안에서 일어납니다.
|
||
|
||
1. `authorizedClient(authentication)` helper를 호출합니다.
|
||
2. Helper는 registration ID `"keycloak"`과 현재 `Authentication`으로 `OAuth2AuthorizeRequest`를 만듭니다.
|
||
3. `OAuth2AuthorizedClientManager.authorize()`를 호출합니다.
|
||
4. Manager는 현재 access token을 사용하거나, 만료됐고 refresh token이 있으면 server-to-server refresh를 시도할 수 있습니다.
|
||
5. 유효한 access token을 controller로 돌려줍니다.
|
||
|
||
Manager bean은 `AuthorizedClientServiceOAuth2AuthorizedClientManager`이고 authorization-code와 refresh-token provider를 함께 사용합니다. 이 코드를 보면 BFF가 token 발급 이후의 수명주기까지 맡는다는 것을 확인할 수 있습니다.
|
||
|
||
Authorized client나 access token이 없으면 helper가 다음 local failure를 만듭니다.
|
||
|
||
```http
|
||
HTTP/1.1 401 Unauthorized
|
||
```
|
||
|
||
Reason은 `No authorized Keycloak client is available`입니다. 성공하면 BFF의 `RestClient`가 별도의 downstream input을 조립합니다.
|
||
|
||
```http
|
||
GET http://app:8081/api/me
|
||
Authorization: Bearer <server-held-access-token>
|
||
```
|
||
|
||
Browser가 보낸 `AP3_SESSION`은 downstream으로 전달되지 않습니다. BFF가 session을 application credential로 소비하고, Resource Server가 이해하는 Bearer credential로 바꿉니다. Resource Server는 AP1·AP2와 같은 stateless JWT path에서 signature, issuer, timestamp와 `keycloak-pattern-api` audience를 검증합니다.
|
||
|
||
`ApiController.currentUser(Jwt)`가 만드는 downstream output은 다음 네 field입니다.
|
||
|
||
```json
|
||
{
|
||
"subject": "<keycloak-user-sub>",
|
||
"username": "regular-user",
|
||
"issuer": "http://localhost:8080/realms/keycloak-patterns",
|
||
"audience": ["<possibly-other-audiences>", "keycloak-pattern-api"]
|
||
}
|
||
```
|
||
|
||
BFF는 `ResponseEntity<Map<String, Object>>`를 받아 그대로 controller return value로 사용합니다. UI helper는 HTTP status를 화면용 object에 더해 렌더합니다. 한 번의 요청을 model 변화로만 보면 다음과 같습니다.
|
||
|
||
```text
|
||
AP3_SESSION
|
||
→ HttpSession SecurityContext
|
||
→ Authentication
|
||
→ OAuth2AuthorizeRequest
|
||
→ OAuth2AuthorizedClient
|
||
→ Bearer header
|
||
→ validated Jwt
|
||
→ Resource Server Map
|
||
→ BFF ResponseEntity
|
||
→ browser JSON
|
||
```
|
||
|
||
이 흐름에는 중요한 network gap이 있습니다. Compose는 학습 편의를 위해 Resource Server의 8081을 host에도 publish합니다. E2E는 AP3 UI가 8081을 직접 호출하지 않는다는 것을 확인하도록 정의하지만, network상 모든 client가 BFF만 거치도록 강제했다는 증거는 아닙니다. 운영에서 BFF-only topology를 원한다면 Resource Server를 private network에 두어 직접 경로를 닫아야 합니다.
|
||
|
||
Downstream failure도 과장하면 안 됩니다. Resource Server가 invalid audience, expired token 등으로 401을 반환할 수 있지만 BFF의 `RestClient.retrieve()` 뒤에 status mapping contract가 없습니다. 그래서 “downstream 401을 BFF가 exact 401 body로 그대로 전달한다”고 보장할 수 없습니다. Timeout, retry, circuit breaker, relogin 변환도 구현되어 있지 않습니다.
|
||
|
||
**4단계 — CSRF 발급에서 masked body와 raw cookie를 구분한다**
|
||
|
||
Cookie session은 browser가 요청마다 자동 첨부합니다. 따라서 `GET /bff/api/me`만으로는 상태 변경 보호를 설명할 수 없습니다. AP3는 preference 변경을 별도 worked example로 둡니다.
|
||
|
||
먼저 browser가 CSRF material을 요청합니다.
|
||
|
||
```http
|
||
GET http://localhost:8083/bff/csrf
|
||
Accept: application/json
|
||
Cookie: AP3_SESSION=<opaque-session-id>
|
||
```
|
||
|
||
`CookieCsrfTokenRepository.withHttpOnlyFalse()`는 JavaScript가 읽을 수 있는 기본 `XSRF-TOKEN` cookie를 path `/`에 만듭니다. `CsrfController.csrf(CsrfToken)`는 request attribute의 token을 materialize하고 다음 JSON을 반환합니다.
|
||
|
||
```http
|
||
HTTP/1.1 200 OK
|
||
Cache-Control: no-store
|
||
Pragma: no-cache
|
||
Set-Cookie: XSRF-TOKEN=<raw-csrf-token>; Path=/
|
||
```
|
||
|
||
```json
|
||
{
|
||
"headerName": "X-XSRF-TOKEN",
|
||
"parameterName": "_csrf",
|
||
"token": "<xor-masked-csrf-token>"
|
||
}
|
||
```
|
||
|
||
Body의 `token`과 cookie의 값은 같은 문자열이 아닙니다. `XorCsrfTokenRequestAttributeHandler`가 request attribute용 token을 XOR와 Base64로 mask하기 때문에 controller JSON에는 masked 값이 보입니다. Cookie repository의 `XSRF-TOKEN`에는 raw 값이 있습니다.
|
||
|
||
SPA도 JSON `token`을 POST에 쓰지 않습니다. JSON에서는 `headerName`만 읽고, `document.cookie`에서 raw `XSRF-TOKEN`을 찾아 다음 header를 만듭니다.
|
||
|
||
```text
|
||
body.token = masked token
|
||
cookie XSRF-TOKEN = raw token
|
||
POST X-XSRF-TOKEN = same raw token
|
||
```
|
||
|
||
`SpaCsrfTokenRequestHandler`가 이 조합을 맞춥니다. Request attribute 노출에는 XOR handler를 사용하지만 expected header가 존재하면 plain resolver로 submitted raw token을 읽습니다. Header가 없으면 XOR resolver 경로를 사용합니다.
|
||
|
||
이 구분을 놓치면 “CSRF JSON에서 받은 token을 그대로 header에 복사한다”는 잘못된 구현 설명이 됩니다. 실제 SPA의 data source는 cookie입니다.
|
||
|
||
<!-- techviz:begin id=ap3-csrf-boundary context-sha256=df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371 -->
|
||
<!-- techviz:generate id=ap3-csrf-boundary -->
|
||

|
||
|
||
<details>
|
||
<summary>Diagram description</summary>
|
||
|
||
왼쪽의 BFF CSRF endpoint에서 두 결과가 갈라집니다. XSRF-TOKEN cookie에는 raw token이 저장되고 JSON body에는 XOR와 Base64로 masked된 token 및 headerName이 담깁니다. 두 결과는 SPA의 POST 조립 단계로 모이지만, JSON에서는 headerName만 사용하고 실제 X-XSRF-TOKEN 값은 document.cookie에서 읽은 raw token입니다. POST에는 같은 raw 값을 가진 cookie와 header가 함께 도달하고 Spring CSRF filter가 일치 여부를 확인합니다.
|
||
|
||
</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가 되기까지**
|
||
|
||
정상 상태 변경 request는 다음과 같습니다.
|
||
|
||
```http
|
||
POST http://localhost:8083/bff/api/preferences
|
||
Content-Type: application/x-www-form-urlencoded
|
||
Cookie: AP3_SESSION=<opaque-session-id>; XSRF-TOKEN=<raw-csrf-token>
|
||
X-XSRF-TOKEN: <same-raw-csrf-token>
|
||
|
||
theme=dark
|
||
```
|
||
|
||
Controller보다 먼저 Spring CSRF filter가 repository의 expected token과 submitted header를 비교합니다. Header가 없거나 값이 맞지 않으면 controller는 실행되지 않고 403이 됩니다. Valid request는 `@RequestParam(defaultValue = "system") String theme`로 bind됩니다.
|
||
|
||
`BffController.updatePreference()`는 값을 `AtomicReference<String>`에 `set()`하고 다음 output을 만듭니다.
|
||
|
||
```json
|
||
{
|
||
"updated": true,
|
||
"theme": "dark",
|
||
"principal": "regular-user"
|
||
}
|
||
```
|
||
|
||
이어지는 `GET /bff/api/preferences`는 다음처럼 current value만 반환합니다.
|
||
|
||
```json
|
||
{"theme":"dark"}
|
||
```
|
||
|
||
여기서 `AtomicReference`를 사용자별 preference repository라고 오해하면 안 됩니다. Singleton controller 안의 reference 한 개이고 user나 session key가 없습니다. 한 사용자가 `dark`로 바꾸면 같은 process의 다른 사용자도 같은 값을 읽을 수 있습니다. Restart하면 기본 `"system"`으로 돌아갑니다. Atomic operation은 동시 `get`·`set`의 원자성만 제공하며 사용자 격리, input validation, persistence, audit, authorization을 제공하지 않습니다. `theme`도 enum이나 길이 검증 없이 arbitrary string으로 들어갑니다.
|
||
|
||
이 endpoint의 목적은 preference 기능을 완성하는 것이 아니라 cookie-authenticated state change에서 CSRF filter가 어느 시점에 작동하는지 보여 주는 데 있습니다.
|
||
|
||
**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 |
|
||
|
||
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 -->
|
||

|
||
|
||
<details>
|
||
<summary>Diagram description</summary>
|
||
|
||
브라우저가 Authorization header 없이 AP3_SESSION cookie로 /bff/api/me를 호출합니다. BFF는 현재 Authentication으로 authorized-client manager를 호출해 server-held access token을 얻고 Resource Server의 /api/me에 Bearer header를 붙입니다. Resource Server가 JWT를 검증해 사용자 JSON을 반환하면 BFF가 ResponseEntity로 받아 브라우저에 중계합니다. 브라우저 session cookie는 downstream으로 전달되지 않습니다.
|
||
|
||
</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이 되기까지
|
||
|
||
마지막으로 OAuth token을 이해하지 못하는 upstream까지 같은 방식으로 보호해 보았습니다. AP4에서는 브라우저의 proxy session이 Nginx를 지나 신뢰할 수 있는 사용자 JSON으로 바뀌는 과정을 확인했습니다.
|
||
|
||
**1단계 — 미인증 navigation을 internal auth query로 바꾼다**
|
||
|
||
외부에서 publish된 application entry point는 Nginx의 8088뿐입니다. App의 8081과 oauth2-proxy의 4180은 Compose network에 `expose`되지만 host `ports`로 publish되지 않습니다.
|
||
|
||
Cookie가 없는 최초 입력은 다음과 같습니다.
|
||
|
||
```http
|
||
GET http://localhost:8088/
|
||
```
|
||
|
||
Nginx의 `location /`는 바로 upstream을 호출하지 않고 먼저 다음 directive를 실행합니다.
|
||
|
||
```nginx
|
||
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 |
|
||
| `Cookie: AP4_SESSION=...` | browser에 cookie가 있을 때 원래 request |
|
||
|
||
미인증 상태에서 oauth2-proxy의 auth endpoint가 401을 반환하면 general `/` location은 `@oauth2_signin`으로 이동해 다음 redirect를 만듭니다.
|
||
|
||
```http
|
||
HTTP/1.1 302 Found
|
||
Location: http://localhost:8088/oauth2/start?rd=http://localhost:8088/
|
||
```
|
||
|
||
Browser가 `/oauth2/start`를 따라가면 Nginx의 `/oauth2/` location이 oauth2-proxy로 proxy합니다. oauth2-proxy는 Keycloak authorization endpoint로 redirect하며 핵심 query는 다음과 같습니다.
|
||
|
||
```text
|
||
client_id=edge-proxy
|
||
redirect_uri=http://localhost:8088/oauth2/callback
|
||
scope=openid profile email
|
||
code_challenge=<opaque>
|
||
code_challenge_method=S256
|
||
```
|
||
|
||
현재 E2E는 unauthenticated root의 302, client ID, S256 method와 nonempty challenge를 확인하도록 작성되어 있습니다. Dynamic state나 전체 query ordering은 계약으로 고정하지 않습니다.
|
||
|
||
**2단계 — oauth2-proxy가 callback code를 proxy session으로 바꾼다**
|
||
|
||
Keycloak 인증 뒤 browser input은 Nginx를 통해 oauth2-proxy로 전달됩니다.
|
||
|
||
```http
|
||
GET http://localhost:8088/oauth2/callback
|
||
?code=<authorization-code>
|
||
&state=<opaque-state>
|
||
```
|
||
|
||
`/oauth2/` location이 request를 oauth2-proxy의 4180으로 보냅니다. Token의 expected issuer는 browser-visible URL로 유지하면서 container가 실제 Keycloak service에 도달하도록 oauth2-proxy endpoint를 외부용과 내부용으로 분리합니다. 그 대신 automatic discovery를 끄고 login·token·JWKS·userinfo URL을 각각 관리하는 비용을 수용합니다.
|
||
|
||
```text
|
||
issuer expected value = http://localhost:8080/realms/keycloak-patterns
|
||
login URL = http://localhost:8080/.../auth
|
||
redeem/token URL = http://keycloak:8080/.../token
|
||
JWKS/userinfo URL = http://keycloak:8080/...
|
||
```
|
||
|
||
Browser가 도달해야 하는 URL은 `localhost`이고 container가 server-to-server로 도달해야 하는 URL은 service name `keycloak`입니다. oauth2-proxy는 `edge-proxy` confidential client, client secret과 original verifier로 code를 교환합니다. Browser request log에 Keycloak token endpoint가 나타나지 않아야 한다는 것이 acceptance contract입니다.
|
||
|
||
성공 뒤 browser에는 `AP4_SESSION` cookie가 남습니다.
|
||
|
||
```text
|
||
name = AP4_SESSION
|
||
HttpOnly = true
|
||
SameSite = Lax
|
||
Secure = false in local HTTP fixture
|
||
expire = 1 hour in proxy configuration
|
||
```
|
||
|
||
Redis 같은 server-side session store는 따로 두지 않았습니다. `session-cookie-minimal=true`를 사용하면 client-side session cookie에는 access·refresh·ID token 대신 edge가 필요한 최소 session 정보만 남습니다. 그래서 AP4가 refresh token을 지속해서 보관한다고 말할 수도 없습니다. 브라우저가 아는 것은 JavaScript로 읽을 수 없고 다음 edge 요청에 자동으로 붙는 opaque cookie뿐입니다. Opaque는 내부 값을 브라우저가 해석하지 않고 그대로 돌려준다는 뜻입니다.
|
||
|
||
운영 HTTPS에서는 먼저 `Secure=true`를 설정해야 합니다. Replica를 늘린다면 같은 cookie를 검증할 secret을 어떻게 배포하고 교체할지도 정해야 합니다.
|
||
|
||
**3단계 — 인증된 `/api/edge`를 auth 결과와 upstream 요청으로 분해한다**
|
||
|
||
로그인 뒤 browser가 보내는 example input은 다음과 같습니다.
|
||
|
||
```http
|
||
GET http://localhost:8088/api/edge
|
||
Cookie: AP4_SESSION=<opaque-session>
|
||
```
|
||
|
||
공격자가 다음 header를 일부러 추가했다고 가정해도 됩니다.
|
||
|
||
```http
|
||
X-Auth-Request-User: spoofed-admin
|
||
X-Auth-Request-Email: spoofed-admin@example.test
|
||
X-Internal-Auth-Token: attacker-controlled-token
|
||
```
|
||
|
||
Nginx는 먼저 같은 internal `/oauth2/auth` subrequest를 만듭니다. oauth2-proxy가 session을 유효하다고 판단하면 auth response에 `X-Auth-Request-User`, `X-Auth-Request-Email`과 갱신된 cookie가 있을 경우 `Set-Cookie`를 돌려줍니다. Nginx는 `auth_request_set`으로 이 값을 local variable에 복사합니다.
|
||
|
||
```text
|
||
$auth_user ← oauth2-proxy X-Auth-Request-User
|
||
$auth_email ← oauth2-proxy X-Auth-Request-Email
|
||
$auth_cookie ← oauth2-proxy Set-Cookie
|
||
```
|
||
|
||
그다음 original request를 그대로 전달하지 않습니다. Exact external `/api/edge`는 internal upstream `/edge/me`로 다시 매핑됩니다.
|
||
|
||
```http
|
||
GET http://app:8081/edge/me
|
||
X-Auth-Request-User: <oauth2-proxy-authenticated-user>
|
||
X-Auth-Request-Email: <oauth2-proxy-authenticated-email>
|
||
X-Internal-Auth-Token: <nginx-environment-secret>
|
||
```
|
||
|
||
Client가 보낸 세 header를 merge하지 않고 위 값으로 덮어씁니다. 따라서 공격자가 `spoofed-admin`을 보냈어도 upstream input은 oauth2-proxy가 확인한 실제 user가 됩니다.
|
||
|
||
General `location /`도 현재는 `proxy_pass http://app:8081/edge/me`를 사용합니다. 즉 `/orders/123` 같은 arbitrary upstream path를 보존하는 범용 transparent reverse proxy가 아닙니다. Root와 `/api/edge` 예시를 동일 identity response로 연결해 auth-request와 header trust를 관찰하는 fixture입니다.
|
||
|
||
**4단계 — controller가 edge header를 reader JSON으로 바꾼다**
|
||
|
||
Spring `EdgeIdentityController.currentUser(HttpServletRequest)`가 `/edge/me`를 받습니다. 변환 순서는 짧지만 신뢰 경계는 두 겹입니다.
|
||
|
||
1. `X-Auth-Request-User`를 읽고 blank인지 확인합니다.
|
||
2. `X-Internal-Auth-Token`을 읽습니다.
|
||
3. Configured token bytes와 supplied bytes를 `MessageDigest.isEqual`로 비교합니다.
|
||
4. 둘 다 유효하면 allowlisted identity field만 output Map에 넣습니다.
|
||
|
||
정상 output은 다음 네 field입니다.
|
||
|
||
```json
|
||
{
|
||
"pattern": "AP4-edge-forward-auth",
|
||
"user": "regular-user",
|
||
"email": "regular-user@example.test",
|
||
"identityHeader": "X-Auth-Request-User"
|
||
}
|
||
```
|
||
|
||
User header가 없거나 internal token이 없거나 틀리면 controller output은 다음과 같습니다.
|
||
|
||
```http
|
||
HTTP/1.1 401 Unauthorized
|
||
Content-Type: application/json
|
||
```
|
||
|
||
```json
|
||
{
|
||
"error": "trusted edge authentication is required"
|
||
}
|
||
```
|
||
|
||
이 검사는 Spring Security의 `/edge/**` rule이 수행하는 것이 아닙니다. 현재 `SecurityConfig`는 `/edge/**`를 `permitAll`로 두고 `/edge/me` controller가 직접 internal token을 확인합니다. 새 edge endpoint를 추가하면서 같은 method를 호출하지 않으면 보호가 자동 상속되지 않습니다. Production expansion에서는 filter, interceptor 또는 security chain처럼 모든 대상 endpoint에 적용되는 공통 경계로 옮겨야 합니다.
|
||
|
||
AP4의 end-to-end model 변환은 다음과 같습니다.
|
||
|
||
```text
|
||
AP4_SESSION cookie
|
||
→ internal auth subrequest
|
||
→ oauth2-proxy session result
|
||
→ X-Auth-Request-User / Email
|
||
→ nginx-owned allowlisted headers + internal token
|
||
→ HttpServletRequest headers
|
||
→ controller Map
|
||
→ browser identity JSON
|
||
```
|
||
|
||
AP1·AP2·AP3의 Resource Server는 JWT의 issuer와 audience를 직접 확인합니다. AP4 `/edge/me`는 JWT를 입력으로 받지 않습니다. 대신 edge를 거쳤다는 network topology와 internal-token check를 신뢰하고, edge가 투영한 user와 email만 사용합니다.
|
||
|
||
**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 |
|
||
|
||
Redirect 없는 JSON 401은 정확히 `/api/edge` 예시 path에만 구성되어 있습니다. “AP4의 모든 API path가 JSON 401을 반환한다”고 일반화하면 안 됩니다. 다른 path는 현재 general location의 login redirect 규칙을 따릅니다.
|
||
|
||
App과 oauth2-proxy의 host port가 닫혀 있다는 사실도 중요합니다. Controller의 shared token만으로는 외부 직접 접근이 어려워지는 network property를 대신할 수 없고, network isolation만으로는 내부 workload나 잘못된 proxy header가 신뢰되는 문제를 대신할 수 없습니다. 현재 예시는 둘을 함께 사용합니다.
|
||
|
||
**6단계 — identity projection의 범위를 인가로 오해하지 않는다**
|
||
|
||
현재 edge response는 user와 email을 전달할 뿐 role, groups, tenant, authentication method, token expiry를 전달하지 않습니다. AP4 패턴 자체가 추가 claim을 금지하는 것은 아닙니다. 다만 header를 늘릴 때마다 다음 계약이 필요합니다.
|
||
|
||
- oauth2-proxy 또는 별도 auth service가 claim을 어떤 source에서 읽는가
|
||
- Nginx가 어떤 response header만 allowlist하는가
|
||
- Client-supplied 동명 header를 항상 지우거나 덮어쓰는가
|
||
- 다중 값, separator, escaping과 최대 크기는 무엇인가
|
||
- Upstream이 header presence만 볼지 값과 internal service identity를 함께 검증할지
|
||
- Role이 바뀌었을 때 proxy session과 downstream authorization이 언제 갱신되는가
|
||
|
||
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 -->
|
||

|
||
|
||
<details>
|
||
<summary>Diagram description</summary>
|
||
|
||
브라우저가 AP4_SESSION cookie로 Nginx의 /api/edge를 호출합니다. Nginx는 oauth2-proxy의 internal auth endpoint에 subrequest를 보내고 인증된 user와 email 결과를 받습니다. 이어서 client가 보낸 동명 header를 사용하지 않고 oauth2-proxy 결과와 Nginx 환경의 internal token으로 /edge/me 요청을 새로 조립합니다. Spring controller가 user header와 internal token을 함께 확인해 identity JSON을 만들고 Nginx가 브라우저에 전달합니다.
|
||
|
||
</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이 들어와도 네 애플리케이션 경계는 바뀌지 않는다
|
||
|
||
Google federation을 다섯 번째 애플리케이션 패턴으로 세면 두 protocol boundary를 섞게 됩니다. Google은 Keycloak 앞의 upstream identity provider입니다. 사용자가 Keycloak login page에서 Google을 선택하면 browser는 upstream authorization을 수행하고, Keycloak이 upstream response를 검증해 local identity와 연결합니다.
|
||
|
||
그다음 애플리케이션으로 나가는 데이터는 다시 Keycloak이 만듭니다.
|
||
|
||
```text
|
||
Google identity assertion
|
||
→ Keycloak broker validation
|
||
→ provider alias + upstream sub로 account identity 결정
|
||
→ Keycloak local user/session
|
||
→ Keycloak authorization code
|
||
→ AP1·AP2·AP3·AP4 중 선택한 downstream 경계
|
||
```
|
||
|
||
AP1 Resource Server가 신뢰하는 issuer도 Keycloak이고, AP2·AP3가 교환하는 code의 issuer도 Keycloak이며, AP4 oauth2-proxy가 연결하는 OIDC provider도 Keycloak입니다. Upstream email이 같다는 이유만으로 application이 Google token을 직접 신뢰하거나 account를 자동 병합하지 않습니다. Stable identity key는 provider와 upstream `sub` 조합이고, email 충돌은 기존 계정 소유권 증명 없이 자동 연결하지 않는 별도 account-linking 문제입니다.
|
||
|
||
현재 자동화는 controllable mock OIDC provider로 broker와 claim mapping 계약을 검증하도록 작성되어 있습니다. 실제 Google account, public HTTPS callback, consent와 production domain policy를 통과했다는 뜻은 아닙니다. Upstream IdP 검증 범위와 AP1–AP4의 application credential 경계를 분리해야 이 사실 경계도 유지됩니다.
|
||
|
||
## 결정이 지켜지는지 확인하는 방법
|
||
|
||
코드 흐름을 모두 따라간 뒤에는 제가 이해한 경계가 테스트에서도 같은 모습으로 나타나는지 확인했습니다. 테스트 개수보다 어떤 입력을 넣었고 어느 지점에서 어떤 결과가 나왔는지를 먼저 보았습니다.
|
||
|
||
### 테스트 개수보다 경계의 input과 output을 확인한다
|
||
|
||
“로그인이 성공한다”는 네 패턴 모두에서 너무 넓은 성공 기준입니다. 로그인 뒤 browser에 refresh token이 노출돼도 화면은 열릴 수 있고, spoofed identity header가 통과해도 정상 사용자는 자기 이름을 볼 수 있습니다. 따라서 verification은 선택한 경계의 입력과 출력에 직접 연결되어야 합니다.
|
||
|
||
아래 표는 최신 실행 성적표가 아니라 커밋된 자동 테스트가 확인하도록 정의한 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 |
|
||
|
||
### AP1 검증을 단계별로 읽는 법
|
||
|
||
AP1 browser contract는 authorization request에서 `response_type=code`, S256 method와 challenge를 확인합니다. Token request를 intercept해 authorization-code grant이고 access·refresh·ID token이 응답에 존재하는지 봅니다. 그다음 UI가 Resource Server를 직접 호출해 200을 받고, decoded access token의 audience에 `keycloak-pattern-api`가 있는지 확인합니다.
|
||
|
||
이 test가 token 노출의 한계도 일부러 재현합니다. Browser fetch를 hook한 뒤 API 호출에서 Bearer access token을 관측하도록 작성되어 있습니다. 동시에 Local Storage와 Session Storage에 access token substring이 남지 않는지 확인합니다. 둘을 함께 봐야 “persistent storage에는 없지만 실행 중 JavaScript 경계에는 있다”는 설계가 검증됩니다.
|
||
|
||
Negative input은 wrong audience와 wrong issuer입니다. 두 diagnostic Resource Server가 같은 JWT를 401로 거부해야 합니다. Invalid signature와 expired JWT를 전용 E2E로 넣는 계약은 없습니다. Unit test에서 synthetic JWT를 주입해 controller 200을 확인하는 것은 실제 Nimbus signature와 issuer validation을 통과했다는 증거도 아닙니다.
|
||
|
||
Refresh test는 새 refresh token 발급, 이전 refresh token 거부, revocation 뒤 refresh 거부를 확인하도록 정의합니다. 이미 발급된 access token이 만료 전까지 200일 수 있다는 결과도 함께 봅니다. 자동 silent renewal, exact SSO cookie flags, CORS preflight와 callback error UX는 이 계약 밖입니다.
|
||
|
||
성공 기준을 운영 문장으로 바꾸면 다음과 같습니다.
|
||
|
||
```text
|
||
정상: challenge가 있고 code flow이며, API는 expected issuer와 audience JWT만 200
|
||
실패: wrong issuer/audience는 401, regular user의 admin endpoint는 403
|
||
노출 경계: token은 runtime JavaScript에서 보이지만 reload용 Web Storage 복사본은 없음
|
||
```
|
||
|
||
### AP2 검증을 단계별로 읽는 법
|
||
|
||
AP2는 먼저 `/token/boundary` output의 세 boolean을 봅니다. Server에 access와 refresh token이 있고 `browserReceivesRefreshToken`은 false여야 합니다. 그다음 `/token/access` response의 key가 `access_token`, `expires_at`, `token_type` 세 개뿐인지 확인하고 `Cache-Control`에 `no-store`가 있는지 봅니다.
|
||
|
||
반환된 access JWT의 audience를 decode하고 browser가 Resource Server를 직접 호출해 200을 받는지도 확인하도록 작성되어 있습니다. Cookie는 `AP2_SESSION`, HttpOnly, SameSite=Lax여야 하고 Local Storage와 Session Storage에는 access token 원문이나 `refresh_token` 문자열이 없어야 합니다.
|
||
|
||
이 acceptance contract가 입증하지 않는 것도 분명합니다. `/token/access`를 두 번 불렀을 때 두 번째 요청이 거부되는지 확인하지 않으며 code에도 그 기능이 없습니다. Access expiry 뒤 실제 refresh, logout 뒤 session과 authorized-client 삭제, restart와 replica 이동, CORS 거부 origin은 검증하지 않습니다.
|
||
|
||
AP2를 검토할 때는 다음 두 assertion을 별도로 유지해야 합니다.
|
||
|
||
```text
|
||
assertion A: refresh token은 browser response에 없다
|
||
assertion B: access token은 browser response와 Authorization header에 있다
|
||
```
|
||
|
||
A가 통과했다고 B까지 사라진 것으로 해석하면 AP2와 AP3의 경계를 혼동합니다.
|
||
|
||
### AP3 검증을 단계별로 읽는 법
|
||
|
||
AP3 authorization request는 `bff-confidential` client와 PKCE S256 challenge를 사용해야 합니다. Callback은 BFF URI의 GET이고, browser request list에는 Keycloak token endpoint와 Resource Server 8081 direct request가 없어야 합니다. `AP3_SESSION`은 HttpOnly·SameSite=Lax이고 browser Web Storage는 비어 있어야 합니다.
|
||
|
||
`/bff/token-boundary`는 server access·refresh token booleans true, `browserTokenCount: 0`, `csrfProtectionEnabled: true`를 반환해야 합니다. 앞서 설명했듯 browser count는 literal이므로 network와 storage assertion이 보완합니다. `/bff/api/me`는 BFF URL에서 200을 받고 downstream response에 username과 expected audience가 있어야 합니다.
|
||
|
||
State-changing path는 최소 세 요청으로 확인합니다.
|
||
|
||
1. `GET /bff/csrf`가 nonempty token metadata와 `XSRF-TOKEN` cookie를 만듭니다.
|
||
2. Session cookie는 있지만 CSRF header가 없는 POST는 403이어야 합니다.
|
||
3. Raw cookie 값을 `X-XSRF-TOKEN`에 넣은 POST는 200과 `theme: "dark"`를 반환해야 합니다.
|
||
|
||
별도의 cross-site request는 SameSite=Lax 때문에 `AP3_SESSION`이 전송되지 않는지 봅니다. Same-site이지만 origin이 다른 request에서는 cookie가 실릴 수 있으므로 CSRF header 부재로 403이 되어야 합니다. 두 negative case가 서로 다른 방어선을 검증합니다.
|
||
|
||
이 테스트는 preference의 사용자별 격리나 persistence를 검증하지 않습니다. 실제 구현은 process-global `AtomicReference` 하나입니다. Shared session store, token encryption, logout, downstream failure mapping, timeout과 per-route authorization도 acceptance contract 밖입니다.
|
||
|
||
### AP4 검증을 단계별로 읽는 법
|
||
|
||
AP4는 미인증 browser navigation과 API request를 분리합니다. Cookie 없는 root navigation은 302로 login을 시작해야 합니다. Cookie 없는 exact `/api/edge` request는 `Location` header 없이 401이어야 합니다. 이 두 assertion이 general browser UX와 programmatic API UX를 분리합니다.
|
||
|
||
Authorization request는 `edge-proxy`와 PKCE S256 challenge를 포함해야 합니다. Login 뒤 browser에는 HttpOnly·SameSite=Lax `AP4_SESSION`이 있어야 하고 browser network에 Keycloak token endpoint가 없어야 합니다. Local Storage와 Session Storage가 비어 있고 `document.cookie`로 session cookie를 읽을 수 없어야 합니다.
|
||
|
||
Spoofing test는 authenticated browser가 `X-Auth-Request-User: spoofed-admin`, fake email과 attacker-controlled internal token을 모두 보냅니다. Response status는 200이지만 output user는 원래 authenticated user여야 하고 `spoofed-admin`이면 안 됩니다. 단순히 request가 실패하는지 보는 것이 아니라 Nginx가 client input을 overwrite하고 정상 identity를 보존하는지 확인합니다.
|
||
|
||
마지막으로 외부 `/oauth2/auth`는 404, host의 4180과 8081은 접근 불가여야 합니다. Backend unit contract는 missing user header, missing internal token과 wrong token이 모두 401이고 correct edge input은 200인지 확인합니다.
|
||
|
||
남은 공백은 role propagation, new endpoint에 대한 centralized enforcement, state-changing upstream request의 CSRF, session renewal, replica sharing, internal secret rotation입니다. Traefik 대안은 configuration을 load할 수 있는 수준이지 현재 hardened `/edge/me`를 같은 속성으로 통과시키는 end-to-end 대안이 아닙니다.
|
||
|
||
### 실제 runtime 검증을 수행할 때의 안전한 순서
|
||
|
||
현재 pattern별 verify procedure는 volume reset을 포함합니다. 검증하려면 먼저 disposable environment인지 확인해야 합니다.
|
||
|
||
**사전 조건**
|
||
|
||
- 보존해야 할 Keycloak realm, user 또는 PostgreSQL data가 같은 Compose project에 없어야 합니다.
|
||
- 필요한 secret과 test user password는 environment로 주입하고 output log에 값을 출력하지 않아야 합니다.
|
||
- Browser automation이 사용할 Chrome 계열 executable과 container runtime이 준비되어야 합니다.
|
||
- 실행 전 현재 volume이 필요하다면 별도 project로 복제하거나 backup·snapshot을 만들어야 합니다.
|
||
|
||
**순서와 기대 결과**
|
||
|
||
1. 한 번에 한 pattern tip만 대상으로 선택합니다. 여러 pattern stack을 같은 port에 동시에 올리지 않습니다.
|
||
2. Static realm validation과 unit test를 먼저 실행합니다. 여기서 client type, redirect URI, audience mapper 또는 controller contract가 실패하면 browser E2E로 진행하지 않습니다.
|
||
3. Disposable volume이라는 것을 다시 확인한 뒤 해당 pattern stack을 build합니다. Health check가 안정되지 않으면 login test를 시작하지 않습니다.
|
||
4. Browser E2E를 실행하고 위 표의 endpoint별 status, cookie flag, network 요청과 payload key를 확인합니다.
|
||
5. Pattern-specific negative input까지 모두 관측한 뒤에만 boundary가 유지된다고 판단합니다.
|
||
6. 검증이 끝나면 test용 stack을 내리고, backup이 필요했던 환경이라면 원래 project와 volume을 복구한 뒤 health와 login을 다시 확인합니다.
|
||
|
||
**중단 조건**
|
||
|
||
- 대상 volume의 소유와 용도를 확정할 수 없음
|
||
- Redirect URI나 host가 test fixture와 다른 실제 environment를 가리킴
|
||
- Secret이 command line, browser output 또는 version-controlled file에 노출됨
|
||
- Health check, expected 401·403 또는 header overwrite 중 하나라도 불일치함
|
||
|
||
이 경우 나머지 단계를 계속 실행해 “전체 PASS”를 만들면 안 됩니다. 실패한 hop의 actual input과 output을 먼저 보존하고, 설정·network·application 중 어느 경계가 깨졌는지 분리해 진단해야 합니다.
|
||
|
||
## 얻은 것, 잃은 것, 적용하지 않을 때
|
||
|
||
### 네 패턴은 사다리가 아니라 서로 다른 운영 계약이다
|
||
|
||
네 패턴을 모두 실행하고 나니 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을 적용하거나 떠날 기준
|
||
|
||
제가 AP1을 다시 선택한다면 브라우저에서 OAuth 동작을 직접 다뤄야 하는 경우입니다. 그 이유는 code 교환과 token 사용을 가장 가까이서 볼 수 있기 때문입니다. 여러 client가 같은 Resource Server를 직접 부르고 JWT 자체가 API credential이어야 할 때도 별도 server session을 만들지 않아도 되었습니다.
|
||
|
||
그런데 조직 정책이 JavaScript에 refresh token이나 access token을 허용하지 않는다면 memory-only로는 해결되지 않았습니다. AP2는 refresh token만 server로 옮겼고, AP3는 access token까지 브라우저 응답에서 없앴습니다. 새로고침 뒤에도 로그인을 남기려고 token을 Local Storage로 옮기려 한다면 편의보다 먼저 노출 시간이 길어지는 비용을 비교해야 했습니다.
|
||
|
||
AP1을 유지할 때는 redirect와 origin을 좁히고 PKCE S256을 사용했습니다. Implicit·direct grant는 끄고 access token 수명은 짧게 두었습니다. Refresh rotation과 issuer·audience·time·signature 검증도 함께 확인했습니다. CSP와 dependency integrity는 실행 중 script 위험을 줄이는 별도 방어선으로 남았습니다. Admin role을 쓴다면 로그인 성공과 role 인가를 서로 다른 테스트로 남겨야 합니다.
|
||
|
||
### AP2를 적용하거나 건너뛸 기준
|
||
|
||
AP2는 “Refresh token은 server에 두고 브라우저는 여러 Resource Server를 직접 부르게 한다”는 요구가 분명할 때 의미가 있었습니다. 기존 Bearer API와 CORS, client-side 요청 조합을 그대로 두면서 오래 사는 credential만 분리할 수 있었습니다.
|
||
|
||
반대로 단순히 AP1보다 안전해 보인다는 이유로 mediator를 넣으면 두 구조의 비용만 함께 가졌습니다. Mediator state를 확장하고 복구해야 했지만 access token은 여전히 XSS에 노출되었습니다. 브라우저 token을 금지하려는 것이라면 AP3가 더 직접적이었고, server state를 운영할 이유가 없다면 AP1이 더 단순했습니다.
|
||
|
||
현재 구현을 운영에 가져가려면 authorized client를 오래 보관할 저장소가 필요했습니다. HttpSession도 여러 인스턴스에서 공유하거나 특정 인스턴스로만 보내야 했습니다. 저장 token 암호화, logout 때 두 state 삭제, refresh 실패 뒤 재인증, handoff rate limit도 구현해야 했습니다. 정말 한 번만 전달해야 한다면 현재 raw access token endpoint를 재사용할 수 없었습니다. 짧게 사는 일회용 code를 만들고 audience가 제한된 exchange endpoint에서 한 번만 소비하는 별도 protocol이 필요했습니다.
|
||
|
||
### AP3를 적용하거나 분해할 기준
|
||
|
||
브라우저가 OAuth token을 받아서는 안 되고 backend가 화면에 맞춰 여러 API를 조합해야 한다면 AP3가 자연스러웠습니다. Downstream API가 늘어나도 브라우저는 BFF 하나만 알면 되었습니다. Token refresh와 provider마다 다른 처리도 server 안에 둘 수 있었습니다.
|
||
|
||
그런데 BFF는 단순히 요청을 전달하는 proxy가 아니었습니다. 로그인 state와 token을 가진 보안 구성요소가 되었습니다. Session 복제와 authorized-client 저장, 암호화 key 교체, CSRF, rate limiting, 경로별 인가, timeout과 오류 변환, logout을 모두 운영해야 했습니다. 모든 UI 요청이 이곳을 지나므로 병목과 단일 장애 지점도 준비해야 했습니다.
|
||
|
||
Preference 예제를 확인하면서는 process 전체가 공유하는 `AtomicReference` 하나에 값이 들어간다는 점도 발견했습니다. 그래서 한 사용자가 바꾼 값을 다른 사용자도 읽을 수 있었습니다. 실제 사용자 state는 인증된 stable subject를 key로 사용하는 repository와 인가 경계로 옮겨야 합니다. `AtomicReference`는 CSRF 동작을 눈으로 확인하기 위한 값일 뿐 운영용 데이터 모델은 아니었습니다.
|
||
|
||
### AP4를 적용하거나 경계를 되돌릴 기준
|
||
|
||
Upstream에 OAuth library를 넣기 어렵거나 여러 legacy service 앞에 같은 로그인 절차를 두어야 한다면 AP4가 잘 맞았습니다. Upstream은 provider token의 형식을 몰라도 되었고, login route와 session policy도 edge에서 통일할 수 있었습니다.
|
||
|
||
그런데 proxy가 붙인 header를 믿는 순간 edge가 전체 인증의 출발점이 되었습니다. 외부에서 backend로 바로 가는 길이 열려 있거나 브라우저가 보낸 header를 그대로 통과시키면 다른 사용자를 흉내 낼 수 있었습니다. Trusted proxy 범위를 너무 넓게 잡거나 shared secret이 노출되어도 마찬가지였습니다. 그래서 network policy와 header overwrite, workload identity를 서로 독립된 방어선으로 두었습니다.
|
||
|
||
현재 fixture는 `/api/edge`와 `/`를 모두 `/edge/me`로 바꾸었습니다. 그래서 generic reverse proxy의 path, method, body, streaming, websocket, large header 동작까지 입증하지는 못했습니다. 실제 upstream을 붙일 때는 URI rewrite, request body, timeout, retry, response header, logout, state-changing request protection을 별도로 설계해야 합니다.
|
||
|
||
Traefik ForwardAuth 대안도 살펴보았지만 현재 Nginx와 같은 속성을 내려면 최소 네 가지가 필요했습니다. `trustForwardHeader=false`, allowlisted auth response header만 복사, 별도 login redirect UX, upstream internal-token 또는 더 강한 workload identity 주입입니다. 현재 대안 설정에는 마지막 항목이 없어서 drop-in equivalence를 입증하지 못했습니다.
|
||
|
||
### 변경 경로도 credential contract의 변화로 본다
|
||
|
||
네 패턴을 나란히 놓은 뒤에는 한 패턴에서 다른 패턴으로 옮길 때 무엇을 바꿔야 하는지도 적어 보았습니다. AP1에서 AP2로 옮겨도 Resource Server의 Bearer 계약은 남았습니다. 대신 OAuth callback은 SPA에서 mediator로 이동했습니다. 브라우저는 access endpoint와 session cookie를 새로 다뤄야 했고 CORS origin도 AP2 UI로 바뀌었습니다.
|
||
|
||
AP2에서 AP3로 옮길 때는 브라우저 계약도 달라졌습니다. `/token/access`와 직접 `/api/me` 호출을 없애고 모든 UI API를 `/bff/**`로 바꿔야 했습니다. 그때부터 downstream 오류를 어떻게 보여 줄지와 CSRF 처리도 server가 맡았습니다.
|
||
|
||
AP3에서 AP4로 옮기는 일은 한 단계 업그레이드가 아니었습니다. 애플리케이션이 갖고 있던 session과 API 조합 책임을 edge session과 identity header로 바꾸는 일이었습니다. BFF가 사용자별 인가까지 했다면 그 로직을 upstream이나 별도 policy service로 다시 옮겨야 했습니다.
|
||
|
||
반대로 AP4의 upstream이 더 많은 claim과 애플리케이션 흐름을 요구하기 시작하면 BFF로 돌아갈 수 있었습니다. Header 종류를 계속 늘리는 것보다 API 조합 책임을 애플리케이션에 돌려주는 편이 명확할 수 있었습니다. 저는 어느 쪽으로 옮길지를 번호로 판단하지 않았습니다. 새로 일을 맡는 곳이 state와 검증을 감당할 수 있는지를 보았습니다.
|
||
|
||
<!-- techviz:begin id=credential-contract-migration context-sha256=df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371 -->
|
||
<!-- techviz:generate id=credential-contract-migration -->
|
||

|
||
|
||
<details>
|
||
<summary>Diagram description</summary>
|
||
|
||
네 항목을 호출 계약, 소유권, 브라우저 계약, 운영 책임, 전환 성격의 다섯 축으로 비교합니다. AP1에서 AP2는 Resource Server의 Bearer 계약을 유지하면서 OAuth callback을 mediator로 옮깁니다. AP2에서 AP3는 browser의 access endpoint와 direct API 호출을 없애고 UI API를 BFF 계약으로 바꿉니다. AP3에서 AP4는 application-owned session과 orchestration을 edge-owned session과 identity projection으로 전환합니다. AP4에서 AP3로 되돌아가는 선택도 별도 항목으로 두어 번호 순서나 성숙도 상승을 암시하지 않습니다.
|
||
|
||
</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 -->
|
||
|
||
## 결국 지키려던 것은 무엇이었나
|
||
|
||
처음에는 브라우저에서 token을 멀리 옮길수록 더 안전한 구조라고 생각했습니다. 그런데 AP1의 token을 server로 옮기자 session과 저장소가 필요했고, edge로 더 옮기자 identity header를 검증할 network 조건이 필요했습니다. Token을 옮길 때마다 session 저장소나 header 검증 같은 다른 운영 문제가 생겼습니다.
|
||
|
||
그래서 저는 패턴을 고르기 전에 누가 실제로 무슨 일을 하는지부터 답하기로 했습니다. 누가 code를 token으로 바꾸는지, refresh token은 어디에 남는지, API 요청은 브라우저와 BFF, edge 중 누가 만드는지를 확인했습니다. 마지막에는 JWT와 identity header 중 무엇을 어떤 근거로 믿는지도 적었습니다.
|
||
|
||
같은 과정을 다른 시스템에 적용할 때는 다음 질문에 구체적인 데이터 이름으로 답하면 됩니다.
|
||
|
||
- Browser JavaScript가 access token response를 받아도 되는가?
|
||
- Refresh token과 login session은 어느 저장소에서 restart와 replica 이동을 견딜 것인가?
|
||
- API의 실제 caller는 browser, BFF, edge 중 누구여야 하는가?
|
||
- 보호 자원은 signed JWT를 검증하는가, 아니면 trusted edge header를 검증하는가?
|
||
- Cookie가 credential이면 어느 state-changing endpoint에서 CSRF를 어떻게 검증하는가?
|
||
- Role과 account identity는 어느 claim에서 어떤 application 권한으로 바뀌는가?
|
||
- 401, 403, refresh failure와 logout을 어느 계층이 최종 HTTP output으로 번역하는가?
|
||
|
||
저는 답을 endpoint와 handler, 중간 object, 다음 요청의 입력과 최종 response까지 적어 보았습니다. 여기까지 적을 수 있어야 설계가 실제 코드와 맞닿았습니다. AP1, AP2, AP3, AP4라는 이름은 그 흐름을 모두 확인한 뒤에 붙이는 짧은 요약일 뿐이었습니다.
|