From 30e3ee69463b6ed8dff8c05f5db2ad75dae89c8e Mon Sep 17 00:00:00 2001 From: Thomas Richner Date: Tue, 30 Jan 2024 19:22:51 +0100 Subject: [PATCH] ARC-979: Add OpenID client --- .../fedclient/api/OpenIdClient.java | 78 +++++++++ .../fedclient/api/ParBodyBuilder.java | 66 ++++++++ .../fedclient/api/UrlFormBodyBuilder.java | 49 ++++++ .../fedclient/api/OpenIdClientTest.java | 159 ++++++++++++++++++ .../fedclient/api/ParBodyBuilderTest.java | 34 ++++ .../fedclient/api/UrlFormBodyBuilderTest.java | 34 ++++ 6 files changed, 420 insertions(+) create mode 100644 gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/OpenIdClient.java create mode 100644 gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/ParBodyBuilder.java create mode 100644 gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/UrlFormBodyBuilder.java create mode 100644 gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/api/OpenIdClientTest.java create mode 100644 gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/api/ParBodyBuilderTest.java create mode 100644 gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/api/UrlFormBodyBuilderTest.java diff --git a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/OpenIdClient.java b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/OpenIdClient.java new file mode 100644 index 0000000..481fab2 --- /dev/null +++ b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/OpenIdClient.java @@ -0,0 +1,78 @@ +package com.oviva.gesundheitsid.fedclient.api; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.oviva.gesundheitsid.fedclient.api.HttpClient.Header; +import com.oviva.gesundheitsid.fedclient.api.HttpClient.Request; +import com.oviva.gesundheitsid.util.JsonCodec; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import java.net.URI; +import java.util.List; + +public class OpenIdClient { + + private final HttpClient httpClient; + + public OpenIdClient(HttpClient httpClient) { + this.httpClient = httpClient; + } + + public TokenResponse exchangePkceCode( + URI tokenEndpoint, String code, String redirectUri, String clientId, String codeVerifier) { + + var body = + UrlFormBodyBuilder.create() + .param("grant_type", "authorization_code") + .param("redirect_uri", redirectUri) + .param("client_id", clientId) + .param("code", code) + .param("code_verifier", codeVerifier) + .build(); + + var headers = + List.of( + new Header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON), + new Header(HttpHeaders.CONTENT_TYPE, UrlFormBodyBuilder.MEDIA_TYPE)); + + var req = new Request(tokenEndpoint, "POST", headers, body); + + var res = httpClient.call(req); + if (res.status() != 200) { + throw HttpExceptions.httpFailBadStatus(req.method(), tokenEndpoint, res.status()); + } + + return JsonCodec.readValue(res.body(), TokenResponse.class); + } + + public ParResponse requestPushedUri( + URI pushedAuthorizationRequestUri, ParBodyBuilder parBodyBuilder) { + + var headers = + List.of( + new Header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON), + new Header(HttpHeaders.CONTENT_TYPE, UrlFormBodyBuilder.MEDIA_TYPE)); + + var req = new Request(pushedAuthorizationRequestUri, "POST", headers, parBodyBuilder.build()); + + var res = httpClient.call(req); + if (res.status() != 201) { + throw HttpExceptions.httpFailBadStatus( + req.method(), pushedAuthorizationRequestUri, res.status()); + } + + return JsonCodec.readValue(res.body(), ParResponse.class); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record ParResponse( + @JsonProperty("request_uri") String requestUri, @JsonProperty("expires_in") long expiresIn) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record TokenResponse( + @JsonProperty("access_token") String accessToken, + @JsonProperty("expires_in") long expiresIn, + @JsonProperty("id_token") String idToken, + @JsonProperty("token_type") String tokenType, + @JsonProperty("refresh_token") String refreshToken) {} +} diff --git a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/ParBodyBuilder.java b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/ParBodyBuilder.java new file mode 100644 index 0000000..2abba95 --- /dev/null +++ b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/ParBodyBuilder.java @@ -0,0 +1,66 @@ +package com.oviva.gesundheitsid.fedclient.api; + +import java.net.URI; +import java.util.List; + +public class ParBodyBuilder { + + private final UrlFormBodyBuilder form = UrlFormBodyBuilder.create(); + + protected ParBodyBuilder() {} + + public static ParBodyBuilder create() { + return new ParBodyBuilder(); + } + + public ParBodyBuilder responseType(String t) { + form.param("response_type", t); + return this; + } + + public ParBodyBuilder codeChallenge(String cc) { + form.param("code_challenge", cc); + return this; + } + + public ParBodyBuilder codeChallengeMethod(String method) { + form.param("code_challenge_method", method); + return this; + } + + public ParBodyBuilder scopes(List scopes) { + var v = String.join(" ", scopes); + form.param("scope", v); + return this; + } + + public ParBodyBuilder state(String state) { + form.param("state", state); + return this; + } + + public ParBodyBuilder nonce(String nonce) { + form.param("nonce", nonce); + return this; + } + + public ParBodyBuilder clientId(String clientId) { + form.param("client_id", clientId); + return this; + } + + public ParBodyBuilder redirectUri(URI redirect) { + form.param("redirect_uri", redirect.toString()); + return this; + } + + // "gematik-ehealth-loa-high" or "gematik-ehealth-loa-substancial" + public ParBodyBuilder acrValues(String acrValues) { + form.param("acr_values", acrValues); + return this; + } + + public byte[] build() { + return form.build(); + } +} diff --git a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/UrlFormBodyBuilder.java b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/UrlFormBodyBuilder.java new file mode 100644 index 0000000..8fd80c0 --- /dev/null +++ b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/UrlFormBodyBuilder.java @@ -0,0 +1,49 @@ +package com.oviva.gesundheitsid.fedclient.api; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class UrlFormBodyBuilder { + + public static final String MEDIA_TYPE = "application/x-www-form-urlencoded;charset=UTF-8"; + private final List fields = new ArrayList<>(); + + private UrlFormBodyBuilder() {} + + public static UrlFormBodyBuilder create() { + return new UrlFormBodyBuilder(); + } + + public UrlFormBodyBuilder param(String name, String value) { + fields.add(new Param(name, value)); + return this; + } + + public byte[] build() { + return fields.stream() + .flatMap(this::encodeParam) + .collect(Collectors.joining("&")) + .getBytes(StandardCharsets.UTF_8); + } + + private Stream encodeParam(Param p) { + if (p.name() == null || p.name().isBlank()) { + return Stream.empty(); + } + + if (p.value() == null) { + return Stream.of(URLEncoder.encode(p.name(), StandardCharsets.UTF_8)); + } + + return Stream.of( + URLEncoder.encode(p.name(), StandardCharsets.UTF_8) + + "=" + + URLEncoder.encode(p.value(), StandardCharsets.UTF_8)); + } + + private record Param(String name, String value) {} +} diff --git a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/api/OpenIdClientTest.java b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/api/OpenIdClientTest.java new file mode 100644 index 0000000..17db4e9 --- /dev/null +++ b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/api/OpenIdClientTest.java @@ -0,0 +1,159 @@ +package com.oviva.gesundheitsid.fedclient.api; + +import static com.github.tomakehurst.wiremock.client.WireMock.and; +import static com.github.tomakehurst.wiremock.client.WireMock.badRequest; +import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.created; +import static com.github.tomakehurst.wiremock.client.WireMock.ok; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.emptyOrNullString; +import static org.hamcrest.Matchers.not; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.List; +import org.junit.jupiter.api.Test; + +@WireMockTest +class OpenIdClientTest { + + private final OpenIdClient client = new OpenIdClient(new JavaHttpClient()); + + @Test + void exchangePkceCode(WireMockRuntimeInfo wm) { + + var body = + """ + { + "access_token" : null, + "token_type" : "Bearer", + "expires_in" : 3600, + "id_token" : "eyJraWQiOiIxZTlnZGs3IiwiYWxnIjoiUl..." + } + """ + .getBytes(StandardCharsets.UTF_8); + + var path = "/auth/token"; + stubFor(post(path).willReturn(ok().withBody(body))); + + var base = URI.create(wm.getHttpBaseUrl()); + + var code = "s3cret"; + var codeVerifier = "k3k3k"; + var clientId = "myclient"; + var redirectUri = "http://localhost:8080/callback"; + + var res = + client.exchangePkceCode(base.resolve(path), code, redirectUri, clientId, codeVerifier); + + assertEquals("Bearer", res.tokenType()); + assertEquals(3600, res.expiresIn()); + assertThat(res.idToken(), not(emptyOrNullString())); + } + + @Test + void exchangePkceCode_badStatus(WireMockRuntimeInfo wm) { + var path = "/auth/token"; + stubFor(post(path).willReturn(badRequest())); + + var base = URI.create(wm.getHttpBaseUrl()); + + var e = + assertThrows( + Exception.class, + () -> client.exchangePkceCode(base.resolve(path), null, null, null, null)); + + assertEquals( + "failed to request 'POST %s/auth/token': bad status 400".formatted(wm.getHttpBaseUrl()), + e.getMessage()); + } + + @Test + void requestPushedUri(WireMockRuntimeInfo wm) { + + var body = + """ + { + "request_uri":"https://example.com/auth/login", + "expires_in": 600 + } + """ + .getBytes(StandardCharsets.UTF_8); + + var path = "/auth/par"; + stubFor(post(path).willReturn(created().withBody(body))); + + var base = URI.create(wm.getHttpBaseUrl()); + + var res = client.requestPushedUri(base.resolve(path), ParBodyBuilder.create()); + + assertEquals("https://example.com/auth/login", res.requestUri()); + assertEquals(600L, res.expiresIn()); + } + + @Test + void requestPushedUri_params(WireMockRuntimeInfo wm) { + + var response = + """ + {"request_uri":"https://example.com/auth/login","expires_in": 600} + """ + .getBytes(StandardCharsets.UTF_8); + + var path = "/auth/par"; + stubFor(post(path).willReturn(created().withBody(response))); + + var base = URI.create(wm.getHttpBaseUrl()); + + var clientId = "test-client"; + var acrValues = "very-very-high"; + var codeChallenge = "myChallenge"; + var scopes = List.of("email", "openid"); + + var body = + ParBodyBuilder.create() + .acrValues(acrValues) + .codeChallenge(codeChallenge) + .scopes(scopes) + .clientId(clientId); + + // when + client.requestPushedUri(base.resolve(path), body); + + // then + verify( + postRequestedFor(urlPathEqualTo(path)) + .withRequestBody( + and( + containing("client_id=" + clientId), + containing("acr_values=" + acrValues), + containing("code_challenge=" + codeChallenge), + containing("scope=" + String.join("+", scopes))))); + } + + @Test + void requestPushedUri_badStatus(WireMockRuntimeInfo wm) { + var path = "/auth/par"; + stubFor(post(path).willReturn(badRequest())); + + var base = URI.create(wm.getHttpBaseUrl()); + + var e = + assertThrows( + Exception.class, + () -> client.requestPushedUri(base.resolve(path), ParBodyBuilder.create())); + + assertEquals( + "failed to request 'POST %s/auth/par': bad status 400".formatted(wm.getHttpBaseUrl()), + e.getMessage()); + } +} diff --git a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/api/ParBodyBuilderTest.java b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/api/ParBodyBuilderTest.java new file mode 100644 index 0000000..c697a8f --- /dev/null +++ b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/api/ParBodyBuilderTest.java @@ -0,0 +1,34 @@ +package com.oviva.gesundheitsid.fedclient.api; + +import static org.junit.jupiter.api.Assertions.*; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.List; +import org.junit.jupiter.api.Test; + +class ParBodyBuilderTest { + + @Test + void build() { + + var body = + ParBodyBuilder.create() + .acrValues("very-very-high") + .codeChallengeMethod("S256") + .codeChallenge("myChallenge") + .responseType("authorization_code") + .redirectUri(URI.create("https://example.com/callback")) + .state("#/myaccount") + .nonce("bcff66cb-4f01-4129-82a9-0e27703db958") + .scopes(List.of("email", "openid")) + .clientId("https://fachdienst.example.com/auth/realms/main") + .build(); + + var asString = new String(body, StandardCharsets.UTF_8); + + assertEquals( + "acr_values=very-very-high&code_challenge_method=S256&code_challenge=myChallenge&response_type=authorization_code&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&state=%23%2Fmyaccount&nonce=bcff66cb-4f01-4129-82a9-0e27703db958&scope=email+openid&client_id=https%3A%2F%2Ffachdienst.example.com%2Fauth%2Frealms%2Fmain", + asString); + } +} diff --git a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/api/UrlFormBodyBuilderTest.java b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/api/UrlFormBodyBuilderTest.java new file mode 100644 index 0000000..dc59824 --- /dev/null +++ b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/api/UrlFormBodyBuilderTest.java @@ -0,0 +1,34 @@ +package com.oviva.gesundheitsid.fedclient.api; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +class UrlFormBodyBuilderTest { + + static Stream successCases() { + return Stream.of( + new TC(List.of(new Param("t", "T3#&t")), "t=T3%23%26t"), + new TC(List.of(new Param("a", "?=?"), new Param("e", "🎄")), "a=%3F%3D%3F&e=%F0%9F%8E%84")); + } + + @ParameterizedTest + @MethodSource("successCases") + void field(TC tc) { + + var builder = UrlFormBodyBuilder.create(); + tc.params().forEach(p -> builder.param(p.k(), p.v())); + var entity = builder.build(); + + var encoded = new String(entity, StandardCharsets.UTF_8); + assertEquals(tc.expected(), encoded); + } + + record TC(List params, String expected) {} + + record Param(String k, String v) {} +}