From 7661e47edc16a260993ec81b0e542679c6aa3735 Mon Sep 17 00:00:00 2001 From: Andrej Petras Date: Sat, 12 Oct 2024 18:53:13 +0200 Subject: [PATCH] feat: extend principal token parsing and validation (#219) --- .../pages/includes/attributes.adoc | 2 +- .../includes/tkit-quarkus-rest-context.adoc | 85 +++++++++++ .../PrincipalTokenRequiredException.java | 6 +- .../rs/context/token/TokenContextConfig.java | 70 +++++++++ .../rs/context/token/TokenContextService.java | 74 ++++++++-- .../rs/context/token/TokenException.java | 5 + .../rs/context/token/TokenParserRequest.java | 61 ++++++++ .../rs/context/token/TokenParserService.java | 50 +++++-- it/pom.xml | 1 + it/rest-context-multi-kc/pom.xml | 125 ++++++++++++++++ .../context/it/MultiTestRestController.java | 30 ++++ .../src/main/resources/application.properties | 30 ++++ .../quarkus/rs/context/it/AbstractTest.java | 32 +++++ .../rs/context/it/KeycloakTestResource.java | 133 ++++++++++++++++++ .../it/MultiTestRestControllerTest.java | 86 +++++++++++ .../quarkus/rs/context/it/RealmFactory.java | 82 +++++++++++ 16 files changed, 852 insertions(+), 20 deletions(-) create mode 100644 it/rest-context-multi-kc/pom.xml create mode 100644 it/rest-context-multi-kc/src/main/java/org/tkit/quarkus/rs/context/it/MultiTestRestController.java create mode 100644 it/rest-context-multi-kc/src/main/resources/application.properties create mode 100644 it/rest-context-multi-kc/src/test/java/org/tkit/quarkus/rs/context/it/AbstractTest.java create mode 100644 it/rest-context-multi-kc/src/test/java/org/tkit/quarkus/rs/context/it/KeycloakTestResource.java create mode 100644 it/rest-context-multi-kc/src/test/java/org/tkit/quarkus/rs/context/it/MultiTestRestControllerTest.java create mode 100644 it/rest-context-multi-kc/src/test/java/org/tkit/quarkus/rs/context/it/RealmFactory.java diff --git a/docs/modules/tkit-quarkus/pages/includes/attributes.adoc b/docs/modules/tkit-quarkus/pages/includes/attributes.adoc index 7860ebd3..3352856d 100644 --- a/docs/modules/tkit-quarkus/pages/includes/attributes.adoc +++ b/docs/modules/tkit-quarkus/pages/includes/attributes.adoc @@ -1,4 +1,4 @@ -:project-version: 2.32.0 +:project-version: 2.33.0 :quarkus-version: 3.15.1 :examples-dir: ./../examples/ \ No newline at end of file diff --git a/docs/modules/tkit-quarkus/pages/includes/tkit-quarkus-rest-context.adoc b/docs/modules/tkit-quarkus/pages/includes/tkit-quarkus-rest-context.adoc index 990b2bfa..da7fbcef 100644 --- a/docs/modules/tkit-quarkus/pages/includes/tkit-quarkus-rest-context.adoc +++ b/docs/modules/tkit-quarkus/pages/includes/tkit-quarkus-rest-context.adoc @@ -127,6 +127,23 @@ endif::add-copy-button-to-env-var[] |string |`apm-principal-token` +a| [[tkit-quarkus-rest-context_tkit-rs-context-token-parser-error-unauthorized]] [.property-path]##link:#tkit-quarkus-rest-context_tkit-rs-context-token-parser-error-unauthorized[`tkit.rs.context.token.parser-error-unauthorized`]## + +[.description] +-- +Throw Unauthorized exception for any parser error. Return StatusCode 401. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++TKIT_RS_CONTEXT_TOKEN_PARSER_ERROR_UNAUTHORIZED+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++TKIT_RS_CONTEXT_TOKEN_PARSER_ERROR_UNAUTHORIZED+++` +endif::add-copy-button-to-env-var[] +-- +|boolean +|`true` + a| [[tkit-quarkus-rest-context_tkit-rs-context-principal-name-enabled]] [.property-path]##link:#tkit-quarkus-rest-context_tkit-rs-context-principal-name-enabled[`tkit.rs.context.principal.name.enabled`]## [.description] @@ -603,6 +620,74 @@ endif::add-copy-button-to-env-var[] |boolean |`true` +a| [[tkit-quarkus-rest-context_tkit-rs-context-token-issuers-issuers-enabled]] [.property-path]##link:#tkit-quarkus-rest-context_tkit-rs-context-token-issuers-issuers-enabled[`tkit.rs.context.token.issuers."issuers".enabled`]## + +[.description] +-- +Enable or disable oidc token config. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++TKIT_RS_CONTEXT_TOKEN_ISSUERS__ISSUERS__ENABLED+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++TKIT_RS_CONTEXT_TOKEN_ISSUERS__ISSUERS__ENABLED+++` +endif::add-copy-button-to-env-var[] +-- +|boolean +|`true` + +a| [[tkit-quarkus-rest-context_tkit-rs-context-token-issuers-issuers-url]] [.property-path]##link:#tkit-quarkus-rest-context_tkit-rs-context-token-issuers-issuers-url[`tkit.rs.context.token.issuers."issuers".url`]## + +[.description] +-- +Token issuer value + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++TKIT_RS_CONTEXT_TOKEN_ISSUERS__ISSUERS__URL+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++TKIT_RS_CONTEXT_TOKEN_ISSUERS__ISSUERS__URL+++` +endif::add-copy-button-to-env-var[] +-- +|string +|required icon:exclamation-circle[title=Configuration property is required] + +a| [[tkit-quarkus-rest-context_tkit-rs-context-token-issuers-issuers-public-key-location-enabled]] [.property-path]##link:#tkit-quarkus-rest-context_tkit-rs-context-token-issuers-issuers-public-key-location-enabled[`tkit.rs.context.token.issuers."issuers".public-key-location.enabled`]## + +[.description] +-- +Use token realm for the public key. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++TKIT_RS_CONTEXT_TOKEN_ISSUERS__ISSUERS__PUBLIC_KEY_LOCATION_ENABLED+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++TKIT_RS_CONTEXT_TOKEN_ISSUERS__ISSUERS__PUBLIC_KEY_LOCATION_ENABLED+++` +endif::add-copy-button-to-env-var[] +-- +|boolean +|`true` + +a| [[tkit-quarkus-rest-context_tkit-rs-context-token-issuers-issuers-public-key-location-suffix]] [.property-path]##link:#tkit-quarkus-rest-context_tkit-rs-context-token-issuers-issuers-public-key-location-suffix[`tkit.rs.context.token.issuers."issuers".public-key-location.suffix`]## + +[.description] +-- +Public key server suffix + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++TKIT_RS_CONTEXT_TOKEN_ISSUERS__ISSUERS__PUBLIC_KEY_LOCATION_SUFFIX+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++TKIT_RS_CONTEXT_TOKEN_ISSUERS__ISSUERS__PUBLIC_KEY_LOCATION_SUFFIX+++` +endif::add-copy-button-to-env-var[] +-- +|string +|`/protocol/openid-connect/certs` + |=== diff --git a/extensions/rest-context/runtime/src/main/java/org/tkit/quarkus/rs/context/token/PrincipalTokenRequiredException.java b/extensions/rest-context/runtime/src/main/java/org/tkit/quarkus/rs/context/token/PrincipalTokenRequiredException.java index d33e824b..0ab4fd88 100644 --- a/extensions/rest-context/runtime/src/main/java/org/tkit/quarkus/rs/context/token/PrincipalTokenRequiredException.java +++ b/extensions/rest-context/runtime/src/main/java/org/tkit/quarkus/rs/context/token/PrincipalTokenRequiredException.java @@ -4,12 +4,14 @@ public class PrincipalTokenRequiredException extends RestContextException { - public PrincipalTokenRequiredException() { - super(ErrorKeys.PRINCIPAL_TOKEN_REQUIRED, "Principal token is required"); + public PrincipalTokenRequiredException(ErrorKeys errorKeys, String message) { + super(errorKeys, message); } public enum ErrorKeys { + PRINCIPAL_TOKEN_WRONG_ISSUER, + PRINCIPAL_TOKEN_REQUIRED; } } diff --git a/extensions/rest-context/runtime/src/main/java/org/tkit/quarkus/rs/context/token/TokenContextConfig.java b/extensions/rest-context/runtime/src/main/java/org/tkit/quarkus/rs/context/token/TokenContextConfig.java index 876a3461..10f40c2b 100644 --- a/extensions/rest-context/runtime/src/main/java/org/tkit/quarkus/rs/context/token/TokenContextConfig.java +++ b/extensions/rest-context/runtime/src/main/java/org/tkit/quarkus/rs/context/token/TokenContextConfig.java @@ -1,5 +1,7 @@ package org.tkit.quarkus.rs.context.token; +import java.util.Map; + import io.quarkus.runtime.annotations.ConfigDocFilename; import io.quarkus.runtime.annotations.ConfigPhase; import io.quarkus.runtime.annotations.ConfigRoot; @@ -69,5 +71,73 @@ interface TokenConfig { @WithName("header-param") @WithDefault("apm-principal-token") String tokenHeaderParam(); + + /** + * Token oidc configuration. + */ + @WithName("issuers") + Map issuers(); + + /** + * Throw Unauthorized exception for any parser error. Return StatusCode 401. + */ + @WithName("parser-error-unauthorized") + @WithDefault("true") + boolean parserErrorUnauthorized(); + + /** + * Throw Unauthorized exception for required error. Return StatusCode 401. + */ + @WithName("required-error-unauthorized") + @WithDefault("false") + boolean requiredErrorUnauthorized(); + + /** + * Throw Unauthorized exception if access token issuer does not equal to principal token issuer. + * Return StatusCode 401. + */ + @WithName("check-tokens-issuer-error-unauthorized") + @WithDefault("true") + boolean checkTokensIssuerErrorUnauthorized(); + + /** + * Compare access token issuer with principal token issuer. + */ + @WithName("check-tokens-issuer") + @WithDefault("true") + boolean checkTokensIssuer(); + } + + /** + * Token oidc configuration. + */ + interface IssuerConfig { + + /** + * Enable or disable oidc token config. + */ + @WithName("enabled") + @WithDefault("true") + boolean enabled(); + + /** + * Token issuer value + */ + @WithName("url") + String url(); + + /** + * Use token realm for the public key. + */ + @WithName("public-key-location.enabled") + @WithDefault("true") + boolean publicKeyLocationEnabled(); + + /** + * Public key server suffix + */ + @WithName("public-key-location.suffix") + @WithDefault("/protocol/openid-connect/certs") + String publicKeyLocationSuffix(); } } diff --git a/extensions/rest-context/runtime/src/main/java/org/tkit/quarkus/rs/context/token/TokenContextService.java b/extensions/rest-context/runtime/src/main/java/org/tkit/quarkus/rs/context/token/TokenContextService.java index e32baf32..5074c979 100644 --- a/extensions/rest-context/runtime/src/main/java/org/tkit/quarkus/rs/context/token/TokenContextService.java +++ b/extensions/rest-context/runtime/src/main/java/org/tkit/quarkus/rs/context/token/TokenContextService.java @@ -5,36 +5,94 @@ import jakarta.ws.rs.container.ContainerRequestContext; import org.eclipse.microprofile.jwt.JsonWebToken; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.quarkus.security.UnauthorizedException; @ApplicationScoped public class TokenContextService { + private static final Logger log = LoggerFactory.getLogger(TokenContextService.class); + @Inject TokenParserService tokenParserService; @Inject TokenContextConfig config; + @Inject + JsonWebToken accessToken; + public JsonWebToken getRestContextPrincipalToken(ContainerRequestContext containerRequestContext) { if (!config.token().enabled()) { return null; } + // parse principal token var token = getToken(containerRequestContext); - if (token == null && config.token().mandatory()) { - throw new PrincipalTokenRequiredException(); + + if (token == null) { + // check if principal token is mandatory + if (config.token().mandatory()) { + log.error("Principal token is required for the request."); + if (config.token().requiredErrorUnauthorized()) { + throw new UnauthorizedException("Principal token is required"); + } + throw new PrincipalTokenRequiredException(PrincipalTokenRequiredException.ErrorKeys.PRINCIPAL_TOKEN_REQUIRED, + "Principal token is required"); + } + } else { + // compare access token and principal token issuer + if (config.token().checkTokensIssuer() && accessToken.getRawToken() != null) { + if (!token.getIssuer().equals(accessToken.getIssuer())) { + log.error("Principal token has undefined issuer compare to access token."); + if (config.token().checkTokensIssuerErrorUnauthorized()) { + throw new UnauthorizedException("Undefined principal token issuer"); + } + throw new PrincipalTokenRequiredException( + PrincipalTokenRequiredException.ErrorKeys.PRINCIPAL_TOKEN_WRONG_ISSUER, + "Undefined principal token issuer"); + } + } } + return token; } private JsonWebToken getToken(ContainerRequestContext containerRequestContext) { - String rawToken = containerRequestContext.getHeaders().getFirst(config.token().tokenHeaderParam()); + var tc = config.token(); + String rawToken = containerRequestContext.getHeaders().getFirst(tc.tokenHeaderParam()); + if (rawToken == null) { + return null; + } + TokenParserRequest request = new TokenParserRequest(rawToken) - .issuerEnabled(config.token().issuerEnabled()) - .type(config.token().type()) - .verify(config.token().verify()) - .issuerSuffix(config.token().issuerSuffix()); - return tokenParserService.parseToken(request); + .issuerEnabled(tc.issuerEnabled()) + .type(tc.type()) + .verify(tc.verify()) + .issuerSuffix(tc.issuerSuffix()); + + var issuers = tc.issuers(); + if (issuers != null && !issuers.isEmpty()) { + issuers.forEach((k, v) -> { + if (v.enabled()) { + request.addIssuerParserRequest(k, new TokenParserRequest.IssuerParserRequest() + .url(v.url()) + .publicKeyLocationEnabled(v.publicKeyLocationEnabled()) + .publicKeyLocationSuffix(v.publicKeyLocationSuffix())); + } + }); + } + + try { + return tokenParserService.parseToken(request); + } catch (TokenException ex) { + if (tc.parserErrorUnauthorized()) { + throw new UnauthorizedException(ex); + } + throw ex; + } } } diff --git a/extensions/rest-context/runtime/src/main/java/org/tkit/quarkus/rs/context/token/TokenException.java b/extensions/rest-context/runtime/src/main/java/org/tkit/quarkus/rs/context/token/TokenException.java index 57c28892..17892a09 100644 --- a/extensions/rest-context/runtime/src/main/java/org/tkit/quarkus/rs/context/token/TokenException.java +++ b/extensions/rest-context/runtime/src/main/java/org/tkit/quarkus/rs/context/token/TokenException.java @@ -4,6 +4,11 @@ public class TokenException extends RuntimeException { private final Enum key; + public TokenException(Enum key, String message) { + super(message); + this.key = key; + } + public TokenException(Enum key, String message, Throwable throwable) { super(message, throwable); this.key = key; diff --git a/extensions/rest-context/runtime/src/main/java/org/tkit/quarkus/rs/context/token/TokenParserRequest.java b/extensions/rest-context/runtime/src/main/java/org/tkit/quarkus/rs/context/token/TokenParserRequest.java index 78202ce6..be08df30 100644 --- a/extensions/rest-context/runtime/src/main/java/org/tkit/quarkus/rs/context/token/TokenParserRequest.java +++ b/extensions/rest-context/runtime/src/main/java/org/tkit/quarkus/rs/context/token/TokenParserRequest.java @@ -1,5 +1,8 @@ package org.tkit.quarkus.rs.context.token; +import java.util.HashMap; +import java.util.Map; + public class TokenParserRequest { private final String rawToken; @@ -12,6 +15,8 @@ public class TokenParserRequest { private String type; + private final Map issuerParserRequests = new HashMap<>(); + public TokenParserRequest(String rawToken) { this.rawToken = rawToken; } @@ -71,4 +76,60 @@ public TokenParserRequest verify(boolean verify) { setVerify(verify); return this; } + + public void addIssuerParserRequest(String name, IssuerParserRequest issuerParserRequest) { + issuerParserRequests.put(name, issuerParserRequest); + } + + public Map getIssuerParserRequests() { + return issuerParserRequests; + } + + public static class IssuerParserRequest { + + private String url; + + private String publicKeyLocationSuffix; + + private boolean publicKeyLocationEnabled; + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public IssuerParserRequest url(String url) { + setUrl(url); + return this; + } + + public void setPublicKeyLocationSuffix(String publicKeyLocationSuffix) { + this.publicKeyLocationSuffix = publicKeyLocationSuffix; + } + + public String getPublicKeyLocationSuffix() { + return publicKeyLocationSuffix; + } + + public IssuerParserRequest publicKeyLocationSuffix(String publicKeyLocationSuffix) { + setPublicKeyLocationSuffix(publicKeyLocationSuffix); + return this; + } + + public boolean getPublicKeyLocationEnabled() { + return publicKeyLocationEnabled; + } + + public void setPublicKeyLocationEnabled(boolean publicKeyLocationEnabled) { + this.publicKeyLocationEnabled = publicKeyLocationEnabled; + } + + public IssuerParserRequest publicKeyLocationEnabled(boolean publicKeyLocationEnabled) { + setPublicKeyLocationEnabled(publicKeyLocationEnabled); + return this; + } + } } diff --git a/extensions/rest-context/runtime/src/main/java/org/tkit/quarkus/rs/context/token/TokenParserService.java b/extensions/rest-context/runtime/src/main/java/org/tkit/quarkus/rs/context/token/TokenParserService.java index e974b7e9..b985cc16 100644 --- a/extensions/rest-context/runtime/src/main/java/org/tkit/quarkus/rs/context/token/TokenParserService.java +++ b/extensions/rest-context/runtime/src/main/java/org/tkit/quarkus/rs/context/token/TokenParserService.java @@ -10,6 +10,8 @@ import org.jose4j.jwt.consumer.InvalidJwtException; import org.jose4j.jwx.JsonWebStructure; import org.jose4j.lang.JoseException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import io.smallrye.jwt.auth.principal.DefaultJWTCallerPrincipal; import io.smallrye.jwt.auth.principal.JWTAuthContextInfo; @@ -19,18 +21,29 @@ @RequestScoped public class TokenParserService { + private static final Logger log = LoggerFactory.getLogger(TokenParserService.class); + @Inject JWTAuthContextInfo authContextInfo; @Inject JWTParser parser; + /** + * Parse RAW web token. + * + * @param request request data for the parser. + * @return valid token + * @throws TokenException if parsing or validation failed. + */ public JsonWebToken parseToken(TokenParserRequest request) throws TokenException { if (request == null) { return null; } try { return parseTokenRequest(request); + } catch (TokenException tex) { + throw tex; } catch (Exception ex) { throw new TokenException(ErrorKeys.ERROR_PARSE_TOKEN, "Error parse raw token", ex); } @@ -43,26 +56,45 @@ private JsonWebToken parseTokenRequest(TokenParserRequest request) return null; } + var jws = (JsonWebSignature) JsonWebStructure.fromCompactSerialization(request.getRawToken()); + var jwtClaims = JwtClaims.parse(jws.getUnverifiedPayload()); + if (request.isVerify()) { - var info = authContextInfo; + var issuer = jwtClaims.getIssuer(); + var publicLocationSuffix = request.getIssuerSuffix(); + var publicLocationEnabled = request.isIssuerEnabled(); + + if (!request.getIssuerParserRequests().isEmpty()) { + + var ir = request.getIssuerParserRequests().entrySet().stream() + .filter(e -> issuer.equals(e.getValue().getUrl())) + .findAny(); + + if (ir.isPresent()) { + var pls = ir.get().getValue(); + publicLocationSuffix = pls.getPublicKeyLocationSuffix(); + publicLocationEnabled = pls.getPublicKeyLocationEnabled(); + } else { + log.error("Undefined issuer found in token. Issuer: '{}'", issuer); + throw new TokenException(ErrorKeys.UNDEFINED_ISSUER_FOUND_IN_TOKEN, "Undefined issuer found in token"); + } - if (request.isIssuerEnabled()) { - var jws = (JsonWebSignature) JsonWebStructure.fromCompactSerialization(request.getRawToken()); - var jwtClaims = JwtClaims.parse(jws.getUnverifiedPayload()); - var publicKeyLocation = jwtClaims.getIssuer() + request.getIssuerSuffix(); + } + + var info = authContextInfo; + if (publicLocationEnabled) { info = new JWTAuthContextInfo(authContextInfo); - info.setPublicKeyLocation(publicKeyLocation); + info.setPublicKeyLocation(issuer + publicLocationSuffix); } return parser.parse(request.getRawToken(), info); } - - var jws = (JsonWebSignature) JsonWebStructure.fromCompactSerialization(request.getRawToken()); - var jwtClaims = JwtClaims.parse(jws.getUnverifiedPayload()); return new DefaultJWTCallerPrincipal(request.getRawToken(), request.getType(), jwtClaims); } public enum ErrorKeys { + UNDEFINED_ISSUER_FOUND_IN_TOKEN, + ERROR_PARSE_TOKEN; } } diff --git a/it/pom.xml b/it/pom.xml index ba7d77c9..1deac123 100644 --- a/it/pom.xml +++ b/it/pom.xml @@ -25,6 +25,7 @@ rs-client rs-client-reactive rest-context + rest-context-multi-kc security-test diff --git a/it/rest-context-multi-kc/pom.xml b/it/rest-context-multi-kc/pom.xml new file mode 100644 index 00000000..da8a5daa --- /dev/null +++ b/it/rest-context-multi-kc/pom.xml @@ -0,0 +1,125 @@ + + + 4.0.0 + + + org.tkit.quarkus.lib.it + tkit-quarkus-it-parent + 999-SNAPSHOT + ../pom.xml + + + tkit-quarkus-it-rest-context-multi-kc + tkit-quarkus-it-rs-rest-context-multi-kc + jar + + + + 25.0.6 + quay.io/keycloak/keycloak:${keycloak.version} + + + + + org.tkit.quarkus.lib + tkit-quarkus-log-cdi + ${project.version} + + + org.tkit.quarkus.lib + tkit-quarkus-log-rs + ${project.version} + + + org.tkit.quarkus.lib + tkit-quarkus-rest-context + ${project.version} + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-rest + + + io.quarkus + quarkus-rest-jackson + + + io.quarkus + quarkus-oidc + + + + + + io.quarkus + quarkus-junit5 + test + + + io.rest-assured + rest-assured + test + + + io.quarkus + quarkus-test-common + test + + + io.quarkus + quarkus-test-keycloak-server + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + ${keycloak.docker.image} + ${project.build.directory}/jta + ${project.build.directory}/jta + org.jboss.logmanager.LogManager + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + integration-test + verify + + + + ${keycloak.docker.image} + org.jboss.logmanager.LogManager + + + + + + + io.quarkus + quarkus-maven-plugin + + + + build + + + + + + + + diff --git a/it/rest-context-multi-kc/src/main/java/org/tkit/quarkus/rs/context/it/MultiTestRestController.java b/it/rest-context-multi-kc/src/main/java/org/tkit/quarkus/rs/context/it/MultiTestRestController.java new file mode 100644 index 00000000..67edc10f --- /dev/null +++ b/it/rest-context-multi-kc/src/main/java/org/tkit/quarkus/rs/context/it/MultiTestRestController.java @@ -0,0 +1,30 @@ +package org.tkit.quarkus.rs.context.it; + +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.tkit.quarkus.log.cdi.LogService; + +@Path("test1") +@LogService +@Consumes(MediaType.APPLICATION_JSON) +@Produces(MediaType.APPLICATION_JSON) +public class MultiTestRestController { + + @Inject + JsonWebToken accessToken; + + @GET + public Response test1() { + return Response.ok(accessToken.getIssuer()).build(); + } + + @GET + @Path("public") + public Response public1() { + return Response.ok(accessToken.getIssuer()).build(); + } +} diff --git a/it/rest-context-multi-kc/src/main/resources/application.properties b/it/rest-context-multi-kc/src/main/resources/application.properties new file mode 100644 index 00000000..dd69df10 --- /dev/null +++ b/it/rest-context-multi-kc/src/main/resources/application.properties @@ -0,0 +1,30 @@ +# AUTHENTICATED +quarkus.http.auth.permission.health.paths=/q/* +quarkus.http.auth.permission.health.policy=permit +quarkus.http.auth.permission.public.paths=/test1/public +quarkus.http.auth.permission.public.policy=permit +quarkus.http.auth.permission.default.paths=/* +quarkus.http.auth.permission.default.policy=authenticated + +tkit.rs.context.token.mandatory=true +tkit.rs.context.token.verify=true +tkit.rs.context.token.public-key-location.enabled=true +tkit.rs.context.token.parser-error-unauthorized=false +tkit.rs.context.token.check-tokens-issuer-error-unauthorized=false + +quarkus.keycloak.devservices.enabled=false + +quarkus.oidc.client-id=quarkus-app +quarkus.oidc.credentials.secret=secret + +# TEST + +%test.tkit.rs.context.token.issuers.kc0.url=${kc0.auth-server-url} +%test.tkit.rs.context.token.issuers.kc1.url=${kc1.auth-server-url} + +%test.quarkus.oidc.auth-server-url=${kc0.auth-server-url} +%test.quarkus.oidc.resolve-tenants-with-issuer=true +%test.quarkus.oidc.kc1.auth-server-url=${kc1.auth-server-url} +%test.quarkus.oidc.kc2.auth-server-url=${kc2.auth-server-url} + + diff --git a/it/rest-context-multi-kc/src/test/java/org/tkit/quarkus/rs/context/it/AbstractTest.java b/it/rest-context-multi-kc/src/test/java/org/tkit/quarkus/rs/context/it/AbstractTest.java new file mode 100644 index 00000000..4d82bde5 --- /dev/null +++ b/it/rest-context-multi-kc/src/test/java/org/tkit/quarkus/rs/context/it/AbstractTest.java @@ -0,0 +1,32 @@ +package org.tkit.quarkus.rs.context.it; + +import static org.tkit.quarkus.rs.context.it.KeycloakTestResource.*; + +import org.eclipse.microprofile.config.ConfigProvider; + +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.keycloak.client.KeycloakTestClient; + +@QuarkusTestResource(KeycloakTestResource.class) +public class AbstractTest { + + static final String ALICE = "alice"; + + protected KeycloakTestClient createClient() { + return new KeycloakTestClient(getPropertyValue(authServerUrlProp(KC0), null)); + } + + protected KeycloakTestClient createClient1() { + return new KeycloakTestClient(getPropertyValue(authServerUrlProp(KC1), null)); + } + + protected KeycloakTestClient createClient2() { + return new KeycloakTestClient(getPropertyValue(authServerUrlProp(KC2), null)); + } + + public String getPropertyValue(String prop, String defaultValue) { + return ConfigProvider.getConfig().getOptionalValue(prop, String.class) + .orElse(null); + } + +} diff --git a/it/rest-context-multi-kc/src/test/java/org/tkit/quarkus/rs/context/it/KeycloakTestResource.java b/it/rest-context-multi-kc/src/test/java/org/tkit/quarkus/rs/context/it/KeycloakTestResource.java new file mode 100644 index 00000000..7733e43a --- /dev/null +++ b/it/rest-context-multi-kc/src/test/java/org/tkit/quarkus/rs/context/it/KeycloakTestResource.java @@ -0,0 +1,133 @@ +package org.tkit.quarkus.rs.context.it; + +import static io.restassured.RestAssured.given; +import static org.tkit.quarkus.rs.context.it.RealmFactory.createRealm; + +import java.io.IOException; +import java.util.*; + +import org.keycloak.representations.AccessTokenResponse; +import org.keycloak.representations.idm.*; +import org.keycloak.util.JsonSerialization; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; +import io.quarkus.test.keycloak.server.KeycloakContainer; + +public class KeycloakTestResource implements QuarkusTestResourceLifecycleManager { + + private static final Logger log = LoggerFactory.getLogger(KeycloakTestResource.class); + + public static final String KC0 = "kc0"; + public static final String KC1 = "kc1"; + public static final String KC2 = "kc2"; + + private final List containers = new ArrayList<>(); + + private static final String KEYCLOAK_REALM = "quarkus"; + + @Override + public Map start() { + + containers.add(new TestKeycloakContainer(KC0)); + containers.add(new TestKeycloakContainer(KC1)); + containers.add(new TestKeycloakContainer(KC2)); + + containers.forEach(this::startContainer); + + Map result = new HashMap<>(); + containers.forEach(c -> { + result.put(urlProp(c.getName()), c.getServerUrl()); + result.put(authServerUrlProp(c.getName()), c.getServerUrl() + "/realms/" + KEYCLOAK_REALM); + }); + return result; + } + + private void startContainer(TestKeycloakContainer container) { + log.info("Start container. Name: '{}'", container.getName()); + container.start(); + + RealmRepresentation realm = createRealm(KEYCLOAK_REALM, "quarkus-app", "secret", + Map.of("alice", List.of("user", "admin"), "bob", List.of("user"))); + + log.info("Create realm '{}' for container '{}'", realm.getRealm(), container.getName()); + postRealm(container.getServerUrl(), realm); + + log.info("Container started. Name: '{}'", container.getName()); + } + + public static String authServerUrlProp(String name) { + return name + ".auth-server-url"; + } + + public static String urlProp(String name) { + return name + ".url"; + } + + @Override + public void stop() { + containers.forEach(k -> { + try { + deleteRealm(k.getServerUrl(), KEYCLOAK_REALM); + k.stop(); + } catch (Exception ex) { + log.error("Error stopping container {}", k, ex); + } + }); + } + + public static class TestKeycloakContainer extends KeycloakContainer { + + private final String name; + + public TestKeycloakContainer(String name) { + this.name = name; + this.withNetworkAliases(name); + } + + public String getName() { + return name; + } + + public String getInternalUrl() { + boolean useHttps = false; + return String.format("%s://%s:%d", + useHttps ? "https" : "http", name, getPort()); + } + } + + private static void deleteRealm(String url, String name) { + given().auth().oauth2(getAdminAccessToken(url)) + .when() + .delete(url + "/admin/realms/" + name) + .then() + .statusCode(204); + } + + private static void postRealm(String url, RealmRepresentation realm) { + try { + given().auth().oauth2(getAdminAccessToken(url)) + .contentType("application/json") + .body(JsonSerialization.writeValueAsString(realm)) + .when() + .post(url + "/admin/realms") + .then() + .statusCode(201); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private static String getAdminAccessToken(String url) { + return given() + .param("grant_type", "password") + .param("username", "admin") + .param("password", "admin") + .param("client_id", "admin-cli") + .when() + .post(url + "/realms/master/protocol/openid-connect/token") + .as(AccessTokenResponse.class).getToken(); + } + +} diff --git a/it/rest-context-multi-kc/src/test/java/org/tkit/quarkus/rs/context/it/MultiTestRestControllerTest.java b/it/rest-context-multi-kc/src/test/java/org/tkit/quarkus/rs/context/it/MultiTestRestControllerTest.java new file mode 100644 index 00000000..eef5a8d1 --- /dev/null +++ b/it/rest-context-multi-kc/src/test/java/org/tkit/quarkus/rs/context/it/MultiTestRestControllerTest.java @@ -0,0 +1,86 @@ +package org.tkit.quarkus.rs.context.it; + +import static io.restassured.RestAssured.given; + +import jakarta.ws.rs.core.Response; + +import org.junit.jupiter.api.Test; + +import io.quarkus.test.common.http.TestHTTPEndpoint; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.keycloak.client.KeycloakTestClient; +import io.restassured.http.ContentType; + +@QuarkusTest +@TestHTTPEndpoint(MultiTestRestController.class) +class MultiTestRestControllerTest extends AbstractTest { + + KeycloakTestClient keycloakClient = createClient(); + KeycloakTestClient keycloakClient1 = createClient1(); + KeycloakTestClient keycloakClient2 = createClient2(); + + @Test + void publicAccessTokenTest() { + var kc0_token = keycloakClient.getAccessToken(ALICE); + given().when() + .header("apm-principal-token", kc0_token) + .contentType(ContentType.JSON) + .get("/public") + .then() + .statusCode(Response.Status.OK.getStatusCode()); + } + + @Test + void mixTokensTest() { + var kc0_token = keycloakClient.getAccessToken(ALICE); + var kc1_token = keycloakClient1.getAccessToken(ALICE); + given().when() + .auth().oauth2(kc1_token) + .header("apm-principal-token", kc0_token) + .contentType(ContentType.JSON) + .get() + .then() + .statusCode(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode()); + } + + @Test + void issuerVerifyTest() { + given().when() + .contentType(ContentType.JSON) + .get() + .then() + .statusCode(Response.Status.UNAUTHORIZED.getStatusCode()); + + // access and valid amp-principal-token + var kc0_token = keycloakClient.getAccessToken(ALICE); + given().when() + .auth().oauth2(kc0_token) + .header("apm-principal-token", kc0_token) + .contentType(ContentType.JSON) + .get() + .then() + .statusCode(Response.Status.OK.getStatusCode()); + + // access and valid amp-principal-token + var kc1_token = keycloakClient1.getAccessToken(ALICE); + given().when() + .auth().oauth2(kc1_token) + .header("apm-principal-token", kc1_token) + .contentType(ContentType.JSON) + .get() + .then() + .statusCode(Response.Status.OK.getStatusCode()); + + // access and NOT valid amp-principal-token + var kc2_token = keycloakClient2.getAccessToken(ALICE); + given().when() + .auth().oauth2(kc2_token) + .header("apm-principal-token", kc2_token) + .contentType(ContentType.JSON) + .get() + .then() + .statusCode(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode()); + + } + +} diff --git a/it/rest-context-multi-kc/src/test/java/org/tkit/quarkus/rs/context/it/RealmFactory.java b/it/rest-context-multi-kc/src/test/java/org/tkit/quarkus/rs/context/it/RealmFactory.java new file mode 100644 index 00000000..1a32b596 --- /dev/null +++ b/it/rest-context-multi-kc/src/test/java/org/tkit/quarkus/rs/context/it/RealmFactory.java @@ -0,0 +1,82 @@ +package org.tkit.quarkus.rs.context.it; + +import java.util.*; +import java.util.stream.Collectors; + +import org.keycloak.representations.idm.*; + +public class RealmFactory { + + public static RealmRepresentation createRealm(String name, String clientId, String clientSecret, + Map> users) { + var realm = createRealm(name); + realm.getClients().add(createClient(clientId, clientSecret)); + if (users != null) { + var userRoles = users.values().stream().flatMap(Collection::stream).collect(Collectors.toSet()); + for (String role : userRoles) { + realm.getRoles().getRealm().add(new RoleRepresentation(role, null, false)); + } + for (Map.Entry> user : users.entrySet()) { + realm.getUsers().add(createUser(user.getKey(), user.getKey(), user.getValue())); + } + } + return realm; + } + + private static RealmRepresentation createRealm(String name) { + RealmRepresentation realm = new RealmRepresentation(); + realm.setRealm(name); + realm.setEnabled(true); + realm.setUsers(new ArrayList<>()); + realm.setClients(new ArrayList<>()); + realm.setAccessTokenLifespan(600); + realm.setSsoSessionMaxLifespan(600); + realm.setRefreshTokenMaxReuse(10); + RolesRepresentation roles = new RolesRepresentation(); + List realmRoles = new ArrayList<>(); + roles.setRealm(realmRoles); + realm.setRoles(roles); + return realm; + } + + private static UserRepresentation createUser(String username, String password, List realmRoles) { + UserRepresentation user = new UserRepresentation(); + + user.setUsername(username); + user.setFirstName(username); + user.setLastName(username); + user.setEnabled(true); + user.setCredentials(new ArrayList<>()); + user.setEmail(username + "@mail.com"); + user.setEmailVerified(true); + user.setRealmRoles(realmRoles); + user.setRequiredActions(null); + + CredentialRepresentation credential = new CredentialRepresentation(); + credential.setType(CredentialRepresentation.PASSWORD); + credential.setValue(password); + credential.setTemporary(false); + + user.getCredentials().add(credential); + + return user; + } + + private static ClientRepresentation createClient(String clientId, String oidcClientSecret) { + ClientRepresentation client = new ClientRepresentation(); + + client.setClientId(clientId); + client.setRedirectUris(List.of("*")); + client.setPublicClient(false); + client.setSecret(oidcClientSecret); + client.setDirectAccessGrantsEnabled(true); + client.setServiceAccountsEnabled(true); + client.setImplicitFlowEnabled(true); + client.setEnabled(true); + client.setRedirectUris(List.of("*")); + client.setDefaultClientScopes(List.of("microprofile-jwt")); + + return client; + } + +}