diff --git a/spring-security-modules/spring-security-auth-server/src/main/java/com/baeldung/auth/server/customclaims/CustomClaimsAuthServerApplication.java b/spring-security-modules/spring-security-auth-server/src/main/java/com/baeldung/auth/server/customclaims/CustomClaimsAuthServerApplication.java new file mode 100644 index 000000000000..0948e8e2c9ec --- /dev/null +++ b/spring-security-modules/spring-security-auth-server/src/main/java/com/baeldung/auth/server/customclaims/CustomClaimsAuthServerApplication.java @@ -0,0 +1,14 @@ +package com.baeldung.auth.server.customclaims; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Import; + +@SpringBootApplication +@Import(com.baeldung.auth.server.customclaims.config.AuthServerConfiguration.class) +public class CustomClaimsAuthServerApplication { + + public static void main(String[] args) { + SpringApplication.run(CustomClaimsAuthServerApplication.class, args); + } +} diff --git a/spring-security-modules/spring-security-auth-server/src/main/java/com/baeldung/auth/server/customclaims/components/UserInfoMapper.java b/spring-security-modules/spring-security-auth-server/src/main/java/com/baeldung/auth/server/customclaims/components/UserInfoMapper.java new file mode 100644 index 000000000000..dd25d6dc426f --- /dev/null +++ b/spring-security-modules/spring-security-auth-server/src/main/java/com/baeldung/auth/server/customclaims/components/UserInfoMapper.java @@ -0,0 +1,63 @@ +package com.baeldung.auth.server.customclaims.components; + +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; +import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcUserInfoAuthenticationContext; + +import java.util.function.Function; + +public class UserInfoMapper implements Function { + + private final UserInfoService userInfoService; + + public UserInfoMapper(UserInfoService userInfoService) { + this.userInfoService = userInfoService; + } + + @Override + public OidcUserInfo apply(OidcUserInfoAuthenticationContext context) { + + var subject = context.getAuthentication().getName(); + var scopes = context.getAuthorization().getAuthorizedScopes(); + var userInfo = userInfoService.getUserInfoByUsername(subject); + + // Handle trivial cases first + if ( userInfo.isEmpty() || scopes.isEmpty() || (scopes.size() == 1 && scopes.contains("openid")) ) { + return OidcUserInfo.builder() + .subject(subject) + .build(); + } + + // Add claims based on the authorized scopes + var user = userInfo.get(); + var b = OidcUserInfo.builder() + .subject(subject); + + if ( scopes.contains("email") ) { + b.email(user.email()) + .emailVerified(user.emailVerified()); + } + + if( scopes.contains("profile") ) { + b.name(user.name()) + .givenName(user.givenName()) + .familyName(user.familyName()) + .locale(user.locale().toLanguageTag()) + .gender(user.gender()) + .birthdate(user.birthdate().toString()) + .zoneinfo(user.zoneId().toString()) + .preferredUsername(user.username()) + .updatedAt(user.updatedAt().toString()); + } + + // Add custom claims only if authorized. Here, we'll use the "account" as an example + if ( scopes.contains("account") ) { + b.claim("account_id", user.accountId().toString()) + .claim("created_at", user.createdAt().toString()) + .claim("account_expires_at", user.accountExpiresAt().toString()); + } + + // Build the final result + return b.build(); + + } +} diff --git a/spring-security-modules/spring-security-auth-server/src/main/java/com/baeldung/auth/server/customclaims/components/UserInfoService.java b/spring-security-modules/spring-security-auth-server/src/main/java/com/baeldung/auth/server/customclaims/components/UserInfoService.java new file mode 100644 index 000000000000..253941c5aed7 --- /dev/null +++ b/spring-security-modules/spring-security-auth-server/src/main/java/com/baeldung/auth/server/customclaims/components/UserInfoService.java @@ -0,0 +1,54 @@ +package com.baeldung.auth.server.customclaims.components; + +import org.slf4j.Logger; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.*; + +@Service +public class UserInfoService { + + static final Map users = Map.of( + "user", new UserInfo( + "user", + "Alice Smith", + "Alice", + "Smith", + "alice.smith@example.com", + true, + Locale.forLanguageTag("en-US"), + "female", + LocalDate.of(1990, 1, 1), + ZoneId.of("America/New_York"), + UUID.randomUUID(), + Instant.now(), + Instant.now(), + Instant.now().plus(60, java.time.temporal.ChronoUnit.DAYS) + ) + ); + + public Optional getUserInfoByUsername(String username) { + return Optional.ofNullable(users.get(username)); + } + + public record UserInfo( + String username, + String name, + String givenName, + String familyName, + String email, + boolean emailVerified, + Locale locale, + String gender, + LocalDate birthdate, + ZoneId zoneId, + UUID accountId, + Instant createdAt, + Instant updatedAt, + Instant accountExpiresAt + ) {} +} diff --git a/spring-security-modules/spring-security-auth-server/src/main/java/com/baeldung/auth/server/customclaims/config/AuthServerConfiguration.java b/spring-security-modules/spring-security-auth-server/src/main/java/com/baeldung/auth/server/customclaims/config/AuthServerConfiguration.java new file mode 100644 index 000000000000..413d59933594 --- /dev/null +++ b/spring-security-modules/spring-security-auth-server/src/main/java/com/baeldung/auth/server/customclaims/config/AuthServerConfiguration.java @@ -0,0 +1,92 @@ +package com.baeldung.auth.server.customclaims.config; + +import com.baeldung.auth.server.customclaims.components.UserInfoMapper; +import com.baeldung.auth.server.customclaims.components.UserInfoService; +import org.slf4j.Logger; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.MediaType; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; +import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcUserInfoAuthenticationContext; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; +import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; + +import java.util.List; +import java.util.Set; +import java.util.function.Function; + +import static org.springframework.security.config.Customizer.withDefaults; + +@Configuration +public class AuthServerConfiguration { + private static final Logger log = org.slf4j.LoggerFactory.getLogger(AuthServerConfiguration.class); + + private final UserInfoService userInfoService; + + @Value("${customuserinfo.enabled:true}") + private boolean enableCustomUserInfo = true; + + public AuthServerConfiguration(UserInfoService userInfoService) { + this.userInfoService = userInfoService; + } + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http, UserInfoMapper userInfoMapper) { + + // @formatter:off + return http.oauth2AuthorizationServer(sas -> { + http + .securityMatcher(sas.getEndpointsMatcher()) + .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated()) + .exceptionHandling(exceptions -> + exceptions.defaultAuthenticationEntryPointFor(new LoginUrlAuthenticationEntryPoint("/login"), createRequestMatcher())); + + if ( enableCustomUserInfo ) { + sas.oidc(oidc -> oidc + .userInfoEndpoint(userInfo -> userInfo + .userInfoMapper(userInfoMapper))); + } + else { + sas.oidc(withDefaults()); + } + }).build(); + // @formatter:on + } + + @Bean + UserInfoMapper userInfoMapper() { + return new UserInfoMapper(userInfoService); + } + + @Bean + @Order(SecurityFilterProperties.BASIC_AUTH_ORDER) + SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) { + // @formatter:off + http + .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated()) + .formLogin(withDefaults()); + // @formatter:on + return http.build(); + } + + private static RequestMatcher createRequestMatcher() { + MediaTypeRequestMatcher requestMatcher = new MediaTypeRequestMatcher(MediaType.TEXT_HTML); + requestMatcher.setIgnoredMediaTypes(Set.of(MediaType.ALL)); + return requestMatcher; + } + +} + + diff --git a/spring-security-modules/spring-security-auth-server/src/main/resources/application-customclaims.yaml b/spring-security-modules/spring-security-auth-server/src/main/resources/application-customclaims.yaml new file mode 100644 index 000000000000..4c6862dfd097 --- /dev/null +++ b/spring-security-modules/spring-security-auth-server/src/main/resources/application-customclaims.yaml @@ -0,0 +1,28 @@ +spring: + security: + user: + name: user + password: "{noop}password" + oauth2: + authorizationserver: + client: + client1: + require-proof-key: false + require-authorization-consent: false + registration: + client-name: "Client 1 - Issuer 1" + client-id: client1 + client-secret: "{noop}secret1" + client-authentication-methods: + - client_secret_basic + redirect-uris: + - http://localhost:9090/login/oauth2/code/issuer1client1 + authorization-grant-types: + - authorization_code + - refresh_token + scopes: + - openid + - email + - profile + - account + diff --git a/spring-security-modules/spring-security-auth-server/src/test/java/com/baeldung/auth/server/customclaims/CustomClaimsAuthServerUnitTest.java b/spring-security-modules/spring-security-auth-server/src/test/java/com/baeldung/auth/server/customclaims/CustomClaimsAuthServerUnitTest.java new file mode 100644 index 000000000000..aab9fd94a471 --- /dev/null +++ b/spring-security-modules/spring-security-auth-server/src/test/java/com/baeldung/auth/server/customclaims/CustomClaimsAuthServerUnitTest.java @@ -0,0 +1,194 @@ +package com.baeldung.auth.server.customclaims; + +import com.baeldung.auth.server.dynamicscopes.DynamicScopesAuthServerApplication; +import org.jsoup.Jsoup; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.client.RestTestClient; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.CookieManager; +import java.net.URI; +import java.net.http.HttpClient; +import java.util.Base64; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integrations tests for the {@link CustomClaimsAuthServerApplication} + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("customclaims") +class CustomClaimsAuthServerUnitTest { + + @LocalServerPort + int port; + + RestTestClient restTestClient, noRedirecRestTestClient; + + private static final String ACCEPT_HEADER_VALUE = "text/html"; + + + // Happy path integration test + @Test + void givenAuthorizationRequestWithValidCredentials_whenAuthorize_thenSuccess() { + + var response = restTestClient.get().uri("/.well-known/openid-configuration").exchange(); + + // sanity check + var result = response.returnResult(Map.class); + var config = result.getResponseBody(); + assertTrue(result.getStatus().is2xxSuccessful()); + assertNotNull(config); + assertTrue(config.containsKey("token_endpoint")); + assertNotNull(config.get("token_endpoint")); + assertTrue(config.containsKey("authorization_endpoint")); + assertNotNull(config.get("authorization_endpoint")); + + var authEndpoint = URI.create(config.get("authorization_endpoint").toString()); + var tokenEndpoint = config.get("token_endpoint").toString(); + + // Build auth request + var state = UUID.randomUUID().toString(); + var redirectUri = "http://localhost:9090/login/oauth2/code/issuer1client1"; + var authResponse = restTestClient.get() + .uri( b -> b.path(authEndpoint.getPath()) + .queryParam("response_type", "code") + .queryParam("client_id", "client1") + .queryParam("scope", String.join(" ","openid","email","profile","account")) + .queryParam("redirect_uri", redirectUri) + .queryParam("state", state) + .build()) + .header("Accept", ACCEPT_HEADER_VALUE) + .header("Cache-Control", "no-cache") + .exchange(); + var authResult = authResponse.returnResult(); + + assertEquals(HttpStatus.OK,authResult.getStatus()); + var loginPage = new String(authResult.getResponseBodyContent()); + var doc = Jsoup.parse(loginPage); + + // Extract the login form submit URI and the csrf token + var tokenElement = doc.expectForm(".login-form").select("input[name=_csrf]").first(); + assertNotNull(tokenElement); + var loginFormCsrfToken = tokenElement.val(); + assertNotNull(loginFormCsrfToken); + + // Extract the URI to submit the credentials + var loginUri = doc.expectForm(".login-form").attr("action"); + + // Submit the credentials + var loginBody = new LinkedMultiValueMap(); + loginBody.add("username", "user"); + loginBody.add("password", "password"); + loginBody.add("_csrf", loginFormCsrfToken); + + var loginResponse = noRedirecRestTestClient.post() + .uri(loginUri) + .header("Accept", ACCEPT_HEADER_VALUE) + .header("Cache-Control", "no-cache") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(loginBody) + .exchange(); + + var loginResult = loginResponse.returnResult(); + assertEquals(HttpStatus.FOUND,loginResult.getStatus()); + + // We expect a redirect to authorization endpoint with the same parameters as before. This + // time, the user is authenticated, and we should get redirect with the authorization code + authResponse = noRedirecRestTestClient.get() + .uri( loginResult.getResponseHeaders().getLocation()) + .header("Cache-Control", "no-cache") + .exchange(); + authResult = authResponse.returnResult(); + assertEquals(HttpStatus.FOUND, authResult.getStatus()); + assertNotNull( authResult.getResponseHeaders().getLocation()); + + var finalRedirect = UriComponentsBuilder.fromUri(authResult.getResponseHeaders().getLocation()).build(); + var expectedUri = UriComponentsBuilder.fromUriString(redirectUri).build(); + + assertEquals(expectedUri.getPath(), finalRedirect.getPath()); + assertTrue(finalRedirect.getQueryParams().containsKey("code")); + + // Build token request + var tokenRequestBody = new LinkedMultiValueMap(); + tokenRequestBody.add("grant_type", "authorization_code"); + tokenRequestBody.add("client_id", "client1"); + tokenRequestBody.add("redirect_uri", redirectUri); + tokenRequestBody.add("code", finalRedirect.getQueryParams().getFirst("code")); + + // Submit token request to the authorization server's token endpoint + var tokenResponse = restTestClient.post() + .uri(tokenEndpoint) + .header("Authorization", "Basic " + Base64.getEncoder().encodeToString("client1:secret1".getBytes())) + .body(tokenRequestBody) + .exchange(); + + var tokenResult = tokenResponse.returnResult(Map.class); + assertEquals(HttpStatus.OK, tokenResult.getStatus()); + var body = tokenResult.getResponseBody(); + + // We expect an access token, an id token and a refresh token + assertNotNull(body); + assertTrue(body.containsKey("access_token")); + assertTrue(body.containsKey("refresh_token")); + assertTrue(body.containsKey("scope")); + assertTrue(body.containsKey("id_token")); + + // Finally, let's use the access token to issue a get request to the userinfo endpoint + var userInfoResponse = restTestClient.get() + .uri(config.get("userinfo_endpoint").toString()) + .header("Authorization", "Bearer " + body.get("access_token")) + .exchange(); + var userInfoResult = userInfoResponse.returnResult(Map.class); + assertEquals(HttpStatus.OK, userInfoResult.getStatus()); + var userInfo = userInfoResult.getResponseBody(); + assertNotNull(userInfo); + assertTrue(userInfo.containsKey("sub")); + assertTrue(userInfo.containsKey("email")); + assertTrue(userInfo.containsKey("name")); + assertTrue(userInfo.containsKey("locale")); + assertTrue(userInfo.containsKey("account_id")); + + + } + + @BeforeEach + void setupRestClient() { + + CookieManager cookieManager = new CookieManager(); + + var followRedirectsHttpClient = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.ALWAYS) + .cookieHandler(cookieManager) + .build(); + + var followRedirectsRequestFactory = new JdkClientHttpRequestFactory(followRedirectsHttpClient); + restTestClient = RestTestClient + .bindToServer(followRedirectsRequestFactory) + .baseUrl("http://localhost:" + port) + .build(); + + var noRedirectsHttpClient = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NEVER) + .cookieHandler(cookieManager) + .build(); + + var noRedirectsRequestFactory = new JdkClientHttpRequestFactory(noRedirectsHttpClient); + noRedirecRestTestClient = RestTestClient + .bindToServer(noRedirectsRequestFactory) + .baseUrl("http://localhost:" + port) + .build(); + + } + +} \ No newline at end of file diff --git a/spring-security-modules/spring-security-auth-server/src/test/java/com/baeldung/auth/server/customclaims/DefaultUserinfoAuthServerUnitTest.java b/spring-security-modules/spring-security-auth-server/src/test/java/com/baeldung/auth/server/customclaims/DefaultUserinfoAuthServerUnitTest.java new file mode 100644 index 000000000000..7302d3684f55 --- /dev/null +++ b/spring-security-modules/spring-security-auth-server/src/test/java/com/baeldung/auth/server/customclaims/DefaultUserinfoAuthServerUnitTest.java @@ -0,0 +1,192 @@ +package com.baeldung.auth.server.customclaims; + +import org.jsoup.Jsoup; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.client.RestTestClient; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.CookieManager; +import java.net.URI; +import java.net.http.HttpClient; +import java.util.Base64; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integrations tests for the {@link CustomClaimsAuthServerApplication} + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "customuserinfo.enabled=false" + } +) +@ActiveProfiles("customclaims") +class DefaultUserinfoAuthServerUnitTest { + + @LocalServerPort + int port; + + RestTestClient restTestClient, noRedirecRestTestClient; + + private static final String ACCEPT_HEADER_VALUE = "text/html"; + + + // Happy path integration test + @Test + void whenAuthorizationRequestWithValidCredentials_thenSuccess() { + + var response = restTestClient.get().uri("/.well-known/openid-configuration").exchange(); + + // sanity check + var result = response.returnResult(Map.class); + var config = result.getResponseBody(); + assertTrue(result.getStatus().is2xxSuccessful()); + assertNotNull(config); + assertTrue(config.containsKey("token_endpoint")); + assertNotNull(config.get("token_endpoint")); + assertTrue(config.containsKey("authorization_endpoint")); + assertNotNull(config.get("authorization_endpoint")); + + var authEndpoint = URI.create(config.get("authorization_endpoint").toString()); + var tokenEndpoint = config.get("token_endpoint").toString(); + + // Build auth request + var state = UUID.randomUUID().toString(); + var redirectUri = "http://localhost:9090/login/oauth2/code/issuer1client1"; + var authResponse = restTestClient.get() + .uri( b -> b.path(authEndpoint.getPath()) + .queryParam("response_type", "code") + .queryParam("client_id", "client1") + .queryParam("scope", String.join(" ","openid","email","profile")) + .queryParam("redirect_uri", redirectUri) + .queryParam("state", state) + .build()) + .header("Accept", ACCEPT_HEADER_VALUE) + .header("Cache-Control", "no-cache") + .exchange(); + var authResult = authResponse.returnResult(); + + assertEquals(HttpStatus.OK,authResult.getStatus()); + var loginPage = new String(authResult.getResponseBodyContent()); + var doc = Jsoup.parse(loginPage); + + // Extract the login form submit URI and the csrf token + var tokenElement = doc.expectForm(".login-form").select("input[name=_csrf]").first(); + assertNotNull(tokenElement); + var loginFormCsrfToken = tokenElement.val(); + assertNotNull(loginFormCsrfToken); + + // Extract the URI to submit the credentials + var loginUri = doc.expectForm(".login-form").attr("action"); + + // Submit the credentials + var loginBody = new LinkedMultiValueMap(); + loginBody.add("username", "user"); + loginBody.add("password", "password"); + loginBody.add("_csrf", loginFormCsrfToken); + + var loginResponse = noRedirecRestTestClient.post() + .uri(loginUri) + .header("Accept", ACCEPT_HEADER_VALUE) + .header("Cache-Control", "no-cache") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(loginBody) + .exchange(); + + var loginResult = loginResponse.returnResult(); + assertEquals(HttpStatus.FOUND,loginResult.getStatus()); + + // We expect a redirect to authorization endpoint with the same parameters as before. This + // time, the user is authenticated, and we should get redirect with the authorization code + authResponse = noRedirecRestTestClient.get() + .uri( loginResult.getResponseHeaders().getLocation()) + .header("Cache-Control", "no-cache") + .exchange(); + authResult = authResponse.returnResult(); + assertEquals(HttpStatus.FOUND, authResult.getStatus()); + assertNotNull( authResult.getResponseHeaders().getLocation()); + + var finalRedirect = UriComponentsBuilder.fromUri(authResult.getResponseHeaders().getLocation()).build(); + var expectedUri = UriComponentsBuilder.fromUriString(redirectUri).build(); + + assertEquals(expectedUri.getPath(), finalRedirect.getPath()); + assertTrue(finalRedirect.getQueryParams().containsKey("code")); + + // Build token request + var tokenRequestBody = new LinkedMultiValueMap(); + tokenRequestBody.add("grant_type", "authorization_code"); + tokenRequestBody.add("client_id", "client1"); + tokenRequestBody.add("redirect_uri", redirectUri); + tokenRequestBody.add("code", finalRedirect.getQueryParams().getFirst("code")); + + // Submit token request to the authorization server's token endpoint + var tokenResponse = restTestClient.post() + .uri(tokenEndpoint) + .header("Authorization", "Basic " + Base64.getEncoder().encodeToString("client1:secret1".getBytes())) + .body(tokenRequestBody) + .exchange(); + + var tokenResult = tokenResponse.returnResult(Map.class); + assertEquals(HttpStatus.OK, tokenResult.getStatus()); + var body = tokenResult.getResponseBody(); + + // We expect an access token, an id token and a refresh token + assertNotNull(body); + assertTrue(body.containsKey("access_token")); + assertTrue(body.containsKey("refresh_token")); + assertTrue(body.containsKey("scope")); + assertTrue(body.containsKey("id_token")); + + // Finally, let's use the access token to issue a get request to the userinfo endpoint + var userInfoResponse = restTestClient.get() + .uri(config.get("userinfo_endpoint").toString()) + .header("Authorization", "Bearer " + body.get("access_token")) + .exchange(); + var userInfoResult = userInfoResponse.returnResult(Map.class); + assertEquals(HttpStatus.OK, userInfoResult.getStatus()); + var userInfo = userInfoResult.getResponseBody(); + assertNotNull(userInfo); + + } + + @BeforeEach + void setupRestClient() { + + CookieManager cookieManager = new CookieManager(); + + var followRedirectsHttpClient = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.ALWAYS) + .cookieHandler(cookieManager) + .build(); + + var followRedirectsRequestFactory = new JdkClientHttpRequestFactory(followRedirectsHttpClient); + restTestClient = RestTestClient + .bindToServer(followRedirectsRequestFactory) + .baseUrl("http://localhost:" + port) + .build(); + + var noRedirectsHttpClient = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NEVER) + .cookieHandler(cookieManager) + .build(); + + var noRedirectsRequestFactory = new JdkClientHttpRequestFactory(noRedirectsHttpClient); + noRedirecRestTestClient = RestTestClient + .bindToServer(noRedirectsRequestFactory) + .baseUrl("http://localhost:" + port) + .build(); + + } + +} \ No newline at end of file diff --git a/spring-security-modules/spring-security-auth-server/src/test/java/com/baeldung/auth/server/customclaims/components/UserInfoMapperTest.java b/spring-security-modules/spring-security-auth-server/src/test/java/com/baeldung/auth/server/customclaims/components/UserInfoMapperTest.java new file mode 100644 index 000000000000..e8015a958eef --- /dev/null +++ b/spring-security-modules/spring-security-auth-server/src/test/java/com/baeldung/auth/server/customclaims/components/UserInfoMapperTest.java @@ -0,0 +1,122 @@ +package com.baeldung.auth.server.customclaims.components; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcUserInfoAuthenticationContext; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class UserInfoMapperTest { + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private UserInfoService userInfoService; + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private OidcUserInfoAuthenticationContext context; + + @InjectMocks + private UserInfoMapper userInfoMapper; + + @Test + void givenContextWithUnknownUser_whenApply_thenReturnUserInfoWithOnlySubject() { + + when(userInfoService.getUserInfoByUsername("unknown_user")).thenReturn(Optional.empty()); + when(context.getAuthentication().getName()).thenReturn("unknown_user"); + + var result = userInfoMapper.apply(context); + + assertNotNull(result); + assertEquals("unknown_user", result.getSubject()); + } + + @Test + void givenContextWithKnownUserAndNoScopes_whenApply_thenReturnUserInfoWithOnlySubject() { + + when(userInfoService.getUserInfoByUsername("user")).thenReturn(Optional.of(ALICE)); + when(context.getAuthentication() + .getName()).thenReturn("user"); + when(context.getAuthorization() + .getAuthorizedScopes()).thenReturn(Set.of()); + + var result = userInfoMapper.apply(context); + assertNotNull(result); + assertTrue(result.getClaims().containsKey("sub")); + assertEquals("user", result.getSubject()); + assertEquals(1, result.getClaims().size()); + + } + + @Test + void givenContextWithKnownUserAndStandardScopes_whenApply_thenReturnUserInfoWithOnlyProfileClaims() { + + when(userInfoService.getUserInfoByUsername("user")).thenReturn(Optional.of(ALICE)); + when(context.getAuthentication() + .getName()).thenReturn("user"); + when(context.getAuthorization() + .getAuthorizedScopes()).thenReturn(Set.of("openid","profile","email")); + + var result = userInfoMapper.apply(context); + assertNotNull(result); + var claims = result.getClaims(); + + var expectedClaims = Set.of( + "sub", "name", "given_name", "family_name","email","email_verified","locale", + "gender","birthdate","zoneinfo","preferred_username", "updated_at"); + assertTrue(claims.keySet().containsAll(expectedClaims)); + + // Ensure that account claims are *not* present + assertFalse(claims.containsKey("account")); + + } + + @Test + void givenContextWithKnownUserAndAccountScope_whenApply_thenReturnUserInfoWithAccountClaims() { + + when(userInfoService.getUserInfoByUsername("user")).thenReturn(Optional.of(ALICE)); + when(context.getAuthentication() + .getName()).thenReturn("user"); + when(context.getAuthorization() + .getAuthorizedScopes()).thenReturn(Set.of("account")); + + var result = userInfoMapper.apply(context); + assertNotNull(result); + var claims = result.getClaims(); + + var expectedClaims = Set.of("account_id", "created_at", "account_expires_at"); + assertTrue(claims.keySet().containsAll(expectedClaims)); + + // Ensure that email and name claims are *not *present + assertFalse(claims.containsKey("email")); + assertFalse(claims.containsKey("name")); + + } + + // Sample user for testing + private static final UserInfoService.UserInfo ALICE = new UserInfoService.UserInfo( + "user", "Alice Smith", "Alice", "Smith", + "user@example.com", true, + Locale.forLanguageTag("en-US"), + "female", + LocalDate.of(1990, 1, 1), + ZoneId.of("America/New_York"), + UUID.randomUUID(), + Instant.now(), + Instant.now(), + Instant.now().plus(60, ChronoUnit.DAYS)); + +} \ No newline at end of file diff --git a/spring-security-modules/spring-security-auth-server/src/test/java/com/baeldung/auth/server/dynamicscopes/TestOAuthClient.java b/spring-security-modules/spring-security-auth-server/src/test/java/com/baeldung/auth/server/dynamicscopes/TestOAuthClient.java deleted file mode 100644 index 2b1aa6f85192..000000000000 --- a/spring-security-modules/spring-security-auth-server/src/test/java/com/baeldung/auth/server/dynamicscopes/TestOAuthClient.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.baeldung.auth.server.dynamicscopes; - -// Simple http server that acts as an OAuth client for tests -class TestOAuthClient implements Runnable { - - - TestOAuthClient() { - - } - - @Override - public void run() { - - } - - /** - * Starts the http server and returns the port it is listening on - * @return - */ - static int startServer() { - - - return 0; - } -}