diff --git a/.gitignore b/.gitignore index 5fd87f2..30cc803 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ target/ gesundheitsid/env.properties *.iml gesundheitsid/dependency-reduced-pom.xml +.flattened-pom.xml diff --git a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/util/JoseModule.java b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/util/JoseModule.java new file mode 100644 index 0000000..4c5d895 --- /dev/null +++ b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/util/JoseModule.java @@ -0,0 +1,17 @@ +package com.oviva.gesundheitsid.util; + +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.std.StdDelegatingSerializer; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSet; +import com.oviva.gesundheitsid.util.JWKSetDeserializer.JWKDeserializer; + +public class JoseModule extends SimpleModule { + + public JoseModule() { + super("jose"); + addDeserializer(JWK.class, new JWKDeserializer(JWK.class)); + addDeserializer(JWKSet.class, new JWKSetDeserializer(JWKSet.class)); + addSerializer(new StdDelegatingSerializer(JWKSet.class, new JWKSetConverter())); + } +} diff --git a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/util/JsonCodec.java b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/util/JsonCodec.java index 1065818..1e4b51e 100644 --- a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/util/JsonCodec.java +++ b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/util/JsonCodec.java @@ -17,11 +17,7 @@ public class JsonCodec { static { var om = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - var mod = new SimpleModule("jwks"); - mod.addDeserializer(JWK.class, new JWKDeserializer(JWK.class)); - mod.addDeserializer(JWKSet.class, new JWKSetDeserializer(JWKSet.class)); - mod.addSerializer(new StdDelegatingSerializer(JWKSet.class, new JWKSetConverter())); - om.registerModule(mod); + om.registerModule(new JoseModule()); JsonCodec.om = om; } diff --git a/oidc-server/pom.xml b/oidc-server/pom.xml new file mode 100644 index 0000000..2dc5d2f --- /dev/null +++ b/oidc-server/pom.xml @@ -0,0 +1,115 @@ + + + 4.0.0 + + + com.oviva.gesundheitsid + gesundheitsid-parent + 0.0.1-SNAPSHOT + + + oidc-server + jar + + + + org.slf4j + slf4j-api + + + com.github.spotbugs + spotbugs-annotations + + + com.nimbusds + nimbus-jose-jwt + + + com.fasterxml.jackson.core + jackson-core + + + com.fasterxml.jackson.core + jackson-annotations + + + com.fasterxml.jackson.core + jackson-databind + + + jakarta.ws.rs + jakarta.ws.rs-api + + + + org.junit.jupiter + junit-jupiter + test + + + org.junit.jupiter + junit-jupiter-params + test + + + org.hamcrest + hamcrest + test + + + org.mockito + mockito-junit-jupiter + test + + + org.mockito + mockito-core + test + + + org.slf4j + slf4j-jdk14 + test + + + + + org.apache.commons + commons-lang3 + test + + + com.github.jknack + handlebars-helpers + test + + + com.jayway.jsonpath + json-path + test + + + org.wiremock + wiremock + test + + + + + org.jsoup + jsoup + 1.16.1 + test + + + + org.jboss.resteasy + resteasy-core + test + + + + + diff --git a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/Main.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/Main.java new file mode 100644 index 0000000..a809816 --- /dev/null +++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/Main.java @@ -0,0 +1,99 @@ +package com.oviva.gesundheitsid.relyingparty; + +import com.oviva.gesundheitsid.relyingparty.cfg.Config; +import com.oviva.gesundheitsid.relyingparty.cfg.ConfigProvider; +import com.oviva.gesundheitsid.relyingparty.cfg.EnvConfigProvider; +import com.oviva.gesundheitsid.relyingparty.svc.InMemorySessionRepo; +import com.oviva.gesundheitsid.relyingparty.svc.InMemoryCodeRepo; +import com.oviva.gesundheitsid.relyingparty.svc.KeyStore; +import com.oviva.gesundheitsid.relyingparty.svc.TokenIssuerImpl; +import com.oviva.gesundheitsid.relyingparty.ws.App; +import jakarta.ws.rs.SeBootstrap; +import jakarta.ws.rs.SeBootstrap.Configuration; +import java.net.URI; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.stream.Stream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class Main { + + private static final Logger logger = LoggerFactory.getLogger(Main.class); + + private static final String BANNER = + """ + ____ _ + / __ \\_ __(_) _____ _ + / /_/ / |/ / / |/ / _ `/ + \\____/|___/_/|___/\\_,_/ + GesundheitsID OpenID Connect Relying-Party + """; + + public static void main(String[] args) throws ExecutionException, InterruptedException { + + var main = new Main(); + main.run(new EnvConfigProvider("OIDC_SERVER", System::getenv)); + } + + private void run(ConfigProvider configProvider) throws ExecutionException, InterruptedException { + logger.atInfo().log("\n" + BANNER); + + var baseUri = URI.create("https://t.oviva.io"); + var validRedirectUris = + List.of(URI.create("https://idp-test.oviva.io/auth/realms/master/broker/oidc/endpoint")); + + var supportedResponseTypes = List.of("code"); + + var port = + configProvider.get("port").stream().mapToInt(Integer::parseInt).findFirst().orElse(1234); + var config = + new Config( + port, + baseUri, // TOOD: hardcoded :) + // configProvider.get("base_uri").map(URI::create).orElse(URI.create("http://localhost:" + // + port)), + supportedResponseTypes, + validRedirectUris // TODO: hardcoded :) + // configProvider.get("redirect_uris").stream() + // .flatMap(this::mustParseCommaList) + // .map(URI::create) + // .toList() + ); + + var keyStore = new KeyStore(); + var tokenIssuer = new TokenIssuerImpl(config.baseUri(), keyStore, new InMemoryCodeRepo()); + var sessionRepo = new InMemorySessionRepo(); + + var instance = + SeBootstrap.start( + new App(config, sessionRepo, keyStore, tokenIssuer), + Configuration.builder().host("0.0.0.0").port(config.port()).build()) + .toCompletableFuture() + .get(); + + var localUri = instance.configuration().baseUri(); + logger.atInfo().addKeyValue("local_addr", localUri).log("Magic at {}", config.baseUri()); + + // wait forever + Thread.currentThread().join(); + } + + private Stream mustParseCommaList(String value) { + if (value == null || value.isBlank()) { + return Stream.empty(); + } + + return Arrays.stream(value.split(",")).map(this::trimmed).filter(Objects::nonNull); + } + + private String trimmed(String value) { + if (value == null || value.isBlank()) { + return null; + } + + return value.trim(); + } +} diff --git a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/cfg/Config.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/cfg/Config.java new file mode 100644 index 0000000..7bd30dc --- /dev/null +++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/cfg/Config.java @@ -0,0 +1,6 @@ +package com.oviva.gesundheitsid.relyingparty.cfg; + +import java.net.URI; +import java.util.List; + +public record Config(int port, URI baseUri, List supportedResponseTypes, List validRedirectUris) {} diff --git a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/cfg/ConfigProvider.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/cfg/ConfigProvider.java new file mode 100644 index 0000000..a6ca670 --- /dev/null +++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/cfg/ConfigProvider.java @@ -0,0 +1,7 @@ +package com.oviva.gesundheitsid.relyingparty.cfg; + +import java.util.Optional; + +public interface ConfigProvider { + Optional get(String name); +} diff --git a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/cfg/EnvConfigProvider.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/cfg/EnvConfigProvider.java new file mode 100644 index 0000000..53f59b0 --- /dev/null +++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/cfg/EnvConfigProvider.java @@ -0,0 +1,26 @@ +package com.oviva.gesundheitsid.relyingparty.cfg; + +import java.util.Locale; +import java.util.Optional; +import java.util.function.Function; + +public class EnvConfigProvider implements ConfigProvider { + + private final String prefix; + private final Function getenv; + + public EnvConfigProvider(String prefix, Function getenv) { + this.prefix = prefix; + this.getenv = getenv; + } + + @Override + public Optional get(String name) { + + var mangled = prefix + "_" + name; + mangled = mangled.toUpperCase(Locale.ROOT); + mangled = mangled.replaceAll("[^A-Z0-9]", "_"); + + return Optional.ofNullable(getenv.apply(mangled)); + } +} diff --git a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/CodeRepo.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/CodeRepo.java new file mode 100644 index 0000000..699073b --- /dev/null +++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/CodeRepo.java @@ -0,0 +1,11 @@ +package com.oviva.gesundheitsid.relyingparty.svc; + +import com.oviva.gesundheitsid.relyingparty.svc.TokenIssuer.Code; +import java.util.Optional; + +public interface CodeRepo { + + void save(Code code); + + Optional remove(String code); +} diff --git a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/InMemoryCodeRepo.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/InMemoryCodeRepo.java new file mode 100644 index 0000000..4045d57 --- /dev/null +++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/InMemoryCodeRepo.java @@ -0,0 +1,23 @@ +package com.oviva.gesundheitsid.relyingparty.svc; + +import com.oviva.gesundheitsid.relyingparty.svc.TokenIssuer.Code; +import edu.umd.cs.findbugs.annotations.NonNull; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +public class InMemoryCodeRepo implements CodeRepo { + + private final ConcurrentMap store = new ConcurrentHashMap<>(); + + @Override + public void save(@NonNull Code code) { + store.put(code.code(), code); + } + + @NonNull + @Override + public Optional remove(String code) { + return Optional.ofNullable(store.remove(code)); + } +} diff --git a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/InMemorySessionRepo.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/InMemorySessionRepo.java new file mode 100644 index 0000000..36eddc4 --- /dev/null +++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/InMemorySessionRepo.java @@ -0,0 +1,35 @@ +package com.oviva.gesundheitsid.relyingparty.svc; + +import com.oviva.gesundheitsid.relyingparty.util.IdGenerator; +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +public class InMemorySessionRepo implements SessionRepo { + + private final ConcurrentMap repo = new ConcurrentHashMap<>(); + + @Override + public String save(@NonNull Session session) { + if (session.id() != null) { + throw new IllegalStateException( + "session already has an ID=%s, already saved?".formatted(session.id())); + } + + var id = IdGenerator.generateID(); + session = + new Session( + id, session.state(), session.nonce(), session.redirectUri(), session.clientId()); + + repo.put(id, session); + + return id; + } + + @Nullable + @Override + public Session load(@NonNull String sessionId) { + return repo.get(sessionId); + } +} diff --git a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/KeyStore.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/KeyStore.java new file mode 100644 index 0000000..191a362 --- /dev/null +++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/KeyStore.java @@ -0,0 +1,29 @@ +package com.oviva.gesundheitsid.relyingparty.svc; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.KeyUse; +import com.nimbusds.jose.jwk.gen.ECKeyGenerator; + +public class KeyStore { + + private final ECKey signingKey; + + public KeyStore() { + + try { + this.signingKey = + new ECKeyGenerator(Curve.P_256) + .keyIDFromThumbprint(false) + .keyUse(KeyUse.SIGNATURE) + .generate(); + } catch (JOSEException e) { + throw new IllegalStateException("failed to generate EC signing key", e); + } + } + + public ECKey signingKey() { + return signingKey; + } +} diff --git a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/SessionRepo.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/SessionRepo.java new file mode 100644 index 0000000..d495f92 --- /dev/null +++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/SessionRepo.java @@ -0,0 +1,13 @@ +package com.oviva.gesundheitsid.relyingparty.svc; + +import edu.umd.cs.findbugs.annotations.NonNull; +import java.net.URI; + +public interface SessionRepo { + + String save(@NonNull Session session); + + Session load(@NonNull String sessionId); + + record Session(String id, String state, String nonce, URI redirectUri, String clientId) {} +} diff --git a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/TokenIssuer.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/TokenIssuer.java new file mode 100644 index 0000000..0cfd19b --- /dev/null +++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/TokenIssuer.java @@ -0,0 +1,17 @@ +package com.oviva.gesundheitsid.relyingparty.svc; + +import com.oviva.gesundheitsid.relyingparty.svc.SessionRepo.Session; +import edu.umd.cs.findbugs.annotations.NonNull; +import java.net.URI; +import java.time.Instant; + +public interface TokenIssuer { + + Code issueCode(Session session); + + Token redeem(@NonNull String code); + + record Code(String code, Instant issuedAt, Instant expiresAt, URI redirectUri, String nonce, String clientId) {} + + record Token(String accessToken, String idToken, long expiresInSeconds) {} +} diff --git a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/TokenIssuerImpl.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/TokenIssuerImpl.java new file mode 100644 index 0000000..aa58491 --- /dev/null +++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/TokenIssuerImpl.java @@ -0,0 +1,128 @@ +package com.oviva.gesundheitsid.relyingparty.svc; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.ECDSASigner; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.oviva.gesundheitsid.relyingparty.svc.SessionRepo.Session; +import com.oviva.gesundheitsid.relyingparty.util.IdGenerator; +import edu.umd.cs.findbugs.annotations.NonNull; +import java.net.URI; +import java.time.Clock; +import java.time.Duration; +import java.util.Date; +import java.util.UUID; + +public class TokenIssuerImpl implements TokenIssuer { + + private final URI issuer; + + private final KeyStore keyStore; + + private final Duration TTL = Duration.ofSeconds(60); + private final Clock clock = Clock.systemUTC(); + + private final CodeRepo codeRepo; + + public TokenIssuerImpl(URI issuer, KeyStore keyStore, CodeRepo codeRepo) { + this.issuer = issuer; + this.keyStore = keyStore; + this.codeRepo = codeRepo; + } + + @Override + public Code issueCode(Session session) { + var code = IdGenerator.generateID(); + var value = + new Code( + code, + clock.instant(), + clock.instant().plus(TTL), + session.redirectUri(), + session.nonce(), + session.clientId()); + codeRepo.save(value); + return value; + } + + @Override + public Token redeem(@NonNull String code) { + var redeemed = codeRepo.remove(code).orElse(null); + if (redeemed == null) { + return null; + } + + if (redeemed.expiresAt().isBefore(clock.instant())) { + return null; + } + + var accessTokenTtl = Duration.ofMinutes(5); + return new Token( + issueAccessToken(accessTokenTtl, redeemed.clientId()), + issueIdToken(redeemed.clientId(), redeemed.nonce()), + accessTokenTtl.getSeconds()); + } + + private String issueIdToken(String audience, String nonce) { + try { + var jwk = keyStore.signingKey(); + var signer = new ECDSASigner(jwk); + + // Prepare JWT with claims set + var now = clock.instant(); + var claimsBuilder = + new JWTClaimsSet.Builder() + .issuer(issuer.toString()) + .audience(audience) + .subject(UUID.randomUUID().toString()) + .issueTime(Date.from(now)) + .expirationTime(Date.from(now.plus(Duration.ofHours(8)))); + + if (nonce != null) { + claimsBuilder.claim("nonce", nonce); + } + + var claims = claimsBuilder.build(); + + var signedJWT = + new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.ES256).keyID(jwk.getKeyID()).build(), claims); + + signedJWT.sign(signer); + + return signedJWT.serialize(); + } catch (JOSEException e) { + throw new RuntimeException(e); + } + } + + private String issueAccessToken(Duration ttl, String audience) { + try { + var jwk = keyStore.signingKey(); + var signer = new ECDSASigner(jwk); + + // Prepare JWT with claims set + var now = clock.instant(); + var claims = + new JWTClaimsSet.Builder() + .issuer(issuer.toString()) + .audience(audience) + .subject(UUID.randomUUID().toString()) + .issueTime(Date.from(now)) + .expirationTime(Date.from(now.plus(ttl))) + .build(); + + var signedJWT = + new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.ES256).keyID(jwk.getKeyID()).build(), claims); + + signedJWT.sign(signer); + + return signedJWT.serialize(); + } catch (JOSEException e) { + throw new RuntimeException(e); + } + } +} diff --git a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/util/IdGenerator.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/util/IdGenerator.java new file mode 100644 index 0000000..49edcbe --- /dev/null +++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/util/IdGenerator.java @@ -0,0 +1,19 @@ +package com.oviva.gesundheitsid.relyingparty.util; + +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Base64.Encoder; + +public class IdGenerator { + + private static final SecureRandom sr = new SecureRandom(); + private static final Encoder encoder = Base64.getUrlEncoder().withoutPadding(); + + private IdGenerator(){} + + public static String generateID() { + var raw = new byte[32]; + sr.nextBytes(raw); + return encoder.encodeToString(raw); + } +} diff --git a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/ws/App.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/ws/App.java new file mode 100644 index 0000000..7cff02e --- /dev/null +++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/ws/App.java @@ -0,0 +1,42 @@ +package com.oviva.gesundheitsid.relyingparty.ws; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.jakarta.rs.json.JacksonJsonProvider; +import com.oviva.gesundheitsid.relyingparty.svc.KeyStore; +import com.oviva.gesundheitsid.relyingparty.svc.SessionRepo; +import com.oviva.gesundheitsid.relyingparty.svc.TokenIssuer; +import com.oviva.gesundheitsid.relyingparty.cfg.Config; +import com.oviva.gesundheitsid.util.JoseModule; +import jakarta.ws.rs.core.Application; +import java.util.Set; + +public class App extends Application { + + private final Config config; + private final SessionRepo sessionRepo; + + private final KeyStore keyStore; + private final TokenIssuer tokenIssuer; + + public App(Config config, SessionRepo sessionRepo, KeyStore keyStore, TokenIssuer tokenIssuer) { + this.config = config; + this.sessionRepo = sessionRepo; + this.keyStore = keyStore; + this.tokenIssuer = tokenIssuer; + } + + @Override + public Set getSingletons() { + + return Set.of( + new OpenIdEndpoint(config, sessionRepo, tokenIssuer, keyStore), + new RequestLogFilter(), + new JacksonJsonProvider(configureObjectMapper())); + } + + private ObjectMapper configureObjectMapper() { + var om = new ObjectMapper(); + om.registerModule(new JoseModule()); + return om; + } +} diff --git a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/ws/OpenIdConfiguration.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/ws/OpenIdConfiguration.java new file mode 100644 index 0000000..a121cc7 --- /dev/null +++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/ws/OpenIdConfiguration.java @@ -0,0 +1,74 @@ +package com.oviva.gesundheitsid.relyingparty.ws; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +// https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata +public record OpenIdConfiguration( + + // REQUIRED. URL using the https scheme with no query or fragment components that the OP asserts + // as its Issuer Identifier. If Issuer discovery is supported (see Section 2), this value MUST + // be identical to the issuer value returned by WebFinger. This also MUST be identical to the + // iss Claim value in ID Tokens issued from this Issuer. + @JsonProperty("issuer") String issuer, + + // REQUIRED. URL of the OP's OAuth 2.0 Authorization Endpoint [OpenID.Core]. This URL MUST use + // the https scheme and MAY contain port, path, and query parameter components. + @JsonProperty("authorization_endpoint") String authorizationEndpoint, + + // URL of the OP's OAuth 2.0 Token Endpoint [OpenID.Core]. This is REQUIRED unless only the + // Implicit Flow is used. This URL MUST use the https scheme and MAY contain port, path, and + // query parameter components. + @JsonProperty("token_endpoint") String tokenEndpoint, + + // REQUIRED. URL of the OP's JWK Set [JWK] document, which MUST use the https scheme. This + // contains the signing key(s) the RP uses to validate signatures from the OP. The JWK Set MAY + // also contain the Server's encryption key(s), which are used by RPs to encrypt requests to the + // Server. When both signing and encryption keys are made available, a use (public key use) + // parameter value is REQUIRED for all keys in the referenced JWK Set to indicate each key's + // intended usage. Although some algorithms allow the same key to be used for both signatures + // and encryption, doing so is NOT RECOMMENDED, as it is less secure. The JWK x5c parameter MAY + // be used to provide X.509 representations of keys provided. When used, the bare key values + // MUST still be present and MUST match those in the certificate. The JWK Set MUST NOT contain + // private or symmetric key values. + @JsonProperty("jwks_uri") String jwksUri, + + // RECOMMENDED. JSON array containing a list of the OAuth 2.0 [RFC6749] scope values that this + // server supports. The server MUST support the openid scope value. Servers MAY choose not to + // advertise some supported scope values even when this parameter is used, although those + // defined in [OpenID.Core] SHOULD be listed, if supported. + @JsonProperty("scopes_supported") List scopesSupported, + + // REQUIRED. JSON array containing a list of the OAuth 2.0 response_type values that this OP + // supports. Dynamic OpenID Providers MUST support the code, id_token, and the id_token token + // Response Type values. + @JsonProperty("response_types_supported") List responseTypesSupported, + + // OPTIONAL. JSON array containing a list of the OAuth 2.0 Grant Type values that this OP + // supports. Dynamic OpenID Providers MUST support the authorization_code and implicit Grant + // Type values and MAY support other Grant Types. If omitted, the default value is + // ["authorization_code", "implicit"]. + @JsonProperty("grant_types_supported") List grantTypesSupported, + + // REQUIRED. JSON array containing a list of the Subject Identifier types that this OP supports. + // Valid types include pairwise and public. + // https://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes + @JsonProperty("subject_types_supported") List subjectTypesSupported, + + // REQUIRED. JSON array containing a list of the JWS signing algorithms (alg values) supported + // by the OP for the ID Token to encode the Claims in a JWT [JWT]. The algorithm RS256 MUST be + // included. The value none MAY be supported but MUST NOT be used unless the Response Type used + // returns no ID Token from the Authorization Endpoint (such as when using the Authorization + // Code Flow). + @JsonProperty("id_token_signing_alg_values_supported") + List idTokenSigningAlgValuesSupported, + + // OPTIONAL. JSON array containing a list of the JWE encryption algorithms (alg values) + // supported by the OP for the ID Token to encode the Claims in a JWT [JWT]. + @JsonProperty("id_token_encryption_alg_values_supported") + List idTokenEncryptionAlgValuesSupported, + + // OPTIONAL. JSON array containing a list of the JWE encryption algorithms (enc values) + // supported by the OP for the ID Token to encode the Claims in a JWT [JWT]. + @JsonProperty("id_token_encryption_enc_values_supported") + List idTokenEncryptionEncValuesSupported) {} diff --git a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/ws/OpenIdEndpoint.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/ws/OpenIdEndpoint.java new file mode 100644 index 0000000..d92e077 --- /dev/null +++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/ws/OpenIdEndpoint.java @@ -0,0 +1,230 @@ +package com.oviva.gesundheitsid.relyingparty.ws; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nimbusds.jose.jwk.JWKSet; +import com.oviva.gesundheitsid.relyingparty.svc.KeyStore; +import com.oviva.gesundheitsid.relyingparty.svc.SessionRepo; +import com.oviva.gesundheitsid.relyingparty.svc.TokenIssuer; +import com.oviva.gesundheitsid.relyingparty.ws.OpenIdErrorResponses.ErrorCode; +import com.oviva.gesundheitsid.relyingparty.svc.SessionRepo.Session; +import com.oviva.gesundheitsid.relyingparty.cfg.Config; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.CookieParam; +import jakarta.ws.rs.FormParam; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.CacheControl; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.NewCookie; +import jakarta.ws.rs.core.NewCookie.SameSite; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import jakarta.ws.rs.core.UriBuilder; +import java.net.URI; +import java.net.URISyntaxException; +import java.time.Duration; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Path("/") +public class OpenIdEndpoint { + + private final Config config; + + private final SessionRepo sessionRepo; + private final TokenIssuer tokenIssuer; + + private final KeyStore keyStore; + + public OpenIdEndpoint( + Config config, SessionRepo sessionRepo, TokenIssuer tokenIssuer, KeyStore keyStore) { + this.config = config; + this.sessionRepo = sessionRepo; + this.tokenIssuer = tokenIssuer; + this.keyStore = keyStore; + } + + @GET + @Path("/.well-known/openid-configuration") + @Produces(MediaType.APPLICATION_JSON) + public Response openIdConfiguration() { + + var body = + new OpenIdConfiguration( + config.baseUri().toString(), + config.baseUri().resolve("/auth").toString(), + config.baseUri().resolve("/token").toString(), + config.baseUri().resolve("/jwks.json").toString(), + List.of("openid"), + config.supportedResponseTypes(), + List.of("authorization_code"), + List.of("public"), + List.of("ES256"), + List.of(), + List.of()); + + return Response.ok(body).build(); + } + + // Authorization Request + // https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1 + @GET + @Path("/auth") + public Response auth( + @QueryParam("scope") String scope, + @QueryParam("state") String state, + @QueryParam("response_type") String responseType, + @QueryParam("client_id") String clientId, + @QueryParam("redirect_uri") String redirectUri, + @QueryParam("nonce") String nonce) { + + URI parsedRedirect = null; + try { + parsedRedirect = new URI(redirectUri); + } catch (URISyntaxException e) { + // TODO nice form + return Response.status(Status.BAD_REQUEST) + .entity("bad 'redirect_uri': %s".formatted(parsedRedirect)) + .build(); + } + + if (!config.validRedirectUris().contains(parsedRedirect)) { + // TODO nice form + return Response.status(Status.BAD_REQUEST) + .entity("untrusted 'redirect_uri': %s".formatted(parsedRedirect)) + .build(); + } + + if (!"openid".equals(scope)) { + return OpenIdErrorResponses.redirectWithError( + parsedRedirect, + ErrorCode.INVALID_SCOPE, + state, + "scope '%s' not supported".formatted(scope)); + } + + if (!config.supportedResponseTypes().contains(responseType)) { + return OpenIdErrorResponses.redirectWithError( + parsedRedirect, + ErrorCode.UNSUPPORTED_RESPONSE_TYPE, + state, + "unsupported response type: '%s'".formatted(responseType)); + } + + var session = new Session(null, state, nonce, parsedRedirect, clientId); + var sessionId = sessionRepo.save(session); + + // TODO: trigger actual flow + return Response.ok( + """ + + +
+
+ +
+
+ + + """, + MediaType.TEXT_HTML_TYPE) + .cookie(createSessionCookie(sessionId)) + .build(); + } + + private NewCookie createSessionCookie(String sessionId) { + return new NewCookie.Builder("session_id") + .value(sessionId) + .secure(true) + .httpOnly(true) + .sameSite(SameSite.LAX) + .maxAge(-1) // session scoped + .path("/auth") + .build(); + } + + @POST + @Path("/auth/callback") + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + public Response callback(@CookieParam("session_id") String sessionId) { + + if (sessionId == null || sessionId.isBlank()) { + // TODO: nice UI + return Response.status(Status.BAD_REQUEST) + .entity("Session missing!") + .type(MediaType.TEXT_PLAIN_TYPE) + .build(); + } + + var session = sessionRepo.load(sessionId); + if (session == null) { + // TODO: nice UI + return Response.status(Status.BAD_REQUEST) + .entity("Session not found!") + .type(MediaType.TEXT_PLAIN_TYPE) + .build(); + } + + // TODO: verify login + + var issued = tokenIssuer.issueCode(session); + + var redirectUri = + UriBuilder.fromUri(session.redirectUri()) + .queryParam("code", issued.code()) + .queryParam("state", session.state()) + .build(); + + return Response.seeOther(redirectUri).build(); + } + + @GET + @Path("/jwks.json") + @Produces(MediaType.APPLICATION_JSON) + public Response jwks() { + var key = keyStore.signingKey().toPublicJWK(); + + var cacheControl = new CacheControl(); + cacheControl.setMaxAge((int) Duration.ofMinutes(30).getSeconds()); + + return Response.ok(new JWKSet(List.of(key))).cacheControl(cacheControl).build(); + } + + // Access Token Request + // https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 + @POST + @Path("/token") + @Produces(MediaType.APPLICATION_JSON) + public Response token( + @FormParam("code") String code, + @FormParam("grant_type") String grantType, + @FormParam("redirect_uri") String redirectUri, + @FormParam("client_id") String clientId) { + + if (!"authorization_code".equals(grantType)) { + return Response.serverError().build(); // TODO + } + + var redeemed = tokenIssuer.redeem(code); + + return Response.ok( + new TokenResponse( + redeemed.accessToken(), + "Bearer", + null, + (int) redeemed.expiresInSeconds(), + redeemed.idToken())) + .build(); + } + + public record TokenResponse( + @JsonProperty("access_token") String accessToken, + @JsonProperty("token_type") String tokenType, + @JsonProperty("refresh_token") String refreshToken, + @JsonProperty("expires_in") int expiresIn, + @JsonProperty("id_token") String idToken) {} +} diff --git a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/ws/OpenIdErrorResponses.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/ws/OpenIdErrorResponses.java new file mode 100644 index 0000000..edc7076 --- /dev/null +++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/ws/OpenIdErrorResponses.java @@ -0,0 +1,72 @@ +package com.oviva.gesundheitsid.relyingparty.ws; + +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriBuilder; +import java.net.URI; + +public class OpenIdErrorResponses { + private OpenIdErrorResponses() {} + + public static Response redirectWithError( + @NonNull URI redirectUri, + @NonNull ErrorCode code, + @Nullable String state, + @Nullable String description) { + var builder = UriBuilder.fromUri(redirectUri); + + builder.queryParam("error", code.error()); + addNonBlankQueryParam(builder, "error_description", description); + addNonBlankQueryParam(builder, "state", state); + + return Response.seeOther(redirectUri).build(); + } + + private static void addNonBlankQueryParam(UriBuilder builder, String name, String value) { + if (value == null || value.isBlank()) { + return; + } + builder.queryParam(name, value); + } + + // https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1 + public enum ErrorCode { + + // The request is missing a required parameter, includes an invalid parameter value, includes a + // parameter more than once, or is otherwise malformed. + INVALID_REQUEST("invalid_request"), + + // The client is not authorized to request an authorization code using this method. + UNAUTHORIZED_CLIENT("unauthorized_client"), + + // The resource owner or authorization server denied the request. + ACCESS_DENIED("access_denied"), + + // The authorization server does not support obtaining an authorization code using this method. + UNSUPPORTED_RESPONSE_TYPE("unsupported_response_type"), + + // The requested scope is invalid, unknown, or malformed. + INVALID_SCOPE("invalid_scope"), + + // The authorization server encountered an unexpected condition that prevented it from + // fulfilling the request. (This error code is needed because a 500 Internal Server Error HTTP + // status code cannot be returned to the client via an HTTP redirect.) + SERVER_ERROR("server_error"), + + // The authorization server is currently unable to handle the request due to a temporary + // overloading or maintenance of the server. (This error code is needed because a 503 Service + // Unavailable HTTP status code cannot be returned to the client via an HTTP redirect.) + TEMPORARILY_UNAVAILABLE("temporarily_unavailable"); + + private final String error; + + ErrorCode(String error) { + this.error = error; + } + + public String error() { + return error; + } + } +} diff --git a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/ws/RequestLogFilter.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/ws/RequestLogFilter.java new file mode 100644 index 0000000..919723b --- /dev/null +++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/ws/RequestLogFilter.java @@ -0,0 +1,46 @@ +package com.oviva.gesundheitsid.relyingparty.ws; + +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerResponseContext; +import jakarta.ws.rs.container.ContainerResponseFilter; +import java.io.IOException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class RequestLogFilter implements ContainerResponseFilter { + + private final Logger logger = LoggerFactory.getLogger("http"); + + @Override + public void filter( + ContainerRequestContext requestContext, ContainerResponseContext responseContext) + throws IOException { + + logger + .atInfo() + .addKeyValue( + "httpRequest", + new HttpRequest( + requestContext.getMethod(), + requestContext.getUriInfo().getRequestUri().toString(), + responseContext.getStatus(), + requestContext.getLength(), + responseContext.getLength(), + requestContext.getHeaderString("user-agent"), + null)) + .log( + "{} {} {}", + requestContext.getMethod(), + requestContext.getUriInfo().getPath(), + responseContext.getStatus()); + } + + private record HttpRequest( + String requestMethod, + String requestUrl, + int status, + int requestSize, + int responseSize, + String userAgent, + String remoteIp) {} +} diff --git a/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/cfg/EnvConfigProviderTest.java b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/cfg/EnvConfigProviderTest.java new file mode 100644 index 0000000..540a3bc --- /dev/null +++ b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/cfg/EnvConfigProviderTest.java @@ -0,0 +1,36 @@ +package com.oviva.gesundheitsid.relyingparty.cfg; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.util.function.Function; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +class EnvConfigProviderTest { + + private static final String PREFIX = "OIDC_SERVER"; + + static Stream mangleTestCases() { + return Stream.of( + new TC("my.config", PREFIX + "_MY_CONFIG"), new TC("a.no..ther", PREFIX + "_A_NO__THER")); + } + + @ParameterizedTest + @MethodSource("mangleTestCases") + void getMangleName(TC t) { + + var getenv = (Function) mock(Function.class); + + var sut = new EnvConfigProvider(PREFIX, getenv); + + // when + sut.get(t.key()); + + // then + verify(getenv).apply(t.expected()); + } + + record TC(String key, String expected) {} +} diff --git a/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/svc/InMemoryCodeRepoTest.java b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/svc/InMemoryCodeRepoTest.java new file mode 100644 index 0000000..25aec0b --- /dev/null +++ b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/svc/InMemoryCodeRepoTest.java @@ -0,0 +1,58 @@ +package com.oviva.gesundheitsid.relyingparty.svc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.oviva.gesundheitsid.relyingparty.svc.TokenIssuer.Code; +import java.net.URI; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class InMemoryCodeRepoTest { + + @Test + void saveAndRemove() { + + var sut = new InMemoryCodeRepo(); + + var id = "1234"; + var issuedAt = Instant.now(); + var expiresAt = issuedAt.plusSeconds(60); + var redirect = URI.create("https://example.com/callback"); + var clientId = "app"; + + var code = new Code(id, issuedAt, expiresAt, redirect, null, clientId); + + sut.save(code); + + var c1 = sut.remove(id); + assertTrue(c1.isPresent()); + assertEquals(code, c1.get()); + } + + @Test + void remove_nonExisting() { + + var sut = new InMemoryCodeRepo(); + var c1 = sut.remove("x"); + assertTrue(c1.isEmpty()); + } + + @Test + void remove_twice() { + + var sut = new InMemoryCodeRepo(); + + var id = "4929"; + + var code = new Code(id, null, null, null, null, null); + + sut.save(code); + + var c1 = sut.remove(id); + assertTrue(c1.isPresent()); + + var c2 = sut.remove(id); + assertTrue(c2.isEmpty()); + } +} diff --git a/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/svc/InMemorySessionRepoTest.java b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/svc/InMemorySessionRepoTest.java new file mode 100644 index 0000000..2394000 --- /dev/null +++ b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/svc/InMemorySessionRepoTest.java @@ -0,0 +1,55 @@ +package com.oviva.gesundheitsid.relyingparty.svc; + +import static org.junit.jupiter.api.Assertions.*; + +import io.undertow.server.session.Session; +import java.net.URI; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class InMemorySessionRepoTest { + + @Test + void save() { + var sut = new InMemorySessionRepo(); + var session = new SessionRepo.Session(null, null, null, null, null); + + var id1 = sut.save(session); + assertNotNull(id1); + + var id2 = sut.save(session); + assertNotNull(id1); + + assertNotEquals(id1, id2); + } + + @Test + void save_alreadySaved() { + var sut = new InMemorySessionRepo(); + var session = new SessionRepo.Session("1", null, null, null, null); + + assertThrows(IllegalStateException.class, () -> sut.save(session)); + } + + @Test + void load() { + + var sut = new InMemorySessionRepo(); + + var state = "myState"; + var nonce = UUID.randomUUID().toString(); + var redirectUri = URI.create("https://example.com/callback"); + var clientId = "app"; + + var session = new SessionRepo.Session(null, state, nonce, redirectUri, clientId); + + var id = sut.save(session); + + var got = sut.load(id); + + assertEquals(id, got.id()); + assertEquals(state, got.state()); + assertEquals(redirectUri, got.redirectUri()); + assertEquals(clientId, got.clientId()); + } +} diff --git a/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/svc/KeyStoreTest.java b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/svc/KeyStoreTest.java new file mode 100644 index 0000000..c4ce323 --- /dev/null +++ b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/svc/KeyStoreTest.java @@ -0,0 +1,21 @@ +package com.oviva.gesundheitsid.relyingparty.svc; + +import static org.junit.jupiter.api.Assertions.*; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.jwk.KeyUse; +import org.junit.jupiter.api.Test; + +class KeyStoreTest { + + @Test + void signingKey() throws JOSEException { + + var sut = new KeyStore(); + + var key = sut.signingKey(); + + assertEquals(KeyUse.SIGNATURE, key.getKeyUse()); + assertNotNull(key.toECPrivateKey()); + } +} diff --git a/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/svc/TokenIssuerImplTest.java b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/svc/TokenIssuerImplTest.java new file mode 100644 index 0000000..ce47d51 --- /dev/null +++ b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/svc/TokenIssuerImplTest.java @@ -0,0 +1,18 @@ +package com.oviva.gesundheitsid.relyingparty.svc; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class TokenIssuerImplTest { + + @Test + void issueCode() { + //TODO + } + + @Test + void redeem() { + //TODO + } +} \ No newline at end of file diff --git a/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/util/IdGeneratorTest.java b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/util/IdGeneratorTest.java new file mode 100644 index 0000000..bfd446e --- /dev/null +++ b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/util/IdGeneratorTest.java @@ -0,0 +1,24 @@ +package com.oviva.gesundheitsid.relyingparty.util; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.HashSet; +import org.junit.jupiter.api.Test; + +class IdGeneratorTest { + + @Test + void generate_unique() { + + var previous = new HashSet<>(); + + for (int i = 0; i < 100; i++) { + + var next = IdGenerator.generateID(); + if (previous.contains(next)) { + fail(); + } + previous.add(next); + } + } +} diff --git a/pom.xml b/pom.xml index b1dc279..5c6ec2b 100644 --- a/pom.xml +++ b/pom.xml @@ -23,6 +23,8 @@ 3.5.3.Final 2.0.10 9.37.3 + 1.4.14 + 0.1.5 2.41.1 1.18.1 @@ -60,18 +62,12 @@ gesundheitsid + oidc-server reports - - - com.oviva.gesundheitsid - gesunheitsid - 0.0.1-SNAPSHOT - - com.github.spotbugs spotbugs-annotations @@ -145,6 +141,21 @@ nimbus-jose-jwt ${version.nimbus-jose-jwt} + + ch.qos.logback + logback-classic + ${version.logback.classic} + + + ch.qos.logback.contrib + logback-json-classic + ${version.logback.json} + + + ch.qos.logback.contrib + logback-jackson + ${version.logback.json} + com.google.auto.service auto-service @@ -313,11 +324,6 @@ flatten-maven-plugin 1.6.0 - - org.apache.maven.plugins - maven-source-plugin - 3.3.0 - org.apache.maven.plugins maven-source-plugin