+ AP3 · Backend-for-Frontend
+
+ 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session
+ cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource
+ Server 요청에 붙입니다.
+
+
+
+
+
+
+
+
+
+
diff --git a/bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java b/bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java
new file mode 100644
index 0000000..6cd928e
--- /dev/null
+++ b/bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java
@@ -0,0 +1,90 @@
+package com.example.keycloakpattern.bff;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
+import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
+import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+import org.springframework.security.oauth2.core.OAuth2RefreshToken;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+
+@SpringBootTest(properties = {
+ "KEYCLOAK_CLIENT_SECRET=test-only-secret",
+ "resource-api.base-url=http://127.0.0.1:9"
+})
+@AutoConfigureMockMvc
+class BffControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @MockitoBean
+ private OAuth2AuthorizedClientService authorizedClientService;
+
+ @MockitoBean
+ private OAuth2AuthorizedClientManager authorizedClientManager;
+
+ @Test
+ void reportsServerTokenCustodyWithoutReturningTokens() throws Exception {
+ OAuth2AuthorizedClient client = mock(OAuth2AuthorizedClient.class);
+ when(client.getAccessToken()).thenReturn(mock(OAuth2AccessToken.class));
+ when(client.getRefreshToken()).thenReturn(mock(OAuth2RefreshToken.class));
+ when(authorizedClientService.loadAuthorizedClient("keycloak", "test-subject"))
+ .thenReturn(client);
+
+ mockMvc.perform(get("/bff/token-boundary").with(oidcLogin()
+ .idToken(token -> token.subject("test-subject"))))
+ .andExpect(status().isOk())
+ .andExpect(header().string("Cache-Control", "no-store"))
+ .andExpect(jsonPath("$.accessTokenStoredOnServer").value(true))
+ .andExpect(jsonPath("$.refreshTokenStoredOnServer").value(true))
+ .andExpect(jsonPath("$.browserTokenCount").value(0))
+ .andExpect(jsonPath("$.csrfProtectionEnabled").value(true))
+ .andExpect(jsonPath("$.access_token").doesNotExist())
+ .andExpect(jsonPath("$.refresh_token").doesNotExist());
+ }
+
+ @Test
+ void rejectsStateChangeWithoutCsrfToken() throws Exception {
+ mockMvc.perform(post("/bff/api/preferences")
+ .param("theme", "attacker")
+ .with(oidcLogin().idToken(token -> token.subject("test-subject"))))
+ .andExpect(status().isForbidden());
+ }
+
+ @Test
+ void acceptsStateChangeWithCsrfToken() throws Exception {
+ mockMvc.perform(post("/bff/api/preferences")
+ .param("theme", "dark")
+ .with(oidcLogin().idToken(token -> token.subject("test-subject")))
+ .with(csrf()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.updated").value(true))
+ .andExpect(jsonPath("$.theme").value("dark"));
+ }
+
+ @Test
+ void exposesSpaCsrfTokenWithoutCaching() throws Exception {
+ mockMvc.perform(get("/bff/csrf").with(oidcLogin()
+ .idToken(token -> token.subject("test-subject"))))
+ .andExpect(status().isOk())
+ .andExpect(header().string("Cache-Control", "no-store"))
+ .andExpect(header().exists("Set-Cookie"))
+ .andExpect(jsonPath("$.headerName").value("X-XSRF-TOKEN"))
+ .andExpect(jsonPath("$.token").isNotEmpty());
+ }
+}
diff --git a/deploy/lab/k8s/bff-redis.yaml b/deploy/lab/k8s/bff-redis.yaml
new file mode 100644
index 0000000..6974f98
--- /dev/null
+++ b/deploy/lab/k8s/bff-redis.yaml
@@ -0,0 +1,161 @@
+# BFF (2 replicas) + Redis, for the B-layer experiments.
+#
+# The BFF is deployed FIRST WITHOUT any session store wiring. That is deliberate:
+# B-0 asks what Spring Boot's autoconfiguration actually picks when nothing is
+# configured, and the only honest way to answer is to look at a running instance
+# that has been given nothing. Redis is deployed alongside but left unused until
+# B-1 turns it on.
+#
+# kubectl apply -f deploy/lab/k8s/bff-redis.yaml
+#
+# Image comes from the workstation, not a registry:
+# docker build -t keycloak-pattern-bff:lab bff/
+# docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
+# (repeat for kc-lab-2)
+# so imagePullPolicy must stay Never on both replicas.
+apiVersion: v1
+kind: Secret
+metadata:
+ name: bff-secrets
+ namespace: keycloak-lab
+type: Opaque
+stringData:
+ # Matches the client created with kcadm in the keycloak-patterns realm.
+ # 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".
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: redis
+ namespace: keycloak-lab
+spec:
+ replicas: 1
+ selector:
+ matchLabels: { app: redis }
+ template:
+ metadata:
+ labels: { app: redis }
+ spec:
+ # Same node as postgres so a node-loss experiment takes both stores at
+ # once, matching how A-4 was set up.
+ nodeSelector:
+ kubernetes.io/hostname: kc-lab-2
+ containers:
+ - name: redis
+ image: redis:7.4-alpine
+ args: ["redis-server", "--save", "", "--appendonly", "no"]
+ ports:
+ - containerPort: 6379
+ name: redis
+ readinessProbe:
+ exec: { command: ["redis-cli", "ping"] }
+ initialDelaySeconds: 3
+ resources:
+ requests: { memory: 32Mi, cpu: 20m }
+ limits: { memory: 128Mi }
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: redis
+ namespace: keycloak-lab
+spec:
+ selector: { app: redis }
+ ports:
+ - port: 6379
+ targetPort: redis
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: bff
+ namespace: keycloak-lab
+spec:
+ # Two replicas is the whole point: Q1 and Q2 only exist because a request can
+ # land on an instance that did not handle the login.
+ replicas: 2
+ selector:
+ matchLabels: { app: bff }
+ template:
+ metadata:
+ labels: { app: bff }
+ spec:
+ # Spread across both nodes so "the other instance" is genuinely another
+ # machine, not another process on the same kernel.
+ topologySpreadConstraints:
+ - maxSkew: 1
+ topologyKey: kubernetes.io/hostname
+ whenUnsatisfiable: ScheduleAnyway
+ labelSelector:
+ matchLabels: { app: bff }
+ containers:
+ - name: bff
+ image: keycloak-pattern-bff:lab
+ imagePullPolicy: Never
+ ports:
+ - containerPort: 8083
+ name: http
+ env:
+ # The browser is redirected to the public name; the BFF calls the
+ # token endpoint over the cluster network. Getting these two the same
+ # way round is what the 2-hop header experiment was about.
+ - name: KC_ISSUER_EXTERNAL
+ value: https://auth.hyeonworks.com/realms/keycloak-patterns
+ - name: KC_ISSUER_INTERNAL
+ value: http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns
+ - name: RESOURCE_API_BASE_URL
+ value: http://echo.keycloak-lab.svc:8080
+ - name: KEYCLOAK_CLIENT_SECRET
+ valueFrom:
+ secretKeyRef: { name: bff-secrets, key: KEYCLOAK_CLIENT_SECRET }
+ # Spring needs to know it is behind TLS termination, for the same
+ # reason Keycloak needs KC_PROXY_HEADERS. Without it the redirect_uri
+ # it builds comes back as http:// and Keycloak rejects it.
+ - name: SERVER_FORWARD_HEADERS_STRATEGY
+ value: native
+ - name: JAVA_TOOL_OPTIONS
+ value: "-Xms128m -Xmx320m"
+ readinessProbe:
+ httpGet: { path: /actuator/health/readiness, port: http }
+ initialDelaySeconds: 20
+ failureThreshold: 30
+ livenessProbe:
+ httpGet: { path: /actuator/health/liveness, port: http }
+ initialDelaySeconds: 60
+ resources:
+ requests: { memory: 320Mi, cpu: 100m }
+ limits: { memory: 512Mi }
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: bff
+ namespace: keycloak-lab
+spec:
+ selector: { app: bff }
+ ports:
+ - port: 8083
+ targetPort: http
+---
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: bff
+ namespace: keycloak-lab
+spec:
+ ingressClassName: traefik
+ rules:
+ - host: app1.hyeonworks.com
+ http:
+ paths:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: bff
+ port:
+ number: 8083
diff --git a/docs/evidence/b0-bff-redis-deploy/01-deploy.txt b/docs/evidence/b0-bff-redis-deploy/01-deploy.txt
new file mode 100644
index 0000000..a57adae
--- /dev/null
+++ b/docs/evidence/b0-bff-redis-deploy/01-deploy.txt
@@ -0,0 +1,21 @@
+=== 배포 전 자원 ===
+Mem: 11648 7329 280 4 4377 4319
+NAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%)
+kc-lab-1 115m 5% 2192Mi 44%
+kc-lab-2 121m 6% 1324Mi 33%
+
+=== 배포 ===
+secret/bff-secrets created
+deployment.apps/redis created
+service/redis created
+deployment.apps/bff created
+service/bff created
+ingress.networking.k8s.io/bff created
+
+deployment "redis" successfully rolled out
+Waiting for deployment "bff" rollout to finish: 1 of 2 updated replicas are available...
+deployment "bff" successfully rolled out
+
+bff-574c6d658b-8cz4x true kc-lab-1
+bff-574c6d658b-zpkbp true kc-lab-2
+redis-568bd7c4-5c5vc true kc-lab-2
diff --git a/docs/evidence/b0-bff-redis-deploy/02-autoconfiguration.txt b/docs/evidence/b0-bff-redis-deploy/02-autoconfiguration.txt
new file mode 100644
index 0000000..f21067d
--- /dev/null
+++ b/docs/evidence/b0-bff-redis-deploy/02-autoconfiguration.txt
@@ -0,0 +1,13 @@
+=== B-0: 자동구성이 실제로 고른 구현체 ===
+ Q1 확인한 사실: "코드에 저장소를 직접 생성하는 Bean 이 없기 때문에,
+ 어떤 구현체가 실제로 사용되는지는 자동구성 결과까지 확인해야 정확하게 알 수 있다"
+
+ File "