Skip to content

Commit

Permalink
EPA-158: Pick right OpenID Provider JWKS for IdToken verification
Browse files Browse the repository at this point in the history
  • Loading branch information
thomasrichner-oviva committed Oct 17, 2024
1 parent de9d44f commit de1e3b0
Show file tree
Hide file tree
Showing 19 changed files with 581 additions and 13 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ private AuthenticationFlow buildAuthFlow(
new FederationApiClientImpl(fedHttpClient),
new InMemoryCacheImpl<>(clock, ttl),
new InMemoryCacheImpl<>(clock, ttl),
new InMemoryCacheImpl<>(clock, ttl),
new InMemoryCacheImpl<>(clock, ttl));

var fedmasterClient = new FederationMasterClientImpl(fedmaster, federationApiClient, clock);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ public List<IdpEntry> fetchIdpOptions() {
redirectUri,
callbackUri,
trustedIdpEntityStatement,
relyingPartyEncKeySupplier);
relyingPartyEncKeySupplier,
fedMasterClient);
}

private URI buildAuthorizationUrl(String parRequestUri, EntityStatement trustedEntityStatement) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import com.oviva.ehealthid.auth.steps.TrustedSectoralIdpStep;
import com.oviva.ehealthid.crypto.JwsVerifier;
import com.oviva.ehealthid.crypto.KeySupplier;
import com.oviva.ehealthid.fedclient.FederationMasterClient;
import com.oviva.ehealthid.fedclient.api.EntityStatementJWS;
import com.oviva.ehealthid.fedclient.api.OpenIdClient;
import com.oviva.ehealthid.util.JsonCodec;
Expand All @@ -26,20 +27,23 @@ public class TrustedSectoralIdpStepImpl implements TrustedSectoralIdpStep {
private final URI callbackUri;
private final EntityStatementJWS trustedIdpEntityStatement;
private final KeySupplier relyingPartyEncKeySupplier;
private final FederationMasterClient federationMasterClient;

public TrustedSectoralIdpStepImpl(
@NonNull OpenIdClient openIdClient,
@NonNull URI selfIssuer,
@NonNull URI idpRedirectUri,
@NonNull URI callbackUri,
@NonNull EntityStatementJWS trustedIdpEntityStatement,
@NonNull KeySupplier relyingPartyEncKeySupplier) {
@NonNull KeySupplier relyingPartyEncKeySupplier,
@NonNull FederationMasterClient federationMasterClient) {
this.openIdClient = openIdClient;
this.selfIssuer = selfIssuer;
this.idpRedirectUri = idpRedirectUri;
this.callbackUri = callbackUri;
this.trustedIdpEntityStatement = trustedIdpEntityStatement;
this.relyingPartyEncKeySupplier = relyingPartyEncKeySupplier;
this.federationMasterClient = federationMasterClient;
}

@Override
Expand Down Expand Up @@ -69,7 +73,9 @@ public IdTokenJWS exchangeSectoralIdpCode(@NonNull String code, @NonNull String

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

if (!JwsVerifier.verify(trustedIdpEntityStatement.body().jwks(), signedJws)) {
var idpSigningKeys =
federationMasterClient.resolveOpenIdProviderJwks(trustedIdpEntityStatement);
if (!JwsVerifier.verify(idpSigningKeys, signedJws)) {
throw AuthExceptions.badIdTokenSignature(trustedIdpEntityStatement.body().sub());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,39 @@ public static FederationException untrustedFederationStatement(String sub) {
"federation statement untrusted: sub=%s".formatted(sub),
FederationException.Reason.UNTRUSTED_IDP);
}

public static FederationException noOpenIdProviderKeys(String sub) {
return new FederationException(
"no keys in .metadata.openid_provider jwks or signed_jwks_uri found: sub=%s".formatted(sub),
FederationException.Reason.INVALID_ENTITY_STATEMENT);
}

public static FederationException missingOpenIdProvider(String sub) {
return new FederationException(
"missing .metadata.openid_provider in entitystatement: sub=%s".formatted(sub),
FederationException.Reason.INVALID_ENTITY_STATEMENT);
}

public static FederationException notASignedJwks(String actualType) {
return new FederationException(
"JWS is not of type jwks-set statement but rather '%s'".formatted(actualType),
FederationException.Reason.INVALID_ENTITY_STATEMENT);
}

public static FederationException expiredSignedJwks(String sub, String signedJwksUri) {
return new FederationException(
"expired signed jwks: sub=%s signed_jwks_uri=%s".formatted(sub, signedJwksUri),
FederationException.Reason.UNTRUSTED_IDP);
}

public static FederationException invalidSignedJwks(String sub, String signedJwksUri) {
return new FederationException(
"invalid signed jwks: sub=%s signed_jwks_uri=%s".formatted(sub, signedJwksUri),
FederationException.Reason.UNTRUSTED_IDP);
}

public static FederationException badSignedJwks(Exception cause) {
return new FederationException(
"failed to parse signed jwks", cause, FederationException.Reason.INVALID_ENTITY_STATEMENT);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.oviva.ehealthid.fedclient;

import com.nimbusds.jose.jwk.JWKSet;
import com.oviva.ehealthid.fedclient.api.EntityStatementJWS;
import java.net.URI;
import java.util.List;
Expand All @@ -9,4 +10,6 @@ public interface FederationMasterClient {
List<IdpEntry> listAvailableIdps();

EntityStatementJWS establishIdpTrust(URI issuer);

JWKSet resolveOpenIdProviderJwks(EntityStatementJWS es);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.oviva.ehealthid.fedclient;

import com.nimbusds.jose.jwk.JWK;
import com.nimbusds.jose.jwk.JWKSet;
import com.oviva.ehealthid.fedclient.api.EntityStatement;
import com.oviva.ehealthid.fedclient.api.EntityStatementJWS;
Expand All @@ -8,7 +9,9 @@
import edu.umd.cs.findbugs.annotations.NonNull;
import java.net.URI;
import java.time.Clock;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

public class FederationMasterClientImpl implements FederationMasterClient {

Expand All @@ -33,6 +36,75 @@ public List<IdpEntry> listAvailableIdps() {
.toList();
}

@Override
public JWKSet resolveOpenIdProviderJwks(@NonNull EntityStatementJWS es) {

// https://openid.net/specs/openid-federation-1_0.html#section-5.2.1.1
// https://gemspec.gematik.de/docs/gemSpec/gemSpec_IDP_Sek/latest/#A_22655-02

var op =
Optional.of(es)
.map(EntityStatementJWS::body)
.map(EntityStatement::metadata)
.map(EntityStatement.Metadata::openidProvider);

if (op.isEmpty()) {
throw FederationExceptions.missingOpenIdProvider(es.body().sub());
}

List<JWK> allKeys = new ArrayList<>();

// embedded keys
op.map(EntityStatement.OpenidProvider::jwks).map(JWKSet::getKeys).ifPresent(allKeys::addAll);

// from signed_jwks_uri
op.map(EntityStatement.OpenidProvider::signedJwksUri)
.flatMap(
u -> fetchOpenIdProviderJwksFromSignedJwksUri(es.body().sub(), u, es.body().jwks()))
.ifPresent(allKeys::addAll);

// Note: OpenID federation also supports a `jwks_uri`, the GesundheitsID does not though
if (allKeys.isEmpty()) {
throw FederationExceptions.noOpenIdProviderKeys(es.body().sub());
}

return new JWKSet(allKeys);
}

@NonNull
private Optional<List<JWK>> fetchOpenIdProviderJwksFromSignedJwksUri(
@NonNull String issuer, @NonNull String signedJwksUri, @NonNull JWKSet idpTrustStore) {

return Optional.of(signedJwksUri)
.map(URI::create)
.map(apiClient::fetchSignedJwks)
.map(
jws -> {
if (!jws.isValidAt(clock.instant())) {
throw FederationExceptions.expiredSignedJwks(issuer, signedJwksUri);
}

if (!jws.verifySignature(idpTrustStore)) {
throw FederationExceptions.invalidSignedJwks(issuer, signedJwksUri);
}

if (!matchesIfPresent(issuer, jws.body().iss())) {
throw FederationExceptions.invalidSignedJwks(issuer, signedJwksUri);
}
return jws;
})
.map(s -> s.body().toJWKSet())
.map(JWKSet::getKeys);
}

private boolean matchesIfPresent(String expected, String actual) {
if (actual == null || actual.isEmpty()) {
return true;
}

return expected.equals(actual);
}

@Override
public EntityStatementJWS establishIdpTrust(URI issuer) {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,19 @@ public class CachedFederationApiClient implements FederationApiClient {

private final Cache<EntityStatementJWS> federationStatementCache;

private final Cache<ExtendedJWKSetJWS> signedJwksCache;
private final Cache<IdpListJWS> idpListCache;

public CachedFederationApiClient(
FederationApiClient delegate,
Cache<EntityStatementJWS> entityStatementCache,
Cache<EntityStatementJWS> federationStatementCache,
Cache<ExtendedJWKSetJWS> signedJwksCache,
Cache<IdpListJWS> idpListCache) {
this.delegate = delegate;
this.entityStatementCache = entityStatementCache;
this.federationStatementCache = federationStatementCache;
this.signedJwksCache = signedJwksCache;
this.idpListCache = idpListCache;
}

Expand All @@ -46,4 +49,11 @@ public IdpListJWS fetchIdpList(URI idpListUrl) {
return entityStatementCache.computeIfAbsent(
entityUrl.toString(), k -> delegate.fetchEntityConfiguration(entityUrl));
}

@NonNull
@Override
public ExtendedJWKSetJWS fetchSignedJwks(URI signedJwksUrl) {
return signedJwksCache.computeIfAbsent(
signedJwksUrl.toString(), k -> delegate.fetchSignedJwks(signedJwksUrl));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,12 @@ public record OpenidProvider(
@JsonProperty("authorization_endpoint") String authorizationEndpoint,
@JsonProperty("scopes_supported") List<String> scopesSupported,
@JsonProperty("grant_types_supported") List<String> grantTypesSupported,
@JsonProperty("user_type_supported") List<String> userTypeSupported) {
@JsonProperty("user_type_supported") List<String> userTypeSupported,

// keys
@JsonProperty("jwks") JWKSet jwks,
// additional location for relying party keys, e.g. for signing tokens
@JsonProperty("signed_jwks_uri") String signedJwksUri) {

public static Builder create() {
return new Builder();
Expand All @@ -94,6 +99,9 @@ public static final class Builder {
private List<String> grantTypesSupported;
private List<String> userTypeSupported;

private JWKSet jwks;
private String signedJwksUri;

private Builder() {}

public Builder pushedAuthorizationRequestEndpoint(String pushedAuthorizationRequestEndpoint) {
Expand Down Expand Up @@ -137,6 +145,16 @@ public Builder userTypeSupported(List<String> userTypeSupported) {
return this;
}

public Builder jwks(JWKSet jwks) {
this.jwks = jwks;
return this;
}

public Builder signedJwksUri(String signedJwksUri) {
this.signedJwksUri = signedJwksUri;
return this;
}

public OpenidProvider build() {
return new OpenidProvider(
pushedAuthorizationRequestEndpoint,
Expand All @@ -146,7 +164,9 @@ public OpenidProvider build() {
authorizationEndpoint,
scopesSupported,
grantTypesSupported,
userTypeSupported);
userTypeSupported,
jwks,
signedJwksUri);
}
}
}
Expand Down Expand Up @@ -298,7 +318,11 @@ public record OpenIdRelyingParty(
@JsonProperty("id_token_signed_response_alg") String idTokenSignedResponseAlg,
@JsonProperty("id_token_encrypted_response_alg") String idTokenEncryptedResponseAlg,
@JsonProperty("id_token_encrypted_response_enc") String idTokenEncryptedResponseEnc,

// keys
@JsonProperty("jwks") JWKSet jwks,
// additional location for relying party keys, e.g. for signing tokens
@JsonProperty("signed_jwks_uri") String signedJwksUri,
@JsonProperty("default_acr_values") List<String> defaultAcrValues,
@JsonProperty("token_endpoint_auth_methods_supported")
List<String> tokenEndpointAuthMethodsSupported,
Expand Down Expand Up @@ -326,6 +350,7 @@ public static final class Builder {
private String idTokenEncryptedResponseEnc;

private JWKSet jwks;
private String signedJwksUri;
private List<String> defaultAcrValues;
private List<String> tokenEndpointAuthMethodsSupported;
private String tokenEndpointAuthMethod;
Expand Down Expand Up @@ -393,6 +418,11 @@ public Builder jwks(JWKSet jwks) {
return this;
}

public Builder signedJwksUri(String signedJwksUri) {
this.signedJwksUri = signedJwksUri;
return this;
}

public Builder defaultAcrValues(List<String> defaultAcrValues) {
this.defaultAcrValues = defaultAcrValues;
return this;
Expand Down Expand Up @@ -423,6 +453,7 @@ public OpenIdRelyingParty build() {
idTokenEncryptedResponseAlg,
idTokenEncryptedResponseEnc,
jwks,
signedJwksUri,
defaultAcrValues,
tokenEndpointAuthMethodsSupported,
tokenEndpointAuthMethod);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.oviva.ehealthid.fedclient.api;

import com.nimbusds.jose.jwk.JWK;
import com.nimbusds.jose.jwk.JWKSet;
import java.util.List;

// slight variation of a JWKSet :/
// https://openid.net/specs/openid-connect-federation-1_0-21.html#name-openid-connect-and-oauth2-m
public record ExtendedJWKSet(long exp, String iss, List<JWK> keys) {

public JWKSet toJWKSet() {
if (keys == null) {
return new JWKSet();
}
return new JWKSet(keys);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.oviva.ehealthid.fedclient.api;

import com.nimbusds.jose.JWSObject;
import com.nimbusds.jose.jwk.JWKSet;
import com.oviva.ehealthid.crypto.JwsVerifier;
import com.oviva.ehealthid.fedclient.FederationExceptions;
import com.oviva.ehealthid.util.JsonCodec;
import com.oviva.ehealthid.util.JsonPayloadTransformer;
import java.text.ParseException;
import java.time.Instant;

public record ExtendedJWKSetJWS(JWSObject jws, ExtendedJWKSet body) implements TemporalValid {

public static final String JWKS_TYPE = "jwk-set+json";

public static ExtendedJWKSetJWS parse(String wire) {
try {

var jws = JWSObject.parse(wire);

if (!JWKS_TYPE.equals(jws.getHeader().getType().getType())) {
throw FederationExceptions.notASignedJwks(jws.getHeader().getType().getType());
}

var es =
jws.getPayload()
.toType(new JsonPayloadTransformer<>(ExtendedJWKSet.class, JsonCodec::readValue));
return new ExtendedJWKSetJWS(jws, es);
} catch (ParseException e) {
throw FederationExceptions.badSignedJwks(e);
}
}

public boolean verifySignature(JWKSet jwks) {
return JwsVerifier.verify(jwks, jws);
}

@Override
public boolean isValidAt(Instant pointInTime) {
var epoch = pointInTime.getEpochSecond();
return body.exp() == 0 || epoch < body.exp();
}
}
Loading

0 comments on commit de1e3b0

Please sign in to comment.