diff --git a/src/main/java/com/alibou/keycloak/AudienceValidator.java b/src/main/java/com/alibou/keycloak/AudienceValidator.java new file mode 100644 index 0000000..8cef1e1 --- /dev/null +++ b/src/main/java/com/alibou/keycloak/AudienceValidator.java @@ -0,0 +1,45 @@ +package com.alibou.keycloak; + +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +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; + +public class AudienceValidator implements OAuth2TokenValidator { + + private final Set expectedAudiences; + private final OAuth2Error invalidAudience = new OAuth2Error( + "invalid_token", + "Token does not contain any expected audience", + null + ); + + public AudienceValidator(List expectedAudiences) { + this.expectedAudiences = expectedAudiences == null + ? Set.of() + : expectedAudiences.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(value -> !value.isEmpty()) + .collect(Collectors.toSet()); + } + + @Override + public OAuth2TokenValidatorResult validate(Jwt jwt) { + if (expectedAudiences.isEmpty()) { + return OAuth2TokenValidatorResult.success(); + } + + List tokenAudiences = jwt.getAudience(); + boolean hasAnyExpectedAudience = tokenAudiences.stream() + .anyMatch(expectedAudiences::contains); + + return hasAnyExpectedAudience + ? OAuth2TokenValidatorResult.success() + : OAuth2TokenValidatorResult.failure(invalidAudience); + } +} diff --git a/src/main/java/com/alibou/keycloak/KeycloakJwtRolesConverter.java b/src/main/java/com/alibou/keycloak/KeycloakJwtRolesConverter.java new file mode 100644 index 0000000..50d2fdb --- /dev/null +++ b/src/main/java/com/alibou/keycloak/KeycloakJwtRolesConverter.java @@ -0,0 +1,79 @@ +package com.alibou.keycloak; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; + +public class KeycloakJwtRolesConverter implements Converter> { + + private final String clientId; + private final JwtGrantedAuthoritiesConverter defaultScopesConverter = new JwtGrantedAuthoritiesConverter(); + + public KeycloakJwtRolesConverter(String clientId) { + this.clientId = clientId; + } + + @Override + public Collection convert(Jwt jwt) { + Set authorities = new HashSet<>(); + + Collection scopeAuthorities = defaultScopesConverter.convert(jwt); + if (scopeAuthorities != null) { + authorities.addAll(scopeAuthorities); + } + + authorities.addAll(extractRealmRoles(jwt)); + authorities.addAll(extractClientRoles(jwt)); + + return authorities; + } + + private Collection extractRealmRoles(Jwt jwt) { + Map realmAccess = jwt.getClaimAsMap("realm_access"); + if (realmAccess == null) { + return List.of(); + } + + return extractAuthorities(realmAccess.get("roles")); + } + + private Collection extractClientRoles(Jwt jwt) { + if (clientId == null || clientId.isBlank()) { + return List.of(); + } + + Map resourceAccess = jwt.getClaimAsMap("resource_access"); + if (resourceAccess == null) { + return List.of(); + } + + Object clientSection = resourceAccess.get(clientId); + if (!(clientSection instanceof Map clientSectionMap)) { + return List.of(); + } + + return extractAuthorities(clientSectionMap.get("roles")); + } + + private Collection extractAuthorities(Object rolesObject) { + if (!(rolesObject instanceof Collection roles)) { + return List.of(); + } + + List authorities = new ArrayList<>(); + for (Object role : roles) { + if (role instanceof String roleName && !roleName.isBlank()) { + authorities.add(new SimpleGrantedAuthority("ROLE_" + roleName)); + } + } + return authorities; + } +} diff --git a/src/main/java/com/alibou/keycloak/SecurityConfig.java b/src/main/java/com/alibou/keycloak/SecurityConfig.java index a488d97..b08d10a 100644 --- a/src/main/java/com/alibou/keycloak/SecurityConfig.java +++ b/src/main/java/com/alibou/keycloak/SecurityConfig.java @@ -1,16 +1,37 @@ package com.alibou.keycloak; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; +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; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.core.convert.converter.Converter; +import org.springframework.beans.factory.annotation.Value; @Configuration @EnableWebSecurity +@EnableMethodSecurity +@EnableConfigurationProperties(SecurityProperties.class) public class SecurityConfig { + + private final SecurityProperties securityProperties; + + public SecurityConfig(SecurityProperties securityProperties) { + this.securityProperties = securityProperties; + } + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) { http @@ -19,7 +40,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) { .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2 - .jwt(jwt -> {}) + .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())) ) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) @@ -27,4 +48,38 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) { return http.build(); } + + @Bean + public Converter jwtAuthenticationConverter() { + JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); + converter.setJwtGrantedAuthoritiesConverter( + new KeycloakJwtRolesConverter(securityProperties.getClientId()) + ); + return converter; + } + + @Bean + public JwtDecoder jwtDecoder( + @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri:http://localhost:8080/realms/Alibou}") String issuerUri, + @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri:}") String configuredJwkSetUri + ) { + String jwkSetUri = configuredJwkSetUri == null || configuredJwkSetUri.isBlank() + ? buildJwkSetUri(issuerUri) + : configuredJwkSetUri; + + NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build(); + + OAuth2TokenValidator defaultValidators = JwtValidators.createDefaultWithIssuer(issuerUri); + OAuth2TokenValidator audienceValidator = new AudienceValidator(securityProperties.getAudiences()); + decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(defaultValidators, audienceValidator)); + + return decoder; + } + + private String buildJwkSetUri(String issuerUri) { + String normalizedIssuer = issuerUri.endsWith("/") + ? issuerUri.substring(0, issuerUri.length() - 1) + : issuerUri; + return normalizedIssuer + "/protocol/openid-connect/certs"; + } } diff --git a/src/main/java/com/alibou/keycloak/SecurityProperties.java b/src/main/java/com/alibou/keycloak/SecurityProperties.java new file mode 100644 index 0000000..244297e --- /dev/null +++ b/src/main/java/com/alibou/keycloak/SecurityProperties.java @@ -0,0 +1,28 @@ +package com.alibou.keycloak; + +import java.util.ArrayList; +import java.util.List; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.security") +public class SecurityProperties { + + private String clientId = "ride-api"; + private List audiences = new ArrayList<>(); + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public List getAudiences() { + return audiences; + } + + public void setAudiences(List audiences) { + this.audiences = audiences; + } +} diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml new file mode 100644 index 0000000..3d677be --- /dev/null +++ b/src/main/resources/application-dev.yaml @@ -0,0 +1,15 @@ +spring: + config: + activate: + on-profile: dev + security: + oauth2: + resourceserver: + jwt: + issuer-uri: ${KC_ISSUER_URI} + jwk-set-uri: ${spring.security.oauth2.resourceserver.jwt.issuer-uri}/protocol/openid-connect/certs + +app: + security: + client-id: ${KC_CLIENT_ID} + audiences: ${KC_EXPECTED_AUDIENCES} diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml new file mode 100644 index 0000000..003fdfc --- /dev/null +++ b/src/main/resources/application-local.yaml @@ -0,0 +1,15 @@ +spring: + config: + activate: + on-profile: local + security: + oauth2: + resourceserver: + jwt: + issuer-uri: ${KC_ISSUER_URI:http://localhost:8080/realms/Alibou} + jwk-set-uri: ${spring.security.oauth2.resourceserver.jwt.issuer-uri}/protocol/openid-connect/certs + +app: + security: + client-id: ${KC_CLIENT_ID:ride-api} + audiences: ${KC_EXPECTED_AUDIENCES:ride-api} diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml new file mode 100644 index 0000000..872fae5 --- /dev/null +++ b/src/main/resources/application-prod.yaml @@ -0,0 +1,15 @@ +spring: + config: + activate: + on-profile: prod + security: + oauth2: + resourceserver: + jwt: + issuer-uri: ${KC_ISSUER_URI} + jwk-set-uri: ${spring.security.oauth2.resourceserver.jwt.issuer-uri}/protocol/openid-connect/certs + +app: + security: + client-id: ${KC_CLIENT_ID} + audiences: ${KC_EXPECTED_AUDIENCES} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 644cfcd..0d78b7c 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,9 +1,6 @@ -spring: - security: - oauth2: - resourceserver: - jwt: - issuer-uri: http://localhost:8080/realms/Alibou - jwk-set-uri: ${spring.security.oauth2.resourceserver.jwt.issuer-uri}/protocol/openid-connect/certs server: - port: 8081 \ No newline at end of file + port: ${SERVER_PORT:8081} + +spring: + application: + name: keycloak-auth-service diff --git a/src/test/java/com/alibou/keycloak/AudienceValidatorTest.java b/src/test/java/com/alibou/keycloak/AudienceValidatorTest.java new file mode 100644 index 0000000..becbb85 --- /dev/null +++ b/src/test/java/com/alibou/keycloak/AudienceValidatorTest.java @@ -0,0 +1,47 @@ +package com.alibou.keycloak; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.jwt.Jwt; + +class AudienceValidatorTest { + + @Test + void shouldValidateAudienceWhenExpectedAudienceIsPresent() { + AudienceValidator validator = new AudienceValidator(List.of("ride-api")); + + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "none") + .audience(List.of("ride-api", "account")) + .build(); + + assertFalse(validator.validate(jwt).hasErrors()); + } + + @Test + void shouldFailWhenExpectedAudienceIsMissing() { + AudienceValidator validator = new AudienceValidator(List.of("ride-api")); + + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "none") + .audience(List.of("other-api")) + .build(); + + assertTrue(validator.validate(jwt).hasErrors()); + } + + @Test + void shouldPassWhenAudienceValidationIsNotConfigured() { + AudienceValidator validator = new AudienceValidator(List.of()); + + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "none") + .audience(List.of("anything")) + .build(); + + assertFalse(validator.validate(jwt).hasErrors()); + } +} diff --git a/src/test/java/com/alibou/keycloak/KeycloakJwtRolesConverterTest.java b/src/test/java/com/alibou/keycloak/KeycloakJwtRolesConverterTest.java new file mode 100644 index 0000000..62deffe --- /dev/null +++ b/src/test/java/com/alibou/keycloak/KeycloakJwtRolesConverterTest.java @@ -0,0 +1,38 @@ +package com.alibou.keycloak; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; + +class KeycloakJwtRolesConverterTest { + + @Test + void shouldExtractRealmAndClientRolesAndScopes() { + KeycloakJwtRolesConverter converter = new KeycloakJwtRolesConverter("ride-api"); + + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "none") + .claim("scope", "profile email") + .claim("realm_access", Map.of("roles", List.of("USER", "ADMIN"))) + .claim("resource_access", Map.of( + "ride-api", Map.of("roles", List.of("SUPPORT")) + )) + .build(); + + Set authorities = converter.convert(jwt) + .stream() + .map(GrantedAuthority::getAuthority) + .collect(java.util.stream.Collectors.toSet()); + + assertTrue(authorities.contains("SCOPE_profile")); + assertTrue(authorities.contains("SCOPE_email")); + assertTrue(authorities.contains("ROLE_USER")); + assertTrue(authorities.contains("ROLE_ADMIN")); + assertTrue(authorities.contains("ROLE_SUPPORT")); + } +}