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..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,15 +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.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; +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.*; @@ -20,9 +28,15 @@ *

* This configuration: *

@@ -36,8 +50,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(); @@ -59,8 +74,26 @@ 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( + 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/IasJwtFlowTest.java b/ams-spring-boot-shopping/src/test/java/com/sap/cloud/security/ams/samples/IasJwtFlowTest.java new file mode 100644 index 0000000..177d13d --- /dev/null +++ b/ams-spring-boot-shopping/src/test/java/com/sap/cloud/security/ams/samples/IasJwtFlowTest.java @@ -0,0 +1,375 @@ +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: 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 + * 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(); + } + + // 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 { + 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/SecurityConfigurationTest.java b/ams-spring-boot-shopping/src/test/java/com/sap/cloud/security/ams/samples/config/SecurityConfigurationTest.java new file mode 100644 index 0000000..7358549 --- /dev/null +++ b/ams-spring-boot-shopping/src/test/java/com/sap/cloud/security/ams/samples/config/SecurityConfigurationTest.java @@ -0,0 +1,87 @@ +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 SecurityConfigurationTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @AfterEach + void tearDown() { + SecurityContext.clear(); + } + + @Test + void jwtIsWrappedIntoSapAuthenticationToken() { + Jwt jwt = testJwt(Map.of("sub", "alice", "app_tid", "tenant1")); + + var authentication = new AuthenticationToken(jwt, SecurityConfiguration.groupAuthorities(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"))); + + List authorities = SecurityConfiguration.groupAuthorities(jwt).stream() + .map(GrantedAuthority::getAuthority) + .toList(); + assertEquals(List.of("admin", "users"), authorities); + } + + @Test + 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(new AuthenticationToken(jwt, SecurityConfiguration.groupAuthorities(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()); + } +}