diff --git a/ci/smoke-tests.yml b/ci/smoke-tests.yml index 2a7350b8..0b71643e 100644 --- a/ci/smoke-tests.yml +++ b/ci/smoke-tests.yml @@ -54,6 +54,7 @@ smoke_tests: - scheduled - security-ldap - security-method + - security-oauth2-authorization-server - security-oauth2-resource-server - security-thymeleaf - security-webflux diff --git a/security-oauth2-authorization-server/build.gradle b/security-oauth2-authorization-server/build.gradle new file mode 100644 index 00000000..0f450ba7 --- /dev/null +++ b/security-oauth2-authorization-server/build.gradle @@ -0,0 +1,26 @@ +plugins { + id 'java' + id 'org.springframework.boot' + id 'org.springframework.aot.smoke-test' + id 'org.graalvm.buildtools.native' +} + +dependencies { + implementation(platform(org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES)) + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.security:spring-security-oauth2-authorization-server:1.0.0-M1") + + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.security:spring-security-test") + + aotTestImplementation("org.springframework.boot:spring-boot-starter-json") + aotTestImplementation(project(":aot-smoke-test-support")) +} + +aotSmokeTest { + webApplication = true +} + +test { + useJUnitPlatform() +} diff --git a/security-oauth2-authorization-server/src/aotTest/java/com/example/security/oauth2authorizationserver/OAuth2AuthorizationServerApplicationAotTests.java b/security-oauth2-authorization-server/src/aotTest/java/com/example/security/oauth2authorizationserver/OAuth2AuthorizationServerApplicationAotTests.java new file mode 100644 index 00000000..939df27d --- /dev/null +++ b/security-oauth2-authorization-server/src/aotTest/java/com/example/security/oauth2authorizationserver/OAuth2AuthorizationServerApplicationAotTests.java @@ -0,0 +1,143 @@ +package com.example.security.oauth2authorizationserver; + +import java.io.IOException; +import java.util.Map; +import java.util.function.Consumer; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import org.springframework.aot.smoketest.support.junit.AotSmokeTest; +import org.springframework.http.HttpHeaders; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.BodyInserters; + +@AotSmokeTest +class OAuth2AuthorizationServerApplicationAotTests { + + private static final String CLIENT_ID = "messaging-client"; + + private static final String CLIENT_SECRET = "secret"; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void performTokenRequestWhenValidClientCredentialsThenOk(WebTestClient client) { + // @formatter:off + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add("grant_type", "client_credentials"); + formData.add("scope", "message:read"); + client.post().uri("/oauth2/token").headers((headers) -> headers.setBasicAuth(CLIENT_ID, CLIENT_SECRET)) + .body(BodyInserters.fromFormData(formData)) + .exchange().expectStatus().isOk() + .expectBody().jsonPath("$.access_token").exists() + .jsonPath("$.expires_in").isNumber() + .jsonPath("$.scope").isEqualTo("message:read") + .jsonPath("$.token_type").isEqualTo("Bearer"); + // @formatter:on + } + + @Test + void performTokenRequestWhenMissingScopeThenOk(WebTestClient client) { + // @formatter:off + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add("grant_type", "client_credentials"); + formData.add("scope", "message:read message:write"); + client.post().uri("/oauth2/token").headers((headers) -> headers.setBasicAuth(CLIENT_ID, CLIENT_SECRET)) + .body(BodyInserters.fromFormData(formData)) + .exchange().expectStatus().isOk() + .expectBody().jsonPath("$.access_token").exists() + .jsonPath("$.expires_in").isNumber() + .jsonPath("$.scope").isEqualTo("message:read message:write") + .jsonPath("$.token_type").isEqualTo("Bearer"); + // @formatter:on + } + + @Test + void performTokenRequestWhenInvalidClientCredentialsThenUnauthorized(WebTestClient client) { + // @formatter:off + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add("grant_type", "client_credentials"); + formData.add("scope", "message:read"); + client.post().uri("/oauth2/token").headers(badCredentials()) + .body(BodyInserters.fromFormData(formData)) + .exchange().expectStatus().isUnauthorized() + .expectBody().jsonPath("$.error").isEqualTo("invalid_client"); + // @formatter:on + } + + @Test + void performTokenRequestWhenMissingGrantTypeThenUnauthorized(WebTestClient client) { + // @formatter:off + client.post().uri("/oauth2/token").headers(badCredentials()) + .exchange().expectStatus().isUnauthorized() + .expectBody().jsonPath("$.error").isEqualTo("invalid_client"); + // @formatter:on + } + + @Test + void performTokenRequestWhenGrantTypeNotRegisteredThenBadRequest(WebTestClient client) { + // @formatter:off + client.post().uri("/oauth2/token").headers((headers) -> headers.setBasicAuth("login-client", "openid-connect")) + .body(BodyInserters.fromFormData("grant_type", "client_credentials")) + .exchange().expectStatus().isBadRequest() + .expectBody().jsonPath("$.error").isEqualTo("unauthorized_client"); + // @formatter:on + } + + @Test + void performIntrospectionRequestWhenValidTokenThenOk(WebTestClient client) throws Exception { + // @formatter:off + client.post().uri("/oauth2/introspect").headers((headers) -> headers.setBasicAuth(CLIENT_ID, CLIENT_SECRET)) + .body(BodyInserters.fromFormData("token", getAccessToken(client))) + .exchange().expectStatus().isOk() + .expectBody().jsonPath("$.active").isEqualTo("true") + .jsonPath("$.aud[0]").isEqualTo(CLIENT_ID) + .jsonPath("$.client_id").isEqualTo(CLIENT_ID) + .jsonPath("$.exp").isNumber() + .jsonPath("$.iat").isNumber() + .jsonPath("$.iss").isEqualTo("http://localhost:9000") + .jsonPath("$.nbf").isNumber() + .jsonPath("$.scope").isEqualTo("message:read") + .jsonPath("$.sub").isEqualTo(CLIENT_ID) + .jsonPath("$.token_type").isEqualTo("Bearer"); + // @formatter:on + } + + @Test + void performIntrospectionRequestWhenInvalidCredentialsThenUnauthorized(WebTestClient client) throws Exception { + // @formatter:off + client.post().uri("/oauth2/introspect").headers(badCredentials()) + .body(BodyInserters.fromFormData("token", getAccessToken(client))) + .exchange().expectStatus().isUnauthorized() + .expectBody().jsonPath("$.error").isEqualTo("invalid_client"); + // @formatter:on + } + + private static Consumer badCredentials() { + return (headers) -> headers.setBasicAuth("bad", "password"); + } + + private String getAccessToken(WebTestClient client) throws IOException { + // @formatter:off + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add("grant_type", "client_credentials"); + formData.add("scope", "message:read"); + byte[] responseBody = client.post().uri("/oauth2/token") + .headers((headers) -> headers.setBasicAuth(CLIENT_ID, CLIENT_SECRET)) + .body(BodyInserters.fromFormData(formData)) + .exchange().expectStatus().isOk() + .expectBody().jsonPath("$.access_token").exists() + .returnResult().getResponseBody(); + // @formatter:on + + Map tokenResponse = this.objectMapper.readValue(responseBody, new TypeReference<>() { + }); + + return tokenResponse.get("access_token").toString(); + } + +} diff --git a/security-oauth2-authorization-server/src/main/java/com/example/security/oauth2authorizationserver/OAuth2AuthorizationServerApplication.java b/security-oauth2-authorization-server/src/main/java/com/example/security/oauth2authorizationserver/OAuth2AuthorizationServerApplication.java new file mode 100644 index 00000000..e47c7ae7 --- /dev/null +++ b/security-oauth2-authorization-server/src/main/java/com/example/security/oauth2authorizationserver/OAuth2AuthorizationServerApplication.java @@ -0,0 +1,35 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.security.oauth2authorizationserver; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * OAuth Authorization Server Application. + * + * @author Steve Riesenberg + */ +@SpringBootApplication +public class OAuth2AuthorizationServerApplication { + + public static void main(String[] args) throws InterruptedException { + SpringApplication.run(OAuth2AuthorizationServerApplication.class, args); + Thread.currentThread().join(); // To be able to measure memory consumption + } + +} diff --git a/security-oauth2-authorization-server/src/main/java/com/example/security/oauth2authorizationserver/OAuth2AuthorizationServerSecurityConfiguration.java b/security-oauth2-authorization-server/src/main/java/com/example/security/oauth2authorizationserver/OAuth2AuthorizationServerSecurityConfiguration.java new file mode 100644 index 00000000..2d4ba761 --- /dev/null +++ b/security-oauth2-authorization-server/src/main/java/com/example/security/oauth2authorizationserver/OAuth2AuthorizationServerSecurityConfiguration.java @@ -0,0 +1,166 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.security.oauth2authorizationserver; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.util.UUID; + +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; + +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Role; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.oidc.OidcScopes; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration; +import org.springframework.security.oauth2.server.authorization.settings.ClientSettings; +import org.springframework.security.oauth2.server.authorization.settings.ProviderSettings; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; + +/** + * OAuth Authorization Server Configuration. + * + * @author Steve Riesenberg + */ +@Configuration +@EnableWebSecurity +public class OAuth2AuthorizationServerSecurityConfiguration { + + @Bean + @Order(1) + public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { + OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); + return http.formLogin(Customizer.withDefaults()).build(); + } + + @Bean + @Order(2) + public SecurityFilterChain standardSecurityFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize + .anyRequest().authenticated() + ) + .formLogin(Customizer.withDefaults()); + // @formatter:on + + return http.build(); + } + + @Bean + public RegisteredClientRepository registeredClientRepository() { + // @formatter:off + RegisteredClient loginClient = RegisteredClient.withId(UUID.randomUUID().toString()) + .clientId("login-client") + .clientSecret("{noop}openid-connect") + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) + .redirectUri("http://127.0.0.1:8080/login/oauth2/code/login-client") + .redirectUri("http://127.0.0.1:8080/authorized") + .scope(OidcScopes.OPENID) + .scope(OidcScopes.PROFILE) + .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build()) + .build(); + RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString()) + .clientId("messaging-client") + .clientSecret("{noop}secret") + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .scope("message:read") + .scope("message:write") + .build(); + // @formatter:on + + return new InMemoryRegisteredClientRepository(loginClient, registeredClient); + } + + @Bean + public JWKSource jwkSource(KeyPair keyPair) { + RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); + RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); + // @formatter:off + RSAKey rsaKey = new RSAKey.Builder(publicKey) + .privateKey(privateKey) + .keyID(UUID.randomUUID().toString()) + .build(); + // @formatter:on + JWKSet jwkSet = new JWKSet(rsaKey); + return new ImmutableJWKSet<>(jwkSet); + } + + @Bean + public JwtDecoder jwtDecoder(KeyPair keyPair) { + return NimbusJwtDecoder.withPublicKey((RSAPublicKey) keyPair.getPublic()).build(); + } + + @Bean + public ProviderSettings providerSettings() { + return ProviderSettings.builder().issuer("http://localhost:9000").build(); + } + + @Bean + public UserDetailsService userDetailsService() { + // @formatter:off + UserDetails userDetails = User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .roles("USER") + .build(); + // @formatter:on + + return new InMemoryUserDetailsManager(userDetails); + } + + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + 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; + } + +} diff --git a/security-oauth2-authorization-server/src/test/java/com/example/security/oauth2authorizationserver/OAuth2AuthorizationServerApplicationTests.java b/security-oauth2-authorization-server/src/test/java/com/example/security/oauth2authorizationserver/OAuth2AuthorizationServerApplicationTests.java new file mode 100644 index 00000000..54d124ac --- /dev/null +++ b/security-oauth2-authorization-server/src/test/java/com/example/security/oauth2authorizationserver/OAuth2AuthorizationServerApplicationTests.java @@ -0,0 +1,15 @@ +package com.example.security.oauth2authorizationserver; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +public class OAuth2AuthorizationServerApplicationTests { + + @Test + void contextLoads() { + + } + +} diff --git a/settings.gradle b/settings.gradle index 052ef10a..ce5c3685 100644 --- a/settings.gradle +++ b/settings.gradle @@ -85,6 +85,7 @@ include "security-method" include "security-thymeleaf" include "security-webflux" include "security-webmvc" +include "security-oauth2-authorization-server" include "security-oauth2-resource-server" include "servlet-tomcat" include "session-jdbc"