diff --git a/README.md b/README.md index d8a7f29..1f790fe 100644 --- a/README.md +++ b/README.md @@ -110,3 +110,9 @@ token은 명시적인 in-memory store에만 보관되므로 새로고침하면 브라우저에서 `http://localhost:8088`을 열어 로그인한 뒤 보호 API를 호출할 수 있습니다. SPA는 `http://localhost:8081/api/me`를 직접 호출하며 Spring Resource Server가 Bearer JWT를 검증합니다. + +Keycloak의 dedicated audience mapper는 `spa-public` access token에 +`keycloak-pattern-api`를 추가합니다. Spring은 signature, `iss`, `exp`뿐 +아니라 이 `aud`도 검사합니다. `verify-pattern1.sh`는 같은 정상 토큰을 +`deliberately-wrong-audience`를 기대하는 진단 인스턴스에도 제출해 `401`을 +확인합니다. diff --git a/backend/src/main/java/com/example/keycloakpattern/AudienceValidator.java b/backend/src/main/java/com/example/keycloakpattern/AudienceValidator.java new file mode 100644 index 0000000..85679e7 --- /dev/null +++ b/backend/src/main/java/com/example/keycloakpattern/AudienceValidator.java @@ -0,0 +1,29 @@ +package com.example.keycloakpattern; + +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; + +final class AudienceValidator implements OAuth2TokenValidator { + + private static final OAuth2Error MISSING_AUDIENCE = new OAuth2Error( + "invalid_token", + "The required resource audience is missing", + null + ); + + private final String expectedAudience; + + AudienceValidator(String expectedAudience) { + this.expectedAudience = expectedAudience; + } + + @Override + public OAuth2TokenValidatorResult validate(Jwt jwt) { + if (jwt.getAudience().contains(expectedAudience)) { + return OAuth2TokenValidatorResult.success(); + } + return OAuth2TokenValidatorResult.failure(MISSING_AUDIENCE); + } +} diff --git a/backend/src/main/java/com/example/keycloakpattern/JwtDecoderConfig.java b/backend/src/main/java/com/example/keycloakpattern/JwtDecoderConfig.java new file mode 100644 index 0000000..a97f71b --- /dev/null +++ b/backend/src/main/java/com/example/keycloakpattern/JwtDecoderConfig.java @@ -0,0 +1,31 @@ +package com.example.keycloakpattern; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtValidators; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; + +@Configuration +public class JwtDecoderConfig { + + @Bean + JwtDecoder jwtDecoder( + @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuer, + @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwkSetUri, + @Value("${security.expected-audience}") String expectedAudience + ) { + NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build(); + OAuth2TokenValidator issuerAndTimestamp = + JwtValidators.createDefaultWithIssuer(issuer); + OAuth2TokenValidator audience = new AudienceValidator(expectedAudience); + decoder.setJwtValidator( + new DelegatingOAuth2TokenValidator<>(issuerAndTimestamp, audience) + ); + return decoder; + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 0046b63..ba329fe 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -11,6 +11,9 @@ spring: issuer-uri: ${SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI:http://localhost:8080/realms/keycloak-patterns} jwk-set-uri: ${SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI:http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/certs} +security: + expected-audience: ${SECURITY_EXPECTED_AUDIENCE:keycloak-pattern-api} + management: endpoint: health: diff --git a/backend/src/test/java/com/example/keycloakpattern/AudienceValidatorTest.java b/backend/src/test/java/com/example/keycloakpattern/AudienceValidatorTest.java new file mode 100644 index 0000000..6151d5f --- /dev/null +++ b/backend/src/test/java/com/example/keycloakpattern/AudienceValidatorTest.java @@ -0,0 +1,49 @@ +package com.example.keycloakpattern; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; + +class AudienceValidatorTest { + + private final AudienceValidator validator = + new AudienceValidator("keycloak-pattern-api"); + + @Test + void acceptsRequiredAudience() { + OAuth2TokenValidatorResult result = validator.validate(jwtWithAudience( + List.of("account", "keycloak-pattern-api") + )); + + assertThat(result.hasErrors()).isFalse(); + } + + @Test + void rejectsForeignAudience() { + OAuth2TokenValidatorResult result = validator.validate(jwtWithAudience( + List.of("another-resource") + )); + + assertThat(result.hasErrors()).isTrue(); + assertThat(result.getErrors()) + .extracting(error -> error.getErrorCode()) + .containsExactly("invalid_token"); + } + + private Jwt jwtWithAudience(List audience) { + Instant now = Instant.now(); + return new Jwt( + "test-token", + now, + now.plusSeconds(300), + Map.of("alg", "none"), + Map.of("sub", "test-subject", "aud", audience) + ); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 585bebf..ee74fcf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -86,6 +86,32 @@ services: - keycloak-net restart: unless-stopped + app-wrong-audience: + profiles: + - diagnostics + build: + context: ./backend + environment: + SERVER_PORT: "8081" + SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: http://localhost:8080/realms/keycloak-patterns + SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/certs + SECURITY_EXPECTED_AUDIENCE: deliberately-wrong-audience + ports: + - "127.0.0.1:18081:8081" + depends_on: + keycloak: + condition: service_healthy + healthcheck: + test: + - CMD-SHELL + - wget -q -O - http://127.0.0.1:8081/actuator/health | grep -q '"status":"UP"' + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + networks: + - keycloak-net + nginx: build: context: ./frontend diff --git a/e2e/pattern1.mjs b/e2e/pattern1.mjs index 0028c89..bfdb312 100644 --- a/e2e/pattern1.mjs +++ b/e2e/pattern1.mjs @@ -38,6 +38,14 @@ try { const accessToken = await page.evaluate(() => window.__pattern1.getAccessToken()); assert.ok(accessToken, "access token must exist in browser memory"); + const payload = JSON.parse( + Buffer.from(accessToken.split(".")[1], "base64url").toString("utf8"), + ); + const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; + assert.ok( + audiences.includes("keycloak-pattern-api"), + "access token must target keycloak-pattern-api", + ); const storageSnapshot = await page.evaluate(() => ({ localStorage: Object.values(localStorage), @@ -55,6 +63,17 @@ try { return text.includes('"httpStatus": 200'); }); + if (process.env.WRONG_AUDIENCE_URL) { + const response = await fetch(process.env.WRONG_AUDIENCE_URL, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + assert.equal( + response.status, + 401, + "the same signed token must fail when the Resource Server expects another audience", + ); + } + await page.reload(); await page.locator('[data-authenticated="false"]').waitFor(); assert.equal( @@ -64,7 +83,7 @@ try { ); console.log( - "pattern1 browser verified: code+PKCE S256, protected API 200, Web Storage token 0, reload clears token", + "pattern1 browser verified: code+PKCE S256, audience positive 200/negative 401, Web Storage token 0, reload clears token", ); } finally { await browser.close(); diff --git a/keycloak/import/keycloak-patterns-realm.json b/keycloak/import/keycloak-patterns-realm.json index 9a4fae9..07b5d9b 100644 --- a/keycloak/import/keycloak-patterns-realm.json +++ b/keycloak/import/keycloak-patterns-realm.json @@ -51,7 +51,22 @@ "attributes": { "pkce.code.challenge.method": "S256", "post.logout.redirect.uris": "http://localhost:8088/*##http://127.0.0.1:8088/*" - } + }, + "protocolMappers": [ + { + "name": "keycloak-pattern-api-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.custom.audience": "keycloak-pattern-api", + "id.token.claim": "false", + "access.token.claim": "true", + "userinfo.token.claim": "false", + "introspection.token.claim": "true" + } + } + ] }, { "clientId": "token-mediating-confidential", diff --git a/scripts/validate-realm.py b/scripts/validate-realm.py index 397886c..53ebb3a 100755 --- a/scripts/validate-realm.py +++ b/scripts/validate-realm.py @@ -70,6 +70,18 @@ def validate(path: Path, runtime: bool) -> None: spa.get("attributes", {}).get("pkce.code.challenge.method") == "S256", "spa-public must enforce PKCE S256", ) + audience_mappers = [ + mapper + for mapper in spa.get("protocolMappers", []) + if mapper.get("protocolMapper") == "oidc-audience-mapper" + ] + if not runtime: + require(len(audience_mappers) == 1, "spa-public must declare one audience mapper") + require( + audience_mappers[0].get("config", {}).get("included.custom.audience") + == "keycloak-pattern-api", + "spa-public access token must target keycloak-pattern-api", + ) for client_id, placeholder in CONFIDENTIAL_CLIENTS.items(): client = clients[client_id] diff --git a/scripts/verify-pattern1.sh b/scripts/verify-pattern1.sh index 7649c14..9cfa026 100755 --- a/scripts/verify-pattern1.sh +++ b/scripts/verify-pattern1.sh @@ -12,10 +12,12 @@ set +a docker compose down --volumes --remove-orphans docker compose up --build -d --wait +docker compose --profile diagnostics up -d --wait app-wrong-audience npm --prefix e2e ci E2E_USERNAME=regular-user \ E2E_PASSWORD="$REGULAR_USER_PASSWORD" \ +WRONG_AUDIENCE_URL=http://localhost:18081/api/me \ npm --prefix e2e run test:pattern1 echo "AP1 verified end to end"