Skip to content

Commit

Permalink
ARC-979: OpenID API Client (#5)
Browse files Browse the repository at this point in the history
* ARC-979: Add OpenID client

* ARC-979: Fix sonar
  • Loading branch information
thomasrichner-oviva authored Jan 31, 2024
1 parent c1d7a73 commit b533aa1
Show file tree
Hide file tree
Showing 7 changed files with 516 additions and 1 deletion.
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) {}
}
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();
}
}
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) {}
}
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());
}
}
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);
}
}
Loading

0 comments on commit b533aa1

Please sign in to comment.