From f70f28cdf944fcf503cf5c9f1707651e9e64fc70 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Thu, 27 Apr 2023 06:54:38 -0400 Subject: [PATCH] Add sample featured-authorizationserver Issue gh-1189 --- .../gradle.properties | 1 + ...amples-featured-authorizationserver.gradle | 27 ++++ ...eaturedAuthorizationServerApplication.java | 32 ++++ .../config/AuthorizationServerConfig.java | 150 ++++++++++++++++++ .../sample/config/DefaultSecurityConfig.java | 75 +++++++++ .../src/main/java/sample/jose/Jwks.java | 75 +++++++++ .../java/sample/jose/KeyGeneratorUtils.java | 86 ++++++++++ .../src/main/resources/application.yml | 9 ++ ...edAuthorizationServerApplicationTests.java | 137 ++++++++++++++++ ...aturedAuthorizationServerConsentTests.java | 126 +++++++++++++++ 10 files changed, 718 insertions(+) create mode 100644 samples/featured-authorizationserver/gradle.properties create mode 100644 samples/featured-authorizationserver/samples-featured-authorizationserver.gradle create mode 100644 samples/featured-authorizationserver/src/main/java/sample/FeaturedAuthorizationServerApplication.java create mode 100644 samples/featured-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java create mode 100644 samples/featured-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java create mode 100644 samples/featured-authorizationserver/src/main/java/sample/jose/Jwks.java create mode 100644 samples/featured-authorizationserver/src/main/java/sample/jose/KeyGeneratorUtils.java create mode 100644 samples/featured-authorizationserver/src/main/resources/application.yml create mode 100644 samples/featured-authorizationserver/src/test/java/sample/FeaturedAuthorizationServerApplicationTests.java create mode 100644 samples/featured-authorizationserver/src/test/java/sample/FeaturedAuthorizationServerConsentTests.java diff --git a/samples/featured-authorizationserver/gradle.properties b/samples/featured-authorizationserver/gradle.properties new file mode 100644 index 00000000..3d071be6 --- /dev/null +++ b/samples/featured-authorizationserver/gradle.properties @@ -0,0 +1 @@ +spring-security.version=6.1.0-RC1 diff --git a/samples/featured-authorizationserver/samples-featured-authorizationserver.gradle b/samples/featured-authorizationserver/samples-featured-authorizationserver.gradle new file mode 100644 index 00000000..61721414 --- /dev/null +++ b/samples/featured-authorizationserver/samples-featured-authorizationserver.gradle @@ -0,0 +1,27 @@ +plugins { + id "org.springframework.boot" version "3.0.0" + id "io.spring.dependency-management" version "1.0.11.RELEASE" + id "java" +} + +group = project.rootProject.group +version = project.rootProject.version +sourceCompatibility = "17" + +repositories { + mavenCentral() + maven { url "https://repo.spring.io/milestone" } +} + +dependencies { + implementation "org.springframework.boot:spring-boot-starter-web" + implementation "org.springframework.boot:spring-boot-starter-security" + implementation "org.springframework.boot:spring-boot-starter-jdbc" + implementation project(":spring-security-oauth2-authorization-server") + runtimeOnly "com.h2database:h2" + + testImplementation "org.springframework.boot:spring-boot-starter-test" + testImplementation "org.springframework.security:spring-security-test" + testImplementation "org.junit.jupiter:junit-jupiter" + testImplementation "net.sourceforge.htmlunit:htmlunit" +} diff --git a/samples/featured-authorizationserver/src/main/java/sample/FeaturedAuthorizationServerApplication.java b/samples/featured-authorizationserver/src/main/java/sample/FeaturedAuthorizationServerApplication.java new file mode 100644 index 00000000..4d59f2cc --- /dev/null +++ b/samples/featured-authorizationserver/src/main/java/sample/FeaturedAuthorizationServerApplication.java @@ -0,0 +1,32 @@ +/* + * Copyright 2020-2023 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 org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * @author Joe Grandja + * @since 1.1.0 + */ +@SpringBootApplication +public class FeaturedAuthorizationServerApplication { + + public static void main(String[] args) { + SpringApplication.run(FeaturedAuthorizationServerApplication.class, args); + } + +} diff --git a/samples/featured-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java b/samples/featured-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java new file mode 100644 index 00000000..8e70f28f --- /dev/null +++ b/samples/featured-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java @@ -0,0 +1,150 @@ +/* + * Copyright 2020-2023 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.config; + +import java.util.UUID; + +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; +import sample.jose.Jwks; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +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.JdbcOAuth2AuthorizationConsentService; +import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository; +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.annotation.web.configuration.OAuth2AuthorizationServerConfiguration; +import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer; +import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; +import org.springframework.security.oauth2.server.authorization.settings.ClientSettings; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; + +/** + * @author Joe Grandja + * @since 1.1.0 + */ +@Configuration(proxyBeanMethods = false) +public class AuthorizationServerConfig { + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { + OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); + http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) + .oidc(Customizer.withDefaults()); // Enable OpenID Connect 1.0 + + // @formatter:off + http + .exceptionHandling(exceptions -> + exceptions.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")) + ) + .oauth2ResourceServer(oauth2ResourceServer -> + oauth2ResourceServer.jwt(Customizer.withDefaults())); + // @formatter:on + return http.build(); + } + + // @formatter:off + @Bean + public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) { + 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") + .postLogoutRedirectUri("http://127.0.0.1:8080/logged-out") + .scope(OidcScopes.OPENID) + .scope(OidcScopes.PROFILE) + .scope("message.read") + .scope("message.write") + .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build()) + .build(); + + // Save registered client in db as if in-memory + JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate); + registeredClientRepository.save(registeredClient); + + return registeredClientRepository; + } + // @formatter:on + + @Bean + public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, + RegisteredClientRepository registeredClientRepository) { + return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository); + } + + @Bean + public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate, + RegisteredClientRepository registeredClientRepository) { + return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository); + } + + @Bean + public JWKSource jwkSource() { + RSAKey rsaKey = Jwks.generateRsa(); + JWKSet jwkSet = new JWKSet(rsaKey); + return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet); + } + + @Bean + public JwtDecoder jwtDecoder(JWKSource jwkSource) { + return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource); + } + + @Bean + public AuthorizationServerSettings authorizationServerSettings() { + return AuthorizationServerSettings.builder().build(); + } + + @Bean + public EmbeddedDatabase embeddedDatabase() { + // @formatter:off + return new EmbeddedDatabaseBuilder() + .generateUniqueName(true) + .setType(EmbeddedDatabaseType.H2) + .setScriptEncoding("UTF-8") + .addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql") + .addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-consent-schema.sql") + .addScript("org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql") + .build(); + // @formatter:on + } + +} diff --git a/samples/featured-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java b/samples/featured-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java new file mode 100644 index 00000000..df0c8067 --- /dev/null +++ b/samples/featured-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java @@ -0,0 +1,75 @@ +/* + * Copyright 2020-2023 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.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.core.session.SessionRegistryImpl; +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.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.session.HttpSessionEventPublisher; + +import static org.springframework.security.config.Customizer.withDefaults; + +/** + * @author Joe Grandja + * @since 1.1.0 + */ +@EnableWebSecurity +@Configuration(proxyBeanMethods = false) +public class DefaultSecurityConfig { + + // @formatter:off + @Bean + public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(authorize -> + authorize.anyRequest().authenticated() + ) + .formLogin(withDefaults()); + return http.build(); + } + // @formatter:on + + // @formatter:off + @Bean + public UserDetailsService users() { + UserDetails user = User.withDefaultPasswordEncoder() + .username("user1") + .password("password") + .roles("USER") + .build(); + return new InMemoryUserDetailsManager(user); + } + // @formatter:on + + @Bean + public SessionRegistry sessionRegistry() { + return new SessionRegistryImpl(); + } + + @Bean + public HttpSessionEventPublisher httpSessionEventPublisher() { + return new HttpSessionEventPublisher(); + } + +} diff --git a/samples/featured-authorizationserver/src/main/java/sample/jose/Jwks.java b/samples/featured-authorizationserver/src/main/java/sample/jose/Jwks.java new file mode 100644 index 00000000..62f581fe --- /dev/null +++ b/samples/featured-authorizationserver/src/main/java/sample/jose/Jwks.java @@ -0,0 +1,75 @@ +/* + * Copyright 2020-2023 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.jose; + +import java.security.KeyPair; +import java.security.interfaces.ECPrivateKey; +import java.security.interfaces.ECPublicKey; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.util.UUID; + +import javax.crypto.SecretKey; + +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.OctetSequenceKey; +import com.nimbusds.jose.jwk.RSAKey; + +/** + * @author Joe Grandja + * @since 1.1.0 + */ +public final class Jwks { + + private Jwks() { + } + + public static RSAKey generateRsa() { + KeyPair keyPair = KeyGeneratorUtils.generateRsaKey(); + RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); + RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); + // @formatter:off + return new RSAKey.Builder(publicKey) + .privateKey(privateKey) + .keyID(UUID.randomUUID().toString()) + .build(); + // @formatter:on + } + + public static ECKey generateEc() { + KeyPair keyPair = KeyGeneratorUtils.generateEcKey(); + ECPublicKey publicKey = (ECPublicKey) keyPair.getPublic(); + ECPrivateKey privateKey = (ECPrivateKey) keyPair.getPrivate(); + Curve curve = Curve.forECParameterSpec(publicKey.getParams()); + // @formatter:off + return new ECKey.Builder(curve, publicKey) + .privateKey(privateKey) + .keyID(UUID.randomUUID().toString()) + .build(); + // @formatter:on + } + + public static OctetSequenceKey generateSecret() { + SecretKey secretKey = KeyGeneratorUtils.generateSecretKey(); + // @formatter:off + return new OctetSequenceKey.Builder(secretKey) + .keyID(UUID.randomUUID().toString()) + .build(); + // @formatter:on + } + +} diff --git a/samples/featured-authorizationserver/src/main/java/sample/jose/KeyGeneratorUtils.java b/samples/featured-authorizationserver/src/main/java/sample/jose/KeyGeneratorUtils.java new file mode 100644 index 00000000..e55e361a --- /dev/null +++ b/samples/featured-authorizationserver/src/main/java/sample/jose/KeyGeneratorUtils.java @@ -0,0 +1,86 @@ +/* + * Copyright 2020-2023 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.jose; + +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.spec.ECFieldFp; +import java.security.spec.ECParameterSpec; +import java.security.spec.ECPoint; +import java.security.spec.EllipticCurve; + +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; + +/** + * @author Joe Grandja + * @since 1.1.0 + */ +final class KeyGeneratorUtils { + + private KeyGeneratorUtils() { + } + + static SecretKey generateSecretKey() { + SecretKey hmacKey; + try { + hmacKey = KeyGenerator.getInstance("HmacSha256").generateKey(); + } catch (Exception ex) { + throw new IllegalStateException(ex); + } + return hmacKey; + } + + static KeyPair generateRsaKey() { + KeyPair keyPair; + try { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + keyPair = keyPairGenerator.generateKeyPair(); + } catch (Exception ex) { + throw new IllegalStateException(ex); + } + return keyPair; + } + + static KeyPair generateEcKey() { + EllipticCurve ellipticCurve = new EllipticCurve( + new ECFieldFp( + new BigInteger("115792089210356248762697446949407573530086143415290314195533631308867097853951")), + new BigInteger("115792089210356248762697446949407573530086143415290314195533631308867097853948"), + new BigInteger("41058363725152142129326129780047268409114441015993725554835256314039467401291")); + ECPoint ecPoint = new ECPoint( + new BigInteger("48439561293906451759052585252797914202762949526041747995844080717082404635286"), + new BigInteger("36134250956749795798585127919587881956611106672985015071877198253568414405109")); + ECParameterSpec ecParameterSpec = new ECParameterSpec( + ellipticCurve, + ecPoint, + new BigInteger("115792089210356248762697446949407573529996955224135760342422259061068512044369"), + 1); + + KeyPair keyPair; + try { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC"); + keyPairGenerator.initialize(ecParameterSpec); + keyPair = keyPairGenerator.generateKeyPair(); + } catch (Exception ex) { + throw new IllegalStateException(ex); + } + return keyPair; + } + +} diff --git a/samples/featured-authorizationserver/src/main/resources/application.yml b/samples/featured-authorizationserver/src/main/resources/application.yml new file mode 100644 index 00000000..ac360647 --- /dev/null +++ b/samples/featured-authorizationserver/src/main/resources/application.yml @@ -0,0 +1,9 @@ +server: + port: 9000 + +logging: + level: + root: INFO + org.springframework.web: INFO + org.springframework.security: INFO + org.springframework.security.oauth2: INFO diff --git a/samples/featured-authorizationserver/src/test/java/sample/FeaturedAuthorizationServerApplicationTests.java b/samples/featured-authorizationserver/src/test/java/sample/FeaturedAuthorizationServerApplicationTests.java new file mode 100644 index 00000000..0d79cd1c --- /dev/null +++ b/samples/featured-authorizationserver/src/test/java/sample/FeaturedAuthorizationServerApplicationTests.java @@ -0,0 +1,137 @@ +/* + * Copyright 2020-2023 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.io.IOException; + +import com.gargoylesoftware.htmlunit.Page; +import com.gargoylesoftware.htmlunit.WebClient; +import com.gargoylesoftware.htmlunit.WebResponse; +import com.gargoylesoftware.htmlunit.html.HtmlButton; +import com.gargoylesoftware.htmlunit.html.HtmlElement; +import com.gargoylesoftware.htmlunit.html.HtmlInput; +import com.gargoylesoftware.htmlunit.html.HtmlPage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +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.http.HttpStatus; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.web.util.UriComponentsBuilder; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for the sample Authorization Server. + * + * @author Daniel Garnier-Moiroux + */ +@ExtendWith(SpringExtension.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +public class FeaturedAuthorizationServerApplicationTests { + private static final String REDIRECT_URI = "http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc"; + + private static final String AUTHORIZATION_REQUEST = UriComponentsBuilder + .fromPath("/oauth2/authorize") + .queryParam("response_type", "code") + .queryParam("client_id", "messaging-client") + .queryParam("scope", "openid") + .queryParam("state", "some-state") + .queryParam("redirect_uri", REDIRECT_URI) + .toUriString(); + + @Autowired + private WebClient webClient; + + @BeforeEach + public void setUp() { + this.webClient.getOptions().setThrowExceptionOnFailingStatusCode(true); + this.webClient.getOptions().setRedirectEnabled(true); + this.webClient.getCookieManager().clearCookies(); // log out + } + + @Test + public void whenLoginSuccessfulThenDisplayNotFoundError() throws IOException { + HtmlPage page = this.webClient.getPage("/"); + + assertLoginPage(page); + + this.webClient.getOptions().setThrowExceptionOnFailingStatusCode(false); + WebResponse signInResponse = signIn(page, "user1", "password").getWebResponse(); + assertThat(signInResponse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND.value()); // there is no "default" index page + } + + @Test + public void whenLoginFailsThenDisplayBadCredentials() throws IOException { + HtmlPage page = this.webClient.getPage("/"); + + HtmlPage loginErrorPage = signIn(page, "user1", "wrong-password"); + + HtmlElement alert = loginErrorPage.querySelector("div[role=\"alert\"]"); + assertThat(alert).isNotNull(); + assertThat(alert.getTextContent()).isEqualTo("Bad credentials"); + } + + @Test + public void whenNotLoggedInAndRequestingTokenThenRedirectsToLogin() throws IOException { + HtmlPage page = this.webClient.getPage(AUTHORIZATION_REQUEST); + + assertLoginPage(page); + } + + @Test + public void whenLoggingInAndRequestingTokenThenRedirectsToClientApplication() throws IOException { + // Log in + this.webClient.getOptions().setThrowExceptionOnFailingStatusCode(false); + this.webClient.getOptions().setRedirectEnabled(false); + signIn(this.webClient.getPage("/login"), "user1", "password"); + + // Request token + WebResponse response = this.webClient.getPage(AUTHORIZATION_REQUEST).getWebResponse(); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.MOVED_PERMANENTLY.value()); + String location = response.getResponseHeaderValue("location"); + assertThat(location).startsWith(REDIRECT_URI); + assertThat(location).contains("code="); + } + + private static

P signIn(HtmlPage page, String username, String password) throws IOException { + HtmlInput usernameInput = page.querySelector("input[name=\"username\"]"); + HtmlInput passwordInput = page.querySelector("input[name=\"password\"]"); + HtmlButton signInButton = page.querySelector("button"); + + usernameInput.type(username); + passwordInput.type(password); + return signInButton.click(); + } + + private static void assertLoginPage(HtmlPage page) { + assertThat(page.getUrl().toString()).endsWith("/login"); + + HtmlInput usernameInput = page.querySelector("input[name=\"username\"]"); + HtmlInput passwordInput = page.querySelector("input[name=\"password\"]"); + HtmlButton signInButton = page.querySelector("button"); + + assertThat(usernameInput).isNotNull(); + assertThat(passwordInput).isNotNull(); + assertThat(signInButton.getTextContent()).isEqualTo("Sign in"); + } + +} diff --git a/samples/featured-authorizationserver/src/test/java/sample/FeaturedAuthorizationServerConsentTests.java b/samples/featured-authorizationserver/src/test/java/sample/FeaturedAuthorizationServerConsentTests.java new file mode 100644 index 00000000..da962796 --- /dev/null +++ b/samples/featured-authorizationserver/src/test/java/sample/FeaturedAuthorizationServerConsentTests.java @@ -0,0 +1,126 @@ +/* + * Copyright 2020-2023 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.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import com.gargoylesoftware.htmlunit.WebClient; +import com.gargoylesoftware.htmlunit.WebResponse; +import com.gargoylesoftware.htmlunit.html.DomElement; +import com.gargoylesoftware.htmlunit.html.HtmlCheckBoxInput; +import com.gargoylesoftware.htmlunit.html.HtmlPage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +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.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpStatus; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.web.util.UriComponentsBuilder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +/** + * Consent screen integration tests for the sample Authorization Server. + * + * @author Dmitriy Dubson + */ +@ExtendWith(SpringExtension.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +public class FeaturedAuthorizationServerConsentTests { + + @Autowired + private WebClient webClient; + + @MockBean + private OAuth2AuthorizationConsentService authorizationConsentService; + + private final String redirectUri = "http://127.0.0.1/login/oauth2/code/messaging-client-oidc"; + + private final String authorizationRequestUri = UriComponentsBuilder + .fromPath("/oauth2/authorize") + .queryParam("response_type", "code") + .queryParam("client_id", "messaging-client") + .queryParam("scope", "openid message.read message.write") + .queryParam("state", "state") + .queryParam("redirect_uri", this.redirectUri) + .toUriString(); + + @BeforeEach + public void setUp() { + this.webClient.getOptions().setThrowExceptionOnFailingStatusCode(false); + this.webClient.getOptions().setRedirectEnabled(true); + this.webClient.getCookieManager().clearCookies(); + when(this.authorizationConsentService.findById(any(), any())).thenReturn(null); + } + + @Test + @WithMockUser("user1") + public void whenUserConsentsToAllScopesThenReturnAuthorizationCode() throws IOException { + final HtmlPage consentPage = this.webClient.getPage(this.authorizationRequestUri); + assertThat(consentPage.getTitleText()).isEqualTo("Consent required"); + + List scopes = new ArrayList<>(); + consentPage.querySelectorAll("input[name='scope']").forEach(scope -> + scopes.add((HtmlCheckBoxInput) scope)); + for (HtmlCheckBoxInput scope : scopes) { + scope.click(); + } + + List scopeIds = new ArrayList<>(); + scopes.forEach(scope -> { + assertThat(scope.isChecked()).isTrue(); + scopeIds.add(scope.getId()); + }); + assertThat(scopeIds).containsExactlyInAnyOrder("message.read", "message.write"); + + DomElement submitConsentButton = consentPage.querySelector("button[id='submit-consent']"); + this.webClient.getOptions().setRedirectEnabled(false); + + WebResponse approveConsentResponse = submitConsentButton.click().getWebResponse(); + assertThat(approveConsentResponse.getStatusCode()).isEqualTo(HttpStatus.MOVED_PERMANENTLY.value()); + String location = approveConsentResponse.getResponseHeaderValue("location"); + assertThat(location).startsWith(this.redirectUri); + assertThat(location).contains("code="); + } + + @Test + @WithMockUser("user1") + public void whenUserCancelsConsentThenReturnAccessDeniedError() throws IOException { + final HtmlPage consentPage = this.webClient.getPage(this.authorizationRequestUri); + assertThat(consentPage.getTitleText()).isEqualTo("Consent required"); + + DomElement cancelConsentButton = consentPage.querySelector("button[id='cancel-consent']"); + this.webClient.getOptions().setRedirectEnabled(false); + + WebResponse cancelConsentResponse = cancelConsentButton.click().getWebResponse(); + assertThat(cancelConsentResponse.getStatusCode()).isEqualTo(HttpStatus.MOVED_PERMANENTLY.value()); + String location = cancelConsentResponse.getResponseHeaderValue("location"); + assertThat(location).startsWith(this.redirectUri); + assertThat(location).contains("error=access_denied"); + } + +}