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:
*
- * - 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
+ * - 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
*
@@ -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());
+ }
+}