From 233a78c8e48fb8de353629bc16fc6af1d80fb910 Mon Sep 17 00:00:00 2001 From: YuriyZ Date: Thu, 1 Dec 2022 18:14:28 +0200 Subject: [PATCH] feat(jans-auth-server): draft for - improve dcr / ssa validation for dynamic registration #2980 (#3109) * feat(jans-auth-server): draft for - improve dcr / ssa validation for dynamic registration #2980 * feat(jans-auth-server): added unit tests for ssa validation config service #2980 * doc(jans-auth-server): documentation for dcr and ssa validation #2980 --- .../endpoints/client-registration.md | 124 ++++++++++++ .../SoftwareStatementValidationType.java | 1 + .../model/configuration/AppConfiguration.java | 12 +- .../as/model/ssa/SsaValidationConfig.java | 145 ++++++++++++++ .../jans/as/model/ssa/SsaValidationType.java | 29 +++ .../register/ws/rs/RegisterValidator.java | 96 +++++++-- .../ws/rs/SsaValidationConfigContext.java | 37 ++++ .../ws/rs/SsaValidationConfigService.java | 182 ++++++++++++++++++ .../ws/rs/SsaValidationConfigServiceTest.java | 161 ++++++++++++++++ .../server/src/test/resources/testng.xml | 1 + 10 files changed, 765 insertions(+), 23 deletions(-) create mode 100644 jans-auth-server/model/src/main/java/io/jans/as/model/ssa/SsaValidationConfig.java create mode 100644 jans-auth-server/model/src/main/java/io/jans/as/model/ssa/SsaValidationType.java create mode 100644 jans-auth-server/server/src/main/java/io/jans/as/server/register/ws/rs/SsaValidationConfigContext.java create mode 100644 jans-auth-server/server/src/main/java/io/jans/as/server/register/ws/rs/SsaValidationConfigService.java create mode 100644 jans-auth-server/server/src/test/java/io/jans/as/server/register/ws/rs/SsaValidationConfigServiceTest.java diff --git a/docs/admin/auth-server/endpoints/client-registration.md b/docs/admin/auth-server/endpoints/client-registration.md index 689b108ccf4..fc2a7f0dd0c 100644 --- a/docs/admin/auth-server/endpoints/client-registration.md +++ b/docs/admin/auth-server/endpoints/client-registration.md @@ -15,6 +15,7 @@ Dynamic client registration refers to the process by which a client submits a re 1. For OpenID Connect relying parties - [OpenID Connect Dynamic Client Registration 1.0](https://openid.net/specs/openid-connect-registration-1_0.html). 1. For OAuth 2.0 client (without OpenID Connect features) - [OAuth 2.0 Dynamic Client Registration Protocol - RFC 7591](https://tools.ietf.org/html/rfc7591). 1. CRUD operations on client - [OAuth 2.0 Dynamic Client Registration Management Protocol - RFC 7592](https://tools.ietf.org/html/rfc7592). +1. [OpenBanking OpenID Dynamic Client Registration](https://openbanking.atlassian.net/wiki/spaces/DZ/pages/36667724/OpenBanking+OpenID+Dynamic+Client+Registration+Specification+-+v1.0.0-rc2#OpenBankingOpenIDDynamicClientRegistrationSpecification-v1.0.0-rc2-ClientRegistrationRequest) ### Client Registration endpoint The URI to dynamically register a client to a Janssen Auth Server can be found by checking the `registration_endpoint` claim of the OpenID Connect configuration reponse, typically deployed at `https:///.well-known/openid-configuration` @@ -296,6 +297,129 @@ Output: "description": "string" } ``` + +### Signed DCR and SSA validation + +In OpenBanking case DCR (Dynamic Client Request) is signed and must contain SSA (Software Statement Assertion) inside it. + +Non-Normative Example: +```curl +POST /register HTTP/1.1 +Content-Type: application/jwt +Accept: application/json +Host: auth.bankone.com + +eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJJREFtYX... +``` + +Decoded DCR Example: + +```json +{ + "typ": "JWT", + "alg": "ES256", + "kid": "ABCD1234" +} +{ + "iss": "Amazon TPPID", + "iat": 1492760444, + "exp": 1524296444, + "aud": "https://authn.gluu.org", + "scope": "openid makepayment", + "token_endpoint_auth_method": "private_key_jwt", + "grant_types": ["authorization_code", "refresh_token", "client_credentials"], + "response_types": ["code"], + "id_token_signed_response_alg": "ES256", + "request_object_signing_alg": "ES256", + "software_id": "65d1f27c-4aea-4549-9c21-60e495a7a86f", + "software_statement": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJlbXB0eSIsInN1YiI6IjEyMzQ1Njc4OTAiLCJuYW1lIjoiSm9obiBEb2UiLCJhZG1pbiI6dHJ1ZSwic2NvcGUiOiJzY29wZTEgc2NvcGUyIiwiY2xhaW1zIjoiY2xhaW0xIGNsYWltMiIsImlhdCI6MTY2OTgwNjc2MywiZXhwIjoxNjY5ODEwMzYzfQ.db0WQh2lmHkNYCWT8tSW684hqWTPJDTElppy42XM_lc" +} +{ + Signature +} +``` + +AS has `dcrSsaValidationConfigs` configuration value which holds json array. It can be used to validated both DCR and SSA. + +Single item of this array has following properties: + +* **id** - REQUIRED primary key for the entity +* **type** - REQUIRED either `ssa` or `dcr` +* **displayName** - Human friendly name in case we build an admin GUI for this +* **description** - Human friendly details +* **scope** - For SSA only -- list of allowed scopes the issuer can enable the client to request automatically. If not present, all scopes are allowed. +* **allowed_claims** - Any claims not listed in this list will be dropped. If not present, all claims are allowed. +* **jwks** - Public key +* **jwks_uri** - URI of public key +* **issuers** - For MTLS, list of issuers trusted +* **configuration_endpoint** - points to discovery page, e.g. `https://examle.com/.well-known/openid-configuration` +* **configuration_endpoint_claim** - e.g. `ssa_jwks_endpoint` +* **shared_secret** - for MTLS HMAC + +One of `jwks`, `jwks_uri` or `issuers` or `configuration_endpoint` is required. + +Non-normative example of `dcrSsaValidationConfigs` + +```json +[ { + "id" : "735ee1c0-895d-4398-9c1b-9ad852257cc0", + "type" : "DCR", + "scopes" : [ "read", "write" ], + "allowedClaims" : [ "exp", "iat" ], + "jwks" : "{jwks here}", + "issuers" : [ "Acme" ], + "sharedSecret" : "secret" +}, { + "id" : "7907cd0b-0f9f-4b4f-aaa2-0d0614546246", + "type" : "SSA", + "scopes" : [ "my_read", "my_write" ], + "allowedClaims" : [ "test_exp", "test_iat" ], + "jwks" : "{jwks here}", + "issuers" : [ "jans-auth" ], + "sharedSecret" : "secret" +}, { + "id" : "1e95c9b1-04d0-4440-9362-88f4e1e62d76", + "type" : "SSA", + "jwks" : "{jwks here}", + "issuers" : [ "empty" ], + "sharedSecret" : "secret" +} ] +``` + +#### Signed DCR validation + +DCR can be validated with two approaches. + +**Via `dcrSsaValidationConfigs` configuration property - RECOMMENDED** + +When create entry in `dcrSsaValidationConfigs` configuration property : +- `type` MUST be equal to `DCR` value +- `issuers` MUST have value which equals to `iss` claim in DCR. + +**Via other configuration properties** + +* **dcrSignatureValidationJwks** - specifies JWKS for DCR's validations +* **dcrSignatureValidationJwksUri** - specifies JWKS URI for DCR's validations +* **dcrIssuers** - List of issues if MTLS private key is used to sign DCR JWT +* **dcrSignatureValidationSharedSecret** - if HMAC is used, this is the shared secret +* **dcrSignatureValidationEnabled** - boolean value enables DCR signature validation. Default is false +* **dcrSignatureValidationSoftwareStatementJwksURIClaim** - specifies claim name inside software statement. Value of claim should point to JWKS URI +* **dcrSignatureValidationSoftwareStatementJwksClaim** - specifies claim name inside software statement. Value of claim should point to inlined JWKS +* **dcrAuthorizationWithClientCredentials** - boolean value indicating if DCR authorization to be performed using client credentials + +#### SSA Validation + +SSA is validated based on `softwareStatementValidationType` which is enum. + +1. **softwareStatementValidationType**=*builtin* - validation is performed against `dcrSsaValidationConfigs` configuration property, where + 1. `type` MUST be equal to `SSA` value + 1. `issuers` MUST have value which equals to `iss` claim in DCR. +1. **softwareStatementValidationType**=*script* - jwks and hmac secret are returned by dynamic client registration script +1. **softwareStatementValidationType**=*jwks_uri*, allows to specify jwks_uri claim name from software_statement. Claim name specified by `softwareStatementValidationClaimName` configuration property. +1. **softwareStatementValidationType**=*jwks*, allows to specify jwks claim name from software_statement. Claim name specified by `softwareStatementValidationClaimName` configuration property. +1. **softwareStatementValidationType**=*none*, no validation. + + ### Customizing the behavior of the AS using Interception script Janssen's allows developers to register a client with the Authorization Server (AS) without any intervention by the administrator. By default, all clients are given the same default scopes and attributes. Through the use of an interception script, this behavior can be modified. These scripts can be used to analyze the registration request and apply customizations to the registered client. For example, a client can be given specific scopes by analyzing the [Software Statement](https://www.rfc-editor.org/rfc/rfc7591.html#section-2.3) that is sent with the registration request. diff --git a/jans-auth-server/model/src/main/java/io/jans/as/model/common/SoftwareStatementValidationType.java b/jans-auth-server/model/src/main/java/io/jans/as/model/common/SoftwareStatementValidationType.java index 79809cc4928..56b296e02e5 100644 --- a/jans-auth-server/model/src/main/java/io/jans/as/model/common/SoftwareStatementValidationType.java +++ b/jans-auth-server/model/src/main/java/io/jans/as/model/common/SoftwareStatementValidationType.java @@ -14,6 +14,7 @@ */ public enum SoftwareStatementValidationType { NONE("none"), + BUILTIN("builtin"), JWKS("jwks"), JWKS_URI("jwks_uri"), SCRIPT("script"); diff --git a/jans-auth-server/model/src/main/java/io/jans/as/model/configuration/AppConfiguration.java b/jans-auth-server/model/src/main/java/io/jans/as/model/configuration/AppConfiguration.java index b7ad2aae18e..1c03449604f 100644 --- a/jans-auth-server/model/src/main/java/io/jans/as/model/configuration/AppConfiguration.java +++ b/jans-auth-server/model/src/main/java/io/jans/as/model/configuration/AppConfiguration.java @@ -8,12 +8,12 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.google.common.collect.Lists; - import io.jans.agama.model.EngineConfig; import io.jans.as.model.common.*; import io.jans.as.model.error.ErrorHandlingMethod; import io.jans.as.model.jwk.KeySelectionStrategy; import io.jans.as.model.ssa.SsaConfiguration; +import io.jans.as.model.ssa.SsaValidationConfig; import io.jans.doc.annotation.DocProperty; import java.util.ArrayList; @@ -803,9 +803,17 @@ public class AppConfiguration implements Configuration { @DocProperty(description = "Engine Config which offers an alternative way to build authentication flows in Janssen server") private EngineConfig agamaConfiguration; - @DocProperty(description = "") + @DocProperty(description = "DCR SSA Validation configurations used to perform validation of SSA or DCR") + private List dcrSsaValidationConfigs; + + @DocProperty(description = "SSA Configuration") private SsaConfiguration ssaConfiguration; + public List getDcrSsaValidationConfigs() { + if (dcrSsaValidationConfigs == null) dcrSsaValidationConfigs = new ArrayList<>(); + return dcrSsaValidationConfigs; + } + public Boolean getRequireRequestObjectEncryption() { if (requireRequestObjectEncryption == null) requireRequestObjectEncryption = false; return requireRequestObjectEncryption; diff --git a/jans-auth-server/model/src/main/java/io/jans/as/model/ssa/SsaValidationConfig.java b/jans-auth-server/model/src/main/java/io/jans/as/model/ssa/SsaValidationConfig.java new file mode 100644 index 00000000000..a7386ea930b --- /dev/null +++ b/jans-auth-server/model/src/main/java/io/jans/as/model/ssa/SsaValidationConfig.java @@ -0,0 +1,145 @@ +package io.jans.as.model.ssa; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import java.util.ArrayList; +import java.util.List; + +/** + * @author Yuriy Z + */ +@JsonIgnoreProperties( + ignoreUnknown = true +) +public class SsaValidationConfig { + + private String id; + private SsaValidationType type; + private String displayName; + private String description; + private List scopes; + private List allowedClaims; + private String jwks; + private String jwksUri; + private List issuers; + private String configurationEndpoint; + private String configurationEndpointClaim; + private String sharedSecret; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public SsaValidationType getType() { + return type; + } + + public void setType(SsaValidationType type) { + this.type = type; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public List getScopes() { + if (scopes == null) scopes = new ArrayList<>(); + return scopes; + } + + public void setScopes(List scopes) { + this.scopes = scopes; + } + + public List getAllowedClaims() { + if (allowedClaims == null) allowedClaims = new ArrayList<>(); + return allowedClaims; + } + + public void setAllowedClaims(List allowedClaims) { + this.allowedClaims = allowedClaims; + } + + public String getJwks() { + return jwks; + } + + public void setJwks(String jwks) { + this.jwks = jwks; + } + + public String getJwksUri() { + return jwksUri; + } + + public void setJwksUri(String jwksUri) { + this.jwksUri = jwksUri; + } + + public List getIssuers() { + if (issuers == null) issuers = new ArrayList<>(); + return issuers; + } + + public void setIssuers(List issuers) { + this.issuers = issuers; + } + + public String getConfigurationEndpoint() { + return configurationEndpoint; + } + + public void setConfigurationEndpoint(String configurationEndpoint) { + this.configurationEndpoint = configurationEndpoint; + } + + public String getConfigurationEndpointClaim() { + return configurationEndpointClaim; + } + + public void setConfigurationEndpointClaim(String configurationEndpointClaim) { + this.configurationEndpointClaim = configurationEndpointClaim; + } + + public String getSharedSecret() { + return sharedSecret; + } + + public void setSharedSecret(String sharedSecret) { + this.sharedSecret = sharedSecret; + } + + @Override + public String toString() { + return "SsaValidationConfig{" + + "id='" + id + '\'' + + ", type=" + type + + ", displayName='" + displayName + '\'' + + ", description='" + description + '\'' + + ", scopes=" + scopes + + ", allowedClaims=" + allowedClaims + + ", jwks='" + jwks + '\'' + + ", jwksUri='" + jwksUri + '\'' + + ", issuers=" + issuers + + ", configurationEndpoint='" + configurationEndpoint + '\'' + + ", configurationEndpointClaim='" + configurationEndpointClaim + '\'' + + ", sharedSecret='" + sharedSecret + '\'' + + '}'; + } +} diff --git a/jans-auth-server/model/src/main/java/io/jans/as/model/ssa/SsaValidationType.java b/jans-auth-server/model/src/main/java/io/jans/as/model/ssa/SsaValidationType.java new file mode 100644 index 00000000000..659bab77a72 --- /dev/null +++ b/jans-auth-server/model/src/main/java/io/jans/as/model/ssa/SsaValidationType.java @@ -0,0 +1,29 @@ +package io.jans.as.model.ssa; + +/** + * @author Yuriy Z + */ +public enum SsaValidationType { + NONE("none"), + SSA("ssa"), + DCR("dcr"); + + private final String value; + + SsaValidationType(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + public static SsaValidationType of(String value) { + for (SsaValidationType t : values()) { + if (t.value.equalsIgnoreCase(value)) { + return t; + } + } + return NONE; + } +} diff --git a/jans-auth-server/server/src/main/java/io/jans/as/server/register/ws/rs/RegisterValidator.java b/jans-auth-server/server/src/main/java/io/jans/as/server/register/ws/rs/RegisterValidator.java index 5de012fff2c..e6064a4493f 100644 --- a/jans-auth-server/server/src/main/java/io/jans/as/server/register/ws/rs/RegisterValidator.java +++ b/jans-auth-server/server/src/main/java/io/jans/as/server/register/ws/rs/RegisterValidator.java @@ -19,6 +19,7 @@ import io.jans.as.model.exception.InvalidJwtException; import io.jans.as.model.jwt.Jwt; import io.jans.as.model.register.RegisterErrorResponseType; +import io.jans.as.model.ssa.SsaValidationType; import io.jans.as.model.util.JwtUtil; import io.jans.as.model.util.Pair; import io.jans.as.server.ciba.CIBARegisterParamsValidatorService; @@ -27,11 +28,6 @@ import io.jans.as.server.model.common.AuthorizationGrantList; import io.jans.as.server.model.registration.RegisterParamsValidator; import io.jans.as.server.service.external.ExternalDynamicClientRegistrationService; -import org.apache.commons.lang.StringUtils; -import org.jetbrains.annotations.Nullable; -import org.json.JSONObject; -import org.slf4j.Logger; - import jakarta.ejb.Stateless; import jakarta.inject.Inject; import jakarta.inject.Named; @@ -39,6 +35,10 @@ import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import org.apache.commons.lang.StringUtils; +import org.jetbrains.annotations.Nullable; +import org.json.JSONObject; +import org.slf4j.Logger; import static io.jans.as.model.register.RegisterRequestParam.SOFTWARE_STATEMENT; import static org.apache.commons.lang3.BooleanUtils.isFalse; @@ -75,6 +75,9 @@ public class RegisterValidator { @Inject private RegisterParamsValidator registerParamsValidator; + @Inject + private SsaValidationConfigService ssaValidationConfigService; + public void validateNotBlank(String input, String errorReason) { if (StringUtils.isBlank(input)) { log.trace("Failed to perform client action, reason: {}", errorReason); @@ -90,23 +93,12 @@ public void validateRequestObject(String requestParams, JSONObject softwareState final Jwt jwt = Jwt.parseOrThrow(requestParams); final SignatureAlgorithm signatureAlgorithm = jwt.getHeader().getSignatureAlgorithm(); + final SsaValidationConfigContext ssaContext = new SsaValidationConfigContext(jwt, SsaValidationType.DCR); final boolean isHmac = AlgorithmFamily.HMAC.equals(signatureAlgorithm.getFamily()); if (isHmac) { - String hmacSecret = appConfiguration.getDcrSignatureValidationSharedSecret(); - if (StringUtils.isBlank(hmacSecret)) { - hmacSecret = externalDynamicClientRegistrationService.getDcrHmacSecret(httpRequest, jwt); - } - if (StringUtils.isBlank(hmacSecret)) { - log.error("No hmacSecret provided in Dynamic Client Registration script (method getDcrHmacSecret didn't return actual secret). "); - throw errorResponseFactory.createWebApplicationException(Response.Status.BAD_REQUEST, RegisterErrorResponseType.INVALID_SOFTWARE_STATEMENT, ""); - } - - boolean validSignature = cryptoProvider.verifySignature(jwt.getSigningInput(), jwt.getEncodedSignature(), null, null, hmacSecret, signatureAlgorithm); - log.trace("Request object validation result: {}", validSignature); - if (!validSignature) { - throw new InvalidJwtException("Invalid cryptographic segment in the request object."); - } + validateRequestObjectHmac(httpRequest, ssaContext); + return; } String jwksUri = null; @@ -126,16 +118,65 @@ public void validateRequestObject(String requestParams, JSONObject softwareState jwt.getEncodedSignature(), jwt.getHeader().getKeyId(), jwks, null, signatureAlgorithm); log.trace("Request object validation result: {}", validSignature); - if (!validSignature) { - throw new InvalidJwtException("Invalid cryptographic segment in the request object."); + if (validSignature) { + return; + } + + if (validateRequestObjectSignatureWithSsaValidationConfigs(requestParams)) { + return; } + + throw new InvalidJwtException("Invalid request object."); } catch (Exception e) { + if (validateRequestObjectSignatureWithSsaValidationConfigs(requestParams)) { + return; + } + final String msg = "Unable to validate request object JWT."; log.error(msg, e); throw errorResponseFactory.createWebApplicationException(Response.Status.BAD_REQUEST, RegisterErrorResponseType.INVALID_CLIENT_METADATA, msg); } } + private boolean validateRequestObjectSignatureWithSsaValidationConfigs(String requestParams) { + try { + final SsaValidationConfigContext ssaContext = new SsaValidationConfigContext(Jwt.parseOrThrow(requestParams), SsaValidationType.DCR); + final boolean valid = ssaValidationConfigService.hasValidSignature(ssaContext); + log.trace("Request object validation result for ssaValidationConfigs: {}", valid); + if (valid) { + log.trace("Request object successfully validated by ssaValidationConfig: {}", ssaContext.getSuccessfulConfig()); + return true; + } + } catch (InvalidJwtException e) { + log.error("Unable to validate request object JWT.", e); + } + return false; + } + + private void validateRequestObjectHmac(HttpServletRequest httpRequest, SsaValidationConfigContext ssaContext) throws CryptoProviderException, InvalidJwtException { + final Jwt jwt = ssaContext.getJwt(); + + if (ssaValidationConfigService.isHmacValid(ssaContext)) { + log.trace("Request object successfully validated by ssaValidationConfig: {}", ssaContext.getSuccessfulConfig()); + return; + } + String hmacSecret = appConfiguration.getDcrSignatureValidationSharedSecret(); + if (StringUtils.isBlank(hmacSecret)) { + hmacSecret = externalDynamicClientRegistrationService.getDcrHmacSecret(httpRequest, jwt); + } + if (StringUtils.isBlank(hmacSecret)) { + log.error("No hmacSecret provided in Dynamic Client Registration script (method getDcrHmacSecret didn't return actual secret). "); + throw errorResponseFactory.createWebApplicationException(Response.Status.BAD_REQUEST, RegisterErrorResponseType.INVALID_SOFTWARE_STATEMENT, ""); + } + + boolean validSignature = cryptoProvider.verifySignature(jwt.getSigningInput(), jwt.getEncodedSignature(), null, null, hmacSecret, jwt.getHeader().getSignatureAlgorithm()); + log.trace("Request object validation result: {}", validSignature); + if (validSignature) { + return; + } + throw new InvalidJwtException("Invalid cryptographic segment in the request object."); + } + @Nullable private String getJwksString(JSONObject softwareStatement) { if (StringUtils.isNotBlank(appConfiguration.getDcrSignatureValidationSoftwareStatementJwksClaim())) { @@ -179,6 +220,8 @@ public JSONObject validateSoftwareStatement(HttpServletRequest httpServletReques final SignatureAlgorithm signatureAlgorithm = softwareStatement.getHeader().getSignatureAlgorithm(); final SoftwareStatementValidationType validationType = SoftwareStatementValidationType.fromString(appConfiguration.getSoftwareStatementValidationType()); + printWarningIfNeeded(validationType); + if (validationType == SoftwareStatementValidationType.NONE) { log.trace("software_statement validation was skipped due to `softwareStatementValidationType` configuration property set to none. (Not recommended.)"); return softwareStatement.getClaims().toJsonObject(); @@ -188,6 +231,10 @@ public JSONObject validateSoftwareStatement(HttpServletRequest httpServletReques return validateSoftwareStatementForScript(httpServletRequest, requestObject, softwareStatement, signatureAlgorithm); } + if (validationType == SoftwareStatementValidationType.BUILTIN) { + return ssaValidationConfigService.validateSsaForBuiltIn(softwareStatement); + } + if ((validationType == SoftwareStatementValidationType.JWKS_URI || validationType == SoftwareStatementValidationType.JWKS) && StringUtils.isBlank(appConfiguration.getSoftwareStatementValidationClaimName())) { @@ -231,6 +278,13 @@ public JSONObject validateSoftwareStatement(HttpServletRequest httpServletReques } } + private void printWarningIfNeeded(SoftwareStatementValidationType validationType) { + if (validationType == SoftwareStatementValidationType.SCRIPT || validationType == SoftwareStatementValidationType.BUILTIN) { + return; + } + log.warn("It is strongly recommended to use SCRIPT or BUILTIN value for softwareStatementValidationType configuration property."); + } + @Nullable private JSONObject validateSoftwareStatementForScript(HttpServletRequest httpServletRequest, JSONObject requestObject, Jwt softwareStatement, SignatureAlgorithm signatureAlgorithm) throws CryptoProviderException, InvalidJwtException { if (!externalDynamicClientRegistrationService.isEnabled()) { diff --git a/jans-auth-server/server/src/main/java/io/jans/as/server/register/ws/rs/SsaValidationConfigContext.java b/jans-auth-server/server/src/main/java/io/jans/as/server/register/ws/rs/SsaValidationConfigContext.java new file mode 100644 index 00000000000..51933af23f4 --- /dev/null +++ b/jans-auth-server/server/src/main/java/io/jans/as/server/register/ws/rs/SsaValidationConfigContext.java @@ -0,0 +1,37 @@ +package io.jans.as.server.register.ws.rs; + +import io.jans.as.model.jwt.Jwt; +import io.jans.as.model.ssa.SsaValidationConfig; +import io.jans.as.model.ssa.SsaValidationType; + +/** + * @author Yuriy Zabrovarnyy + */ +public class SsaValidationConfigContext { + + private final Jwt jwt; + private final SsaValidationType type; + + private SsaValidationConfig successfulConfig; + + public SsaValidationConfigContext(Jwt jwt, SsaValidationType type) { + this.jwt = jwt; + this.type = type; + } + + public Jwt getJwt() { + return jwt; + } + + public SsaValidationType getType() { + return type; + } + + public SsaValidationConfig getSuccessfulConfig() { + return successfulConfig; + } + + public void setSuccessfulConfig(SsaValidationConfig successfulConfig) { + this.successfulConfig = successfulConfig; + } +} diff --git a/jans-auth-server/server/src/main/java/io/jans/as/server/register/ws/rs/SsaValidationConfigService.java b/jans-auth-server/server/src/main/java/io/jans/as/server/register/ws/rs/SsaValidationConfigService.java new file mode 100644 index 00000000000..f3fdb9abc06 --- /dev/null +++ b/jans-auth-server/server/src/main/java/io/jans/as/server/register/ws/rs/SsaValidationConfigService.java @@ -0,0 +1,182 @@ +package io.jans.as.server.register.ws.rs; + +import io.jans.as.model.configuration.AppConfiguration; +import io.jans.as.model.crypto.AbstractCryptoProvider; +import io.jans.as.model.crypto.signature.AlgorithmFamily; +import io.jans.as.model.crypto.signature.SignatureAlgorithm; +import io.jans.as.model.exception.CryptoProviderException; +import io.jans.as.model.exception.InvalidJwtException; +import io.jans.as.model.jwt.Jwt; +import io.jans.as.model.jwt.JwtClaimName; +import io.jans.as.model.jwt.JwtClaims; +import io.jans.as.model.register.RegisterRequestParam; +import io.jans.as.model.ssa.SsaValidationConfig; +import io.jans.as.model.ssa.SsaValidationType; +import io.jans.as.model.util.JwtUtil; +import jakarta.ejb.Stateless; +import jakarta.inject.Inject; +import jakarta.inject.Named; +import org.apache.commons.lang.StringUtils; +import org.json.JSONObject; +import org.slf4j.Logger; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import static io.jans.as.model.util.StringUtils.implode; + +/** + * @author Yuriy Z + */ +@Stateless +@Named +public class SsaValidationConfigService { + + @Inject + private AppConfiguration appConfiguration; + + @Inject + private Logger log; + + @Inject + private AbstractCryptoProvider cryptoProvider; + + public List getByIssuer(String issuer, SsaValidationType type) { + if (StringUtils.isBlank(issuer)) { + return new ArrayList<>(); + } + final List all = appConfiguration.getDcrSsaValidationConfigs(); + return all.stream().filter(s -> s.getIssuers().contains(issuer) && s.getType() == type).collect(Collectors.toList()); + } + + public List getByIssuer(Jwt jwt, SsaValidationType type) { + final String issuer = jwt.getClaims().getClaimAsString(JwtClaimName.ISSUER); + return getByIssuer(issuer, type); + } + + public boolean isHmacValid(SsaValidationConfigContext context) { + final List byIssuer = getByIssuer(context.getJwt(), context.getType()); + if (byIssuer.isEmpty()) { + return false; + } + + for (SsaValidationConfig config : byIssuer) { + if (isHmacValid(context.getJwt(), config)) { + context.setSuccessfulConfig(config); + return true; + } + } + return false; + } + + private boolean isHmacValid(Jwt jwt, SsaValidationConfig config) { + String hmacSecret = config.getSharedSecret(); + if (StringUtils.isBlank(hmacSecret)) { + log.trace("No hmacSecret provided in SsaValidationConfig: {}", config); + return false; + } + + final SignatureAlgorithm signatureAlgorithm = jwt.getHeader().getSignatureAlgorithm(); + + try { + boolean validSignature = cryptoProvider.verifySignature(jwt.getSigningInput(), jwt.getEncodedSignature(), null, null, hmacSecret, signatureAlgorithm); + log.trace("Request object validation result: {}, SsaValidationConfig: {}", validSignature, config); + if (validSignature) { + log.trace("Request object is validated successfully. SsaValidationConfig: {}", config); + return true; + } + } catch (CryptoProviderException | InvalidJwtException e) { + log.trace("Unable to validate jwt with ssaValidationConfig: " + config, e); + } + + return false; + } + + public boolean hasValidSignature(SsaValidationConfigContext context) { + final List byIssuer = getByIssuer(context.getJwt(), context.getType()); + if (byIssuer.isEmpty()) { + return false; + } + + for (SsaValidationConfig config : byIssuer) { + if (isSignatureValid(context.getJwt(), config)) { + context.setSuccessfulConfig(config); + return true; + } + } + + return false; + } + + private boolean isSignatureValid(Jwt jwt, SsaValidationConfig config) { + try { + JSONObject jwks = loadJwks(config); + if (jwks == null || jwks.isEmpty()) { + log.error("Unable to load jwks for ssaValidationConfig: {}", config); + return false; + } + + log.trace("Validating request object with jwks: {} ...", jwks); + + return cryptoProvider.verifySignature(jwt.getSigningInput(), + jwt.getEncodedSignature(), jwt.getHeader().getKeyId(), jwks, null, jwt.getHeader().getSignatureAlgorithm()); + } catch (CryptoProviderException | InvalidJwtException e) { + log.trace("Unable to validate jwt with ssaValidationConfig: " + config, e); + } + return false; + } + + private JSONObject loadJwks(SsaValidationConfig config) { + JSONObject jwks = null; + if (StringUtils.isNotBlank(config.getJwksUri())) { + jwks = JwtUtil.getJSONWebKeys(config.getJwksUri()); + } + + if (jwks == null && StringUtils.isNotBlank(config.getJwks())) { + jwks = new JSONObject(config.getJwks()); + } + + if (jwks == null && StringUtils.isNotBlank(config.getConfigurationEndpoint()) && StringUtils.isNotBlank(config.getConfigurationEndpointClaim())) { + final JSONObject responseJson = JwtUtil.getJSONWebKeys(config.getConfigurationEndpoint()); + final String jwksEndpoint = responseJson.optString(config.getConfigurationEndpointClaim()); + if (StringUtils.isNotBlank(jwksEndpoint)) { + jwks = JwtUtil.getJSONWebKeys(jwksEndpoint); + } + } + return jwks; + } + + public JSONObject validateSsaForBuiltIn(Jwt ssa) throws InvalidJwtException { + log.debug("Validating ssa with softwareStatementValidationType=builtin validation ..."); + + final List byIssuer = getByIssuer(ssa, SsaValidationType.SSA); + final SignatureAlgorithm signatureAlgorithm = ssa.getHeader().getSignatureAlgorithm(); + final boolean isHmac = AlgorithmFamily.HMAC.equals(signatureAlgorithm.getFamily()); + + for (SsaValidationConfig config : byIssuer) { + if (isHmac && isHmacValid(ssa, config)) { + return prepareSsaJsonObject(ssa.getClaims(), config); + } + + if (!isHmac && isSignatureValid(ssa, config)) { + return prepareSsaJsonObject(ssa.getClaims(), config); + } + } + + return null; + } + + public JSONObject prepareSsaJsonObject(JwtClaims ssa, SsaValidationConfig config) throws InvalidJwtException { + final JSONObject result = ssa.toJsonObject(); + if (!config.getScopes().isEmpty()) { + log.trace("Set scopes from ssaValidationConfig: {}", config); + result.putOpt(RegisterRequestParam.SCOPE.toString(), implode(config.getScopes(), " ")); + } + if (!config.getAllowedClaims().isEmpty()) { + log.trace("Set claims from ssaValidationConfig: {}", config); + result.putOpt(RegisterRequestParam.CLAIMS.toString(), implode(config.getAllowedClaims(), " ")); + } + return result; + } +} diff --git a/jans-auth-server/server/src/test/java/io/jans/as/server/register/ws/rs/SsaValidationConfigServiceTest.java b/jans-auth-server/server/src/test/java/io/jans/as/server/register/ws/rs/SsaValidationConfigServiceTest.java new file mode 100644 index 00000000000..8ed96e445c5 --- /dev/null +++ b/jans-auth-server/server/src/test/java/io/jans/as/server/register/ws/rs/SsaValidationConfigServiceTest.java @@ -0,0 +1,161 @@ +package io.jans.as.server.register.ws.rs; + +import io.jans.as.model.configuration.AppConfiguration; +import io.jans.as.model.crypto.AbstractCryptoProvider; +import io.jans.as.model.exception.CryptoProviderException; +import io.jans.as.model.exception.InvalidJwtException; +import io.jans.as.model.jwt.Jwt; +import io.jans.as.model.jwt.JwtClaims; +import io.jans.as.model.ssa.SsaValidationConfig; +import io.jans.as.model.ssa.SsaValidationType; +import org.json.JSONObject; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.testng.MockitoTestNGListener; +import org.slf4j.Logger; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Listeners; +import org.testng.annotations.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertFalse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertTrue; + +/** + * @author Yuriy Z + */ +@Listeners(MockitoTestNGListener.class) +public class SsaValidationConfigServiceTest { + + @InjectMocks + @Spy + private SsaValidationConfigService ssaValidationConfigService; + + @Mock + private AppConfiguration appConfiguration; + + @Mock + private Logger log; + + @Mock + private AbstractCryptoProvider cryptoProvider; + + @BeforeMethod + public void setUp() { + SsaValidationConfig one = new SsaValidationConfig(); + one.setId(UUID.randomUUID().toString()); + one.setType(SsaValidationType.DCR); + one.setIssuers(Collections.singletonList("Acme")); + one.setScopes(Arrays.asList("read", "write")); + one.setAllowedClaims(Arrays.asList("exp", "iat")); + one.setJwks("{}"); + one.setSharedSecret("secret"); + + SsaValidationConfig two = new SsaValidationConfig(); + two.setId(UUID.randomUUID().toString()); + two.setType(SsaValidationType.SSA); + two.setIssuers(Collections.singletonList("jans-auth")); + two.setScopes(Arrays.asList("my_read", "my_write")); + two.setAllowedClaims(Arrays.asList("test_exp", "test_iat")); + two.setJwks("{}"); + two.setSharedSecret("secret"); + + SsaValidationConfig three = new SsaValidationConfig(); + three.setId(UUID.randomUUID().toString()); + three.setType(SsaValidationType.SSA); + three.setIssuers(Collections.singletonList("empty")); + three.setJwks("{}"); + three.setSharedSecret("secret"); + + lenient().when(appConfiguration.getDcrSsaValidationConfigs()).thenReturn(Arrays.asList( + one, two, three + )); + } + + @Test + public void getByIssuer_whenCalledWithWrongIssuer_shouldReturnEmptyList() { + List configs = ssaValidationConfigService.getByIssuer("none_existent_issuer", SsaValidationType.DCR); + assertTrue(configs.isEmpty()); + + configs = ssaValidationConfigService.getByIssuer("none_existent_issuer", SsaValidationType.SSA); + assertTrue(configs.isEmpty()); + + configs = ssaValidationConfigService.getByIssuer("none_existent_issuer", SsaValidationType.NONE); + assertTrue(configs.isEmpty()); + + configs = ssaValidationConfigService.getByIssuer("none_existent_issuer", null); + assertTrue(configs.isEmpty()); + } + + @Test + public void getByIssuer_whenCalledExistingIssuer_shouldReturnNonEmptyList() { + List configs = ssaValidationConfigService.getByIssuer("Acme", SsaValidationType.DCR); + assertFalse(configs.isEmpty()); + assertTrue(configs.iterator().next().getIssuers().contains("Acme")); + + configs = ssaValidationConfigService.getByIssuer("jans-auth", SsaValidationType.SSA); + assertFalse(configs.isEmpty()); + assertTrue(configs.iterator().next().getIssuers().contains("jans-auth")); + } + + @Test + public void prepareSsaJsonObject_whenScopesAreSet_shouldOverwriteScopesInResultObject() throws InvalidJwtException { + JwtClaims jwtClaims = new JwtClaims(); + jwtClaims.setClaim("scope", Arrays.asList("scope1", "scope2")); + + SsaValidationConfig config = new SsaValidationConfig(); + config.setScopes(Arrays.asList("config_scope1", "config_scope2")); + + final JSONObject result = ssaValidationConfigService.prepareSsaJsonObject(jwtClaims, config); + assertEquals(result.get("scope"), "config_scope1 config_scope2"); + } + + @Test + public void prepareSsaJsonObject_whenClaimsAreSet_shouldOverwriteClaimsInResultObject() throws InvalidJwtException { + JwtClaims jwtClaims = new JwtClaims(); + jwtClaims.setClaim("claims", Arrays.asList("claim1", "claim2")); + + SsaValidationConfig config = new SsaValidationConfig(); + config.setAllowedClaims(Arrays.asList("config_claim1", "config_claim2")); + + final JSONObject result = ssaValidationConfigService.prepareSsaJsonObject(jwtClaims, config); + assertEquals(result.get("claims"), "config_claim1 config_claim2"); + } + + @Test + public void validateSsaForBuiltIn_whenVerifiedSuccessfullyAndHasScopesAndClaimsSet_shouldOverwriteScopesAndClaims() throws InvalidJwtException, CryptoProviderException { + // jwt with iss=jans-auth + String jwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqYW5zLWF1dGgiLCJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsInNjb3BlIjoic2NvcGUxIHNjb3BlMiIsImNsYWltcyI6ImNsYWltMSBjbGFpbTIiLCJpYXQiOjE2Njk4MDY3NjMsImV4cCI6MTY2OTgxMDM2M30.nR3LURANa5YAxOcLRdeFh0YjHbNA6roIUOhDfvhNeAw"; + + when(cryptoProvider.verifySignature(any(), any(), any(), any(), any(), any())).thenReturn(true); + + final JSONObject result = ssaValidationConfigService.validateSsaForBuiltIn(Jwt.parseOrThrow(jwt)); + assertEquals(result.get("scope"), "my_read my_write"); + assertEquals(result.get("claims"), "test_exp test_iat"); + } + + @Test + public void validateSsaForBuiltIn_whenVerifiedSuccessfullyAndHasNoScopesAndClaimsSet_shouldHaveOriginalJwtScopesAndClaims() throws InvalidJwtException, CryptoProviderException { + // jwt with iss=empty + String jwtString = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJlbXB0eSIsInN1YiI6IjEyMzQ1Njc4OTAiLCJuYW1lIjoiSm9obiBEb2UiLCJhZG1pbiI6dHJ1ZSwic2NvcGUiOiJzY29wZTEgc2NvcGUyIiwiY2xhaW1zIjoiY2xhaW0xIGNsYWltMiIsImlhdCI6MTY2OTgwNjc2MywiZXhwIjoxNjY5ODEwMzYzfQ.db0WQh2lmHkNYCWT8tSW684hqWTPJDTElppy42XM_lc"; + + when(cryptoProvider.verifySignature(any(), any(), any(), any(), any(), any())).thenReturn(true); + + final Jwt jwt = Jwt.parseOrThrow(jwtString); + final Object scope = jwt.getClaims().getClaim("scope"); + final Object claims = jwt.getClaims().getClaim("claims"); + + final JSONObject result = ssaValidationConfigService.validateSsaForBuiltIn(jwt); + assertEquals(result.get("scope"), scope); + assertEquals(result.get("claims"), claims); + } +} diff --git a/jans-auth-server/server/src/test/resources/testng.xml b/jans-auth-server/server/src/test/resources/testng.xml index 328bf6abdf3..fde8aa49b18 100644 --- a/jans-auth-server/server/src/test/resources/testng.xml +++ b/jans-auth-server/server/src/test/resources/testng.xml @@ -24,6 +24,7 @@ +