Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions src/main/java/com/alibou/keycloak/AudienceValidator.java
Original file line number Diff line number Diff line change
@@ -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<Jwt> {

private final Set<String> expectedAudiences;
private final OAuth2Error invalidAudience = new OAuth2Error(
"invalid_token",
"Token does not contain any expected audience",
null
);

public AudienceValidator(List<String> 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<String> tokenAudiences = jwt.getAudience();
boolean hasAnyExpectedAudience = tokenAudiences.stream()
.anyMatch(expectedAudiences::contains);

return hasAnyExpectedAudience
? OAuth2TokenValidatorResult.success()
: OAuth2TokenValidatorResult.failure(invalidAudience);
}
}
79 changes: 79 additions & 0 deletions src/main/java/com/alibou/keycloak/KeycloakJwtRolesConverter.java
Original file line number Diff line number Diff line change
@@ -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<Jwt, Collection<GrantedAuthority>> {

private final String clientId;
private final JwtGrantedAuthoritiesConverter defaultScopesConverter = new JwtGrantedAuthoritiesConverter();

public KeycloakJwtRolesConverter(String clientId) {
this.clientId = clientId;
}

@Override
public Collection<GrantedAuthority> convert(Jwt jwt) {
Set<GrantedAuthority> authorities = new HashSet<>();

Collection<GrantedAuthority> scopeAuthorities = defaultScopesConverter.convert(jwt);
if (scopeAuthorities != null) {
authorities.addAll(scopeAuthorities);
}

authorities.addAll(extractRealmRoles(jwt));
authorities.addAll(extractClientRoles(jwt));

return authorities;
}

private Collection<GrantedAuthority> extractRealmRoles(Jwt jwt) {
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess == null) {
return List.of();
}

return extractAuthorities(realmAccess.get("roles"));
}

private Collection<GrantedAuthority> extractClientRoles(Jwt jwt) {
if (clientId == null || clientId.isBlank()) {
return List.of();
}

Map<String, Object> 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<GrantedAuthority> extractAuthorities(Object rolesObject) {
if (!(rolesObject instanceof Collection<?> roles)) {
return List.of();
}

List<GrantedAuthority> authorities = new ArrayList<>();
for (Object role : roles) {
if (role instanceof String roleName && !roleName.isBlank()) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + roleName));
}
}
return authorities;
}
}
57 changes: 56 additions & 1 deletion src/main/java/com/alibou/keycloak/SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -19,12 +40,46 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) {
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> {})
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);

return http.build();
}

@Bean
public Converter<Jwt, ? extends AbstractAuthenticationToken> 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<Jwt> defaultValidators = JwtValidators.createDefaultWithIssuer(issuerUri);
OAuth2TokenValidator<Jwt> 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";
}
}
28 changes: 28 additions & 0 deletions src/main/java/com/alibou/keycloak/SecurityProperties.java
Original file line number Diff line number Diff line change
@@ -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<String> audiences = new ArrayList<>();

public String getClientId() {
return clientId;
}

public void setClientId(String clientId) {
this.clientId = clientId;
}

public List<String> getAudiences() {
return audiences;
}

public void setAudiences(List<String> audiences) {
this.audiences = audiences;
}
}
15 changes: 15 additions & 0 deletions src/main/resources/application-dev.yaml
Original file line number Diff line number Diff line change
@@ -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}
15 changes: 15 additions & 0 deletions src/main/resources/application-local.yaml
Original file line number Diff line number Diff line change
@@ -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}
15 changes: 15 additions & 0 deletions src/main/resources/application-prod.yaml
Original file line number Diff line number Diff line change
@@ -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}
13 changes: 5 additions & 8 deletions src/main/resources/application.yaml
Original file line number Diff line number Diff line change
@@ -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
port: ${SERVER_PORT:8081}

spring:
application:
name: keycloak-auth-service
47 changes: 47 additions & 0 deletions src/test/java/com/alibou/keycloak/AudienceValidatorTest.java
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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<String> 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"));
}
}