-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
c1d7a73
commit b533aa1
Showing
7 changed files
with
516 additions
and
1 deletion.
There are no files selected for viewing
78 changes: 78 additions & 0 deletions
78
gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/OpenIdClient.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) {} | ||
} |
66 changes: 66 additions & 0 deletions
66
gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/ParBodyBuilder.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<String> 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(); | ||
} | ||
} |
49 changes: 49 additions & 0 deletions
49
gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/UrlFormBodyBuilder.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<Param> 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<String> 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) {} | ||
} |
159 changes: 159 additions & 0 deletions
159
gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/api/OpenIdClientTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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()); | ||
} | ||
} |
34 changes: 34 additions & 0 deletions
34
gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/api/ParBodyBuilderTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
} | ||
} |
Oops, something went wrong.