Skip to content

Commit

Permalink
ARC-1217: Basic setup
Browse files Browse the repository at this point in the history
  • Loading branch information
thomasrichner-oviva committed Jan 31, 2024
1 parent 47dedac commit 1461f98
Show file tree
Hide file tree
Showing 17 changed files with 549 additions and 6 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ target/
gesundheitsid/env.properties
*.iml
gesundheitsid/dependency-reduced-pom.xml
*_jwks.json
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=oviva-ag_keycloak-gesundheitsid&metric=alert_status&token=64c09371c0f6c1d729fc0b0424706cd54011cb90)](https://sonarcloud.io/summary/new_code?id=oviva-ag_keycloak-gesundheitsid)
[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=oviva-ag_keycloak-gesundheitsid&metric=coverage&token=64c09371c0f6c1d729fc0b0424706cd54011cb90)](https://sonarcloud.io/summary/new_code?id=oviva-ag_keycloak-gesundheitsid)
1 change: 1 addition & 0 deletions gesundheitsid/env.properties.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
GEMATIK_AUTH_HEADER=
7 changes: 7 additions & 0 deletions gesundheitsid/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@
</dependency>
<!-- END wiremock -->

<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.16.1</version>
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-core</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.oviva.gesundheitsid.auth;

public class AuthExceptions {
private AuthExceptions() {}

public static RuntimeException invalidParRequestUri(String uri) {
return new RuntimeException("invalid par request_uri '%s'".formatted(uri));
}

public static RuntimeException missingAuthorizationUrl(String sub) {
return new RuntimeException(
"entity statement of '%s' has no authorization url configuration".formatted(sub));
}

public static RuntimeException missingPARUrl(String sub) {
return new RuntimeException(
"entity statement of '%s' has no pushed authorization request configuration"
.formatted(sub));
}

public static RuntimeException missingOpenIdConfigurationInEntityStatement(String sub) {
return new RuntimeException(
"entity statement of '%s' lacks openid configuration".formatted(sub));
}

public static RuntimeException noEntityConfiguration() {
return new RuntimeException("no entity configuration for idp available");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.oviva.gesundheitsid.auth;

import com.oviva.gesundheitsid.auth.steps.SelectSectoralIdpStep;
import com.oviva.gesundheitsid.crypto.KeySupplier;
import com.oviva.gesundheitsid.fedclient.FederationMasterClient;
import com.oviva.gesundheitsid.fedclient.api.OpenIdClient;
import java.net.URI;
import java.util.List;

public class AuthenticationFlow {

private final URI selfIssuer;
private final FederationMasterClient federationMasterClient;

private final OpenIdClient openIdClient;

private final KeySupplier relyingPartyKeySupplier;

public AuthenticationFlow(
URI selfIssuer,
FederationMasterClient federationMasterClient,
OpenIdClient openIdClient,
KeySupplier relyingPartyKeySupplier) {
this.selfIssuer = selfIssuer;
this.federationMasterClient = federationMasterClient;
this.openIdClient = openIdClient;
this.relyingPartyKeySupplier = relyingPartyKeySupplier;
}

public SelectSectoralIdpStep start(Session session) {

return new SelectSectoralIdpStep(
selfIssuer,
federationMasterClient,
openIdClient,
relyingPartyKeySupplier,
session.callbackUri(),
session.nonce(),
session.codeChallengeS256(),
session.state(),
session.scopes());
}

public record Session(
String state, String nonce, URI callbackUri, String codeChallengeS256, List<String> scopes) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.oviva.gesundheitsid.auth;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.nimbusds.jose.JWSObject;

public record IdTokenJWS(JWSObject jws, Payload payload) {

@JsonIgnoreProperties(ignoreUnknown = true)
public record Payload(
@JsonProperty("iss") String iss,
@JsonProperty("sub") String sub,
@JsonProperty("aud") String aud,
@JsonProperty("iat") long iat,
@JsonProperty("exp") long exp,
@JsonProperty("nbf") long nbf,
@JsonProperty("nonce") String nonce,
@JsonProperty("acr") String acr,
@JsonProperty("amr") String amr,
@JsonProperty("email") String email,
@JsonProperty("urn:telematik:claims:profession") String telematikProfession,
@JsonProperty("urn:telematik:claims:given_name") String telematikGivenName,

/** vor insured person (IP) the immutable part of the Krankenversichertennummer (KVNR) * */
@JsonProperty("urn:telematik:claims:id") String telematikKvnr,
@JsonProperty("urn:telematik:claims:email") String telematikEmail) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package com.oviva.gesundheitsid.auth.steps;

import com.oviva.gesundheitsid.auth.AuthExceptions;
import com.oviva.gesundheitsid.crypto.KeySupplier;
import com.oviva.gesundheitsid.fedclient.FederationMasterClient;
import com.oviva.gesundheitsid.fedclient.IdpEntry;
import com.oviva.gesundheitsid.fedclient.api.EntityStatement;
import com.oviva.gesundheitsid.fedclient.api.EntityStatement.OpenidProvider;
import com.oviva.gesundheitsid.fedclient.api.OpenIdClient;
import com.oviva.gesundheitsid.fedclient.api.OpenIdClient.ParResponse;
import com.oviva.gesundheitsid.fedclient.api.ParBodyBuilder;
import edu.umd.cs.findbugs.annotations.NonNull;
import jakarta.ws.rs.core.UriBuilder;
import java.net.URI;
import java.util.List;

/**
* Official documentation: -
* https://wiki.gematik.de/display/IDPKB/App-App+Flow#AppAppFlow-0-FederationMaster
*/
public class SelectSectoralIdpStep {

private final URI selfIssuer;
private final FederationMasterClient fedMasterClient;
private final OpenIdClient openIdClient;
private final KeySupplier relyingPartyEncKeySupplier;

private final URI callbackUri;
private final String nonce;
private final String codeChallengeS256;
private final String state;
private final List<String> scopes;

public SelectSectoralIdpStep(
URI selfIssuer,
FederationMasterClient fedMasterClient,
OpenIdClient openIdClient,
KeySupplier relyingPartyEncKeySupplier1,
URI callbackUri,
String nonce,
String codeChallengeS256,
String state,
List<String> scopes) {
this.selfIssuer = selfIssuer;
this.fedMasterClient = fedMasterClient;
this.openIdClient = openIdClient;
this.relyingPartyEncKeySupplier = relyingPartyEncKeySupplier1;
this.callbackUri = callbackUri;
this.nonce = nonce;
this.codeChallengeS256 = codeChallengeS256;
this.state = state;
this.scopes = scopes;
}

public List<IdpEntry> fetchIdpOptions() {
return fedMasterClient.listAvailableIdps();
}

public TrustedSectoralIdpStep redirectToSectoralIdp(@NonNull String sectoralIdpIss) {

var trustedIdpEntityStatement = fedMasterClient.establishIdpTrust(URI.create(sectoralIdpIss));

// start PAR with sectoral IdP
// https://datatracker.ietf.org/doc/html/rfc9126

var parBody =
ParBodyBuilder.create()
.clientId(selfIssuer.toString())
.codeChallenge(codeChallengeS256)
.codeChallengeMethod("S256")
.redirectUri(callbackUri)
.nonce(nonce)
.state(state)
.scopes(scopes)
.acrValues("gematik-ehealth-loa-high")
.responseType("code");

var res = doPushedAuthorizationRequest(parBody, trustedIdpEntityStatement.body());

var redirectUri = buildAuthorizationUrl(res.requestUri(), trustedIdpEntityStatement.body());

return new TrustedSectoralIdpStep(
openIdClient,
selfIssuer,
redirectUri,
callbackUri,
trustedIdpEntityStatement,
relyingPartyEncKeySupplier);
}

private URI buildAuthorizationUrl(String parRequestUri, EntityStatement trustedEntityStatement) {

if (parRequestUri == null || parRequestUri.isBlank()) {
throw AuthExceptions.invalidParRequestUri(parRequestUri);
}

var openidConfig = getIdpOpenIdProvider(trustedEntityStatement);
var authzEndpoint = openidConfig.authorizationEndpoint();

if (authzEndpoint == null || authzEndpoint.isBlank()) {
throw AuthExceptions.missingAuthorizationUrl(trustedEntityStatement.sub());
}

return UriBuilder.fromUri(authzEndpoint)
.queryParam("request_uri", parRequestUri)
.queryParam("client_id", selfIssuer.toString())
.build();
}

private ParResponse doPushedAuthorizationRequest(
ParBodyBuilder builder, EntityStatement trustedEntityStatement) {

var openidConfig = getIdpOpenIdProvider(trustedEntityStatement);
var parEndpoint = openidConfig.pushedAuthorizationRequestEndpoint();
if (parEndpoint == null || parEndpoint.isBlank()) {
throw AuthExceptions.missingPARUrl(trustedEntityStatement.sub());
}

return openIdClient.requestPushedUri(URI.create(parEndpoint), builder);
}

private OpenidProvider getIdpOpenIdProvider(
@NonNull EntityStatement trustedIdpEntityConfiguration) {

if (trustedIdpEntityConfiguration.metadata() == null
|| trustedIdpEntityConfiguration.metadata().openidProvider() == null) {
throw AuthExceptions.missingOpenIdConfigurationInEntityStatement(
trustedIdpEntityConfiguration.sub());
}

return trustedIdpEntityConfiguration.metadata().openidProvider();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package com.oviva.gesundheitsid.auth.steps;

import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.JWEObject;
import com.nimbusds.jose.crypto.MultiDecrypter;
import com.oviva.gesundheitsid.auth.IdTokenJWS;
import com.oviva.gesundheitsid.auth.IdTokenJWS.Payload;
import com.oviva.gesundheitsid.crypto.JwsVerifier;
import com.oviva.gesundheitsid.crypto.KeySupplier;
import com.oviva.gesundheitsid.fedclient.api.EntityStatementJWS;
import com.oviva.gesundheitsid.fedclient.api.OpenIdClient;
import com.oviva.gesundheitsid.util.JsonCodec;
import com.oviva.gesundheitsid.util.JsonPayloadTransformer;
import edu.umd.cs.findbugs.annotations.NonNull;
import java.net.URI;
import java.text.ParseException;

public class TrustedSectoralIdpStep {

private final OpenIdClient openIdClient;

private final URI selfIssuer;
private final URI idpRedirectUri;
private final URI callbackUri;
private final EntityStatementJWS trustedIdpEntityStatement;
private final KeySupplier relyingPartyEncKeySupplier;

public TrustedSectoralIdpStep(
OpenIdClient openIdClient,
URI selfIssuer,
URI idpRedirectUri,
URI callbackUri,
EntityStatementJWS trustedIdpEntityStatement,
KeySupplier relyingPartyEncKeySupplier) {
this.openIdClient = openIdClient;
this.selfIssuer = selfIssuer;
this.idpRedirectUri = idpRedirectUri;
this.callbackUri = callbackUri;
this.trustedIdpEntityStatement = trustedIdpEntityStatement;
this.relyingPartyEncKeySupplier = relyingPartyEncKeySupplier;
}

public URI idpRedirectUri() {
return idpRedirectUri;
}

public IdTokenJWS exchangeSectoralIdpCode(@NonNull String code, @NonNull String codeVerifier) {

if (trustedIdpEntityStatement == null) {
throw new IllegalStateException("flow has no trusted IDP statement, state not persisted?");
}

if (callbackUri == null) {
throw new IllegalStateException("flow has no callback_uri, state not persisted?");
}

var tokenEndpoint =
trustedIdpEntityStatement.body().metadata().openidProvider().tokenEndpoint();
var res =
openIdClient.exchangePkceCode(
URI.create(tokenEndpoint),
code,
callbackUri.toString(),
selfIssuer.toString(),
codeVerifier);

try {
var jweObject = JWEObject.parse(res.idToken());
// var decrypter = new ECDHDecrypter(relyingPartyEncKeySupplier.get().priv());
var decrypter =
new MultiDecrypter(relyingPartyEncKeySupplier.apply(jweObject.getHeader().getKeyID()));
jweObject.decrypt(decrypter);

var signedJws = jweObject.getPayload().toJWSObject();

if (!JwsVerifier.verify(trustedIdpEntityStatement.body().jwks(), signedJws)) {
throw new RuntimeException("bad signature from IDP on id token");
}

var payload =
signedJws
.getPayload()
.toType(new JsonPayloadTransformer<>(Payload.class, JsonCodec::readValue));
return new IdTokenJWS(signedJws, payload);

} catch (JOSEException | ParseException e) {
throw new RuntimeException(e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.oviva.gesundheitsid.crypto;

import java.security.interfaces.ECPrivateKey;
import java.security.interfaces.ECPublicKey;

public record ECKeyPair(ECPublicKey pub, ECPrivateKey priv) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.oviva.gesundheitsid.crypto;

import com.nimbusds.jose.jwk.JWK;
import java.util.function.Function;

public interface KeySupplier extends Function<String, JWK> {}
Loading

0 comments on commit 1461f98

Please sign in to comment.