Skip to content

Commit

Permalink
ARC-1217: GesundheitsID Simple Test Flow (#7)
Browse files Browse the repository at this point in the history
* ARC-1217: Basic setup

* ARC-1217: Include fully working authentication flow.

* ARC-1217: Readme

* ARC-1217: Review findings
  • Loading branch information
thomasrichner-oviva authored Feb 1, 2024
1 parent 47dedac commit bcba63a
Show file tree
Hide file tree
Showing 25 changed files with 1,148 additions and 9 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
100 changes: 100 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
[![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)

# Keycloak Identity Provider for GesundheitsID (eHealthID)

## Contents
- [gesundheitsid](./gesundheitsid) - A plain Java library to build RelyingParties for GesundheitsID.
- API clients
- Models for the EntityStatments, IDP list endpoints etc.
- Narrow support for the 'Fachdienst' use-case.

## End-to-End Test flow with Gematik Reference IDP

**Prerequisites**:

1. Setup your test environment, your own issuer **MUST** serve a **VALID** and **TRUSTED** entity
statement. See [Gematik docs](https://wiki.gematik.de/pages/viewpage.action?pageId=544316583)
2. Setup the file `.env.properties` to provide
the [X-Authorization header](https://wiki.gematik.de/display/IDPKB/Fachdienste+Test-Umgebungen)
for the Gematik
3. Setup the JWK sets for signing and encryption keys

```java
public class Example {

public static void main(String[] args) {

// ... setup, see full example linked below

var flow =
new AuthenticationFlow(
self, fedmasterClient, openIdClient, relyingPartyEncryptionJwks::getKeyByKeyId);

// these should come from the client in the real world
var verifier = generateCodeVerifier();
var codeChallenge = calculateS256CodeChallenge(verifier);

// ==== 1) start a new flow
var step1 = flow.start(new Session("test", "test", redirectUri, codeChallenge, scopes));

// ==== 2) get the list of available IDPs
var idps = step1.fetchIdpOptions();

// ==== 3) select and IDP

// for now we hardcode the reference IDP from Gematik
var sektoralerIdpIss = "https://gsi.dev.gematik.solutions";

var step2 = step1.redirectToSectoralIdp(sektoralerIdpIss);

var idpRedirectUri = step2.idpRedirectUri();

// ==== 3a) do in-code authentication flow, this is in reality the proprietary flow
var redirectResult = doFederatedAuthFlow(idpRedirectUri);
System.out.println(redirectResult);

var values = parseQuery(redirectResult);
var code = values.get("code");

// ==== 4) exchange the code for the ID token
var token = step2.exchangeSectoralIdpCode(code, verifier);

// Success! Let's print it.
var om = new ObjectMapper().configure(SerializationFeature.INDENT_OUTPUT, true);
System.out.println(om.writeValueAsString(token.body()));
}
}

```

See [AuthenticationFlowExampleTest](https://github.com/oviva-ag/keycloak-gesundheitsid/blob/8751c92e45531f6cdca204b8db18a2825e05e69a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/auth/AuthenticationFlowExampleTest.java#L44-L117)

## Working with Gematik Test Environment


### Gematik Test Sektoraler IdP in Browser

Since the Gematik reference IDP in the Test Environment needs a custom header, it can not be used directly in the browser for authentication.
Setting up a proxy with a header filter can get around that limitation though.

**Prerequisite:** Install some Chrome-ish browser like [Thorium](https://github.com/Alex313031/Thorium-MacOS/releases) or Chromium.

1. launch `mitmweb`: `mitmweb -p 8881 --web-port=8882`
2. launch Chrome-like browser
```
/Applications/Thorium.app/Contents/MacOS/Thorium --proxy-server=http://localhost:8881
```
3. setup `modify_headers` option
```mitmproxy
# modify_headers filter
/~q & ~d gsi.dev.gematik.solutions/X-Authorization/<value goes here>
```
## Helpful Links
- [AppFlow - Authentication flow to implement](https://wiki.gematik.de/display/IDPKB/App-App+Flow#AppAppFlow-0-FederationMaster)
- [Sektoraler IDP - Examples & Reference Implementation](https://wiki.gematik.de/display/IDPKB/Sektoraler+IDP+-+Referenzimplementierung+und+Beispiele)
- [OpenID Federation Spec](https://openid.net/specs/openid-federation-1_0.html)
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,33 @@
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 badIdTokenSignature(String issuer) {
return new RuntimeException("bad ID token signature from sub=%s".formatted(issuer));
}

public static RuntimeException badIdToken(String issuer, Exception cause) {
return new RuntimeException("bad ID token from sub=%s".formatted(issuer), cause);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.oviva.gesundheitsid.auth;

import com.oviva.gesundheitsid.auth.internal.steps.SelectSectoralIdpStepImpl;
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 edu.umd.cs.findbugs.annotations.NonNull;
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(
@NonNull URI selfIssuer,
@NonNull FederationMasterClient federationMasterClient,
@NonNull OpenIdClient openIdClient,
@NonNull KeySupplier relyingPartyKeySupplier) {
this.selfIssuer = selfIssuer;
this.federationMasterClient = federationMasterClient;
this.openIdClient = openIdClient;
this.relyingPartyKeySupplier = relyingPartyKeySupplier;
}

@NonNull
public SelectSectoralIdpStep start(@NonNull Session session) {

return new SelectSectoralIdpStepImpl(
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, IdToken body) {

@JsonIgnoreProperties(ignoreUnknown = true)
public record IdToken(
@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,

// for 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,138 @@
package com.oviva.gesundheitsid.auth.internal.steps;

import com.oviva.gesundheitsid.auth.AuthExceptions;
import com.oviva.gesundheitsid.auth.steps.SelectSectoralIdpStep;
import com.oviva.gesundheitsid.auth.steps.TrustedSectoralIdpStep;
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 SelectSectoralIdpStepImpl implements 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 SelectSectoralIdpStepImpl(
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;
}

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

@Override
public @NonNull 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 TrustedSectoralIdpStepImpl(
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();
}
}
Loading

0 comments on commit bcba63a

Please sign in to comment.