From 6039d6b34b169db0e634c7bbcce86e1079952d17 Mon Sep 17 00:00:00 2001 From: Andreas Svanberg Date: Wed, 26 Mar 2025 11:05:41 +0100 Subject: [PATCH 01/10] Refactor out storage specifics from the ClientManager In preparation of being able to change how clients are stored (from JDBC to in-memory) --- .../su/dsv/oauth2/JDBCClientRepository.java | 79 +++++++++++++ .../dsv/oauth2/PersistentConfiguration.java | 13 +++ .../se/su/dsv/oauth2/admin/ClientManager.java | 108 ++++-------------- .../admin/repository/ClientRepository.java | 21 ++++ .../oauth2/admin/repository/ClientRow.java | 26 +++++ 5 files changed, 160 insertions(+), 87 deletions(-) create mode 100644 src/main/java/se/su/dsv/oauth2/JDBCClientRepository.java create mode 100644 src/main/java/se/su/dsv/oauth2/PersistentConfiguration.java create mode 100644 src/main/java/se/su/dsv/oauth2/admin/repository/ClientRepository.java create mode 100644 src/main/java/se/su/dsv/oauth2/admin/repository/ClientRow.java diff --git a/src/main/java/se/su/dsv/oauth2/JDBCClientRepository.java b/src/main/java/se/su/dsv/oauth2/JDBCClientRepository.java new file mode 100644 index 0000000..638398e --- /dev/null +++ b/src/main/java/se/su/dsv/oauth2/JDBCClientRepository.java @@ -0,0 +1,79 @@ +package se.su.dsv.oauth2; + +import org.springframework.jdbc.core.simple.JdbcClient; +import se.su.dsv.oauth2.admin.repository.ClientRepository; +import se.su.dsv.oauth2.admin.repository.ClientRow; + +import java.security.Principal; +import java.util.List; +import java.util.Optional; + +public class JDBCClientRepository implements ClientRepository { + public final JdbcClient jdbc; + + public JDBCClientRepository(final JdbcClient jdbc) { + this.jdbc = jdbc; + } + + public JdbcClient getJdbc() { + return jdbc; + } + + @Override + public void addClientOwner(final String principalName, final String id) { + getJdbc().sql("INSERT INTO client_owner (client_id, owner) VALUES (:clientId, :owner)") + .param("clientId", id) + .param("owner", principalName) + .update(); + } + + @Override + public List getClients(final Principal owner) { + return getJdbc().sql("SELECT id, client_id, name, contact_email, redirect_uri, scopes, client_secret FROM client WHERE id IN (SELECT client_id FROM client_owner WHERE owner = :owner)") + .param("owner", owner.getName()) + .query(ClientRow.class) + .list(); + } + + @Override + public void removeOwner(final String id, final String owner) { + getJdbc().sql("DELETE FROM client_owner WHERE client_id = :id AND owner = :owner") + .param("id", id) + .param("owner", owner) + .update(); + } + + @Override + public Optional getClientRowById(final String id) { + return getJdbc().sql("SELECT id, client_id, name, contact_email, redirect_uri, scopes, client_secret FROM client WHERE id = :id") + .param("id", id) + .query(ClientRow.class) + .optional(); + } + + @Override + public Optional getClientRowByClientId(final String clientId) { + return getJdbc().sql("SELECT id, client_id, name, contact_email, redirect_uri, scopes, client_secret FROM client WHERE client_id = :clientId") + .param("clientId", clientId) + .query(ClientRow.class) + .optional(); + } + + @Override + public void addNewClient(final ClientRow clientRow) { + getJdbc().sql(""" + INSERT INTO client (id, client_id, client_secret, name, redirect_uri, contact_email, scopes) + VALUES (:id, :clientId, :clientSecret, :name, :redirectUri, :contactEmail, :scopes) + """) + .paramSource(clientRow) + .update(); + } + + @Override + public List getOwners(final String id) { + return getJdbc().sql("SELECT owner FROM client_owner WHERE client_id = :clientId") + .param("clientId", id) + .query(String.class) + .list(); + } +} diff --git a/src/main/java/se/su/dsv/oauth2/PersistentConfiguration.java b/src/main/java/se/su/dsv/oauth2/PersistentConfiguration.java new file mode 100644 index 0000000..0641ac4 --- /dev/null +++ b/src/main/java/se/su/dsv/oauth2/PersistentConfiguration.java @@ -0,0 +1,13 @@ +package se.su.dsv.oauth2; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.simple.JdbcClient; + +@Configuration +public class PersistentConfiguration { + @Bean + public JDBCClientRepository jdbcClientRepository(JdbcClient jdbcClient) { + return new JDBCClientRepository(jdbcClient); + } +} diff --git a/src/main/java/se/su/dsv/oauth2/admin/ClientManager.java b/src/main/java/se/su/dsv/oauth2/admin/ClientManager.java index 8ff738b..da5ce0e 100644 --- a/src/main/java/se/su/dsv/oauth2/admin/ClientManager.java +++ b/src/main/java/se/su/dsv/oauth2/admin/ClientManager.java @@ -1,6 +1,5 @@ package se.su.dsv.oauth2.admin; -import org.springframework.jdbc.core.simple.JdbcClient; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.ClientAuthenticationMethod; @@ -10,29 +9,28 @@ import org.springframework.security.oauth2.server.authorization.settings.ClientS import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat; import org.springframework.security.oauth2.server.authorization.settings.TokenSettings; import org.springframework.stereotype.Service; +import se.su.dsv.oauth2.admin.repository.ClientRepository; +import se.su.dsv.oauth2.admin.repository.ClientRow; import java.security.Principal; import java.time.Duration; -import java.util.Arrays; import java.util.Comparator; import java.util.List; import java.util.Optional; import java.util.Set; import java.util.UUID; -import java.util.function.Predicate; -import java.util.stream.Collectors; @Service public class ClientManager implements RegisteredClientRepository, ClientManagementService { private final PasswordEncoder passwordEncoder; - private final JdbcClient jdbc; + private final ClientRepository clientRepository; public ClientManager( PasswordEncoder passwordEncoder, - JdbcClient jdbc) + ClientRepository clientRepository) { this.passwordEncoder = passwordEncoder; - this.jdbc = jdbc; + this.clientRepository = clientRepository; } @Override @@ -42,49 +40,32 @@ public class ClientManager implements RegisteredClientRepository, ClientManageme String clientSecret = clientData.isPublic() ? null : Util.generateAlphanumericString(32); String encodedClientSecret = clientSecret == null ? null : passwordEncoder.encode(clientSecret); String redirectURI = clientData.redirectURI() != null ? clientData.redirectURI().toString() : null; + String scopeString = String.join(" ", clientData.scopes()); - jdbc.sql(""" - INSERT INTO client (id, client_id, client_secret, name, redirect_uri, contact_email, scopes) - VALUES (:id, :clientId, :clientSecret, :name, :redirectUri, :contactEmail, :scopes) - """) - .param("id", id) - .param("clientId", clientId) - .param("clientSecret", encodedClientSecret) - .param("name", clientData.clientName()) - .param("redirectUri", redirectURI) - .param("contactEmail", clientData.contactEmail()) - .param("scopes", String.join(" ", clientData.scopes())) - .update(); + ClientRow clientRow = new ClientRow(id, clientId, clientData.clientName(), clientData.contactEmail(), + redirectURI, scopeString, encodedClientSecret); - addClientOwner(owner.getName(), id); + clientRepository.addNewClient(clientRow); + + clientRepository.addClientOwner(owner.getName(), id); return new NewClient(id, clientId, clientSecret); } - private void addClientOwner(final String principalName, final String id) { - jdbc.sql("INSERT INTO client_owner (client_id, owner) VALUES (:clientId, :owner)") - .param("clientId", id) - .param("owner", principalName) - .update(); - } - @Override public Optional getClient(final Principal principal, final String id) { - boolean ownsClient = getOwners(id).contains(principal.getName()); + boolean ownsClient = clientRepository.getOwners(id).contains(principal.getName()); if (!ownsClient) { return Optional.empty(); } - return getClientRowById(id) + return clientRepository.getClientRowById(id) .map(this::toClient); } @Override public List getClients(final Principal owner) { - return jdbc.sql("SELECT id, client_id, name, contact_email, redirect_uri, scopes, client_secret FROM client WHERE id IN (SELECT client_id FROM client_owner WHERE owner = :owner)") - .param("owner", owner.getName()) - .query(ClientRow.class) - .list() + return clientRepository.getClients(owner) .stream() .map(this::toClient) .toList(); @@ -92,48 +73,28 @@ public class ClientManager implements RegisteredClientRepository, ClientManageme @Override public void addOwner(final Principal currentUser, final String id, final String newOwnerPrincipal) { - if (!getOwners(id).contains(currentUser.getName())) { + if (!clientRepository.getOwners(id).contains(currentUser.getName())) { throw new IllegalStateException(currentUser.getName() + " is not an owner of the client"); } - jdbc.sql("INSERT INTO client_owner (client_id, owner) VALUES (:id, :owner) ON DUPLICATE KEY UPDATE owner = owner") - .param("id", id) - .param("owner", newOwnerPrincipal) - .update(); + clientRepository.addClientOwner(newOwnerPrincipal, id); } @Override public boolean removeOwner(final Principal currentUser, final String id, final String owner) { - if (!getOwners(id).contains(currentUser.getName())) { + if (!clientRepository.getOwners(id).contains(currentUser.getName())) { throw new IllegalStateException(currentUser.getName() + " is not an owner of the client"); } if (currentUser.getName().equals(owner)) { return false; } else { - jdbc.sql("DELETE FROM client_owner WHERE client_id = :id AND owner = :owner") - .param("id", id) - .param("owner", owner) - .update(); + clientRepository.removeOwner(id, owner); return true; } } - private Optional getClientRowById(final String id) { - return jdbc.sql("SELECT id, client_id, name, contact_email, redirect_uri, scopes, client_secret FROM client WHERE id = :id") - .param("id", id) - .query(ClientRow.class) - .optional(); - } - - - private Optional getClientRowByClientId(final String clientId) { - return jdbc.sql("SELECT id, client_id, name, contact_email, redirect_uri, scopes, client_secret FROM client WHERE client_id = :clientId") - .param("clientId", clientId) - .query(ClientRow.class) - .optional(); - } private Client toClient(final ClientRow clientRow) { - List owners = getOwners(clientRow.id()); + List owners = clientRepository.getOwners(clientRow.id()); owners.sort(Comparator.naturalOrder()); Set scopes = clientRow.scopeSet(); boolean isPublic = clientRow.isPublic(); @@ -148,13 +109,6 @@ public class ClientManager implements RegisteredClientRepository, ClientManageme owners); } - private List getOwners(final String id) { - return jdbc.sql("SELECT owner FROM client_owner WHERE client_id = :clientId") - .param("clientId", id) - .query(String.class) - .list(); - } - // Used by various components of the OAuth 2.0 infrastructure to upgrade // the client secret if necessary based on the PasswordEncoder bean. @Override @@ -164,14 +118,14 @@ public class ClientManager implements RegisteredClientRepository, ClientManageme @Override public RegisteredClient findById(final String id) { - return getClientRowById(id) + return clientRepository.getClientRowById(id) .map(ClientManager::toRegisteredClient) .orElse(null); } @Override public RegisteredClient findByClientId(final String clientId) { - return getClientRowByClientId(clientId) + return clientRepository.getClientRowByClientId(clientId) .map(ClientManager::toRegisteredClient) .orElse(null); } @@ -216,24 +170,4 @@ public class ClientManager implements RegisteredClientRepository, ClientManageme .scopes(currentScopes -> currentScopes.addAll(clientRow.scopeSet())) .build(); } - - private record ClientRow( - String id, - String clientId, - String name, - String contactEmail, - String redirectUri, - String scopes, - String clientSecret) - { - private Set scopeSet() { - return Arrays.stream(this.scopes.split(" ")) - .filter(Predicate.not(String::isBlank)) - .collect(Collectors.toUnmodifiableSet()); - } - - private boolean isPublic() { - return clientSecret == null; - } - } } diff --git a/src/main/java/se/su/dsv/oauth2/admin/repository/ClientRepository.java b/src/main/java/se/su/dsv/oauth2/admin/repository/ClientRepository.java new file mode 100644 index 0000000..e69c2db --- /dev/null +++ b/src/main/java/se/su/dsv/oauth2/admin/repository/ClientRepository.java @@ -0,0 +1,21 @@ +package se.su.dsv.oauth2.admin.repository; + +import java.security.Principal; +import java.util.List; +import java.util.Optional; + +public interface ClientRepository { + void addNewClient(ClientRow clientRow); + + List getClients(Principal owner); + + void addClientOwner(String principalName, String id); + + void removeOwner(String id, String owner); + + List getOwners(String id); + + Optional getClientRowById(String id); + + Optional getClientRowByClientId(String clientId); +} diff --git a/src/main/java/se/su/dsv/oauth2/admin/repository/ClientRow.java b/src/main/java/se/su/dsv/oauth2/admin/repository/ClientRow.java new file mode 100644 index 0000000..30f6ae9 --- /dev/null +++ b/src/main/java/se/su/dsv/oauth2/admin/repository/ClientRow.java @@ -0,0 +1,26 @@ +package se.su.dsv.oauth2.admin.repository; + +import java.util.Arrays; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +public record ClientRow( + String id, + String clientId, + String name, + String contactEmail, + String redirectUri, + String scopes, + String clientSecret) +{ + public Set scopeSet() { + return Arrays.stream(this.scopes.split(" ")) + .filter(Predicate.not(String::isBlank)) + .collect(Collectors.toUnmodifiableSet()); + } + + public boolean isPublic() { + return clientSecret == null; + } +} -- 2.39.5 From 5cfbaa6535ab323562e3ea9c7081a85d0e2427ad Mon Sep 17 00:00:00 2001 From: Andreas Svanberg Date: Wed, 26 Mar 2025 12:49:31 +0100 Subject: [PATCH 02/10] Working Docker container --- Dockerfile | 45 +++++++++ pom.xml | 48 ++++++---- .../su/dsv/oauth2/EmbeddedConfiguration.java | 91 +++++++++++++++++++ .../dsv/oauth2/PersistentConfiguration.java | 2 + src/main/resources/application-embedded.yml | 5 + .../su/dsv/oauth2/EmbeddedContainerTest.java | 52 +++++++++++ 6 files changed, 223 insertions(+), 20 deletions(-) create mode 100644 Dockerfile create mode 100644 src/main/java/se/su/dsv/oauth2/EmbeddedConfiguration.java create mode 100644 src/main/resources/application-embedded.yml create mode 100644 src/test/java/se/su/dsv/oauth2/EmbeddedContainerTest.java diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..70cde08 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,45 @@ +FROM eclipse-temurin:23 AS build + +WORKDIR /build +COPY pom.xml mvnw ./ +COPY .mvn .mvn + +RUN ./mvnw dependency:copy-dependencies \ + --activate-profiles=!persistent \ + --define includeScope=compile \ + --define outputDirectory=lib + +RUN ./mvnw dependency:build-classpath \ + --activate-profiles=!persistent \ + --define includeScope=compile \ + --define mdep.outputFile=classpath \ + --define mdep.prefix=lib + +COPY src src + +RUN ./mvnw compile + +# Create as small a runtime as possible but Spring/Tomcat needs a lot of modules +RUN jlink \ + --output jre \ + --add-modules java.sql,java.desktop,java.management,java.naming,java.security.jgss,java.instrument + +FROM debian:stable-slim AS runtime + +WORKDIR /app + +COPY --from=build /build/jre jre +COPY --from=build /build/lib lib +COPY --from=build /build/classpath classpath +COPY --from=build /build/target/classes classes + +# Adds the output of Maven compilation to output +RUN echo ":classes" >> classpath + +EXPOSE 8080 + +CMD [ "./jre/bin/java" \ + , "-cp", "@classpath" \ + , "se.su.dsv.oauth2.AuthorizationServer" \ + , "--spring.profiles.active=dev,embedded" \ + ] diff --git a/pom.xml b/pom.xml index 4c28b99..c6fb8a1 100644 --- a/pom.xml +++ b/pom.xml @@ -23,10 +23,6 @@ - - org.springframework.boot - spring-boot-starter-jdbc - org.springframework.boot spring-boot-starter-oauth2-authorization-server @@ -52,21 +48,6 @@ ${jte.version} - - org.flywaydb - flyway-core - - - org.flywaydb - flyway-mysql - - - - org.mariadb.jdbc - mariadb-java-client - runtime - - org.springframework.boot @@ -146,7 +127,6 @@ ${project.basedir}/src/main/resources/templates ${project.build.directory}/jte-classes Html - true gg.jte.models.generator.ModelExtension @@ -172,4 +152,32 @@ + + + persistent + + true + + + + org.springframework.boot + spring-boot-starter-jdbc + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-mysql + + + org.mariadb.jdbc + mariadb-java-client + runtime + + + + + diff --git a/src/main/java/se/su/dsv/oauth2/EmbeddedConfiguration.java b/src/main/java/se/su/dsv/oauth2/EmbeddedConfiguration.java new file mode 100644 index 0000000..e26765b --- /dev/null +++ b/src/main/java/se/su/dsv/oauth2/EmbeddedConfiguration.java @@ -0,0 +1,91 @@ +package se.su.dsv.oauth2; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import se.su.dsv.oauth2.admin.repository.ClientRepository; +import se.su.dsv.oauth2.admin.repository.ClientRow; + +import java.security.Principal; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +@Configuration +@Profile("embedded") +public class EmbeddedConfiguration { + @Bean + public ClientRepository clientRepository() { + ArrayList clients = new ArrayList<>(); + ClientRow clientRow = getClientFromEnvironment(); + if (clientRow != null) { + clients.add(clientRow); + } + + return new InMemoryClientrepository(clients); + + } + + private static ClientRow getClientFromEnvironment() { + String clientId = System.getenv("CLIENT_ID"); + String clientSecret = System.getenv("CLIENT_SECRET"); + String redirectUri = System.getenv("CLIENT_REDIRECT_URI"); + String scopeString = System.getenv("CLIENT_SCOPES"); + + return new ClientRow(clientId, clientId, clientId, "dev@localhost", + redirectUri, scopeString, clientSecret); + } + + private static class InMemoryClientrepository implements ClientRepository { + private List clientRows; + private Map> clientOwners = new HashMap<>(); + + public InMemoryClientrepository(final List clients) { + this.clientRows = new ArrayList<>(clients); + } + + @Override + public void addNewClient(final ClientRow clientRow) { + clientRows.add(clientRow); + } + + @Override + public List getClients(final Principal owner) { + return List.copyOf(clientRows); + } + + @Override + public void addClientOwner(final String principalName, final String id) { + clientOwners.putIfAbsent(id, new ArrayList<>()); + clientOwners.get(id).add(principalName); + } + + @Override + public void removeOwner(final String id, final String owner) { + clientOwners.putIfAbsent(id, new ArrayList<>()); + clientOwners.get(id).remove(owner); + } + + @Override + public List getOwners(final String id) { + return clientOwners.getOrDefault(id, List.of()); + } + + @Override + public Optional getClientRowById(final String id) { + return clientRows.stream() + .filter(clientRow -> Objects.equals(clientRow.id(), id)) + .findAny(); + } + + @Override + public Optional getClientRowByClientId(final String clientId) { + return clientRows.stream() + .filter(clientRow -> Objects.equals(clientRow.clientId(), clientId)) + .findAny(); + } + } +} diff --git a/src/main/java/se/su/dsv/oauth2/PersistentConfiguration.java b/src/main/java/se/su/dsv/oauth2/PersistentConfiguration.java index 0641ac4..1e595e0 100644 --- a/src/main/java/se/su/dsv/oauth2/PersistentConfiguration.java +++ b/src/main/java/se/su/dsv/oauth2/PersistentConfiguration.java @@ -2,9 +2,11 @@ package se.su.dsv.oauth2; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springframework.jdbc.core.simple.JdbcClient; @Configuration +@Profile("!embedded") public class PersistentConfiguration { @Bean public JDBCClientRepository jdbcClientRepository(JdbcClient jdbcClient) { diff --git a/src/main/resources/application-embedded.yml b/src/main/resources/application-embedded.yml new file mode 100644 index 0000000..fcd4776 --- /dev/null +++ b/src/main/resources/application-embedded.yml @@ -0,0 +1,5 @@ +gg: + jte: + templateLocation: src/main/resources/templates + developmentMode: false + usePrecompiledTemplates: true diff --git a/src/test/java/se/su/dsv/oauth2/EmbeddedContainerTest.java b/src/test/java/se/su/dsv/oauth2/EmbeddedContainerTest.java new file mode 100644 index 0000000..5e4fdc8 --- /dev/null +++ b/src/test/java/se/su/dsv/oauth2/EmbeddedContainerTest.java @@ -0,0 +1,52 @@ +package se.su.dsv.oauth2; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriComponentsBuilder; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.images.builder.ImageFromDockerfile; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.nio.file.Paths; + +import static org.assertj.core.api.Assertions.assertThat; + +@Testcontainers +public class EmbeddedContainerTest { + + @Container + static GenericContainer container = new GenericContainer<>( + new ImageFromDockerfile() + .withFileFromPath(".", Paths.get("."))) + .withExposedPorts(8080) + .withEnv("CLIENT_ID", "client-id") + .withEnv("CLIENT_SECRET", "client-secret") + .withEnv("CLIENT_REDIRECT_URI", "http://localhost:8080") + .withEnv("CLIENT_SCOPES", "openid profile email"); + + @Test + public void working_container() { + String baseUri = UriComponentsBuilder.newInstance() + .scheme("http") + .host(container.getHost()) + .port(container.getMappedPort(8080)) + .toUriString(); + + RestClient restClient = RestClient.create(baseUri); + + ResponseEntity response = restClient + .get() + .retrieve() + .onStatus(ignored -> false) // treat all responses as successful and let asserts fail + .toEntity(String.class); + + assertThat(response.getStatusCode()) + .isEqualTo(HttpStatus.OK); + + assertThat(response.getBody()) + .contains("DSV"); + } +} -- 2.39.5 From 800748094e6009078ea69053bc25edb676885f66 Mon Sep 17 00:00:00 2001 From: Andreas Svanberg Date: Wed, 26 Mar 2025 13:45:43 +0100 Subject: [PATCH 03/10] Test the behaviour of the embedded container --- .../se/su/dsv/oauth2/admin/ClientManager.java | 2 +- .../su/dsv/oauth2/EmbeddedContainerTest.java | 122 +++++++++++++++++- 2 files changed, 117 insertions(+), 7 deletions(-) diff --git a/src/main/java/se/su/dsv/oauth2/admin/ClientManager.java b/src/main/java/se/su/dsv/oauth2/admin/ClientManager.java index da5ce0e..08573e1 100644 --- a/src/main/java/se/su/dsv/oauth2/admin/ClientManager.java +++ b/src/main/java/se/su/dsv/oauth2/admin/ClientManager.java @@ -113,7 +113,7 @@ public class ClientManager implements RegisteredClientRepository, ClientManageme // the client secret if necessary based on the PasswordEncoder bean. @Override public void save(final RegisteredClient registeredClient) { - throw new UnsupportedOperationException("ClientManager#save(RegisteredClient)"); + // TODO fix support for upgrading client secrets } @Override diff --git a/src/test/java/se/su/dsv/oauth2/EmbeddedContainerTest.java b/src/test/java/se/su/dsv/oauth2/EmbeddedContainerTest.java index 5e4fdc8..fb27112 100644 --- a/src/test/java/se/su/dsv/oauth2/EmbeddedContainerTest.java +++ b/src/test/java/se/su/dsv/oauth2/EmbeddedContainerTest.java @@ -1,8 +1,23 @@ package se.su.dsv.oauth2; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.jwk.source.JWKSourceBuilder; +import com.nimbusds.jose.proc.JWSVerificationKeySelector; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jose.util.DefaultResourceRetriever; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.proc.DefaultJWTProcessor; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; import org.springframework.web.client.RestClient; import org.springframework.web.util.UriComponentsBuilder; import org.testcontainers.containers.GenericContainer; @@ -10,33 +25,49 @@ import org.testcontainers.images.builder.ImageFromDockerfile; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; +import java.net.URI; +import java.net.URL; import java.nio.file.Paths; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; @Testcontainers public class EmbeddedContainerTest { + private static final String CLIENT_ID = "client-id"; + private static final String CLIENT_SECRET = "client-secret"; + private static final String CLIENT_REDIRECT_URI = "http://localhost:8080"; + private static final String AUTHORIZATION_HEADER = "Basic " + HttpHeaders.encodeBasicAuth(CLIENT_ID, CLIENT_SECRET, null); + @Container static GenericContainer container = new GenericContainer<>( new ImageFromDockerfile() .withFileFromPath(".", Paths.get("."))) .withExposedPorts(8080) - .withEnv("CLIENT_ID", "client-id") - .withEnv("CLIENT_SECRET", "client-secret") - .withEnv("CLIENT_REDIRECT_URI", "http://localhost:8080") + .withEnv("CLIENT_ID", CLIENT_ID) + .withEnv("CLIENT_SECRET", CLIENT_SECRET) + .withEnv("CLIENT_REDIRECT_URI", CLIENT_REDIRECT_URI) .withEnv("CLIENT_SCOPES", "openid profile email"); - @Test - public void working_container() { + private RestClient restClient; + private ObjectMapper objectMapper; + + @BeforeEach + public void setUp() { String baseUri = UriComponentsBuilder.newInstance() .scheme("http") .host(container.getHost()) .port(container.getMappedPort(8080)) .toUriString(); - RestClient restClient = RestClient.create(baseUri); + restClient = RestClient.create(baseUri); + objectMapper = new ObjectMapper(); + } + @Test + public void working_container() { ResponseEntity response = restClient .get() .retrieve() @@ -49,4 +80,83 @@ public class EmbeddedContainerTest { assertThat(response.getBody()) .contains("DSV"); } + + @Test + public void custom_authorize_flow_via_metadata_and_public_key_verification() throws Exception { + String metadata = restClient.get() + .uri("/.well-known/oauth-authorization-server") + .retrieve() + .body(String.class); + + JsonNode parsedMetadata = objectMapper.readTree(metadata); + + // 2. Get JWKS + URL jwksUri = URI.create(parsedMetadata.required("jwks_uri").asText()).toURL(); + JWKSource jwkSource = JWKSourceBuilder + .create(jwksUri, new DefaultResourceRetriever()) + .build(); + + final DefaultJWTProcessor processor = new DefaultJWTProcessor<>(); + JWSAlgorithm acceptedAlgorithms = JWSAlgorithm.RS256; + JWSVerificationKeySelector keySelector = + new JWSVerificationKeySelector<>(acceptedAlgorithms, jwkSource); + processor.setJWSKeySelector(keySelector); + + final MultiValueMap form = new LinkedMultiValueMap<>(); + form.put("principal", List.of("test")); + + String authorizationEndpoint = parsedMetadata.required("authorization_endpoint").asText(); + String authorizeUri = UriComponentsBuilder.fromUriString(authorizationEndpoint) + .queryParam("response_type", "code") + .queryParam("client_id", CLIENT_ID) + .queryParam("redirect_uri", CLIENT_REDIRECT_URI) + .queryParam("scope", "openid profile email") + .build() + .toUriString(); + + ResponseEntity authorizationResponse = restClient.post() + .uri(authorizeUri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(form) + .retrieve() + .toBodilessEntity(); + + URI redirectLocation = authorizationResponse.getHeaders().getLocation(); + assertThat(redirectLocation).isNotNull(); + + String query = redirectLocation.getQuery(); + assertThat(query).isNotBlank(); + + String code = query.substring("code=".length()); + assertThat(code).isNotBlank(); + + LinkedMultiValueMap tokenRequestBody = new LinkedMultiValueMap<>(); + tokenRequestBody.add("code", code); + tokenRequestBody.add("grant_type", "authorization_code"); + tokenRequestBody.add("redirect_uri", CLIENT_REDIRECT_URI); + + TokenResponse tokenResponse = restClient.post() + .uri(parsedMetadata.required("token_endpoint").asText()) + .header("Authorization", AUTHORIZATION_HEADER) + .body(tokenRequestBody) + .retrieve() + .body(TokenResponse.class); + + assertThat(tokenResponse).isNotNull(); + assertThat(tokenResponse.accessToken()).isNotBlank(); + assertThat(tokenResponse.idToken()).isNotBlank(); + + JWTClaimsSet accessTokenClaims = assertDoesNotThrow( + () -> processor.process(tokenResponse.accessToken(), null), + "Failed to verify access token"); + + assertThat(accessTokenClaims.getSubject()).isEqualTo("test"); + + JWTClaimsSet idTokenClaims = assertDoesNotThrow( + () -> processor.process(tokenResponse.idToken(), null), + "Failed to verify id token"); + + assertThat(accessTokenClaims.getSubject()).isEqualTo("test"); + assertThat(idTokenClaims.getSubject()).isEqualTo("test"); + } } -- 2.39.5 From ed0c510638b1338500bb68058c15dcae0f90f174 Mon Sep 17 00:00:00 2001 From: Andreas Svanberg Date: Wed, 26 Mar 2025 16:42:04 +0100 Subject: [PATCH 04/10] Add example how to use the embedded container --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index 8f8a5c8..dddcd6d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,18 @@ +## Using as an embedded Docker Compose service + +``` +services: + oauth2: + build: https://gitea.dsv.su.se/DMC/oauth2-authorization-server.git + restart: unless-stopped + ports: + - ":8080" + environment: + CLIENT_ID=awesome-app + CLIENT_SECRET=p4ssw0rd + CLIENT_REDIRECT_URI=http://localhost/oauth2/callback +``` + ## Development ### Prerequisites - JDK 17 (or later) -- 2.39.5 From 0f03bbca5203ab1ee857d3928da1212ffcc5e499 Mon Sep 17 00:00:00 2001 From: Andreas Svanberg Date: Wed, 26 Mar 2025 16:42:47 +0100 Subject: [PATCH 05/10] Fix null scope --- src/main/java/se/su/dsv/oauth2/admin/repository/ClientRow.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/se/su/dsv/oauth2/admin/repository/ClientRow.java b/src/main/java/se/su/dsv/oauth2/admin/repository/ClientRow.java index 30f6ae9..2764de8 100644 --- a/src/main/java/se/su/dsv/oauth2/admin/repository/ClientRow.java +++ b/src/main/java/se/su/dsv/oauth2/admin/repository/ClientRow.java @@ -15,6 +15,9 @@ public record ClientRow( String clientSecret) { public Set scopeSet() { + if (scopes == null) { + return Set.of(); + } return Arrays.stream(this.scopes.split(" ")) .filter(Predicate.not(String::isBlank)) .collect(Collectors.toUnmodifiableSet()); -- 2.39.5 From 220a8a454dede88abb06d903e73cc6a789e136ae Mon Sep 17 00:00:00 2001 From: Andreas Svanberg Date: Wed, 26 Mar 2025 18:33:36 +0100 Subject: [PATCH 06/10] Prevent session cookie conflicts on localhost When using this as an embedded Docker container, if the calling application has set a session cookie it is possibly that it is overwritten during the authorization process. --- src/main/java/se/su/dsv/oauth2/AuthorizationServer.java | 6 ++++++ .../se/su/dsv/oauth2/shibboleth/ShibbolethConfigurer.java | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/src/main/java/se/su/dsv/oauth2/AuthorizationServer.java b/src/main/java/se/su/dsv/oauth2/AuthorizationServer.java index 810cf99..478e989 100644 --- a/src/main/java/se/su/dsv/oauth2/AuthorizationServer.java +++ b/src/main/java/se/su/dsv/oauth2/AuthorizationServer.java @@ -24,6 +24,7 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.intercept.AuthorizationFilter; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; import org.springframework.security.web.authentication.preauth.j2ee.J2eePreAuthenticatedProcessingFilter; +import org.springframework.security.web.context.RequestAttributeSecurityContextRepository; import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher; import se.su.dsv.oauth2.shibboleth.Entitlement; import se.su.dsv.oauth2.shibboleth.ShibbolethAuthenticationDetailsSource; @@ -153,6 +154,11 @@ public class AuthorizationServer extends SpringBootServletInitializer { // Using a custom authentication details source to extract the Shibboleth attributes // and convert them to the relevant Spring Security objects. object.setAuthenticationDetailsSource(new ShibbolethAuthenticationDetailsSource()); + + // Prevent session creation + // It can cause conflicts when running on the same host as an embedded docker container + // as it overwrites the session cookie (it does not factor in port) + object.setSecurityContextRepository(new RequestAttributeSecurityContextRepository()); return object; } }; diff --git a/src/main/java/se/su/dsv/oauth2/shibboleth/ShibbolethConfigurer.java b/src/main/java/se/su/dsv/oauth2/shibboleth/ShibbolethConfigurer.java index 982a55f..1aa0252 100644 --- a/src/main/java/se/su/dsv/oauth2/shibboleth/ShibbolethConfigurer.java +++ b/src/main/java/se/su/dsv/oauth2/shibboleth/ShibbolethConfigurer.java @@ -7,6 +7,7 @@ import org.springframework.security.web.authentication.preauth.PreAuthenticatedA import org.springframework.security.web.authentication.preauth.PreAuthenticatedGrantedAuthoritiesUserDetailsService; import org.springframework.security.web.authentication.preauth.j2ee.J2eePreAuthenticatedProcessingFilter; import org.springframework.security.web.authentication.preauth.x509.X509AuthenticationFilter; +import org.springframework.security.web.context.RequestAttributeSecurityContextRepository; public class ShibbolethConfigurer extends AbstractHttpConfigurer { @Override @@ -24,6 +25,12 @@ public class ShibbolethConfigurer extends AbstractHttpConfigurer Date: Wed, 26 Mar 2025 18:38:51 +0100 Subject: [PATCH 07/10] Fix trying to modify immutable collection --- src/main/java/se/su/dsv/oauth2/admin/ClientManager.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/se/su/dsv/oauth2/admin/ClientManager.java b/src/main/java/se/su/dsv/oauth2/admin/ClientManager.java index 08573e1..966e2c6 100644 --- a/src/main/java/se/su/dsv/oauth2/admin/ClientManager.java +++ b/src/main/java/se/su/dsv/oauth2/admin/ClientManager.java @@ -14,6 +14,7 @@ import se.su.dsv.oauth2.admin.repository.ClientRow; import java.security.Principal; import java.time.Duration; +import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Optional; @@ -94,7 +95,7 @@ public class ClientManager implements RegisteredClientRepository, ClientManageme private Client toClient(final ClientRow clientRow) { - List owners = clientRepository.getOwners(clientRow.id()); + List owners = new ArrayList<>(clientRepository.getOwners(clientRow.id())); owners.sort(Comparator.naturalOrder()); Set scopes = clientRow.scopeSet(); boolean isPublic = clientRow.isPublic(); -- 2.39.5 From 77ca199b59c2968872b3f9df63481a8f3c1c44a5 Mon Sep 17 00:00:00 2001 From: Andreas Svanberg Date: Wed, 26 Mar 2025 18:39:50 +0100 Subject: [PATCH 08/10] Small improvement to the layering of the Dockerfile --- Dockerfile | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 70cde08..5decd7b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,12 @@ FROM eclipse-temurin:23 AS build WORKDIR /build + +# Create as small a runtime as possible but Spring/Tomcat needs a lot of modules +RUN jlink \ + --output jre \ + --add-modules java.sql,java.desktop,java.management,java.naming,java.security.jgss,java.instrument + COPY pom.xml mvnw ./ COPY .mvn .mvn @@ -19,11 +25,6 @@ COPY src src RUN ./mvnw compile -# Create as small a runtime as possible but Spring/Tomcat needs a lot of modules -RUN jlink \ - --output jre \ - --add-modules java.sql,java.desktop,java.management,java.naming,java.security.jgss,java.instrument - FROM debian:stable-slim AS runtime WORKDIR /app -- 2.39.5 From 016f4d226b7fa9cd8d432a04daf53beea16cfa8b Mon Sep 17 00:00:00 2001 From: Andreas Svanberg Date: Wed, 26 Mar 2025 18:44:18 +0100 Subject: [PATCH 09/10] Fix client ownership in embedded container --- .../java/se/su/dsv/oauth2/EmbeddedConfiguration.java | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/main/java/se/su/dsv/oauth2/EmbeddedConfiguration.java b/src/main/java/se/su/dsv/oauth2/EmbeddedConfiguration.java index e26765b..2896fb9 100644 --- a/src/main/java/se/su/dsv/oauth2/EmbeddedConfiguration.java +++ b/src/main/java/se/su/dsv/oauth2/EmbeddedConfiguration.java @@ -8,9 +8,7 @@ import se.su.dsv.oauth2.admin.repository.ClientRow; import java.security.Principal; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -41,7 +39,6 @@ public class EmbeddedConfiguration { private static class InMemoryClientrepository implements ClientRepository { private List clientRows; - private Map> clientOwners = new HashMap<>(); public InMemoryClientrepository(final List clients) { this.clientRows = new ArrayList<>(clients); @@ -59,19 +56,15 @@ public class EmbeddedConfiguration { @Override public void addClientOwner(final String principalName, final String id) { - clientOwners.putIfAbsent(id, new ArrayList<>()); - clientOwners.get(id).add(principalName); } @Override public void removeOwner(final String id, final String owner) { - clientOwners.putIfAbsent(id, new ArrayList<>()); - clientOwners.get(id).remove(owner); } @Override public List getOwners(final String id) { - return clientOwners.getOrDefault(id, List.of()); + return List.of("dev@localhost"); } @Override -- 2.39.5 From 806de61859219ce612ac2b405e63d552a599b490 Mon Sep 17 00:00:00 2001 From: Andreas Svanberg Date: Wed, 26 Mar 2025 18:49:40 +0100 Subject: [PATCH 10/10] Minor cleanup --- src/main/resources/application-embedded.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/resources/application-embedded.yml b/src/main/resources/application-embedded.yml index fcd4776..6da4a58 100644 --- a/src/main/resources/application-embedded.yml +++ b/src/main/resources/application-embedded.yml @@ -1,5 +1,4 @@ gg: jte: - templateLocation: src/main/resources/templates developmentMode: false usePrecompiledTemplates: true -- 2.39.5