From 21d96328aada84f95f67dbe62101b6fa908bc6f9 Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Tue, 26 Nov 2019 12:24:58 -0700 Subject: [PATCH 1/4] Polish oauth2Login Sample Test Issue: gh-7618 --- .../OAuth2LoginApplicationTests.java | 51 +++++-------------- 1 file changed, 14 insertions(+), 37 deletions(-) rename samples/boot/oauth2login/src/integration-test/java/{org/springframework/security/samples => sample}/OAuth2LoginApplicationTests.java (93%) diff --git a/samples/boot/oauth2login/src/integration-test/java/org/springframework/security/samples/OAuth2LoginApplicationTests.java b/samples/boot/oauth2login/src/integration-test/java/sample/OAuth2LoginApplicationTests.java similarity index 93% rename from samples/boot/oauth2login/src/integration-test/java/org/springframework/security/samples/OAuth2LoginApplicationTests.java rename to samples/boot/oauth2login/src/integration-test/java/sample/OAuth2LoginApplicationTests.java index 067bed44317..89a0b26b6f3 100644 --- a/samples/boot/oauth2login/src/integration-test/java/org/springframework/security/samples/OAuth2LoginApplicationTests.java +++ b/samples/boot/oauth2login/src/integration-test/java/sample/OAuth2LoginApplicationTests.java @@ -13,7 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.security.samples; +package sample; + +import java.net.URI; +import java.net.URL; +import java.net.URLDecoder; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; import com.gargoylesoftware.htmlunit.FailingHttpStatusCodeException; import com.gargoylesoftware.htmlunit.WebClient; @@ -25,29 +36,22 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; + import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.SpringBootConfiguration; -import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.ComponentScan; import org.springframework.http.HttpStatus; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.oauth2.client.InMemoryOAuth2AuthorizedClientService; -import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; -import org.springframework.security.oauth2.client.web.AuthenticatedPrincipalOAuth2AuthorizedClientRepository; import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter; -import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; import org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter; import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; @@ -60,17 +64,6 @@ import org.springframework.web.util.UriComponents; import org.springframework.web.util.UriComponentsBuilder; -import java.net.URI; -import java.net.URL; -import java.net.URLDecoder; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; - import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -85,7 +78,7 @@ * @since 5.0 */ @RunWith(SpringRunner.class) -@SpringBootTest +@SpringBootTest(classes={ OAuth2LoginApplication.class, OAuth2LoginApplicationTests.SecurityTestConfig.class }) @AutoConfigureMockMvc public class OAuth2LoginApplicationTests { private static final String AUTHORIZATION_BASE_URI = "/oauth2/authorization"; @@ -405,20 +398,4 @@ private OAuth2UserService mockUserService() { return userService; } } - - @SpringBootConfiguration - @EnableAutoConfiguration - @ComponentScan(basePackages = "sample.web") - public static class SpringBootApplicationTestConfig { - - @Bean - public OAuth2AuthorizedClientService authorizedClientService(ClientRegistrationRepository clientRegistrationRepository) { - return new InMemoryOAuth2AuthorizedClientService(clientRegistrationRepository); - } - - @Bean - public OAuth2AuthorizedClientRepository authorizedClientRepository(OAuth2AuthorizedClientService authorizedClientService) { - return new AuthenticatedPrincipalOAuth2AuthorizedClientRepository(authorizedClientService); - } - } } From 98aad5b90656021c638b0aa70e149d90bf64b6e2 Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Mon, 4 Nov 2019 10:19:38 -0700 Subject: [PATCH 2/4] Add OidcIdToken.Builder Fixes gh-7592 --- .../oauth2/core/oidc/OidcIdToken.java | 219 +++++++++++++++++- .../core/oidc/OidcIdTokenBuilderTests.java | 139 +++++++++++ 2 files changed, 355 insertions(+), 3 deletions(-) create mode 100644 oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/oidc/OidcIdTokenBuilderTests.java diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/OidcIdToken.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/OidcIdToken.java index 6e1297862db..9cd2b3afeeb 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/OidcIdToken.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/OidcIdToken.java @@ -15,13 +15,29 @@ */ package org.springframework.security.oauth2.core.oidc; -import org.springframework.security.oauth2.core.AbstractOAuth2Token; -import org.springframework.util.Assert; - import java.time.Instant; +import java.util.Collection; import java.util.Collections; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; +import java.util.function.Consumer; + +import org.springframework.security.oauth2.core.AbstractOAuth2Token; +import org.springframework.util.Assert; + +import static org.springframework.security.oauth2.core.oidc.IdTokenClaimNames.ACR; +import static org.springframework.security.oauth2.core.oidc.IdTokenClaimNames.AMR; +import static org.springframework.security.oauth2.core.oidc.IdTokenClaimNames.AT_HASH; +import static org.springframework.security.oauth2.core.oidc.IdTokenClaimNames.AUD; +import static org.springframework.security.oauth2.core.oidc.IdTokenClaimNames.AUTH_TIME; +import static org.springframework.security.oauth2.core.oidc.IdTokenClaimNames.AZP; +import static org.springframework.security.oauth2.core.oidc.IdTokenClaimNames.C_HASH; +import static org.springframework.security.oauth2.core.oidc.IdTokenClaimNames.EXP; +import static org.springframework.security.oauth2.core.oidc.IdTokenClaimNames.IAT; +import static org.springframework.security.oauth2.core.oidc.IdTokenClaimNames.ISS; +import static org.springframework.security.oauth2.core.oidc.IdTokenClaimNames.NONCE; +import static org.springframework.security.oauth2.core.oidc.IdTokenClaimNames.SUB; /** * An implementation of an {@link AbstractOAuth2Token} representing an OpenID Connect Core 1.0 ID Token. @@ -59,4 +75,201 @@ public OidcIdToken(String tokenValue, Instant issuedAt, Instant expiresAt, Map getClaims() { return this.claims; } + + /** + * Create a {@link Builder} based on the given token value + * + * @param tokenValue the token value to use + * @return the {@link Builder} for further configuration + */ + public static Builder withTokenValue(String tokenValue) { + return new Builder(tokenValue); + } + + /** + * A builder for {@link OidcIdToken}s + * + * @since 5.3 + * @author Josh Cummings + */ + public static final class Builder { + private String tokenValue; + private final Map claims = new LinkedHashMap<>(); + + private Builder(String tokenValue) { + this.tokenValue = tokenValue; + } + + /** + * Use this token value in the resulting {@link OidcIdToken} + * + * @param tokenValue The token value to use + * @return the {@link Builder} for further configurations + */ + public Builder tokenValue(String tokenValue) { + this.tokenValue = tokenValue; + return this; + } + + /** + * Use this claim in the resulting {@link OidcIdToken} + * + * @param name The claim name + * @param value The claim value + * @return the {@link Builder} for further configurations + */ + public Builder claim(String name, Object value) { + this.claims.put(name, value); + return this; + } + + /** + * Provides access to every {@link #claim(String, Object)} + * declared so far with the possibility to add, replace, or remove. + * @param claimsConsumer the consumer + * @return the {@link Builder} for further configurations + */ + public Builder claims(Consumer> claimsConsumer) { + claimsConsumer.accept(this.claims); + return this; + } + + /** + * Use this access token hash in the resulting {@link OidcIdToken} + * + * @param accessTokenHash The access token hash to use + * @return the {@link Builder} for further configurations + */ + public Builder accessTokenHash(String accessTokenHash) { + return claim(AT_HASH, accessTokenHash); + } + + /** + * Use this audience in the resulting {@link OidcIdToken} + * + * @param audience The audience(s) to use + * @return the {@link Builder} for further configurations + */ + public Builder audience(Collection audience) { + return claim(AUD, audience); + } + + /** + * Use this authentication {@link Instant} in the resulting {@link OidcIdToken} + * + * @param authenticatedAt The authentication {@link Instant} to use + * @return the {@link Builder} for further configurations + */ + public Builder authTime(Instant authenticatedAt) { + return claim(AUTH_TIME, authenticatedAt); + } + + /** + * Use this authentication context class reference in the resulting {@link OidcIdToken} + * + * @param authenticationContextClass The authentication context class reference to use + * @return the {@link Builder} for further configurations + */ + public Builder authenticationContextClass(String authenticationContextClass) { + return claim(ACR, authenticationContextClass); + } + + /** + * Use these authentication methods in the resulting {@link OidcIdToken} + * + * @param authenticationMethods The authentication methods to use + * @return the {@link Builder} for further configurations + */ + public Builder authenticationMethods(List authenticationMethods) { + return claim(AMR, authenticationMethods); + } + + /** + * Use this authorization code hash in the resulting {@link OidcIdToken} + * + * @param authorizationCodeHash The authorization code hash to use + * @return the {@link Builder} for further configurations + */ + public Builder authorizationCodeHash(String authorizationCodeHash) { + return claim(C_HASH, authorizationCodeHash); + } + + /** + * Use this authorized party in the resulting {@link OidcIdToken} + * + * @param authorizedParty The authorized party to use + * @return the {@link Builder} for further configurations + */ + public Builder authorizedParty(String authorizedParty) { + return claim(AZP, authorizedParty); + } + + /** + * Use this expiration in the resulting {@link OidcIdToken} + * + * @param expiresAt The expiration to use + * @return the {@link Builder} for further configurations + */ + public Builder expiresAt(Instant expiresAt) { + return this.claim(EXP, expiresAt); + } + + /** + * Use this issued-at timestamp in the resulting {@link OidcIdToken} + * + * @param issuedAt The issued-at timestamp to use + * @return the {@link Builder} for further configurations + */ + public Builder issuedAt(Instant issuedAt) { + return this.claim(IAT, issuedAt); + } + + /** + * Use this issuer in the resulting {@link OidcIdToken} + * + * @param issuer The issuer to use + * @return the {@link Builder} for further configurations + */ + public Builder issuer(String issuer) { + return this.claim(ISS, issuer); + } + + /** + * Use this nonce in the resulting {@link OidcIdToken} + * + * @param nonce The nonce to use + * @return the {@link Builder} for further configurations + */ + public Builder nonce(String nonce) { + return this.claim(NONCE, nonce); + } + + /** + * Use this subject in the resulting {@link OidcIdToken} + * + * @param subject The subject to use + * @return the {@link Builder} for further configurations + */ + public Builder subject(String subject) { + return this.claim(SUB, subject); + } + + /** + * Build the {@link OidcIdToken} + * + * @return The constructed {@link OidcIdToken} + */ + public OidcIdToken build() { + Instant iat = toInstant(this.claims.get(IAT)); + Instant exp = toInstant(this.claims.get(EXP)); + return new OidcIdToken(this.tokenValue, iat, exp, this.claims); + } + + private Instant toInstant(Object timestamp) { + if (timestamp != null) { + Assert.isInstanceOf(Instant.class, timestamp, "timestamps must be of type Instant"); + } + return (Instant) timestamp; + } + } } diff --git a/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/oidc/OidcIdTokenBuilderTests.java b/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/oidc/OidcIdTokenBuilderTests.java new file mode 100644 index 00000000000..c3b049eb066 --- /dev/null +++ b/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/oidc/OidcIdTokenBuilderTests.java @@ -0,0 +1,139 @@ +/* + * Copyright 2002-2019 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 org.springframework.security.oauth2.core.oidc; + +import java.time.Instant; + +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.springframework.security.oauth2.core.oidc.IdTokenClaimNames.EXP; +import static org.springframework.security.oauth2.core.oidc.IdTokenClaimNames.IAT; +import static org.springframework.security.oauth2.core.oidc.IdTokenClaimNames.SUB; + +/** + * Tests for {@link OidcUserInfo} + */ +public class OidcIdTokenBuilderTests { + @Test + public void buildWhenCalledTwiceThenGeneratesTwoOidcIdTokens() { + OidcIdToken.Builder idTokenBuilder = OidcIdToken.withTokenValue("token"); + + OidcIdToken first = idTokenBuilder + .tokenValue("V1") + .claim("TEST_CLAIM_1", "C1") + .build(); + + OidcIdToken second = idTokenBuilder + .tokenValue("V2") + .claim("TEST_CLAIM_1", "C2") + .claim("TEST_CLAIM_2", "C3") + .build(); + + assertThat(first.getClaims()).hasSize(1); + assertThat(first.getClaims().get("TEST_CLAIM_1")).isEqualTo("C1"); + assertThat(first.getTokenValue()).isEqualTo("V1"); + + assertThat(second.getClaims()).hasSize(2); + assertThat(second.getClaims().get("TEST_CLAIM_1")).isEqualTo("C2"); + assertThat(second.getClaims().get("TEST_CLAIM_2")).isEqualTo("C3"); + assertThat(second.getTokenValue()).isEqualTo("V2"); + } + + @Test + public void expiresAtWhenUsingGenericOrNamedClaimMethodRequiresInstant() { + OidcIdToken.Builder idTokenBuilder = OidcIdToken.withTokenValue("token"); + + Instant now = Instant.now(); + + OidcIdToken idToken = idTokenBuilder + .expiresAt(now).build(); + assertThat(idToken.getExpiresAt()).isSameAs(now); + + idToken = idTokenBuilder + .expiresAt(now).build(); + assertThat(idToken.getExpiresAt()).isSameAs(now); + + assertThatCode(() -> idTokenBuilder + .claim(EXP, "not an instant").build()) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void issuedAtWhenUsingGenericOrNamedClaimMethodRequiresInstant() { + OidcIdToken.Builder idTokenBuilder = OidcIdToken.withTokenValue("token"); + + Instant now = Instant.now(); + + OidcIdToken idToken = idTokenBuilder + .issuedAt(now).build(); + assertThat(idToken.getIssuedAt()).isSameAs(now); + + idToken = idTokenBuilder + .issuedAt(now).build(); + assertThat(idToken.getIssuedAt()).isSameAs(now); + + assertThatCode(() -> idTokenBuilder + .claim(IAT, "not an instant").build()) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void subjectWhenUsingGenericOrNamedClaimMethodThenLastOneWins() { + OidcIdToken.Builder idTokenBuilder = OidcIdToken.withTokenValue("token"); + + String generic = new String("sub"); + String named = new String("sub"); + + OidcIdToken idToken = idTokenBuilder + .subject(named) + .claim(SUB, generic).build(); + assertThat(idToken.getSubject()).isSameAs(generic); + + idToken = idTokenBuilder + .claim(SUB, generic) + .subject(named).build(); + assertThat(idToken.getSubject()).isSameAs(named); + } + + @Test + public void claimsWhenRemovingAClaimThenIsNotPresent() { + OidcIdToken.Builder idTokenBuilder = OidcIdToken.withTokenValue("token") + .claim("needs", "a claim"); + + OidcIdToken idToken = idTokenBuilder + .subject("sub") + .claims(claims -> claims.remove(SUB)) + .build(); + assertThat(idToken.getSubject()).isNull(); + } + + @Test + public void claimsWhenAddingAClaimThenIsPresent() { + OidcIdToken.Builder idTokenBuilder = OidcIdToken.withTokenValue("token"); + + String name = new String("name"); + String value = new String("value"); + OidcIdToken idToken = idTokenBuilder + .claims(claims -> claims.put(name, value)) + .build(); + + assertThat(idToken.getClaims()).hasSize(1); + assertThat(idToken.getClaims().get(name)).isSameAs(value); + } +} From 3f39a4b8f992e564f9736dd1f569bd29edcffe0f Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Mon, 4 Nov 2019 10:21:10 -0700 Subject: [PATCH 3/4] Add OidcUserInfo.Builder Fixes gh-7593 --- .../oauth2/core/oidc/OidcUserInfo.java | 282 +++++++++++++++++- .../core/oidc/OidcUserInfoBuilderTests.java | 92 ++++++ 2 files changed, 371 insertions(+), 3 deletions(-) create mode 100644 oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/oidc/OidcUserInfoBuilderTests.java diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/OidcUserInfo.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/OidcUserInfo.java index 0d3ba43183a..673f94d50d6 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/OidcUserInfo.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/OidcUserInfo.java @@ -15,13 +15,36 @@ */ package org.springframework.security.oauth2.core.oidc; -import org.springframework.security.core.SpringSecurityCoreVersion; -import org.springframework.util.Assert; - import java.io.Serializable; +import java.time.Instant; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; +import java.util.function.Consumer; + +import org.springframework.security.core.SpringSecurityCoreVersion; +import org.springframework.util.Assert; + +import static org.springframework.security.oauth2.core.oidc.StandardClaimNames.ADDRESS; +import static org.springframework.security.oauth2.core.oidc.StandardClaimNames.BIRTHDATE; +import static org.springframework.security.oauth2.core.oidc.StandardClaimNames.EMAIL; +import static org.springframework.security.oauth2.core.oidc.StandardClaimNames.EMAIL_VERIFIED; +import static org.springframework.security.oauth2.core.oidc.StandardClaimNames.FAMILY_NAME; +import static org.springframework.security.oauth2.core.oidc.StandardClaimNames.GENDER; +import static org.springframework.security.oauth2.core.oidc.StandardClaimNames.GIVEN_NAME; +import static org.springframework.security.oauth2.core.oidc.StandardClaimNames.LOCALE; +import static org.springframework.security.oauth2.core.oidc.StandardClaimNames.MIDDLE_NAME; +import static org.springframework.security.oauth2.core.oidc.StandardClaimNames.NAME; +import static org.springframework.security.oauth2.core.oidc.StandardClaimNames.NICKNAME; +import static org.springframework.security.oauth2.core.oidc.StandardClaimNames.PHONE_NUMBER; +import static org.springframework.security.oauth2.core.oidc.StandardClaimNames.PHONE_NUMBER_VERIFIED; +import static org.springframework.security.oauth2.core.oidc.StandardClaimNames.PICTURE; +import static org.springframework.security.oauth2.core.oidc.StandardClaimNames.PREFERRED_USERNAME; +import static org.springframework.security.oauth2.core.oidc.StandardClaimNames.PROFILE; +import static org.springframework.security.oauth2.core.oidc.StandardClaimNames.SUB; +import static org.springframework.security.oauth2.core.oidc.StandardClaimNames.UPDATED_AT; +import static org.springframework.security.oauth2.core.oidc.StandardClaimNames.WEBSITE; +import static org.springframework.security.oauth2.core.oidc.StandardClaimNames.ZONEINFO; /** * A representation of a UserInfo Response that is returned @@ -74,4 +97,257 @@ public boolean equals(Object obj) { public int hashCode() { return this.getClaims().hashCode(); } + + /** + * Create a {@link Builder} + * + * @return the {@link Builder} for further configuration + */ + public static Builder builder() { + return new Builder(); + } + + /** + * A builder for {@link OidcUserInfo}s + * + * @since 5.3 + * @author Josh Cummings + */ + public static final class Builder { + private final Map claims = new LinkedHashMap<>(); + + private Builder() {} + + /** + * Use this claim in the resulting {@link OidcUserInfo} + * + * @param name The claim name + * @param value The claim value + * @return the {@link Builder} for further configurations + */ + public Builder claim(String name, Object value) { + this.claims.put(name, value); + return this; + } + + /** + * Provides access to every {@link #claim(String, Object)} + * declared so far with the possibility to add, replace, or remove. + * @param claimsConsumer the consumer + * @return the {@link Builder} for further configurations + */ + public Builder claims(Consumer> claimsConsumer) { + claimsConsumer.accept(this.claims); + return this; + } + + /** + * Use this address in the resulting {@link OidcUserInfo} + * + * @param address The address to use + * @return the {@link Builder} for further configurations + */ + public Builder address(String address) { + return this.claim(ADDRESS, address); + } + + /** + * Use this birthdate in the resulting {@link OidcUserInfo} + * + * @param birthdate The birthdate to use + * @return the {@link Builder} for further configurations + */ + public Builder birthdate(String birthdate) { + return this.claim(BIRTHDATE, birthdate); + } + + /** + * Use this email in the resulting {@link OidcUserInfo} + * + * @param email The email to use + * @return the {@link Builder} for further configurations + */ + public Builder email(String email) { + return this.claim(EMAIL, email); + } + + /** + * Use this verified-email indicator in the resulting {@link OidcUserInfo} + * + * @param emailVerified The verified-email indicator to use + * @return the {@link Builder} for further configurations + */ + public Builder emailVerified(Boolean emailVerified) { + return this.claim(EMAIL_VERIFIED, emailVerified); + } + + /** + * Use this family name in the resulting {@link OidcUserInfo} + * + * @param familyName The family name to use + * @return the {@link Builder} for further configurations + */ + public Builder familyName(String familyName) { + return claim(FAMILY_NAME, familyName); + } + + /** + * Use this gender in the resulting {@link OidcUserInfo} + * + * @param gender The gender to use + * @return the {@link Builder} for further configurations + */ + public Builder gender(String gender) { + return this.claim(GENDER, gender); + } + + /** + * Use this given name in the resulting {@link OidcUserInfo} + * + * @param givenName The given name to use + * @return the {@link Builder} for further configurations + */ + public Builder givenName(String givenName) { + return claim(GIVEN_NAME, givenName); + } + + /** + * Use this locale in the resulting {@link OidcUserInfo} + * + * @param locale The locale to use + * @return the {@link Builder} for further configurations + */ + public Builder locale(String locale) { + return this.claim(LOCALE, locale); + } + + /** + * Use this middle name in the resulting {@link OidcUserInfo} + * + * @param middleName The middle name to use + * @return the {@link Builder} for further configurations + */ + public Builder middleName(String middleName) { + return claim(MIDDLE_NAME, middleName); + } + + /** + * Use this name in the resulting {@link OidcUserInfo} + * + * @param name The name to use + * @return the {@link Builder} for further configurations + */ + public Builder name(String name) { + return claim(NAME, name); + } + + /** + * Use this nickname in the resulting {@link OidcUserInfo} + * + * @param nickname The nickname to use + * @return the {@link Builder} for further configurations + */ + public Builder nickname(String nickname) { + return claim(NICKNAME, nickname); + } + + /** + * Use this picture in the resulting {@link OidcUserInfo} + * + * @param picture The picture to use + * @return the {@link Builder} for further configurations + */ + public Builder picture(String picture) { + return this.claim(PICTURE, picture); + } + + /** + * Use this phone number in the resulting {@link OidcUserInfo} + * + * @param phoneNumber The phone number to use + * @return the {@link Builder} for further configurations + */ + public Builder phoneNumber(String phoneNumber) { + return this.claim(PHONE_NUMBER, phoneNumber); + } + + /** + * Use this verified-phone-number indicator in the resulting {@link OidcUserInfo} + * + * @param phoneNumberVerified The verified-phone-number indicator to use + * @return the {@link Builder} for further configurations + */ + public Builder phoneNumberVerified(String phoneNumberVerified) { + return this.claim(PHONE_NUMBER_VERIFIED, phoneNumberVerified); + } + + /** + * Use this preferred username in the resulting {@link OidcUserInfo} + * + * @param preferredUsername The preferred username to use + * @return the {@link Builder} for further configurations + */ + public Builder preferredUsername(String preferredUsername) { + return claim(PREFERRED_USERNAME, preferredUsername); + } + + /** + * Use this profile in the resulting {@link OidcUserInfo} + * + * @param profile The profile to use + * @return the {@link Builder} for further configurations + */ + public Builder profile(String profile) { + return claim(PROFILE, profile); + } + + /** + * Use this subject in the resulting {@link OidcUserInfo} + * + * @param subject The subject to use + * @return the {@link Builder} for further configurations + */ + public Builder subject(String subject) { + return this.claim(SUB, subject); + } + + /** + * Use this updated-at {@link Instant} in the resulting {@link OidcUserInfo} + * + * @param updatedAt The updated-at {@link Instant} to use + * @return the {@link Builder} for further configurations + */ + public Builder updatedAt(String updatedAt) { + return this.claim(UPDATED_AT, updatedAt); + } + + /** + * Use this website in the resulting {@link OidcUserInfo} + * + * @param website The website to use + * @return the {@link Builder} for further configurations + */ + public Builder website(String website) { + return this.claim(WEBSITE, website); + } + + /** + * Use this zoneinfo in the resulting {@link OidcUserInfo} + * + * @param zoneinfo The zoneinfo to use + * @return the {@link Builder} for further configurations + */ + public Builder zoneinfo(String zoneinfo) { + return this.claim(ZONEINFO, zoneinfo); + } + + /** + * Build the {@link OidcUserInfo} + * + * @return The constructed {@link OidcUserInfo} + */ + public OidcUserInfo build() { + return new OidcUserInfo(this.claims); + } + } } diff --git a/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/oidc/OidcUserInfoBuilderTests.java b/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/oidc/OidcUserInfoBuilderTests.java new file mode 100644 index 00000000000..9b1c0570160 --- /dev/null +++ b/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/oidc/OidcUserInfoBuilderTests.java @@ -0,0 +1,92 @@ +/* + * Copyright 2002-2019 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 org.springframework.security.oauth2.core.oidc; + +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.oauth2.core.oidc.IdTokenClaimNames.SUB; + +/** + * Tests for {@link OidcUserInfo} + */ +public class OidcUserInfoBuilderTests { + @Test + public void buildWhenCalledTwiceThenGeneratesTwoOidcUserInfos() { + OidcUserInfo.Builder userInfoBuilder = OidcUserInfo.builder(); + + OidcUserInfo first = userInfoBuilder + .claim("TEST_CLAIM_1", "C1") + .build(); + + OidcUserInfo second = userInfoBuilder + .claim("TEST_CLAIM_1", "C2") + .claim("TEST_CLAIM_2", "C3") + .build(); + + assertThat(first.getClaims()).hasSize(1); + assertThat(first.getClaims().get("TEST_CLAIM_1")).isEqualTo("C1"); + + assertThat(second.getClaims()).hasSize(2); + assertThat(second.getClaims().get("TEST_CLAIM_1")).isEqualTo("C2"); + assertThat(second.getClaims().get("TEST_CLAIM_2")).isEqualTo("C3"); + } + + @Test + public void subjectWhenUsingGenericOrNamedClaimMethodThenLastOneWins() { + OidcUserInfo.Builder userInfoBuilder = OidcUserInfo.builder(); + + String generic = new String("sub"); + String named = new String("sub"); + + OidcUserInfo userInfo = userInfoBuilder + .subject(named) + .claim(SUB, generic).build(); + assertThat(userInfo.getSubject()).isSameAs(generic); + + userInfo = userInfoBuilder + .claim(SUB, generic) + .subject(named).build(); + assertThat(userInfo.getSubject()).isSameAs(named); + } + + @Test + public void claimsWhenRemovingAClaimThenIsNotPresent() { + OidcUserInfo.Builder userInfoBuilder = OidcUserInfo.builder() + .claim("needs", "a claim"); + + OidcUserInfo userInfo = userInfoBuilder + .subject("sub") + .claims(claims -> claims.remove(SUB)) + .build(); + assertThat(userInfo.getSubject()).isNull(); + } + + @Test + public void claimsWhenAddingAClaimThenIsPresent() { + OidcUserInfo.Builder userInfoBuilder = OidcUserInfo.builder(); + + String name = new String("name"); + String value = new String("value"); + OidcUserInfo userInfo = userInfoBuilder + .claims(claims -> claims.put(name, value)) + .build(); + + assertThat(userInfo.getClaims()).hasSize(1); + assertThat(userInfo.getClaims().get(name)).isSameAs(value); + } +} From c1f69cea350e7294d3fa12298a908cdedad82ae7 Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Mon, 4 Nov 2019 15:17:03 -0700 Subject: [PATCH 4/4] Add oidcLogin MockMvc Test Support Fixes gh-7618 --- .../sample/OAuth2LoginApplicationTests.java | 26 +++ .../web/OAuth2LoginControllerTests.java | 94 +++++++++ test/spring-security-test.gradle | 1 + .../SecurityMockMvcRequestPostProcessors.java | 176 ++++++++++++++++ ...vcRequestPostProcessorsOidcLoginTests.java | 195 ++++++++++++++++++ 5 files changed, 492 insertions(+) create mode 100644 samples/boot/oauth2login/src/test/java/sample/web/OAuth2LoginControllerTests.java create mode 100644 test/src/test/java/org/springframework/security/test/web/servlet/request/SecurityMockMvcRequestPostProcessorsOidcLoginTests.java diff --git a/samples/boot/oauth2login/src/integration-test/java/sample/OAuth2LoginApplicationTests.java b/samples/boot/oauth2login/src/integration-test/java/sample/OAuth2LoginApplicationTests.java index 89a0b26b6f3..ebb5a08523f 100644 --- a/samples/boot/oauth2login/src/integration-test/java/sample/OAuth2LoginApplicationTests.java +++ b/samples/boot/oauth2login/src/integration-test/java/sample/OAuth2LoginApplicationTests.java @@ -18,6 +18,7 @@ import java.net.URI; import java.net.URL; import java.net.URLDecoder; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -40,6 +41,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; import org.springframework.http.HttpStatus; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -51,7 +53,9 @@ import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizedClientRepository; import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; import org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter; import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; @@ -61,6 +65,7 @@ import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.oauth2.core.user.OAuth2UserAuthority; import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; import org.springframework.web.util.UriComponents; import org.springframework.web.util.UriComponentsBuilder; @@ -68,6 +73,10 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import static org.springframework.security.oauth2.core.oidc.IdTokenClaimNames.SUB; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; /** * Integration tests for the OAuth 2.0 client filters {@link OAuth2AuthorizationRequestRedirectFilter} @@ -87,6 +96,9 @@ public class OAuth2LoginApplicationTests { @Autowired private WebClient webClient; + @Autowired + private MockMvc mvc; + @Autowired private ClientRegistrationRepository clientRegistrationRepository; @@ -284,6 +296,15 @@ public void requestAuthorizationCodeGrantWhenInvalidRedirectUriThenDisplayLoginP assertThat(errorElement.asText()).contains("invalid_redirect_uri_parameter"); } + @Test + public void requestWhenMockOidcLoginThenIndex() throws Exception { + ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId("github"); + this.mvc.perform(get("/").with(oidcLogin().clientRegistration(clientRegistration))) + .andExpect(model().attribute("userName", "test-subject")) + .andExpect(model().attribute("clientName", "GitHub")) + .andExpect(model().attribute("userAttributes", Collections.singletonMap(SUB, "test-subject"))); + } + private void assertLoginPage(HtmlPage page) { assertThat(page.getTitleText()).isEqualTo("Please sign in"); @@ -397,5 +418,10 @@ private OAuth2UserService mockUserService() { when(userService.loadUser(any())).thenReturn(user); return userService; } + + @Bean + OAuth2AuthorizedClientRepository authorizedClientRepository() { + return new HttpSessionOAuth2AuthorizedClientRepository(); + } } } diff --git a/samples/boot/oauth2login/src/test/java/sample/web/OAuth2LoginControllerTests.java b/samples/boot/oauth2login/src/test/java/sample/web/OAuth2LoginControllerTests.java new file mode 100644 index 00000000000..99970efdf80 --- /dev/null +++ b/samples/boot/oauth2login/src/test/java/sample/web/OAuth2LoginControllerTests.java @@ -0,0 +1,94 @@ +/* + * Copyright 2002-2019 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.web; + +import java.util.Collections; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.security.oauth2.core.oidc.IdTokenClaimNames.SUB; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; + +/** + * Tests for {@link OAuth2LoginController} + * + * @author Josh Cummings + */ +@RunWith(SpringRunner.class) +@WebMvcTest +@Import({OAuth2LoginController.class, OAuth2LoginControllerTests.OAuth2ClientConfig.class}) +public class OAuth2LoginControllerTests { + + static ClientRegistration clientRegistration = ClientRegistration.withRegistrationId("test") + .authorizationGrantType(AuthorizationGrantType.PASSWORD) + .clientId("my-client-id") + .clientName("my-client-name") + .tokenUri("https://token-uri.example.org") + .build(); + + @Autowired + MockMvc mvc; + + @Test + public void rootWhenAuthenticatedReturnsUserAndClient() throws Exception { + this.mvc.perform(get("/").with(oidcLogin())) + .andExpect(model().attribute("userName", "test-subject")) + .andExpect(model().attribute("clientName", "test")) + .andExpect(model().attribute("userAttributes", Collections.singletonMap(SUB, "test-subject"))); + } + + @Test + public void rootWhenOverridingClientRegistrationReturnsAccordingly() throws Exception { + this.mvc.perform(get("/").with(oidcLogin() + .clientRegistration(clientRegistration) + .idToken(i -> i.subject("spring-security")))) + .andExpect(model().attribute("userName", "spring-security")) + .andExpect(model().attribute("clientName", "my-client-name")) + .andExpect(model().attribute("userAttributes", Collections.singletonMap(SUB, "spring-security"))); + } + + @Configuration + static class OAuth2ClientConfig { + + @Bean + ClientRegistrationRepository clientRegistrationRepository() { + return new InMemoryClientRegistrationRepository(clientRegistration); + } + + @Bean + OAuth2AuthorizedClientRepository authorizedClientRepository() { + return new HttpSessionOAuth2AuthorizedClientRepository(); + } + } +} diff --git a/test/spring-security-test.gradle b/test/spring-security-test.gradle index 9fbf195f7d4..7083de9437c 100644 --- a/test/spring-security-test.gradle +++ b/test/spring-security-test.gradle @@ -7,6 +7,7 @@ dependencies { compile 'org.springframework:spring-test' optional project(':spring-security-config') + optional project(':spring-security-oauth2-client') optional project(':spring-security-oauth2-jose') optional project(':spring-security-oauth2-resource-server') optional 'io.projectreactor:reactor-core' diff --git a/test/src/main/java/org/springframework/security/test/web/servlet/request/SecurityMockMvcRequestPostProcessors.java b/test/src/main/java/org/springframework/security/test/web/servlet/request/SecurityMockMvcRequestPostProcessors.java index 017567930a3..3a0b58b5b06 100644 --- a/test/src/main/java/org/springframework/security/test/web/servlet/request/SecurityMockMvcRequestPostProcessors.java +++ b/test/src/main/java/org/springframework/security/test/web/servlet/request/SecurityMockMvcRequestPostProcessors.java @@ -25,7 +25,10 @@ import java.util.Arrays; import java.util.Base64; import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; import java.util.function.Consumer; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -46,6 +49,19 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; @@ -314,6 +330,11 @@ public static RequestPostProcessor httpBasic(String username, String password) { return new HttpBasicRequestPostProcessor(username, password); } + public static OidcLoginRequestPostProcessor oidcLogin() { + OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, "access-token", null, null, Collections.singleton("user")); + return new OidcLoginRequestPostProcessor(accessToken); + } + /** * Populates the X509Certificate instances onto the request */ @@ -1024,6 +1045,161 @@ public MockHttpServletRequest postProcessRequest(MockHttpServletRequest request) } + /** + * @author Josh Cummings + * @since 5.3 + */ + public final static class OidcLoginRequestPostProcessor implements RequestPostProcessor { + private ClientRegistration clientRegistration; + private OAuth2AccessToken accessToken; + private OidcIdToken idToken; + private OidcUserInfo userInfo; + private OidcUser oidcUser; + private Collection authorities; + + private OidcLoginRequestPostProcessor(OAuth2AccessToken accessToken) { + this.accessToken = accessToken; + this.clientRegistration = clientRegistrationBuilder().build(); + } + + /** + * Use the provided authorities in the {@link Authentication} + * + * @param authorities the authorities to use + * @return the {@link OidcLoginRequestPostProcessor} for further configuration + */ + public OidcLoginRequestPostProcessor authorities(Collection authorities) { + Assert.notNull(authorities, "authorities cannot be null"); + this.authorities = authorities; + return this; + } + + /** + * Use the provided authorities in the {@link Authentication} + * + * @param authorities the authorities to use + * @return the {@link OidcLoginRequestPostProcessor} for further configuration + */ + public OidcLoginRequestPostProcessor authorities(GrantedAuthority... authorities) { + Assert.notNull(authorities, "authorities cannot be null"); + this.authorities = Arrays.asList(authorities); + return this; + } + + /** + * Use the provided {@link OidcIdToken} when constructing the authenticated user + * + * @param idTokenBuilderConsumer a {@link Consumer} of a {@link OidcIdToken.Builder} + * @return the {@link OidcLoginRequestPostProcessor} for further configuration + */ + public OidcLoginRequestPostProcessor idToken(Consumer idTokenBuilderConsumer) { + OidcIdToken.Builder builder = OidcIdToken.withTokenValue("id-token"); + builder.subject("test-subject"); + idTokenBuilderConsumer.accept(builder); + this.idToken = builder.build(); + return this; + } + + /** + * Use the provided {@link OidcUserInfo} when constructing the authenticated user + * + * @param userInfoBuilderConsumer a {@link Consumer} of a {@link OidcUserInfo.Builder} + * @return the {@link OidcLoginRequestPostProcessor} for further configuration + */ + public OidcLoginRequestPostProcessor userInfoToken(Consumer userInfoBuilderConsumer) { + OidcUserInfo.Builder builder = OidcUserInfo.builder(); + userInfoBuilderConsumer.accept(builder); + this.userInfo = builder.build(); + return this; + } + + /** + * Use the provided {@link OidcUser} as the authenticated user. + * + * Supplying an {@link OidcUser} will take precedence over {@link #idToken}, {@link #userInfo}, + * and list of {@link GrantedAuthority}s to use. + * + * @param oidcUser the {@link OidcUser} to use + * @return the {@link OidcLoginRequestPostProcessor} for further configuration + */ + public OidcLoginRequestPostProcessor oidcUser(OidcUser oidcUser) { + this.oidcUser = oidcUser; + return this; + } + + /** + * Use the provided {@link ClientRegistration} as the client to authorize. + * + * The supplied {@link ClientRegistration} will be registered into an + * {@link HttpSessionOAuth2AuthorizedClientRepository}. Tests relying on + * {@link org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient} + * annotations should register a {@link HttpSessionOAuth2AuthorizedClientRepository} bean + * to the application context. + * + * The client registration must be a valid {@link ClientRegistration} from the + * {@link org.springframework.security.oauth2.client.registration.ClientRegistrationRepository} + * in the application context. + * + * @param clientRegistration the {@link ClientRegistration} to use + * @return the {@link OidcLoginRequestPostProcessor} for further configuration + */ + public OidcLoginRequestPostProcessor clientRegistration(ClientRegistration clientRegistration) { + this.clientRegistration = clientRegistration; + return this; + } + + @Override + public MockHttpServletRequest postProcessRequest(MockHttpServletRequest request) { + OidcUser oidcUser = getOidcUser(); + OAuth2AuthenticationToken token = new OAuth2AuthenticationToken(oidcUser, oidcUser.getAuthorities(), this.clientRegistration.getRegistrationId()); + OAuth2AuthorizedClient client = new OAuth2AuthorizedClient(this.clientRegistration, token.getName(), this.accessToken); + OAuth2AuthorizedClientRepository authorizedClientRepository = new HttpSessionOAuth2AuthorizedClientRepository(); + authorizedClientRepository.saveAuthorizedClient(client, token, request, new MockHttpServletResponse()); + + return new AuthenticationRequestPostProcessor(token).postProcessRequest(request); + } + + private ClientRegistration.Builder clientRegistrationBuilder() { + return ClientRegistration.withRegistrationId("test") + .authorizationGrantType(AuthorizationGrantType.PASSWORD) + .clientId("test-client") + .tokenUri("https://token-uri.example.org"); + } + + private Collection getAuthorities() { + if (this.authorities == null) { + Set authorities = new LinkedHashSet<>(); + authorities.add(new OidcUserAuthority(getOidcIdToken(), getOidcUserInfo())); + for (String authority : this.accessToken.getScopes()) { + authorities.add(new SimpleGrantedAuthority("SCOPE_" + authority)); + } + return authorities; + } else { + return this.authorities; + } + } + + private OidcIdToken getOidcIdToken() { + if (this.idToken == null) { + return new OidcIdToken("id-token", null, null, Collections.singletonMap(IdTokenClaimNames.SUB, "test-subject")); + } else { + return this.idToken; + } + } + + private OidcUserInfo getOidcUserInfo() { + return this.userInfo; + } + + private OidcUser getOidcUser() { + if (this.oidcUser == null) { + return new DefaultOidcUser(getAuthorities(), getOidcIdToken(), this.userInfo); + } else { + return this.oidcUser; + } + } + } + private SecurityMockMvcRequestPostProcessors() { } } diff --git a/test/src/test/java/org/springframework/security/test/web/servlet/request/SecurityMockMvcRequestPostProcessorsOidcLoginTests.java b/test/src/test/java/org/springframework/security/test/web/servlet/request/SecurityMockMvcRequestPostProcessorsOidcLoginTests.java new file mode 100644 index 00000000000..1034100cc19 --- /dev/null +++ b/test/src/test/java/org/springframework/security/test/web/servlet/request/SecurityMockMvcRequestPostProcessorsOidcLoginTests.java @@ -0,0 +1,195 @@ +/* + * Copyright 2002-2019 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 org.springframework.security.test.web.servlet.request; + +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.test.context.TestSecurityContextHolder; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.powermock.api.mockito.PowerMockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests for {@link SecurityMockMvcRequestPostProcessors#oidcLogin()} + * + * @author Josh Cummings + * @since 5.3 + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration +@WebAppConfiguration +public class SecurityMockMvcRequestPostProcessorsOidcLoginTests { + @Autowired + WebApplicationContext context; + + MockMvc mvc; + + @Before + public void setup() { + // @formatter:off + this.mvc = MockMvcBuilders + .webAppContextSetup(this.context) + .apply(springSecurity()) + .build(); + // @formatter:on + } + + @After + public void cleanup() { + TestSecurityContextHolder.clearContext(); + } + + @Test + public void oidcLoginWhenUsingDefaultsThenProducesDefaultAuthentication() + throws Exception { + + this.mvc.perform(get("/name").with(oidcLogin())) + .andExpect(content().string("test-subject")); + this.mvc.perform(get("/admin/id-token/name").with(oidcLogin())) + .andExpect(status().isForbidden()); + } + + @Test + public void oidcLoginWhenUsingDefaultsThenProducesDefaultAuthorizedClient() + throws Exception { + + ClientRegistration clientRegistration = ClientRegistration.withRegistrationId("test") + .authorizationGrantType(AuthorizationGrantType.PASSWORD) + .clientId("test-client") + .tokenUri("https://token-uri.example.org") + .build(); + ClientRegistrationRepository repository = this.context.getBean(ClientRegistrationRepository.class); + when(repository.findByRegistrationId(anyString())).thenReturn(clientRegistration); + + this.mvc.perform(get("/access-token").with(oidcLogin().clientRegistration(clientRegistration))) + .andExpect(content().string("access-token")); + } + + @Test + public void oidcLoginWhenAuthoritiesSpecifiedThenGrantsAccess() throws Exception { + this.mvc.perform(get("/admin/scopes") + .with(oidcLogin().authorities(new SimpleGrantedAuthority("SCOPE_admin")))) + .andExpect(content().string("[\"SCOPE_admin\"]")); + } + + @Test + public void oidcLoginWhenIdTokenSpecifiedThenUserHasClaims() throws Exception { + this.mvc.perform(get("/id-token/iss") + .with(oidcLogin().idToken(i -> i.issuer("https://idp.example.org")))) + .andExpect(content().string("https://idp.example.org")); + } + + @Test + public void oidcLoginWhenUserInfoSpecifiedThenUserHasClaims() throws Exception { + this.mvc.perform(get("/user-info/email") + .with(oidcLogin().userInfoToken(u -> u.email("email@email")))) + .andExpect(content().string("email@email")); + } + + @EnableWebSecurity + @EnableWebMvc + static class OAuth2LoginConfig extends WebSecurityConfigurerAdapter { + @Override + protected void configure(HttpSecurity http) throws Exception { + http + .authorizeRequests() + .mvcMatchers("/admin/**").hasAuthority("SCOPE_admin") + .anyRequest().hasAuthority("SCOPE_user") + .and() + .oauth2Login(); + } + + @Bean + ClientRegistrationRepository clientRegistrationRepository() { + return mock(ClientRegistrationRepository.class); + } + + + @Bean + OAuth2AuthorizedClientRepository oAuth2AuthorizedClientRepository() { + return new HttpSessionOAuth2AuthorizedClientRepository(); + } + + @RestController + static class PrincipalController { + @GetMapping("/name") + String name(@AuthenticationPrincipal OidcUser oidcUser) { + return oidcUser.getName(); + } + + @GetMapping("/access-token") + String authorizedClient(@RegisteredOAuth2AuthorizedClient OAuth2AuthorizedClient authorizedClient) { + return authorizedClient.getAccessToken().getTokenValue(); + } + + @GetMapping("/id-token/{claim}") + String idTokenClaim(@AuthenticationPrincipal OidcUser oidcUser, @PathVariable("claim") String claim) { + return oidcUser.getIdToken().getClaim(claim); + } + + @GetMapping("/user-info/{claim}") + String userInfoClaim(@AuthenticationPrincipal OidcUser oidcUser, @PathVariable("claim") String claim) { + return oidcUser.getUserInfo().getClaim(claim); + } + + @GetMapping("/admin/scopes") + List scopes(@AuthenticationPrincipal(expression = "authorities") + Collection authorities) { + return authorities.stream().map(GrantedAuthority::getAuthority) + .collect(Collectors.toList()); + } + } + } +}