From 5f27d225e5ec7d4597a3bb873e5a5644dd8029fc Mon Sep 17 00:00:00 2001 From: Oliver Wolff <23139298+cuioss@users.noreply.github.com> Date: Wed, 2 Oct 2024 09:20:50 +0200 Subject: [PATCH] Adding Module for token-handling (#60) * Fixing redundant formatted call * Adding portal-authentication.-token module * Fixing maven-build --- bom/pom.xml | 9 +- modules/authentication/pom.xml | 1 + .../impl/Oauth2AuthenticationFacadeImpl.java | 62 +++--- .../portal-authentication-token/README.adoc | 28 +++ .../portal-authentication-token/pom.xml | 68 +++++++ .../token/JwksAwareTokenParser.java | 43 +++++ .../authentication/token/LogMessages.java | 19 ++ .../token/ParsedAccessToken.java | 182 ++++++++++++++++++ .../authentication/token/ParsedIdToken.java | 43 +++++ .../authentication/token/ParsedToken.java | 111 +++++++++++ .../token/JwksAwareTokenParserTest.java | 95 +++++++++ .../token/JwksResolveDispatcher.java | 62 ++++++ .../token/ParsedAccessTokenTest.java | 145 ++++++++++++++ .../token/ParsedIdTokenTest.java | 25 +++ .../authentication/token/ParsedTokenTest.java | 77 ++++++++ .../token/TestTokenProducer.java | 79 ++++++++ .../src/test/resources/invalid.json | 1 + .../src/test/resources/token/KeyPair.keystore | Bin 0 -> 2451 bytes .../test/resources/token/other-public-key.pub | 9 + .../test/resources/token/some-id-token.json | 5 + .../src/test/resources/token/some-name.json | 4 + .../src/test/resources/token/some-roles.json | 7 + .../src/test/resources/token/some-scopes.json | 7 + .../resources/token/test-private-key.pkcs8 | 28 +++ .../test/resources/token/test-public-key.jwks | 5 + .../test/resources/token/test-public-key.pub | 9 + .../src/test/resources/well_known.json | 1 + .../BaseAllAcceptDispatcherTest.java | 16 +- 28 files changed, 1090 insertions(+), 51 deletions(-) create mode 100644 modules/authentication/portal-authentication-token/README.adoc create mode 100644 modules/authentication/portal-authentication-token/pom.xml create mode 100644 modules/authentication/portal-authentication-token/src/main/java/de/cuioss/portal/authentication/token/JwksAwareTokenParser.java create mode 100644 modules/authentication/portal-authentication-token/src/main/java/de/cuioss/portal/authentication/token/LogMessages.java create mode 100644 modules/authentication/portal-authentication-token/src/main/java/de/cuioss/portal/authentication/token/ParsedAccessToken.java create mode 100644 modules/authentication/portal-authentication-token/src/main/java/de/cuioss/portal/authentication/token/ParsedIdToken.java create mode 100644 modules/authentication/portal-authentication-token/src/main/java/de/cuioss/portal/authentication/token/ParsedToken.java create mode 100644 modules/authentication/portal-authentication-token/src/test/java/de/cuioss/portal/authentication/token/JwksAwareTokenParserTest.java create mode 100644 modules/authentication/portal-authentication-token/src/test/java/de/cuioss/portal/authentication/token/JwksResolveDispatcher.java create mode 100644 modules/authentication/portal-authentication-token/src/test/java/de/cuioss/portal/authentication/token/ParsedAccessTokenTest.java create mode 100644 modules/authentication/portal-authentication-token/src/test/java/de/cuioss/portal/authentication/token/ParsedIdTokenTest.java create mode 100644 modules/authentication/portal-authentication-token/src/test/java/de/cuioss/portal/authentication/token/ParsedTokenTest.java create mode 100644 modules/authentication/portal-authentication-token/src/test/java/de/cuioss/portal/authentication/token/TestTokenProducer.java create mode 100644 modules/authentication/portal-authentication-token/src/test/resources/invalid.json create mode 100644 modules/authentication/portal-authentication-token/src/test/resources/token/KeyPair.keystore create mode 100644 modules/authentication/portal-authentication-token/src/test/resources/token/other-public-key.pub create mode 100644 modules/authentication/portal-authentication-token/src/test/resources/token/some-id-token.json create mode 100644 modules/authentication/portal-authentication-token/src/test/resources/token/some-name.json create mode 100644 modules/authentication/portal-authentication-token/src/test/resources/token/some-roles.json create mode 100644 modules/authentication/portal-authentication-token/src/test/resources/token/some-scopes.json create mode 100644 modules/authentication/portal-authentication-token/src/test/resources/token/test-private-key.pkcs8 create mode 100644 modules/authentication/portal-authentication-token/src/test/resources/token/test-public-key.jwks create mode 100644 modules/authentication/portal-authentication-token/src/test/resources/token/test-public-key.pub create mode 100644 modules/authentication/portal-authentication-token/src/test/resources/well_known.json diff --git a/bom/pom.xml b/bom/pom.xml index eebcfaf..649a1fe 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -1,4 +1,5 @@ - + 4.0.0 de.cuioss.portal @@ -36,6 +37,12 @@ ${project.version} test + + de.cuioss.portal.authentication + portal-authentication-token + ${project.version} + compile + de.cuioss.portal.core diff --git a/modules/authentication/pom.xml b/modules/authentication/pom.xml index 5e76107..888837a 100644 --- a/modules/authentication/pom.xml +++ b/modules/authentication/pom.xml @@ -15,6 +15,7 @@ portal-authentication-mock portal-authentication-oauth portal-authentication-dummy + portal-authentication-token diff --git a/modules/authentication/portal-authentication-oauth/src/main/java/de/cuioss/portal/authentication/oauth/impl/Oauth2AuthenticationFacadeImpl.java b/modules/authentication/portal-authentication-oauth/src/main/java/de/cuioss/portal/authentication/oauth/impl/Oauth2AuthenticationFacadeImpl.java index fe50100..639eb5a 100644 --- a/modules/authentication/portal-authentication-oauth/src/main/java/de/cuioss/portal/authentication/oauth/impl/Oauth2AuthenticationFacadeImpl.java +++ b/modules/authentication/portal-authentication-oauth/src/main/java/de/cuioss/portal/authentication/oauth/impl/Oauth2AuthenticationFacadeImpl.java @@ -15,51 +15,37 @@ */ package de.cuioss.portal.authentication.oauth.impl; -import static de.cuioss.tools.string.MoreStrings.emptyToNull; -import static java.net.URLEncoder.encode; -import static java.util.Objects.requireNonNull; - -import java.io.IOException; -import java.io.Serial; -import java.io.Serializable; -import java.math.BigInteger; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; -import java.util.Base64; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; - -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.inject.Inject; -import jakarta.inject.Provider; -import jakarta.servlet.http.HttpServletRequest; - import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; - import de.cuioss.portal.authentication.AuthenticatedUserInfo; import de.cuioss.portal.authentication.facade.AuthenticationSource; import de.cuioss.portal.authentication.facade.BaseAuthenticationFacade; import de.cuioss.portal.authentication.facade.PortalAuthenticationFacade; import de.cuioss.portal.authentication.model.BaseAuthenticatedUserInfo; -import de.cuioss.portal.authentication.oauth.LoginPagePath; -import de.cuioss.portal.authentication.oauth.Oauth2AuthenticationFacade; -import de.cuioss.portal.authentication.oauth.Oauth2Configuration; -import de.cuioss.portal.authentication.oauth.Oauth2Service; -import de.cuioss.portal.authentication.oauth.OauthAuthenticationException; -import de.cuioss.portal.authentication.oauth.OauthRedirector; -import de.cuioss.portal.authentication.oauth.OidcRpInitiatedLogoutParams; -import de.cuioss.portal.authentication.oauth.Token; +import de.cuioss.portal.authentication.oauth.*; import de.cuioss.tools.collect.CollectionBuilder; import de.cuioss.tools.logging.CuiLogger; import de.cuioss.tools.net.UrlParameter; import de.cuioss.tools.string.MoreStrings; import de.cuioss.tools.string.Splitter; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.inject.Provider; +import jakarta.servlet.http.HttpServletRequest; + +import java.io.IOException; +import java.io.Serial; +import java.io.Serializable; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.*; + +import static de.cuioss.tools.string.MoreStrings.emptyToNull; +import static java.net.URLEncoder.encode; +import static java.util.Objects.requireNonNull; /** * Default implementation of {@link Oauth2AuthenticationFacade}. Uses @@ -167,10 +153,10 @@ private void sendRedirect(final String scopes, final String idToken) { } private Optional triggerAuthenticate(final List parameters, - final String scopes) { - final var code = parameters.stream().filter(parameter -> "code".equals(parameter.getName())).findAny(); - final var state = parameters.stream().filter(parameter -> "state".equals(parameter.getName())).findAny(); - final var error = parameters.stream().filter(parameter -> "error".equals(parameter.getName())).findAny(); + final String scopes) { + final var code = parameters.stream().filter(parameter -> "code" .equals(parameter.getName())).findAny(); + final var state = parameters.stream().filter(parameter -> "state" .equals(parameter.getName())).findAny(); + final var error = parameters.stream().filter(parameter -> "error" .equals(parameter.getName())).findAny(); if (state.isPresent()) { if (code.isPresent()) { return handleTriggerAuthenticate(scopes, code.get(), state.get()); @@ -193,7 +179,7 @@ private Optional triggerAuthenticate(final List handleTriggerAuthenticate(final String scopes, final UrlParameter code, - final UrlParameter state) { + final UrlParameter state) { final var servletRequest = servletRequestProvider.get(); LOGGER.debug("code and state parameter are present"); final AuthenticatedUserInfo sessionUser; diff --git a/modules/authentication/portal-authentication-token/README.adoc b/modules/authentication/portal-authentication-token/README.adoc new file mode 100644 index 0000000..c5b4afd --- /dev/null +++ b/modules/authentication/portal-authentication-token/README.adoc @@ -0,0 +1,28 @@ += portal-authentication-token + +== What is it? +Provides some convenience structures for dealing with JTW-Token. + +It is essentially a wrapper around the types from io.smallrye:smallrye-jwt. + +The core functionality is the simplified configuration of checking the signature of a given token, by looking up the corresponding public-keys from an oauth-server, tested with keycloak. + +== Maven Coordinates + +[source, xml] +---- + + de.cuioss.portal.authentication + portal-authentication-token + +---- + +== Usage + +The central objects are: + +* link:src/main/java/de/cuioss/portal/authentication/token/JwksAwareTokenParser.java[Configuration of the io.smallrye.jwt.auth.principal.JWTParser] + +* link:src/main/java/de/cuioss/portal/authentication/token/ParsedAccessToken.java[ParsedAccessToken] + +* link:src/main/java/de/cuioss/portal/authentication/token/ParsedIdToken.java[ParsedIdToken] \ No newline at end of file diff --git a/modules/authentication/portal-authentication-token/pom.xml b/modules/authentication/portal-authentication-token/pom.xml new file mode 100644 index 0000000..31f5b46 --- /dev/null +++ b/modules/authentication/portal-authentication-token/pom.xml @@ -0,0 +1,68 @@ + + + 4.0.0 + + de.cuioss.portal.authentication + authentication + 1.1.0-SNAPSHOT + + portal.authentication.token + + 4.5.2 + 1.1.6 + + + + + io.smallrye + smallrye-jwt + ${version.smallrye-jwt} + + + + io.smallrye + smallrye-jwt-build + ${version.smallrye-jwt} + test + + + + org.eclipse.parsson + parsson + ${version.parsson} + test + + + + + + jakarta.json + jakarta.json-api + + + io.smallrye + smallrye-jwt + + + + io.smallrye + smallrye-jwt-build + + + de.cuioss.portal.test + portal-core-unit-testing + + + com.squareup.okhttp3 + mockwebserver3-junit5 + + + + org.eclipse.parsson + parsson + test + + + \ No newline at end of file diff --git a/modules/authentication/portal-authentication-token/src/main/java/de/cuioss/portal/authentication/token/JwksAwareTokenParser.java b/modules/authentication/portal-authentication-token/src/main/java/de/cuioss/portal/authentication/token/JwksAwareTokenParser.java new file mode 100644 index 0000000..37ec8fa --- /dev/null +++ b/modules/authentication/portal-authentication-token/src/main/java/de/cuioss/portal/authentication/token/JwksAwareTokenParser.java @@ -0,0 +1,43 @@ +package de.cuioss.portal.authentication.token; + +import de.cuioss.tools.logging.CuiLogger; +import io.smallrye.jwt.auth.principal.DefaultJWTParser; +import io.smallrye.jwt.auth.principal.JWTAuthContextInfo; +import io.smallrye.jwt.auth.principal.JWTParser; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NonNull; +import lombok.ToString; +import lombok.experimental.Delegate; + +/** + * Variant of {@link JWTParser} that will be configured for remote loading of the public-keys. + * They are needed to verify the signature or the token. + * + * @author Oliver Wolff + */ +@ToString +@EqualsAndHashCode +public class JwksAwareTokenParser implements JWTParser { + + private static final CuiLogger LOGGER = new CuiLogger(JwksAwareTokenParser.class); + + @Delegate + private final JWTParser tokenParser; + + @Getter + private final String jwksIssuer; + + public JwksAwareTokenParser(@NonNull String jwksEndpoint, @NonNull Integer jwksRefreshIntervall, @NonNull String jwksIssuer) { + this.jwksIssuer = jwksIssuer; + LOGGER.info(LogMessages.CONFIGURED_JWKS.format(jwksEndpoint, jwksRefreshIntervall, jwksIssuer)); + JWTAuthContextInfo contextInfo = new JWTAuthContextInfo(); + contextInfo.setPublicKeyLocation(jwksEndpoint); + contextInfo.setJwksRefreshInterval(jwksRefreshIntervall); + contextInfo.setIssuedBy(jwksIssuer); + LOGGER.debug("Successfully configured JWTAuthContextInfo: %s", contextInfo); + tokenParser = new DefaultJWTParser(contextInfo); + } + + +} diff --git a/modules/authentication/portal-authentication-token/src/main/java/de/cuioss/portal/authentication/token/LogMessages.java b/modules/authentication/portal-authentication-token/src/main/java/de/cuioss/portal/authentication/token/LogMessages.java new file mode 100644 index 0000000..dce476e --- /dev/null +++ b/modules/authentication/portal-authentication-token/src/main/java/de/cuioss/portal/authentication/token/LogMessages.java @@ -0,0 +1,19 @@ +package de.cuioss.portal.authentication.token; + +import de.cuioss.tools.logging.LogRecord; +import de.cuioss.tools.logging.LogRecordModel; +import lombok.experimental.UtilityClass; + +@UtilityClass +class LogMessages { + + static final String PREFIX = "Portal"; + + // Info-Level + static final LogRecord CONFIGURED_JWKS = LogRecordModel.builder().prefix(PREFIX).identifier(120).template("Initializing JWKS lookup, jwks-endpoint='%s', refresh-interval='%s', issuer = '%s'").build(); + + // WARN-LEVEL + static final LogRecord TOKEN_IS_EMPTY = LogRecordModel.builder().prefix(PREFIX).identifier(120).template("The given token was empty").build(); + static final LogRecord COULD_NOT_PARSE_TOKEN = LogRecordModel.builder().prefix(PREFIX).identifier(121).template("Unable to parse token due to ParseException").build(); + static final LogRecord COULD_NOT_PARSE_TOKEN_TRACE = LogRecordModel.builder().prefix(PREFIX).identifier(121).template("Offending token '{}'").build(); +} diff --git a/modules/authentication/portal-authentication-token/src/main/java/de/cuioss/portal/authentication/token/ParsedAccessToken.java b/modules/authentication/portal-authentication-token/src/main/java/de/cuioss/portal/authentication/token/ParsedAccessToken.java new file mode 100644 index 0000000..5b7ce24 --- /dev/null +++ b/modules/authentication/portal-authentication-token/src/main/java/de/cuioss/portal/authentication/token/ParsedAccessToken.java @@ -0,0 +1,182 @@ +package de.cuioss.portal.authentication.token; + +import de.cuioss.tools.logging.CuiLogger; +import io.smallrye.jwt.auth.principal.JWTParser; +import jakarta.json.JsonArray; +import jakarta.json.JsonString; +import lombok.EqualsAndHashCode; +import lombok.NonNull; +import lombok.ToString; +import org.eclipse.microprofile.jwt.Claims; +import org.eclipse.microprofile.jwt.JsonWebToken; + +import java.util.*; + +import static java.util.stream.Collectors.toSet; + +/** + * Represents an Access Token with corresponding information. In essence, it is a convenience type + * for accessing concrete instances of {@link JsonWebToken} + * + * @author Oliver Wolff + */ +@ToString +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = true) +public class ParsedAccessToken extends ParsedToken { + + private static final CuiLogger LOGGER = new CuiLogger(ParsedAccessToken.class); + + private static final String CLAIM_NAME_SCOPE = "scp"; + private static final String CLAIM_NAME_NAME = "name"; + private static final String CLAIM_NAME_ROLES = "roles"; + + /** + * @param tokenString to be parsed + * @param tokenParser the actual parser to be used + * @return an {@link ParsedAccessToken} if given Token can be parsed correctly, + * otherwise {@link ParsedAccessToken#EMPTY_WEB_TOKEN} + */ + public static ParsedAccessToken fromTokenString(String tokenString, @NonNull JWTParser tokenParser) { + return fromTokenString(tokenString, null, tokenParser); + } + + /** + * @param tokenString to be parsed + * @param email email address or null + * @param tokenParser the actual parser to be used + * @return an {@link ParsedAccessToken} if given Token can be parsed correctly, + * {@code Optional#empty()} otherwise. + */ + public static ParsedAccessToken fromTokenString(String tokenString, String email, JWTParser tokenParser) { + return new ParsedAccessToken(jsonWebTokenFrom(tokenString, tokenParser, LOGGER), email); + } + + private ParsedAccessToken(JsonWebToken jsonWebToken, String email) { + super(jsonWebToken); + this.email = email; + } + + @EqualsAndHashCode.Include + private final String email; + + /** + * @return a {@link Set} representing all scopes. If none can be found, it returns an empty set + */ + public Set getScopes() { + if (!jsonWebToken.containsClaim(CLAIM_NAME_SCOPE)) { + LOGGER.debug("No Scopes available"); + return Set.of(); + } + + Set result = jsonWebToken.getClaim(CLAIM_NAME_SCOPE) + .getValuesAs(JsonString.class) + .stream() + .map(JsonString::getString).collect(toSet()); + + LOGGER.debug("Extracted scopes: '%s'", result); + return new TreeSet<>(result); + } + + /** + * @param expectedScopes to be checked + * @return boolean indicating whether the token provides all given Scopes + */ + public boolean providesScopes(Collection expectedScopes) { + return getScopes().containsAll(expectedScopes); + } + + /** + * @param expectedScopes to be checked + * @param logContext Usually + * @return boolean indicating whether the token provides all given Scopes. In contrast to + * {@link #providesScopes(Collection)} it log on debug the corresponding scopes + */ + public boolean providesScopesAndDebugIfScopesAreMissing(Collection expectedScopes, String logContext, + CuiLogger logger) { + Set delta = determineMissingScopes(expectedScopes); + if (delta.isEmpty()) { + logger.trace("All expected scopes are present: {}, {}", expectedScopes, logContext); + return true; + } + logger.debug( + "Current Token does not provide all needed scopes:\nMissing in token='{}',\nExpected='{}'\nPresent in Token='{}', {}", + delta, expectedScopes, getScopes(), logContext); + return false; + } + + /** + * @param expectedScopes to be checked + * @return an empty-Set in case the token provides all expectedScopes, otherwise a + * {@link TreeSet} containing all missing scopes. + */ + public Set determineMissingScopes(Collection expectedScopes) { + if (providesScopes(expectedScopes)) { + return Collections.emptySet(); + } + Set scopeDelta = new TreeSet<>(expectedScopes); + scopeDelta.removeAll(getScopes()); + return scopeDelta; + } + + /** + * @return the roles defined in the 'roles' claim of the token + */ + public Set getRoles() { + if (!jsonWebToken.containsClaim(CLAIM_NAME_ROLES)) { + return Set.of(); + } + + return jsonWebToken.getClaim(CLAIM_NAME_ROLES) + .getValuesAs(JsonString.class) + .stream() + .map(JsonString::getString).collect(toSet()); + } + + /** + * Checks if the expected role is present within the 'roles' claim of the token. + * + * @param expectedRole the expected role + * @return if the role is present + */ + public boolean hasRole(String expectedRole) { + return getRoles().contains(expectedRole); + } + + /** + * @return the subject id from the underlying token + */ + public String getSubjectId() { + return jsonWebToken.getSubject(); + } + + /** + * Resolves the email address. Either given or extracted from the token. + * + * @return an optional containing the potential email + */ + public Optional getEmail() { + return Optional + .ofNullable(email) + .or(() -> Optional.ofNullable(jsonWebToken.getClaim(Claims.email))); + } + + /** + * Resolves the name from the token. + * + * @return an optional containing the potential name + */ + public Optional getName() { + return Optional.ofNullable(jsonWebToken.getClaim(CLAIM_NAME_NAME)); + } + + + /** + * Resolves the preferred username from the token. + * + * @return an optional containing the potential preferred username + */ + public Optional getPreferredUsername() { + return Optional.ofNullable(jsonWebToken.getClaim(Claims.preferred_username)); + } + +} diff --git a/modules/authentication/portal-authentication-token/src/main/java/de/cuioss/portal/authentication/token/ParsedIdToken.java b/modules/authentication/portal-authentication-token/src/main/java/de/cuioss/portal/authentication/token/ParsedIdToken.java new file mode 100644 index 0000000..5fc829f --- /dev/null +++ b/modules/authentication/portal-authentication-token/src/main/java/de/cuioss/portal/authentication/token/ParsedIdToken.java @@ -0,0 +1,43 @@ +package de.cuioss.portal.authentication.token; + +import de.cuioss.tools.logging.CuiLogger; +import io.smallrye.jwt.auth.principal.JWTParser; +import lombok.ToString; +import org.eclipse.microprofile.jwt.JsonWebToken; + +import java.util.Optional; + +/** + * Variant of {@link ParsedToken} representing an id-token + * + * @author Oliver Wolff + */ +@ToString +public class ParsedIdToken extends ParsedToken { + + private static final CuiLogger LOGGER = new CuiLogger(ParsedIdToken.class); + + private ParsedIdToken(JsonWebToken jsonWebToken) { + super(jsonWebToken); + } + + /** + * @param tokenString to be passed + * @param tokenParser to be passed + * @return an {@link ParsedIdToken} if given Token can be parsed correctly, + * otherwise {@link ParsedAccessToken#EMPTY_WEB_TOKEN}} + */ + public static ParsedIdToken fromTokenString(String tokenString, JWTParser tokenParser) { + return new ParsedIdToken(jsonWebTokenFrom(tokenString, tokenParser, LOGGER)); + } + + /** + * Resolves the email from the token. Only available, if the current token is an ID token. + * + * @return email if present + */ + public Optional getEmail() { + return jsonWebToken.claim("email"); + } + +} diff --git a/modules/authentication/portal-authentication-token/src/main/java/de/cuioss/portal/authentication/token/ParsedToken.java b/modules/authentication/portal-authentication-token/src/main/java/de/cuioss/portal/authentication/token/ParsedToken.java new file mode 100644 index 0000000..fe9c0f5 --- /dev/null +++ b/modules/authentication/portal-authentication-token/src/main/java/de/cuioss/portal/authentication/token/ParsedToken.java @@ -0,0 +1,111 @@ +package de.cuioss.portal.authentication.token; + +import de.cuioss.tools.logging.CuiLogger; +import de.cuioss.tools.string.MoreStrings; +import io.smallrye.jwt.auth.principal.JWTParser; +import io.smallrye.jwt.auth.principal.ParseException; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.eclipse.microprofile.jwt.JsonWebToken; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.util.Set; + +import static de.cuioss.tools.string.MoreStrings.trimOrNull; + +/** + * Wrapper around {@link JsonWebToken} + * + * @author Oliver Wolff + * + */ +@RequiredArgsConstructor +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +public abstract class ParsedToken { + + protected static final String EMPTY_NAME = "EMPTY"; + + /** null token. */ + public static final JsonWebToken EMPTY_WEB_TOKEN = new JsonWebToken() { + + @Override + public String getName() { + return EMPTY_NAME; + } + + @Override + public Set getClaimNames() { + return Set.of(); + } + + @Override + public T getClaim(String claimName) { + return null; + } + }; + + /** + * @return true, if the token could not be parsed. + */ + public boolean isEmpty() { + return EMPTY_NAME.equals(jsonWebToken.getName()); + } + + /** + * @return the token as encoded String. + */ + public String getTokenString() { + return jsonWebToken.getRawToken(); + } + + protected static JsonWebToken jsonWebTokenFrom(String tokenString, JWTParser tokenParser, CuiLogger logger) { + logger.trace("Parsing token '%s'", tokenString); + if (MoreStrings.isEmpty(trimOrNull(tokenString))) { + logger.warn(LogMessages.TOKEN_IS_EMPTY.format()); + return EMPTY_WEB_TOKEN; + } + try { + return tokenParser.parse(tokenString); + } catch (ParseException e) { + logger.warn(e, LogMessages.COULD_NOT_PARSE_TOKEN.format()); + logger.trace(() -> LogMessages.COULD_NOT_PARSE_TOKEN_TRACE.format(tokenString)); + return EMPTY_WEB_TOKEN; + } + } + + @Getter + @EqualsAndHashCode.Include + protected final JsonWebToken jsonWebToken; + + /** + * @return boolean indicating whether the token is already expired. Shorthand for + * {@link #willExpireInSeconds(int)} + * with '0'. + */ + public boolean isExpired() { + return willExpireInSeconds(0); + } + + public boolean isValid() { + return !(isEmpty() || isExpired()); + } + + /** + * @param seconds maybe {@code 0}. Calling it with a negative number is not defined. + * @return boolean indicating whether the token will expired within the given number of seconds. + */ + public boolean willExpireInSeconds(int seconds) { + return OffsetDateTime.now().plusSeconds(seconds).isAfter(getExpirationTime()); + } + + /** + * @return {@link OffsetDateTime} representation of the expiration-Time + */ + public OffsetDateTime getExpirationTime() { + return OffsetDateTime + .ofInstant(Instant.ofEpochSecond(jsonWebToken.getExpirationTime()), ZoneId.systemDefault()); + } +} diff --git a/modules/authentication/portal-authentication-token/src/test/java/de/cuioss/portal/authentication/token/JwksAwareTokenParserTest.java b/modules/authentication/portal-authentication-token/src/test/java/de/cuioss/portal/authentication/token/JwksAwareTokenParserTest.java new file mode 100644 index 0000000..be95cd8 --- /dev/null +++ b/modules/authentication/portal-authentication-token/src/test/java/de/cuioss/portal/authentication/token/JwksAwareTokenParserTest.java @@ -0,0 +1,95 @@ +package de.cuioss.portal.authentication.token; + +import de.cuioss.portal.core.test.junit5.mockwebserver.EnableMockWebServer; +import de.cuioss.portal.core.test.junit5.mockwebserver.MockWebServerHolder; +import de.cuioss.portal.core.test.junit5.mockwebserver.dispatcher.CombinedDispatcher; +import de.cuioss.tools.logging.CuiLogger; +import lombok.Getter; +import lombok.Setter; +import mockwebserver3.MockWebServer; +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static de.cuioss.portal.authentication.token.TestTokenProducer.SOME_SCOPES; +import static de.cuioss.portal.authentication.token.TestTokenProducer.validSignedJWTWithClaims; +import static org.junit.jupiter.api.Assertions.*; + +@EnableMockWebServer +class JwksAwareTokenParserTest implements MockWebServerHolder { + + public static final int JWKS_REFRESH_INTERVALL = 60; + private static final CuiLogger LOGGER = new CuiLogger(JwksAwareTokenParserTest.class); + + @Setter + private MockWebServer mockWebServer; + + private JwksAwareTokenParser tokenParser; + + protected int mockserverPort; + + private final JwksResolveDispatcher jwksResolveDispatcher = new JwksResolveDispatcher(); + + @Getter + private CombinedDispatcher dispatcher = new CombinedDispatcher().addDispatcher(jwksResolveDispatcher); + private String jwksEndpoint; + + @BeforeEach + void setupMockServer() { + mockserverPort = mockWebServer.getPort(); + jwksEndpoint = "http://localhost:" + mockserverPort + jwksResolveDispatcher.getBaseUrl(); + tokenParser = new JwksAwareTokenParser(jwksEndpoint, JWKS_REFRESH_INTERVALL, TestTokenProducer.ISSUER); + jwksResolveDispatcher.setCallCounter(0); + } + + + @Test + void shouldResolveFromRemote() { + String initialToken = validSignedJWTWithClaims(SOME_SCOPES); + + JsonWebToken jsonWebToken = assertDoesNotThrow(() -> ParsedToken.jsonWebTokenFrom(initialToken, tokenParser, LOGGER)); + + assertValidJsonWebToken(jsonWebToken, initialToken); + } + + @Test + void shouldFailFromRemoteWithInvalidIssuer() { + tokenParser = new JwksAwareTokenParser(jwksEndpoint, JWKS_REFRESH_INTERVALL, "Wrong Issuer"); + String initialToken = validSignedJWTWithClaims(SOME_SCOPES); + JsonWebToken jsonWebToken = assertDoesNotThrow(() -> ParsedToken.jsonWebTokenFrom(initialToken, tokenParser, LOGGER)); + assertEquals(ParsedToken.EMPTY_WEB_TOKEN, jsonWebToken); + } + + @Test + void shouldFailFromRemoteWithInvalidJWKS() { + jwksResolveDispatcher.switchToOtherPublicKey(); + String initialToken = validSignedJWTWithClaims(SOME_SCOPES); + JsonWebToken jsonWebToken = assertDoesNotThrow(() -> ParsedToken.jsonWebTokenFrom(initialToken, tokenParser, LOGGER)); + assertEquals(ParsedToken.EMPTY_WEB_TOKEN, jsonWebToken); + } + + @Test + void shouldCacheMultipleCalls() { + jwksResolveDispatcher.assertCallsAnswered(0); + String initialToken = validSignedJWTWithClaims(SOME_SCOPES); + for (int i = 0; i < 100; i++) { + JsonWebToken jsonWebToken = ParsedToken.jsonWebTokenFrom(initialToken, tokenParser, LOGGER); + assertValidJsonWebToken(jsonWebToken, initialToken); + } + // For some reason, there are always at least 2 calls, instead of expected one call. No + // problem because as shown within this test, the number stays at 2 + assertTrue(jwksResolveDispatcher.getCallCounter() < 3); + + for (int i = 0; i < 100; i++) { + JsonWebToken jsonWebToken = assertDoesNotThrow(() -> ParsedToken.jsonWebTokenFrom(initialToken, tokenParser, LOGGER)); + assertValidJsonWebToken(jsonWebToken, initialToken); + } + assertTrue(jwksResolveDispatcher.getCallCounter() < 3); + } + + private void assertValidJsonWebToken(JsonWebToken jsonWebToken, String initialTokenString) { + assertNotEquals(ParsedToken.EMPTY_WEB_TOKEN, jsonWebToken); + assertEquals(initialTokenString, jsonWebToken.getRawToken()); + } + +} diff --git a/modules/authentication/portal-authentication-token/src/test/java/de/cuioss/portal/authentication/token/JwksResolveDispatcher.java b/modules/authentication/portal-authentication-token/src/test/java/de/cuioss/portal/authentication/token/JwksResolveDispatcher.java new file mode 100644 index 0000000..ef77e2b --- /dev/null +++ b/modules/authentication/portal-authentication-token/src/test/java/de/cuioss/portal/authentication/token/JwksResolveDispatcher.java @@ -0,0 +1,62 @@ +package de.cuioss.portal.authentication.token; + +import de.cuioss.portal.core.test.junit5.mockwebserver.dispatcher.ModuleDispatcherElement; +import de.cuioss.tools.io.FileLoaderUtility; +import lombok.Getter; +import lombok.NonNull; +import lombok.Setter; +import mockwebserver3.MockResponse; +import mockwebserver3.RecordedRequest; +import okhttp3.Headers; + +import java.util.Optional; + +import static jakarta.servlet.http.HttpServletResponse.SC_OK; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Handles the Resolving of JWKS Files from the Mocked oauth-Server. In essence, it returns the file + * "src/test/resources/token/test-public-key.jwks" + */ +public class JwksResolveDispatcher implements ModuleDispatcherElement { + + /** "/oidc/jwks.json" */ + public static final String LOCAL_PATH = "/oidc/jwks.json"; + public static final String PUBLIC_KEY_JWKS = TestTokenProducer.BASE_PATH + "test-public-key.jwks"; + public static final String PUBLIC_KEY_OTHER = TestTokenProducer.BASE_PATH + "other-public-key.pub"; + + public String currentKey; + + public JwksResolveDispatcher() { + currentKey = PUBLIC_KEY_JWKS; + } + + @Getter + @Setter + private int callCounter = 0; + + @Override + public Optional handleGet(@NonNull RecordedRequest request) { + callCounter++; + return Optional.of(new MockResponse(SC_OK, Headers.of("Content-Type", "application/json"), FileLoaderUtility + .toStringUnchecked(FileLoaderUtility.getLoaderForPath(currentKey)))); + } + + void switchToOtherPublicKey(){ + currentKey = PUBLIC_KEY_OTHER; + } + + @Override + public String getBaseUrl() { + return LOCAL_PATH; + } + + /** + * Verifies whether this endpoint was called the given times + * + * @param expected count of calls + */ + public void assertCallsAnswered(int expected) { + assertEquals(expected, callCounter); + } +} diff --git a/modules/authentication/portal-authentication-token/src/test/java/de/cuioss/portal/authentication/token/ParsedAccessTokenTest.java b/modules/authentication/portal-authentication-token/src/test/java/de/cuioss/portal/authentication/token/ParsedAccessTokenTest.java new file mode 100644 index 0000000..db3a864 --- /dev/null +++ b/modules/authentication/portal-authentication-token/src/test/java/de/cuioss/portal/authentication/token/ParsedAccessTokenTest.java @@ -0,0 +1,145 @@ +package de.cuioss.portal.authentication.token; + +import de.cuioss.test.generator.Generators; +import de.cuioss.test.generator.domain.EmailGenerator; +import de.cuioss.test.juli.junit5.EnableTestLogger; +import de.cuioss.tools.logging.CuiLogger; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static de.cuioss.portal.authentication.token.TestTokenProducer.*; +import static org.junit.jupiter.api.Assertions.*; + + +@EnableTestLogger +class ParsedAccessTokenTest { + + private static final String TEST_CONTEXT = "Test"; + private static final String EXISTING_SCOPE = "email"; + private static final String DEFINITELY_NO_SCOPE = "Definitely No Scope"; + + private static final CuiLogger LOGGER = new CuiLogger(ParsedAccessTokenTest.class); + + @Test + void shouldParseValidToken() { + String initialToken = validSignedJWTWithClaims(SOME_SCOPES); + + ParsedAccessToken parsedAccessToken = ParsedAccessToken.fromTokenString(initialToken, DEFAULT_TOKEN_PARSER); + + assertTrue(parsedAccessToken.isValid()); + assertEquals(initialToken, parsedAccessToken.getTokenString()); + assertEquals(3, parsedAccessToken.getScopes().size()); + assertTrue(parsedAccessToken.getScopes().contains(EXISTING_SCOPE)); + assertFalse(parsedAccessToken.getScopes().contains(DEFINITELY_NO_SCOPE)); + + assertTrue(parsedAccessToken.providesScopes(Set.of(EXISTING_SCOPE))); + assertFalse(parsedAccessToken.providesScopes(Set.of(DEFINITELY_NO_SCOPE))); + assertFalse(parsedAccessToken.providesScopes(Set.of(DEFINITELY_NO_SCOPE, EXISTING_SCOPE))); + + assertTrue(parsedAccessToken.providesScopesAndDebugIfScopesAreMissing(Set.of(EXISTING_SCOPE), TEST_CONTEXT, LOGGER)); + assertFalse(parsedAccessToken.providesScopesAndDebugIfScopesAreMissing(Set.of(EXISTING_SCOPE, DEFINITELY_NO_SCOPE), TEST_CONTEXT, LOGGER)); + + Set missingScopes = parsedAccessToken.determineMissingScopes(Set.of(EXISTING_SCOPE)); + assertTrue(missingScopes.isEmpty()); + missingScopes = parsedAccessToken.determineMissingScopes(Set.of(DEFINITELY_NO_SCOPE)); + assertEquals(1, missingScopes.size()); + assertTrue(missingScopes.contains(DEFINITELY_NO_SCOPE)); + + missingScopes = parsedAccessToken.determineMissingScopes(Set.of(EXISTING_SCOPE, DEFINITELY_NO_SCOPE)); + assertEquals(1, missingScopes.size()); + assertTrue(missingScopes.contains(DEFINITELY_NO_SCOPE)); + + } + + @Test + void shouldHandleMissingScopes() { + String initialToken = validSignedEmptyJWT(); + ParsedAccessToken parsedAccessToken = ParsedAccessToken.fromTokenString(initialToken, DEFAULT_TOKEN_PARSER); + assertEquals(0, parsedAccessToken.getScopes().size()); + } + + + + @Test + void shouldHandleGivenRoles() { + String initialToken = validSignedJWTWithClaims(SOME_ROLES); + ParsedAccessToken parsedAccessToken = ParsedAccessToken.fromTokenString(initialToken, DEFAULT_TOKEN_PARSER); + assertTrue(parsedAccessToken.hasRole("reader")); + } + + @Test + void shouldHandleMissingRoles() { + String initialToken = validSignedJWTWithClaims(SOME_ROLES); + ParsedAccessToken parsedAccessToken = ParsedAccessToken.fromTokenString(initialToken, DEFAULT_TOKEN_PARSER); + assertFalse(parsedAccessToken.hasRole(DEFINITELY_NO_SCOPE)); + } + + @Test + void shouldHandleNoRoles() { + String initialToken = validSignedJWTWithClaims(SOME_SCOPES); + ParsedAccessToken parsedAccessToken = ParsedAccessToken.fromTokenString(initialToken, DEFAULT_TOKEN_PARSER); + assertTrue(parsedAccessToken.getRoles().isEmpty()); + } + + @Test + void shouldHandleSubjectId() { + String expectedSubjectId = Generators.letterStrings(4, 9).next(); + String initialToken = validSignedJWTWithClaims(SOME_SCOPES, expectedSubjectId); + + ParsedAccessToken parsedAccessToken = ParsedAccessToken.fromTokenString(initialToken, DEFAULT_TOKEN_PARSER); + assertEquals(expectedSubjectId, parsedAccessToken.getSubjectId()); + } + + @Test + void shouldHandleGivenEmail() { + String expectedEmail = new EmailGenerator().next(); + String initialToken = validSignedJWTWithClaims(SOME_SCOPES); + + ParsedAccessToken parsedAccessToken = ParsedAccessToken.fromTokenString(initialToken, expectedEmail, DEFAULT_TOKEN_PARSER); + + assertEquals(expectedEmail, parsedAccessToken.getEmail().get()); + } + + @Test + void shouldHandleMissingEmail() { + String initialToken = validSignedJWTWithClaims(SOME_SCOPES); + + ParsedAccessToken parsedAccessToken = ParsedAccessToken.fromTokenString(initialToken, DEFAULT_TOKEN_PARSER); + assertFalse(parsedAccessToken.getEmail().isPresent()); + } + + @Test + void shouldHandleGivenName() { + String initialToken = validSignedJWTWithClaims(SOME_NAME); + + ParsedAccessToken parsedAccessToken = ParsedAccessToken.fromTokenString(initialToken, DEFAULT_TOKEN_PARSER); + assertEquals("hello", parsedAccessToken.getName().get()); + } + + @Test + void shouldHandleMissingName() { + String initialToken = validSignedJWTWithClaims(SOME_SCOPES); + + ParsedAccessToken parsedAccessToken = ParsedAccessToken.fromTokenString(initialToken, DEFAULT_TOKEN_PARSER); + assertFalse(parsedAccessToken.getName().isPresent()); + } + + + @Test + void shouldHandlePreferredName() { + String initialToken = validSignedJWTWithClaims(SOME_NAME); + + ParsedAccessToken parsedAccessToken = ParsedAccessToken.fromTokenString(initialToken, DEFAULT_TOKEN_PARSER); + assertEquals("world", parsedAccessToken.getPreferredUsername().get()); + } + + @Test + void shouldHandleMissingPreferredName() { + String initialToken = validSignedJWTWithClaims(SOME_SCOPES); + + ParsedAccessToken parsedAccessToken = ParsedAccessToken.fromTokenString(initialToken, DEFAULT_TOKEN_PARSER); + assertFalse(parsedAccessToken.getPreferredUsername().isPresent()); + } + +} diff --git a/modules/authentication/portal-authentication-token/src/test/java/de/cuioss/portal/authentication/token/ParsedIdTokenTest.java b/modules/authentication/portal-authentication-token/src/test/java/de/cuioss/portal/authentication/token/ParsedIdTokenTest.java new file mode 100644 index 0000000..74ac154 --- /dev/null +++ b/modules/authentication/portal-authentication-token/src/test/java/de/cuioss/portal/authentication/token/ParsedIdTokenTest.java @@ -0,0 +1,25 @@ +package de.cuioss.portal.authentication.token; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ParsedIdTokenTest { + + @Test + void shouldHandleValidToken() { + String initialTokenString = TestTokenProducer.validSignedJWTWithClaims(TestTokenProducer.SOME_ID_TOKEN); + + ParsedIdToken parsedIdToken = ParsedIdToken.fromTokenString(initialTokenString, TestTokenProducer.DEFAULT_TOKEN_PARSER); + assertEquals(parsedIdToken.getTokenString(), initialTokenString); + } + + @Test + void shouldHandleEmail() { + String initialTokenString = TestTokenProducer.validSignedJWTWithClaims(TestTokenProducer.SOME_ID_TOKEN); + + ParsedIdToken parsedIdToken = ParsedIdToken.fromTokenString(initialTokenString, TestTokenProducer.DEFAULT_TOKEN_PARSER); + assertEquals("hello@world.com", parsedIdToken.getEmail().get()); + } + +} diff --git a/modules/authentication/portal-authentication-token/src/test/java/de/cuioss/portal/authentication/token/ParsedTokenTest.java b/modules/authentication/portal-authentication-token/src/test/java/de/cuioss/portal/authentication/token/ParsedTokenTest.java new file mode 100644 index 0000000..84331d9 --- /dev/null +++ b/modules/authentication/portal-authentication-token/src/test/java/de/cuioss/portal/authentication/token/ParsedTokenTest.java @@ -0,0 +1,77 @@ +package de.cuioss.portal.authentication.token; + +import de.cuioss.test.generator.Generators; +import de.cuioss.test.juli.LogAsserts; +import de.cuioss.test.juli.TestLogLevel; +import de.cuioss.test.juli.junit5.EnableTestLogger; +import de.cuioss.tools.logging.CuiLogger; +import io.smallrye.jwt.auth.principal.DefaultJWTParser; +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import static de.cuioss.portal.authentication.token.TestTokenProducer.*; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@EnableTestLogger +class ParsedTokenTest { + + private static final CuiLogger LOGGER = new CuiLogger(ParsedTokenTest.class); + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = " ") + void shouldProvideEmptyFallbackOnEmptyInput(String initialTokenString) { + JsonWebToken jsonWebToken = ParsedToken.jsonWebTokenFrom(initialTokenString, TestTokenProducer.DEFAULT_TOKEN_PARSER, LOGGER); + + Assertions.assertEquals(ParsedToken.EMPTY_WEB_TOKEN, jsonWebToken); + LogAsserts.assertSingleLogMessagePresentContaining(TestLogLevel.WARN, LogMessages.TOKEN_IS_EMPTY.resolveIdentifierString()); + } + + @Test + void shouldProvideEmptyFallbackOnParseError() { + String initialTokenString = Generators.letterStrings(10, 20).next(); + + JsonWebToken jsonWebToken = ParsedToken.jsonWebTokenFrom(initialTokenString, TestTokenProducer.DEFAULT_TOKEN_PARSER, LOGGER); + + Assertions.assertEquals(ParsedToken.EMPTY_WEB_TOKEN, jsonWebToken); + LogAsserts.assertSingleLogMessagePresentContaining(TestLogLevel.WARN, LogMessages.COULD_NOT_PARSE_TOKEN.resolveIdentifierString()); + } + + @Test + void shouldProvideEmptyFallbackOnInvalidIssuer() { + String initialTokenString = TestTokenProducer.validSignedJWTWithClaims(TestTokenProducer.SOME_SCOPES); + + JsonWebToken jsonWebToken = ParsedToken + .jsonWebTokenFrom(initialTokenString, new DefaultJWTParser(TestTokenProducer.TEST_AUTH_CONTEXT_INFO_WRONG_ISSUER), LOGGER); + + Assertions.assertEquals(ParsedToken.EMPTY_WEB_TOKEN, jsonWebToken); + LogAsserts.assertSingleLogMessagePresentContaining(TestLogLevel.WARN, LogMessages.COULD_NOT_PARSE_TOKEN.resolveIdentifierString()); + } + + @Test + void shouldProvideEmptyFallbackOnWrongPublicKey() { + String initialTokenString = TestTokenProducer.validSignedJWTWithClaims(TestTokenProducer.SOME_SCOPES); + + JsonWebToken jsonWebToken = ParsedToken + .jsonWebTokenFrom(initialTokenString, new DefaultJWTParser(TestTokenProducer.TEST_AUTH_CONTEXT_INFO_WRONG_PUBLIC_KEY), + LOGGER); + Assertions.assertEquals(ParsedToken.EMPTY_WEB_TOKEN, jsonWebToken); + LogAsserts.assertSingleLogMessagePresentContaining(TestLogLevel.WARN, LogMessages.COULD_NOT_PARSE_TOKEN.resolveIdentifierString()); + } + + @Test + void shouldHandleNotExpiredToken() { + String initialToken = validSignedJWTWithClaims(SOME_SCOPES); + + var token = ParsedAccessToken.fromTokenString(initialToken, DEFAULT_TOKEN_PARSER); + + assertFalse(token.isExpired()); + assertFalse(token.willExpireInSeconds(5)); + assertTrue(token.willExpireInSeconds(500)); + } +} diff --git a/modules/authentication/portal-authentication-token/src/test/java/de/cuioss/portal/authentication/token/TestTokenProducer.java b/modules/authentication/portal-authentication-token/src/test/java/de/cuioss/portal/authentication/token/TestTokenProducer.java new file mode 100644 index 0000000..01887e8 --- /dev/null +++ b/modules/authentication/portal-authentication-token/src/test/java/de/cuioss/portal/authentication/token/TestTokenProducer.java @@ -0,0 +1,79 @@ +package de.cuioss.portal.authentication.token; + +import de.cuioss.test.generator.Generators; +import io.smallrye.jwt.auth.principal.DefaultJWTParser; +import io.smallrye.jwt.auth.principal.JWTAuthContextInfo; +import io.smallrye.jwt.auth.principal.JWTParser; +import io.smallrye.jwt.auth.principal.ParseException; +import io.smallrye.jwt.build.Jwt; +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneId; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +public class TestTokenProducer { + + public static final String ISSUER = "Token-Test-testIssuer"; + + public static final String BASE_PATH = "src/test/resources/token/"; + + public static final String PRIVATE_KEY = BASE_PATH + "test-private-key.pkcs8"; + + public static final String PUBLIC_KEY = BASE_PATH + "test-public-key.pub"; + + public static final String PUBLIC_KEY_OTHER = BASE_PATH + "other-public-key.pub"; + + public static final String SOME_SCOPES = BASE_PATH + "some-scopes.json"; + + public static final String SOME_ROLES = BASE_PATH + "some-roles.json"; + + public static final String SOME_NAME = BASE_PATH + "some-name.json"; + + public static final String SOME_ID_TOKEN = BASE_PATH + "some-id-token.json"; + + + public static final JWTAuthContextInfo TEST_AUTH_CONTEXT_INFO = new JWTAuthContextInfo(PUBLIC_KEY, ISSUER); + + public static final JWTAuthContextInfo TEST_AUTH_CONTEXT_INFO_WRONG_PUBLIC_KEY = + new JWTAuthContextInfo(PUBLIC_KEY_OTHER, ISSUER); + + public static final JWTAuthContextInfo TEST_AUTH_CONTEXT_INFO_WRONG_ISSUER = + new JWTAuthContextInfo(PUBLIC_KEY, new StringBuilder(ISSUER).reverse().toString()); + + public static final JWTParser DEFAULT_TOKEN_PARSER = new DefaultJWTParser(TEST_AUTH_CONTEXT_INFO); + + public static final String SUBJECT = Generators.letterStrings(10, 12).next(); + + public static String validSignedJWTWithClaims(String claims) { + return Jwt.claims(claims).issuer(ISSUER).subject(SUBJECT).sign(PRIVATE_KEY); + } + + public static String validSignedEmptyJWT() { + return Jwt.claims().issuer(ISSUER).subject(SUBJECT).sign(PRIVATE_KEY); + } + + public static String validSignedJWTWithClaims(String claims, String subject) { + return Jwt.claims(claims).issuer(ISSUER).subject(subject).sign(PRIVATE_KEY); + } + + public static String validSignedJWTExpireAt(Instant expireAt) { + return Jwt.claims(SOME_SCOPES).issuer(ISSUER) + .issuedAt(OffsetDateTime.ofInstant(expireAt, ZoneId.systemDefault()).minusMinutes(5).toInstant()) + .subject(SUBJECT).expiresAt(expireAt).sign(PRIVATE_KEY); + } + + @Test + void shouldCreateScopesAndClaims() throws ParseException { + String token = validSignedJWTWithClaims(SOME_SCOPES); + assertNotNull(token); + + DefaultJWTParser parser = new DefaultJWTParser(TEST_AUTH_CONTEXT_INFO); + + JsonWebToken parsedToken = parser.parse(token); + assertNotNull(parsedToken); + } +} diff --git a/modules/authentication/portal-authentication-token/src/test/resources/invalid.json b/modules/authentication/portal-authentication-token/src/test/resources/invalid.json new file mode 100644 index 0000000..5c677bd --- /dev/null +++ b/modules/authentication/portal-authentication-token/src/test/resources/invalid.json @@ -0,0 +1 @@ +b00m diff --git a/modules/authentication/portal-authentication-token/src/test/resources/token/KeyPair.keystore b/modules/authentication/portal-authentication-token/src/test/resources/token/KeyPair.keystore new file mode 100644 index 0000000000000000000000000000000000000000..e23ee53a2e7d62fb609f83cb74b12bc3b34f0f21 GIT binary patch literal 2451 zcmY+Ec{~(~7RQHKWb9e9g)EU}#`*}Ov0ODM$yl<+jD2bb2_thS2~&p3zBJataFb9e z#xi4xX|mlUS)wedxtB1up7%cQ-TS$JoO3>(@9&)R=NCp6r1J9tVPru^h@eiYW9p^| zp8y|?ENBiQ3z{5YIE)OA`bPxz1Chbr2N-=&=OIG>yAp=-@zcnl9T*w33Dbr^|Bnxz zD}bap@zr4`YP%6!G`Hw%v|!d7Ugj=85YT-NLz%1~GQg{br^^Qi6=B?pu_}({a}))n;E@Dm)HAzwG{sleE*%&)uHvyu=;-Wsu{g z6}w!#!2+11dw(JC8K$k5+`;Ft`zMYmB65wWoG{mY`CIMV5#Tjhf@;FAw3}HG6U%aZ zmN8X<#?L+NjE`GtamCGBY3wo!V7=0qO;3TwD{Q1#w}>*FYsoY~_XMwv zT1ZM-M8jL@;j=kFS+y@aL#n*iGYYz3L#>*CHhK~@(dG4F19^M!*?r9c3YBPOPnASC z>Jr!TiDGj+l+oHZ+we;l-X6P=`&gBrbEHDuCwVF~6ewWCbc3ot4g-0VZk7XkQ@SRt z`hl4;Cc9B=y!1%cl;+zh=N7ZzSm)qm!mTPv)rW1>6G?VUaWnZy{$%}N(=iypvNa|* zu4Wti%qOTO6=0yazch&PfkARyir)&MkolZ;CKDgF3i=_4ztbfK(jqh+!K`%p}4OHSx7R_RXgR-p((+L0bcY2pfvOjlfBnG?5pL)4i8;Rx}HnGwhdajm5D47PkX zbf1eB-mIN-@O+YSF=|A&=X=TTbWcEl3$LihywYCbtGk!3LihqnbG`S*1A#i`1rhJ9 z#BOTcx0JKlhEe@CT}fzo@)bfyx$aMV4=j{qR{dIOL!B@K`0jhhH6gzvtohhiKO?ZvWDKtyeh!R#+)r zTgEOZ;ybM#b!fIW?tPXo@Fe9kt(@2LlDMAprn3^$zZv|dbj7LYzAz{J+~)4>!TjsE z+7_jnRfeCDGJG(!BQ3<j z4@!PojkJgy-MH#EfSaPxnLI-egfHt6`i4BB3bpP~)srRE^!bdR4b~x7_ceP+a*L%}dFjh{dxBMIJM?E7rcN zgk=05Iq}J?$kkgCBkeMcz8zGDmU&>)TKK}Z2P74F`1*~SRfvzyT@!_ARt?QYh9dyC zgB|@#m4Z_6!^+}-Yk(L427o*0VSvj398C4^1!=ex=tqSc^*im`cC zw2Jn3hL?MET%|P9$TCUJ9qX?9W(-&{05^zu9h$tr_cdJr{K}$m93z7?)OIlou7Sp3 zPmXfLQ#wHx25=!&Pl86HrjB$vD`g7I#;ZwaO zJ`?0k@egTP8MA3w$Fz@xM9x*JN-Y`=Qk*xALO+8O98#y-s)Mocg@s@@-e z;AJ_!@_5EIMINN+NIW^?tubMPDRsndp#@Ba^oZdVBg%5gC%EV0BgntE9rehuNlCUX%pKc_WC~ z=@PbFLr=R%>iwN4ScM4u>kpGoW}Ovz$gr)RdK>>|XxrxOoQePuW(`w?2|~apM1lPB zU;s!eF!P~tX0(;)nAoahS}dcfug~~^CXe6aiMWmUJ6U7_w0{FM C>23A^ literal 0 HcmV?d00001 diff --git a/modules/authentication/portal-authentication-token/src/test/resources/token/other-public-key.pub b/modules/authentication/portal-authentication-token/src/test/resources/token/other-public-key.pub new file mode 100644 index 0000000..e385a20 --- /dev/null +++ b/modules/authentication/portal-authentication-token/src/test/resources/token/other-public-key.pub @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuAma0F6AgObeIi3+5o/e +XeU+1S/ykAZndgsq14WWrCDXV3D1P2En+DQPcBQFbKuvt/jPHpPbt4XiMgMyKzjm +DrLBKRo0WoVK6Wpz9e+MKx5CTt4Xbj9LbDCQjSCzElBMXKpbBpissf0lp6zsXE46 +wF8FBOj88yy9cR0MG41rqW80n9MCSGx9PUbaxm7FeBg4/tTNBkGjocqj0DNQ/UC5 +ggVpgEzgggeKthZvIkEnklkll4k8Q6hDlrOFzhklenrFt5KcOeIYLJJ9zhR3skni +MeN8gq2M+v1UuXsqEa5EMfLghfh8M5Ra3WpEapj4klOvJvMSn1vtP42dJ6X36Mty +MQIDAQAB +-----END PUBLIC KEY----- diff --git a/modules/authentication/portal-authentication-token/src/test/resources/token/some-id-token.json b/modules/authentication/portal-authentication-token/src/test/resources/token/some-id-token.json new file mode 100644 index 0000000..1e756b0 --- /dev/null +++ b/modules/authentication/portal-authentication-token/src/test/resources/token/some-id-token.json @@ -0,0 +1,5 @@ +{ + "name": "hello", + "preferred_username": "world", + "email": "hello@world.com" +} \ No newline at end of file diff --git a/modules/authentication/portal-authentication-token/src/test/resources/token/some-name.json b/modules/authentication/portal-authentication-token/src/test/resources/token/some-name.json new file mode 100644 index 0000000..cdfa5e1 --- /dev/null +++ b/modules/authentication/portal-authentication-token/src/test/resources/token/some-name.json @@ -0,0 +1,4 @@ +{ + "name": "hello", + "preferred_username": "world" +} \ No newline at end of file diff --git a/modules/authentication/portal-authentication-token/src/test/resources/token/some-roles.json b/modules/authentication/portal-authentication-token/src/test/resources/token/some-roles.json new file mode 100644 index 0000000..0eaa212 --- /dev/null +++ b/modules/authentication/portal-authentication-token/src/test/resources/token/some-roles.json @@ -0,0 +1,7 @@ +{ + "roles": [ + "reader", + "writer", + "gambler" + ] +} diff --git a/modules/authentication/portal-authentication-token/src/test/resources/token/some-scopes.json b/modules/authentication/portal-authentication-token/src/test/resources/token/some-scopes.json new file mode 100644 index 0000000..e11c517 --- /dev/null +++ b/modules/authentication/portal-authentication-token/src/test/resources/token/some-scopes.json @@ -0,0 +1,7 @@ +{ + "scp": [ + "openid", + "email", + "offline_access" + ] +} \ No newline at end of file diff --git a/modules/authentication/portal-authentication-token/src/test/resources/token/test-private-key.pkcs8 b/modules/authentication/portal-authentication-token/src/test/resources/token/test-private-key.pkcs8 new file mode 100644 index 0000000..1c100c8 --- /dev/null +++ b/modules/authentication/portal-authentication-token/src/test/resources/token/test-private-key.pkcs8 @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCkFOSqavlB4XcA +3V7ri3zPvwKFpK6KRrgpaDW1XnbSoMQmTgGxY3ONyBO4c4VXSBCcblz6n2BbtA4d +m+r4XFMfgbrhLTUvcL2MYyoI4O9hub0q/UFz6BHBtfN9q57s+o81JUrvGHklDgSp +eCIZJuadhBKsMYJB5X41l6+jFQvbKd5FuBKX5x1rN+LSoufzziHPKy+vY0nBFaJZ +eig3PM+ihGW9rLiEcKSJtsGdmmI4gxkW5e7gQavAPbipmY/NAv8ughP8EC/lnI0a +awi+p0wqkJh9Cs7ZUAeJ9rTlVJkl/B1eVjE8vzXmHEgZYdgodOK6hmt0n19x5s33 +HZwAQYBjAgMBAAECggEAS7xBF9AsoRYawdFsQLivsYxu85Th431k/+fT8pVZrzr5 +jtyut1w6OBVD1P2tbG4dDLd8P0pVRb7ETTJssJqc24wb4FfnlrApLaWP/20j0S3U +fQVMst1JZOpBK56NysCUXWmLPvVC5bGkwTxsMMPd6pLNs6FeiGAPc1lCfrn2MQBS +pffiL92+E91wN8+gmm9vGZr7MvOOgzLUca0NAjtyNyLc4F2Ugnh8IKNc3dNt3K0L +AtNfIfMy6Um+uyYdnb5X6vGH/+ajyl6TFUl1hfYiJZpnaYxYD3VeR0yNcBr7Bfeu +VsFplvDlqUWhq1eMX7oe8gaOfWMBZo9fW6g7YclngQKBgQDb74DhGzj8fl6doYPq +wrBo893F+aOW0ofZAOYVwc7MxygunEKeNOVaw62lXUBKza9fudTEx4LlNdaOJrYe +sk+ffUKJl61EyR+vjPE7cw6GYVj10HbcyoDrvtuuvz9rlldGR3I9z52kCpr7Esqf +yHRzgqIydbu/Ghe1rDBwSSg9QwKBgQC+/L4Ff9zgrcm3BeTylDcdTSkMxZ5gf2ir +8SIff1x48Sa69CsdebKIm+DQjMkXou2FeIAPu3cfQreqBXZiMatJRZ9zOcvPH+uq +ST54hQ/pP1YpIbSMLyihYbPj1cAMqqa3YlAnSIz912pB1GVcGB1JdL5Jt/mfe8T1 +BySPPNruYQKBgF55q2sHPpt3zTz5PKmDqDPtTb7VVahcF27oK+38qtDcXC5pgVRk +dIFgvR7jx9JaOJNuSC+fZGMBlYDKsEDPZ9SjAgoI1a1OmAXZDWb0LbEb8BLn0adW +dbrO6Z9PF/cnRaYy2qginxzwUVK458FrYlqcKwByAow8sSKhLM2PH3HxAoGARQvA +cuKH6t5JV5aU77HvvvMfTRPAryhAojC54mM7/ilIlDwjvjM5/TNFcoADTz1C24b1 +3Tor1axcE+aHpvZH82RtQc36RbZHj7eNEysf1nZkYthhmnCOItHcpBippqqnPRMY +4SrUdgzDyGrN/h5lsCG5jZMqdqLbUK41ZGdeyuECgYBWnnlD0qHRYLRIOjvAaCYG +WASOv7FjUrCZ9sAZ841RDqZMAoIsfHKleQMw9eSpZc6ueZb0b3cXIxIRuagH6n7R ++1N5gQLMteoUYACbc8f69mDEK/YPoMEki1FWKzSRka2Ncel5O7A2WRlcbuy0jRKJ +Dkca6VhUbh4hK//vyt6ezQ== +-----END PRIVATE KEY----- diff --git a/modules/authentication/portal-authentication-token/src/test/resources/token/test-public-key.jwks b/modules/authentication/portal-authentication-token/src/test/resources/token/test-public-key.jwks new file mode 100644 index 0000000..908570b --- /dev/null +++ b/modules/authentication/portal-authentication-token/src/test/resources/token/test-public-key.jwks @@ -0,0 +1,5 @@ +{ + "kty": "RSA", + "n": "pBTkqmr5QeF3AN1e64t8z78ChaSuika4KWg1tV520qDEJk4BsWNzjcgTuHOFV0gQnG5c-p9gW7QOHZvq-FxTH4G64S01L3C9jGMqCODvYbm9Kv1Bc-gRwbXzfaue7PqPNSVK7xh5JQ4EqXgiGSbmnYQSrDGCQeV-NZevoxUL2yneRbgSl-cdazfi0qLn884hzysvr2NJwRWiWXooNzzPooRlvay4hHCkibbBnZpiOIMZFuXu4EGrwD24qZmPzQL_LoIT_BAv5ZyNGmsIvqdMKpCYfQrO2VAHifa05VSZJfwdXlYxPL815hxIGWHYKHTiuoZrdJ9fcebN9x2cAEGAYw", + "e": "AQAB" +} \ No newline at end of file diff --git a/modules/authentication/portal-authentication-token/src/test/resources/token/test-public-key.pub b/modules/authentication/portal-authentication-token/src/test/resources/token/test-public-key.pub new file mode 100644 index 0000000..123d80c --- /dev/null +++ b/modules/authentication/portal-authentication-token/src/test/resources/token/test-public-key.pub @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApBTkqmr5QeF3AN1e64t8 +z78ChaSuika4KWg1tV520qDEJk4BsWNzjcgTuHOFV0gQnG5c+p9gW7QOHZvq+FxT +H4G64S01L3C9jGMqCODvYbm9Kv1Bc+gRwbXzfaue7PqPNSVK7xh5JQ4EqXgiGSbm +nYQSrDGCQeV+NZevoxUL2yneRbgSl+cdazfi0qLn884hzysvr2NJwRWiWXooNzzP +ooRlvay4hHCkibbBnZpiOIMZFuXu4EGrwD24qZmPzQL/LoIT/BAv5ZyNGmsIvqdM +KpCYfQrO2VAHifa05VSZJfwdXlYxPL815hxIGWHYKHTiuoZrdJ9fcebN9x2cAEGA +YwIDAQAB +-----END PUBLIC KEY----- diff --git a/modules/authentication/portal-authentication-token/src/test/resources/well_known.json b/modules/authentication/portal-authentication-token/src/test/resources/well_known.json new file mode 100644 index 0000000..86c31ab --- /dev/null +++ b/modules/authentication/portal-authentication-token/src/test/resources/well_known.json @@ -0,0 +1 @@ +{"issuer":"http://localhost:9090/oauth","authorization_endpoint":"http://localhost:9090/oauth/protocol/openid-connect/auth","token_endpoint":"http://localhost:9090/oauth/protocol/openid-connect/token","introspection_endpoint":"http://localhost:9090/oauth/protocol/openid-connect/token/introspect","userinfo_endpoint":"http://localhost:9090/oauth/protocol/openid-connect/userinfo","end_session_endpoint":"http://localhost:9090/oauth/protocol/openid-connect/logout","jwks_uri":"http://localhost:9090/oauth/protocol/openid-connect/certs","check_session_iframe":"http://localhost:9090/oauth/protocol/openid-connect/login-status-iframe.html","grant_types_supported":["authorization_code","implicit","refresh_token","password","client_credentials","urn:ietf:params:oauth:grant-type:device_code","urn:openid:params:grant-type:ciba"],"response_types_supported":["code","none","id_token","token","id_token token","code id_token","code token","code id_token token"],"subject_types_supported":["public","pairwise"],"id_token_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"id_token_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"id_token_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"userinfo_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512","none"],"request_object_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512","none"],"response_modes_supported":["query","fragment","form_post"],"registration_endpoint":"http://localhost:9090/oauth/clients-registrations/openid-connect","token_endpoint_auth_methods_supported":["private_key_jwt","client_secret_basic","client_secret_post","tls_client_auth","client_secret_jwt"],"token_endpoint_auth_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"introspection_endpoint_auth_methods_supported":["private_key_jwt","client_secret_basic","client_secret_post","tls_client_auth","client_secret_jwt"],"introspection_endpoint_auth_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"claims_supported":["aud","sub","iss","auth_time","name","given_name","family_name","preferred_username","email","acr"],"claim_types_supported":["normal"],"claims_parameter_supported":true,"scopes_supported":["openid","profile","microprofile-jwt","email","roles","web-origins","phone","address","offline_access"],"request_parameter_supported":true,"request_uri_parameter_supported":true,"require_request_uri_registration":true,"code_challenge_methods_supported":["plain","S256"],"tls_client_certificate_bound_access_tokens":true,"revocation_endpoint":"http://localhost:9090/oauth/protocol/openid-connect/revoke","revocation_endpoint_auth_methods_supported":["private_key_jwt","client_secret_basic","client_secret_post","tls_client_auth","client_secret_jwt"],"revocation_endpoint_auth_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"backchannel_logout_supported":true,"backchannel_logout_session_supported":true,"device_authorization_endpoint":"http://localhost:9090/oauth/protocol/openid-connect/auth/device","backchannel_token_delivery_modes_supported":["poll"],"backchannel_authentication_endpoint":"http://localhost:9090/oauth/protocol/openid-connect/ext/ciba/auth"} diff --git a/modules/test/portal-core-unit-testing/src/test/java/de/cuioss/portal/core/test/junit5/mockwebserver/dispatcher/BaseAllAcceptDispatcherTest.java b/modules/test/portal-core-unit-testing/src/test/java/de/cuioss/portal/core/test/junit5/mockwebserver/dispatcher/BaseAllAcceptDispatcherTest.java index 5b46ade..6738e02 100644 --- a/modules/test/portal-core-unit-testing/src/test/java/de/cuioss/portal/core/test/junit5/mockwebserver/dispatcher/BaseAllAcceptDispatcherTest.java +++ b/modules/test/portal-core-unit-testing/src/test/java/de/cuioss/portal/core/test/junit5/mockwebserver/dispatcher/BaseAllAcceptDispatcherTest.java @@ -15,20 +15,12 @@ */ package de.cuioss.portal.core.test.junit5.mockwebserver.dispatcher; -import static de.cuioss.portal.core.test.junit5.mockwebserver.dispatcher.EndpointAnswerHandlerTest.assertMockResponse; -import static de.cuioss.portal.core.test.junit5.mockwebserver.dispatcher.HttpMethodMapper.DELETE; -import static de.cuioss.portal.core.test.junit5.mockwebserver.dispatcher.HttpMethodMapper.GET; -import static de.cuioss.portal.core.test.junit5.mockwebserver.dispatcher.HttpMethodMapper.POST; -import static de.cuioss.portal.core.test.junit5.mockwebserver.dispatcher.HttpMethodMapper.PUT; -import static jakarta.servlet.http.HttpServletResponse.SC_CREATED; -import static jakarta.servlet.http.HttpServletResponse.SC_FORBIDDEN; -import static jakarta.servlet.http.HttpServletResponse.SC_NOT_IMPLEMENTED; -import static jakarta.servlet.http.HttpServletResponse.SC_NO_CONTENT; -import static jakarta.servlet.http.HttpServletResponse.SC_OK; - +import mockwebserver3.RecordedRequest; import org.junit.jupiter.api.Test; -import mockwebserver3.RecordedRequest; +import static de.cuioss.portal.core.test.junit5.mockwebserver.dispatcher.EndpointAnswerHandlerTest.assertMockResponse; +import static de.cuioss.portal.core.test.junit5.mockwebserver.dispatcher.HttpMethodMapper.*; +import static jakarta.servlet.http.HttpServletResponse.*; class BaseAllAcceptDispatcherTest {