diff --git a/.gitignore b/.gitignore index 8f0f1cc..0850df4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,7 @@ *.iml backend/target/ +token-mediator/target/ +**/node_modules/ +frontend/dist/ build/ diff --git a/README.md b/README.md index ac1a49e..429a201 100644 --- a/README.md +++ b/README.md @@ -96,3 +96,16 @@ Keycloak을 잠시 중지하고 export한 뒤 자동으로 다시 올립니다. runtime export에는 실제 client secret과 credential hash가 포함될 수 있어 gitignored `build/keycloak-export/`에 권한 `0600`으로만 저장됩니다. + +## AP2: Token-Mediating Backend + +`develop-keycloak-pattern2`의 Spring confidential client는 +`http://localhost:8082`에서 실행됩니다. 브라우저는 로그인 redirect와 +HttpOnly `AP2_SESSION`만 사용하고, authorization code 교환과 +access/refresh token 보관은 backend가 담당합니다. + +```bash +./scripts/verify-pattern2.sh +``` + +`/token/boundary`는 실제 token 값을 반환하지 않고 서버 저장 여부만 보여줍니다. diff --git a/docker-compose.yml b/docker-compose.yml index 585bebf..8f3a436 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -86,6 +86,29 @@ services: - keycloak-net restart: unless-stopped + token-mediator: + build: + context: ./token-mediator + environment: + SERVER_PORT: "8082" + KEYCLOAK_CLIENT_SECRET: ${TOKEN_MEDIATING_CLIENT_SECRET:?set TOKEN_MEDIATING_CLIENT_SECRET in .env} + ports: + - "127.0.0.1:8082:8082" + depends_on: + keycloak: + condition: service_healthy + healthcheck: + test: + - CMD-SHELL + - wget -q -O - http://127.0.0.1:8082/actuator/health | grep -q '"status":"UP"' + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + networks: + - keycloak-net + restart: unless-stopped + nginx: build: context: ./frontend diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..78d42d6 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,28 @@ +{ + "name": "keycloak-pattern-e2e", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "keycloak-pattern-e2e", + "version": "1.0.0", + "devDependencies": { + "playwright-core": "1.62.0" + } + }, + "node_modules/playwright-core": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..14ca818 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,12 @@ +{ + "name": "keycloak-pattern-e2e", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "test:pattern2": "node pattern2.mjs" + }, + "devDependencies": { + "playwright-core": "1.62.0" + } +} diff --git a/e2e/pattern2.mjs b/e2e/pattern2.mjs new file mode 100644 index 0000000..7987e3e --- /dev/null +++ b/e2e/pattern2.mjs @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { chromium } from "playwright-core"; + +const password = process.env.E2E_PASSWORD; +assert.ok(password, "E2E_PASSWORD must be set"); + +const browser = await chromium.launch({ + executablePath: process.env.CHROME_BIN ?? "/usr/bin/google-chrome", + headless: true, + args: ["--no-sandbox"], +}); + +try { + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto("http://localhost:8082"); + await page.locator("#login").click(); + await page.waitForURL(/localhost:8080/u); + await page.locator("#username").fill( + process.env.E2E_USERNAME ?? "regular-user", + ); + await page.locator("#password").fill(password); + await page.locator("#kc-login").click(); + await page.waitForURL("http://localhost:8082/"); + + const boundaryResponsePromise = page.waitForResponse((response) => + response.url().endsWith("/token/boundary"), + ); + await page.locator("#inspect").click(); + const boundaryResponse = await boundaryResponsePromise; + assert.equal(boundaryResponse.status(), 200); + const boundary = await boundaryResponse.json(); + + assert.equal(boundary.accessTokenStored, true); + assert.equal(boundary.refreshTokenStored, true); + assert.equal(boundary.browserReceivesRefreshToken, false); + assert.equal(JSON.stringify(boundary).includes("refresh_token"), false); + + const cookies = await context.cookies("http://localhost:8082/"); + const sessionCookie = cookies.find((cookie) => cookie.name === "AP2_SESSION"); + assert.ok(sessionCookie); + assert.equal(sessionCookie.httpOnly, true); + assert.equal(sessionCookie.sameSite, "Lax"); + + const storage = await page.evaluate(() => ({ + localStorage: Object.values(localStorage), + sessionStorage: Object.values(sessionStorage), + })); + assert.equal(JSON.stringify(storage).includes("refresh_token"), false); + + console.log( + "pattern2 confidential client verified: server code exchange, server access/refresh custody, HttpOnly session", + ); +} finally { + await browser.close(); +} diff --git a/scripts/verify-pattern2.sh b/scripts/verify-pattern2.sh new file mode 100755 index 0000000..91a9139 --- /dev/null +++ b/scripts/verify-pattern2.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env sh +set -eu + +if [ ! -f .env ]; then + echo "missing .env; copy .env.example and set development values" >&2 + exit 1 +fi + +set -a +. ./.env +set +a + +docker compose down --volumes --remove-orphans +docker compose up --build -d --wait + +npm --prefix e2e ci +E2E_USERNAME=regular-user \ +E2E_PASSWORD="$REGULAR_USER_PASSWORD" \ + npm --prefix e2e run test:pattern2 + +echo "AP2 confidential client boundary verified" diff --git a/token-mediator/.dockerignore b/token-mediator/.dockerignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/token-mediator/.dockerignore @@ -0,0 +1 @@ +target/ diff --git a/token-mediator/Dockerfile b/token-mediator/Dockerfile new file mode 100644 index 0000000..8db653e --- /dev/null +++ b/token-mediator/Dockerfile @@ -0,0 +1,17 @@ +FROM maven:3.9.11-eclipse-temurin-21-alpine AS build + +WORKDIR /workspace +COPY pom.xml . +RUN mvn --batch-mode dependency:go-offline +COPY src src +RUN mvn --batch-mode verify + +FROM eclipse-temurin:21-jre-alpine + +RUN addgroup -S spring && adduser -S spring -G spring +WORKDIR /app +COPY --from=build /workspace/target/keycloak-token-mediator.jar app.jar +USER spring:spring + +EXPOSE 8082 +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/token-mediator/pom.xml b/token-mediator/pom.xml new file mode 100644 index 0000000..16eacc2 --- /dev/null +++ b/token-mediator/pom.xml @@ -0,0 +1,58 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.16 + + + + com.example + keycloak-token-mediator + 0.0.1-SNAPSHOT + keycloak-token-mediator + + + 21 + + + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-oauth2-client + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + + + keycloak-token-mediator + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/token-mediator/src/main/java/com/example/keycloakpattern/mediator/SecurityConfig.java b/token-mediator/src/main/java/com/example/keycloakpattern/mediator/SecurityConfig.java new file mode 100644 index 0000000..1f2432c --- /dev/null +++ b/token-mediator/src/main/java/com/example/keycloakpattern/mediator/SecurityConfig.java @@ -0,0 +1,29 @@ +package com.example.keycloakpattern.mediator; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +public class SecurityConfig { + + @Bean + SecurityFilterChain mediatorSecurity(HttpSecurity http) throws Exception { + return http + .authorizeHttpRequests(authorize -> authorize + .requestMatchers( + "/", + "/index.html", + "/app.js", + "/favicon.ico", + "/actuator/health", + "/actuator/health/**" + ) + .permitAll() + .anyRequest() + .authenticated()) + .oauth2Login(oauth2 -> oauth2.defaultSuccessUrl("/", true)) + .build(); + } +} diff --git a/token-mediator/src/main/java/com/example/keycloakpattern/mediator/TokenBoundaryController.java b/token-mediator/src/main/java/com/example/keycloakpattern/mediator/TokenBoundaryController.java new file mode 100644 index 0000000..c7f58a1 --- /dev/null +++ b/token-mediator/src/main/java/com/example/keycloakpattern/mediator/TokenBoundaryController.java @@ -0,0 +1,44 @@ +package com.example.keycloakpattern.mediator; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.http.CacheControl; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class TokenBoundaryController { + + private final OAuth2AuthorizedClientService authorizedClientService; + + public TokenBoundaryController( + OAuth2AuthorizedClientService authorizedClientService + ) { + this.authorizedClientService = authorizedClientService; + } + + @GetMapping("/token/boundary") + ResponseEntity> tokenBoundary(Authentication authentication) { + OAuth2AuthorizedClient client = authorizedClientService.loadAuthorizedClient( + "keycloak", + authentication.getName() + ); + + Map response = new LinkedHashMap<>(); + response.put("pattern", "AP2-token-mediating-backend"); + response.put("principal", authentication.getName()); + response.put("accessTokenStored", client != null && client.getAccessToken() != null); + response.put("refreshTokenStored", client != null && client.getRefreshToken() != null); + response.put("browserReceivesRefreshToken", false); + + return ResponseEntity.ok() + .cacheControl(CacheControl.noStore()) + .header("Pragma", "no-cache") + .body(response); + } +} diff --git a/token-mediator/src/main/java/com/example/keycloakpattern/mediator/TokenMediatorApplication.java b/token-mediator/src/main/java/com/example/keycloakpattern/mediator/TokenMediatorApplication.java new file mode 100644 index 0000000..45a185e --- /dev/null +++ b/token-mediator/src/main/java/com/example/keycloakpattern/mediator/TokenMediatorApplication.java @@ -0,0 +1,12 @@ +package com.example.keycloakpattern.mediator; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class TokenMediatorApplication { + + public static void main(String[] args) { + SpringApplication.run(TokenMediatorApplication.class, args); + } +} diff --git a/token-mediator/src/main/resources/application.yml b/token-mediator/src/main/resources/application.yml new file mode 100644 index 0000000..0c16559 --- /dev/null +++ b/token-mediator/src/main/resources/application.yml @@ -0,0 +1,44 @@ +server: + port: ${SERVER_PORT:8082} + servlet: + session: + cookie: + name: AP2_SESSION + http-only: true + same-site: lax + +spring: + application: + name: keycloak-token-mediator + security: + oauth2: + client: + registration: + keycloak: + provider: keycloak + client-id: token-mediating-confidential + client-secret: ${KEYCLOAK_CLIENT_SECRET} + client-authentication-method: client_secret_basic + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + scope: + - openid + - profile + - email + provider: + keycloak: + authorization-uri: http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/auth + token-uri: http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/token + jwk-set-uri: http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/certs + user-info-uri: http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/userinfo + user-name-attribute: preferred_username + +management: + endpoint: + health: + probes: + enabled: true + endpoints: + web: + exposure: + include: health,info diff --git a/token-mediator/src/main/resources/static/app.js b/token-mediator/src/main/resources/static/app.js new file mode 100644 index 0000000..31daa0a --- /dev/null +++ b/token-mediator/src/main/resources/static/app.js @@ -0,0 +1,20 @@ +const result = document.querySelector("#result"); + +function render(value) { + result.textContent = JSON.stringify(value, null, 2); +} + +document.querySelector("#login").addEventListener("click", () => { + window.location.assign("/oauth2/authorization/keycloak"); +}); + +document.querySelector("#inspect").addEventListener("click", async () => { + const response = await fetch("/token/boundary", { + headers: { Accept: "application/json" }, + }); + if (response.redirected || response.status === 401) { + window.location.assign("/oauth2/authorization/keycloak"); + return; + } + render(await response.json()); +}); diff --git a/token-mediator/src/main/resources/static/index.html b/token-mediator/src/main/resources/static/index.html new file mode 100644 index 0000000..728f88c --- /dev/null +++ b/token-mediator/src/main/resources/static/index.html @@ -0,0 +1,29 @@ + + + + + + AP2 · Token-Mediating Backend + + + +
+

AP2 · Token-Mediating Backend

+

+ confidential backend가 authorization code를 token으로 교환합니다. + refresh token은 서버의 OAuth2AuthorizedClientService에만 + 보관됩니다. +

+ + +

+  
+ + + diff --git a/token-mediator/src/test/java/com/example/keycloakpattern/mediator/TokenBoundaryControllerTest.java b/token-mediator/src/test/java/com/example/keycloakpattern/mediator/TokenBoundaryControllerTest.java new file mode 100644 index 0000000..86dd241 --- /dev/null +++ b/token-mediator/src/test/java/com/example/keycloakpattern/mediator/TokenBoundaryControllerTest.java @@ -0,0 +1,50 @@ +package com.example.keycloakpattern.mediator; + +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.test.web.servlet.request.MockMvcRequestBuilders.get; +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.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") +@AutoConfigureMockMvc +class TokenBoundaryControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private OAuth2AuthorizedClientService authorizedClientService; + + @Test + void reportsServerSideTokensWithoutReturningTheirValues() 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("/token/boundary").with(oidcLogin() + .idToken(token -> token.subject("test-subject")))) + .andExpect(status().isOk()) + .andExpect(header().string("Cache-Control", "no-store")) + .andExpect(jsonPath("$.accessTokenStored").value(true)) + .andExpect(jsonPath("$.refreshTokenStored").value(true)) + .andExpect(jsonPath("$.browserReceivesRefreshToken").value(false)) + .andExpect(jsonPath("$.access_token").doesNotExist()) + .andExpect(jsonPath("$.refresh_token").doesNotExist()); + } +}