From 917100695236561feaf010462f91797d91262b73 Mon Sep 17 00:00:00 2001 From: Steve Riesenberg Date: Mon, 11 Apr 2022 17:36:48 -0500 Subject: [PATCH] Add "How-to: Customize the OpenID Connect 1.0 UserInfo response" Closes gh-537 --- .../EnableUserInfoSecurityConfig.java | 166 +++++++++++++++ .../idtoken/IdTokenCustomizerConfig.java | 43 ++++ .../userinfo/idtoken/OidcUserInfoService.java | 77 +++++++ .../jwt/JwtTokenCustomizerConfig.java | 41 ++++ .../jwt/JwtUserInfoMapperSecurityConfig.java | 190 ++++++++++++++++++ .../sample/AuthorizationCodeGrantFlow.java | 171 ++++++++++++++++ .../EnableUserInfoSecurityConfigTests.java | 189 +++++++++++++++++ .../docs/asciidoc/guides/how-to-userinfo.adoc | 89 ++++++++ docs/src/docs/asciidoc/how-to.adoc | 2 +- 9 files changed, 967 insertions(+), 1 deletion(-) create mode 100644 docs/src/docs/asciidoc/examples/src/main/java/sample/userinfo/EnableUserInfoSecurityConfig.java create mode 100644 docs/src/docs/asciidoc/examples/src/main/java/sample/userinfo/idtoken/IdTokenCustomizerConfig.java create mode 100644 docs/src/docs/asciidoc/examples/src/main/java/sample/userinfo/idtoken/OidcUserInfoService.java create mode 100644 docs/src/docs/asciidoc/examples/src/main/java/sample/userinfo/jwt/JwtTokenCustomizerConfig.java create mode 100644 docs/src/docs/asciidoc/examples/src/main/java/sample/userinfo/jwt/JwtUserInfoMapperSecurityConfig.java create mode 100644 docs/src/docs/asciidoc/examples/src/test/java/sample/AuthorizationCodeGrantFlow.java create mode 100644 docs/src/docs/asciidoc/examples/src/test/java/sample/userinfo/EnableUserInfoSecurityConfigTests.java create mode 100644 docs/src/docs/asciidoc/guides/how-to-userinfo.adoc diff --git a/docs/src/docs/asciidoc/examples/src/main/java/sample/userinfo/EnableUserInfoSecurityConfig.java b/docs/src/docs/asciidoc/examples/src/main/java/sample/userinfo/EnableUserInfoSecurityConfig.java new file mode 100644 index 00000000..eeccba90 --- /dev/null +++ b/docs/src/docs/asciidoc/examples/src/main/java/sample/userinfo/EnableUserInfoSecurityConfig.java @@ -0,0 +1,166 @@ +/* + * Copyright 2020-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.userinfo; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.util.UUID; + +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration; +import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.oidc.OidcScopes; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.config.ClientSettings; +import org.springframework.security.oauth2.server.authorization.config.ProviderSettings; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; + +@Configuration +public class EnableUserInfoSecurityConfig { + + @Bean // <1> + @Order(1) + public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { + OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); + // @formatter:off + http + .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt) // <2> + .exceptionHandling((exceptions) -> exceptions + .authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")) + ); + // @formatter:on + + return http.build(); + } + + // @fold:on + @Bean + @Order(2) + public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize + .anyRequest().authenticated() + ) + .formLogin(Customizer.withDefaults()); + // @formatter:on + + return http.build(); + } + // @fold:off + + @Bean // <3> + public JwtDecoder jwtDecoder(JWKSource jwkSource) { + return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource); + } + + // @fold:on + @Bean + public UserDetailsService userDetailsService() { + // @formatter:off + UserDetails userDetails = User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .roles("USER") + .build(); + // @formatter:on + + return new InMemoryUserDetailsManager(userDetails); + } + + @Bean + public RegisteredClientRepository registeredClientRepository() { + // @formatter:off + RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString()) + .clientId("messaging-client") + .clientSecret("{noop}secret") + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc") + .redirectUri("http://127.0.0.1:8080/authorized") + .scope(OidcScopes.OPENID) + .scope(OidcScopes.ADDRESS) + .scope(OidcScopes.EMAIL) + .scope(OidcScopes.PHONE) + .scope(OidcScopes.PROFILE) + .scope("message.read") + .scope("message.write") + .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build()) + .build(); + // @formatter:on + + return new InMemoryRegisteredClientRepository(registeredClient); + } + + @Bean + public JWKSource jwkSource() { + KeyPair keyPair = generateRsaKey(); + RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); + RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); + // @formatter:off + RSAKey rsaKey = new RSAKey.Builder(publicKey) + .privateKey(privateKey) + .keyID(UUID.randomUUID().toString()) + .build(); + // @formatter:on + JWKSet jwkSet = new JWKSet(rsaKey); + return new ImmutableJWKSet<>(jwkSet); + } + + private static KeyPair generateRsaKey() { // <6> + KeyPair keyPair; + try { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + keyPair = keyPairGenerator.generateKeyPair(); + } + catch (Exception ex) { + throw new IllegalStateException(ex); + } + return keyPair; + } + + @Bean + public ProviderSettings providerSettings() { + return ProviderSettings.builder().issuer("http://localhost:9000").build(); + } + // @fold:off + +} diff --git a/docs/src/docs/asciidoc/examples/src/main/java/sample/userinfo/idtoken/IdTokenCustomizerConfig.java b/docs/src/docs/asciidoc/examples/src/main/java/sample/userinfo/idtoken/IdTokenCustomizerConfig.java new file mode 100644 index 00000000..560676c8 --- /dev/null +++ b/docs/src/docs/asciidoc/examples/src/main/java/sample/userinfo/idtoken/IdTokenCustomizerConfig.java @@ -0,0 +1,43 @@ +/* + * Copyright 2020-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.userinfo.idtoken; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; +import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames; +import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer; + +@Configuration +public class IdTokenCustomizerConfig { + + // @formatter:off + @Bean // <1> + public OAuth2TokenCustomizer tokenCustomizer( + OidcUserInfoService userInfoService) { + return (context) -> { + if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) { + OidcUserInfo userInfo = userInfoService.loadUser( // <2> + context.getPrincipal().getName()); + context.getClaims().claims(claims -> + claims.putAll(userInfo.getClaims())); + } + }; + } + // @formatter:on + +} diff --git a/docs/src/docs/asciidoc/examples/src/main/java/sample/userinfo/idtoken/OidcUserInfoService.java b/docs/src/docs/asciidoc/examples/src/main/java/sample/userinfo/idtoken/OidcUserInfoService.java new file mode 100644 index 00000000..0ae3f0da --- /dev/null +++ b/docs/src/docs/asciidoc/examples/src/main/java/sample/userinfo/idtoken/OidcUserInfoService.java @@ -0,0 +1,77 @@ +/* + * Copyright 2020-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.userinfo.idtoken; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; +import org.springframework.stereotype.Service; + +/** + * Example service to perform lookup of user info for customizing an {@code id_token}. + */ +@Service +public class OidcUserInfoService { + + private final UserInfoRepository userInfoRepository = new UserInfoRepository(); + + public OidcUserInfo loadUser(String username) { + return new OidcUserInfo(this.userInfoRepository.findByUsername(username)); + } + + static class UserInfoRepository { + + private final Map> userInfo = new HashMap<>(); + + public UserInfoRepository() { + this.userInfo.put("user1", createUser("user1")); + this.userInfo.put("user2", createUser("user2")); + } + + public Map findByUsername(String username) { + return this.userInfo.get(username); + } + + private static Map createUser(String username) { + return OidcUserInfo.builder() + .subject(username) + .name("First Last") + .givenName("First") + .familyName("Last") + .middleName("Middle") + .nickname("User") + .preferredUsername(username) + .profile("https://example.com/" + username) + .picture("https://example.com/" + username + ".jpg") + .website("https://example.com") + .email(username + "@example.com") + .emailVerified(true) + .gender("female") + .birthdate("1970-01-01") + .zoneinfo("Europe/Paris") + .locale("en-US") + .phoneNumber("+1 (604) 555-1234;ext=5678") + .phoneNumberVerified("false") + .claim("address", Collections.singletonMap("formatted", "Champ de Mars\n5 Av. Anatole France\n75007 Paris\nFrance")) + .updatedAt("1970-01-01T00:00:00Z") + .build() + .getClaims(); + } + } + +} diff --git a/docs/src/docs/asciidoc/examples/src/main/java/sample/userinfo/jwt/JwtTokenCustomizerConfig.java b/docs/src/docs/asciidoc/examples/src/main/java/sample/userinfo/jwt/JwtTokenCustomizerConfig.java new file mode 100644 index 00000000..f4755555 --- /dev/null +++ b/docs/src/docs/asciidoc/examples/src/main/java/sample/userinfo/jwt/JwtTokenCustomizerConfig.java @@ -0,0 +1,41 @@ +/* + * Copyright 2020-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.userinfo.jwt; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.core.OAuth2TokenType; +import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer; + +@Configuration +public class JwtTokenCustomizerConfig { + + // @formatter:off + @Bean + public OAuth2TokenCustomizer tokenCustomizer() { + return (context) -> { + if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) { + context.getClaims().claims((claims) -> { + claims.put("claim-1", "value-1"); + claims.put("claim-2", "value-2"); + }); + } + }; + } + // @formatter:on + +} diff --git a/docs/src/docs/asciidoc/examples/src/main/java/sample/userinfo/jwt/JwtUserInfoMapperSecurityConfig.java b/docs/src/docs/asciidoc/examples/src/main/java/sample/userinfo/jwt/JwtUserInfoMapperSecurityConfig.java new file mode 100644 index 00000000..c6a341e7 --- /dev/null +++ b/docs/src/docs/asciidoc/examples/src/main/java/sample/userinfo/jwt/JwtUserInfoMapperSecurityConfig.java @@ -0,0 +1,190 @@ +/* + * Copyright 2020-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.userinfo.jwt; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.util.UUID; +import java.util.function.Function; + +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration; +import org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization.OAuth2AuthorizationServerConfigurer; +import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.oidc.OidcScopes; +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.config.ClientSettings; +import org.springframework.security.oauth2.server.authorization.config.ProviderSettings; +import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcUserInfoAuthenticationContext; +import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcUserInfoAuthenticationToken; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; +import org.springframework.security.web.util.matcher.RequestMatcher; + +@Configuration +public class JwtUserInfoMapperSecurityConfig { + + @Bean // <1> + @Order(1) + public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { + OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = + new OAuth2AuthorizationServerConfigurer<>(); + RequestMatcher endpointsMatcher = authorizationServerConfigurer + .getEndpointsMatcher(); + + Function userInfoMapper = (context) -> { // <2> + OidcUserInfoAuthenticationToken authentication = context.getAuthentication(); + JwtAuthenticationToken principal = (JwtAuthenticationToken) authentication.getPrincipal(); + + return new OidcUserInfo(principal.getToken().getClaims()); + }; + + // @formatter:off + authorizationServerConfigurer + .oidc((oidc) -> oidc + .userInfoEndpoint((userInfo) -> userInfo + .userInfoMapper(userInfoMapper) // <3> + ) + ); + http + .requestMatcher(endpointsMatcher) + .authorizeRequests((authorize) -> authorize + .anyRequest().authenticated() + ) + .csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher)) + .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt) // <4> + .exceptionHandling((exceptions) -> exceptions + .authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")) + ) + .apply(authorizationServerConfigurer); // <5> + // @formatter:on + + return http.build(); + } + + // @fold:on + @Bean + @Order(2) + public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize + .anyRequest().authenticated() + ) + .formLogin(Customizer.withDefaults()); + // @formatter:on + + return http.build(); + } + + @Bean // <3> + public JwtDecoder jwtDecoder(JWKSource jwkSource) { + return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource); + } + + @Bean + public UserDetailsService userDetailsService() { + // @formatter:off + UserDetails userDetails = User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .roles("USER") + .build(); + // @formatter:on + + return new InMemoryUserDetailsManager(userDetails); + } + + @Bean + public RegisteredClientRepository registeredClientRepository() { + // @formatter:off + RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString()) + .clientId("messaging-client") + .clientSecret("{noop}secret") + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc") + .redirectUri("http://127.0.0.1:8080/authorized") + .scope(OidcScopes.OPENID) + .scope("message.read") + .scope("message.write") + .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build()) + .build(); + // @formatter:on + + return new InMemoryRegisteredClientRepository(registeredClient); + } + + @Bean + public JWKSource jwkSource() { + KeyPair keyPair = generateRsaKey(); + RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); + RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); + // @formatter:off + RSAKey rsaKey = new RSAKey.Builder(publicKey) + .privateKey(privateKey) + .keyID(UUID.randomUUID().toString()) + .build(); + // @formatter:on + JWKSet jwkSet = new JWKSet(rsaKey); + return new ImmutableJWKSet<>(jwkSet); + } + + private static KeyPair generateRsaKey() { // <6> + KeyPair keyPair; + try { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + keyPair = keyPairGenerator.generateKeyPair(); + } + catch (Exception ex) { + throw new IllegalStateException(ex); + } + return keyPair; + } + + @Bean + public ProviderSettings providerSettings() { + return ProviderSettings.builder().issuer("http://localhost:9000").build(); + } + // @fold:off + +} diff --git a/docs/src/docs/asciidoc/examples/src/test/java/sample/AuthorizationCodeGrantFlow.java b/docs/src/docs/asciidoc/examples/src/test/java/sample/AuthorizationCodeGrantFlow.java new file mode 100644 index 00000000..7339946e --- /dev/null +++ b/docs/src/docs/asciidoc/examples/src/test/java/sample/AuthorizationCodeGrantFlow.java @@ -0,0 +1,171 @@ +/* + * Copyright 2020-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +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.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Helper class that performs steps of the {@code authorization_code} flow using + * {@link MockMvc} for testing. + * + * @author Steve Riesenberg + */ +public class AuthorizationCodeGrantFlow { + private static final Pattern HIDDEN_STATE_INPUT_PATTERN = Pattern.compile(".+.+"); + private static final TypeReference> TOKEN_RESPONSE_TYPE_REFERENCE = new TypeReference>() { + }; + + private final MockMvc mockMvc; + + private String username = "user"; + + private Set scopes = new HashSet<>(); + + public AuthorizationCodeGrantFlow(MockMvc mockMvc) { + this.mockMvc = mockMvc; + } + + public void setUsername(String username) { + this.username = username; + } + + public void addScope(String scope) { + this.scopes.add(scope); + } + + /** + * Perform the authorization request and obtain a state parameter. + * + * @param registeredClient The registered client + * @return The state parameter for submitting consent for authorization + */ + public String authorize(RegisteredClient registeredClient) throws Exception { + MultiValueMap parameters = new LinkedMultiValueMap<>(); + parameters.set(OAuth2ParameterNames.RESPONSE_TYPE, OAuth2AuthorizationResponseType.CODE.getValue()); + parameters.set(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId()); + parameters.set(OAuth2ParameterNames.REDIRECT_URI, registeredClient.getRedirectUris().iterator().next()); + parameters.set(OAuth2ParameterNames.SCOPE, + StringUtils.collectionToDelimitedString(registeredClient.getScopes(), " ")); + parameters.set(OAuth2ParameterNames.STATE, "state"); + + MvcResult mvcResult = this.mockMvc.perform(get("/oauth2/authorize") + .params(parameters) + .with(user(this.username).roles("USER"))) + .andExpect(status().isOk()) + .andExpect(header().string("content-type", containsString(MediaType.TEXT_HTML_VALUE))) + .andReturn(); + String responseHtml = mvcResult.getResponse().getContentAsString(); + Matcher matcher = HIDDEN_STATE_INPUT_PATTERN.matcher(responseHtml); + + return matcher.matches() ? matcher.group(1) : null; + } + + /** + * Submit consent for the authorization request and obtain an authorization code. + * + * @param registeredClient The registered client + * @param state The state paramter from the authorization request + * @return An authorization code + */ + public String submitConsent(RegisteredClient registeredClient, String state) throws Exception { + MultiValueMap parameters = new LinkedMultiValueMap<>(); + parameters.set(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId()); + parameters.set(OAuth2ParameterNames.STATE, state); + for (String scope : scopes) { + parameters.add(OAuth2ParameterNames.SCOPE, scope); + } + + MvcResult mvcResult = this.mockMvc.perform(post("/oauth2/authorize") + .params(parameters) + .with(user(this.username).roles("USER"))) + .andExpect(status().is3xxRedirection()) + .andReturn(); + String redirectedUrl = mvcResult.getResponse().getRedirectedUrl(); + assertThat(redirectedUrl).isNotNull(); + assertThat(redirectedUrl).matches("http://127.0.0.1:8080/authorized\\?code=.{15,}&state=state"); + + String locationHeader = URLDecoder.decode(redirectedUrl, StandardCharsets.UTF_8.name()); + UriComponents uriComponents = UriComponentsBuilder.fromUriString(locationHeader).build(); + + return uriComponents.getQueryParams().getFirst("code"); + } + + /** + * Exchange an authorization code for an access token. + * + * @param registeredClient The registered client + * @param authorizationCode The authorization code obtained from the authorization request + * @return The token response + */ + public Map getTokenResponse(RegisteredClient registeredClient, String authorizationCode) throws Exception { + MultiValueMap parameters = new LinkedMultiValueMap<>(); + parameters.set(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.AUTHORIZATION_CODE.getValue()); + parameters.set(OAuth2ParameterNames.CODE, authorizationCode); + parameters.set(OAuth2ParameterNames.REDIRECT_URI, registeredClient.getRedirectUris().iterator().next()); + + HttpHeaders basicAuth = new HttpHeaders(); + basicAuth.setBasicAuth(registeredClient.getClientId(), "secret"); + + MvcResult mvcResult = this.mockMvc.perform(post("/oauth2/token") + .params(parameters) + .headers(basicAuth)) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CONTENT_TYPE, containsString(MediaType.APPLICATION_JSON_VALUE))) + .andExpect(jsonPath("$.access_token").isNotEmpty()) + .andExpect(jsonPath("$.token_type").isNotEmpty()) + .andExpect(jsonPath("$.expires_in").isNotEmpty()) + .andExpect(jsonPath("$.refresh_token").isNotEmpty()) + .andExpect(jsonPath("$.scope").isNotEmpty()) + .andExpect(jsonPath("$.id_token").isNotEmpty()) + .andReturn(); + + ObjectMapper objectMapper = new ObjectMapper(); + String responseJson = mvcResult.getResponse().getContentAsString(); + return objectMapper.readValue(responseJson, TOKEN_RESPONSE_TYPE_REFERENCE); + } +} diff --git a/docs/src/docs/asciidoc/examples/src/test/java/sample/userinfo/EnableUserInfoSecurityConfigTests.java b/docs/src/docs/asciidoc/examples/src/test/java/sample/userinfo/EnableUserInfoSecurityConfigTests.java new file mode 100644 index 00000000..ea435466 --- /dev/null +++ b/docs/src/docs/asciidoc/examples/src/test/java/sample/userinfo/EnableUserInfoSecurityConfigTests.java @@ -0,0 +1,189 @@ +/* + * Copyright 2020-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.userinfo; + +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import sample.AuthorizationCodeGrantFlow; +import sample.test.SpringTestContext; +import sample.test.SpringTestContextExtension; +import sample.userinfo.idtoken.IdTokenCustomizerConfig; +import sample.userinfo.idtoken.OidcUserInfoService; +import sample.userinfo.jwt.JwtTokenCustomizerConfig; +import sample.userinfo.jwt.JwtUserInfoMapperSecurityConfig; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.oidc.OidcScopes; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests for the guide How-to: Customize the OpenID Connect 1.0 UserInfo response. + * + * @author Steve Riesenberg + */ +@ExtendWith(SpringTestContextExtension.class) +public class EnableUserInfoSecurityConfigTests { + public final SpringTestContext spring = new SpringTestContext(this); + + @Autowired + private MockMvc mockMvc; + + @Autowired + private RegisteredClientRepository registeredClientRepository; + + @Test + public void userInfoWhenEnabledThenSuccess() throws Exception { + this.spring.register(AuthorizationServerConfig.class).autowire(); + + RegisteredClient registeredClient = this.registeredClientRepository.findByClientId("messaging-client"); + assertThat(registeredClient).isNotNull(); + + AuthorizationCodeGrantFlow authorizationCodeGrantFlow = new AuthorizationCodeGrantFlow(this.mockMvc); + authorizationCodeGrantFlow.setUsername("user1"); + authorizationCodeGrantFlow.addScope("message.read"); + authorizationCodeGrantFlow.addScope("message.write"); + + String state = authorizationCodeGrantFlow.authorize(registeredClient); + String authorizationCode = authorizationCodeGrantFlow.submitConsent(registeredClient, state); + Map tokenResponse = authorizationCodeGrantFlow.getTokenResponse(registeredClient, authorizationCode); + String accessToken = (String) tokenResponse.get(OAuth2ParameterNames.ACCESS_TOKEN); + + this.mockMvc.perform(get("/userinfo") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CONTENT_TYPE, equalTo(MediaType.APPLICATION_JSON_VALUE))) + .andExpect(jsonPath("sub").value("user1")); + } + + @Test + public void userInfoWhenIdTokenCustomizerThenIdTokenClaimsMappedToResponse() throws Exception { + this.spring.register(AuthorizationServerConfigWithIdTokenCustomizer.class).autowire(); + + RegisteredClient registeredClient = this.registeredClientRepository.findByClientId("messaging-client"); + assertThat(registeredClient).isNotNull(); + + AuthorizationCodeGrantFlow authorizationCodeGrantFlow = new AuthorizationCodeGrantFlow(this.mockMvc); + authorizationCodeGrantFlow.setUsername("user1"); + authorizationCodeGrantFlow.addScope(OidcScopes.ADDRESS); + authorizationCodeGrantFlow.addScope(OidcScopes.EMAIL); + authorizationCodeGrantFlow.addScope(OidcScopes.PHONE); + authorizationCodeGrantFlow.addScope(OidcScopes.PROFILE); + + String state = authorizationCodeGrantFlow.authorize(registeredClient); + String authorizationCode = authorizationCodeGrantFlow.submitConsent(registeredClient, state); + Map tokenResponse = authorizationCodeGrantFlow.getTokenResponse(registeredClient, authorizationCode); + String accessToken = (String) tokenResponse.get(OAuth2ParameterNames.ACCESS_TOKEN); + + this.mockMvc.perform(get("/userinfo") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CONTENT_TYPE, equalTo(MediaType.APPLICATION_JSON_VALUE))) + .andExpectAll( + jsonPath("sub").value("user1"), + jsonPath("name").value("First Last"), + jsonPath("given_name").value("First"), + jsonPath("family_name").value("Last"), + jsonPath("middle_name").value("Middle"), + jsonPath("nickname").value("User"), + jsonPath("preferred_username").value("user1"), + jsonPath("profile").value("https://example.com/user1"), + jsonPath("picture").value("https://example.com/user1.jpg"), + jsonPath("website").value("https://example.com"), + jsonPath("email").value("user1@example.com"), + jsonPath("email_verified").value("true"), + jsonPath("gender").value("female"), + jsonPath("birthdate").value("1970-01-01"), + jsonPath("zoneinfo").value("Europe/Paris"), + jsonPath("locale").value("en-US"), + jsonPath("phone_number").value("+1 (604) 555-1234;ext=5678"), + jsonPath("phone_number_verified").value("false"), + jsonPath("address.formatted").value("Champ de Mars\n5 Av. Anatole France\n75007 Paris\nFrance"), + jsonPath("updated_at").value("1970-01-01T00:00:00Z") + ); + } + + @Test + public void userInfoWhenUserInfoMapperThenClaimsMappedToResponse() throws Exception { + this.spring.register(AuthorizationServerConfigWithJwtTokenCustomizer.class).autowire(); + + RegisteredClient registeredClient = this.registeredClientRepository.findByClientId("messaging-client"); + assertThat(registeredClient).isNotNull(); + + AuthorizationCodeGrantFlow authorizationCodeGrantFlow = new AuthorizationCodeGrantFlow(this.mockMvc); + authorizationCodeGrantFlow.setUsername("user1"); + authorizationCodeGrantFlow.addScope("message.read"); + authorizationCodeGrantFlow.addScope("message.write"); + + String state = authorizationCodeGrantFlow.authorize(registeredClient); + String authorizationCode = authorizationCodeGrantFlow.submitConsent(registeredClient, state); + Map tokenResponse = authorizationCodeGrantFlow.getTokenResponse(registeredClient, authorizationCode); + String accessToken = (String) tokenResponse.get(OAuth2ParameterNames.ACCESS_TOKEN); + + this.mockMvc.perform(get("/userinfo") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CONTENT_TYPE, equalTo(MediaType.APPLICATION_JSON_VALUE))) + .andExpectAll( + jsonPath("sub").value("user1"), + jsonPath("claim-1").value("value-1"), + jsonPath("claim-2").value("value-2") + ); + } + + @EnableWebSecurity + @EnableAutoConfiguration + @Import(EnableUserInfoSecurityConfig.class) + static class AuthorizationServerConfig { + + } + + @EnableWebSecurity + @Import({EnableUserInfoSecurityConfig.class, IdTokenCustomizerConfig.class}) + static class AuthorizationServerConfigWithIdTokenCustomizer { + + @Bean + public OidcUserInfoService userInfoService() { + return new OidcUserInfoService(); + } + + } + + @EnableWebSecurity + @EnableAutoConfiguration + @Import({JwtUserInfoMapperSecurityConfig.class, JwtTokenCustomizerConfig.class}) + static class AuthorizationServerConfigWithJwtTokenCustomizer { + + } + +} diff --git a/docs/src/docs/asciidoc/guides/how-to-userinfo.adoc b/docs/src/docs/asciidoc/guides/how-to-userinfo.adoc new file mode 100644 index 00000000..e255757b --- /dev/null +++ b/docs/src/docs/asciidoc/guides/how-to-userinfo.adoc @@ -0,0 +1,89 @@ +[[how-to-userinfo]] += How-to: Customize the OpenID Connect 1.0 UserInfo response +:index-link: ../how-to.html +:docs-dir: .. + +[[getting-started]] +== Getting Started + +This guide shows how to customize the xref:{docs-dir}/protocol-endpoints.adoc#oidc-user-info-endpoint[User Info endpoint] of the xref:{docs-dir}/index.adoc#top[Spring Authorization Server]. +The purpose of this guide is to demonstrate how to enable the endpoint and use the available customization options to produce a custom response. + +[[enable-user-info]] +== Enable the User Info Endpoint + +Before customizing the response, you need to enable the User Info endpoint. +In https://openid.net/specs/openid-connect-core-1_0.html#UserInfoRequest[Section 5.3.1], the OpenID Connect 1.0 Core specification states: + +> The Access Token obtained from an OpenID Connect Authentication Request MUST be sent as a Bearer Token, per Section 2 of https://openid.net/specs/openid-connect-core-1_0.html#RFC6750[OAuth 2.0 Bearer Token Usage] [RFC6750]. + +The User Info endpoint requires an authenticated request using the access token (which is a JWT when using the xref:{docs-dir}/getting-started.adoc#sample.gettingStarted[Getting Started config]). + +The following listing shows how to configure https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html[Resource Server support] and provide a `JwtDecoder` that can validate the access token to allow authenticated requests to the User Info endpoint. + +[[sample.userinfo]] +include::code:EnableUserInfoSecurityConfig[] + +TIP: Click on the "Expanded folded text" icon in the code sample above to display the full example. + +This configuration provides the following: + +<1> A Spring Security filter chain for the xref:{docs-dir}/protocol-endpoints.adoc[Protocol Endpoints]. +<2> Resource server support that allows User Info requests to be authenticated with access tokens. +<3> An instance of `JwtDecoder` used to validate access tokens. + +[[customize-user-info]] +== Customize the User Info response + +The following sections describe some options for customizing the user info response. + +[[customize-id-token]] +=== Customize the ID Token + +By default, the user info response is generated by using claims from the `id_token` that are returned with the xref:{docs-dir}/protocol-endpoints.adoc#oauth2-token-endpoint[token response]. +Using the default strategy, https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims[standard claims] are returned only with the user info response based on the https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims[requested scopes] during authorization. + +The preferred way to customize the user info response is to add standard claims to the `id_token`. +The following listing shows how add claims to the `id_token`. + +[[sample.userinfo.idtoken]] +include::code:IdTokenCustomizerConfig[] + +This configuration provides the following: + +<1> An instance of `OAuth2TokenCustomizer` for customizing the `id_token`. +<2> A custom service used to obtain user info in a domain-specific way. + +The following listing shows a custom service for looking up user info in a domain-specific way: + +include::code:OidcUserInfoService[] + +[[customize-user-info-mapper]] +=== Customize the User Info Mapper + +To fully customize the user info response, you can provide a custom user info mapper capable of generating the object used to render the response, which is an instance of the `OidcUserInfo` class from Spring Security. +The mapper implementation receives an instance of `OidcUserInfoAuthenticationContext` with information about the current request, including the xref:{docs-dir}/core-model-components.adoc#oauth2-authorization-service[`OAuth2Authorization`]. + +The following listing shows how to use the customization option that is available while working directly with the `OAuth2AuthorizationServerConfigurer`. + +[[sample.userinfo.jwt]] +include::code:JwtUserInfoMapperSecurityConfig[] + +This configuration maps claims from the access token (which is a JWT when using the xref:{docs-dir}/getting-started.adoc#sample.gettingStarted[Getting Started config]) to populate the user info response and provides the following: + +<1> A Spring Security filter chain for the xref:{docs-dir}/protocol-endpoints.adoc[Protocol Endpoints]. +<2> A user info mapper that maps claims in a domain-specific way. +<3> An example showing the configuration option for customizing the user info mapper. +<4> Resource server support that allows User Info requests to be authenticated with access tokens. +<5> An example showing how to apply the `OAuth2AuthorizationServerConfigurer` to the Spring Security configuration. + +The user info mapper is not limited to mapping claims from a JWT, but this is a simple example that demonstrates the customization option. +Similar to the <> where we customize claims of the ID token, you can customize claims of the access token itself ahead of time, as in the following example: + +include::code:JwtTokenCustomizerConfig[] + +Whether you customize the user info response directly or use this example and customize the access token, you can look up information in a database, perform an LDAP query, make a request to another service, or use any other means of obtaining the information you want to be presented in the user info response. + +== Conclusion + +In this guide, you have learned how to <> the xref:{docs-dir}/protocol-endpoints.adoc#oidc-user-info-endpoint[User Info endpoint] and explored various ways of customizing the response, including <> while continuing to use the built-in response and <> using a custom user info mapper. diff --git a/docs/src/docs/asciidoc/how-to.adoc b/docs/src/docs/asciidoc/how-to.adoc index cf0f065c..cfe8afbd 100644 --- a/docs/src/docs/asciidoc/how-to.adoc +++ b/docs/src/docs/asciidoc/how-to.adoc @@ -4,12 +4,12 @@ [[how-to-overview]] == Overview +* xref:guides/how-to-userinfo.adoc[Customize the OpenID Connect 1.0 UserInfo response] * xref:guides/how-to-jpa.adoc[Implement core services with JPA] [[how-to-coming-soon]] == Coming Soon -* Customize the OpenID Connect 1.0 UserInfo response (https://github.com/spring-projects/spring-authorization-server/issues/537[#537]) * Authenticate using social login, e.g. Google (https://github.com/spring-projects/spring-authorization-server/issues/538[#538]) * Authenticate a user in a Single Page Application with PKCE (https://github.com/spring-projects/spring-authorization-server/issues/539[#539]) * Deny access for a revoked JWT access token (https://github.com/spring-projects/spring-authorization-server/issues/543[#543])