From ecad68b03aa84c86657312675bd59a763175d527 Mon Sep 17 00:00:00 2001 From: Thomas Richner Date: Sun, 4 Feb 2024 16:32:41 +0100 Subject: [PATCH] ARC-1222: Test coverage --- oidc-server/pom.xml | 4 +- .../gesundheitsid/relyingparty/Main.java | 28 +- .../relyingparty/svc/TokenIssuer.java | 2 +- .../relyingparty/svc/TokenIssuerImpl.java | 25 +- .../relyingparty/util/Strings.java | 24 ++ .../gesundheitsid/relyingparty/ws/App.java | 1 - .../relyingparty/ws/OpenIdEndpoint.java | 18 +- .../relyingparty/ws/OpenIdErrorResponses.java | 2 +- .../relyingparty/ws/RequestLogFilter.java | 46 --- .../gesundheitsid/relyingparty/MainTest.java | 20 + .../relyingparty/svc/TokenIssuerImplTest.java | 190 +++++++++- .../relyingparty/util/StringsTest.java | 31 ++ .../relyingparty/ws/OpenIdEndpointTest.java | 350 ++++++++++++++++++ 13 files changed, 661 insertions(+), 80 deletions(-) create mode 100644 oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/util/Strings.java delete mode 100644 oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/ws/RequestLogFilter.java create mode 100644 oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/MainTest.java create mode 100644 oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/util/StringsTest.java create mode 100644 oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/ws/OpenIdEndpointTest.java diff --git a/oidc-server/pom.xml b/oidc-server/pom.xml index 8bf6581..82d8b67 100644 --- a/oidc-server/pom.xml +++ b/oidc-server/pom.xml @@ -56,8 +56,8 @@ resteasy-undertow - org.jboss.resteasy - resteasy-jackson2-provider + com.fasterxml.jackson.jakarta.rs + jackson-jakarta-rs-json-provider 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 index 21a1e16..ad9d034 100644 --- a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/Main.java +++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/Main.java @@ -7,6 +7,7 @@ import com.oviva.gesundheitsid.relyingparty.svc.InMemorySessionRepo; import com.oviva.gesundheitsid.relyingparty.svc.KeyStore; import com.oviva.gesundheitsid.relyingparty.svc.TokenIssuerImpl; +import com.oviva.gesundheitsid.relyingparty.util.Strings; import com.oviva.gesundheitsid.relyingparty.ws.App; import jakarta.ws.rs.SeBootstrap; import jakarta.ws.rs.SeBootstrap.Configuration; @@ -38,7 +39,7 @@ public static void main(String[] args) throws ExecutionException, InterruptedExc main.run(new EnvConfigProvider("OIDC_SERVER", System::getenv)); } - private void run(ConfigProvider configProvider) throws ExecutionException, InterruptedException { + public void run(ConfigProvider configProvider) throws ExecutionException, InterruptedException { logger.atInfo().log("\n" + BANNER); var baseUri = URI.create("https://t.oviva.io"); @@ -57,10 +58,11 @@ private void run(ConfigProvider configProvider) throws ExecutionException, Inter // + port)), supportedResponseTypes, validRedirectUris // TODO: hardcoded :) - // configProvider.get("redirect_uris").stream() - // .flatMap(this::mustParseCommaList) - // .map(URI::create) - // .toList() + + // configProvider.get("redirect_uris").stream() + // .flatMap(Strings::mustParseCommaList) + // .map(URI::create) + // .toList() ); var keyStore = new KeyStore(); @@ -80,20 +82,4 @@ private void run(ConfigProvider configProvider) throws ExecutionException, Inter // 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/svc/TokenIssuer.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/TokenIssuer.java index 2ab63b2..71979a0 100644 --- 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 @@ -9,7 +9,7 @@ public interface TokenIssuer { Code issueCode(Session session); - Token redeem(@NonNull String code); + Token redeem(@NonNull String code, String redirectUri, String clientId); record Code( String code, 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 index aa58491..3bcebc9 100644 --- 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 @@ -10,10 +10,13 @@ import com.oviva.gesundheitsid.relyingparty.util.IdGenerator; import edu.umd.cs.findbugs.annotations.NonNull; import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.time.Clock; import java.time.Duration; import java.util.Date; import java.util.UUID; +import org.apache.commons.codec.cli.Digest; public class TokenIssuerImpl implements TokenIssuer { @@ -48,13 +51,14 @@ public Code issueCode(Session session) { } @Override - public Token redeem(@NonNull String code) { + public Token redeem(@NonNull String code, String redirectUri, String clientId) { + var redeemed = codeRepo.remove(code).orElse(null); if (redeemed == null) { return null; } - if (redeemed.expiresAt().isBefore(clock.instant())) { + if (!validateCode(redeemed, redirectUri, clientId)) { return null; } @@ -65,6 +69,23 @@ public Token redeem(@NonNull String code) { accessTokenTtl.getSeconds()); } + private boolean validateCode(Code code, String redirectUri, String clientId) { + + if (code.expiresAt().isBefore(clock.instant())) { + return false; + } + + if (redirectUri == null || clientId == null) { + return false; + } + + if (!code.redirectUri().toString().equals(redirectUri)) { + return false; + } + + return code.clientId().equals(clientId); + } + private String issueIdToken(String audience, String nonce) { try { var jwk = keyStore.signingKey(); diff --git a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/util/Strings.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/util/Strings.java new file mode 100644 index 0000000..5b3ea3a --- /dev/null +++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/util/Strings.java @@ -0,0 +1,24 @@ +package com.oviva.gesundheitsid.relyingparty.util; + +import java.util.Arrays; +import java.util.Objects; +import java.util.stream.Stream; + +public class Strings { + + public static Stream mustParseCommaList(String value) { + if (value == null || value.isBlank()) { + return Stream.empty(); + } + + return Arrays.stream(value.split(",")).map(Strings::trimmed).filter(Objects::nonNull); + } + + public static 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/ws/App.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/ws/App.java index 9905a40..a44f2a3 100644 --- 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 @@ -30,7 +30,6 @@ public Set getSingletons() { return Set.of( new OpenIdEndpoint(config, sessionRepo, tokenIssuer, keyStore), - new RequestLogFilter(), new JacksonJsonProvider(configureObjectMapper())); } 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 index bc4e191..8087ff6 100644 --- 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 @@ -90,6 +90,13 @@ public Response auth( .build(); } + if (!"https".equals(parsedRedirect.getScheme())) { + // TODO nice form + return Response.status(Status.BAD_REQUEST) + .entity("not https 'redirect_uri': %s".formatted(parsedRedirect)) + .build(); + } + if (!config.validRedirectUris().contains(parsedRedirect)) { // TODO nice form return Response.status(Status.BAD_REQUEST) @@ -204,10 +211,16 @@ public Response token( @FormParam("client_id") String clientId) { if (!"authorization_code".equals(grantType)) { - return Response.serverError().build(); // TODO + return Response.status(Status.BAD_REQUEST).entity("bad 'grant_type': " + grantType).build(); + } + + var redeemed = tokenIssuer.redeem(code, redirectUri, clientId); + if (redeemed == null) { + return Response.status(Status.BAD_REQUEST).entity("invalid code").build(); } - var redeemed = tokenIssuer.redeem(code); + var cacheControl = new CacheControl(); + cacheControl.setNoStore(true); return Response.ok( new TokenResponse( @@ -216,6 +229,7 @@ public Response token( null, (int) redeemed.expiresInSeconds(), redeemed.idToken())) + .cacheControl(cacheControl) .build(); } 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 index edc7076..2d95936 100644 --- 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 @@ -20,7 +20,7 @@ public static Response redirectWithError( addNonBlankQueryParam(builder, "error_description", description); addNonBlankQueryParam(builder, "state", state); - return Response.seeOther(redirectUri).build(); + return Response.seeOther(builder.build()).build(); } private static void addNonBlankQueryParam(UriBuilder builder, String name, String value) { 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 deleted file mode 100644 index 919723b..0000000 --- a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/ws/RequestLogFilter.java +++ /dev/null @@ -1,46 +0,0 @@ -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/MainTest.java b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/MainTest.java new file mode 100644 index 0000000..2aa1b8c --- /dev/null +++ b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/MainTest.java @@ -0,0 +1,20 @@ +package com.oviva.gesundheitsid.relyingparty; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +import com.oviva.gesundheitsid.relyingparty.cfg.ConfigProvider; +import java.util.concurrent.ExecutionException; +import org.junit.jupiter.api.Test; + +class MainTest { + + @Test + void test() throws ExecutionException, InterruptedException { + var sut = new Main(); + + var configProvider = mock(ConfigProvider.class); + + sut.run(configProvider); + } +} 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 index b1a0b57..2279e17 100644 --- 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 @@ -1,18 +1,200 @@ package com.oviva.gesundheitsid.relyingparty.svc; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSObject; +import com.nimbusds.jose.crypto.ECDSAVerifier; +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; +import com.oviva.gesundheitsid.relyingparty.svc.TokenIssuer.Code; +import java.net.URI; +import java.text.ParseException; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; import org.junit.jupiter.api.Test; class TokenIssuerImplTest { @Test - void issueCode() { - // TODO + void issueCode_unique() { + var issuer = URI.create("https://idp.example.com"); + var keyStore = mock(KeyStore.class); + var codeRepo = mock(CodeRepo.class); + + var sut = new TokenIssuerImpl(issuer, keyStore, codeRepo); + + var session = new SessionRepo.Session(null, null, null, null, null); + + // when + var c1 = sut.issueCode(session); + var c2 = sut.issueCode(session); + + // then + assertNotNull(c1); + assertNotNull(c2); + + assertNotEquals(c1.code(), c2.code()); + + assertEquals(43, c1.code().length()); + } + + @Test + void issueCode_notExpired() { + var issuer = URI.create("https://idp.example.com"); + var keyStore = mock(KeyStore.class); + var codeRepo = mock(CodeRepo.class); + + var sut = new TokenIssuerImpl(issuer, keyStore, codeRepo); + + var session = new SessionRepo.Session(null, null, null, null, null); + + // when + var c1 = sut.issueCode(session); + + // then + var now = Instant.now(); + assertTrue(c1.issuedAt().isBefore(now)); + assertTrue(c1.expiresAt().isAfter(now)); + } + + @Test + void issueCode_propagatesValues() { + var issuer = URI.create("https://idp.example.com"); + var keyStore = mock(KeyStore.class); + var codeRepo = mock(CodeRepo.class); + + var sut = new TokenIssuerImpl(issuer, keyStore, codeRepo); + + var nonce = UUID.randomUUID().toString(); + var redirectUri = URI.create("https://myapp.example.com/callback"); + var clientId = "myapp"; + + var session = new SessionRepo.Session(null, null, nonce, redirectUri, clientId); + + // when + var code = sut.issueCode(session); + + // then + assertEquals(nonce, code.nonce()); + assertEquals(redirectUri, code.redirectUri()); + assertEquals(clientId, code.clientId()); + } + + @Test + void issueCode_saves() { + var codeRepo = mock(CodeRepo.class); + + var sut = new TokenIssuerImpl(null, null, codeRepo); + + var nonce = UUID.randomUUID().toString(); + var redirectUri = URI.create("https://myapp.example.com/callback"); + var clientId = "myapp"; + + var session = new SessionRepo.Session(null, null, nonce, redirectUri, clientId); + + // when + var code = sut.issueCode(session); + + // then + verify(codeRepo).save(code); + } + + @Test + void redeem_nonExisting() { + var issuer = URI.create("https://idp.example.com"); + var keyStore = mock(KeyStore.class); + var codeRepo = mock(CodeRepo.class); + + var sut = new TokenIssuerImpl(issuer, keyStore, codeRepo); + + var code = UUID.randomUUID().toString(); + + var token = sut.redeem(code, null, null); + + verify(codeRepo).remove(code); + assertNull(token); + } + + @Test + void redeem_twice() throws JOSEException { + var issuer = URI.create("https://idp.example.com"); + + var k = genKey(); + var keyStore = mock(KeyStore.class); + + when(keyStore.signingKey()).thenReturn(k); + var codeRepo = mock(CodeRepo.class); + + var sut = new TokenIssuerImpl(issuer, keyStore, codeRepo); + + var redirectUri = URI.create("https://myapp.example.com"); + var clientId = "myapp"; + + var id = UUID.randomUUID().toString(); + var code = new Code(id, null, Instant.now().plusSeconds(10), redirectUri, null, clientId); + + when(codeRepo.remove(id)).thenReturn(Optional.of(code), Optional.empty()); + + // when + var t1 = sut.redeem(id, redirectUri.toString(), clientId); + var t2 = sut.redeem(id, redirectUri.toString(), clientId); + + // then + assertNotNull(t1); + assertNull(t2); } @Test - void redeem() { - // TODO + void redeem_idToken() throws JOSEException, ParseException { + var issuer = URI.create("https://idp.example.com"); + + var k = genKey(); + var keyStore = mock(KeyStore.class); + when(keyStore.signingKey()).thenReturn(k); + var codeRepo = mock(CodeRepo.class); + + var sut = new TokenIssuerImpl(issuer, keyStore, codeRepo); + + var id = UUID.randomUUID().toString(); + + var nonce = UUID.randomUUID().toString(); + var redirectUri = URI.create("https://myapp.example.com/callback"); + var clientId = "myapp"; + + var code = new Code(id, null, Instant.now().plusSeconds(10), redirectUri, nonce, clientId); + + when(codeRepo.remove(id)).thenReturn(Optional.of(code)); + + // when + var token = sut.redeem(id, redirectUri.toString(), clientId); + + // then + var idToken = token.idToken(); + + var jws = JWSObject.parse(idToken); + var verifier = new ECDSAVerifier(k); + jws.verify(verifier); + + assertIdTokenClaims(jws, nonce, issuer, clientId); + } + + private void assertIdTokenClaims(JWSObject idToken, String nonce, URI issuer, String clientId) { + + var body = idToken.getPayload().toJSONObject(); + assertEquals(nonce, body.get("nonce")); + assertEquals(issuer.toString(), body.get("iss")); + assertEquals(clientId, body.get("aud")); + } + + private ECKey genKey() throws JOSEException { + var gen = new ECKeyGenerator(Curve.P_256); + return gen.keyIDFromThumbprint(false).keyUse(KeyUse.SIGNATURE).generate(); } } diff --git a/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/util/StringsTest.java b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/util/StringsTest.java new file mode 100644 index 0000000..bd8983a --- /dev/null +++ b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/util/StringsTest.java @@ -0,0 +1,31 @@ +package com.oviva.gesundheitsid.relyingparty.util; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +class StringsTest { + + static Stream listTestCases() { + return Stream.of( + new ListTC("a,b,c", List.of("a", "b", "c")), + new ListTC("a,,c", List.of("a", "c")), + new ListTC("a, ,c", List.of("a", "c")), + new ListTC(",a,,,c,", List.of("a", "c")), + new ListTC(null, List.of()), + new ListTC("a , b , \tc", List.of("a", "b", "c"))); + } + + @ParameterizedTest + @MethodSource("listTestCases") + void mustParseCommaList(ListTC t) { + var got = Strings.mustParseCommaList(t.value()); + assertEquals(t.expected(), got.toList()); + } + + record ListTC(String value, List expected) {} +} diff --git a/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/ws/OpenIdEndpointTest.java b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/ws/OpenIdEndpointTest.java new file mode 100644 index 0000000..0d70639 --- /dev/null +++ b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/ws/OpenIdEndpointTest.java @@ -0,0 +1,350 @@ +package com.oviva.gesundheitsid.relyingparty.ws; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.OctetSequenceKey; +import com.oviva.gesundheitsid.relyingparty.cfg.Config; +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.svc.TokenIssuer.Code; +import com.oviva.gesundheitsid.relyingparty.svc.TokenIssuer.Token; +import com.oviva.gesundheitsid.relyingparty.ws.OpenIdEndpoint.TokenResponse; +import jakarta.ws.rs.core.Response.Status; +import java.net.URI; +import java.text.ParseException; +import java.util.List; +import java.util.UUID; +import org.checkerframework.checker.units.qual.C; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +class OpenIdEndpointTest { + + private static final URI BASE_URI = URI.create("https://idp.example.com"); + private static final URI REDIRECT_URI = URI.create("https://myapp.example.com"); + + @Test + void openIdConfiguration() { + + var config = new Config(0, BASE_URI, null, null); + var sut = new OpenIdEndpoint(config, null, null, null); + + // when + OpenIdConfiguration body; + try (var res = sut.openIdConfiguration()) { + body = res.readEntity(OpenIdConfiguration.class); + } + + // then + assertEquals(BASE_URI.toString(), body.issuer()); + assertEquals(BASE_URI.resolve("/auth").toString(), body.authorizationEndpoint()); + assertEquals(BASE_URI.resolve("/jwks.json").toString(), body.jwksUri()); + assertEquals(BASE_URI.resolve("/token").toString(), body.tokenEndpoint()); + assertEquals(List.of("ES256"), body.idTokenSigningAlgValuesSupported()); + } + + @Test + void auth_badScopes() { + var config = new Config(0, BASE_URI, null, List.of(REDIRECT_URI)); + + var sut = new OpenIdEndpoint(config, null, null, null); + + var scope = "openid email"; + var state = UUID.randomUUID().toString(); + var nonce = UUID.randomUUID().toString(); + var responseType = "code"; + var clientId = "myapp"; + + // when + try (var res = sut.auth(scope, state, responseType, clientId, REDIRECT_URI.toString(), nonce)) { + // then + assertEquals(Status.SEE_OTHER.getStatusCode(), res.getStatus()); + var location = res.getHeaderString("location"); + assertEquals( + "https://myapp.example.com?error=invalid_scope&error_description=scope+%%27openid+email%%27+not+supported&state=%s" + .formatted(state), + location); + } + } + + @Test + void auth_malformedRedirect() { + var config = new Config(0, BASE_URI, null, List.of(REDIRECT_URI)); + + var sut = new OpenIdEndpoint(config, null, null, null); + + var scope = "openid email"; + var state = UUID.randomUUID().toString(); + var nonce = UUID.randomUUID().toString(); + var responseType = "code"; + var clientId = "myapp"; + var redirectUri = "httpyolo://yolo!"; + + // when + try (var res = sut.auth(scope, state, responseType, clientId, redirectUri, nonce)) { + // then + assertEquals(Status.BAD_REQUEST.getStatusCode(), res.getStatus()); + } + } + + @Test + void auth_untrustedRedirect() { + var config = new Config(0, BASE_URI, null, List.of(REDIRECT_URI)); + + var sut = new OpenIdEndpoint(config, null, null, null); + + var scope = "openid email"; + var state = UUID.randomUUID().toString(); + var nonce = UUID.randomUUID().toString(); + var responseType = "code"; + var clientId = "myapp"; + var redirectUri = "https://bad.example.com/evil"; + + // when + try (var res = sut.auth(scope, state, responseType, clientId, redirectUri, nonce)) { + // then + assertEquals(Status.BAD_REQUEST.getStatusCode(), res.getStatus()); + + // according to spec we _MUST NOT_ redirect if we don't trust the redirect + assertNull(res.getHeaderString("location")); + } + } + + @Test + void auth_badResponseType() { + var config = new Config(0, BASE_URI, List.of("code"), List.of(REDIRECT_URI)); + + var sessionRepo = mock(SessionRepo.class); + var sut = new OpenIdEndpoint(config, sessionRepo, null, null); + + var scope = "openid"; + var state = UUID.randomUUID().toString(); + var nonce = UUID.randomUUID().toString(); + var responseType = "badtype"; + var clientId = "myapp"; + + var sessionId = UUID.randomUUID().toString(); + when(sessionRepo.save(any())).thenReturn(sessionId); + + // when + try (var res = sut.auth(scope, state, responseType, clientId, REDIRECT_URI.toString(), nonce)) { + + // then + assertEquals(Status.SEE_OTHER.getStatusCode(), res.getStatus()); + var location = res.getHeaderString("location"); + assertEquals( + "https://myapp.example.com?error=unsupported_response_type&error_description=unsupported+response+type%3A+%27badtype%27&state=" + + state, + location); + } + } + + @Test + void auth_success() { + var config = new Config(0, BASE_URI, List.of("code"), List.of(REDIRECT_URI)); + + var sessionRepo = mock(SessionRepo.class); + var sut = new OpenIdEndpoint(config, sessionRepo, null, null); + + var scope = "openid"; + var state = UUID.randomUUID().toString(); + var nonce = UUID.randomUUID().toString(); + var responseType = "code"; + var clientId = "myapp"; + + var sessionId = UUID.randomUUID().toString(); + when(sessionRepo.save(any())).thenReturn(sessionId); + + // when + try (var res = sut.auth(scope, state, responseType, clientId, REDIRECT_URI.toString(), nonce)) { + + // then + assertEquals(Status.OK.getStatusCode(), res.getStatus()); + var sessionCookie = res.getCookies().get("session_id"); + assertEquals(sessionId, sessionCookie.getValue()); + } + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = {" ", " \n\t"}) + void callback_noSessionId(String sessionId) { + + var config = new Config(0, null, null, null); + + var sut = new OpenIdEndpoint(config, null, null, null); + + // when + try (var res = sut.callback(sessionId)) { + + // then + assertEquals(Status.BAD_REQUEST.getStatusCode(), res.getStatus()); + } + } + + @Test + void callback_unknownSession() { + + var config = new Config(0, null, null, null); + + var sessionRepo = mock(SessionRepo.class); + + var sut = new OpenIdEndpoint(config, sessionRepo, null, null); + + var sessionId = UUID.randomUUID().toString(); + + when(sessionRepo.load(sessionId)).thenReturn(null); + + // when + try (var res = sut.callback(sessionId)) { + + // then + assertEquals(Status.BAD_REQUEST.getStatusCode(), res.getStatus()); + } + } + + @Test + void callback() { + + var config = new Config(0, BASE_URI, List.of("code"), List.of(REDIRECT_URI)); + + var sessionRepo = mock(SessionRepo.class); + var tokenIssuer = mock(TokenIssuer.class); + + var sut = new OpenIdEndpoint(config, sessionRepo, tokenIssuer, null); + + var sessionId = UUID.randomUUID().toString(); + + var state = "mySuperDuperState"; + var nonce = "20e5ed8b-f96b-48de-ae73-4460bcfc35a1"; + var clientId = "myapp"; + + var session = new SessionRepo.Session(sessionId, state, nonce, REDIRECT_URI, clientId); + when(sessionRepo.load(sessionId)).thenReturn(session); + + var code = "6238e4504332468aa0c12e300787fded"; + var issued = new Code(code, null, null, REDIRECT_URI, nonce, clientId); + when(tokenIssuer.issueCode(session)).thenReturn(issued); + + // when + try (var res = sut.callback(sessionId)) { + + // then + assertEquals( + "https://myapp.example.com?code=6238e4504332468aa0c12e300787fded&state=mySuperDuperState", + res.getHeaderString("location")); + } + } + + @Test + void jwks() throws ParseException { + + var key = + ECKey.parse( + """ + {"kty":"EC","use":"sig","crv":"P-256","x":"yi3EF1QZS1EiAfAAfjoDyZkRnf59H49gUyklmfwKwSY","y":"Y_SGRGjwacDuT8kbcaX1Igyq8aRfJFNBMKLb2yr0x18"} + """); + var keyStore = mock(KeyStore.class); + when(keyStore.signingKey()).thenReturn(key); + + var sut = new OpenIdEndpoint(null, null, null, keyStore); + + try (var res = sut.jwks()) { + var jwks = res.readEntity(JWKSet.class); + assertEquals(key, jwks.getKeys().get(0)); + } + } + + @Test + void token_badGrantType() { + + var config = new Config(0, BASE_URI, List.of("code"), List.of(REDIRECT_URI)); + + var tokenIssuer = mock(TokenIssuer.class); + + var sut = new OpenIdEndpoint(config, null, tokenIssuer, null); + + var clientId = "myapp"; + + var grantType = "yolo"; + + var code = "6238e4504332468aa0c12e300787fded"; + + when(tokenIssuer.redeem(code, null, null)).thenReturn(null); + + // when + try (var res = sut.token(code, grantType, REDIRECT_URI.toString(), clientId)) { + + // then + assertEquals(Status.BAD_REQUEST.getStatusCode(), res.getStatus()); + } + } + @Test + void token_badCode() { + + var config = new Config(0, BASE_URI, List.of("code"), List.of(REDIRECT_URI)); + + var tokenIssuer = mock(TokenIssuer.class); + + var sut = new OpenIdEndpoint(config, null, tokenIssuer, null); + + var clientId = "myapp"; + + var grantType = "authorization_code"; + + var code = "6238e4504332468aa0c12e300787fded"; + + when(tokenIssuer.redeem(code, REDIRECT_URI.toString(), clientId)).thenReturn(null); + + // when + try (var res = sut.token(code, grantType, REDIRECT_URI.toString(), clientId)) { + + // then + assertEquals(Status.BAD_REQUEST.getStatusCode(), res.getStatus()); + } + } + + @Test + void token() { + + var config = new Config(0, BASE_URI, List.of("code"), List.of(REDIRECT_URI)); + + var tokenIssuer = mock(TokenIssuer.class); + + var sut = new OpenIdEndpoint(config, null, tokenIssuer, null); + + var clientId = "myapp"; + + var grantType = "authorization_code"; + + var idToken = UUID.randomUUID().toString(); + var accessToken = UUID.randomUUID().toString(); + var expiresIn = 3600; + + var code = "6238e4504332468aa0c12e300787fded"; + var token = new Token(accessToken, idToken, expiresIn); + when(tokenIssuer.redeem(code, REDIRECT_URI.toString(), clientId)).thenReturn(token); + + // when + try (var res = sut.token(code, grantType, REDIRECT_URI.toString(), clientId)) { + + // then + assertEquals(Status.OK.getStatusCode(), res.getStatus()); + var got = res.readEntity(TokenResponse.class); + + assertEquals(idToken, got.idToken()); + assertEquals(accessToken, got.accessToken()); + assertEquals(expiresIn, got.expiresIn()); + } + } +}