From 2b350df9670439e0271cf9025681e778bc1ded51 Mon Sep 17 00:00:00 2001 From: Niklasherrmann21 Date: Thu, 24 Sep 2026 14:01:58 +0200 Subject: [PATCH 1/3] Establish AMS principal for IAS tokens in spring-boot sample The default Spring Security JwtAuthenticationConverter produces a plain JwtAuthenticationToken whose principal is a Jwt. The cloud security library only copies the token into its SecurityContext when the principal is a SAP Token, so the AMS principal was never established and every privilege check was denied with HTTP 403. Add an IasJwtAuthenticationConverter that converts the validated IAS Jwt into a SAP AuthenticationToken and wire it into the resource server configuration. Add unit tests for the converter and an end-to-end regression test for the production token flow. Fixes #99 --- .../config/IasJwtAuthenticationConverter.java | 51 +++ .../samples/config/SecurityConfiguration.java | 11 +- .../security/ams/samples/IasJwtFlowTest.java | 362 ++++++++++++++++++ .../IasJwtAuthenticationConverterTest.java | 83 ++++ 4 files changed, 502 insertions(+), 5 deletions(-) create mode 100644 ams-spring-boot-shopping/src/main/java/com/sap/cloud/security/ams/samples/config/IasJwtAuthenticationConverter.java create mode 100644 ams-spring-boot-shopping/src/test/java/com/sap/cloud/security/ams/samples/IasJwtFlowTest.java create mode 100644 ams-spring-boot-shopping/src/test/java/com/sap/cloud/security/ams/samples/config/IasJwtAuthenticationConverterTest.java diff --git a/ams-spring-boot-shopping/src/main/java/com/sap/cloud/security/ams/samples/config/IasJwtAuthenticationConverter.java b/ams-spring-boot-shopping/src/main/java/com/sap/cloud/security/ams/samples/config/IasJwtAuthenticationConverter.java new file mode 100644 index 0000000..e91fbbd --- /dev/null +++ b/ams-spring-boot-shopping/src/main/java/com/sap/cloud/security/ams/samples/config/IasJwtAuthenticationConverter.java @@ -0,0 +1,51 @@ +package com.sap.cloud.security.ams.samples.config; + +import com.sap.cloud.security.spring.token.authentication.AuthenticationToken; +import com.sap.cloud.security.token.TokenClaims; +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Converts a validated IAS JWT into a SAP {@link AuthenticationToken}. + * + *

+ * This is required for the AMS integration: the cloud security library only copies + * the token into its {@code SecurityContext} when the Spring Security principal is a + * SAP {@code Token} (see {@code JavaSecurityContextHolderStrategy}). Only then can the + * AMS library derive the current principal and evaluate the caller's policies. + * Spring Security's default converter produces a plain {@code JwtAuthenticationToken} + * whose principal is a plain {@code Jwt}, which leaves the AMS principal unresolved + * and results in denied privilege checks (HTTP 403). + *

+ * + *

+ * The granted authorities are derived from the {@code groups} claim of the token. + * Authorization decisions in this application, however, are made by AMS + * (route-level checks via {@code AmsRouteSecurity} and method-level checks via + * {@code @CheckPrivilege}/{@code @PrecheckPrivilege}). + *

+ */ +public class IasJwtAuthenticationConverter implements Converter { + + @Override + public AbstractAuthenticationToken convert(Jwt jwt) { + return new AuthenticationToken(jwt, groupAuthorities(jwt)); + } + + private static List groupAuthorities(Jwt jwt) { + List groups = jwt.getClaimAsStringList(TokenClaims.GROUPS); + if (groups == null) { + return Collections.emptyList(); + } + return groups.stream() + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + } +} diff --git a/ams-spring-boot-shopping/src/main/java/com/sap/cloud/security/ams/samples/config/SecurityConfiguration.java b/ams-spring-boot-shopping/src/main/java/com/sap/cloud/security/ams/samples/config/SecurityConfiguration.java index 19d488f..8c0a73a 100644 --- a/ams-spring-boot-shopping/src/main/java/com/sap/cloud/security/ams/samples/config/SecurityConfiguration.java +++ b/ams-spring-boot-shopping/src/main/java/com/sap/cloud/security/ams/samples/config/SecurityConfiguration.java @@ -5,7 +5,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.PropertySource; -import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -20,9 +19,10 @@ *

* This configuration: *

    - *
  • Configures route-level security using Spring Security's hasAuthority - * checks
  • - *
  • Integrates with AMS through the amsAuthenticationConverter
  • + *
  • Configures route-level security using AMS route-level checks + * ({@code AmsRouteSecurity}) in addition to standard Spring Security rules
  • + *
  • Integrates with AMS through the {@link IasJwtAuthenticationConverter}, which + * establishes the caller's token as an AMS principal for authorization checks
  • *
  • Uses Privilege constants with toAuthority() to check for * "action:resource" authorities
  • *
@@ -59,7 +59,8 @@ public SecurityFilterChain filterChain(HttpSecurity http, AmsRouteSecurity via) // Deny all other requests authz.anyRequest().denyAll(); }) - .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())); + .oauth2ResourceServer(oauth2 -> oauth2 + .jwt(jwt -> jwt.jwtAuthenticationConverter(new IasJwtAuthenticationConverter()))); return http.build(); } diff --git a/ams-spring-boot-shopping/src/test/java/com/sap/cloud/security/ams/samples/IasJwtFlowTest.java b/ams-spring-boot-shopping/src/test/java/com/sap/cloud/security/ams/samples/IasJwtFlowTest.java new file mode 100644 index 0000000..8eefd31 --- /dev/null +++ b/ams-spring-boot-shopping/src/test/java/com/sap/cloud/security/ams/samples/IasJwtFlowTest.java @@ -0,0 +1,362 @@ +package com.sap.cloud.security.ams.samples; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.sap.cloud.security.ams.samples.db.SimpleDatabase; +import com.sap.cloud.security.ams.samples.model.Order; +import com.sap.cloud.security.ams.api.Privilege; +import com.sap.cloud.security.spring.token.authentication.JavaSecurityContextHolderStrategy; +import com.sap.cloud.security.token.SapIdToken; +import com.sap.cloud.security.xsuaa.jwt.Base64JwtDecoder; +import com.sap.cloud.security.xsuaa.jwt.DecodedJwt; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.http.MediaType; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +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.status; + +/** + * End-to-end tests for the production IAS token flow, covering the full API surface + * of the shopping sample: privilege lookup, product and order reads with + * instance-based filtering, order creation with per-order attribute checks + * (including the App2App principal propagation flow), and order deletion. + * + *

+ * The production {@code JwtDecoder} validates the token but does NOT populate the + * cloud security {@code SecurityContext}. The token must therefore be established + * by the {@code jwtAuthenticationConverter} configured in {@code SecurityConfiguration}, + * otherwise the AMS principal is missing and all privilege checks are denied with + * HTTP 403. + *

+ * + *

+ * The {@code resourceserver-security-spring-boot-starter} is excluded from the test + * classpath (see {@code maven-surefire-plugin} in the pom). In a real deployment its + * {@code SecurityContextEnvironmentPostProcessor} activates the + * {@code JavaSecurityContextHolderStrategy}, which copies the token from the Spring + * Security context into the cloud security {@code SecurityContext}. The strategy is + * therefore activated by the {@link SecurityContextStrategyInitializer} below, which + * runs before the application beans capture the strategy at creation time. + *

+ */ +@SpringBootTest +@ActiveProfiles("test") +@ContextConfiguration(initializers = IasJwtFlowTest.SecurityContextStrategyInitializer.class) +@Import(IasJwtFlowTest.ProductionLikeDecoderConfiguration.class) +class IasJwtFlowTest { + + private static MockMvc mockMvc; + private static ObjectMapper objectMapper; + + @Autowired + private WebApplicationContext webApplicationContext; + + @Autowired + private SimpleDatabase database; + + @BeforeAll + static void setUpAll(@Autowired WebApplicationContext wac) { + mockMvc = MockMvcBuilders.webAppContextSetup(wac).apply(springSecurity()).build(); + + // Configure ObjectMapper with custom deserializer for Privilege class + objectMapper = new ObjectMapper(); + SimpleModule module = new SimpleModule(); + module.addDeserializer(Privilege.class, new StdDeserializer(Privilege.class) { + @Override + public Privilege deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode node = p.getCodec().readTree(p); + return Privilege.of(node.get("action").asText(), node.get("resource").asText()); + } + }); + objectMapper.registerModule(module); + } + + @BeforeEach + void setUp() { + // Reset database to initial state before each test to ensure test independence + database.reset(); + } + + // GET /privileges tests + @Test + void privilegesEndpointReturnsPrivilegesOfTheCurrentUser() throws Exception { + String aliceJwt = loadJwtFromFile("User_alice.json"); + String response = mockMvc.perform(get("/privileges") + .header("Authorization", "Bearer " + aliceJwt)) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + Set privileges = objectMapper.readValue(response, new TypeReference>() { + }); + + // Alice has DeleteOrders and CreateOrders policies, which also grant + // read:orders (via DeleteOrders -> ReadOrders) and read:products (via CreateOrders -> ReadProducts) + Assertions.assertEquals(Set.of( + Privilege.of("read", "products"), + Privilege.of("create", "orders"), + Privilege.of("delete", "orders"), + Privilege.of("read", "orders") + ), privileges); + } + + @Test + void privilegesEndpointIsUnauthorizedWithoutToken() throws Exception { + mockMvc.perform(get("/privileges")) + .andExpect(status().isUnauthorized()); + } + + // GET /products tests + @Test + void productsEndpointIsAccessibleWithValidIasToken() throws Exception { + String aliceJwt = loadJwtFromFile("User_alice.json"); + + mockMvc.perform(get("/products") + .header("Authorization", "Bearer " + aliceJwt)) + .andExpect(status().isOk()); + } + + @Test + void productsEndpointIsDeniedForUserWithoutReadProductsPrivilege() throws Exception { + String carolJwt = loadJwtFromFile("User_carol.json"); + + mockMvc.perform(get("/products") + .header("Authorization", "Bearer " + carolJwt)) + .andExpect(status().isForbidden()); + } + + @Test + void productsEndpointIsUnauthorizedWithoutToken() throws Exception { + mockMvc.perform(get("/products")) + .andExpect(status().isUnauthorized()); + } + + // GET /orders tests + @Test + void ordersEndpointReturnsAllOrdersForUserWithReadOrdersPrivilege() throws Exception { + String aliceJwt = loadJwtFromFile("User_alice.json"); + String response = mockMvc.perform(get("/orders") + .header("Authorization", "Bearer " + aliceJwt)) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + List orders = objectMapper.readValue(response, new TypeReference>() { + }); + Assertions.assertNotNull(orders); + Assertions.assertEquals(4, orders.size()); + } + + @Test + void ordersEndpointIsFilteredToOwnOrdersForUserWithReadOwnOrdersPrivilege() throws Exception { + String bobJwt = loadJwtFromFile("User_bob.json"); + String response = mockMvc.perform(get("/orders") + .header("Authorization", "Bearer " + bobJwt)) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + List orders = objectMapper.readValue(response, new TypeReference>() { + }); + Assertions.assertNotNull(orders); + Assertions.assertFalse(orders.isEmpty()); + Assertions.assertTrue(orders.stream().allMatch(o -> "bob".equals(o.getCreatedBy()))); + } + + @Test + void ordersEndpointIsDeniedForUserWithoutReadOrdersPrivilege() throws Exception { + String carolJwt = loadJwtFromFile("User_carol.json"); + + mockMvc.perform(get("/orders") + .header("Authorization", "Bearer " + carolJwt)) + .andExpect(status().isForbidden()); + } + + @Test + void ordersEndpointIsUnauthorizedWithoutToken() throws Exception { + mockMvc.perform(get("/orders")) + .andExpect(status().isUnauthorized()); + } + + // POST /orders tests + @Test + void createOrderIsAllowedForUserWithCreateOrdersPrivilege() throws Exception { + String aliceJwt = loadJwtFromFile("User_alice.json"); + + mockMvc.perform(post("/orders") + .header("Authorization", "Bearer " + aliceJwt) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"productId\": 1, \"quantity\": 1}")) + .andExpect(status().isCreated()); + } + + @Test + void createOrderIsRestrictedToAccessoriesForUserWithOrderAccessoryPolicy() throws Exception { + String bobJwt = loadJwtFromFile("User_bob.json"); + + // Ordering a non-accessory item (Yubikey, category securityAccessory) is denied + mockMvc.perform(post("/orders") + .header("Authorization", "Bearer " + bobJwt) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"productId\": 5, \"quantity\": 1}")) + .andExpect(status().isForbidden()); + + // Ordering an accessory item (Cherry Keyboard) is allowed + mockMvc.perform(post("/orders") + .header("Authorization", "Bearer " + bobJwt) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"productId\": 4, \"quantity\": 1}")) + .andExpect(status().isCreated()); + } + + @Test + void createOrderForExternalOrderFlowIsRestrictedByOrderTotal() throws Exception { + String bobExternalJwt = loadJwtFromFile("RestrictedPrincipalPropagation_bob.json"); + + // Order total 160 exceeds the ExternalOrder limit of 100 + mockMvc.perform(post("/orders") + .header("Authorization", "Bearer " + bobExternalJwt) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"productId\": 4, \"quantity\": 4}")) + .andExpect(status().isForbidden()); + + // Wrong product category for this flow (Yubikey, category securityAccessory) + mockMvc.perform(post("/orders") + .header("Authorization", "Bearer " + bobExternalJwt) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"productId\": 5, \"quantity\": 1}")) + .andExpect(status().isForbidden()); + + // Order within the limit is allowed + mockMvc.perform(post("/orders") + .header("Authorization", "Bearer " + bobExternalJwt) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"productId\": 4, \"quantity\": 2}")) + .andExpect(status().isCreated()); + } + + // DELETE /orders/{id} tests + @Test + void deleteOrderIsAllowedForUserWithDeleteOrdersPrivilege() throws Exception { + String aliceJwt = loadJwtFromFile("User_alice.json"); + + mockMvc.perform(delete("/orders/1") + .header("Authorization", "Bearer " + aliceJwt)) + .andExpect(status().isNoContent()); + } + + @Test + void deleteOrderIsDeniedForUserWithoutDeleteOrdersPrivilege() throws Exception { + String bobJwt = loadJwtFromFile("User_bob.json"); + + mockMvc.perform(delete("/orders/4") + .header("Authorization", "Bearer " + bobJwt)) + .andExpect(status().isForbidden()); + } + + /** + * Activates the {@link JavaSecurityContextHolderStrategy} before the application + * context is refreshed, mirroring the production environment where the + * {@code SecurityContextEnvironmentPostProcessor} of the + * {@code resourceserver-security-spring-boot-starter} performs this step. + * Spring Security filters capture the strategy at bean creation time, so it must + * be set before beans are instantiated. + */ + public static class SecurityContextStrategyInitializer implements ApplicationContextInitializer { + + @Override + public void initialize(ConfigurableApplicationContext context) { + SecurityContextHolder.setContextHolderStrategy(new JavaSecurityContextHolderStrategy()); + } + } + + @TestConfiguration + static class ProductionLikeDecoderConfiguration { + + private final Base64JwtDecoder base64JwtDecoder = Base64JwtDecoder.getInstance(); + + /** + * Decodes JWTs without validation and, like the production IAS decoder, does not + * set up the cloud security SecurityContext. The converter in + * SecurityConfiguration is the only component that establishes the token. + */ + @Bean + @Primary + public JwtDecoder jwtDecoder() { + return token -> { + try { + DecodedJwt decodedJwt = base64JwtDecoder.decode(token); + SapIdToken sapIdToken = new SapIdToken(decodedJwt); + Map headers = sapIdToken.getHeaders(); + Map claims = sapIdToken.getClaims(); + Instant issuedAt = claims.containsKey("iat") + ? Instant.ofEpochSecond(((Number) claims.get("iat")).longValue()) + : Instant.now(); + Instant expiresAt = Optional.ofNullable(sapIdToken.getExpiration()) + .orElse(Instant.now().plusSeconds(3600)); + return new Jwt(token, issuedAt, expiresAt, headers, claims); + } catch (Exception e) { + throw new JwtException("Failed to decode test JWT", e); + } + }; + } + } + + private String loadJwtFromFile(String filename) throws IOException { + Path filePath = Path.of("src/test/resources/jwt", filename); + String jsonPayload = Files.readString(filePath); + return createTestJwt(jsonPayload); + } + + private String createTestJwt(String jsonPayload) { + String header = Base64.getUrlEncoder().withoutPadding() + .encodeToString("{\"alg\":\"none\",\"typ\":\"JWT\"}".getBytes()); + String payload = Base64.getUrlEncoder().withoutPadding() + .encodeToString(jsonPayload.getBytes()); + String signature = Base64.getUrlEncoder().withoutPadding() + .encodeToString("test-signature".getBytes()); + return header + "." + payload + "." + signature; + } +} diff --git a/ams-spring-boot-shopping/src/test/java/com/sap/cloud/security/ams/samples/config/IasJwtAuthenticationConverterTest.java b/ams-spring-boot-shopping/src/test/java/com/sap/cloud/security/ams/samples/config/IasJwtAuthenticationConverterTest.java new file mode 100644 index 0000000..40fbdf5 --- /dev/null +++ b/ams-spring-boot-shopping/src/test/java/com/sap/cloud/security/ams/samples/config/IasJwtAuthenticationConverterTest.java @@ -0,0 +1,83 @@ +package com.sap.cloud.security.ams.samples.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.time.Instant; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sap.cloud.security.ams.api.Principal; +import com.sap.cloud.security.spring.token.authentication.AuthenticationToken; +import com.sap.cloud.security.spring.token.authentication.JavaSecurityContextHolderStrategy; +import com.sap.cloud.security.token.SapIdToken; +import com.sap.cloud.security.token.SecurityContext; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextImpl; +import org.springframework.security.oauth2.jwt.Jwt; + +class IasJwtAuthenticationConverterTest { + + private final IasJwtAuthenticationConverter converter = new IasJwtAuthenticationConverter(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + @AfterEach + void tearDown() { + SecurityContext.clear(); + } + + @Test + void convertsJwtIntoSapAuthenticationToken() { + Jwt jwt = testJwt(Map.of("sub", "alice", "app_tid", "tenant1")); + + var authentication = converter.convert(jwt); + + assertInstanceOf(AuthenticationToken.class, authentication); + assertInstanceOf(SapIdToken.class, authentication.getPrincipal()); + } + + @Test + void derivesAuthoritiesFromGroupsClaim() { + Jwt jwt = testJwt(Map.of("sub", "alice", "groups", List.of("admin", "users"))); + + var authentication = converter.convert(jwt); + + List authorities = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .toList(); + assertEquals(List.of("admin", "users"), authorities); + } + + @Test + void convertedAuthenticationEstablishesAmsPrincipal() { + Jwt jwt = testJwt(Map.of("sub", "alice", "app_tid", "tenant1", "scim_id", "alice")); + + new JavaSecurityContextHolderStrategy() + .setContext(new SecurityContextImpl(converter.convert(jwt))); + + assertInstanceOf(SapIdToken.class, SecurityContext.getToken()); + assertNotNull(Principal.fromSecurityContext()); + } + + private Jwt testJwt(Map claims) { + try { + String header = base64Url("{\"alg\":\"none\",\"typ\":\"JWT\"}"); + String payload = base64Url(objectMapper.writeValueAsString(claims)); + String signature = base64Url("test-signature"); + String rawJwt = header + "." + payload + "." + signature; + return new Jwt(rawJwt, Instant.now(), Instant.now().plusSeconds(3600), Map.of("alg", "none"), claims); + } catch (Exception e) { + throw new AssertionError("Failed to build test JWT", e); + } + } + + private static String base64Url(String input) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(input.getBytes()); + } +} From d6f93e02146ed7c19bd2d20bbd38a676a5830f88 Mon Sep 17 00:00:00 2001 From: Niklasherrmann21 Date: Thu, 24 Sep 2026 14:01:58 +0200 Subject: [PATCH 2/3] Make /health endpoint publicly accessible The custom /health endpoint fell through to anyRequest().denyAll() and was unreachable (401/403), although the sample documents it as a public health check. Permit it explicitly, in line with /actuator/health. --- .../samples/config/SecurityConfiguration.java | 3 ++- .../security/ams/samples/IasJwtFlowTest.java | 19 ++++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/ams-spring-boot-shopping/src/main/java/com/sap/cloud/security/ams/samples/config/SecurityConfiguration.java b/ams-spring-boot-shopping/src/main/java/com/sap/cloud/security/ams/samples/config/SecurityConfiguration.java index 8c0a73a..97ba8b8 100644 --- a/ams-spring-boot-shopping/src/main/java/com/sap/cloud/security/ams/samples/config/SecurityConfiguration.java +++ b/ams-spring-boot-shopping/src/main/java/com/sap/cloud/security/ams/samples/config/SecurityConfiguration.java @@ -36,8 +36,9 @@ public class SecurityConfiguration { @Bean public SecurityFilterChain filterChain(HttpSecurity http, AmsRouteSecurity via) throws Exception { http.authorizeHttpRequests(authz -> { - // Public endpoints - Spring Boot Actuator health check + // Public endpoints - health checks authz.requestMatchers(GET, "/actuator/health").permitAll(); + authz.requestMatchers(GET, "/health").permitAll(); // Authenticated endpoints without authorization checks authz.requestMatchers(GET, "/privileges").authenticated(); diff --git a/ams-spring-boot-shopping/src/test/java/com/sap/cloud/security/ams/samples/IasJwtFlowTest.java b/ams-spring-boot-shopping/src/test/java/com/sap/cloud/security/ams/samples/IasJwtFlowTest.java index 8eefd31..177d13d 100644 --- a/ams-spring-boot-shopping/src/test/java/com/sap/cloud/security/ams/samples/IasJwtFlowTest.java +++ b/ams-spring-boot-shopping/src/test/java/com/sap/cloud/security/ams/samples/IasJwtFlowTest.java @@ -56,9 +56,9 @@ /** * End-to-end tests for the production IAS token flow, covering the full API surface - * of the shopping sample: privilege lookup, product and order reads with - * instance-based filtering, order creation with per-order attribute checks - * (including the App2App principal propagation flow), and order deletion. + * of the shopping sample: health endpoints, privilege lookup, product and order + * reads with instance-based filtering, order creation with per-order attribute + * checks (including the App2App principal propagation flow), and order deletion. * *

* The production {@code JwtDecoder} validates the token but does NOT populate the @@ -116,6 +116,19 @@ void setUp() { database.reset(); } + // Health endpoint tests + @Test + void healthEndpointIsPublicWithoutAuthentication() throws Exception { + mockMvc.perform(get("/health")) + .andExpect(status().isOk()); + } + + @Test + void actuatorHealthEndpointIsPublicWithoutAuthentication() throws Exception { + mockMvc.perform(get("/actuator/health")) + .andExpect(status().isOk()); + } + // GET /privileges tests @Test void privilegesEndpointReturnsPrivilegesOfTheCurrentUser() throws Exception { From 85efbbb61793f748a3fa7f357970a644408118b2 Mon Sep 17 00:00:00 2001 From: Niklasherrmann21 Date: Fri, 25 Sep 2026 13:54:10 +0200 Subject: [PATCH 3/3] Inline the IAS JWT converter in SecurityConfiguration --- .../config/IasJwtAuthenticationConverter.java | 51 ------------------- .../samples/config/SecurityConfiguration.java | 37 ++++++++++++-- ...st.java => SecurityConfigurationTest.java} | 22 ++++---- 3 files changed, 47 insertions(+), 63 deletions(-) delete mode 100644 ams-spring-boot-shopping/src/main/java/com/sap/cloud/security/ams/samples/config/IasJwtAuthenticationConverter.java rename ams-spring-boot-shopping/src/test/java/com/sap/cloud/security/ams/samples/config/{IasJwtAuthenticationConverterTest.java => SecurityConfigurationTest.java} (79%) diff --git a/ams-spring-boot-shopping/src/main/java/com/sap/cloud/security/ams/samples/config/IasJwtAuthenticationConverter.java b/ams-spring-boot-shopping/src/main/java/com/sap/cloud/security/ams/samples/config/IasJwtAuthenticationConverter.java deleted file mode 100644 index e91fbbd..0000000 --- a/ams-spring-boot-shopping/src/main/java/com/sap/cloud/security/ams/samples/config/IasJwtAuthenticationConverter.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.sap.cloud.security.ams.samples.config; - -import com.sap.cloud.security.spring.token.authentication.AuthenticationToken; -import com.sap.cloud.security.token.TokenClaims; -import org.springframework.core.convert.converter.Converter; -import org.springframework.security.authentication.AbstractAuthenticationToken; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.jwt.Jwt; - -import java.util.Collections; -import java.util.List; -import java.util.stream.Collectors; - -/** - * Converts a validated IAS JWT into a SAP {@link AuthenticationToken}. - * - *

- * This is required for the AMS integration: the cloud security library only copies - * the token into its {@code SecurityContext} when the Spring Security principal is a - * SAP {@code Token} (see {@code JavaSecurityContextHolderStrategy}). Only then can the - * AMS library derive the current principal and evaluate the caller's policies. - * Spring Security's default converter produces a plain {@code JwtAuthenticationToken} - * whose principal is a plain {@code Jwt}, which leaves the AMS principal unresolved - * and results in denied privilege checks (HTTP 403). - *

- * - *

- * The granted authorities are derived from the {@code groups} claim of the token. - * Authorization decisions in this application, however, are made by AMS - * (route-level checks via {@code AmsRouteSecurity} and method-level checks via - * {@code @CheckPrivilege}/{@code @PrecheckPrivilege}). - *

- */ -public class IasJwtAuthenticationConverter implements Converter { - - @Override - public AbstractAuthenticationToken convert(Jwt jwt) { - return new AuthenticationToken(jwt, groupAuthorities(jwt)); - } - - private static List groupAuthorities(Jwt jwt) { - List groups = jwt.getClaimAsStringList(TokenClaims.GROUPS); - if (groups == null) { - return Collections.emptyList(); - } - return groups.stream() - .map(SimpleGrantedAuthority::new) - .collect(Collectors.toList()); - } -} diff --git a/ams-spring-boot-shopping/src/main/java/com/sap/cloud/security/ams/samples/config/SecurityConfiguration.java b/ams-spring-boot-shopping/src/main/java/com/sap/cloud/security/ams/samples/config/SecurityConfiguration.java index 97ba8b8..7104389 100644 --- a/ams-spring-boot-shopping/src/main/java/com/sap/cloud/security/ams/samples/config/SecurityConfiguration.java +++ b/ams-spring-boot-shopping/src/main/java/com/sap/cloud/security/ams/samples/config/SecurityConfiguration.java @@ -2,14 +2,23 @@ import com.sap.cloud.security.ams.spring.AmsRouteSecurity; import com.sap.cloud.security.spring.config.IdentityServicesPropertySourceFactory; +import com.sap.cloud.security.spring.token.authentication.AuthenticationToken; +import com.sap.cloud.security.token.TokenClaims; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.PropertySource; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.web.SecurityFilterChain; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + import static com.sap.cloud.security.ams.samples.config.Privileges.*; import static org.springframework.http.HttpMethod.*; @@ -21,8 +30,13 @@ *
    *
  • Configures route-level security using AMS route-level checks * ({@code AmsRouteSecurity}) in addition to standard Spring Security rules
  • - *
  • Integrates with AMS through the {@link IasJwtAuthenticationConverter}, which - * establishes the caller's token as an AMS principal for authorization checks
  • + *
  • Wraps every validated IAS JWT into a SAP {@link AuthenticationToken}: the cloud + * security library only copies the token into its {@code SecurityContext} when the + * Spring Security principal is a SAP {@code Token} (see + * {@code JavaSecurityContextHolderStrategy}). Only then can the AMS library derive the + * current principal and evaluate the caller's policies. Spring Security's default + * converter produces a plain {@code Jwt} principal and would leave the AMS principal + * unresolved, denying all privilege checks with HTTP 403.
  • *
  • Uses Privilege constants with toAuthority() to check for * "action:resource" authorities
  • *
@@ -61,8 +75,25 @@ public SecurityFilterChain filterChain(HttpSecurity http, AmsRouteSecurity via) authz.anyRequest().denyAll(); }) .oauth2ResourceServer(oauth2 -> oauth2 - .jwt(jwt -> jwt.jwtAuthenticationConverter(new IasJwtAuthenticationConverter()))); + .jwt(jwt -> jwt.jwtAuthenticationConverter( + j -> new AuthenticationToken(j, groupAuthorities(j))))); return http.build(); } + + /** + * Maps the {@code groups} claim of the token to Spring Security authorities. + * The authorities are informational: authorization decisions in this + * application are made by AMS (route-level checks via {@code AmsRouteSecurity} + * and method-level checks via {@code @CheckPrivilege}/{@code @PrecheckPrivilege}). + */ + static List groupAuthorities(Jwt jwt) { + List groups = jwt.getClaimAsStringList(TokenClaims.GROUPS); + if (groups == null) { + return Collections.emptyList(); + } + return groups.stream() + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + } } diff --git a/ams-spring-boot-shopping/src/test/java/com/sap/cloud/security/ams/samples/config/IasJwtAuthenticationConverterTest.java b/ams-spring-boot-shopping/src/test/java/com/sap/cloud/security/ams/samples/config/SecurityConfigurationTest.java similarity index 79% rename from ams-spring-boot-shopping/src/test/java/com/sap/cloud/security/ams/samples/config/IasJwtAuthenticationConverterTest.java rename to ams-spring-boot-shopping/src/test/java/com/sap/cloud/security/ams/samples/config/SecurityConfigurationTest.java index 40fbdf5..7358549 100644 --- a/ams-spring-boot-shopping/src/test/java/com/sap/cloud/security/ams/samples/config/IasJwtAuthenticationConverterTest.java +++ b/ams-spring-boot-shopping/src/test/java/com/sap/cloud/security/ams/samples/config/SecurityConfigurationTest.java @@ -22,9 +22,8 @@ import org.springframework.security.core.context.SecurityContextImpl; import org.springframework.security.oauth2.jwt.Jwt; -class IasJwtAuthenticationConverterTest { +class SecurityConfigurationTest { - private final IasJwtAuthenticationConverter converter = new IasJwtAuthenticationConverter(); private final ObjectMapper objectMapper = new ObjectMapper(); @AfterEach @@ -33,10 +32,10 @@ void tearDown() { } @Test - void convertsJwtIntoSapAuthenticationToken() { + void jwtIsWrappedIntoSapAuthenticationToken() { Jwt jwt = testJwt(Map.of("sub", "alice", "app_tid", "tenant1")); - var authentication = converter.convert(jwt); + var authentication = new AuthenticationToken(jwt, SecurityConfiguration.groupAuthorities(jwt)); assertInstanceOf(AuthenticationToken.class, authentication); assertInstanceOf(SapIdToken.class, authentication.getPrincipal()); @@ -46,20 +45,25 @@ void convertsJwtIntoSapAuthenticationToken() { void derivesAuthoritiesFromGroupsClaim() { Jwt jwt = testJwt(Map.of("sub", "alice", "groups", List.of("admin", "users"))); - var authentication = converter.convert(jwt); - - List authorities = authentication.getAuthorities().stream() + List authorities = SecurityConfiguration.groupAuthorities(jwt).stream() .map(GrantedAuthority::getAuthority) .toList(); assertEquals(List.of("admin", "users"), authorities); } @Test - void convertedAuthenticationEstablishesAmsPrincipal() { + void noAuthoritiesWithoutGroupsClaim() { + Jwt jwt = testJwt(Map.of("sub", "alice", "app_tid", "tenant1")); + + assertEquals(List.of(), SecurityConfiguration.groupAuthorities(jwt)); + } + + @Test + void authenticationEstablishesAmsPrincipal() { Jwt jwt = testJwt(Map.of("sub", "alice", "app_tid", "tenant1", "scim_id", "alice")); new JavaSecurityContextHolderStrategy() - .setContext(new SecurityContextImpl(converter.convert(jwt))); + .setContext(new SecurityContextImpl(new AuthenticationToken(jwt, SecurityConfiguration.groupAuthorities(jwt)))); assertInstanceOf(SapIdToken.class, SecurityContext.getToken()); assertNotNull(Principal.fromSecurityContext());