Browse Source

Add sample featured-authorizationserver

Issue gh-1189
Joe Grandja 2 years ago
parent
commit
f70f28cdf9

+ 1 - 0
samples/featured-authorizationserver/gradle.properties

@@ -0,0 +1 @@
+spring-security.version=6.1.0-RC1

+ 27 - 0
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"
+}

+ 32 - 0
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);
+	}
+
+}

+ 150 - 0
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<SecurityContext> jwkSource() {
+		RSAKey rsaKey = Jwks.generateRsa();
+		JWKSet jwkSet = new JWKSet(rsaKey);
+		return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
+	}
+
+	@Bean
+	public JwtDecoder jwtDecoder(JWKSource<SecurityContext> 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
+	}
+
+}

+ 75 - 0
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();
+	}
+
+}

+ 75 - 0
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
+	}
+
+}

+ 86 - 0
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;
+	}
+
+}

+ 9 - 0
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

+ 137 - 0
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 extends Page> 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");
+	}
+
+}

+ 126 - 0
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<HtmlCheckBoxInput> scopes = new ArrayList<>();
+		consentPage.querySelectorAll("input[name='scope']").forEach(scope ->
+				scopes.add((HtmlCheckBoxInput) scope));
+		for (HtmlCheckBoxInput scope : scopes) {
+			scope.click();
+		}
+
+		List<String> 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");
+	}
+
+}