Compare commits

..
Author SHA1 Message Date
DongHyeonkaandClaude Opus 5 df5af95cb3 docs: D-1 — an empty database still answered 200
Dropping the schema left Keycloak serving realm metadata and JWKS from its Infinispan cache, so the front door stayed at 200 while only the paths that read the database failed. That is a different shape from A-2, where the connection itself broke and readiness pulled the pods out of the Service; here the connection is fine and the tables are simply gone, which the health check does not notice.

Restoring the pg_dump took one second with zero errors and no pod restart, and the row counts matched the backup exactly, sessions included. The real RPO is the backup interval plus the synchronous_commit loss measured in A-3, and this dump sits in the host's /tmp, which is the same failure domain as the thing it protects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:59:56 +09:00
DongHyeonkaandClaude Opus 5 6c310c93b7 docs: C-2 — nothing propagates because nobody implemented the receiving end
Neither client had a backchannel logout URL and the BFF has no oidcLogout configuration, so the three candidate paths all answer 302, which is the authentication redirect rather than a handler. Setting the URL on the identity provider alone changed nothing: with a live session, logging the user out emptied the Keycloak side and left the Redis session untouched.

Reachability is not the blocker here, since a Keycloak pod fetches the app's public URL with a 200, but that is a property of this tailnet split-DNS lab and is the assumption most likely to fail in production, where it fails silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:56:58 +09:00
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
67 changed files with 2559 additions and 4 deletions
@@ -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,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\" }"
@@ -0,0 +1 @@
- generic [active] [ref=f50e1]: "{\"pattern\":\"AP3-backend-for-frontend\",\"principal\":\"labuser\",\"accessTokenStoredOnServer\":true,\"refreshTokenStoredOnServer\":true,\"browserTokenCount\":0,\"csrfProtectionEnabled\":true}"
@@ -0,0 +1,16 @@
- generic [ref=f51e3]:
- banner [ref=f51e4]:
- generic [ref=f51e5]: keycloak-patterns
- main [ref=f51e6]:
- heading "Sign in to your account" [level=1] [ref=f51e8]
- generic [ref=f51e12]:
- generic [ref=f51e13]:
- generic [ref=f51e14]: Username or email
- textbox "Username or email" [active] [ref=f51e17]
- generic [ref=f51e18]:
- generic [ref=f51e19]: Password
- generic [ref=f51e21]:
- textbox "Password" [ref=f51e24]
- button "Show password" [ref=f51e26] [cursor=pointer]:
- generic [aria-hidden] [ref=f51e27]:
- button "Sign In" [ref=f51e30] [cursor=pointer]
@@ -0,0 +1,7 @@
- main [ref=f52e2]:
- heading "AP3 · Backend-for-Frontend" [level=1] [ref=f52e3]
- paragraph [ref=f52e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다.
- button "Keycloak 로그인" [ref=f52e5] [cursor=pointer]
- button "token 경계 확인" [ref=f52e6] [cursor=pointer]
- button "BFF 경유 API 호출" [ref=f52e7] [cursor=pointer]
- button "CSRF token으로 상태 변경" [ref=f52e8] [cursor=pointer]
@@ -0,0 +1,16 @@
- generic [ref=f53e3]:
- banner [ref=f53e4]:
- generic [ref=f53e5]: keycloak-patterns
- main [ref=f53e6]:
- heading "Sign in to your account" [level=1] [ref=f53e8]
- generic [ref=f53e12]:
- generic [ref=f53e13]:
- generic [ref=f53e14]: Username or email
- textbox "Username or email" [ref=f53e17]
- generic [ref=f53e18]:
- generic [ref=f53e19]: Password
- generic [ref=f53e21]:
- textbox "Password" [ref=f53e24]
- button "Show password" [ref=f53e26] [cursor=pointer]:
- generic [aria-hidden] [ref=f53e27]:
- button "Sign In" [ref=f53e30] [cursor=pointer]
@@ -0,0 +1,16 @@
- generic [ref=f53e3]:
- banner [ref=f53e4]:
- generic [ref=f53e5]: keycloak-patterns
- main [ref=f53e6]:
- heading "Sign in to your account" [level=1] [ref=f53e8]
- generic [ref=f53e12]:
- generic [ref=f53e13]:
- generic [ref=f53e14]: Username or email
- textbox "Username or email" [ref=f53e17]: labuser
- generic [ref=f53e18]:
- generic [ref=f53e19]: Password
- generic [ref=f53e21]:
- textbox "Password" [active] [ref=f53e24]: labpass
- button "Show password" [ref=f53e26] [cursor=pointer]:
- generic [aria-hidden] [ref=f53e27]:
- button "Sign In" [ref=f53e30] [cursor=pointer]
@@ -0,0 +1,16 @@
- generic [ref=f53e3]:
- banner [ref=f53e4]:
- generic [ref=f53e5]: keycloak-patterns
- main [ref=f53e6]:
- heading "Sign in to your account" [level=1] [ref=f53e8]
- generic [ref=f53e12]:
- generic [ref=f53e13]:
- generic [ref=f53e14]: Username or email
- textbox "Username or email" [ref=f53e17]: labuser
- generic [ref=f53e18]:
- generic [ref=f53e19]: Password
- generic [ref=f53e21]:
- textbox "Password" [active] [ref=f53e24]: labpass
- button "Show password" [ref=f53e26] [cursor=pointer]:
- generic [aria-hidden] [ref=f53e27]:
- button "Sign In" [ref=f53e30] [cursor=pointer]
@@ -0,0 +1,16 @@
- generic [ref=f53e3]:
- banner [ref=f53e4]:
- generic [ref=f53e5]: keycloak-patterns
- main [ref=f53e6]:
- heading "Sign in to your account" [level=1] [ref=f53e8]
- generic [ref=f53e12]:
- generic [ref=f53e13]:
- generic [ref=f53e14]: Username or email
- textbox "Username or email" [ref=f53e17]: labuser
- generic [ref=f53e18]:
- generic [ref=f53e19]: Password
- generic [ref=f53e21]:
- textbox "Password" [active] [ref=f53e24]: labpass
- button "Show password" [ref=f53e26] [cursor=pointer]:
- generic [aria-hidden] [ref=f53e27]:
- button "Sign In" [ref=f53e30] [cursor=pointer]
@@ -0,0 +1,16 @@
- generic [ref=f54e3]:
- banner [ref=f54e4]:
- generic [ref=f54e5]: keycloak-patterns
- main [ref=f54e6]:
- heading "Sign in to your account" [level=1] [ref=f54e8]
- generic [ref=f54e12]:
- generic [ref=f54e13]:
- generic [ref=f54e14]: Username or email
- textbox "Username or email" [ref=f54e17]
- generic [ref=f54e18]:
- generic [ref=f54e19]: Password
- generic [ref=f54e21]:
- textbox "Password" [ref=f54e24]
- button "Show password" [ref=f54e26] [cursor=pointer]:
- generic [aria-hidden] [ref=f54e27]:
- button "Sign In" [ref=f54e30] [cursor=pointer]
@@ -0,0 +1,16 @@
- generic [ref=f54e3]:
- banner [ref=f54e4]:
- generic [ref=f54e5]: keycloak-patterns
- main [ref=f54e6]:
- heading "Sign in to your account" [level=1] [ref=f54e8]
- generic [ref=f54e12]:
- generic [ref=f54e13]:
- generic [ref=f54e14]: Username or email
- textbox "Username or email" [ref=f54e17]: labuser
- generic [ref=f54e18]:
- generic [ref=f54e19]: Password
- generic [ref=f54e21]:
- textbox "Password" [active] [ref=f54e24]: labpass
- button "Show password" [ref=f54e26] [cursor=pointer]:
- generic [aria-hidden] [ref=f54e27]:
- button "Sign In" [ref=f54e30] [cursor=pointer]
+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
+28 -4
View File
@@ -24,9 +24,24 @@ stringData:
# Base64 in etcd is not encryption — see D-3. # Base64 in etcd is not encryption — see D-3.
KEYCLOAK_CLIENT_SECRET: bff-lab-secret KEYCLOAK_CLIENT_SECRET: bff-lab-secret
--- ---
# Redis. No persistence yet: `--save ""` and no appendonly, so a restart loses # Redis. B-5 measured that turning on AOF with `redis-cli config set` changes
# everything. B-5 and B-6 compare that against RDB and AOF, which is easier to # nothing here, because /data is the container filesystem and dies with the
# reason about when the starting point is "nothing survives". # 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 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
@@ -47,16 +62,25 @@ spec:
containers: containers:
- name: redis - name: redis
image: redis:7.4-alpine 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: ports:
- containerPort: 6379 - containerPort: 6379
name: redis name: redis
readinessProbe: readinessProbe:
exec: { command: ["redis-cli", "ping"] } exec: { command: ["redis-cli", "ping"] }
initialDelaySeconds: 3 initialDelaySeconds: 3
volumeMounts:
- name: data
mountPath: /data
resources: resources:
requests: { memory: 32Mi, cpu: 20m } requests: { memory: 32Mi, cpu: 20m }
limits: { memory: 128Mi } limits: { memory: 128Mi }
volumes:
- name: data
persistentVolumeClaim:
claimName: redis-data
--- ---
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
@@ -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,12 @@
=== 현재 클라이언트의 백채널 로그아웃 설정 ===
--- bff-confidential ---
"frontchannelLogout" : false,
--- oauth2-proxy ---
"frontchannelLogout" : false,
=== BFF 가 백채널 로그아웃 엔드포인트를 갖고 있는가 ===
=== 실제로 그 경로가 있는가 ===
/logout/connect/back-channel/keycloak HTTP 302
/backchannel-logout HTTP 302
/oauth2/sign_out HTTP 302
@@ -0,0 +1,8 @@
=== IdP 쪽에만 백채널 로그아웃 URL 을 설정한다 ===
client id: 9055fa46-6abb-4d6d-a339-8a9183bbf26d
command terminated with exit code 1
=== 로그인 상태를 만든다 ===
(브라우저에 이미 세션이 있다)
Keycloak 세션: 2
Redis: 2 키
@@ -0,0 +1,28 @@
=== 로그아웃 전 상태 ===
Redis: 2 키
keycloak-patterns 세션: 0
=== ★ IdP 로그아웃 — Keycloak 이 백채널 알림을 보낼 것이다 ===
시각: 14:53:29
=== Keycloak 로그 — 백채널 요청을 보냈는가, 결과는 ===
=== BFF 로그 — 백채널 요청이 도착했는가 ===
=== 앱 세션이 정리되었는가 ===
Redis: 2 키
_oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
=== 로그아웃 전 — 실제 세션이 있는가 ===
keycloak-patterns 세션: 1
Redis: 1 키
=== ★ IdP 로그아웃 → 백채널 알림 ===
시각: 14:54:21
=== Keycloak 로그 ===
=== BFF 로그 — 요청이 왔는가 ===
=== 앱 세션 ===
Redis: 1 키
@@ -0,0 +1,15 @@
=== IdP 세션은 실제로 끊겼는가 ===
keycloak-patterns 세션: 0
=== ★ Keycloak 파드가 app1.hyeonworks.com 에 닿는가 ===
DNS 해석:
Address: 100.83.212.4
Non-authoritative answer:
HTTPS 도달:
HTTP 200 (0 이면 못 닿음)
=== Keycloak 로그 전체에서 backchannel 흔적 ===
keycloak-0: 0 줄
keycloak-1: 0 줄
@@ -0,0 +1,17 @@
# C-2 — 백채널 로그아웃 증거
2026-09-04 16:3016:55 KST
해설: [`docs/experiment-c2-backchannel-logout.md`](../../experiment-c2-backchannel-logout.md)
| 파일 | 무엇을 보여주는가 |
|---|---|
| `01-current-state.txt` | 두 클라이언트 모두 `backchannelLogoutUrl` 없음 · BFF 소스에 `oidcLogout` 없음 · 후보 경로 셋 다 **302**(핸들러 없음) |
| `02-configure-idp.txt` | IdP 쪽에만 `backchannel.logout.url` 설정 (점 표기는 실패, JSON 으로 성공) |
| `03-logout-attempt.txt` | **살아 있는 세션(1)에 로그아웃 → IdP 세션 0, Redis 세션은 1 그대로.** Keycloak·BFF 로그에 흔적 없음 |
| `04-reachability.txt` | **Keycloak 파드가 `app1.hyeonworks.com` 에 `HTTP 200` 으로 닿는다** — 네트워크 문제가 아님 |
## 핵심 세 줄
1. **백채널 로그아웃은 어느 쪽에도 구현되어 있지 않았다.** C-1 이 관측한 "전파 안 됨"의 원인이다.
2. **IdP 쪽만 설정해도 소용없다.** 받을 엔드포인트와 `sid → 세션` 역인덱스가 앱에 있어야 한다.
3. **도달성이 숨은 전제다.** 이 실험대는 닿지만, 앱이 사설망에 있으면 설정해도 조용히 실패한다.
@@ -0,0 +1,15 @@
=== 백업 전 상태 ===
realms|clients|users|sessions|authclients = 2|15|2|3|1
=== pg_dump — 전체 덤프 ===
시작: 14:59:30
완료: 14:59:30
크기: 394945 bytes (6956 줄)
포함된 테이블 수: 101
=== 덤프에 세션이 들어 있는가 ===
offline_user_session 언급: 13
COPY public.offline_user_session (user_session_id, user_id, realm_id, created_on, offline_flag, data, last_session_refre
E1q5xI7tt4U_WhZpW7rEPIF2 48b37d33-8419-49aa-9b5b-7731975be50c 7845f394-723a-4d07-b530-c7416b2e1d31 1788500836 0 {"ipAddr
2ap3DyRiBF8OdMiqCodsJ0mp 48b37d33-8419-49aa-9b5b-7731975be50c 7845f394-723a-4d07-b530-c7416b2e1d31 1788501029 0 {"ipAddr
Zsk4QcgXf_qgyMKzde5AG-Fz 48b37d33-8419-49aa-9b5b-7731975be50c 7845f394-723a-4d07-b530-c7416b2e1d31 1788501263 0 {"ipAddr
@@ -0,0 +1,17 @@
=== ★ 파괴 — 스키마를 통째로 지운다 ===
시각: 14:59:47
DROP SCHEMA
CREATE SCHEMA
남은 테이블: 0
=== 서비스 영향 ===
https://auth.hyeonworks.com/realms/master HTTP 200
https://app1.hyeonworks.com/ HTTP 200
bff-555df79c97-6j86w 1/1 Running 0 49m
bff-555df79c97-vgg6g 1/1 Running 0 49m
keycloak-0 1/1 Running 0 4m15s
keycloak-1 1/1 Running 0 4m38s
=== Keycloak 이 무엇을 말하는가 ===
2026-09-04 05:58:02,598 WARN [org.keycloak.jgroups.protocol.KEYCLOAK_JDBC_PING2] (blocking-thread--p3-t2) Failed to fetch the cluster members from the database.: org.postgresql.ut
at org.postgresql.core.v3.QueryExecutorImpl.receiveErrorResponse(QueryExecutorImpl.java:2904)
@@ -0,0 +1,29 @@
=== 무엇이 실제로 깨지는가 ===
/.well-known/openid-configuration HTTP 500
/protocol/openid-connect/certs HTTP 200
토큰 발급 (DB 쓰기 필요) HTTP 400
=== ★ 복구 — 덤프에서 되돌린다 ===
시작: 15:00:12
완료: 15:00:13
오류 줄: 0
=== 복구 후 데이터 ===
realms|clients|users|sessions|authclients = 2|15|2|3|1
=== 복구 직후 — 재시작 없이 되는가 ===
+15초 well-known=200 토큰발급=200
→ 재시작 없이 회복
=== 복구 전 세션이 살아났는가 ===
user_session_id | realm
--------------------------+-------------------
E1q5xI7tt4U_WhZpW7rEPIF2 | master
2ap3DyRiBF8OdMiqCodsJ0mp | master
Zsk4QcgXf_qgyMKzde5AG-Fz | master
vsDgCVo12-qX0CC63ZmYzbYF | keycloak-patterns
(4 rows)
=== 파드 재시작 횟수 ===
keycloak-0 restarts=0
keycloak-1 restarts=0
+16
View File
@@ -0,0 +1,16 @@
# D-1 — 백업·복구 리허설 증거
2026-09-04 16:5517:05 KST
해설: [`docs/experiment-d1-backup-restore.md`](../../experiment-d1-backup-restore.md)
| 파일 | 무엇을 보여주는가 |
|---|---|
| `01-backup.txt` | `pg_dump --clean --if-exists` — 395KB · 101 테이블 · **세션 데이터 포함** |
| `02-destruction.txt` | `DROP SCHEMA public CASCADE` → 테이블 0개. **그런데 외부는 `HTTP 200`** — Keycloak 이 realm 캐시로 서빙한다 |
| `03-restore.txt` | 깨지는 것과 안 깨지는 것(`certs` 200 / `well-known` 500 / 토큰 400) · **복구 1초 · 오류 0건 · 데이터 완전 일치 · 재시작 0회** |
## 핵심 세 줄
1. **데이터베이스를 통째로 비웠는데 서비스가 200 을 냈다.** 헬스체크는 "DB 가 살아 있다"만 보고 "데이터가 있다"는 안 본다.
2. **복구는 1초, 오류 0건, 재시작 불필요.** 절차가 맞다는 것은 확인됐다.
3. **RPO 는 두 겹이다** — 백업 주기 + A-3 에서 측정한 `synchronous_commit OFF` 손실. 그리고 이번 덤프는 호스트의 `/tmp` 에 있어 **같은 장애 도메인**이다.
@@ -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 을 잃으면 전 앱이 끊긴다 — 백업 범위에 들어간다 |
| 운영 | **세 층의 수명을 맞추거나, 어긋날 때의 동작을 정의해야 한다** |
+238
View File
@@ -0,0 +1,238 @@
# C-2 — 로그아웃이 전 앱에 퍼지는가 (백채널 로그아웃)
브랜치 `feature/keycloak-c2-backchannel-logout` ·
증거 [`docs/evidence/c2-backchannel-logout/`](evidence/c2-backchannel-logout/) ·
2026-09-04 16:3016:55 KST
선행: [`C-1`](experiment-c1-multi-app-sso.md) — **IdP 로그아웃이 앱에 전파되지 않는다**를 관측했다
---
## 0. 결론부터
| 확인 | 결과 |
|---|---|
| 백채널 로그아웃이 설정되어 있었는가 | **아니다.** 두 클라이언트 모두 `backchannelLogoutUrl` 없음 |
| BFF 에 그 엔드포인트가 있는가 | **아니다.** 소스에 `oidcLogout` 설정이 없다 |
| IdP 쪽만 설정하면 되는가 | **★ 안 된다.** 앱 세션이 그대로 남았다 |
| Keycloak 이 앱 URL 에 닿기는 하는가 | **닿는다** (`HTTP 200`) — 네트워크 문제가 아니다 |
**C-1 이 관측한 "로그아웃이 안 퍼진다" 의 원인은 단순했다 —
아무도 구현하지 않았다.**
---
## 1. 현재 상태 — 어느 쪽에도 없다
### IdP 쪽
```bash
kcadm.sh get clients -r keycloak-patterns -q clientId=bff-confidential --fields attributes
```
```
frontchannelLogout : false
(backchannel.logout.url 없음)
```
`oauth2-proxy` 클라이언트도 마찬가지였다.
### 앱 쪽
```bash
grep -rn "oidcLogout\|backchannel" bff/src/main/java/
```
**아무것도 안 나온다.**
Spring Security 6.2+ 는 백채널 로그아웃을 지원하지만 **명시적으로 켜야 한다.**
```java
.oidcLogout(oidc -> oidc.backChannel(Customizer.withDefaults()))
```
이 설정이 없으면 `/logout/connect/back-channel/{registrationId}` 경로가
**생기지 않는다.**
```
/logout/connect/back-channel/keycloak HTTP 302 ← 로그인으로 리다이렉트
/backchannel-logout HTTP 302
/oauth2/sign_out HTTP 302
```
**302 는 "그런 핸들러가 없어서 인증 요구로 떨어졌다"는 뜻**이다.
엔드포인트가 있었다면 POST 를 받아 200 이나 400 을 돌려줬을 것이다.
---
## 2. IdP 쪽만 설정하고 시험했다
```bash
kcadm.sh update clients/<id> -r keycloak-patterns \
-s 'attributes={"backchannel.logout.url":"https://app1.hyeonworks.com/logout/connect/back-channel/keycloak",
"backchannel.logout.session.required":"true"}'
```
```
backchannel.logout.session.required = true
backchannel.logout.url = https://app1.hyeonworks.com/logout/connect/back-channel/keycloak
```
### 살아 있는 세션에 로그아웃을 걸었다
```
=== 로그아웃 전 ===
keycloak-patterns 세션: 1
Redis: 1 키
=== IdP 로그아웃 ===
kcadm.sh create users/<id>/logout -r keycloak-patterns
=== 결과 ===
keycloak-patterns 세션: 0 ← IdP 쪽은 끊겼다
Redis: 1 키 ← ★ 앱 세션은 그대로다
```
**IdP 세션만 사라지고 앱 세션은 남았다.** C-1 과 같은 결과다.
### 네트워크 문제가 아님을 확인했다
```
=== Keycloak 파드가 app1.hyeonworks.com 에 닿는가 ===
DNS 해석: Address: 100.83.212.4
HTTPS 도달: HTTP 200
```
**클러스터 안에서 공개 이름으로 앱에 닿는다.** 이 실험대는 tailnet + split DNS
구성이라 헤어핀이 되는데, **운영에서는 안 되는 경우가 흔하다.**
> **백채널 로그아웃의 숨은 전제** — IdP 가 **앱의 공개 URL 로 서버에서 서버로**
> 요청을 보낼 수 있어야 한다. 앱이 사설망에 있고 IdP 가 밖에 있으면
> **설정을 해도 도달하지 못한다.** 그때는 로그도 안 남고 조용히 실패한다.
---
## 3. 그래서 왜 안 퍼졌는가
```
IdP 로그아웃
├─ ① Keycloak 이 backchannel.logout.url 로 POST 를 보낸다 (설정함)
├─ ② 앱이 그 POST 를 받는 엔드포인트를 갖고 있다 ★ 없다
└─ ③ 앱이 logout token 을 검증하고 sid 로 세션을 찾아 지운다 ★ 없다
```
**②와 ③이 없다.** ①만 설정해도 받을 사람이 없다.
Keycloak 로그에 `backchannel` 문자열이 **0줄**이었다 — 다만 이것만으로
"보내지 않았다"고 단정할 수는 없다 (DEBUG 레벨일 수 있다).
**확실한 것은 앱 세션이 남았다는 관측**이다.
---
## 4. 개념 — 백채널 로그아웃의 구조
```
사용자가 어느 앱에서든 로그아웃
Keycloak 이 SSO 세션에 붙은 client session 목록을 본다 (C-1 의 그 구조)
├──POST──▶ app1 의 backchannel.logout.url
└──POST──▶ app2 의 backchannel.logout.url
본문: logout_token (JWT)
{ "sid": "...", "sub": "...", "events": {...} }
```
### `sid` 가 여기서 쓰인다
**A-0 에서 확인한 그 `sid`** 다 — JWT·DB·관리 API 에서 같은 문자열이었던.
```
logout_token 의 sid → 앱이 "그 sid 로 만든 내 세션"을 찾아 지운다
```
**그래서 앱은 `sid → 자기 세션 ID` 역인덱스를 갖고 있어야 한다.**
Spring Security 는 이를 위해 `OidcSessionRegistry` 를 쓰며,
**여러 인스턴스가 있으면 그 레지스트리도 공유 저장소여야 한다**
B-1·B-2 에서 겪은 것과 **같은 문제가 한 겹 더 있다.**
### 부분 실패는 어떻게 되는가
```
app1 로그아웃 성공, app2 는 응답 없음
└─ Keycloak 은 재시도하는가? 얼마나?
└─ 사용자는 app2 에서 여전히 로그인 상태다
```
**로그아웃은 원자적이지 않다.** 앱이 늘어날수록 "일부만 로그아웃된 상태"가
생길 확률이 올라간다.
---
## 5. 겪은 문제
| 문제 | |
|---|---|
| `kcadm -s "attributes.backchannel.logout.url=..."` 이 exit 1 | 점 표기가 안 먹는다. **JSON 으로 통째로** 줘야 한다 |
| 첫 시험이 무의미했다 | 로그아웃 전 IdP 세션이 **이미 0** 이었다. 끊을 대상이 없었다 |
| 세션 수를 realm 없이 셌다 | C-1 과 같은 실수 — `master` 의 admin 세션이 섞인다 |
| Keycloak 재시작 후 로그인 폼이 안 넘어갔다 | 인증 세션 쿠키가 무효화된 상태에서 폼을 재사용했다 |
**두 번째가 A층에서 반복한 교훈이다** — **주입 대상이 실제로 존재하는지
먼저 확인한다.** 세션이 없는 상태에서 로그아웃을 걸고 "전파가 안 된다"고
결론지을 뻔했다.
---
## 6. 구현하려면 무엇이 필요한가
| 계층 | 할 일 |
|---|---|
| **IdP** | 클라이언트마다 `backchannel.logout.url` 설정 (완료) |
| **앱** | `.oidcLogout(oidc -> oidc.backChannel(...))` 활성화 |
| **앱** | `OidcSessionRegistry` 를 **공유 저장소**로 (인스턴스가 여럿이므로) |
| **네트워크** | IdP → 앱 공개 URL 도달 (이 실험대는 됨, 운영은 확인 필요) |
| **oauth2-proxy** | **지원하지 않는다.** 별도 방안이 필요하다 |
**마지막이 C-1 과 맞물린다** — app1(BFF)은 구현할 수 있지만
app2(oauth2-proxy)는 못 한다. **한 SSO 안에서 로그아웃 전파가 앱마다
다르게 동작하게 된다.**
---
## 7. 재현 절차 (명령어)
```bash
# 1. 현재 설정 확인 — 어느 쪽에도 없다
kcadm.sh get clients -r keycloak-patterns -q clientId=bff-confidential --fields attributes
grep -rn "oidcLogout\|backchannel" bff/src/main/java/
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://app1.hyeonworks.com/logout/connect/back-channel/keycloak
# 2. IdP 쪽 설정 — 점 표기는 안 먹는다. JSON 으로
kcadm.sh update clients/<id> -r keycloak-patterns \
-s 'attributes={"backchannel.logout.url":"...","backchannel.logout.session.required":"true"}'
# 3. ★ 살아 있는 세션이 있는지 먼저 확인한다 (realm 을 join 해서)
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from offline_user_session us join realm r on r.id=us.realm_id
where r.name='keycloak-patterns' and us.offline_flag='0'"
# 4. 로그아웃하고 앱 세션을 본다
kcadm.sh create users/<user-id>/logout -r keycloak-patterns
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
# 5. 도달성 확인 — 클러스터 안에서 앱 공개 URL 로
kubectl -n keycloak-lab run t --rm -i --restart=Never --image=curlimages/curl:8.11.1 \
--command -- curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/
```
---
## 8. 다음에 남기는 것
| | |
|---|---|
| **구현** | `.oidcLogout()` 활성화 + `OidcSessionRegistry` 공유 |
| **oauth2-proxy** | 백채널 로그아웃 미지원 — SSO 안에서 앱마다 동작이 갈린다 |
| **운영** | IdP → 앱 도달성이 전제다. 안 되면 **조용히 실패한다** |
| **B-2 와 연결** | 로그아웃이 지우는 것은 지금도 세 곳 중 하나뿐이다 |
+246
View File
@@ -0,0 +1,246 @@
# D-1 — 백업이 있다와 복구해봤다는 다르다
브랜치 `feature/keycloak-d1-backup-restore` ·
증거 [`docs/evidence/d1-backup-restore/`](evidence/d1-backup-restore/) ·
2026-09-04 16:5517:05 KST
선행: [`A-2`](experiment-a2-database-loss.md) · [`A-3`](experiment-a3-database-crash.md)
---
## 0. 결론부터
| 측정 | 값 |
|---|---|
| 덤프 크기 / 시간 | **395KB · 1초 미만** (101개 테이블) |
| 복구 시간 | **1초** (`15:00:12 → 15:00:13`), **오류 0건** |
| 서비스 회복 | **재시작 없이 15초 이내** (`restarts=0`) |
| 데이터 일치 | **완전 일치** — realms 2 / clients 15 / users 2 / sessions 3 / authclients 1 |
| **RTO** | 약 **30초** (파괴 감지부터 서비스 복귀까지) |
| **RPO** | **마지막 덤프 시점** + A-3 의 `synchronous_commit OFF` 손실 |
**그리고 예상 못 한 것 — 스키마를 통째로 지웠는데 서비스가 `200` 을 계속 냈다.**
---
## 1. 백업
```bash
kubectl -n keycloak-lab exec deploy/postgres -- pg_dump -U keycloak -d keycloak \
--clean --if-exists > /tmp/keycloak-backup.sql
```
```
크기: 394945 bytes (6956 줄)
CREATE TABLE: 101 개
offline_user_session 언급: 13
```
**세션도 덤프에 들어간다.**
```
COPY public.offline_user_session (user_session_id, user_id, realm_id, created_on, offline_flag, data, ...)
E1q5xI7tt4U_WhZpW7rEPIF2 48b37d33-... 7845f394-... 1788500836 0 {"ipAddr...
```
| 옵션 | 뜻 |
|---|---|
| `--clean` | 복구 시 기존 객체를 **DROP 하고** 다시 만든다 |
| `--if-exists` | 없는 객체를 DROP 할 때 오류를 내지 않는다 |
**두 옵션이 없으면 "이미 존재한다" 오류가 쏟아진다.**
---
## 2. 파괴
```bash
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
```
```
DROP SCHEMA
CREATE SCHEMA
남은 테이블: 0
```
### ★ 그런데 서비스가 살아 있었다
```
https://auth.hyeonworks.com/realms/master HTTP 200
https://app1.hyeonworks.com/ HTTP 200
keycloak-0 / keycloak-1 1/1 Running
```
**데이터베이스가 통째로 비었는데 `200` 이다.**
Keycloak 이 realm 정보를 **Infinispan `realms` 캐시**에서 서빙하기 때문이다
(A-0 에서 그 캐시에 57개 엔트리가 있는 것을 봤다).
### 무엇이 깨지고 무엇이 안 깨지는가
```
/protocol/openid-connect/certs HTTP 200 ← realm 키는 캐시에 있다
/.well-known/openid-configuration HTTP 500 ← 이건 DB 를 본다
토큰 발급 HTTP 400
```
**부분적으로만 깨진다.** 헬스체크는 통과하고, 일부 엔드포인트는 정상이며,
**로그인만 안 된다.**
```
KEYCLOAK_JDBC_PING2: Failed to fetch the cluster members from the database
```
> **A-2(DB 프로세스 정지)와 다른 모양이다.** 거기서는 커넥션이 아예 안 돼
> readiness 가 DOWN 이 되고 전 파드가 Service 에서 빠졌다.
> **여기서는 커넥션은 되고 테이블만 없다** — 헬스체크가 통과해버린다.
>
> **"DB 가 살아 있다"와 "데이터가 있다"는 다르다.** 헬스체크는 앞의 것만 본다.
---
## 3. 복구
```bash
kubectl -n keycloak-lab exec -i deploy/postgres -- psql -U keycloak -d keycloak \
< /tmp/keycloak-backup.sql
```
```
시작: 15:00:12
완료: 15:00:13
오류 줄: 0
```
**1초, 오류 없음.**
```
복구 후: realms 2 | clients 15 | users 2 | sessions 3 | authclients 1
백업 시: realms 2 | clients 15 | users 2 | sessions 3 | authclients 1
```
**완전히 일치한다.**
### 서비스는 재시작 없이 돌아왔다
```
+15초 well-known=200 토큰발급=200
→ 재시작 없이 회복
keycloak-0 restarts=0
keycloak-1 restarts=0
```
**커넥션 풀이 이미 붙어 있었으므로 테이블이 돌아오자마자 동작했다.**
A-2 에서 본 것과 같은 자가 회복이다.
### 세션도 살아났다
```
user_session_id | realm
--------------------------+-------------------
E1q5xI7tt4U_WhZpW7rEPIF2 | master
2ap3DyRiBF8OdMiqCodsJ0mp | master
Zsk4QcgXf_qgyMKzde5AG-Fz | master
vsDgCVo12-qX0CC63ZmYzbYF | keycloak-patterns
```
**`persistent-user-sessions` 덕분에 세션이 백업 대상이 된다** (A-0).
volatile 이었다면 세션은 애초에 DB 에 없으므로 **복구해도 전원 재로그인**이다.
---
## 4. RTO 와 RPO
```
14:59:47 파괴
15:00:12 복구 시작
15:00:13 복구 완료
~15:00:28 서비스 정상 확인
RTO ≈ 30초 (이 규모에서는 대부분이 사람의 판단 시간이다)
```
### RPO 는 두 겹이다
```
① 마지막 덤프 이후의 모든 변경 ← 백업 주기가 정한다
② A-3 에서 측정한 synchronous_commit 손실 ← 수백 ms
실제 RPO = ① + ②
```
**A-3 에서 "153건 중 4건 유실"을 측정한 것이 여기에 더해진다.**
백업 주기만 보고 RPO 를 말하면 ②를 빠뜨린다.
### 이 실험대의 규모는 현실적이지 않다
| | 이 실험대 | 운영 |
|---|---|---|
| 덤프 크기 | 395KB | GB~TB |
| 복구 시간 | 1초 | 분~시간 |
| 세션 수 | 3 | 수만 |
**복구가 1초인 것은 데이터가 작기 때문**이며, **절차가 맞다는 것만 확인된다.**
시간은 규모에 따라 완전히 달라진다.
---
## 5. 이 실험이 검증한 것과 못 한 것
| | |
|---|---|
| ✔ 덤프에 필요한 것이 다 들어간다 | realm·client·user·session·authorized client |
| ✔ 복구 절차가 동작한다 | `--clean --if-exists` 로 오류 0 |
| ✔ 서비스가 자가 회복한다 | 재시작 불필요 |
| ✘ **노드가 죽은 경우** | A-4 에서 본 대로 **PVC 가 노드에 묶여 있다.** 노드가 안 돌아오면 덤프가 유일한 길인데, **덤프를 어디에 두느냐**가 문제가 된다 |
| ✘ 대규모 복구 시간 | 데이터가 작아 측정 의미가 없다 |
| ✘ 백업 자동화·보존·검증 | 이번엔 손으로 한 번 떴다 |
> **가장 중요한 미검증 항목이 "덤프를 어디에 두는가" 다.**
> 이번 덤프는 `test-server:/tmp` 에 있다. **호스트가 죽으면 같이 사라진다.**
> A-4 에서 PVC 가 노드에 묶인 것을 봤듯, **백업도 같은 장애 도메인에 있으면
> 백업이 아니다.**
---
## 6. 재현 절차 (명령어)
```bash
# 1. 백업 — --clean --if-exists 가 없으면 복구 때 오류가 쏟아진다
kubectl -n keycloak-lab exec deploy/postgres -- pg_dump -U keycloak -d keycloak \
--clean --if-exists > keycloak-backup.sql
# 2. 무엇이 들어갔는지 확인 (세션이 있어야 한다)
grep -c '^CREATE TABLE' keycloak-backup.sql
grep -A3 'COPY public.offline_user_session' keycloak-backup.sql
# 3. 파괴
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
# 4. ★ 무엇이 깨지는지 확인 — 전부 깨지지 않는다
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/keycloak-patterns/.well-known/openid-configuration
# 5. 복구
kubectl -n keycloak-lab exec -i deploy/postgres -- psql -U keycloak -d keycloak < keycloak-backup.sql
# 6. 데이터 대조 — 백업 시점의 수치와 같아야 한다
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select (select count(*) from realm), (select count(*) from client),
(select count(*) from user_entity),
(select count(*) from offline_user_session where offline_flag='0')"
```
---
## 7. 다음 실험에 남기는 것
| 실험 | 이 실험이 준 것 |
|---|---|
| **D-2** 버전 업그레이드 | **백업이 전제다.** 스키마 마이그레이션은 되돌리기 어렵다 |
| 운영 | **덤프를 다른 장애 도메인에 둔다** |
| 관측 | **"DB 가 살아 있다"만 보는 헬스체크는 빈 DB 를 통과시킨다** |