Browse Source

Add "How-to: Customize the OpenID Connect 1.0 UserInfo response"

Closes gh-537
Steve Riesenberg 3 năm trước cách đây
mục cha
commit
9171006952

+ 166 - 0
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<SecurityContext> 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<SecurityContext> 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
+
+}

+ 43 - 0
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<JwtEncodingContext> 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
+
+}

+ 77 - 0
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<String, Map<String, Object>> userInfo = new HashMap<>();
+
+		public UserInfoRepository() {
+			this.userInfo.put("user1", createUser("user1"));
+			this.userInfo.put("user2", createUser("user2"));
+		}
+
+		public Map<String, Object> findByUsername(String username) {
+			return this.userInfo.get(username);
+		}
+
+		private static Map<String, Object> 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();
+		}
+	}
+
+}

+ 41 - 0
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<JwtEncodingContext> 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
+
+}

+ 190 - 0
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<HttpSecurity> authorizationServerConfigurer =
+				new OAuth2AuthorizationServerConfigurer<>();
+		RequestMatcher endpointsMatcher = authorizationServerConfigurer
+				.getEndpointsMatcher();
+
+		Function<OidcUserInfoAuthenticationContext, OidcUserInfo> 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<SecurityContext> 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<SecurityContext> 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
+
+}

+ 171 - 0
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(".+<input type=\"hidden\" name=\"state\" value=\"([^\"]+)\">.+");
+	private static final TypeReference<Map<String, Object>> TOKEN_RESPONSE_TYPE_REFERENCE = new TypeReference<Map<String, Object>>() {
+	};
+
+	private final MockMvc mockMvc;
+
+	private String username = "user";
+
+	private Set<String> 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<String, String> 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<String, String> 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<String, Object> getTokenResponse(RegisteredClient registeredClient, String authorizationCode) throws Exception {
+		MultiValueMap<String, String> 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);
+	}
+}

+ 189 - 0
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<String, Object> 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<String, Object> 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<String, Object> 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 {
+
+	}
+
+}

+ 89 - 0
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 <<customize-id-token,example shown earlier>> 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 <<enable-user-info,enable>> the xref:{docs-dir}/protocol-endpoints.adoc#oidc-user-info-endpoint[User Info endpoint] and explored various ways of customizing the response, including <<customize-id-token,customizing the ID token>> while continuing to use the built-in response and <<customize-user-info-mapper,customizing the response directly>> using a custom user info mapper.

+ 1 - 1
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])