Skip to content
Snippets Groups Projects
Commit 23e7e9e1 authored by Jan Zickermann's avatar Jan Zickermann
Browse files

#3 OZG-7112 auth2: Configure resource parameter

parent 22e14ce7
Branches
Tags
1 merge request!3Resolve "Anmeldung am Servicekonto & Token Erstellung"
Pipeline #1149 passed
......@@ -19,7 +19,6 @@
<properties>
<api-lib.version>0.13.0</api-lib.version>
<nachrichten-manager.version>2.14.0</nachrichten-manager.version>
<testcontainers-keycloak.version>3.2.0</testcontainers-keycloak.version>
<mockserver-client.version>5.15.0</mockserver-client.version>
</properties>
<dependencies>
......@@ -61,12 +60,6 @@
<type>test-jar</type>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.github.dasniko</groupId>
<artifactId>testcontainers-keycloak</artifactId>
<version>${testcontainers-keycloak.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
......
......@@ -8,18 +8,26 @@ import org.springframework.context.annotation.Primary;
import org.springframework.core.env.Environment;
import org.springframework.security.oauth2.client.AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.InMemoryReactiveOAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientProvider;
import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientProviderBuilder;
import org.springframework.security.oauth2.client.endpoint.WebClientReactiveClientCredentialsTokenResponseClient;
import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.reactive.function.client.ServerOAuth2AuthorizedClientExchangeFilterFunction;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.reactive.function.client.WebClient;
import lombok.RequiredArgsConstructor;
@Configuration
@RequiredArgsConstructor
public class WebClientConfiguration {
private final Environment environment;
@Bean("osi2PostfachWebClient")
public WebClient osi2PostfachWebClient(
ServerOAuth2AuthorizedClientExchangeFilterFunction serverOAuth2AuthorizedClientExchangeFilterFunction,
Environment environment) {
ServerOAuth2AuthorizedClientExchangeFilterFunction serverOAuth2AuthorizedClientExchangeFilterFunction) {
var url = Objects.requireNonNull(
environment.getProperty("ozgcloud.osiv2-postfach.api.url"),
"ozgcloud.osiv2-postfach.api.url is not set");
......@@ -46,12 +54,33 @@ public class WebClientConfiguration {
var authorizedClientManager = new AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager(
clientRegistrations, clientService);
authorizedClientManager.setAuthorizedClientProvider(
ReactiveOAuth2AuthorizedClientProviderBuilder.builder()
.clientCredentials()
.build());
authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider());
return authorizedClientManager;
}
ReactiveOAuth2AuthorizedClientProvider authorizedClientProvider() {
return ReactiveOAuth2AuthorizedClientProviderBuilder.builder()
.clientCredentials(builder ->
builder.accessTokenResponseClient(getClientCredentialsTokenResponseClient())
)
.build();
}
WebClientReactiveClientCredentialsTokenResponseClient getClientCredentialsTokenResponseClient() {
var client = new WebClientReactiveClientCredentialsTokenResponseClient();
var resource = Objects.requireNonNull(
environment.getProperty("ozgcloud.osiv2-postfach.api.resource"),
"ozgcloud.osiv2-postfach.api.resource is not set"
);
client.addParametersConverter(source -> {
MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
// Pass a resource indicator parameter https://datatracker.ietf.org/doc/html/rfc8707
parameters.add("resource", resource);
return parameters;
});
return client;
}
}
......@@ -12,10 +12,19 @@ spring:
client-secret: 'changeme'
scope: default, access_urn:some:scope:for:ozgkopfstelle
authorization-grant-type: 'client_credentials'
client-authentication-method: client_secret_post
provider:
osi2:
token-uri: http://localhost:8080/realms/master/protocol/openid-connect/token
token-uri: 'https://idp.serviceportal-stage.schleswig-holstein.de/webidp2/connect/token'
ozgcloud:
osiv2-postfach:
enabled: true
api:
url: 'replaceme'
resource: 'urn:dataport:osi:postfach:rz2:stage:sh'
url: 'https://api-gateway-stage.dataport.de:443/api/osi_postfach/1.0.0'
tenant: 'SH'
name-identifier: 'ozgkopfstelle'
http-proxy:
host: 127.0.0.1
port: 3128
authentication-required: false
package de.ozgcloud.nachrichten.postfach.osiv2;
import static de.ozgcloud.nachrichten.postfach.osiv2.factory.JwtFactory.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockserver.matchers.Times.*;
import static org.mockserver.model.Header.*;
import static org.mockserver.model.HttpRequest.*;
import static org.mockserver.model.HttpResponse.*;
import static org.mockserver.model.Parameter.*;
import static org.mockserver.model.ParameterBody.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
......@@ -10,15 +15,16 @@ import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.mockserver.client.MockServerClient;
import org.mockserver.matchers.Times;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import de.ozgcloud.nachrichten.postfach.PostfachNachricht;
import de.ozgcloud.nachrichten.postfach.osiv2.extension.JwtParser;
import de.ozgcloud.nachrichten.postfach.osiv2.extension.Jwt;
import de.ozgcloud.nachrichten.postfach.osiv2.extension.OsiMockServerExtension;
import de.ozgcloud.nachrichten.postfach.osiv2.factory.JwtFactory;
import lombok.SneakyThrows;
@SpringBootTest(classes = TestApplication.class, webEnvironment = SpringBootTest.WebEnvironment.NONE)
public class OsiPostfachRemoteServiceITCase {
......@@ -36,46 +42,83 @@ public class OsiPostfachRemoteServiceITCase {
@DynamicPropertySource
static void dynamicProperties(DynamicPropertyRegistry registry) {
registry.add("spring.security.oauth2.client.provider.osi2.token-uri", OSI_MOCK_SERVER_EXTENSION::getTokenUri);
registry.add("ozgcloud.osiv2-postfach.api.url", OSI_MOCK_SERVER_EXTENSION::getPostfachMockServerUrl);
registry.add("spring.security.oauth2.client.provider.osi2.token-uri", OSI_MOCK_SERVER_EXTENSION::getAccessTokenUrl);
registry.add("spring.security.oauth2.client.registration.osi2.scope", () -> CLIENT_SCOPES);
registry.add("spring.security.oauth2.client.registration.osi2.client-id", () -> CLIENT_ID);
registry.add("ozgcloud.osiv2-postfach.api.url", OSI_MOCK_SERVER_EXTENSION::getPostfachFacadeUrl);
registry.add("ozgcloud.osiv2-postfach.api.resource", () -> RESOURCE_URN);
}
private MockServerClient mockServerClient;
private MockServerClient mockClient;
@BeforeEach
@SneakyThrows
public void setup() {
mockServerClient = OSI_MOCK_SERVER_EXTENSION.getMockServerClient();
mockServerClient
mockClient = OSI_MOCK_SERVER_EXTENSION.getMockClient();
mockClient
.when(
request()
.withMethod("GET")
.withPath("/dummy"),
Times.exactly(1)
.withMethod("POST")
.withPath("/access-token")
.withHeaders(
header("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8")
)
.withBody(
params(
param("grant_type", "client_credentials"),
param("client_id", CLIENT_ID),
param("client_secret", "changeme"),
param("scope", String.join(" ", CLIENT_SCOPES)),
param("resource", RESOURCE_URN)
)
),
exactly(1)
)
.respond(
response()
.withStatusCode(200)
.withHeader("Content-Type", "application/json")
.withBody(
JwtFactory.createTokenResponse(
JwtFactory.createAccessTokenExampleWithExpireIn(900)
)
)
);
}
@DisplayName("send message")
@Nested
class TestSendMessage {
@DisplayName("should send dummy request with jwt")
@Test
void shouldSendDummyRequestWithJwt() {
mockClient
.when(
request()
.withMethod("GET")
.withPath("/dummy"),
exactly(1)
)
.respond(
response()
.withStatusCode(200)
);
osiPostfachRemoteService.sendMessage(postfachNachricht);
var requests = mockServerClient.retrieveRecordedRequests(
var requests = mockClient.retrieveRecordedRequests(
request()
.withMethod("GET")
.withPath("/dummy")
);
assertThat(requests).hasSize(1);
String clientId = JwtParser.parseBody(
var jwt = Jwt.parseAuthHeaderValue(
requests[0].getHeader("Authorization").getFirst()
).read("$.client_id");
assertThat(clientId).isEqualTo("OZG-Kopfstelle");
);
assertThat(jwt.body().read("$.client_id", String.class)).isEqualTo(CLIENT_ID);
assertThat(jwt.body().read("$.aud", String.class)).isEqualTo(RESOURCE_URN);
}
}
......
package de.ozgcloud.nachrichten.postfach.osiv2.extension;
import static de.ozgcloud.nachrichten.postfach.osiv2.factory.JsonUtil.*;
import java.io.Serializable;
import java.util.Map;
import org.eclipse.jgit.util.Base64;
import com.jayway.jsonpath.JsonPath;
......@@ -7,26 +12,48 @@ import com.jayway.jsonpath.ReadContext;
import lombok.SneakyThrows;
public class JwtParser {
public record Jwt(String token) {
public record JwtParts(ReadContext header, ReadContext body, byte[] signature) {
}
@SneakyThrows
public static ReadContext parseBody(String authorizationHeaderValue) {
var jwtParts = splitIntoSignatureAndHeaderAndBody(
discardBearerPrefix(authorizationHeaderValue)
public static JwtParts parseAuthHeaderValue(String authorizationHeaderValue) {
return new Jwt(discardBearerPrefix(authorizationHeaderValue)).parse();
}
public String scope() {
return parse().body().read("$.scope");
}
public Integer issuedAt() {
return parse().body().read("$.iat", Integer.class);
}
public Integer expireTime() {
return parse().body().read("$.exp", Integer.class);
}
public JwtParts parse() {
var jwtParts = splitIntoHeaderAndBodyAndSignature(this.token);
return new JwtParts(
parseJsonPath(base64UrlDecode(jwtParts[0])),
parseJsonPath(base64UrlDecode(jwtParts[1])),
base64UrlDecode(jwtParts[2])
);
var bodyPart = jwtParts[1];
return parseJsonPartFromUrlEncodedBase64(bodyPart);
}
private static ReadContext parseJsonPartFromUrlEncodedBase64(String base64EncodedPayload) {
return JsonPath.parse(new String(base64UrlDecode(base64EncodedPayload)));
private static ReadContext parseJsonPath(byte[] bytes) {
return JsonPath.parse(new String(bytes));
}
private static String discardBearerPrefix(String authorizationHeaderValue) {
return authorizationHeaderValue.substring("Bearer ".length());
}
private static String[] splitIntoSignatureAndHeaderAndBody(String jwt) {
private static String[] splitIntoHeaderAndBodyAndSignature(String jwt) {
var jwtParts = jwt.split("\\.");
if (jwtParts.length != 3) {
throw new IllegalArgumentException("Invalid JWT token");
......@@ -44,4 +71,19 @@ public class JwtParser {
}
return Base64.decode(base64);
}
@SneakyThrows
public static Jwt create(Map<String, Serializable> header, Map<String, Serializable> body, byte[] signature) {
var headerJson = toJson(header);
var bodyJson = toJson(body);
var token = base64UrlEncode(headerJson) + "." + base64UrlEncode(bodyJson) + "." + base64UrlEncode(new String(signature));
return new Jwt(token);
}
private static String base64UrlEncode(String input) {
String base64 = Base64.encodeBytes(input.getBytes());
return base64.replace('+', '-').replace('/', '_').replace("=", "");
}
}
......@@ -2,18 +2,35 @@ package de.ozgcloud.nachrichten.postfach.osiv2.extension;
import static org.assertj.core.api.Assertions.*;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class JwtParserTest {
class JwtTest {
@DisplayName("should parse")
@Test
void shouldParse() {
var headerValue = "Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJ1aV9EaHVXUzdocFhzV3dZTHRlOHFIRkR4bnNFYldlVmJBZ0pzaWpsWGw4In0.eyJleHAiOjE3MzEwNzA5NTEsImlhdCI6MTczMTA3MDg5MSwianRpIjoiZTFjNWE4YjEtZWEyYS00Mzg5LTkyNDQtZWE5Mjc4M2IyZDA1IiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDozMjkyNy9yZWFsbXMvbWFzdGVyIiwic3ViIjoiNTg1MzdjMGQtMzU3MS00MDExLWIxM2ItZDY1MGZjOGUwZjQ0IiwidHlwIjoiQmVhcmVyIiwiYXpwIjoiT1pHLUtvcGZzdGVsbGUiLCJzY29wZSI6ImFjY2Vzc191cm46c29tZTpzY29wZTpmb3I6b3pna29wZnN0ZWxsZSBkZWZhdWx0IiwiY2xpZW50SG9zdCI6IjE3Mi4xNy4wLjEiLCJjbGllbnRBZGRyZXNzIjoiMTcyLjE3LjAuMSIsImNsaWVudF9pZCI6Ik9aRy1Lb3Bmc3RlbGxlIn0.MRGusCVssO-fHRp8-tEcdQWE7QVi3P0iHdmO4rGUwj_17KtHzQAT8ShZEVvE8oL-y-XKAPh7eT9will3oON1qhW6GHbZk5Xds4P5u8D0iHNl8nCSi_YS122v9Q1gwPrwPtVH26AKrdNM_YYv0AzT63gOVUoK4YY4jLhow3Uid2AVr2OMNAtcSPMysHXS1VeQRrhOm33JF_WVlguIHNjRpvRqCULkwywBRXDJm2mHOohkXFf10nM3ORAlmeElJCZa7Lg0zeg3q957Z9Mv5KbZA1X_QiHR5qpaDvimn0R_TTCZTGWM00GfyEHi2UU1s2ZfBeZTLOTNg2MUuDgA1cI7CQ";
var context = JwtParser.parseBody(headerValue);
var context = Jwt.parseAuthHeaderValue(headerValue);
String value = context.read("$.client_id");
String value = context.body().read("$.client_id");
assertThat(value).isEqualTo("OZG-Kopfstelle");
}
@DisplayName("should create")
@Test
void shouldCreate() {
var jwt = Jwt.create(Map.of(
"alg", "RS256"
), Map.of(
"sub", "58537c0d-3571-4011-b13b-d650fc8e0f44"
), "signature".getBytes());
var jwtParts = jwt.parse();
assertThat(jwtParts.header().read("$.alg", String.class)).isEqualTo("RS256");
assertThat(jwtParts.body().read("$.sub", String.class)).isEqualTo("58537c0d-3571-4011-b13b-d650fc8e0f44");
assertThat(jwtParts.signature()).isEqualTo("signature".getBytes());
}
}
package de.ozgcloud.nachrichten.postfach.osiv2.extension;
import static org.assertj.core.api.Assertions.*;
import java.util.List;
import java.util.function.Supplier;
import jakarta.ws.rs.core.Response;
import org.junit.jupiter.api.extension.AfterAllCallback;
import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.BeforeAllCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.mockserver.client.MockServerClient;
import org.testcontainers.containers.MockServerContainer;
import org.testcontainers.containers.output.OutputFrame;
import org.testcontainers.utility.DockerImageName;
import dasniko.testcontainers.keycloak.KeycloakContainer;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
@Log4j2
@Getter
@RequiredArgsConstructor
public class OsiMockServerExtension implements BeforeAllCallback, AfterAllCallback, AfterEachCallback {
private MockServerClient mockServerClient;
private MockServerContainer mockServerContainer;
private KeycloakContainer keycloakContainer;
private MockServerClient mockClient;
private final MockServerContainer mockServerContainer = new MockServerContainer(DockerImageName.parse("mockserver/mockserver")
.withTag("mockserver-5.15.0"))
.withLogConsumer(outputFrame -> {
var line = outputFrame.getUtf8String().stripTrailing();
if (outputFrame.getType() == OutputFrame.OutputType.STDERR) {
LOG.error(line);
} else {
LOG.info(line);
}
});
@Override
public void beforeAll(ExtensionContext context) {
setupPostfachMockServer();
setupKeycloak();
setupMockServer();
}
@Override
public void afterEach(ExtensionContext context) {
mockServerClient.reset();
mockClient.reset();
}
@Override
public void afterAll(ExtensionContext context) {
mockServerContainer.stop();
mockServerClient.stop();
keycloakContainer.stop();
mockClient.stop();
}
private void setupPostfachMockServer() {
mockServerContainer = new MockServerContainer(DockerImageName.parse("mockserver/mockserver")
.withTag("mockserver-5.15.0"));
private void setupMockServer() {
mockServerContainer.start();
mockServerClient = new MockServerClient(mockServerContainer.getHost(), mockServerContainer.getServerPort());
}
private void setupKeycloak() {
keycloakContainer = new KeycloakContainer("quay.io/keycloak/keycloak:24.0.5");
keycloakContainer.start();
try (var keycloak = keycloakContainer.getKeycloakAdminClient()) {
keycloak.tokenManager().getAccessToken();
var masterRealm = keycloak.realm("master");
var clientScopes = List.of("default", "access_urn:some:scope:for:ozgkopfstelle");
clientScopes.forEach(scope -> createClientScope(masterRealm, scope));
createPostfachClient(masterRealm, clientScopes);
}
}
private void createPostfachClient(RealmResource realmResource, List<String> clientScopes) {
var clients = realmResource.clients();
var postfach = new ClientRepresentation();
postfach.setClientId("OZG-Kopfstelle");
postfach.setSecret("changeme");
postfach.setOptionalClientScopes(clientScopes);
postfach.setServiceAccountsEnabled(true);
postfach.setEnabled(true);
verifyResponseOk(() -> clients.create(postfach));
}
private void createClientScope(RealmResource realmResource, String scopeName) {
var clientScopes = realmResource.clientScopes();
var clientScopeRepresentation = new ClientScopeRepresentation();
clientScopeRepresentation.setName(scopeName);
clientScopeRepresentation.setProtocol("openid-connect");
verifyResponseOk(() -> clientScopes.create(clientScopeRepresentation));
}
private void verifyResponseOk(Supplier<Response> responseSupplier) {
try (var response = responseSupplier.get()) {
assertThat(response.getStatus()).isEqualTo(201);
}
mockClient = new MockServerClient(
mockServerContainer.getHost(),
mockServerContainer.getServerPort()
);
}
public String getTokenUri() {
return getAuthProtocolUrl() + "/token";
public String getAccessTokenUrl() {
return getMockServerUrl() + "/access-token";
}
public String getAuthProtocolUrl() {
return keycloakContainer.getAuthServerUrl() + "/realms/master/protocol/openid-connect";
public String getPostfachFacadeUrl() {
return getMockServerUrl();
}
public String getPostfachMockServerUrl() {
return "http://" + mockServerClient.remoteAddress().getHostName() + ":" + mockServerClient.remoteAddress().getPort();
private String getMockServerUrl() {
return "http://" + mockClient.remoteAddress().getHostName() + ":" + mockClient.remoteAddress().getPort();
}
}
package de.ozgcloud.nachrichten.postfach.osiv2.factory;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.SneakyThrows;
public class JsonUtil {
private static final ObjectMapper jsonMapper = new ObjectMapper();
@SneakyThrows
public static String toJson(Object object) {
return jsonMapper.writeValueAsString(object);
}
}
package de.ozgcloud.nachrichten.postfach.osiv2.factory;
import static groovy.json.JsonOutput.*;
import java.util.List;
import java.util.Map;
import de.ozgcloud.nachrichten.postfach.osiv2.extension.Jwt;
import lombok.SneakyThrows;
public class JwtFactory {
public static final String CLIENT_ID = "ITCase OZG Kopfstelle";
public static final String RESOURCE_URN = "urn:dataport:osi:postfach:rz2:itcase:sh";
public static final List<String> CLIENT_SCOPES = List.of("default", "access_urn:itcase:scope:ozgkopfstelle");
public static Jwt createAccessTokenExampleWithExpireIn(Integer expireInSeconds) {
var nowEpochSeconds = System.currentTimeMillis() / 1000;
return Jwt.create(
Map.of(
"alg", "RS256",
"kid", "...",
"x5t", "...",
"typ", "at+jwt"
),
Map.of(
"iss", "https://idp.serviceportal-stage.schleswig-holstein.de/webidp2",
"nbf", nowEpochSeconds,
"iat", nowEpochSeconds,
"exp", nowEpochSeconds + expireInSeconds,
"aud", RESOURCE_URN,
"scope", String.join(" ", CLIENT_SCOPES),
"client_id", CLIENT_ID,
"client_tenant", "urn:osp:names:realm:stage:sh",
"jti", "..."
),
"signature".getBytes());
}
@SneakyThrows
public static String createTokenResponse(Jwt accessToken) {
return toJson(Map.of(
"access_token", accessToken.token(),
"token_type", "Bearer",
"expires_in", accessToken.expireTime() - accessToken.issuedAt(),
"scope", accessToken.scope()
));
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment