Compare commits

..
Author SHA1 Message Date
DongHyeonkaandClaude Opus 5 e856e7af4d docs: C-1 — killing the SSO session logs nobody out
One user session carries a client session per application, so visiting the second app skips the login screen. Deleting the identity provider session leaves both application sessions untouched and both apps keep serving, because the identity provider, the application session and the access token each have their own lifetime.

That inverts the B-2 finding: there the app session was cleared and the surviving SSO session let the user straight back in. Either way, clearing one side leaves the other. It also means an identity provider outage is a single point of failure for logging in, not for already-authenticated users, and the failure arrives late and all at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:49:36 +09:00
DongHyeonkaandClaude Opus 5 bcb563a04e docs: B-7 — the cookie secret has no overlap window and rotation orphans sessions
oauth2-proxy carries the authorization request in a signed cookie, so the callback can land on a different replica and still succeed, which is the opposite of the BFF failure in B-0. Sharing is therefore just sharing one Secret.

Rotating it is all-or-nothing: --cookie-secret is singular, so there is no second key to read old tickets with, and the log shows both the validation failure and Error removing session, leaving the Redis session orphaned because the key cannot be derived from a ticket that will not decode.

Getting there required two diagnoses: the callback 502 came from the full session riding in Set-Cookie past nginx's buffer, and every earlier attempt to read nginx config returned nothing because sudo on the host asks for a password while the guests do not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:44:00 +09:00
DongHyeonkaandClaude Opus 5 aa2c3907f5 docs: B-6 — rotation is safe, retiring the old key is not
Adding a higher-priority RSA provider leaves both kids in JWKS, so tokens signed before and after the rotation both validate. Deleting the old provider makes its tokens 401 immediately, and the resource server's JWKS cache does not buy a grace period because an unknown kid triggers a refetch.

The encryption key Q3 asks about does not exist yet, since B-2 showed the tokens are stored as plaintext JWTs, so the measured signing-key rotation is what its design has to copy: write with one key, read with several, and keep the overlap longer than the lifetime of anything signed with the old one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:33:42 +09:00
DongHyeonkaandClaude Opus 5 f45a2a2aaa docs: B-5 — the pod stays Ready while every request hangs
Stopping Redis returns HTTP 000 rather than an error because the client waits on reconnect, and the pod keeps serving traffic because the redis health indicator is not in the readiness group even though /actuator/health returns 503. That is the mirror image of A-2, where Keycloak put its database check in readiness and the pods left the Service.

Turning on AOF with config set created the appendonlydir and still lost everything on pod deletion, because /data was the container filesystem; adding a PVC makes the same setting work. Volume first, persistence setting second.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:29:17 +09:00
DongHyeonkaandClaude Opus 5 7dc0a3e5da docs: B-4 — the edge does not overwrite the headers it never sets
Two headers of the same name both arrive rather than one overwriting the other, because nginx only replaces headers it sets with proxy_set_header. A comma inside a role name is indistinguishable from the delimiter, and the size limit is a cliff: Tomcat returns 400 around 8KB and the connection dies around 16KB, so the same cause produces two different-looking failures.

Forged identity headers reach the upstream untouched while the JWT-protected paths return 401, which is Q4's own point that a header-fed upstream has nothing to verify against. By Q4's checklist that answer alone points at the BFF structure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:23:41 +09:00
DongHyeonkaandClaude Opus 5 b16e1dccf7 docs: B-3 — concurrent refresh does not lose a race, it destroys the session
Five simultaneous refreshes with one token return a single 200, and that winner's new token is already dead. Reuse detection removes the client session while the user session stays, which is why the other responses read Session doesn't have required client rather than a reuse error.

Comparing policies shows rotation off passes all five and keeps the session, while raising refreshTokenMaxReuse to one still destroys it. Since no retry can recover a removed client session, Q2's own criterion resolves to a lock, and a database row lock is the natural place because its lifetime is tied to the connection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:20:04 +09:00
DongHyeonkaandClaude Opus 5 711878379c docs: B-2 — sharing the stores fixes one problem and exposes three more
Moving the authorized client to JdbcOAuth2AuthorizedClientService makes tokens work across replicas, so the session-in-Redis plus tokens-in-PostgreSQL split holds. The table then shows what sharing cannot fix: the primary key is (client_registration_id, principal_name) with no session in it, so a second login for the same user updates the same row rather than adding one.

The refresh token sits in bytea as the raw JWT, readable with convert_from, and logout clears only the Redis session while the plaintext token row and the Keycloak SSO session both survive. The schema itself failed silently first because the default DDL uses blob, which PostgreSQL does not have, and continue-on-error swallowed it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:15:05 +09:00
72 changed files with 2685 additions and 4 deletions
@@ -0,0 +1,2 @@
[ 6898ms] [ERROR] Failed to load resource: the server responded with a status of 403 () @ https://app1.hyeonworks.com/logout:0
[ 23950ms] [ERROR] Failed to load resource: the server responded with a status of 403 () @ https://app1.hyeonworks.com/logout:0
@@ -0,0 +1,2 @@
[ 275ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://app2.hyeonworks.com/oauth2/callback?state=G2-BDWkehNWO7hGwhYCxXBVRKZ6AomLIrSLBtIXr0Gw%3A%2Fapi%2Fecho&session_state=vsW8xDlLJN3DUl-0B-X1WL7Q&iss=https%3A%2F%2Fauth.hyeonworks.com%2Frealms%2Fkeycloak-patterns&code=4155f58e-6a58-e47b-93bd-7e2b625c2b91.vsW8xDlLJN3DUl-0B-X1WL7Q.80431dbc-af81-4673-9790-ad06d1570b2e:0
[ 408ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://app2.hyeonworks.com/oauth2/callback?state=Da-7OcMB4f7vyHgr-6CqrTtJpmj1R_SfRfcE3CUJNzE%3A%2Ffavicon.ico&session_state=vsW8xDlLJN3DUl-0B-X1WL7Q&iss=https%3A%2F%2Fauth.hyeonworks.com%2Frealms%2Fkeycloak-patterns&code=96d66247-91b0-0cc1-89dc-e527c2b69bf2.vsW8xDlLJN3DUl-0B-X1WL7Q.80431dbc-af81-4673-9790-ad06d1570b2e:0
@@ -0,0 +1,2 @@
[ 266ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://app2.hyeonworks.com/oauth2/callback?state=TZnQvjIWCEySrf4PMg2WLVFoOfkyOJWB58f2LGjVfpo%3A%2Fapi%2Fecho&session_state=vsW8xDlLJN3DUl-0B-X1WL7Q&iss=https%3A%2F%2Fauth.hyeonworks.com%2Frealms%2Fkeycloak-patterns&code=8ae913a1-2647-0ed9-625e-3d80e1565024.vsW8xDlLJN3DUl-0B-X1WL7Q.80431dbc-af81-4673-9790-ad06d1570b2e:0
[ 416ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://app2.hyeonworks.com/oauth2/callback?state=uAYwZp59ncz95XjkJXAlIgsT7oksBQBViiQS07t2eow%3A%2Ffavicon.ico&session_state=vsW8xDlLJN3DUl-0B-X1WL7Q&iss=https%3A%2F%2Fauth.hyeonworks.com%2Frealms%2Fkeycloak-patterns&code=7b7fc816-0b27-93a0-83b8-656488813253.vsW8xDlLJN3DUl-0B-X1WL7Q.80431dbc-af81-4673-9790-ad06d1570b2e:0
@@ -0,0 +1,2 @@
[ 287ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://app2.hyeonworks.com/oauth2/callback?state=0EOFj1PoLyPil0dgukpi7zKW4JKnGZTP9Wj6EhTR-lw%3A%2Fapi%2Fecho&session_state=vsW8xDlLJN3DUl-0B-X1WL7Q&iss=https%3A%2F%2Fauth.hyeonworks.com%2Frealms%2Fkeycloak-patterns&code=d13cc206-133f-00a9-9908-599988c4d7cf.vsW8xDlLJN3DUl-0B-X1WL7Q.80431dbc-af81-4673-9790-ad06d1570b2e:0
[ 457ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://app2.hyeonworks.com/oauth2/callback?state=kbYC5O4_ELsoQFw85vyYfgWEqlI2yWImB4rmelbmSTM%3A%2Ffavicon.ico&session_state=vsW8xDlLJN3DUl-0B-X1WL7Q&iss=https%3A%2F%2Fauth.hyeonworks.com%2Frealms%2Fkeycloak-patterns&code=91f9fde9-f376-b6f3-4622-a1ba674cc4fd.vsW8xDlLJN3DUl-0B-X1WL7Q.80431dbc-af81-4673-9790-ad06d1570b2e:0
@@ -0,0 +1 @@
[ 307ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ https://app2.hyeonworks.com/favicon.ico:0
@@ -0,0 +1 @@
[ 261ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ https://app2.hyeonworks.com/favicon.ico:0
@@ -0,0 +1 @@
[ 882ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ https://app2.hyeonworks.com/favicon.ico:0
@@ -0,0 +1 @@
[ 178ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ https://app2.hyeonworks.com/favicon.ico:0
@@ -0,0 +1 @@
- generic [active] [ref=f35e1]: "{\"pattern\":\"AP3-backend-for-frontend\",\"principal\":\"labuser\",\"accessTokenStoredOnServer\":false,\"refreshTokenStoredOnServer\":false,\"browserTokenCount\":0,\"csrfProtectionEnabled\":true}"
@@ -0,0 +1,7 @@
- main [ref=f36e2]:
- heading "AP3 · Backend-for-Frontend" [level=1] [ref=f36e3]
- paragraph [ref=f36e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다.
- button "Keycloak 로그인" [ref=f36e5] [cursor=pointer]
- button "token 경계 확인" [ref=f36e6] [cursor=pointer]
- button "BFF 경유 API 호출" [ref=f36e7] [cursor=pointer]
- button "CSRF token으로 상태 변경" [ref=f36e8] [cursor=pointer]
@@ -0,0 +1 @@
- generic [active] [ref=f37e1]: "{\"pattern\":\"AP3-backend-for-frontend\",\"principal\":\"labuser\",\"accessTokenStoredOnServer\":true,\"refreshTokenStoredOnServer\":true,\"browserTokenCount\":0,\"csrfProtectionEnabled\":true}"
@@ -0,0 +1,7 @@
- main [ref=f38e2]:
- heading "AP3 · Backend-for-Frontend" [level=1] [ref=f38e3]
- paragraph [ref=f38e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다.
- button "Keycloak 로그인" [ref=f38e5] [cursor=pointer]
- button "token 경계 확인" [ref=f38e6] [cursor=pointer]
- button "BFF 경유 API 호출" [ref=f38e7] [cursor=pointer]
- button "CSRF token으로 상태 변경" [ref=f38e8] [cursor=pointer]
@@ -0,0 +1,7 @@
- main [ref=f39e2]:
- heading "AP3 · Backend-for-Frontend" [level=1] [ref=f39e3]
- paragraph [ref=f39e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다.
- button "Keycloak 로그인" [ref=f39e5] [cursor=pointer]
- button "token 경계 확인" [ref=f39e6] [cursor=pointer]
- button "BFF 경유 API 호출" [ref=f39e7] [cursor=pointer]
- button "CSRF token으로 상태 변경" [ref=f39e8] [cursor=pointer]
@@ -0,0 +1,4 @@
- generic [active] [ref=f40e1]:
- heading "502 Bad Gateway" [level=1] [ref=f40e3]
- separator [ref=f40e4]
- generic [ref=f40e5]: nginx/1.30.4
@@ -0,0 +1,4 @@
- generic [active] [ref=f41e1]:
- heading "502 Bad Gateway" [level=1] [ref=f41e3]
- separator [ref=f41e4]
- generic [ref=f41e5]: nginx/1.30.4
@@ -0,0 +1,4 @@
- generic [active] [ref=f42e1]:
- heading "502 Bad Gateway" [level=1] [ref=f42e3]
- separator [ref=f42e4]
- generic [ref=f42e5]: nginx/1.30.4
@@ -0,0 +1 @@
- generic [active] [ref=f43e1]: "{ \"headers\" : { \"host\" : [ \"app2.hyeonworks.com\" ], \"user-agent\" : [ \"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36\" ], \"accept\" : [ \"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7\" ], \"accept-encoding\" : [ \"gzip, deflate, br, zstd\" ], \"accept-language\" : [ \"en-US,en;q=0.9\" ], \"cookie\" : [ \"grafana_session=60c3e7ae41ffc00665f4a2c377def399; grafana_session_expiry=1788497124; _oauth2_proxy=djIuWDI5aGRYUm9NbDl3Y205NGVTMWlNall4TVRGbVltUXhabVJoWWpOaFpUSXhPREpsTWpnM01EQXhZakF5WVEuTk81VE82RHRod2NWZWstaHpPZVg1Zw==|1788500470|iPSRUlwHDB0XgC6sUdU4dq1EHq9WQDPYrDoezajKVUA=\" ], \"priority\" : [ \"u=0, i\" ], \"sec-ch-ua\" : [ \"\\\"Chromium\\\";v=\\\"152\\\", \\\"Not?A_Brand\\\";v=\\\"24\\\", \\\"Google Chrome\\\";v=\\\"152\\\"\" ], \"sec-ch-ua-mobile\" : [ \"?0\" ], \"sec-ch-ua-platform\" : [ \"\\\"Linux\\\"\" ], \"sec-fetch-dest\" : [ \"document\" ], \"sec-fetch-mode\" : [ \"navigate\" ], \"sec-fetch-site\" : [ \"none\" ], \"sec-fetch-user\" : [ \"?1\" ], \"upgrade-insecure-requests\" : [ \"1\" ], \"x-forwarded-email\" : [ \"labuser@example.com\" ], \"x-forwarded-host\" : [ \"app2.hyeonworks.com\" ], \"x-forwarded-port\" : [ \"443\" ], \"x-forwarded-preferred-username\" : [ \"labuser\" ], \"x-forwarded-proto\" : [ \"https\" ], \"x-forwarded-server\" : [ \"traefik-5d6fcf895-wpfhr\" ], \"x-forwarded-user\" : [ \"27df5ea9-8703-4ec5-badd-d972c583e1ff\" ], \"x-real-ip\" : [ \"100.123.124.30\" ] }, \"remoteAddr\" : \"100.123.124.30\", \"localAddr\" : \"10.42.0.53\", \"scheme\" : \"https\", \"secure\" : true, \"serverName\" : \"app2.hyeonworks.com\", \"serverPort\" : 443, \"requestUrl\" : \"https://app2.hyeonworks.com/api/echo\" }"
@@ -0,0 +1 @@
- generic [active] [ref=f44e1]: "{ \"headers\" : { \"host\" : [ \"app2.hyeonworks.com\" ], \"user-agent\" : [ \"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36\" ], \"accept\" : [ \"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7\" ], \"accept-encoding\" : [ \"gzip, deflate, br, zstd\" ], \"accept-language\" : [ \"en-US,en;q=0.9\" ], \"cookie\" : [ \"grafana_session=60c3e7ae41ffc00665f4a2c377def399; grafana_session_expiry=1788497124; _oauth2_proxy=djIuWDI5aGRYUm9NbDl3Y205NGVTMDVOemhrWm1GbFptSmtZV1JqWTJJNU5tTTNZbVV4TmpJMVpHSmhOVFl4TmcucmoxSnJPYjJKOW1ZV191aXVWa2FCZw==|1788500538|RoiStOeQcIDldxB3cckyO-OAiMgBjBfw5gOSvUsgTFU=\" ], \"priority\" : [ \"u=0, i\" ], \"sec-ch-ua\" : [ \"\\\"Chromium\\\";v=\\\"152\\\", \\\"Not?A_Brand\\\";v=\\\"24\\\", \\\"Google Chrome\\\";v=\\\"152\\\"\" ], \"sec-ch-ua-mobile\" : [ \"?0\" ], \"sec-ch-ua-platform\" : [ \"\\\"Linux\\\"\" ], \"sec-fetch-dest\" : [ \"document\" ], \"sec-fetch-mode\" : [ \"navigate\" ], \"sec-fetch-site\" : [ \"none\" ], \"sec-fetch-user\" : [ \"?1\" ], \"upgrade-insecure-requests\" : [ \"1\" ], \"x-forwarded-email\" : [ \"labuser@example.com\" ], \"x-forwarded-host\" : [ \"app2.hyeonworks.com\" ], \"x-forwarded-port\" : [ \"443\" ], \"x-forwarded-preferred-username\" : [ \"labuser\" ], \"x-forwarded-proto\" : [ \"https\" ], \"x-forwarded-server\" : [ \"traefik-5d6fcf895-wpfhr\" ], \"x-forwarded-user\" : [ \"27df5ea9-8703-4ec5-badd-d972c583e1ff\" ], \"x-real-ip\" : [ \"100.123.124.30\" ] }, \"remoteAddr\" : \"100.123.124.30\", \"localAddr\" : \"10.42.1.132\", \"scheme\" : \"https\", \"secure\" : true, \"serverName\" : \"app2.hyeonworks.com\", \"serverPort\" : 443, \"requestUrl\" : \"https://app2.hyeonworks.com/api/echo\" }"
@@ -0,0 +1,16 @@
- generic [ref=f45e3]:
- banner [ref=f45e4]:
- generic [ref=f45e5]: keycloak-patterns
- main [ref=f45e6]:
- heading "Sign in to your account" [level=1] [ref=f45e8]
- generic [ref=f45e12]:
- generic [ref=f45e13]:
- generic [ref=f45e14]: Username or email
- textbox "Username or email" [active] [ref=f45e17]
- generic [ref=f45e18]:
- generic [ref=f45e19]: Password
- generic [ref=f45e21]:
- textbox "Password" [ref=f45e24]
- button "Show password" [ref=f45e26] [cursor=pointer]:
- generic [aria-hidden] [ref=f45e27]:
- button "Sign In" [ref=f45e30] [cursor=pointer]
@@ -0,0 +1,7 @@
- main [ref=f46e2]:
- heading "AP3 · Backend-for-Frontend" [level=1] [ref=f46e3]
- paragraph [ref=f46e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다.
- button "Keycloak 로그인" [ref=f46e5] [cursor=pointer]
- button "token 경계 확인" [ref=f46e6] [cursor=pointer]
- button "BFF 경유 API 호출" [ref=f46e7] [cursor=pointer]
- button "CSRF token으로 상태 변경" [ref=f46e8] [cursor=pointer]
@@ -0,0 +1 @@
- generic [active] [ref=f47e1]: "{ \"headers\" : { \"host\" : [ \"app2.hyeonworks.com\" ], \"user-agent\" : [ \"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36\" ], \"accept\" : [ \"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7\" ], \"accept-encoding\" : [ \"gzip, deflate, br, zstd\" ], \"accept-language\" : [ \"en-US,en;q=0.9\" ], \"cookie\" : [ \"grafana_session=60c3e7ae41ffc00665f4a2c377def399; grafana_session_expiry=1788497124; _oauth2_proxy=djIuWDI5aGRYUm9NbDl3Y205NGVTMDJZakF5T0dFM01HWTJPV000WmpCa1lUazVOalpsWWpNMk9UY3laR1ptTWcuWmpUamNTbGxVSHV1RmJjQ2ZRd2lsUQ==|1788500797|17Hbo3RDtzOldnLZx2xOt3e34lHo_v0yqRNdqdKUx-g=\" ], \"priority\" : [ \"u=0, i\" ], \"sec-ch-ua\" : [ \"\\\"Chromium\\\";v=\\\"152\\\", \\\"Not?A_Brand\\\";v=\\\"24\\\", \\\"Google Chrome\\\";v=\\\"152\\\"\" ], \"sec-ch-ua-mobile\" : [ \"?0\" ], \"sec-ch-ua-platform\" : [ \"\\\"Linux\\\"\" ], \"sec-fetch-dest\" : [ \"document\" ], \"sec-fetch-mode\" : [ \"navigate\" ], \"sec-fetch-site\" : [ \"none\" ], \"sec-fetch-user\" : [ \"?1\" ], \"upgrade-insecure-requests\" : [ \"1\" ], \"x-forwarded-email\" : [ \"labuser@example.com\" ], \"x-forwarded-host\" : [ \"app2.hyeonworks.com\" ], \"x-forwarded-port\" : [ \"443\" ], \"x-forwarded-preferred-username\" : [ \"labuser\" ], \"x-forwarded-proto\" : [ \"https\" ], \"x-forwarded-server\" : [ \"traefik-5d6fcf895-wpfhr\" ], \"x-forwarded-user\" : [ \"27df5ea9-8703-4ec5-badd-d972c583e1ff\" ], \"x-real-ip\" : [ \"100.123.124.30\" ] }, \"remoteAddr\" : \"100.123.124.30\", \"localAddr\" : \"10.42.0.53\", \"scheme\" : \"https\", \"secure\" : true, \"serverName\" : \"app2.hyeonworks.com\", \"serverPort\" : 443, \"requestUrl\" : \"https://app2.hyeonworks.com/api/echo\" }"
@@ -0,0 +1 @@
- generic [active] [ref=f48e1]: "{\"pattern\":\"AP3-backend-for-frontend\",\"principal\":\"labuser\",\"accessTokenStoredOnServer\":true,\"refreshTokenStoredOnServer\":true,\"browserTokenCount\":0,\"csrfProtectionEnabled\":true}"
@@ -0,0 +1 @@
- generic [active] [ref=f49e1]: "{ \"headers\" : { \"host\" : [ \"app2.hyeonworks.com\" ], \"user-agent\" : [ \"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36\" ], \"accept\" : [ \"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7\" ], \"accept-encoding\" : [ \"gzip, deflate, br, zstd\" ], \"accept-language\" : [ \"en-US,en;q=0.9\" ], \"cookie\" : [ \"grafana_session=60c3e7ae41ffc00665f4a2c377def399; grafana_session_expiry=1788497124; _oauth2_proxy=djIuWDI5aGRYUm9NbDl3Y205NGVTMDJZakF5T0dFM01HWTJPV000WmpCa1lUazVOalpsWWpNMk9UY3laR1ptTWcuWmpUamNTbGxVSHV1RmJjQ2ZRd2lsUQ==|1788500797|17Hbo3RDtzOldnLZx2xOt3e34lHo_v0yqRNdqdKUx-g=\" ], \"priority\" : [ \"u=0, i\" ], \"sec-ch-ua\" : [ \"\\\"Chromium\\\";v=\\\"152\\\", \\\"Not?A_Brand\\\";v=\\\"24\\\", \\\"Google Chrome\\\";v=\\\"152\\\"\" ], \"sec-ch-ua-mobile\" : [ \"?0\" ], \"sec-ch-ua-platform\" : [ \"\\\"Linux\\\"\" ], \"sec-fetch-dest\" : [ \"document\" ], \"sec-fetch-mode\" : [ \"navigate\" ], \"sec-fetch-site\" : [ \"none\" ], \"sec-fetch-user\" : [ \"?1\" ], \"upgrade-insecure-requests\" : [ \"1\" ], \"x-forwarded-email\" : [ \"labuser@example.com\" ], \"x-forwarded-host\" : [ \"app2.hyeonworks.com\" ], \"x-forwarded-port\" : [ \"443\" ], \"x-forwarded-preferred-username\" : [ \"labuser\" ], \"x-forwarded-proto\" : [ \"https\" ], \"x-forwarded-server\" : [ \"traefik-5d6fcf895-wpfhr\" ], \"x-forwarded-user\" : [ \"27df5ea9-8703-4ec5-badd-d972c583e1ff\" ], \"x-real-ip\" : [ \"100.123.124.30\" ] }, \"remoteAddr\" : \"100.123.124.30\", \"localAddr\" : \"10.42.0.53\", \"scheme\" : \"https\", \"secure\" : true, \"serverName\" : \"app2.hyeonworks.com\", \"serverPort\" : 443, \"requestUrl\" : \"https://app2.hyeonworks.com/api/echo\" }"
+19
View File
@@ -42,6 +42,25 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- B-2: OAuth2AuthorizedClient 를 PostgreSQL 로 옮긴다.
Q3 가 후보로 든 "Redis 와 JDBC 중 무엇" 에서 JDBC 쪽이며,
JdbcOAuth2AuthorizedClientService 는 같은 인터페이스라
컨트롤러를 바꾸지 않아도 된다. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
@@ -8,15 +8,38 @@ import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProvider;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.JdbcOAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestCustomizers;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.jdbc.core.JdbcOperations;
@Configuration
public class SecurityConfig {
/**
* B-2 — authorized client 를 프로세스 메모리에서 PostgreSQL 로 옮긴다.
*
* B-1 에서 Application Session 만 Redis 로 옮겼더니, 사용자는 로그인
* 상태로 보이는데 BFF 에는 access token 이 없는 상태가 만들어졌다.
* 두 상태의 저장소를 **각각** 정해야 한다는 Q3 의 지적이 그대로 나타난 것이다.
*
* 주의 — 이것이 고치는 것과 고치지 못하는 것이 다르다.
* 고친다 : 인스턴스 간 공유. 어느 replica 로 가도 같은 토큰을 본다.
* 못 고친다: 조회 키. JdbcOAuth2AuthorizedClientService 도
* (clientRegistrationId, principalName) 으로 찾으므로
* 같은 사용자의 두 브라우저는 여전히 한 항목을 공유한다.
*/
@Bean
OAuth2AuthorizedClientService authorizedClientService(
JdbcOperations jdbcOperations,
ClientRegistrationRepository clientRegistrationRepository
) {
return new JdbcOAuth2AuthorizedClientService(jdbcOperations, clientRegistrationRepository);
}
@Bean
SecurityFilterChain bffSecurity(
HttpSecurity http,
+17
View File
@@ -10,6 +10,23 @@ server:
spring:
application:
name: keycloak-bff
datasource:
# B-2: authorized client 전용. Keycloak 과 같은 PostgreSQL 인스턴스지만
# 테이블이 다르다(oauth2_authorized_client). 운영이라면 분리를 검토한다.
url: ${BFF_DB_URL:jdbc:postgresql://localhost:5432/keycloak}
username: ${BFF_DB_USER:keycloak}
password: ${BFF_DB_PASSWORD:keycloak}
sql:
init:
# Spring Security 가 제공하는 DDL 을 그대로 쓴다.
# always 로 두면 매 기동마다 실행되므로 CREATE TABLE IF NOT EXISTS 가 아닌
# 스크립트에서는 실패한다 → continue-on-error 로 넘긴다.
mode: ${SPRING_SQL_INIT_MODE:always}
# ★ PostgreSQL 은 -postgres 판본을 써야 한다. 기본 판본은 `blob` 타입을
# 쓰는데 PostgreSQL 에는 그 타입이 없다(`bytea` 다). continue-on-error 가
# 그 실패를 삼켜서 "테이블이 조용히 안 생기는" 상태가 됐었다.
schema-locations: classpath:org/springframework/security/oauth2/client/oauth2-client-schema-postgres.sql
continue-on-error: true
data:
redis:
host: ${REDIS_HOST:localhost}
@@ -27,6 +27,12 @@ import org.springframework.test.web.servlet.MockMvc;
// 테스트는 Redis 를 띄우지 않는다. store-type=none 이면 자동구성이
// 서블릿 컨테이너 기본 세션으로 되돌아가 컨텍스트가 뜬다.
"spring.session.store-type=none",
// 테스트에는 PostgreSQL 이 없다. H2 로 대신하고 Spring Security 의
// DDL 을 그대로 태워 JdbcOAuth2AuthorizedClientService 가 뜨게 한다.
"spring.datasource.url=jdbc:h2:mem:bfftest;DB_CLOSE_DELAY=-1",
"spring.datasource.username=sa",
"spring.datasource.password=",
"spring.sql.init.mode=always",
"resource-api.base-url=http://127.0.0.1:9"
})
@AutoConfigureMockMvc
+128
View File
@@ -0,0 +1,128 @@
# Experiment B-7 — oauth2-proxy, to measure how replicas share a cookie secret
# and what happens when it is rotated (Q1, unknown 7).
#
# This is a different shape of problem from the BFF. The BFF keeps state on the
# server, so the question was "which store". oauth2-proxy keeps no server state
# at all: the whole session rides in a cookie that is signed and encrypted with
# --cookie-secret. So there is nothing to share and nothing to lose on restart —
# instead, every replica must hold the *same* secret, and changing it invalidates
# every cookie at once.
#
# kubectl apply -f deploy/lab/k8s/b7-oauth2-proxy.yaml
#
# app2.hyeonworks.com is borrowed from Grafana for the duration of this
# experiment; the certificate only covers auth / app1 / app2, so a fourth name
# is not available. Grafana's Ingress is restored afterwards.
apiVersion: v1
kind: Secret
metadata:
name: oauth2-proxy-secrets
namespace: keycloak-lab
type: Opaque
stringData:
# oauth2-proxy requires exactly 16, 24 or 32 bytes. This is the value whose
# rotation the experiment is about.
COOKIE_SECRET_A: "lab-cookie-secret-aaaaaaaaaaaaaa"
COOKIE_SECRET_B: "lab-cookie-secret-bbbbbbbbbbbbbb"
CLIENT_SECRET: proxy-lab-secret
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: oauth2-proxy
namespace: keycloak-lab
spec:
# Two replicas is the point: Q1 asks how they share the secret.
replicas: 2
selector:
matchLabels: { app: oauth2-proxy }
template:
metadata:
labels: { app: oauth2-proxy }
spec:
# See B-1: Kubernetes injects <SVCNAME>_PORT as a tcp:// URL and it
# collides with ordinary configuration names.
enableServiceLinks: false
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels: { app: oauth2-proxy }
containers:
- name: oauth2-proxy
image: quay.io/oauth2-proxy/oauth2-proxy:v7.7.1
args:
- --provider=oidc
- --oidc-issuer-url=https://auth.hyeonworks.com/realms/keycloak-patterns
- --client-id=oauth2-proxy
- --redirect-url=https://app2.hyeonworks.com/oauth2/callback
- --email-domain=*
- --http-address=0.0.0.0:4180
# The upstream is the same echo app the B-4 header experiment used,
# so what the proxy forwards can be read straight off the response.
- --upstream=http://echo.header-lab.svc:8081
# ★ 이 옵션을 켜면 세션(=쿠키)에 access token 이 들어간다.
# 그러면 Set-Cookie 가 커져 프록시 앞단에서 502 가 났다.
# B-4 에서 본 헤더 크기 절벽이 이번에는 응답 쪽에서 나타난 것이다.
# - --pass-authorization-header=true
- --set-xauthrequest=true
- --reverse-proxy=true
- --cookie-secure=true
# One hour, matching the value Q1 records for the current setup.
- --cookie-expire=1h
- --skip-provider-button=true
# ★ 쿠키에 세션 전체를 담으면 Set-Cookie 가 커지고, 그 응답이
# 앞단 nginx 의 proxy_buffer 를 넘겨 502 가 났다(측정됨).
# Redis 로 옮기면 쿠키에는 티켓만 남는다 — 그리고 그 순간
# "replica 가 secret 을 공유해야 한다"는 문제의 성격도 바뀐다.
- --session-store-type=redis
- --redis-connection-url=redis://redis.keycloak-lab.svc:6379
env:
- name: OAUTH2_PROXY_CLIENT_SECRET
valueFrom:
secretKeyRef: { name: oauth2-proxy-secrets, key: CLIENT_SECRET }
# Which of the two secrets is in use is switched here. Both replicas
# read the same key, which is exactly the sharing Q1 asks about.
- name: OAUTH2_PROXY_COOKIE_SECRET
valueFrom:
secretKeyRef: { name: oauth2-proxy-secrets, key: COOKIE_SECRET_A }
ports:
- containerPort: 4180
name: http
readinessProbe:
httpGet: { path: /ping, port: http }
initialDelaySeconds: 5
resources:
requests: { memory: 32Mi, cpu: 20m }
limits: { memory: 128Mi }
---
apiVersion: v1
kind: Service
metadata:
name: oauth2-proxy
namespace: keycloak-lab
spec:
selector: { app: oauth2-proxy }
ports:
- port: 4180
targetPort: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: oauth2-proxy
namespace: keycloak-lab
spec:
ingressClassName: traefik
rules:
- host: app2.hyeonworks.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: oauth2-proxy
port:
number: 4180
+37 -4
View File
@@ -24,9 +24,24 @@ stringData:
# Base64 in etcd is not encryption — see D-3.
KEYCLOAK_CLIENT_SECRET: bff-lab-secret
---
# Redis. No persistence yet: `--save ""` and no appendonly, so a restart loses
# everything. B-5 and B-6 compare that against RDB and AOF, which is easier to
# reason about when the starting point is "nothing survives".
# Redis. B-5 measured that turning on AOF with `redis-cli config set` changes
# nothing here, because /data is the container filesystem and dies with the
# container — the appendonlydir was created and then thrown away. Persistence
# configuration without a volume is decoration.
#
# So the volume comes first, and only then does `--appendonly yes` mean anything.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: redis-data
namespace: keycloak-lab
spec:
accessModes: [ReadWriteOnce]
storageClassName: local-path
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -47,16 +62,25 @@ spec:
containers:
- name: redis
image: redis:7.4-alpine
args: ["redis-server", "--save", "", "--appendonly", "no"]
# appendfsync everysec 이 기본값이다 — 1초 분량을 잃을 수 있다.
# Keycloak 의 synchronous_commit OFF(A-3)와 같은 모양의 트레이드오프다.
args: ["redis-server", "--appendonly", "yes", "--dir", "/data"]
ports:
- containerPort: 6379
name: redis
readinessProbe:
exec: { command: ["redis-cli", "ping"] }
initialDelaySeconds: 3
volumeMounts:
- name: data
mountPath: /data
resources:
requests: { memory: 32Mi, cpu: 20m }
limits: { memory: 128Mi }
volumes:
- name: data
persistentVolumeClaim:
claimName: redis-data
---
apiVersion: v1
kind: Service
@@ -137,6 +161,15 @@ spec:
value: redis.keycloak-lab.svc
- name: REDIS_PORT
value: "6379"
# B-2: authorized client 는 PostgreSQL 로. 세션(Redis)과 다른
# 저장소를 쓰는 것이 Q3 가 말한 "각각 설계한다"의 실물이다.
- name: BFF_DB_URL
value: jdbc:postgresql://postgres.keycloak-lab.svc:5432/keycloak
- name: BFF_DB_USER
value: keycloak
- name: BFF_DB_PASSWORD
valueFrom:
secretKeyRef: { name: keycloak-lab-secrets, key: POSTGRES_PASSWORD }
- name: JAVA_TOOL_OPTIONS
value: "-Xms128m -Xmx320m"
readinessProbe:
@@ -0,0 +1,8 @@
deployment.apps/bff configured
deployment "bff" successfully rolled out
bff-555df79c97-6j86w 1/1 Running 0 44s
bff-555df79c97-vgg6g 1/1 Running 0 22s
=== oauth2_authorized_client 테이블이 생겼는가 ===
Did not find any relation named "oauth2_authorized_client".
command terminated with exit code 1
@@ -0,0 +1,33 @@
=== PostgreSQL 전용 스키마 ===
CREATE TABLE oauth2_authorized_client (
client_registration_id varchar(100) NOT NULL,
principal_name varchar(200) NOT NULL,
access_token_type varchar(100) NOT NULL,
access_token_value bytea NOT NULL,
access_token_issued_at timestamp NOT NULL,
access_token_expires_at timestamp NOT NULL,
access_token_scopes varchar(1000) DEFAULT NULL,
refresh_token_value bytea DEFAULT NULL,
refresh_token_issued_at timestamp DEFAULT NULL,
created_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
PRIMARY KEY (client_registration_id, principal_name)
);
=== 적용 ===
CREATE TABLE
Table "public.oauth2_authorized_client"
Column | Type | Collation | Nullable | Default
-------------------------+-----------------------------+-----------+----------+-------------------------
client_registration_id | character varying(100) | | not null |
principal_name | character varying(200) | | not null |
access_token_type | character varying(100) | | not null |
access_token_value | bytea | | not null |
access_token_issued_at | timestamp without time zone | | not null |
access_token_expires_at | timestamp without time zone | | not null |
access_token_scopes | character varying(1000) | | | NULL::character varying
refresh_token_value | bytea | | |
refresh_token_issued_at | timestamp without time zone | | |
created_at | timestamp without time zone | | not null | CURRENT_TIMESTAMP
Indexes:
"oauth2_authorized_client_pkey" PRIMARY KEY, btree (client_registration_id, principal_name)
@@ -0,0 +1,20 @@
=== Q3 검증 2번 — 저장소를 직접 열어 refresh token 이 평문인가 ===
eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJlMmUzZDZkMy0yNzQyLTRhYWItYjk4Ni02ZDU2ZDM5MDk1ZDEifQ.eyJleHAiOjE3ODg1MDA0NDYsImlhdCI6MTc4ODQ5ODY0NiwianRpIjoiNTQwOTZmYTQtZWRjNi1iZjZkLWE4OGMtZDJhNjEzOGJjNmVlIiwiaXNzIjoiaHR0cHM6Ly9hdXRoLmh5ZW9ud29ya3MuY29tL3JlYWxtcy9rZXljbG9hay1wYXR0ZXJucyIsImF1ZCI6I
=== access token 도 ===
eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJPWS1jYVlETkdvUDRITUF6LVE5VVBUVS1ETTFpODk2TnV6VVp1NmdmQ3FNIn0.eyJleHAi
=== 그 문자열이 실제 JWT 인지 — 헤더를 디코드 ===
File "<string>", line 3
h=open(/tmp/hdr.txt).read().strip()
^
SyntaxError: invalid syntax
=== 저장된 바이트를 그대로 디코드한 결과 ===
refresh_token 헤더 : {"alg":"HS512","typ" : "JWT","kid" : "e2e3d6d3-2742-4aab-b986-6d56d39095d1"}
refresh_token 페이로드(앞부분):
{"exp":1788500446,"iat":1788498646,"jti":"54096fa4-edc6-bf6d-a88c-d2a6138bc6ee","iss":"https://auth.hyeonworks.com/realms/keycloak-patterns"
access_token 헤더 : {"alg":"RS256","typ" : "JWT","kid" : "OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"}
→ bytea 에 들어 있는 것은 암호화된 덩어리가 아니라 JWT 문자열 그대로다.
DB 읽기 권한만 있으면 그 자리에서 쓸 수 있는 토큰을 얻는다.
@@ -0,0 +1,25 @@
=== [현재] 같은 사용자의 항목 ===
client_registration_id | principal_name | access_token_issued_at | at_md5
------------------------+----------------+----------------------------+----------------------------------
keycloak | labuser | 2026-09-04 05:10:46.927192 | 675af2286bfc2fd9d2bab7bc8f391df7
(1 row)
행 수: 1
=== [모의 두 번째 브라우저] 세션만 지우고 같은 사용자로 다시 로그인시킨다 ===
(브라우저가 달라도 principal 은 같으므로 조회 키가 같다)
Redis 세션 삭제 완료 — 다음 요청이 새 로그인을 만든다
=== [재로그인 후] 행이 늘었는가, 덮어써졌는가 ===
client_registration_id | principal_name | access_token_issued_at | at_md5
------------------------+----------------+----------------------------+----------------------------------
keycloak | labuser | 2026-09-04 05:12:13.018828 | e19a63fc5aa18bd0a68b3e19dff16b3b
(1 row)
행 수: 1
★ 행 수가 1 그대로이고 md5 가 바뀌었으면 → 덮어쓰기다
=== Q1 검증 ④ — 로그아웃하면 두 저장소가 다 정리되는가 ===
로그아웃 전
Redis: 1 키
PostgreSQL: 1 행
@@ -0,0 +1,14 @@
=== Q1 검증 ④ — 로그아웃 후 두 저장소 상태 ===
Redis 세션 : 0 키
PostgreSQL 토큰 : 1 행
principal_name | access_token_issued_at | access_token_expires_at
----------------+----------------------------+----------------------------
labuser | 2026-09-04 05:12:13.018828 | 2026-09-04 05:13:13.018828
(1 row)
★ Redis 는 비었는데 PostgreSQL 에 행이 남아 있으면 → 한쪽만 정리된 것
=== Keycloak 쪽 SSO 세션은? ===
Keycloak 온라인 세션: 2
@@ -0,0 +1,21 @@
# B-2 — 다중 인스턴스 운영 증거
2026-09-04 15:0515:15 KST
해설: [`docs/experiment-b2-multi-instance-session.md`](../../experiment-b2-multi-instance-session.md)
| 파일 | 무엇을 보여주는가 |
|---|---|
| `01-jdbc-store-deploy.txt` | JDBC 저장소로 배포. **테이블이 조용히 안 만들어졌다** |
| `02-schema.txt` | 원인 — 기본 DDL 은 `blob`(PostgreSQL 에 없음), `-postgres.sql` 판본이 따로 있다. **`PRIMARY KEY (client_registration_id, principal_name)`** — 조회 키 문제가 DDL 에 박혀 있다 |
| `03-plaintext-tokens.txt` | **Q3 검증 2번**`bytea` 안이 JWT 문자열 그대로. 디코드하면 `{"alg":"HS512",...}` |
| `04-overwrite-test.txt` | **Q1 검증 3번** — 같은 사용자 재로그인 시 행 수 1 그대로, `issued_at` 과 md5 만 바뀜 = **UPDATE(덮어쓰기)** |
| `05-logout-cleanup.txt` | **Q1 검증 4번** — Redis 0키 / PostgreSQL **1행 잔존** / Keycloak SSO **2세션 잔존** |
| `b2-before-relogin.png` | JDBC 전환 직후, 옛 세션은 여전히 `false` |
| `b2-tokens-shared-across-instances.png` | 재로그인 후 **`accessTokenStoredOnServer: true`** — 두 replica 에서 동작 |
## 핵심 네 줄
1. **세션 Redis + 토큰 PostgreSQL 분리 저장이 성립한다.** B-1 의 "로그인은 됐는데 토큰이 없는" 상태가 해결됐다.
2. **refresh token 은 평문이다.** DB 읽기 권한이면 작동하는 토큰을 얻는다.
3. **같은 사용자의 두 번째 로그인이 첫 번째를 덮어쓴다.** 기본키에 session id 가 없어 구조적으로 그렇다.
4. **로그아웃은 셋 중 하나만 지운다.** 평문 토큰과 Keycloak SSO 세션이 남는다.
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

@@ -0,0 +1,11 @@
=== [1] refresh token 하나 확보 ===
토큰 길이: 811
jti: 8e7e3ee2-0dc8-573d-58ec-d12651a50b9c
sid: BvFiB01Rntz1FcLdf7zG4BNt
=== [2] 같은 refresh token 으로 동시에 5회 갱신 ===
요청 1: HTTP 400 {"error":"invalid_grant","error_description":"Maximum allowed refresh token reuse exceeded"}
요청 2: HTTP 400 {"error":"invalid_grant","error_description":"Session doesn't have required client"}
요청 3: HTTP 400 {"error":"invalid_grant","error_description":"Session doesn't have required client"}
요청 4: HTTP 400 {"error":"invalid_grant","error_description":"Session doesn't have required client"}
요청 5: HTTP 200 {"access_token":"...(발급됨)
@@ -0,0 +1,16 @@
=== [3] 이긴 요청이 받은 새 토큰은 쓸 수 있는가 ===
새 refresh token 길이: 810
그 토큰으로 다시 갱신: HTTP 400
{"error":"invalid_grant","error_description":"Session doesn't have required client"}
=== [4] 그 sid 의 세션이 DB 에 남아 있는가 ===
user_session_id | offline_flag | last_session_refresh
--------------------------+--------------+----------------------
BvFiB01Rntz1FcLdf7zG4BNt | 0 | 1788498996
(1 row)
=== [5] revoked_token 테이블 ===
revoked_count
---------------
0
(1 row)
@@ -0,0 +1,14 @@
=== user session 과 client session 을 나눠서 본다 ===
user_session_id | offline_flag | client_sessions
--------------------------+--------------+-----------------
BvFiB01Rntz1FcLdf7zG4BNt | 0 | 0
(1 row)
=== 대조: 정상 세션 하나를 새로 만들어 비교 ===
새 sid: JT-XuepgutWcE273QwAnIXta
user_session_id | client_sessions
--------------------------+-----------------
JT-XuepgutWcE273QwAnIXta | 1
(1 row)
@@ -0,0 +1,24 @@
=== 구성 A: rotation ON (revokeRefreshToken=true, maxReuse=0) — 앞서 측정 ===
성공 1 / 5, 세션 파괴됨
=== 구성 B: rotation OFF (revokeRefreshToken=false) ===
sid=iW1CGyO7COdyJLryIrCt3njk
1: 200
2: 200
3: 200
4: 200
5: 200
성공 5 / 5
이긴 토큰 재사용: HTTP 200
남은 client_session: 1
=== 구성 C: rotation ON + 재사용 1회 허용 (maxReuse=1) ===
sid=72c04JCdr0NpCHGQmXWW2wM8
1: 200
2: 400 "error_description":"Session doesn't have required client"
3: 200
4: 400 "error_description":"Maximum allowed refresh token reuse exceeded"
5: 400 "error_description":"Session doesn't have required client"
성공 2 / 5
이긴 토큰 재사용: HTTP 400
남은 client_session: 0
@@ -0,0 +1,18 @@
# B-3 — Refresh Token 동시 갱신 경쟁 증거
2026-09-04 15:1515:25 KST
해설: [`docs/experiment-b3-refresh-token-contention.md`](../../experiment-b3-refresh-token-contention.md)
| 파일 | 무엇을 보여주는가 |
|---|---|
| `01-concurrent-refresh.txt` | 같은 토큰으로 동시 5회 — **1개만 200**, 나머지는 `Maximum allowed refresh token reuse exceeded``Session doesn't have required client` **두 종류** 오류 |
| `02-session-impact.txt` | **★ 이긴 요청의 새 토큰조차 400.** user_session 행은 남아 있고 `revoked_token` 은 0건 |
| `03-client-session-removed.txt` | **기제 확정 (대조군 포함)** — 경쟁 세션 `client_sessions=0`, 정상 세션 `client_sessions=1` |
| `04-policy-comparison.txt` | 정책 3종 비교 — rotation OFF 는 **5/5 성공·세션 생존**, maxReuse=1 은 **여전히 세션 파괴** |
## 핵심 네 줄
1. **"하나는 성공"이 아니다.** 이긴 요청이 받은 토큰도 곧바로 쓸 수 없다.
2. **재사용 탐지가 client session 을 제거한다.** user session 은 껍데기로 남아 `Session doesn't have required client` 가 된다.
3. **`refreshTokenMaxReuse` 를 올려도 안 된다.** 동시 요청 수만큼 올려야 하고 그러면 rotation 의 목적이 사라진다.
4. **재시도로 회복되지 않으므로 Q2 의 답은 lock 이다.** 그리고 lock 은 저장소 쪽(가급적 DB 행 잠금)에 있어야 한다.
@@ -0,0 +1,40 @@
=== Q4 ① 다중 값 role — 구분자와 동명 헤더 ===
(a) 쉼표 구분 한 개 헤더
보냄: X-Auth-Request-Roles: admin,editor,viewer
도착: ['admin,editor,viewer'] ← 문자열 하나 그대로
(b) 동명 헤더 두 개
보냄: X-Auth-Request-Roles: admin
X-Auth-Request-Roles: editor
도착: ['admin', 'editor'] ← ★ 둘 다 도착. 덮어쓰지도 합치지도 않는다
(c) 값 안에 구분자가 들어간 경우
보냄: X-Auth-Request-Roles: role-with,comma
도착: ['role-with,comma'] ← (a) 와 구별 불가
=== Q4 ② 헤더 크기 상한 ===
보낸 길이 1000 → HTTP 200, 도착 길이 1000
보낸 길이 4000 → HTTP 200, 도착 길이 4000
보낸 길이 8000 → HTTP 400 (Tomcat 의 HTML 오류 페이지)
보낸 길이 16000 → HTTP 000 (응답을 못 받음 = 연결이 끊김)
보낸 길이 32000 → HTTP 000
→ 자르지 않는다. 거부한다. 그리고 거부하는 계층이 둘이며 증상이 다르다.
=== Q4 ④ upstream 이 검증하는가 ===
아무 인증 없이 보냄:
x-auth-request-user ['administrator']
x-auth-request-email ['admin@example.com']
x-auth-request-roles ['realm-admin,superuser']
remoteAddr 100.123.124.30
→ 그대로 도착. 검증 없음.
대조 — JWT 를 요구하는 경로:
/api/echo HTTP 200 (permitAll)
/api/me HTTP 401
/api/protected HTTP 401
backend SecurityConfig:
.requestMatchers("/actuator/health", "/actuator/health/**", "/api/public", ...).permitAll()
.anyRequest().authenticated()
.oauth2ResourceServer(oauth2 -> oauth2.jwt(...))
@@ -0,0 +1,14 @@
# B-4 — Edge 인가 범위 증거
2026-09-04 15:2515:35 KST
해설: [`docs/experiment-b4-edge-authorization-scope.md`](../../experiment-b4-edge-authorization-scope.md)
| 파일 | 무엇을 보여주는가 |
|---|---|
| `01-header-handling.txt` | ① 동명 헤더가 **둘 다 도착**(`['admin','editor']`)하고 값 안의 쉼표를 구분자와 구별할 수 없다 · ② 8KB 에서 Tomcat 400, 16KB 에서 연결 끊김 — **자르지 않고 거부** · ④ 위조 신원 헤더가 그대로 도착, JWT 경로는 401 |
## 핵심 세 줄
1. **Q4 의 「nginx 가 동명 헤더를 덮어쓴다」는 조건부다.** nginx 는 자기가 `proxy_set_header` 한 헤더만 덮어쓰고, 나머지는 통과시킨다 — 지금 `X-Auth-Request-*` 는 통과한다.
2. **크기는 절벽이다.** 점진적으로 나빠지지 않고 8KB 에서 전면 400 이 되며, role 이 많은 사용자만 깨진다.
3. **헤더를 인가 근거로 쓰면 위조 가능성이 곧 권한 상승이다.** 2홉 실험의 결론이 여기서는 신원 자체에 적용된다.
@@ -0,0 +1,9 @@
=== 기준선 ===
Redis 키: 1
PostgreSQL 토큰: 1 행
Redis 영속화 설정:
save = save
appendonly no
=== 외부 진입점 정상 확인 ===
https://app1.hyeonworks.com/ HTTP 200
@@ -0,0 +1,25 @@
=== ① Redis 정지 ===
정지: 14:26:30
deployment.apps/redis scaled
삭제 완료
=== 로그인한 사용자의 다음 요청은 어떻게 되는가 ===
/ HTTP 200
/bff/token-boundary HTTP 000
/actuator/health HTTP 503
--- token-boundary 응답 본문 ---
=== 파드 상태 — readiness 가 Redis 를 보는가 ===
bff-555df79c97-6j86w 1/1 Running 0 17m
bff-555df79c97-vgg6g 1/1 Running 0 16m
=== health 상세 ===
=== BFF 로그 ===
at java.base/sun.nio.ch.Net.pollConnect(Native Method) ~[na:na]
at java.base/sun.nio.ch.Net.pollConnectNow(Unknown Source) ~[na:na]
at java.base/sun.nio.ch.SocketChannelImpl.finishConnect(Unknown Source) ~[na:na]
at io.netty.channel.socket.nio.NioSocketChannel.doFinishConnect(NioSocketChannel.java:336) ~[netty-transport-4.1.135.Final.jar!/:4.1.135.Final]
at io.netty.channel.nio.AbstractNioChannel$AbstractNioUnsafe.finishConnect(AbstractNioChannel.java:339) ~[netty-transport-4.1.135.Final.jar!/:4.1.135.Final]
@@ -0,0 +1,13 @@
=== health 그룹별 응답 — 왜 파드는 Ready 인가 ===
/actuator/health HTTP server
/actuator/health/readiness HTTP 200
/actuator/health/liveness HTTP 200
=== /actuator/health 본문 (Redis 항목이 있는가) ===
=== /actuator/health/readiness 본문 ===
{"status":"UP"}
=== Service 엔드포인트 — 트래픽을 계속 받는가 ===
ready: [10.42.0.52 10.42.1.124]
@@ -0,0 +1,39 @@
=== 복구 ===
deployment.apps/redis scaled
deployment "redis" successfully rolled out
/actuator/health HTTP 200
/bff/token-boundary HTTP 302
BFF 재시작 필요했나: 0,0 회 재시작
=== ② 영속화 — 지금 설정으로 재시작하면 무엇이 남는가 ===
키 심음: before-restart
dbsize: 4
--- AOF 를 켜고 다시 심는다 (영속화가 켜져 있으면 살아남는가) ---
appendonly yes
total 12
drwxr-xr-x 3 redis redis 4096 Sep 4 05:26 .
drwxr-xr-x 1 root root 4096 Sep 4 05:26 ..
drwx------ 2 redis redis 4096 Sep 4 05:26 appendonlydir
--- 파드를 지운다 ---
deployment "redis" successfully rolled out
재기동 후:
dbsize: 0
b5:probe
b5:aof
appendonly no
persistentvolumeclaim/redis-data created
deployment.apps/redis configured
deployment "redis" successfully rolled out
=== 영속 볼륨 위에서 다시 시험 ===
appendonly yes
키 심음: written-on-pvc
sed: -e expression #1, char 8: unknown option to 's'
--- 파드를 지운다 ---
deployment "redis" successfully rolled out
재기동 후:
dbsize: 1
b5:pvc written-on-pvc
+17
View File
@@ -0,0 +1,17 @@
# B-5 — Redis 상실과 영속화 증거
2026-09-04 15:3515:50 KST
해설: [`docs/experiment-b5-redis-loss-persistence.md`](../../experiment-b5-redis-loss-persistence.md)
| 파일 | 무엇을 보여주는가 |
|---|---|
| `01-baseline.txt` | 정지 전 — Redis 1키, PostgreSQL 1행, `save`/`appendonly no`, 외부 200 |
| `02-redis-down.txt` | 정지 후 — `/bff/token-boundary` **`HTTP 000`(멈춤)**, `/actuator/health` 503, **파드는 1/1 Ready 유지**, Lettuce 재연결 스택 |
| `03-health-groups.txt` | **핵심**`/actuator/health` 503 인데 `/actuator/health/readiness``{"status":"UP"}`. Service 엔드포인트에 두 파드 모두 남아 있다 |
| `04-persistence.txt` | 복구는 자동(재시작 0회) · **AOF 를 켰는데 파드 삭제 후 `dbsize 0`** · PVC 를 붙인 뒤 `written-on-pvc` **생존** |
## 핵심 세 줄
1. **파드가 Ready 를 유지한 채 계속 실패한다.** `redis` 헬스 지표가 readiness 그룹에 없기 때문이며, A-2 에서 Keycloak 이 NotReady 가 된 것과 정반대다.
2. **오류가 아니라 멈춤이다.** `HTTP 000` — 빠른 실패가 안 되어 있어 사용자는 멈춘 화면을 본다.
3. **볼륨 없이 AOF 만 켜는 것은 장식이다.** `appendonlydir` 까지 만들어지지만 컨테이너와 함께 사라진다.
@@ -0,0 +1,9 @@
=== [1] 회전 전: 토큰 발급 + JWKS 상태 ===
발급 토큰의 kid: OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM
JWKS 의 RS256 키 수: 1
JWKS kid 목록:
{"keys":[{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
{"kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"
=== [2] 그 토큰이 지금 통하는가 (리소스 서버) ===
/api/me HTTP 200
@@ -0,0 +1,16 @@
=== [3] 키 회전 — 우선순위가 더 높은 RSA 공급자를 추가한다 ===
Created new component with id '7902af43-a0cc-4ebd-ad25-04d563854d16'
=== [4] 회전 후 JWKS — 옛 키가 남아 있는가 ===
RS256 키 수: 2
kid 목록:
{"keys":[{"kid":"1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84"
{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
{"kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"
=== [5] 새 토큰은 어느 키로 서명되는가 ===
새 토큰의 kid: 1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84
=== [6] ★ 회전 전에 발급된 토큰은 아직 통하는가 ===
옛 토큰 /api/me HTTP 200
새 토큰 /api/me HTTP 200
@@ -0,0 +1,16 @@
=== [7] 옛 RSA 공급자(980ee9b7 = OY-caYDN 키) 제거 ===
제거 완료
=== [8] JWKS 에서 사라졌는가 ===
RS256 키 수: 1
{"keys":[{"kid":"1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84"
{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
=== [9] ★ 옛 키로 서명된 토큰은 이제 어떻게 되는가 ===
옛 토큰 /api/me HTTP 401 (캐시가 살아 있으면 아직 통할 수 있다)
새 토큰 /api/me HTTP 200
=== [10] 리소스 서버를 재시작해 JWKS 캐시를 비우면 ===
deployment "echo" successfully rolled out
옛 토큰 /api/me HTTP 401
새 토큰 /api/me HTTP 200
+16
View File
@@ -0,0 +1,16 @@
# B-6 — key 회전 증거
2026-09-04 15:5016:00 KST
해설: [`docs/experiment-b6-key-rotation.md`](../../experiment-b6-key-rotation.md)
| 파일 | 무엇을 보여주는가 |
|---|---|
| `01-before-rotation.txt` | 회전 전 — 토큰 `kid=OY-caYDN...`, JWKS RS256 1개, `/api/me` 200 |
| `02-rotation.txt` | 우선순위 200 공급자 추가 → **JWKS RS256 2개**, 새 토큰은 새 kid, **옛 토큰도 새 토큰도 200** (무중단) |
| `03-old-key-removed.txt` | 옛 공급자 제거 → JWKS 1개, **옛 토큰 즉시 401**. 리소스 서버 재시작 후에도 동일 |
## 핵심 세 줄
1. **Keycloak 의 키 회전은 "바꾸기"가 아니라 "더 높은 우선순위로 추가하기"** 다. 추가만으로는 아무것도 안 깨진다.
2. **위험한 것은 옛 키를 버리는 시점이다.** 제거 즉시 그 키로 서명된 토큰이 401 이 된다.
3. **캐시는 유예가 아니다.** 모르는 `kid` 를 만나면 JWKS 를 다시 받으므로 제거가 곧바로 반영된다. 유예는 옛 키를 남겨두는 기간으로 만들어야 한다.
@@ -0,0 +1,13 @@
=== Grafana ingress 를 잠시 내린다 (app2 를 빌린다) ===
grafana ingress 삭제
secret/oauth2-proxy-secrets created
deployment.apps/oauth2-proxy created
service/oauth2-proxy created
ingress.networking.k8s.io/oauth2-proxy created
deployment "oauth2-proxy" successfully rolled out
oauth2-proxy-c76b49c59-8p5hl true kc-lab-1
oauth2-proxy-c76b49c59-b9928 true kc-lab-2
=== 진입점 확인 ===
https://app2.hyeonworks.com/ HTTP 302
/ping HTTP 200
@@ -0,0 +1,9 @@
=== curl 로 OAuth 흐름을 완주한다 (nginx 우회, Traefik 직접) ===
로그인 폼 action: https://auth.hyeonworks.com/realms/keycloak-patterns/login-actions/authenticate?session_co...
쿠키 항아리:
len=0
KC_AUTH_SESSION_HASH len=64
=== 두 replica 모두 이 쿠키를 받아들이는가 ===
10.42.1.135 /oauth2/auth HTTP 000
10.42.1.134 /oauth2/auth HTTP 000
@@ -0,0 +1,31 @@
=== 세션이 Redis 에 들어갔는가 ===
b5:pvc
_oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a
dbsize: 2
=== oauth2-proxy 가 cookie secret 을 여러 개 받는가 ===
--cookie-secret string the seed string for secure cookies (optionally base64 encoded)
=== ★ secret 을 A → B 로 교체한다 ===
deployment.apps/oauth2-proxy patched
deployment "oauth2-proxy" successfully rolled out
현재 secret 키: COOKIE_SECRET_B
Redis 세션은 그대로인가: 2 키
=== secret 교체 후 oauth2-proxy 로그 — 옛 쿠키를 어떻게 처리했나 ===
[2026/09/04 05:41:46] [oauthproxy.go:178] Cookie settings: name:_oauth2_proxy secure(https):true httponly:true expiry:1h0m0s domains: path:/ samesite: refresh:disabled
[2026/09/04 05:42:18] [oauthproxy.go:1024] No valid authentication in request. Initiating login.
100.123.124.30 - cb8c0ec1-1d87-479c-9aef-e1d9158a5829 - - [2026/09/04 05:42:18] app2.hyeonworks.com GET - "/api/echo" HTTP/1.1 "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, lik
[2026/09/04 05:42:18] [stored_session.go:94] Error loading cookied session: session ticket cookie failed validation: <nil>, removing session
[2026/09/04 05:42:18] [stored_session.go:97] Error removing session: error decoding ticket to clear session: session ticket cookie failed validation: <nil>
100.123.124.30 - 28af938f-08b5-4e15-9094-8d9591a18a3f - labuser@example.com [2026/09/04 05:42:18] app2.hyeonworks.com GET / "/api/echo" HTTP/1.1 "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/
[2026/09/04 05:41:58] [providers.go:146] Warning: Your provider supports PKCE methods ["plain" "S256"], but you have not enabled one with --code-challenge-method
[2026/09/04 05:41:58] [oauthproxy.go:172] OAuthProxy configured for OpenID Connect Client ID: oauth2-proxy
[2026/09/04 05:41:58] [oauthproxy.go:178] Cookie settings: name:_oauth2_proxy secure(https):true httponly:true expiry:1h0m0s domains: path:/ samesite: refresh:disabled
100.123.124.30 - 5a08219f-60e0-4c97-bfee-78cae8891ca8 - labuser@example.com [2026/09/04 05:42:18] [AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com user:27df5ea9-8703
100.123.124.30 - 5a08219f-60e0-4c97-bfee-78cae8891ca8 - - [2026/09/04 05:42:18] app2.hyeonworks.com GET - "/oauth2/callback?state=j7eKInWCrYqRyi5LVDGDjtLrIBJCpwdkmzoJVdhJUc0%3A%2Fapi%2Fecho&
100.123.124.30 - a3074807-5143-49b9-b77c-e7e2eb90ac24 - labuser@example.com [2026/09/04 05:42:18] app2.hyeonworks.com GET / "/favicon.ico" HTTP/1.1 "Mozilla/5.0 (X11; Linux x86_64) AppleWebK
=== Redis 세션 수 (옛 세션이 남아 있는가) ===
_oauth2_proxy-978dfaefbdadccb96c7be1625dba5616
_oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a
총: 2 개
+18
View File
@@ -0,0 +1,18 @@
# B-7 — oauth2-proxy cookie secret 교체 증거
2026-09-04 16:0016:15 KST
해설: [`docs/experiment-b7-cookie-secret-rotation.md`](../../experiment-b7-cookie-secret-rotation.md)
| 파일 | 무엇을 보여주는가 |
|---|---|
| `01-deploy.txt` | 양 노드에 replica 하나씩. `/` 302, `/ping` 200 |
| `02-cookie-portability.txt` | curl 로 흐름을 완주하려던 시도 — 파드 IP 는 호스트에서 안 닿는다 |
| `03-rotation.txt` | **`--cookie-secret string` 단수 확인** · 교체 후 `session ticket cookie failed validation` · **`Error removing session`** · Redis 에 **고아 세션 2개** |
| `b7-oauth2proxy-login-success.png` | Redis 세션 전환 후 성공한 Forward-Auth — `x-forwarded-user/email/preferred-username`**티켓 형태 쿠키** |
## 핵심 네 줄
1. **BFF 와 정반대다.** 인가 요청이 쿠키에 있어 **콜백이 다른 replica 로 가도 성공**한다 — B-0 에서 BFF 가 실패한 바로 그 지점.
2. **502 의 원인은 큰 쿠키였다.** Traefik 직접은 정상이고 nginx 만 502 — B-4 의 헤더 절벽이 응답 쪽에서 재현됐다.
3. **겹침 구간을 만들 수 없다.** `--cookie-secret` 이 단수라 B-6 의 무중단 회전이 불가능하다.
4. **교체하면 서버 세션이 고아로 남는다.** 티켓을 못 푸니 지울 수도 없다.
Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

@@ -0,0 +1,11 @@
=== 깨끗한 상태로 초기화 ===
DELETE 1
=== 기준선 ===
Keycloak 온라인 세션: 4
Redis 키: 0
=== 두 앱의 구조 ===
app1.hyeonworks.com → BFF (서버 세션: Redis + PostgreSQL)
app2.hyeonworks.com → oauth2-proxy (쿠키 티켓 + Redis)
둘 다 realm keycloak-patterns 를 쓴다
@@ -0,0 +1,9 @@
=== app1 로그인 직후 Keycloak 세션 ===
user_session_id | client_sessions
--------------------------+-----------------
oqOjHekin4JU-BZjgQLjUByW | 1
(1 row)
Redis 키: 1
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
PostgreSQL authorized client: 1 행
@@ -0,0 +1,20 @@
=== app2 방문 후 — 로그인 화면 없이 통과했는가 ===
user_session_id | client_sessions
--------------------------+-----------------
oqOjHekin4JU-BZjgQLjUByW | 2
(1 row)
=== 어느 클라이언트가 붙었는가 ===
client_id | name
--------------------------------------+------------------
9055fa46-6abb-4d6d-a339-8a9183bbf26d | bff-confidential
80431dbc-af81-4673-9790-ad06d1570b2e | oauth2-proxy
(2 rows)
=== 저장소 상태 ===
Redis 키:
_oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
PostgreSQL authorized client: 1 행
@@ -0,0 +1,25 @@
=== ★ Keycloak 의 SSO 세션 하나를 죽인다 ===
남은 Keycloak 세션: 1
=== 두 앱의 애플리케이션 세션은 그대로인가 ===
_oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
PostgreSQL authorized client: 1 행
→ IdP 세션은 없어졌는데 앱 세션은 남아 있다면, 두 계층의 수명이 어긋난 것이다
=== 사용자 단위 로그아웃 (IdP 세션만 끊는다) ===
남은 Keycloak 세션: 1
=== 앱 세션은 남아 있는가 ===
_oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
PostgreSQL authorized client: 1 행
=== 남은 세션의 realm 과 client ===
user_session_id | realm | clients
--------------------------+--------+---------
E1q5xI7tt4U_WhZpW7rEPIF2 | master | 1
(1 row)
=== 브라우저에서 두 앱을 다시 열면 어떻게 되는가 ===
(IdP 세션이 사라졌으면 재로그인이 필요해야 한다)
+19
View File
@@ -0,0 +1,19 @@
# C-1 — 다중 앱 SSO 증거
2026-09-04 16:1516:30 KST
해설: [`docs/experiment-c1-multi-app-sso.md`](../../experiment-c1-multi-app-sso.md)
| 파일 | 무엇을 보여주는가 |
|---|---|
| `01-baseline.txt` | 초기화 시도 — `logout-all` 이 안 먹어 세션 4개가 남았다 |
| `02-after-app1-login.txt` | app1 로그인 후 — user session 1 · client session 1 · Redis 1 · DB 1행 |
| `03-after-app2-visit.txt` | **app2 방문 후 client session 1 → 2**, `bff-confidential``oauth2-proxy` 가 같은 user session 에 붙음. Redis 에 두 종류 세션 |
| `04-sso-session-killed.txt` | IdP 세션 삭제 후 — **앱 세션 셋 다 남아 있다**. realm 을 join 해 보고서야 남은 것이 master 세션임을 확인 |
| `c1-sso-app2-no-login-screen.png` | app2 가 로그인 화면 없이 열린 화면 |
| `c1-apps-alive-after-idp-logout.png` | **IdP 세션을 죽인 뒤에도 그대로 열리는 화면** |
## 핵심 세 줄
1. **SSO 는 user session 1개에 client session N개** 구조다 — A-3(전체 소실)과 B-3(client 만 제거)의 차이가 여기서 의미를 갖는다.
2. **IdP 세션을 죽여도 두 앱은 계속 동작한다.** 세 층(IdP·앱·토큰)의 수명이 각자이기 때문이다.
3. **IdP 는 "로그인 경로"의 단일 장애점이지 "이미 로그인한 사용자"의 단일 장애점이 아니다.** 장애는 앱 세션 수명만큼 지연되어 몰려온다.
Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

@@ -0,0 +1,314 @@
# B-2 — 인스턴스를 늘렸을 때 무엇이 깨지고 무엇이 남는가 → Q1
브랜치 `feature/keycloak-b2-multi-instance-session` ·
증거 [`docs/evidence/b2-multi-instance-session/`](evidence/b2-multi-instance-session/) ·
2026-09-04 15:0515:15 KST
선행: [`B-0`](experiment-b0-bff-redis-deploy.md) · [`B-1`](experiment-b1-redis-session-store.md)
**대응 질문** — [Q1 · 서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가](https://hyeonworks.com/questions/server-session-pattern-multi-instance)
---
## 0. 결론부터
B-1 이 남긴 문제(세션만 공유되고 토큰은 안 됨)를 **JDBC 로 옮겨 해결했다.**
그러자 **다른 두 문제가 남았다.**
| Q1 검증 | 결과 |
|---|---|
| ① 다른 인스턴스로 요청해도 되는가 | **된다** — 세션 Redis + 토큰 PostgreSQL |
| ② 재시작 후 로그인 유지 | **된다** |
| ③ 같은 사용자의 다른 브라우저가 덮어쓰는가 | **★ 덮어쓴다.** 기본키가 그렇게 되어 있다 |
| ④ 로그아웃하면 두 저장소가 다 정리되는가 | **★ 아니다. 한쪽만 정리된다** |
```
로그아웃 후:
Redis 세션 : 0 키 ← 정리됨
PostgreSQL 토큰 : 1 행 ← 평문 refresh token 이 그대로 남는다
Keycloak SSO : 2 세션 ← 남아 있다
```
---
## 1. 설계 — 왜 JDBC 를 골랐나
B-1 에서 컨트롤러가 `OAuth2AuthorizedClientService` 를 직접 쓰는 것을 확인했다.
```java
private final OAuth2AuthorizedClientService authorizedClientService;
...
OAuth2AuthorizedClient client = authorizedClientService.loadAuthorizedClient(...);
```
| 후보 | 컨트롤러 변경 | 조회 키 문제 |
|---|---|---|
| **`JdbcOAuth2AuthorizedClientService`** | **불필요** (같은 인터페이스) | 안 고쳐짐 |
| Redis 직접 구현 | 불필요 | 안 고쳐짐 |
| `HttpSessionOAuth2AuthorizedClientRepository` | **필요** (Repository 로 바꿔야) | **고쳐짐** |
**Q3 가 "Redis 와 JDBC 중 무엇" 을 물었으므로 JDBC 를 골랐다.**
PostgreSQL 이 이미 있어 새 인프라가 필요 없고, 세션(Redis) + 토큰(JDBC)
**분리 저장**을 그대로 시험할 수 있다.
```java
@Bean
OAuth2AuthorizedClientService authorizedClientService(
JdbcOperations jdbcOperations,
ClientRegistrationRepository clientRegistrationRepository
) {
return new JdbcOAuth2AuthorizedClientService(jdbcOperations, clientRegistrationRepository);
}
```
---
## 2. 문제 — 스키마가 조용히 안 만들어졌다
파드는 떴고 Hikari 도 붙었는데 테이블이 없었다.
```
HikariPool-1 - Start completed.
...
Did not find any relation named "oauth2_authorized_client".
```
**Spring Security 가 두 벌의 DDL 을 제공한다.**
```
org/springframework/security/oauth2/client/oauth2-client-schema.sql ← 기본
org/springframework/security/oauth2/client/oauth2-client-schema-postgres.sql ← PostgreSQL 용
```
기본 판본은 `blob` 타입을 쓴다. **PostgreSQL 에는 그 타입이 없다** (`bytea` 다).
```sql
access_token_value blob NOT NULL, -- 기본 판본
access_token_value bytea NOT NULL, -- postgres 판본
```
그리고 내가 `continue-on-error: true` 를 켜둬서 **그 실패가 삼켜졌다.**
```yaml
schema-locations: classpath:org/springframework/security/oauth2/client/oauth2-client-schema-postgres.sql
```
> **`continue-on-error` 는 "없어도 되는 초기화"에만 쓴다.**
> 여기서는 그것 때문에 "테이블이 조용히 안 생기는" 상태가 됐고, 파드는
> **정상으로 보였다.** A층에서 반복해서 만난 "실패가 조용한" 유형이다.
### 그리고 DDL 자체가 Q1 의 답을 담고 있었다
```sql
CREATE TABLE oauth2_authorized_client (
client_registration_id varchar(100) NOT NULL,
principal_name varchar(200) NOT NULL,
...
PRIMARY KEY (client_registration_id, principal_name)
);
```
**기본키에 session id 가 없다.** B-0 에서 빈 이름
(`AuthenticatedPrincipalOAuth2AuthorizedClientRepository`)으로 짐작한 것이
**테이블 정의로 확정된다.** 구현을 바꿔도, 저장소를 바꿔도, **이 키를 그대로
쓰는 한 같은 사용자의 두 브라우저는 한 행을 공유한다.**
---
## 3. 결과 ① — 인스턴스 간 공유가 된다
```
=== 재로그인 후 oauth2_authorized_client ===
client_registration_id | principal_name | access_token_type | at_len | rt_len
------------------------+----------------+-------------------+--------+--------
keycloak | labuser | Bearer | 1431 | 744
=== Redis ===
bff:session:sessions:c63c39ee-... (dbsize 1)
```
![토큰이 인스턴스 간에 공유된다](evidence/b2-multi-instance-session/b2-tokens-shared-across-instances.png)
```json
{"principal":"labuser",
"accessTokenStoredOnServer":true, B-1 false
"refreshTokenStoredOnServer":true,
"browserTokenCount":0}
```
**두 저장소가 각자 제 일을 한다.**
```
Application Session ──▶ Redis (인증 상태)
OAuth2AuthorizedClient ▶ PostgreSQL (access / refresh token)
```
**Q3 가 "두 상태를 반드시 같은 저장소에 보관해야 하는 것은 아니다" 라고 한 것이
실물로 성립한다.** 다만 B-1 에서 본 대로, **한쪽만 옮기면 더 나쁘다.**
---
## 4. 결과 ② — refresh token 이 평문이다 → Q3 검증 2번
```sql
select convert_from(refresh_token_value, 'UTF8') from oauth2_authorized_client;
```
```
eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJlMmUz...
```
디코드하면
```
refresh_token 헤더 : {"alg":"HS512","typ":"JWT","kid":"e2e3d6d3-..."}
refresh_token 본문 : {"exp":1788500446,"iat":1788498646,"jti":"54096fa4-...",
"iss":"https://auth.hyeonworks.com/realms/keycloak-patterns"}
access_token 헤더 : {"alg":"RS256","typ":"JWT","kid":"OY-caYDNGoP4HMAz-..."}
```
**`bytea` 안에 든 것은 암호화된 덩어리가 아니라 JWT 문자열 그대로다.**
> **DB 읽기 권한만 있으면 그 자리에서 쓸 수 있는 토큰을 얻는다.**
> 백업 파일, 읽기 전용 복제본, 덤프, 로그 — 어디로든 새면 그대로 쓸 수 있다.
>
> Q3 의 가정 *"저장된 refresh token 을 평문으로 두면 안 된다"* 는 옳고,
> **Spring Security 기본 구현은 그 가정을 지키지 않는다.**
> 암호화하려면 `JdbcOAuth2AuthorizedClientService` 를 감싸거나 직접 구현해야 한다.
---
## 5. 결과 ③ — 같은 사용자의 두 번째 로그인이 덮어쓴다 → Q1 검증 3번
같은 사용자로 다시 로그인시키고 행을 비교했다.
```
=== 재로그인 전 ===
principal_name | access_token_issued_at | at_md5
labuser | 2026-09-04 05:10:46.927192 | 675af2286bfc2fd9d2bab7bc8f391df7
행 수: 1
=== 재로그인 후 ===
labuser | 2026-09-04 05:12:13.018828 | e19a63fc5aa18bd0a68b3e19dff16b3b
행 수: 1
```
**행 수는 그대로, 값만 바뀌었다. UPDATE 다.**
```
브라우저 A 로그인 → (keycloak, labuser) 행 생성
브라우저 B 로그인 → 같은 행을 덮어쓴다
└─ A 의 토큰은 사라진다
```
**A 쪽에서 다음 요청을 하면 B 의 토큰을 쓰게 된다.** 같은 사용자이므로
당장은 문제가 안 보이지만,
| 언제 문제가 되는가 | |
|---|---|
| B 가 로그아웃하면 | **A 도 같이 끊긴다** (행이 지워지므로) |
| refresh 회전이 걸려 있으면 | **A 와 B 가 같은 refresh token 을 다툰다** → B-3 |
| 스코프가 다른 로그인이면 | 나중 것이 이긴다 |
**저장소를 바꿔도 안 고쳐진다.** 고치려면 조회 키에 session 을 넣어야 하고,
그것이 `HttpSessionOAuth2AuthorizedClientRepository` 다.
---
## 6. 결과 ④ — 로그아웃이 한쪽만 정리한다 → Q1 검증 4번
```
=== 로그아웃 후 ===
Redis 세션 : 0 키 ← 정리됨
PostgreSQL 토큰 : 1 행 ← 남아 있다
Keycloak SSO : 2 세션 ← 남아 있다
principal_name | access_token_issued_at | access_token_expires_at
labuser | 2026-09-04 05:12:13.018828 | 2026-09-04 05:13:13.018828
```
**세 저장소 중 하나만 지워졌다.**
```
로그아웃
├─▶ HttpSession 무효화 ✔ Redis 키 삭제됨
├─▶ authorized client 삭제 ✗ 아무도 안 지운다
└─▶ Keycloak SSO 종료 ✗ RP-initiated logout 을 안 보낸다
```
| 남은 것 | 결과 |
|---|---|
| **PostgreSQL 의 평문 refresh token** | 로그아웃한 사용자의 **작동하는 토큰**이 DB 에 남는다 |
| **Keycloak SSO 세션** | 앱을 다시 열면 **로그인 화면 없이 다시 로그인**된다 |
**두 번째가 사용자에게 특히 혼란스럽다** — "로그아웃했는데 다시 들어가면
그냥 들어가진다". 실험 중에도 계속 그랬다. 세션을 지워도 Keycloak SSO 가
살아 있어 조용히 재인증됐다.
### 무엇을 해야 하는가
| 필요한 것 | 방법 |
|---|---|
| authorized client 삭제 | `LogoutSuccessHandler` 에서 `removeAuthorizedClient` 호출 |
| Keycloak 세션 종료 | **RP-initiated logout**`OidcClientInitiatedLogoutSuccessHandler` |
| 두 곳을 원자적으로 | 한쪽이 실패하면? — **정리 순서와 실패 처리를 정해야 한다** |
**Q3 의 미지수 5번("두 store 를 logout 에서 어떻게 한 번에 지우게 되는가")이
바로 이 지점이며, 답은 "지금은 하나도 안 지운다" 이다.**
---
## 7. Q1 검증 항목 대조
| # | Q1 의 검증 | 결과 |
|---|---|---|
| 1 | 다른 인스턴스로 요청 시 200 유지 | **된다** (Redis + JDBC 조합) |
| 2 | 재시작 후 session cookie 로 상태 유지 | **된다** |
| 3 | 두 브라우저에서 authorized client 덮어쓰기 | **★ 덮어쓴다.** 기본키가 원인 |
| 4 | 한쪽 logout 후 다른 쪽 | **★ 한쪽만 정리된다** |
| 5 | session 만료 ≠ token 만료 | 세션 30분 / access 60초 — **처음부터 어긋나 있다** |
| — | (B-0 에서) replica 2개에서 **로그인 자체가 실패** | Redis 세션으로 **해결됨** |
---
## 8. 재현 절차 (명령어)
```bash
# 1. JDBC authorized client service 빈 추가 (SecurityConfig)
# + spring-boot-starter-jdbc, postgresql 의존성
# 2. 스키마 — PostgreSQL 판본을 써야 한다
kubectl -n keycloak-lab exec <bff-pod> -- sh -c \
'unzip -p /app/app.jar BOOT-INF/lib/spring-security-oauth2-client-*.jar' > /dev/null
# 실제로는 nested jar 를 풀어서 -postgres.sql 을 꺼낸다
kubectl -n keycloak-lab exec -i deploy/postgres -- psql -U keycloak -d keycloak < oauth2-pg.sql
# 3. 저장소가 채워지는지
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select client_registration_id, principal_name, length(refresh_token_value) from oauth2_authorized_client"
# 4. 평문 여부
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select convert_from(refresh_token_value,'UTF8') from oauth2_authorized_client limit 1"
# 5. 덮어쓰기 — 같은 사용자로 다시 로그인시키고 md5 를 비교
kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushall # 세션만 지운다
# 브라우저로 재접속 → 행 수는 그대로, md5 는 바뀐다
# 6. 로그아웃 정리
# POST /logout (CSRF 는 form 파라미터 _csrf 로)
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select count(*) from oauth2_authorized_client"
```
---
## 9. 다음 실험에 남기는 것
| 실험 | 이 실험이 준 것 |
|---|---|
| **B-3** refresh 경쟁 | **이제 토큰이 공유된다** — 경쟁이 재현될 조건이 갖춰졌다. 그리고 **덮어쓰기 때문에 두 브라우저가 같은 refresh token 을 다툰다** |
| **B-4** Edge 인가 | 리소스 서버 직접 호출 차단(Q1 제약)은 2홉 NetworkPolicy 패턴 재사용 |
| **B-5** Redis 상실 | 이제 세션(Redis)과 토큰(PostgreSQL)이 나뉘어 있어 **각각 죽여볼 수 있다** |
| 보안 | **평문 refresh token****로그아웃 후 잔존** — 둘 다 코드로 막아야 한다 |
@@ -0,0 +1,270 @@
# B-3 — 같은 refresh token 으로 동시에 갱신하면 → Q2
브랜치 `feature/keycloak-b3-refresh-token-contention` ·
증거 [`docs/evidence/b3-refresh-contention/`](evidence/b3-refresh-contention/) ·
2026-09-04 15:1515:25 KST
선행: [`B-2`](experiment-b2-multi-instance-session.md) — 토큰이 공유되어야 경쟁이 성립한다
**대응 질문** — [Q2 · Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가](https://hyeonworks.com/questions/refresh-rotation-replica-contention)
> Q2 가 남긴 것: *"실제 Keycloak 응답과 session 영향은 아직 재현해 보지 않았다."*
---
## 0. 결론부터
**"하나는 성공하고 하나는 실패한다"가 아니다. 세션이 파괴된다.**
```
5개를 동시에 보냈을 때 (rotation ON, maxReuse=0)
요청 1: 400 "Maximum allowed refresh token reuse exceeded"
요청 2: 400 "Session doesn't have required client"
요청 3: 400 "Session doesn't have required client"
요청 4: 400 "Session doesn't have required client"
요청 5: 200 (토큰 발급됨)
★ 그런데 5번이 받은 토큰으로 다시 갱신하면 → 400
```
**이긴 요청조차 쓸 수 없는 토큰을 받는다.**
| 구성 | 성공 | 이긴 토큰 재사용 | client_session |
|---|---|---|---|
| **A** rotation ON · maxReuse=0 | **1 / 5** | **400** | **0 — 파괴** |
| **B** rotation OFF | **5 / 5** | 200 | **1 — 생존** |
| **C** rotation ON · maxReuse=1 | **2 / 5** | **400** | **0 — 파괴** |
**Q2 의 판정 기준***"실패가 사용자에게 노출되면 lock, 노출되지 않으면 재시도."*
**재시도로 회복되지 않는다.** 세션 자체가 없어지므로 답은 **lock** 이다.
---
## 1. 전제를 Q2 에 맞춘다
```bash
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh get realms/keycloak-patterns \
--fields revokeRefreshToken,refreshTokenMaxReuse,accessTokenLifespan
```
```json
{ "revokeRefreshToken" : false, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }
```
**기본값은 rotation 이 꺼져 있었다.** Q2 는 *"realm 이 refresh token rotation 과
재사용 허용 0회를 쓰게 되어서"* 를 전제로 하므로 맞춰야 한다.
```bash
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update realms/keycloak-patterns -s revokeRefreshToken=true -s refreshTokenMaxReuse=0
```
> **`revokeRefreshToken` 이 rotation 스위치다.** 이름이 "회전"이 아니라
> "취소"인 것이 헷갈리는데, **켜면 새 토큰을 줄 때 옛 토큰을 무효화**한다.
> `refreshTokenMaxReuse` 는 그 위에서 **몇 번까지 봐줄 것인가**이다.
---
## 2. 재현 — 진짜 동시성을 만든다
B-2 에서 토큰이 PostgreSQL 로 공유되므로 두 replica 가 같은 항목을 본다.
다만 **Keycloak 쪽 동작을 분리해서 보려면** BFF 를 거치지 않는 편이 낫다.
```bash
# 파드 안에서 5개를 동시에 띄우고 wait
i=1; while [ $i -le 5 ]; do
( curl -s -o /tmp/b$i -w "%{http_code}" -X POST $KC \
-d grant_type=refresh_token -d client_id=bff-confidential \
-d client_secret=bff-lab-secret -d refresh_token=$RT > /tmp/c$i ) &
i=$((i+1)); done
wait
```
**순차 실행이면 재현되지 않는다.** `&` 로 띄우고 `wait` 해야 진짜로 겹친다.
---
## 3. 무슨 일이 일어났는가 — 기제
오류 메시지가 **두 종류**인 것이 단서였다.
| 메시지 | 뜻 |
|---|---|
| `Maximum allowed refresh token reuse exceeded` | **재사용 탐지가 발동** |
| `Session doesn't have required client` | **그 여파** — client session 이 이미 없다 |
DB 로 확인했다.
```sql
select us.user_session_id,
(select count(*) from offline_client_session cs
where cs.user_session_id = us.user_session_id) as client_sessions
from offline_user_session us where us.user_session_id = '<sid>';
```
```
경쟁을 겪은 세션: BvFiB01Rntz1FcLdf7zG4BNt client_sessions = 0 ← 제거됨
정상 세션(대조군): JT-XuepgutWcE273QwAnIXta client_sessions = 1
```
**user session 은 남고 client session 만 제거된다.**
```
user session "이 브라우저는 labuser 로 로그인함" ← 남는다
└─ client session "그중 bff-confidential 에 대한 상태" ← 지워진다
```
그래서 오류가 `"Session doesn't have required client"` 다 —
**세션은 있는데 그 클라이언트 몫이 없다.**
### 그래서 이긴 요청도 죽는다
```
t0 5개가 동시에 도착
t1 하나가 처리를 시작 → 새 토큰 발급 준비
t2 다른 것들이 같은 옛 토큰으로 들어옴 → 재사용 탐지 발동
t3 ★ client session 제거
t4 t1 의 응답이 나간다 → HTTP 200, 새 토큰
t5 그 토큰을 쓰면 → client session 이 없다 → 400
```
**애플리케이션은 200 을 받았으므로 성공했다고 믿는다.**
다음 요청에서야 끊긴 것을 안다. **오류가 지연되어 나타난다.**
---
## 4. 정책을 바꿔 비교했다
### 구성 B — rotation OFF
```
1: 200 2: 200 3: 200 4: 200 5: 200
성공 5 / 5
이긴 토큰 재사용: HTTP 200
남은 client_session: 1
```
**전부 성공하고 세션도 멀쩡하다.** 같은 refresh token 을 계속 쓸 수 있으므로
경쟁 자체가 성립하지 않는다.
**대신 잃는 것** — 토큰이 유출되면 **만료까지 계속 쓸 수 있다.**
rotation 의 목적이 그 창을 좁히는 것이었다.
### 구성 C — rotation ON · maxReuse=1
```
1: 200
2: 400 "Session doesn't have required client"
3: 200
4: 400 "Maximum allowed refresh token reuse exceeded"
5: 400 "Session doesn't have required client"
성공 2 / 5
이긴 토큰 재사용: HTTP 400
남은 client_session: 0
```
**허용치를 1로 올려도 세션은 파괴됐다.**
> **`refreshTokenMaxReuse` 를 올리는 것은 해법이 아니다.**
> 동시 요청이 N 개면 `maxReuse ≥ N-1` 이어야 하는데, 그러면
> **rotation 의 보안 목적이 사라진다.** 값을 올려 버티려는 시도는
> "몇 개까지 동시에 올 것인가"를 맞춰야 하는 문제로 바뀔 뿐이다.
---
## 5. Q2 검증 항목 대조
| # | Q2 의 검증 | 결과 |
|---|---|---|
| 1 | 동시 갱신 시 각 replica 동작 | **1개만 200, 나머지 400. 그런데 200 도 무효** |
| 2 | 사용자 화면에 로그인 만료로 보이나 일시 오류로 보이나 | **로그인 만료로 보인다** — 세션이 실제로 없어졌으므로 |
| 3 | 새 token 을 다시 읽어 **재시도하면 성공하는가** | **★ 실패한다.** client session 이 없어 어떤 토큰도 안 통한다 |
| 4 | 한 곳에서만 갱신할지 / 각자 하고 재시도할지 | **재시도로는 회복 불가 → 한 곳에서만** |
| 5 | lock 을 어디에 두고 얼마나 / 잡은 채 죽으면 | **아래 6절** |
| 6 | 갱신 실패를 로그인 만료와 구분할 수 있는가 | **구분할 필요가 없다 — 실제로 로그인 만료다** |
| 7 | rotation 전제를 바꿔서 비교 | **구성 B/C 로 측정 완료** |
**3번이 이 실험의 핵심이다.** Q2 는 "재시도하면 성공하는가"를 열어뒀는데,
**답은 아니오**이고 그래서 판정 기준이 자동으로 lock 쪽으로 결정된다.
---
## 6. 그래서 무엇을 해야 하는가
### lock 이 필요하다 — 그런데 어디에
```
BFF replica 1 ─┐
├─▶ 같은 (client, principal) 항목
BFF replica 2 ─┘
```
**lock 은 저장소 쪽에 있어야 한다.** 프로세스 안의 `synchronized`
replica 를 넘지 못한다.
| 후보 | |
|---|---|
| **PostgreSQL 행 잠금** | `SELECT ... FOR UPDATE`**A-0 에서 Keycloak 자신이 쓰는 방식** |
| Redis 분산 lock | `SET NX PX` — TTL 로 스스로 풀린다 |
| 갱신 전용 인스턴스 | 단일 지점. 그 인스턴스가 죽으면? |
**첫 번째가 자연스럽다** — 토큰이 이미 PostgreSQL 에 있고(B-2),
Keycloak 도 세션 갱신에 같은 기법을 쓴다.
```sql
-- A-0 에서 Keycloak 이 실제로 쓰는 것
select VERSION from OFFLINE_USER_SESSION ... for no key update skip locked
```
### lock 을 잡은 채 죽으면 (Q2 미지수 5번)
| 방식 | 프로세스가 죽으면 |
|---|---|
| **DB 행 잠금** | **연결이 끊기면 자동 해제** — 가장 안전하다 |
| Redis lock + TTL | TTL 만료까지 막힌다. TTL 이 짧으면 **중복 갱신**, 길면 **정지** |
**DB 잠금이 이 문제에서 유리한 이유가 여기 있다** — 잠금의 수명이
**연결의 수명**과 묶여 있어 따로 관리할 것이 없다.
**B-5(Redis 상실)에서 Redis lock 의 이 약점을 재볼 수 있다.**
---
## 7. 재현 절차 (명령어)
```bash
# 1. 전제 맞추기
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update realms/keycloak-patterns -s revokeRefreshToken=true -s refreshTokenMaxReuse=0
# 2. refresh token 하나 확보 (direct grant)
curl -s -X POST $KC -d grant_type=password -d client_id=bff-confidential \
-d client_secret=bff-lab-secret -d username=labuser -d password=labpass -d scope=openid
# 3. 동시에 5개 — & 와 wait 이 없으면 재현되지 않는다
i=1; while [ $i -le 5 ]; do ( curl ... -d refresh_token=$RT > /tmp/c$i ) & i=$((i+1)); done; wait
# 4. ★ 이긴 요청의 토큰을 다시 써본다 — 여기서 진짜 답이 나온다
curl -s -o /dev/null -w '%{http_code}' -X POST $KC -d grant_type=refresh_token -d refresh_token=$NEW
# 5. 기제 확인 — client session 이 지워졌는지
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"select us.user_session_id,
(select count(*) from offline_client_session cs
where cs.user_session_id = us.user_session_id) as client_sessions
from offline_user_session us where us.user_session_id = '<sid>'"
# 6. 정책 비교 — revokeRefreshToken 과 refreshTokenMaxReuse 를 바꿔가며 3~5 반복
```
---
## 8. 다음 실험에 남기는 것
| 실험 | 이 실험이 준 것 |
|---|---|
| **B-5** Redis 상실 | Redis lock 을 쓴다면 **Redis 가 죽었을 때 갱신이 멈춘다** |
| **B-6** 암호화 key 교체 | 같은 "동시 접근" 문제의 다른 얼굴 |
| **A-6** 지연 주입 (기록 정정) | A-6 에서 낙관적 락 충돌이 0 이었던 이유가 확인된다 — **로그인은 새 행을 만들 뿐**이고, 다투는 것은 **여기서처럼 같은 항목을 갱신할 때**다 |
| 설계 | **재시도로 회복되지 않는다 → lock.** Q2 의 판정 기준이 결정됐다 |
@@ -0,0 +1,247 @@
# B-4 — 인가를 Edge 에 어디까지 둘 것인가 → Q4
브랜치 `feature/keycloak-b4-edge-authorization-scope` ·
증거 [`docs/evidence/b4-edge-authorization/`](evidence/b4-edge-authorization/) ·
2026-09-04 15:2515:35 KST
선행: [`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) — 헤더 신뢰 경계
**대응 질문** — [Q4 · Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가](https://hyeonworks.com/questions/edge-authorization-scope)
---
## 0. 결론부터
| Q4 의 미지수 | 측정 결과 |
|---|---|
| ① 다중 값 구분자·escaping | **값 안의 쉼표와 구분자를 구별할 수 없다.** 동명 헤더는 **둘 다 도착한다** |
| ② 크기 상한 초과 시 | **자르지 않고 거부한다.** 거부 계층이 둘이고 증상이 다르다 (400 / 연결 끊김) |
| ③ role 변경 반영 시점 | **아래 4절** |
| ④ upstream 이 값을 검증하는가 | **아무것도 검증하지 않는다.** 위조 헤더가 그대로 도착한다 |
**그리고 Q4 가 「확인한 사실」로 적어둔 것 하나가 측정과 어긋났다.**
---
## 1. Q4 의 전제 하나를 정정한다
> Q4 확인한 사실: *"Nginx는 client가 보낸 동명 헤더를 merge하지 않고 덮어쓴다."*
측정하면 그렇지 않다.
```
보냄: X-Auth-Request-Roles: admin
X-Auth-Request-Roles: editor
도착: ['admin', 'editor'] ← 둘 다 살아서 도착했다
```
### 왜 어긋나는가 — 조건이 빠져 있다
**nginx 는 자기가 `proxy_set_header` 로 설정한 헤더만 덮어쓴다.**
설정하지 않은 헤더는 **손대지 않고 그대로 흘려보낸다.** 그리고 HTTP 는
같은 이름의 헤더가 여러 번 오는 것을 허용한다.
```nginx
proxy_set_header X-Forwarded-Proto https; # ← 이건 덮어쓴다 (2홉 실험에서 확인)
# X-Auth-Request-Roles 에 대한 설정이 없다 # ← 이건 통과한다
```
> **"nginx 가 덮어쓴다"는 명제는 조건부다.**
> 덮어쓰려면 **그 헤더를 명시적으로 설정해야 한다.**
> Q4 의 제약 *"전달할 헤더는 allowlist 로 해야 하고 client 가 보낸 동명 헤더는
> 항상 덮어써야 한다"* 는 옳고, **지금은 그렇게 되어 있지 않다.**
### 보안적 함의
Edge 가 `X-Auth-Request-Roles: viewer` 를 붙여도, 공격자가 같은 헤더를
`admin` 으로 함께 보내면 **둘 다 upstream 에 도착한다.**
```
edge 가 붙인 것: X-Auth-Request-Roles: viewer
공격자가 보낸 것: X-Auth-Request-Roles: admin
upstream 이 받는 것: ['viewer', 'admin'] 또는 ['admin', 'viewer']
└─ 프레임워크가 "첫 번째"를 고르면 순서가 권한을 정한다
```
**어느 것을 고르느냐가 프레임워크 구현에 달려 있다.** Spring 의
`request.getHeader()` 는 **첫 번째**를 돌려준다. 순서는 프록시가 정한다.
---
## 2. 구분자 문제 → Q4 ①
```
(a) X-Auth-Request-Roles: admin,editor,viewer → 도착 ['admin,editor,viewer']
(c) X-Auth-Request-Roles: role-with,comma → 도착 ['role-with,comma']
```
**(a) 와 (c) 가 도착 시점에 구별되지 않는다.**
```
"admin,editor,viewer" 쉼표로 자르면 → [admin, editor, viewer] 맞다
"role-with,comma" 쉼표로 자르면 → [role-with, comma] ★ 틀렸다
```
**role 이름에 쉼표가 들어갈 수 있다면 이 방식은 성립하지 않는다.**
Keycloak 의 role 이름은 임의 문자열이므로 **막을 수 있는 것이 아니다.**
| 대안 | |
|---|---|
| 동명 헤더 여러 개 | HTTP 가 허용하고 실제로 도착한다. **다만 위조와 구별이 안 된다** |
| Base64 로 감싼 JSON 배열 | 구분자 문제가 사라진다. 대신 크기가 커진다 (②) |
| **헤더를 안 쓰고 JWT 를 넘긴다** | 서명이 있어 위조도 구분자도 해결된다 → **BFF 구조** |
**세 번째가 Q4 가 도달하려는 결론이다.**
---
## 3. 크기 상한 → Q4 ②
```
1000 → 200, 도착 1000
4000 → 200, 도착 4000
8000 → 400 (Tomcat 의 HTML 오류 페이지)
16000 → 000 (응답 자체를 못 받음)
32000 → 000
```
**자르지 않는다. 거부한다.** 그리고 **거부하는 계층이 둘**이다.
| 크기 | 누가 거부하나 | 클라이언트가 보는 것 |
|---|---|---|
| ~8KB | **Tomcat** (`maxHttpHeaderSize` 기본 8KB) | `400` + HTML 오류 페이지 |
| ~16KB 이상 | **nginx** (`large_client_header_buffers`) | **응답 없음 / 연결 끊김** |
> **두 실패가 전혀 다르게 보인다.** 앞의 것은 애플리케이션 오류처럼,
> 뒤의 것은 네트워크 장애처럼 보인다. **원인은 같은데 진단이 갈린다.**
### 실무적 의미
```
role 이 늘어난다 → 헤더가 커진다 → 8KB 를 넘는 순간 전면 400
```
**점진적으로 나빠지지 않고 절벽에서 떨어진다.** 그리고 그 절벽은
**사용자마다 다르다** — role 이 많은 사용자만 깨진다.
**Q4 의 가정** *"헤더 종류가 늘어나면 정해야 할 계약도 늘어난다"*
크기에서도 성립하며, **한계가 있다**는 것이 이 측정이다.
---
## 4. upstream 은 아무것도 검증하지 않는다 → Q4 ④
인증 없이 신원 헤더를 위조해 보냈다.
```
x-auth-request-user ['administrator']
x-auth-request-email ['admin@example.com']
x-auth-request-roles ['realm-admin,superuser']
remoteAddr 100.123.124.30
```
**그대로 도착했다.**
대조 — JWT 를 요구하는 경로는 막힌다.
```
/api/echo HTTP 200 ← permitAll
/api/me HTTP 401
/api/protected HTTP 401
```
```java
.requestMatchers("/actuator/health", "/api/public", ...).permitAll()
.anyRequest().authenticated()
.oauth2ResourceServer(oauth2 -> oauth2.jwt(...))
```
**JWT 경로는 서명을 검증하므로 위조가 안 된다. 헤더 경로는 검증할 대상이 없다.**
> Q4 확인한 사실 — *"upstream은 JWT를 입력으로 받지 않아서 헤더로 넘어온 값을
> 검증할 방법이 없다."* **정확하다. 그리고 그것이 이 구조의 본질적 한계다.**
>
> 2홉 실험에서 **헤더 위조로 `serverName: evil.example.com` 을 만든 것과 같은
> 종류**다. 거기서는 쿠키 속성이었지만 **여기서는 신원 그 자체다.**
### 그래서 세 곳이 독립적으로 필요하다
2홉 실험의 결론이 그대로 적용된다.
| 필요한 것 | 지금 상태 |
|---|---|
| ① 외부에서 upstream 으로 **직접 가는 경로 차단** | NetworkPolicy 패턴 확립됨 (2홉 실험) |
| ② edge 에서 **동명 헤더 덮어쓰기** | **★ 안 되어 있다** (1절) |
| ③ upstream 에서 **내부 credential 검증** | **★ controller 한 곳에만 있다** (Q4 제약) |
**셋 중 하나라도 빠지면 나머지 둘이 무의미하다.**
---
## 5. Q4 의 설계 판단 5문항 — 측정에 근거해 답한다
> *2번부터 5번 중 하나라도 그렇다면 헤더를 늘리기보다 BFF 구조로 구성하자.*
| # | 질문 | 이 실험이 주는 답 |
|---|---|---|
| 1 | 전달할 claim 이 계속 늘어나는가 | **늘면 8KB 절벽이 있다** (3절). 크기가 상한을 정한다 |
| 2 | role·tenant 변경이 **즉시 반영**돼야 하는가 | 헤더는 **edge 가 세션을 갱신할 때까지 옛 값**이다 |
| 3 | 정책이 애플리케이션 **도메인을 알아야** 하는가 | 안다면 edge 가 도메인을 알아야 하고, **경계가 무너진다** |
| 4 | 헤더 값이 **인가 판단의 근거**가 되는가 | **★ 그렇다면 위조 가능성이 곧 권한 상승이다** (4절) |
| 5 | **서비스별 정책 차이**가 커지는가 | edge 설정이 서비스 수만큼 늘어난다 |
**4번이 이 실험에서 가장 무겁다.** 헤더를 인가 근거로 쓰는 순간,
**헤더 신뢰 경계 세 곳이 모두 완전해야만** 안전하다. 하나라도 새면
**인증 우회가 아니라 권한 상승**이다.
> **결론 — 2·4번이 해당하므로 Q4 자신의 기준에 따라 BFF 구조가 맞다.**
> 그리고 이 실험대에는 이미 BFF(B-0~B-3)가 있다. 두 구조를 같은
> 실험대에서 비교할 수 있는 상태다.
---
## 6. 남긴 것
| 항목 | 상태 |
|---|---|
| ③ role 변경 반영 시점 | **미측정.** oauth2-proxy 가 없어 "proxy session" 이 존재하지 않는다 |
| ⑤ internal token 을 공통 경계로 이동 | **코드 변경.** `backend/` 의 SecurityConfig 에서 `permitAll` 경로를 좁히고 Filter 로 옮기는 작업 |
| edge 에서 동명 헤더 덮어쓰기 | **nginx 설정 변경 필요**`proxy_set_header X-Auth-Request-Roles ""` 로 먼저 지우고 다시 설정 |
**③ 은 oauth2-proxy 배포가 선행이며, 그것은 B-7 의 주제와 겹친다.**
---
## 7. 재현 절차 (명령어)
```bash
# ① 동명 헤더 — 덮어쓰는가 합치는가 통과시키는가
curl -s -H "X-Auth-Request-Roles: admin" -H "X-Auth-Request-Roles: editor" \
https://app1.hyeonworks.com/api/echo | python3 -m json.tool | grep -A3 roles
# ② 크기 상한 — 어디서 어떻게 깨지는가
for n in 1000 4000 8000 16000; do
V=$(python3 -c "print('r'*$n)")
curl -s -o /tmp/o -w "$n -> %{http_code}\n" -H "X-Auth-Request-Roles: $V" \
https://app1.hyeonworks.com/api/echo
done
# ④ 위조가 통하는가
curl -s -H "X-Auth-Request-User: administrator" \
-H "X-Auth-Request-Roles: realm-admin" \
https://app1.hyeonworks.com/api/echo
# 대조 — JWT 를 요구하는 경로
curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/api/me
```
---
## 8. 다음 실험에 남기는 것
| 실험 | 이 실험이 준 것 |
|---|---|
| **B-7** oauth2-proxy | ③(반영 시점)을 재려면 proxy session 이 있어야 한다 |
| **C-1** SSO | 헤더 기반과 BFF 기반이 **SSO 에서 어떻게 다른가** |
| 코드 | `permitAll` 을 좁히고 internal token 검증을 **공통 경계**로 옮긴다 (Q4 제약) |
| 설정 | nginx 에서 `X-Auth-Request-*`**명시적으로 덮어쓴다** |
@@ -0,0 +1,254 @@
# B-5 — Redis 가 죽으면, 그리고 재시작하면 무엇이 남는가
브랜치 `feature/keycloak-b5-redis-loss-persistence` ·
증거 [`docs/evidence/b5-redis-loss/`](evidence/b5-redis-loss/) ·
2026-09-04 15:3515:50 KST
선행: [`B-2`](experiment-b2-multi-instance-session.md) — 세션(Redis)과 토큰(PostgreSQL)이 나뉘어 있어야 각각 죽여볼 수 있다
---
## 0. 결론부터
| | 결과 |
|---|---|
| Redis 정지 시 요청 | **오류가 아니라 멈춘다** (`HTTP 000`) |
| `/actuator/health` | **503** |
| **파드 readiness** | **`UP` 유지 — 트래픽을 계속 받으며 계속 실패한다** |
| 복구 | 자동. **BFF 재시작 0회** |
| **AOF 를 켰는데 재시작 후 전부 소실** | **볼륨이 없었다.** 영속화 설정만으로는 아무것도 안 남는다 |
| PVC 를 붙인 뒤 | **살아남는다** |
**A-2(Keycloak DB 상실)와 정반대의 실패 모양이다.** 거기서는 헬스체크가
파드를 트래픽에서 빼줬는데, 여기서는 안 빼준다.
---
## 1. Redis 정지 — 오류가 아니라 정지다
```bash
kubectl -n keycloak-lab scale deployment/redis --replicas=0
```
```
/ HTTP 200 ← permitAll 정적 페이지
/bff/token-boundary HTTP 000 ← ★ 응답이 없다
/actuator/health HTTP 503
```
`000` 은 curl 이 응답을 못 받았다는 뜻이다. **오류를 돌려주는 것이 아니라
매달려 있다.** Lettuce 가 재연결을 시도하며 타임아웃을 기다리기 때문이다.
```
io.netty.channel.socket.nio.NioSocketChannel.doFinishConnect
java.base/sun.nio.ch.Net.pollConnect
```
> **"빨리 실패하기(fail fast)"가 안 되어 있다.** 사용자는 오류 화면 대신
> **멈춘 화면**을 본다. 이것이 A-6(지연 주입)에서 본 것과 같은 문제다 —
> **느린 실패가 빠른 실패보다 나쁘다.**
---
## 2. 그런데 파드는 Ready 를 유지한다 — 가장 중요한 발견
```
/actuator/health HTTP 503
/actuator/health/readiness HTTP 200 {"status":"UP"}
/actuator/health/liveness HTTP 200
Service ready: [10.42.0.52 10.42.1.124] ← 둘 다 트래픽을 받는다
```
### 개념 — health group
Spring Boot 는 헬스 지표를 **그룹**으로 나눈다.
```
/actuator/health 모든 지표의 합 ← redis 지표가 여기 있다
/actuator/health/readiness readiness 그룹 ← 기본값은 readinessState 뿐
/actuator/health/liveness liveness 그룹
```
**`redis` 헬스 지표는 자동으로 readiness 그룹에 들어가지 않는다.**
그래서 전체 상태는 `DOWN` 인데 readiness 는 `UP` 이다.
kubelet 은 `/actuator/health/readiness` 를 보므로 **파드를 빼지 않는다.**
### A-2 와의 대비
| | A-2 (Keycloak · DB 상실) | **B-5 (BFF · Redis 상실)** |
|---|---|---|
| 의존 대상 헬스 지표 | **readiness 에 포함** | **포함 안 됨** |
| 파드 상태 | **NotReady** | **Ready 유지** |
| Service 엔드포인트 | **비었다** | 둘 다 남는다 |
| 외부 응답 | **503** (즉시, 명확) | **000** (멈춤) |
**Keycloak 은 자기 의존성을 readiness 에 넣었고, 이 BFF 는 안 넣었다.**
어느 쪽이 옳은지는 상황에 달렸다.
| readiness 에 넣으면 | 넣지 않으면 |
|---|---|
| 의존 대상이 죽으면 **전 파드가 빠진다** → 전면 장애 | 파드가 남아 **실패를 계속 서빙한다** |
| 부분 기능이라도 살릴 수 없다 | 부분 기능(정적 페이지 등)은 살아 있다 |
| A-2 처럼 **명확한 503** | **멈춤** — 진단이 어렵다 |
**의도적으로 골라야 하는 설정이며, 기본값에 맡기면 후자가 된다.**
```yaml
management:
endpoint:
health:
group:
readiness:
include: readinessState, redis # 넣으려면 명시해야 한다
```
---
## 3. 복구는 자동이다
```
/actuator/health HTTP 200
/bff/token-boundary HTTP 302 (세션이 사라져 로그인으로 보냄)
BFF 재시작: 0, 0 회
```
**Lettuce 가 스스로 재연결했다.** A-2 에서 Keycloak 의 커넥션 풀이 그랬던
것과 같다. **liveness 를 Redis 에 걸었다면 파드가 재시작됐을 것**이고,
회복이 더 늦어졌을 것이다.
`302` 는 세션이 사라졌기 때문이다 — Redis 가 비었으므로 로그인 상태가 없다.
**사용자는 로그아웃된다.**
---
## 4. 영속화 — 설정만으로는 아무것도 안 남는다
### 시도 ① AOF 를 켜고 파드를 지운다
```bash
kubectl -n keycloak-lab exec deploy/redis -- redis-cli config set appendonly yes
kubectl -n keycloak-lab exec deploy/redis -- redis-cli set b5:aof "written-with-aof"
```
```
appendonly yes
/data 내용: appendonlydir ← 파일이 실제로 만들어졌다
```
파드를 지운 뒤
```
dbsize: 0
b5:probe (없음)
b5:aof (없음)
appendonly no ← 설정도 되돌아갔다
```
**전부 사라졌다.**
| 왜 | |
|---|---|
| `/data`**컨테이너 파일시스템** | 볼륨이 없으므로 컨테이너와 함께 사라진다 |
| `CONFIG SET`**런타임 전용** | 재기동하면 매니페스트의 `args` 가 이긴다 |
> **쿠버네티스에서 영속화 설정만 켜는 것은 장식이다.**
> `appendonly yes` 를 켜고 안심하는 것이 가장 위험하다 — **파일은 만들어지고
> 로그도 정상이며, 사라지는 것은 재시작 순간뿐**이다.
### 시도 ② PVC 를 붙인다
```yaml
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: redis-data
args: ["redis-server", "--appendonly", "yes", "--dir", "/data"]
```
```
appendonly yes
키 심음: written-on-pvc
--- 파드 삭제 후 ---
dbsize: 1
b5:pvc written-on-pvc ← 살아남았다
```
### 비교
| 구성 | 파드 삭제 후 |
|---|---|
| AOF **끔**, 볼륨 없음 | 전부 소실 |
| AOF **켬**, 볼륨 없음 | **전부 소실** (설정은 켰는데) |
| AOF **켬**, **PVC** | **생존** |
**순서가 있다 — 볼륨이 먼저고 설정이 나중이다.**
### `appendfsync` 는 여전히 트레이드오프다
```
appendfsync everysec ← 기본값
```
**1초 분량을 잃을 수 있다.** A-3 에서 본 PostgreSQL 의
`synchronous_commit OFF` 와 **같은 모양의 맞바꿈**이다.
| 설정 | 잃는 양 | 비용 |
|---|---|---|
| `always` | 없음 | 쓰기마다 fsync — 느리다 |
| **`everysec`** | **최대 1초** | 기본값 |
| `no` | OS 에 맡김 | 가장 빠름 |
**세션 저장소에서 1초를 잃는다는 것은 그 사이 로그인한 사용자가
다시 로그인해야 한다는 뜻이다.** A-3 에서 Keycloak 이 같은 판단을 했다.
### PVC 도 노드에 못박힌다
`local-path` PVC 이므로 **A-4 에서 본 것과 같다** — 노드가 죽으면
볼륨도 함께 접근 불가가 된다. **영속화는 재시작을 견디게 하지만
노드 상실을 견디게 하지는 않는다.**
---
## 5. 재현 절차 (명령어)
```bash
# ① 정지
kubectl -n keycloak-lab scale deployment/redis --replicas=0
curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/bff/token-boundary # 000
# ② 왜 파드가 안 빠지는가 — 그룹별로 본다
kubectl -n keycloak-lab exec <bff-pod> -- wget -qO- http://localhost:8083/actuator/health
kubectl -n keycloak-lab exec <bff-pod> -- wget -qO- http://localhost:8083/actuator/health/readiness
kubectl -n keycloak-lab get endpoints bff -o jsonpath='{.subsets[*].addresses[*].ip}'
# ③ 복구
kubectl -n keycloak-lab scale deployment/redis --replicas=1
# ④ 영속화 — 볼륨 없이 AOF 만 켜본다
kubectl -n keycloak-lab exec deploy/redis -- redis-cli config set appendonly yes
kubectl -n keycloak-lab exec deploy/redis -- redis-cli set k v
kubectl -n keycloak-lab delete pod -l app=redis
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize # 0
# ⑤ PVC 를 붙이고 다시
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
kubectl -n keycloak-lab exec deploy/redis -- redis-cli set k v
kubectl -n keycloak-lab delete pod -l app=redis
kubectl -n keycloak-lab exec deploy/redis -- redis-cli get k # v
```
---
## 6. 다음 실험에 남기는 것
| 실험 | 이 실험이 준 것 |
|---|---|
| **B-6** 암호화 key 교체 | Redis 가 이제 영속적이므로 **key 를 바꾸면 옛 데이터가 남아 있다** |
| **D-1** 백업·복구 | `local-path` PVC 는 **노드에 묶여 있다** — 노드가 안 돌아오면 백업뿐 |
| 구성 | **readiness 그룹에 무엇을 넣을지 명시적으로 정한다** |
| 구성 | Redis 클라이언트에 **타임아웃**을 걸어 빠르게 실패시킨다 |
+228
View File
@@ -0,0 +1,228 @@
# B-6 — key 를 교체하면 옛 것으로 만든 것은 어떻게 되는가 → Q3 미지수 3
브랜치 `feature/keycloak-b6-key-rotation` ·
증거 [`docs/evidence/b6-key-rotation/`](evidence/b6-key-rotation/) ·
2026-09-04 15:5016:00 KST
선행: [`B-2`](experiment-b2-multi-instance-session.md) — 토큰이 **평문**임을 확인했다
**대응 질문** — Q3 미지수 3
> *"암호화 key 를 어디에 두고 어떻게 교체하게 되는가. 교체하는 동안 이전 key 로
> 저장된 값은 어떻게 읽는가."*
---
## 0. 결론부터
**질문이 두 갈래로 나뉜다.**
| | 상태 |
|---|---|
| **① 토큰 저장소의 암호화 key** | **★ 존재하지 않는다.** B-2 에서 평문임을 확인했다 |
| **② 토큰 서명 key (Keycloak realm)** | 존재하고 회전 가능하다 — **측정했다** |
②를 측정한 결과가 ①을 설계할 때 그대로 쓰인다.
```
키 추가 → 무중단. JWKS 에 옛 키와 새 키가 함께 남는다
키 제거 → ★ 즉시 파괴적. 옛 키로 서명된 토큰이 곧바로 401
```
**"교체하는 동안 이전 key 로 저장된 값은 어떻게 읽는가" 의 답은
"두 key 를 동시에 들고 있으면 읽힌다" 이고, 위험한 것은 교체가 아니라
옛 key 를 언제 버리느냐다.**
---
## 1. ① 암호화 key 는 애초에 없다
B-2 에서 확인했다.
```sql
select convert_from(refresh_token_value, 'UTF8') from oauth2_authorized_client;
eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJlMmUz...
```
**`bytea` 안이 JWT 문자열 그대로다.** 암호화가 없으므로 **교체할 key 도 없다.**
> Q3 는 *"Access Token 과 Refresh Token 을 Redis 에 저장할 경우 Token 을 어떤
> 방식으로 암호화할지"* 를 미지수로 뒀다. **먼저 확인해야 할 것은 "지금은
> 암호화가 없다" 이고, 그것을 B-2 에서 측정했다.**
암호화를 넣는다면 회전 설계는 ②에서 본 모양을 따라야 한다 —
**읽기는 여러 key 를 받아들이고, 쓰기는 한 key 만 쓴다.**
---
## 2. ② 서명 key 회전을 실측한다
### 회전 전
```
발급 토큰의 kid: OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM
JWKS 의 RS256 키 수: 1
/api/me HTTP 200
```
### 회전 — 우선순위가 높은 공급자를 추가한다
```bash
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh create components \
-r keycloak-patterns \
-s name=rsa-rotated -s providerId=rsa-generated \
-s providerType=org.keycloak.keys.KeyProvider \
-s 'config.priority=["200"]' -s 'config.algorithm=["RS256"]' -s 'config.keySize=["2048"]'
```
**Keycloak 의 키 회전은 "바꾸기"가 아니라 "더 높은 우선순위로 추가하기"다.**
```
RS256 키 수: 1 → 2
kid 목록:
1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84 ← 새 키 (우선순위 200)
OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM ← 옛 키 (우선순위 100, 남아 있다)
새 토큰의 kid: 1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84
```
### 결과 — 무중단이다
```
옛 토큰 /api/me HTTP 200
새 토큰 /api/me HTTP 200
```
**둘 다 통한다.** 새 토큰은 새 키로 서명되고, 옛 토큰은 **JWKS 에 아직 있는
옛 키로 검증**된다.
---
## 3. 옛 키를 제거하면 — 여기가 진짜 질문이다
```bash
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
delete components/980ee9b7-... -r keycloak-patterns
```
```
RS256 키 수: 2 → 1
옛 토큰 /api/me HTTP 401 ← ★ 즉시 깨진다
새 토큰 /api/me HTTP 200
```
**리소스 서버를 재시작해 JWKS 캐시를 비운 뒤에도 같다.**
```
옛 토큰 /api/me HTTP 401
새 토큰 /api/me HTTP 200
```
### 캐시가 구해주지 않는다
처음에는 "리소스 서버가 JWKS 를 캐시하고 있으니 한동안은 통하겠지" 라고
예상했다. **아니었다. 제거 직후 바로 401 이다.**
이유는 Spring 의 `NimbusJwtDecoder` 동작에 있다 —
**모르는 `kid` 를 만나면 JWKS 를 다시 가져온다.** 그래서 캐시가 오히려
빨리 갱신되고, 결과적으로 **제거가 즉시 반영된다.**
> **캐시를 유예 기간으로 기대하면 안 된다.**
> 유예는 **옛 키를 JWKS 에 남겨두는 기간**으로 만들어야 한다.
---
## 4. 개념
### `kid` — key ID
```json
{"alg":"RS256","typ":"JWT","kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"}
```
토큰 헤더에 **어느 키로 서명했는지**가 적혀 있다. 검증하는 쪽은 JWKS 에서
`kid` 를 찾아 공개키를 얻는다.
**`kid` 가 있기 때문에 여러 키를 동시에 운용할 수 있다.**
### 안전한 회전의 모양
```
t0 키 A 만 있다. 발급: A, 검증: A
t1 키 B 추가. 발급: B, 검증: A + B ← 겹치는 구간
t2 키 A 제거. 발급: B, 검증: B
★ t1 ~ t2 사이가 "A 로 서명된 것이 살아 있는 기간" 보다 길어야 한다
```
**겹치는 구간의 최소 길이 = 옛 키로 서명된 것 중 가장 오래 사는 것의 수명.**
| 이 실험대에서 | |
|---|---|
| access token | 60초 |
| refresh token | 1800초 (30분) |
| **필요한 겹침** | **최소 30분** |
**Q3 가 물은 "교체하는 동안" 이 바로 이 구간**이고, 길이를 정하는 것은
key 가 아니라 **그 key 로 만든 것의 수명**이다.
### ①에 적용하면
토큰 저장소를 암호화한다면 같은 구조가 필요하다.
```
쓰기: 새 key 하나로만
읽기: 새 key + 옛 key(들) ← key 에도 식별자가 필요하다
제거: 옛 key 로 암호화된 마지막 항목이 만료된 뒤
```
**저장된 값에 `kid` 에 해당하는 표시가 없으면 회전이 불가능하다.**
암호화를 설계할 때 **key 식별자를 값과 함께 저장**해야 하는 이유다.
---
## 5. 겪은 문제
### `kcadm get components` 가 조용히 빈 결과
```bash
kcadm.sh get components -r keycloak-patterns -q type=org.keycloak.keys.KeyProvider
```
`-q type=...` 로는 아무것도 안 나왔다. `--fields` 를 붙여 전체를 받아야 보였다.
**필터가 안 먹었는데 오류도 없었다** — A층에서 반복해 만난 "조용한 실패"다.
---
## 6. 재현 절차 (명령어)
```bash
# 1. 현재 키와 토큰의 kid
kcadm.sh get keys -r keycloak-patterns
curl -s $KC/protocol/openid-connect/certs | tr ',' '\n' | grep kid
# 2. 회전 — 더 높은 우선순위로 추가한다 (바꾸는 것이 아니다)
kcadm.sh create components -r keycloak-patterns \
-s name=rsa-rotated -s providerId=rsa-generated \
-s providerType=org.keycloak.keys.KeyProvider \
-s 'config.priority=["200"]' -s 'config.algorithm=["RS256"]' -s 'config.keySize=["2048"]'
# 3. 옛 토큰이 아직 통하는지 (통해야 정상)
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $OLD" http://echo...:8081/api/me
# 4. 옛 공급자 목록 — -q 필터는 안 먹는다. --fields 로 전체를 본다
kcadm.sh get components -r keycloak-patterns --fields id,name,providerId
# 5. 제거하고 다시 확인 — 여기서 401 이 나와야 정상이다
kcadm.sh delete components/<old-id> -r keycloak-patterns
```
---
## 7. 다음 실험에 남기는 것
| 실험 | 이 실험이 준 것 |
|---|---|
| **B-7** oauth2-proxy cookie secret | **같은 모양의 문제.** 다만 거기는 겹침 구간을 만들 수 있는가가 관건 |
| **D-2** 버전 업그레이드 | Redis 의 **Java 직렬화 세션**(B-1)도 같은 "옛 형식을 읽을 수 있는가" 문제다 |
| 설계 | 암호화를 넣는다면 **값과 함께 key 식별자를 저장**해야 회전할 수 있다 |
@@ -0,0 +1,250 @@
# B-7 — oauth2-proxy 의 cookie secret 을 교체하면 → Q1 미지수 7
브랜치 `feature/keycloak-b7-cookie-secret-rotation` ·
증거 [`docs/evidence/b7-cookie-secret/`](evidence/b7-cookie-secret/) ·
2026-09-04 16:0016:15 KST
선행: [`B-6`](experiment-b6-key-rotation.md) — 같은 "key 회전" 주제의 다른 사례
**대응 질문** — Q1 미지수 7
> *"OAuth2-Proxy 구조의 replica 들이 같은 cookie secret 을 어떻게 공유하고
> 교체하게 되는가. 교체하는 동안 로그인해 있던 사람은 어떻게 되는가."*
---
## 0. 결론부터
| 물음 | 답 |
|---|---|
| replica 가 secret 을 어떻게 공유하는가 | **같은 k8s Secret 을 읽는다.** 그러면 **콜백이 다른 replica 로 가도 된다** |
| 겹침 구간을 만들 수 있는가 | **★ 없다.** `--cookie-secret` 은 **단수**다 |
| 교체하면 로그인한 사람은 | **쿠키가 무효가 된다.** SSO 가 살아 있으면 재로그인이 조용히 일어난다 |
| **서버 쪽 세션은** | **★ 고아로 남는다.** 티켓을 못 풀어 지우지도 못한다 |
---
## 1. BFF 와 정반대의 성질
B-0 에서 **BFF 는 replica 2개면 로그인 자체가 실패**했다. 인가 요청이
인스턴스 메모리에 있어 콜백이 다른 인스턴스로 가면 못 찾기 때문이다.
oauth2-proxy 는 그렇지 않았다.
```
--- replica 8p5hl ---
[oauthproxy.go:1024] No valid authentication in request. Initiating login.
GET "/api/echo" ← 흐름을 시작한 replica
--- replica b9928 ---
[AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com ...}
GET "/oauth2/callback?state=..." ← 콜백을 받은 replica
```
**시작한 replica 와 콜백을 처리한 replica 가 다른데도 성공했다.**
| 왜 | |
|---|---|
| BFF | 인가 요청을 **HttpSession**(인스턴스 메모리)에 둔다 |
| oauth2-proxy | 인가 요청(state, CSRF)을 **쿠키**에 두고 **secret 으로 서명**한다 |
**secret 만 같으면 어느 replica 든 그 쿠키를 검증할 수 있다.**
이것이 Q1 이 물은 "어떻게 공유하는가" 의 답이다 — **공유할 상태가 없고,
공유할 것은 secret 하나뿐이다.**
---
## 2. 문제 — 큰 쿠키가 프록시를 넘지 못했다
처음 구성에서 콜백이 계속 **502** 였다.
```
GET /oauth2/callback?state=...&code=... → 502 Bad Gateway
```
**계층을 분리해 원인을 좁혔다.**
```bash
# Traefik 직접 (nginx 우회)
curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/ping → 200
curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/ → 302
```
**Traefik 은 정상이고 nginx 가 502 를 낸다.** 그리고 502 는 **쿠키를 설정하는
응답에서만** 났다.
**진단** — oauth2-proxy 는 기본적으로 **세션 전체를 쿠키에 담는다.**
`Set-Cookie` 가 nginx 의 `proxy_buffer_size` 를 넘겼다.
> **B-4 에서 본 헤더 크기 절벽이 이번에는 응답 쪽에서 나타났다.**
> 거기서는 요청 헤더가 8KB 에서 400 이 됐고, 여기서는 응답 헤더가
> 프록시 버퍼를 넘겨 502 가 됐다. **같은 종류의 한계다.**
### 그리고 여기서 환경 사실 하나를 발견했다
nginx 설정을 보려는 시도가 계속 **빈 결과**였다.
```
$ sudo -n true
sudo: a password is required
```
**test-server 의 sudo 는 비밀번호를 요구한다.** 게스트(kc-lab-1/2)는 무암호라
A층에서 `conntrack`·`tc` 를 문제없이 썼는데, **호스트는 다르다.**
> **앞선 "nginx 로그가 비어 있다" 는 관측은 로그가 없던 것이 아니라
> sudo 가 조용히 실패한 것이었다.** A층 내내 만난 "조용한 실패" 유형이
> 도구 자체에서 또 나왔다.
### 해결 — 세션을 Redis 로 옮긴다
```yaml
- --session-store-type=redis
- --redis-connection-url=redis://redis.keycloak-lab.svc:6379
```
```
쿠키: _oauth2_proxy=djIuWDI5aGRYUm9NbDl3Y205NGVTMWlNall4TVRGbVltUXhabVJoWWpO...|1788500470|iPSRUlwHDB0XgC6sUdU4dq1EHq9WQDPYrDoezajKVUA=
└─ 세션 전체가 아니라 티켓이다 (약 180자)
```
**쿠키가 티켓으로 줄자 502 가 사라졌다.**
```
Redis: _oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a
```
---
## 3. 동작 확인 — upstream 이 받는 것
![oauth2-proxy 로그인 성공](evidence/b7-cookie-secret/b7-oauth2proxy-login-success.png)
```json
"x-forwarded-email" : [ "labuser@example.com" ],
"x-forwarded-preferred-username" : [ "labuser" ],
"x-forwarded-user" : [ "27df5ea9-8703-4ec5-badd-d972c583e1ff" ],
"x-forwarded-proto" : [ "https" ]
```
**B-4 에서 위조가 통한다고 측정한 바로 그 헤더**를 oauth2-proxy 가 붙인다.
Forward-Auth 구조의 신원 전달 방식이며, **B-4 의 결론이 그대로 적용된다**
edge 가 붙인 것과 공격자가 보낸 것을 upstream 은 구별하지 못한다.
---
## 4. ★ secret 교체 — 겹침 구간이 없다
```bash
kubectl -n keycloak-lab exec deploy/oauth2-proxy -- /bin/oauth2-proxy --help | grep cookie-secret
```
```
--cookie-secret string the seed string for secure cookies (optionally base64 encoded)
```
**단수다.** `--cookie-secrets``--old-cookie-secret` 도 없다.
> **B-6 에서 Keycloak 이 두 키를 동시에 들고 무중단 회전을 할 수 있었던 것과
> 대비된다.** 거기서는 `kid` 로 여러 키를 구분했는데, **oauth2-proxy 의 쿠키에는
> 그런 식별자가 없다.**
>
> **key 식별자가 없으면 회전에 겹침 구간을 만들 수 없다** — B-6 에서
> "값과 함께 key 식별자를 저장해야 한다" 고 쓴 것의 반례다.
### 교체하고 관찰했다
```bash
kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
-p '[{"op":"replace","path":"/spec/.../secretKeyRef/key","value":"COOKIE_SECRET_B"}]'
```
```
[stored_session.go:94] Error loading cookied session:
session ticket cookie failed validation: <nil>, removing session
[stored_session.go:97] Error removing session:
error decoding ticket to clear session: session ticket cookie failed validation
[oauthproxy.go:1024] No valid authentication in request. Initiating login.
...
[AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com ...}
```
| 관찰 | |
|---|---|
| 옛 쿠키 | **검증 실패**`session ticket cookie failed validation` |
| 사용자 경험 | **Keycloak SSO 가 살아 있어 조용히 재로그인**됐다. 로그인 화면을 안 봤다 |
| **서버 쪽 세션** | **★ 지우지 못했다** |
### 고아 세션이 남는다
```
_oauth2_proxy-978dfaefbdadccb96c7be1625dba5616 ← 새 세션
_oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a ← ★ 옛 세션. 남아 있다
총: 2 개
```
**`Error removing session: error decoding ticket to clear session`**
정리하려면 **티켓에서 Redis 키를 계산해야 하는데, 그 티켓을 못 푼다.**
그래서 **지울 수도 없다.**
```
secret 교체
└─ 옛 티켓을 못 푼다
├─ 사용자는 재로그인 (SSO 가 있으면 조용히)
└─ ★ 서버 세션은 TTL 만료까지 고아로 남는다
```
**로그인한 사용자 수만큼 고아가 생긴다.** TTL(여기서는 1시간)이 지나야 사라진다.
---
## 5. Q1 미지수 7 에 대한 답
| 물음 | 답 |
|---|---|
| **어떻게 공유하는가** | 같은 k8s Secret 을 모든 replica 가 읽는다. 상태를 공유할 필요가 없다 |
| **어떻게 교체하는가** | **무중단 교체 수단이 없다.** 옵션이 단수이므로 한 번에 바뀐다 |
| **교체 중 로그인한 사람은** | 쿠키가 무효가 된다. **IdP SSO 가 살아 있으면 눈에 안 띄고, 없으면 전원 로그인 화면** |
| (추가로 드러난 것) | **서버 세션이 고아로 남는다** |
### 그래서 무엇을 해야 하는가
| | |
|---|---|
| 교체 시점 | **트래픽이 적은 시간.** 전원이 한 번씩 재인증을 거친다 |
| IdP SSO 유지 | SSO 세션이 살아 있으면 **사용자에게 안 보인다** — 이것이 실질적 완충재다 |
| 고아 정리 | TTL 에 의존하거나 **교체 전에 Redis 를 비운다** (어차피 다 무효다) |
| 근본 | 쿠키에 **key 식별자**가 있어야 겹침이 가능하다 — 지금 구현엔 없다 |
---
## 6. 재현 절차 (명령어)
```bash
# 1. 배포 (Redis 세션 저장소로 — 쿠키가 크면 프록시에서 502)
kubectl apply -f deploy/lab/k8s/b7-oauth2-proxy.yaml
# 2. 502 가 나면 계층을 분리한다
curl -H "Host: app2.hyeonworks.com" http://<node-ip>/ping # Traefik 직접
# 3. 겹침 가능 여부 — 옵션이 단수인지 확인
kubectl -n keycloak-lab exec deploy/oauth2-proxy -- /bin/oauth2-proxy --help | grep cookie-secret
# 4. 교체
kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
-p '[{"op":"replace","path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key","value":"COOKIE_SECRET_B"}]'
# 5. 무슨 일이 났는지 — 로그와 Redis 를 함께 본다
kubectl -n keycloak-lab logs deploy/oauth2-proxy | grep -i "stored_session"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy*'
```
---
## 7. 다음 실험에 남기는 것
| 실험 | 이 실험이 준 것 |
|---|---|
| **C-1** SSO | **app1(BFF) 과 app2(oauth2-proxy) 두 앱이 준비됐다.** 서로 다른 구조로 같은 IdP 를 쓴다 |
| **D-3** 비밀 관리 | cookie secret 이 k8s Secret 에 평문이다 |
| 운영 | secret 교체는 **무중단이 아니다.** 창을 고르고 고아를 정리한다 |
+244
View File
@@ -0,0 +1,244 @@
# C-1 — SSO 를 붙이면 무엇이 달라지는가
브랜치 `feature/keycloak-c1-multi-app-sso` ·
증거 [`docs/evidence/c1-multi-app-sso/`](evidence/c1-multi-app-sso/) ·
2026-09-04 16:1516:30 KST
선행: [`B-2`](experiment-b2-multi-instance-session.md) (app1 = BFF) ·
[`B-7`](experiment-b7-cookie-secret-rotation.md) (app2 = oauth2-proxy)
**원래 질문***"SSO 를 추가하게 되면 어떻게 달라지는지"*
---
## 0. 결론부터
| 물음 | 답 |
|---|---|
| 두 번째 앱에서 로그인 화면이 뜨는가 | **안 뜬다.** SSO 가 동작한다 |
| Keycloak 안의 구조는 | **user session 1개 · client session 2개** |
| **IdP 세션을 죽이면 두 앱이 끊기는가** | **★ 안 끊긴다.** 두 앱 모두 그대로 동작한다 |
**"SSO 를 붙이면 IdP 가 단일 장애점이 된다" 는 절반만 맞다.**
**로그인할 때는 그렇고, 이미 로그인한 뒤에는 아니다.**
---
## 1. 구성 — 서로 다른 구조의 두 앱
```
app1.hyeonworks.com → BFF 서버 세션 (Redis) + 토큰 (PostgreSQL)
app2.hyeonworks.com → oauth2-proxy 쿠키 티켓 + 세션 (Redis)
둘 다 realm keycloak-patterns
```
**우연히 좋은 실험대가 됐다.** B-2 와 B-7 에서 만든 두 앱이
**같은 IdP 를 쓰지만 세션을 완전히 다르게 다룬다.**
### 깨끗한 상태에서 시작한다
```bash
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "delete from offline_client_session" -c "delete from offline_user_session"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushall
kubectl -n keycloak-lab rollout restart statefulset/keycloak
```
**`kcadm create realms/.../logout-all` 은 듣지 않았다.** 세션이 그대로 남아
DB 를 직접 지우고 Keycloak 을 재시작해야 했다 — **캐시 때문이다**
(A-1 에서 확인한 대로, DB 를 직접 지워도 캐시는 남는다).
```
Keycloak 온라인 세션: 0
app1 HTTP 200 / app2 HTTP 200
```
---
## 2. SSO 가 동작한다
### app1 로그인 — 로그인 화면이 나온다
```
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth
?client_id=bff-confidential&...
→ Sign in to keycloak-patterns
```
로그인 후
```
user_session_id | client_sessions
--------------------------+-----------------
oqOjHekin4JU-BZjgQLjUByW | 1
Redis: bff:session:sessions:6e0d9af4-...
PostgreSQL authorized client: 1 행
```
### app2 방문 — **로그인 화면 없이 통과한다**
![app2 가 로그인 없이 열린다](evidence/c1-multi-app-sso/c1-sso-app2-no-login-screen.png)
```
user_session_id | client_sessions
--------------------------+-----------------
oqOjHekin4JU-BZjgQLjUByW | 2 ← 1 → 2
client_id | name
--------------------------------------+------------------
9055fa46-6abb-4d6d-a339-8a9183bbf26d | bff-confidential
80431dbc-af81-4673-9790-ad06d1570b2e | oauth2-proxy
Redis:
bff:session:sessions:6e0d9af4-... ← app1 의 세션
_oauth2_proxy-6b028a70f... ← app2 의 세션
```
### 개념 — SSO 의 데이터 구조
```
user session (사용자 · 브라우저 하나당 하나)
├─ client session : bff-confidential
└─ client session : oauth2-proxy
```
**A-3 과 B-3 에서 봤던 그 구조가 여기서 의미를 갖는다.**
- **A-3** — 크래시로 `user_session` 행이 통째로 사라지면 **모든 앱이 끊긴다**
- **B-3** — 재사용 탐지가 **`client_session` 만** 제거하면 **그 앱만 끊긴다**
**두 층이 나뉘어 있는 이유가 SSO 다.** 앱 하나의 문제가 다른 앱에 번지지
않도록 하려면 client session 이 따로 있어야 한다.
---
## 3. ★ IdP 세션을 죽여도 두 앱은 살아 있다
```bash
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
create users/<user-id>/logout -r keycloak-patterns
```
```
남은 keycloak-patterns 세션: 0 (master realm 의 admin 세션만 남음)
Redis:
_oauth2_proxy-6b028a70f... ← 남아 있다
bff:session:sessions:6e0d9af4-... ← 남아 있다
PostgreSQL authorized client: 1 행 ← 남아 있다
```
브라우저로 두 앱을 다시 열었다.
![IdP 세션이 없어도 앱은 동작한다](evidence/c1-multi-app-sso/c1-apps-alive-after-idp-logout.png)
**둘 다 로그인 화면 없이 그대로 열렸다.**
### 왜 그런가 — 세 개의 독립된 수명
```
① IdP 세션 (Keycloak) ssoSessionIdleTimeout 1800초
② 앱 세션 (BFF / oauth2-proxy) 각자 30분 / 1시간
③ access token 60초
①을 지워도 ②는 자기 수명을 산다
```
**로그아웃은 ①만 지운 것이고, ②는 아무도 안 건드렸다.**
| 언제 끊기는가 | |
|---|---|
| BFF | access token 이 만료되어 **refresh 를 시도할 때**`Session not active` |
| oauth2-proxy | 쿠키 만료(1시간) 또는 **토큰 갱신 시도**할 때 |
**즉시가 아니라 지연되어 끊긴다.** 최대 지연은 access token 수명(60초)이
아니라 **앱이 다음에 IdP 를 부를 때까지**다.
> **이것이 B-2 에서 "로그아웃했는데 다시 들어가진다" 를 겪은 것의 반대편이다.**
> 거기서는 앱 세션을 지웠는데 IdP 세션이 남아 재로그인이 됐고,
> 여기서는 IdP 세션을 지웠는데 앱 세션이 남아 계속 들어가진다.
>
> **두 방향 모두 "한쪽만 지우면 다른 쪽이 남는다" 이다.**
---
## 4. 그래서 SSO 의 대가는 무엇인가
원래 질문 *"SSO 를 추가하면 어떻게 달라지는가"* 에 대한 답이다.
| | 앱이 하나일 때 | **SSO 일 때** |
|---|---|---|
| 로그인 | 앱마다 | **한 번** |
| IdP 가 죽으면 | 그 앱만 로그인 불가 | **모든 앱이 로그인 불가** |
| **이미 로그인한 사용자** | — | **★ 영향 없다** (앱 세션이 살아 있으므로) |
| 로그아웃 | 그 앱만 | **전 앱을 끊으려면 백채널 로그아웃이 필요** |
| 세션 수명 | 하나 | **세 층이 각자** — 어긋나면 예측이 어렵다 |
**IdP 는 "로그인 경로"의 단일 장애점이지 "이미 로그인한 사용자"의 단일 장애점이
아니다.** A-2(DB 상실)에서 본 것과 합치면
```
Keycloak DB 죽음 → 새 로그인 불가 (전 앱)
→ 이미 로그인한 사용자는 앱 세션 수명 동안 계속 쓴다
→ 그 뒤 갱신 시점에 한꺼번에 끊긴다
```
**장애가 즉시 전면화되지 않고 "앱 세션 수명만큼 지연되어 몰려온다."**
이것이 SSO 구조의 장애 모양이며, **모니터링이 어려운 이유**다.
---
## 5. 겪은 문제
| 문제 | |
|---|---|
| `kcadm create realms/<r>/logout-all` 이 안 먹었다 | 세션 수가 그대로였다. DB 를 직접 지우고 **Keycloak 재시작**해야 했다 (A-1 의 캐시 문제) |
| `kcadm delete sessions/<id>` 도 안 먹었다 | 오류 없이 아무 일도 안 일어났다. `users/<id>/logout` 은 동작했다 |
| 세션 수를 셀 때 realm 을 안 봤다 | `master` realm 의 admin 세션이 섞여 "안 지워졌다" 로 오독할 뻔했다 |
**세 번째가 특히 위험했다** — realm 을 join 해서 보고 나서야 알았다.
```sql
select us.user_session_id, r.name as realm, ...
from offline_user_session us join realm r on r.id = us.realm_id
```
---
## 6. 재현 절차 (명령어)
```bash
# 1. 깨끗한 상태 — logout-all 은 안 먹으므로 DB + 재시작
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "delete from offline_client_session" -c "delete from offline_user_session"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushall
kubectl -n keycloak-lab rollout restart statefulset/keycloak
# 2. app1 로그인 → app2 방문 (로그인 화면이 안 떠야 SSO)
# 3. 구조 확인 — user session 1 에 client session 2
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"select us.user_session_id, r.name as realm,
(select count(*) from offline_client_session cs
where cs.user_session_id=us.user_session_id) as clients
from offline_user_session us join realm r on r.id=us.realm_id
where us.offline_flag='0'"
# 4. IdP 세션만 죽인다
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
create users/<user-id>/logout -r keycloak-patterns
# 5. 두 앱을 다시 연다 — 그대로 열리면 앱 세션이 독립적이라는 뜻
```
---
## 7. 다음 실험에 남기는 것
| 실험 | 이 실험이 준 것 |
|---|---|
| **C-2** 백채널 로그아웃 | **이 실험이 C-2 가 왜 필요한지 보여준다** — IdP 로그아웃이 앱에 전파되지 않는다 |
| **D-1** 백업·복구 | user session 을 잃으면 전 앱이 끊긴다 — 백업 범위에 들어간다 |
| 운영 | **세 층의 수명을 맞추거나, 어긋날 때의 동작을 정의해야 한다** |