-
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.
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
1 parent
47dedac
commit bcba63a
Showing
25 changed files
with
1,148 additions
and
9 deletions.
There are no files selected for viewing
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 |
---|---|---|
|
@@ -3,3 +3,4 @@ target/ | |
gesundheitsid/env.properties | ||
*.iml | ||
gesundheitsid/dependency-reduced-pom.xml | ||
*_jwks.json |
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,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) | ||
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 @@ | ||
GEMATIK_AUTH_HEADER= |
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
33 changes: 33 additions & 0 deletions
33
gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/AuthExceptions.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,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); | ||
} | ||
} |
49 changes: 49 additions & 0 deletions
49
gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/AuthenticationFlow.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.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) {} | ||
} |
27 changes: 27 additions & 0 deletions
27
gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/IdTokenJWS.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,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) {} | ||
} |
138 changes: 138 additions & 0 deletions
138
.../src/main/java/com/oviva/gesundheitsid/auth/internal/steps/SelectSectoralIdpStepImpl.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,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(); | ||
} | ||
} |
Oops, something went wrong.