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

Closes gh-537
This commit is contained in:
Steve Riesenberg
2022-04-11 17:36:48 -05:00
committed by Steve Riesenberg
parent ecda4e0a15
commit 9171006952
9 changed files with 967 additions and 1 deletions

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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();
}
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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);
}
}

View File

@@ -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 {
}
}

View File

@@ -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.

View File

@@ -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])