diff --git a/component/org.wso2.carbon.identity.dpop/README.md b/component/org.wso2.carbon.identity.dpop/README.md new file mode 100644 index 00000000..6728838c --- /dev/null +++ b/component/org.wso2.carbon.identity.dpop/README.md @@ -0,0 +1,78 @@ +# DPoP component + +DPoP ( Demonstrating Proof of Possession ) is an additional security mechanism for the token +generation which overcomes the issue of bearer token which will not validate between who is +requested token and who is actually using the token for the access of a particular resource.The specification defines a mechanism to prevent illegal API calls from succeeding only with a stolen access token. In the traditional mechanism, API access is allowed only if the access token presented by the client application is valid + +## Specification +https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop-02 + +## Design + +### Sequence Diagram. + +1. DPoP token request +![Screenshot from 2021-10-25 23-06-12](https://user-images.githubusercontent.com/26603378/138743329-5cc54271-08a6-44ec-938e-d675bdd24717.png) + + +2. Invoking protected APIs with DPoP token and DPoP proof. +![Invoke API(2)](https://user-images.githubusercontent.com/26603378/138742776-3d2c2714-c87e-4f77-9dce-24fde3df600e.jpeg) + +### Sample client application to create dpop proof +PR : [wso2 /samples-is#302 ](https://github.com/wso2/samples-is/pull/302 ) + +### Deployment Instructions + +1. Build the project using mvn clean install. +2. Add the org.wso2.carbon.identity.dpop-2.4.3-SNAPSHOT.jar JAR into the /repository/components/droppings folder. +3. Add the below configuration to deployment.toml file. + + ``` +[[event_listener]] +id = "dpop_listener" +type = "org.wso2.carbon.identity.core.handler.AbstractIdentityHandler" +name="org.wso2.carbon.identity.dpop.listener.OauthDPoPInterceptorHandlerProxy" +order = 13 +enable = true +properties.header_validity_period = 90 + +[[oauth.custom_token_validator]] +type = "dpop" +class = "org.wso2.carbon.identity.dpop.validators.DPoPTokenValidator" + +[oauth.grant_type.uma_ticket] +retrieve_uma_permission_info_through_introspection = true +``` +4. Restart the Identity Server. +5. Sign in to the Management Console and navigate to + ```Service Providers -> List -> Edit -> Inbound Authentication Configuration ->OAuth OpenID Connect Configuration -> Edit``` +6. Enable DPoP Based Access token binding type and Validate token bindings. + +![Screenshot from 2021-10-25 23-08-05](https://user-images.githubusercontent.com/26603378/138743547-c6d71a23-e654-463b-9650-2cebdf37268d.png) + +Sample dpop token request : +``` +curl --location --request POST 'https://localhost:9443/oauth2/token' \ +--header 'Content-Type: application/x-www-form-urlencoded' \ +--header 'dpop: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IlJTMzg0IiwiandrIjp7Imt0eSI6IlJTQSIsImUiOiJBUUFCIiwibiI6ImgyNlFBSUQtQWhyTmdac2FyX1pmUzM2aUtrTVQ0ZWR2YVJ3eHBheVFSVUlyV29qdENtZ0tCUnRXSllzSmJfQmJ5ZWJnb3gxVXhnaHRjMWNGVFFueVF6aDNHTHRfZXh5ajJ5Y2lFRHhUVTgyTHllT2ZaTnpVQTF0cjBPOFNtdVp4NWxSNnZKYTlMSFFYLXNYdFRsNVBMOWpHNDJVeENnZ3VETG5EZzJUcUMtWmNmdnItMER0OXFJNS1CdVo0TmZTQmE3WlBFeGZ0d2RuemVnRHJOemlfbEFDM0drRUs4dmdHYjFDc0hVS0dUdXZsX0MzX2JtVU50ZzdVdURYdEVyQmRxOHlxc0NHQ3lzSGs5YlBodkZ1bXJaQS1lU0pEQTlpMFYzOUJnaTZUYUpqNU5PZ0hkUFVET0lmUzF6aXE5WlVNR0NRdlJuN3hsN2N2X01MTUNTVElhdyJ9fQ.eyJodG0iOiJQT1NUIiwic3ViIjoic3ViIiwibmJmIjoxNjMyNzUzODQwLCJpc3MiOiJpc3N1ZXIiLCJodHUiOiJodHRwczpcL1wvbG9jYWxob3N0Ojk0NDNcL29hdXRoMlwvdG9rZW4iLCJpYXQiOjE2MzI3NTM4NDAsImp0aSI6IjE2MGVlYjIwLTVjN2EtNDM4ZC05YmI2LTY5ZTY0ZmU2N2ZjYiJ9.IMTqfcHtrlyJM9NqSuVulN2n2yWDgHkzRroxDF764HZrfThoJHp6YAx9PnSRjb652I5agZy48UZehKUiQ-tIXvW-vU8-C_3oeaOIMbTrXKDPHh41_1udw3B_zNkdwOPlyyNgFFRk_vzcV7yV7JdLaJVmMKmbNcqWE5zj7SbvorXhIzhVTL0XKhC1RzcuGImJYwzEUsAp0EWKHmD5Io46WQgY_Qauqzlyat2NYp797yySjfsIXxtFhlv_dsnMwBG4_-qWuwKCWLbUS1dEctwpv3cRqmt3L1ICQK7-t6CorhKy3MxWn7uM0viM7Jm0tjZbz3PYl5aDA55bqUAst9IlsQ' \ +--header 'Authorization: Basic ajdPOWVqbmpUSUN1VFl4cGMwamQ4MjJvU2FjYTpmREJzSXB5djlYS1lOVUxfQWs1QTM0NFh6cUVh' \ +--data-urlencode 'grant_type=password' \ +--data-urlencode 'username=admin' \ +--data-urlencode 'password=admin' \ +--data-urlencode 'scope=openid internal_user_mgt_list' +``` + +Response: + +``` +{ + "access_token": "fbf01348-3e34-3644-a6f3-eebace38fc1b", + "refresh_token": "408ee317-bd4a-388e-bc85-c558bdd7b578", + "scope": "internal_user_mgt_list openid", + "id_token": "eyJ4NXQiOiJNell4TW1Ga09HWXdNV0kwWldObU5EY3hOR1l3WW1NNFpUQTNNV0kyTkRBelpHUXpOR00wWkdSbE5qSmtPREZrWkRSaU9URmtNV0ZoTXpVMlpHVmxOZyIsImtpZCI6Ik16WXhNbUZrT0dZd01XSTBaV05tTkRjeE5HWXdZbU00WlRBM01XSTJOREF6WkdRek5HTTBaR1JsTmpKa09ERmtaRFJpT1RGa01XRmhNelUyWkdWbE5nX1JTMjU2IiwiYWxnIjoiUlMyNTYifQ.eyJhdF9oYXNoIjoiRHBUbjRYbjFYNjdkdGtES1JIeHFyQSIsImF1ZCI6Imo3Tzllam5qVElDdVRZeHBjMGpkODIyb1NhY2EiLCJzdWIiOiJhZG1pbiIsIm5iZiI6MTYzMjc1NDAzMSwiYXpwIjoiajdPOWVqbmpUSUN1VFl4cGMwamQ4MjJvU2FjYSIsImFtciI6WyJwYXNzd29yZCJdLCJpc3MiOiJodHRwczpcL1wvbG9jYWxob3N0Ojk0NDNcL29hdXRoMlwvdG9rZW4iLCJleHAiOjE2MzI3NTc2MzEsImlhdCI6MTYzMjc1NDAzMX0.COAX5moYElnEBl-KRd81GokgtCq8ENz4gHMqdupXff8TW1Xt2GEqahBDxwuk1kQA7Z-pRfIvm-UJ8_h0SHKjf3670FKt6oSwEAVLeJ_esdtFmAbrq-hbnPvp1SVAIfhUp9q3sGT_c6YsU8MTkyIz8BDfl0JHwU26364GO37tHXJ40kTxHVZ8pTHwZj-yVFY1OdPSCsioYd7f3ukh9YWxPrBYsPcvPzSrORfUpzY6U5OmSa4w4YVqLUzVCCZ1qEK2Zk1pPn_w6-vgYt2i7pMWcu3I4pSFfo9E1W89dp4Y2oVFB7rAiH4x0GNoPCmhCWYFIYHRKmcQ1n2sUNZSIn1KsQ", + "token_type": "DPoP", + "expires_in": 3477 +} +``` + + diff --git a/component/org.wso2.carbon.identity.dpop/pom.xml b/component/org.wso2.carbon.identity.dpop/pom.xml new file mode 100644 index 00000000..db544c5e --- /dev/null +++ b/component/org.wso2.carbon.identity.dpop/pom.xml @@ -0,0 +1,151 @@ + + + + + identity-oauth2-extenstions + org.wso2.carbon.extension.identity.oauth.addons + 2.4.3-SNAPSHOT + ../../pom.xml + + 4.0.0 + + org.wso2.carbon.identity.dpop + bundle + + + + org.wso2.carbon.utils + org.wso2.carbon.database.utils + + + org.wso2.eclipse.osgi + org.eclipse.osgi.services + + + org.wso2.orbit.com.nimbusds + nimbus-jose-jwt + + + org.wso2.carbon.identity.inbound.auth.oauth2 + org.wso2.carbon.identity.oauth + + + org.wso2.carbon.identity.inbound.auth.oauth2 + org.wso2.carbon.identity.oauth.common + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.event + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.base + + + com.googlecode.json-simple.wso2 + json-simple + provided + + + org.json.wso2 + json + provided + + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.core + + + org.wso2.carbon.identity.auth.rest + org.wso2.carbon.identity.auth.service + + + org.wso2.carbon + org.wso2.carbon.core.common + + + org.apache.felix + org.apache.felix.scr.ds-annotations + + + + + + + org.apache.felix + maven-bundle-plugin + true + + + org.wso2.carbon.identity.dpop + ${project.artifactId} + + org.wso2.carbon.identity.dpop.internal, + + + com.nimbusds.jose.*; version="${nimbusds.osgi.version.range}", + com.nimbusds.jwt; version="${nimbusds.osgi.version.range}", + javax.servlet.http; version="${javax.servlet.http.package.import.version.range}", + org.osgi.framework; version="${osgi.framework.package.import.version.range}", + + org.json, + org.json.simple, + org.json.simple.parser, + + org.osgi.service.component; + version="${osgi.service.component.package.import.version.range}", + org.apache.commons.logging; + version="${apache.commons.logging.package.import.version.range}", + org.wso2.carbon.identity.oauth.*; + version="${identity.inbound.auth.oauth.imp.pkg.version}", + org.wso2.carbon.identity.oauth2.*; + version="${identity.inbound.auth.oauth.imp.pkg.version}", + org.wso2.carbon.identity.auth.service.*; + version="${identity.carbon.auth.rest.imp.pkg.version}", + org.wso2.carbon.identity.base; + version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.identity.core.*; + version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.user.core.*; + version="${carbon.kernel.package.import.version.range}", + org.wso2.carbon.identity.application.common.*; + version="${carbon.identity.package.import.version.range}", + org.wso2.carbon.utils.multitenancy; + version="${carbon.kernel.package.import.version.range}", + org.apache.catalina.*;version="${apache.catalina.version}", + org.wso2.carbon.database.utils.*; + version="${org.wso2.carbon.database.utils.version.range}", + org.apache.axiom.om.*; version="${axiom.osgi.version.range}", + org.wso2.carbon.idp.mgt; + version="${carbon.identity.framework.imp.pkg.version.range}", + org.wso2.carbon.context.*; version="${carbon.kernel.imp.pkg.version.range}", + net.minidev.json.*; version="${net.minidev.json.imp.pkg.version.range}" + + + !org.wso2.carbon.identity.dpop.internal, + org.wso2.carbon.identity.dpop.*; version="${project.version}" + + + + + + + diff --git a/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/constant/DPoPConstants.java b/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/constant/DPoPConstants.java new file mode 100644 index 00000000..4b19da4f --- /dev/null +++ b/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/constant/DPoPConstants.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2021, WSO2 Inc. (http://www.wso2.org) All Rights Reserved. + * + * WSO2 Inc. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License + */ + +package org.wso2.carbon.identity.dpop.constant; + +/** + * This class defines constants for Oauth2 DPoP validation. + */ +public class DPoPConstants { + + public static final String VALIDITY_PERIOD = "header_validity_period"; + public static final int DEFAULT_HEADER_VALIDITY = 60000; + public static final String DPOP_ISSUED_AT = "iat"; + public static final String DPOP_HTTP_URI = "htu"; + public static final String DPOP_HTTP_METHOD = "htm"; + public static final String DPOP_JWT_TYPE = "dpop+jwt"; + public static final String DPOP_TOKEN_TYPE = "DPoP"; + public static final String INVALID_DPOP_PROOF = "invalid_dpop_proof"; + public static final String INVALID_DPOP_ERROR = "Invalid DPoP Proof"; + public static final String INVALID_CLIENT = "invalid_client"; + public static final String INVALID_CLIENT_ERROR = "Invalid Client"; + public static final String ECDSA_ENCRYPTION = "EC"; + public static final String RSA_ENCRYPTION = "RSA"; + public static final String HTTP_METHOD ="httpMethod"; + public static final String HTTP_URL ="httpUrl"; + public static final String JTI = "jti"; + public static final String OAUTH_DPOP_HEADER = "DPoP"; + public static final String CNF = "cnf"; + public static final String TOKEN_TYPE = "token_type"; + public static final String JWK_THUMBPRINT = "jkt"; + public static final String AUTHORIZATION_HEADER = "authorization"; + + /** + * This class defines SQLQueries. + */ + public static class SQLQueries { + + public static final String RETRIEVE_TOKEN_BINDING_BY_REFRESH_TOKEN = + "SELECT BINDING.TOKEN_BINDING_TYPE,BINDING.TOKEN_BINDING_VALUE,BINDING.TOKEN_BINDING_REF " + + "FROM IDN_OAUTH2_ACCESS_TOKEN TOKEN LEFT JOIN IDN_OAUTH2_TOKEN_BINDING BINDING ON " + + "TOKEN.TOKEN_ID=BINDING.TOKEN_ID WHERE TOKEN.REFRESH_TOKEN = ? " + + "AND BINDING.TOKEN_BINDING_TYPE = ?"; + } +} diff --git a/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/dao/DPoPTokenManagerDAO.java b/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/dao/DPoPTokenManagerDAO.java new file mode 100644 index 00000000..a2ffa21b --- /dev/null +++ b/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/dao/DPoPTokenManagerDAO.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2021, WSO2 Inc. (http://www.wso2.com). + * + * WSO2 Inc. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.wso2.carbon.identity.dpop.dao; + +import org.wso2.carbon.identity.oauth2.IdentityOAuth2Exception; +import org.wso2.carbon.identity.oauth2.token.bindings.TokenBinding; + +/** + * This interface defines methods to access the database for DPoP token purposes. + */ +public interface DPoPTokenManagerDAO { + + /** + * Returns the binding type using the refresh token and checking Hash is enabled or not. + * + * @param refreshToken Refresh token. + * @return TokenBinding from the refresh token. + * @throws IdentityOAuth2Exception If an error occurs while retrieving the binding type. + */ + TokenBinding getTokenBinding(String refreshToken, boolean isHashedToken) throws IdentityOAuth2Exception; +} diff --git a/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/dao/DPoPTokenManagerDAOImpl.java b/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/dao/DPoPTokenManagerDAOImpl.java new file mode 100644 index 00000000..49e9dc8d --- /dev/null +++ b/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/dao/DPoPTokenManagerDAOImpl.java @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2021, WSO2 Inc. (http://www.wso2.com). + * + * WSO2 Inc. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.wso2.carbon.identity.dpop.dao; + +import org.wso2.carbon.database.utils.jdbc.JdbcTemplate; +import org.wso2.carbon.database.utils.jdbc.exceptions.DataAccessException; +import org.wso2.carbon.identity.dpop.constant.DPoPConstants; +import org.wso2.carbon.identity.dpop.util.Utils; +import org.wso2.carbon.identity.oauth.tokenprocessor.HashingPersistenceProcessor; +import org.wso2.carbon.identity.oauth.tokenprocessor.TokenPersistenceProcessor; +import org.wso2.carbon.identity.oauth2.IdentityOAuth2Exception; +import org.wso2.carbon.identity.oauth2.token.bindings.TokenBinding; + +import java.util.List; + +/** + * This class implements {@link DPoPTokenManagerDAO} interface. + */ +public class DPoPTokenManagerDAOImpl implements DPoPTokenManagerDAO { + + private static TokenPersistenceProcessor hashingPersistenceProcessor; + + public DPoPTokenManagerDAOImpl() { + + hashingPersistenceProcessor = new HashingPersistenceProcessor(); + } + + @Override + public TokenBinding getTokenBinding(String refreshToken, boolean isTokenHashingEnabled) throws IdentityOAuth2Exception { + + if (isTokenHashingEnabled) { + return getBindingFromRefreshToken(refreshToken, true); + } + return getBindingFromRefreshToken(refreshToken, false); + } + + private TokenBinding getBindingFromRefreshToken(String refreshToken,boolean isTokenHashingEnabled) throws IdentityOAuth2Exception { + + JdbcTemplate jdbcTemplate = Utils.getNewTemplate(); + if (isTokenHashingEnabled) { + refreshToken = hashingPersistenceProcessor.getProcessedRefreshToken(refreshToken); + } + try { + String finalRefreshToken = refreshToken; + List tokenBindingList = jdbcTemplate.executeQuery( + DPoPConstants.SQLQueries.RETRIEVE_TOKEN_BINDING_BY_REFRESH_TOKEN, + (resultSet, rowNumber) -> { + TokenBinding tokenBinding = new TokenBinding(); + tokenBinding.setBindingType(resultSet.getString(1)); + tokenBinding.setBindingValue(resultSet.getString(2)); + tokenBinding.setBindingReference(resultSet.getString(3)); + + return tokenBinding; + }, + preparedStatement -> { + int parameterIndex = 0; + preparedStatement.setString(++parameterIndex, finalRefreshToken); + preparedStatement.setString(++parameterIndex, DPoPConstants.DPOP_TOKEN_TYPE); + }); + + return tokenBindingList.isEmpty() ? null : tokenBindingList.get(0); + } catch (DataAccessException e) { + String error = String.format("Error obtaining token binding type using refresh token: %s.", + refreshToken); + throw new IdentityOAuth2Exception(error, e); + } + } +} diff --git a/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/handler/DPoPAuthenticationHandler.java b/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/handler/DPoPAuthenticationHandler.java new file mode 100644 index 00000000..47cff4ba --- /dev/null +++ b/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/handler/DPoPAuthenticationHandler.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2021, WSO2 Inc. (http://www.wso2.com). + * + * WSO2 Inc. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.wso2.carbon.identity.dpop.handler; + +import org.apache.commons.lang.StringUtils; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.apache.http.HttpHeaders; +import org.wso2.carbon.identity.auth.service.AuthenticationContext; +import org.wso2.carbon.identity.auth.service.AuthenticationRequest; +import org.wso2.carbon.identity.auth.service.AuthenticationResult; +import org.wso2.carbon.identity.auth.service.AuthenticationStatus; +import org.wso2.carbon.identity.auth.service.exception.AuthenticationFailException; +import org.wso2.carbon.identity.auth.service.handler.AuthenticationHandler; +import org.wso2.carbon.identity.auth.service.util.AuthConfigurationUtil; +import org.wso2.carbon.identity.core.bean.context.MessageContext; +import org.wso2.carbon.identity.dpop.constant.DPoPConstants; +import org.wso2.carbon.identity.oauth2.OAuth2TokenValidationService; +import org.wso2.carbon.identity.oauth2.dto.OAuth2ClientApplicationDTO; +import org.wso2.carbon.identity.oauth2.dto.OAuth2TokenValidationRequestDTO; +import org.wso2.carbon.identity.oauth2.dto.OAuth2TokenValidationResponseDTO; + +import javax.servlet.http.HttpServletRequest; + +/** + * DPoPAuthenticationHandler will validate the requests authorized with DPoP access tokens. + */ +public class DPoPAuthenticationHandler extends AuthenticationHandler { + + private static final Log log = LogFactory.getLog(DPoPAuthenticationHandler.class); + + @Override + protected AuthenticationResult doAuthenticate(MessageContext messageContext) throws + AuthenticationFailException { + + AuthenticationResult authenticationResult = new AuthenticationResult(AuthenticationStatus.FAILED); + AuthenticationContext authenticationContext = (AuthenticationContext) messageContext; + AuthenticationRequest authenticationRequest = authenticationContext.getAuthenticationRequest(); + if (authenticationRequest != null) { + + String authorizationHeader = authenticationRequest.getHeader(HttpHeaders.AUTHORIZATION); + if (StringUtils.isNotBlank(authorizationHeader) && + authorizationHeader.startsWith(DPoPConstants.OAUTH_DPOP_HEADER)) { + String accessToken; + String[] dpopToken = authorizationHeader.split(" "); + if (dpopToken.length != 2) { + String errorMessage = String.format("Error occurred while trying to authenticate." + + "The %s header value is not defined correctly.", DPoPConstants.OAUTH_DPOP_HEADER); + throw new AuthenticationFailException(errorMessage); + } + accessToken = dpopToken[1]; + OAuth2TokenValidationService oAuth2TokenValidationService = new OAuth2TokenValidationService(); + OAuth2TokenValidationRequestDTO requestDTO = new OAuth2TokenValidationRequestDTO(); + OAuth2TokenValidationRequestDTO.OAuth2AccessToken token = requestDTO.new OAuth2AccessToken(); + token.setIdentifier(accessToken); + token.setTokenType(DPoPConstants.OAUTH_DPOP_HEADER); + requestDTO.setAccessToken(token); + setContextParam(authenticationRequest, requestDTO); + OAuth2ClientApplicationDTO clientApplicationDTO = + oAuth2TokenValidationService.findOAuthConsumerIfTokenIsValid(requestDTO); + OAuth2TokenValidationResponseDTO responseDTO = clientApplicationDTO.getAccessTokenValidationResponse(); + if (!responseDTO.isValid()) { + if (log.isDebugEnabled()) { + log.debug(responseDTO.getErrorMsg()); + } + return authenticationResult; + } + authenticationResult.setAuthenticationStatus(AuthenticationStatus.SUCCESS); + return authenticationResult; + } + } + return authenticationResult; + } + + @Override + public int getPriority(MessageContext messageContext) { + + return getPriority(messageContext, 24); + } + + @Override + public boolean canHandle(MessageContext messageContext) { + + return AuthConfigurationUtil.isAuthHeaderMatch(messageContext, DPoPConstants.OAUTH_DPOP_HEADER); + } + + private void setContextParam(AuthenticationRequest authenticationRequest, + OAuth2TokenValidationRequestDTO requestDTO) { + + HttpServletRequest request = authenticationRequest.getRequest(); + String dpopHeader = request.getHeader(DPoPConstants.OAUTH_DPOP_HEADER); + + OAuth2TokenValidationRequestDTO.TokenValidationContextParam httpMethod = requestDTO.new + TokenValidationContextParam(); + httpMethod.setKey(DPoPConstants.HTTP_METHOD); + httpMethod.setValue(request.getMethod()); + + OAuth2TokenValidationRequestDTO.TokenValidationContextParam httpURL = requestDTO.new + TokenValidationContextParam(); + httpURL.setKey(DPoPConstants.HTTP_URL); + httpURL.setValue(String.valueOf(request.getRequestURL())); + + OAuth2TokenValidationRequestDTO.TokenValidationContextParam dpopProof = requestDTO.new + TokenValidationContextParam(); + dpopProof.setKey(DPoPConstants.OAUTH_DPOP_HEADER); + dpopProof.setValue(dpopHeader); + + OAuth2TokenValidationRequestDTO.TokenValidationContextParam[] contextParams = + new OAuth2TokenValidationRequestDTO.TokenValidationContextParam[3]; + contextParams[0] = httpMethod; + contextParams[1] = httpURL; + contextParams[2] = dpopProof; + requestDTO.setContext(contextParams); + } +} diff --git a/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/internal/DPoPDataHolder.java b/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/internal/DPoPDataHolder.java new file mode 100644 index 00000000..9d9b7e1b --- /dev/null +++ b/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/internal/DPoPDataHolder.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2021, WSO2 Inc. (http://www.wso2.com). + * + * WSO2 Inc. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.wso2.carbon.identity.dpop.internal; + +import org.wso2.carbon.identity.dpop.dao.DPoPTokenManagerDAO; + +/** + * DPoP data holder. + */ +public class DPoPDataHolder { + + private static final DPoPDataHolder dPoPDataHolder = new DPoPDataHolder(); + private DPoPTokenManagerDAO tokenBindingTypeManagerDao; + + public static DPoPDataHolder getInstance() { + + return dPoPDataHolder; + } + + public static DPoPDataHolder getDPoPDataHolder() { + + return dPoPDataHolder; + } + + /** + * Get Token binding type manager dao. + * + * @return TokenBindingTypeManagerDao + */ + public DPoPTokenManagerDAO getTokenBindingTypeManagerDao() { + + return tokenBindingTypeManagerDao; + } + + /** + * Set Token binding type manager dao. + * + * @param tokenBindingTypeManagerDao TokenBindingTypeManagerDao + */ + public void setTokenBindingTypeManagerDao( + DPoPTokenManagerDAO tokenBindingTypeManagerDao) { + + this.tokenBindingTypeManagerDao = tokenBindingTypeManagerDao; + } +} diff --git a/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/internal/DPoPServiceComponent.java b/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/internal/DPoPServiceComponent.java new file mode 100644 index 00000000..f6d73693 --- /dev/null +++ b/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/internal/DPoPServiceComponent.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2021, WSO2 Inc. (http://www.wso2.org) All Rights Reserved. + * + * WSO2 Inc. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License + */ + +package org.wso2.carbon.identity.dpop.internal; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.osgi.service.component.ComponentContext; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.wso2.carbon.identity.auth.service.handler.AuthenticationHandler; +import org.wso2.carbon.identity.dpop.dao.DPoPTokenManagerDAOImpl; +import org.wso2.carbon.identity.dpop.handler.DPoPAuthenticationHandler; +import org.wso2.carbon.identity.dpop.introspection.dataprovider.DPoPIntrospectionDataProvider; +import org.wso2.carbon.identity.dpop.listener.OauthDPoPInterceptorHandlerProxy; +import org.wso2.carbon.identity.dpop.token.binder.DPoPBasedTokenBinder; +import org.wso2.carbon.identity.dpop.validators.DPoPTokenValidator; +import org.wso2.carbon.identity.oauth.common.token.bindings.TokenBinderInfo; +import org.wso2.carbon.identity.oauth.event.OAuthEventInterceptor; +import org.wso2.carbon.identity.oauth2.IntrospectionDataProvider; +import org.wso2.carbon.identity.oauth2.validators.OAuth2TokenValidator; + +@Component( + name = "org.wso2.carbon.identity.oauth.dpop", + immediate = true) +public class DPoPServiceComponent { + + private static final Log log = LogFactory.getLog(DPoPServiceComponent.class); + + @Activate + protected void activate(ComponentContext context) { + + try { + DPoPDataHolder.getInstance().setTokenBindingTypeManagerDao(new DPoPTokenManagerDAOImpl()); + context.getBundleContext().registerService(TokenBinderInfo.class.getName(), + new DPoPBasedTokenBinder(), null); + context.getBundleContext().registerService(OAuthEventInterceptor.class, + new OauthDPoPInterceptorHandlerProxy(), null); + context.getBundleContext().registerService(AuthenticationHandler.class.getName(), + new DPoPAuthenticationHandler(), null); + context.getBundleContext().registerService(IntrospectionDataProvider.class.getName(), + new DPoPIntrospectionDataProvider(), null); + context.getBundleContext().registerService(OAuth2TokenValidator.class.getName(), + new DPoPTokenValidator(), null); + if (log.isDebugEnabled()) { + log.debug("DPoPService is activated."); + } + } catch (Throwable e) { + log.error("Error while activating DPoPServiceComponent.", e); + } + } +} diff --git a/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/introspection/dataprovider/DPoPIntrospectionDataProvider.java b/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/introspection/dataprovider/DPoPIntrospectionDataProvider.java new file mode 100644 index 00000000..ac480c61 --- /dev/null +++ b/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/introspection/dataprovider/DPoPIntrospectionDataProvider.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2021, WSO2 Inc. (http://www.wso2.com). + * + * WSO2 Inc. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.wso2.carbon.identity.dpop.introspection.dataprovider; + +import org.json.simple.JSONObject; +import org.wso2.carbon.identity.core.handler.AbstractIdentityHandler; +import org.wso2.carbon.identity.dpop.constant.DPoPConstants; +import org.wso2.carbon.identity.oauth2.IdentityOAuth2Exception; +import org.wso2.carbon.identity.oauth2.IntrospectionDataProvider; +import org.wso2.carbon.identity.oauth2.dto.OAuth2IntrospectionResponseDTO; +import org.wso2.carbon.identity.oauth2.dto.OAuth2TokenValidationRequestDTO; +import org.wso2.carbon.identity.oauth2.model.AccessTokenDO; +import org.wso2.carbon.identity.oauth2.util.OAuth2Util; + +import java.util.HashMap; +import java.util.Map; + +/** + * Introspection Data provider to include cnf to introspection response. + */ +public class DPoPIntrospectionDataProvider extends AbstractIdentityHandler implements IntrospectionDataProvider { + + @Override + public Map getIntrospectionData(OAuth2TokenValidationRequestDTO oAuth2TokenValidationRequestDTO, + OAuth2IntrospectionResponseDTO oAuth2IntrospectionResponseDTO) + throws IdentityOAuth2Exception { + + Map introspectionData = new HashMap<>(); + AccessTokenDO accessTokenDO; + + if (isEnabled()) { + + accessTokenDO = OAuth2Util.findAccessToken(oAuth2TokenValidationRequestDTO. + getAccessToken().getIdentifier(), false); + + if (accessTokenDO.getTokenBinding() != null && + DPoPConstants.DPOP_TOKEN_TYPE.equals(accessTokenDO.getTokenBinding().getBindingType())) { + introspectionData.put(DPoPConstants.TOKEN_TYPE, (DPoPConstants.DPOP_TOKEN_TYPE)); + JSONObject cnf = new JSONObject(); + cnf.put(DPoPConstants.JWK_THUMBPRINT, accessTokenDO.getTokenBinding().getBindingValue()); + introspectionData.put(DPoPConstants.CNF, cnf); + } + } + return introspectionData; + } +} diff --git a/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/listener/OauthDPoPInterceptorHandlerProxy.java b/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/listener/OauthDPoPInterceptorHandlerProxy.java new file mode 100644 index 00000000..46f59add --- /dev/null +++ b/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/listener/OauthDPoPInterceptorHandlerProxy.java @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2021, WSO2 Inc. (http://www.wso2.org) All Rights Reserved. + * + * WSO2 Inc. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License + */ + +package org.wso2.carbon.identity.dpop.listener; + +import org.apache.commons.lang.StringUtils; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.wso2.carbon.identity.core.handler.AbstractIdentityHandler; +import org.wso2.carbon.identity.core.model.IdentityEventListenerConfig; +import org.wso2.carbon.identity.core.util.IdentityUtil; +import org.wso2.carbon.identity.dpop.constant.DPoPConstants; +import org.wso2.carbon.identity.dpop.dao.DPoPTokenManagerDAO; +import org.wso2.carbon.identity.dpop.internal.DPoPDataHolder; +import org.wso2.carbon.identity.dpop.validators.DPoPHeaderValidator; +import org.wso2.carbon.identity.oauth.common.exception.InvalidOAuthClientException; +import org.wso2.carbon.identity.oauth.event.AbstractOAuthEventInterceptor; +import org.wso2.carbon.identity.oauth2.IdentityOAuth2ClientException; +import org.wso2.carbon.identity.oauth2.IdentityOAuth2Exception; +import org.wso2.carbon.identity.oauth2.dto.OAuth2AccessTokenReqDTO; +import org.wso2.carbon.identity.oauth2.dto.OAuth2AccessTokenRespDTO; +import org.wso2.carbon.identity.oauth2.token.OAuthTokenReqMessageContext; +import org.wso2.carbon.identity.oauth2.token.bindings.TokenBinding; +import org.wso2.carbon.identity.oauth2.util.OAuth2Util; + +import java.util.Map; + +/** + * This class extends {@link AbstractOAuthEventInterceptor} and listen to oauth token related events. + * In this class, DPoP proof validation will be handled for DPoP token requests. + */ +public class OauthDPoPInterceptorHandlerProxy extends AbstractOAuthEventInterceptor { + + private static final Log log = LogFactory.getLog(OauthDPoPInterceptorHandlerProxy.class); + private DPoPTokenManagerDAO + tokenBindingTypeManagerDao = DPoPDataHolder.getInstance().getTokenBindingTypeManagerDao(); + + /** + * {@inheritdoc} + */ + @Override + public void onPreTokenIssue(OAuth2AccessTokenReqDTO tokenReqDTO, OAuthTokenReqMessageContext tokReqMsgCtx, + Map params) throws IdentityOAuth2Exception { + + String consumerKey = tokenReqDTO.getClientId(); + if (log.isDebugEnabled()) { + log.debug(String.format("DPoP proxy intercepted the token request from the client : %s.", consumerKey)); + } + try { + String tokenBindingType = DPoPHeaderValidator.getApplicationBindingType(tokenReqDTO.getClientId()); + if (DPoPConstants.DPOP_TOKEN_TYPE.equals(tokenBindingType)) { + + String dPoPProof = DPoPHeaderValidator.getDPoPHeader(tokReqMsgCtx); + if (StringUtils.isBlank(dPoPProof)) { + throw new IdentityOAuth2ClientException(DPoPConstants.INVALID_DPOP_PROOF, + "DPoP header is required."); + } + boolean isValidDPoP = DPoPHeaderValidator.isValidDPoP(dPoPProof, tokenReqDTO, tokReqMsgCtx); + if (!isValidDPoP) { + if (log.isDebugEnabled()) { + log.debug(String.format("DPoP proof validation failed, Application ID: %s.", consumerKey)); + } + throw new IdentityOAuth2ClientException(DPoPConstants.INVALID_DPOP_PROOF, + DPoPConstants.INVALID_DPOP_ERROR); + } + } else { + if (log.isDebugEnabled()) { + log.debug(String.format("Bearer access token request received from client: %s.", consumerKey)); + } + } + } catch (InvalidOAuthClientException e) { + throw new IdentityOAuth2ClientException(DPoPConstants.INVALID_CLIENT, DPoPConstants.INVALID_CLIENT_ERROR); + } + } + + /** + * {@inheritdoc} + */ + @Override + public void onPreTokenRenewal(OAuth2AccessTokenReqDTO tokenReqDTO, OAuthTokenReqMessageContext tokReqMsgCtx, + Map params) throws IdentityOAuth2Exception { + + String consumerKey = tokenReqDTO.getClientId(); + if (log.isDebugEnabled()) { + log.debug(String.format("DPoP proxy intercepted the token renewal request from the client : %s.", + consumerKey)); + } + try { + String tokenBindingType = DPoPHeaderValidator.getApplicationBindingType(tokenReqDTO.getClientId()); + TokenBinding tokenBinding = + tokenBindingTypeManagerDao.getTokenBinding(tokenReqDTO.getRefreshToken(), OAuth2Util.isHashEnabled()); + if (tokenBinding != null) { + if (!DPoPConstants.DPOP_TOKEN_TYPE.equals(tokenBindingType)) { + if (log.isDebugEnabled()) { + log.debug(String.format("DPoP based token binding is not enabled for the " + + "application Id : %s.", consumerKey)); + } + throw new IdentityOAuth2ClientException(DPoPConstants.INVALID_CLIENT, + DPoPConstants.INVALID_CLIENT_ERROR); + } + + String dPoPProof = DPoPHeaderValidator.getDPoPHeader(tokReqMsgCtx); + if (StringUtils.isBlank(dPoPProof)) { + if (log.isDebugEnabled()) { + log.debug(String.format("Renewal request received without the DPoP proof from the " + + "application Id: %s.", consumerKey)); + } + throw new IdentityOAuth2ClientException(DPoPConstants.INVALID_DPOP_PROOF, + "DPoP proof is required."); + } + + if (!DPoPHeaderValidator.isValidDPoP(dPoPProof, tokenReqDTO, tokReqMsgCtx)) { + if (log.isDebugEnabled()) { + log.debug(String.format("DPoP proof validation failed for the application Id : %s.", + consumerKey)); + } + throw new IdentityOAuth2ClientException(DPoPConstants.INVALID_DPOP_PROOF, + DPoPConstants.INVALID_DPOP_ERROR); + } + if (!tokReqMsgCtx.getTokenBinding().getBindingValue().equalsIgnoreCase(tokenBinding.getBindingValue())) { + if (log.isDebugEnabled()) { + log.debug("DPoP proof thumbprint value of the public key is not equal to binding value from" + + " the refresh token."); + } + throw new IdentityOAuth2ClientException(DPoPConstants.INVALID_DPOP_PROOF, + DPoPConstants.INVALID_DPOP_ERROR); + } + } + } catch (InvalidOAuthClientException e) { + throw new IdentityOAuth2ClientException(DPoPConstants.INVALID_CLIENT, DPoPConstants.INVALID_CLIENT_ERROR); + } + } + + @Override + public boolean isEnabled() { + + IdentityEventListenerConfig identityEventListenerConfig = IdentityUtil.readEventListenerProperty + (AbstractIdentityHandler.class.getName(), this.getClass().getName()); + return identityEventListenerConfig != null && Boolean.parseBoolean(identityEventListenerConfig.getEnable()); + } + + /** + * {@inheritdoc} + */ + @Override + public void onPostTokenIssue(OAuth2AccessTokenReqDTO tokenReqDTO, OAuth2AccessTokenRespDTO tokenRespDTO, + OAuthTokenReqMessageContext tokReqMsgCtx, Map params) { + + setDPoPTokenType(tokReqMsgCtx,tokenRespDTO); + } + + /** + * {@inheritdoc} + */ + @Override + public void onPostTokenRenewal(OAuth2AccessTokenReqDTO tokenReqDTO, OAuth2AccessTokenRespDTO tokenRespDTO, + OAuthTokenReqMessageContext tokReqMsgCtx, Map params) { + setDPoPTokenType(tokReqMsgCtx,tokenRespDTO); + + } + + private void setDPoPTokenType(OAuthTokenReqMessageContext tokReqMsgCtx,OAuth2AccessTokenRespDTO tokenRespDTO){ + if (tokReqMsgCtx.getTokenBinding() != null && + DPoPConstants.DPOP_TOKEN_TYPE.equals(tokReqMsgCtx.getTokenBinding().getBindingType())) { + tokenRespDTO.setTokenType(DPoPConstants.DPOP_TOKEN_TYPE); + } + } +} diff --git a/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/token/binder/DPoPBasedTokenBinder.java b/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/token/binder/DPoPBasedTokenBinder.java new file mode 100644 index 00000000..9efe090f --- /dev/null +++ b/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/token/binder/DPoPBasedTokenBinder.java @@ -0,0 +1,244 @@ +/* + * Copyright (c) 2021, WSO2 Inc. (http://www.wso2.com). + * + * WSO2 Inc. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.wso2.carbon.identity.dpop.token.binder; + +import org.apache.commons.lang.ArrayUtils; +import org.apache.commons.lang.StringUtils; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.wso2.carbon.identity.dpop.constant.DPoPConstants; +import org.wso2.carbon.identity.dpop.dao.DPoPTokenManagerDAO; +import org.wso2.carbon.identity.dpop.internal.DPoPDataHolder; +import org.wso2.carbon.identity.dpop.util.Utils; +import org.wso2.carbon.identity.dpop.validators.DPoPHeaderValidator; +import org.wso2.carbon.identity.oauth.common.OAuthConstants.GrantTypes; +import org.wso2.carbon.identity.oauth2.IdentityOAuth2Exception; +import org.wso2.carbon.identity.oauth2.dto.OAuth2AccessTokenReqDTO; +import org.wso2.carbon.identity.oauth2.model.HttpRequestHeader; +import org.wso2.carbon.identity.oauth2.token.bindings.TokenBinding; +import org.wso2.carbon.identity.oauth2.token.bindings.impl.AbstractTokenBinder; +import org.wso2.carbon.identity.oauth2.util.OAuth2Util; + +import java.text.ParseException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * This class provides the DPoP based token binder implementation. + */ +public class DPoPBasedTokenBinder extends AbstractTokenBinder { + + private static final String BINDING_TYPE = "DPoP"; + private static final Log log = LogFactory.getLog(DPoPBasedTokenBinder.class); + private final List supportedGrantTypes = Arrays.asList(GrantTypes.AUTHORIZATION_CODE, GrantTypes.PASSWORD, + GrantTypes.CLIENT_CREDENTIALS, GrantTypes.REFRESH_TOKEN); + private DPoPTokenManagerDAO + tokenBindingTypeManagerDao = DPoPDataHolder.getInstance().getTokenBindingTypeManagerDao(); + + @Override + public String getDisplayName() { + + return "DPoP Based"; + } + + @Override + public String getDescription() { + + return "Bind tokens as DPoP tokens."; + } + + @Override + public String getBindingType() { + + return BINDING_TYPE; + } + + @Override + public List getSupportedGrantTypes() { + + return Collections.unmodifiableList(supportedGrantTypes); + } + + @Override + public String getOrGenerateTokenBindingValue(HttpServletRequest request) { + + return null; + } + + @Override + public void setTokenBindingValueForResponse(HttpServletResponse response, String bindingValue) { + + // Not required. + } + + @Override + public void clearTokenBindingElements(HttpServletRequest request, HttpServletResponse response) { + + // Not required. + } + + @Override + public boolean isValidTokenBinding(Object request, String bindingReference) { + + return true; + } + + @Override + public boolean isValidTokenBinding(Object request, TokenBinding tokenBinding) { + + try { + if (tokenBinding != null && DPoPConstants.OAUTH_DPOP_HEADER.equals(tokenBinding.getBindingType())) { + return validateDPoPHeader(request, tokenBinding); + } + } catch (IdentityOAuth2Exception | ParseException e) { + log.error("Error while getting the token binding value", e); + return false; + } + return false; + } + + @Override + public boolean isValidTokenBinding(OAuth2AccessTokenReqDTO oAuth2AccessTokenReqDTO, String bindingReference) { + + if (StringUtils.isBlank(bindingReference)) { + return false; + } + + String refreshToken = oAuth2AccessTokenReqDTO.getRefreshToken(); + try { + TokenBinding tokenBinding = + tokenBindingTypeManagerDao.getTokenBinding(refreshToken, OAuth2Util.isHashEnabled()); + + if (tokenBinding != null && DPoPConstants.OAUTH_DPOP_HEADER.equals(tokenBinding.getBindingType())) { + return bindingReference.equalsIgnoreCase(tokenBinding.getBindingReference()); + } + return false; + } catch (IdentityOAuth2Exception e) { + return false; + } + } + + @Override + public String getTokenBindingValue(HttpServletRequest request) { + + try { + String tokenBindingValue = retrieveTokenBindingValueFromDPoPHeader(request); + + if (StringUtils.isNotBlank(tokenBindingValue)) { + return tokenBindingValue; + } + return null; + } catch (IdentityOAuth2Exception e) { + return null; + } + } + + @Override + public Optional getTokenBindingValue(OAuth2AccessTokenReqDTO oAuth2AccessTokenReqDTO) { + + HttpRequestHeader[] httpRequestHeaders = oAuth2AccessTokenReqDTO.getHttpRequestHeaders(); + if (ArrayUtils.isEmpty(httpRequestHeaders)) { + return Optional.empty(); + } + for (HttpRequestHeader httpRequestHeader : httpRequestHeaders) { + if (DPoPConstants.OAUTH_DPOP_HEADER.equalsIgnoreCase(httpRequestHeader.getName())) { + if (ArrayUtils.isEmpty(httpRequestHeader.getValue())) { + return Optional.empty(); + } + + String dpopProof = httpRequestHeader.getValue()[0]; + if (StringUtils.isEmpty(dpopProof)) { + return Optional.empty(); + } + + try { + String thumbprintOfPublicKey = Utils.getThumbprintOfKeyFromDpopProof(dpopProof); + return Optional.of(thumbprintOfPublicKey); + } catch (IdentityOAuth2Exception e) { + return Optional.empty(); + } + } + } + return Optional.empty(); + } + + private String retrieveTokenBindingValueFromDPoPHeader(HttpServletRequest request) throws IdentityOAuth2Exception { + + String dpopProof = request.getHeader(DPoPConstants.OAUTH_DPOP_HEADER); + if (StringUtils.isBlank(dpopProof)) { + return null; + } + + String thumbprintOfPublicKey = Utils.getThumbprintOfKeyFromDpopProof(dpopProof); + if (StringUtils.isBlank(thumbprintOfPublicKey)) { + return null; + } + return thumbprintOfPublicKey; + } + + private boolean validateDPoPHeader(Object request, TokenBinding tokenBinding) throws IdentityOAuth2Exception, + ParseException { + + if (!((HttpServletRequest) request).getHeader(DPoPConstants.AUTHORIZATION_HEADER) + .startsWith(DPoPConstants.OAUTH_DPOP_HEADER)) { + if (log.isDebugEnabled()) { + log.debug("DPoP prefix is not defined correctly in the Authorization header."); + } + return false; + } + + String dpopHeader = ((HttpServletRequest) request).getHeader(DPoPConstants.OAUTH_DPOP_HEADER); + + if (StringUtils.isBlank(dpopHeader)) { + if (log.isDebugEnabled()) { + log.debug("DPoP header is empty."); + } + return false; + + } + + String httpMethod = (((HttpServletRequest) request).getMethod()); + String httpUrl = (((HttpServletRequest) request).getRequestURL().toString()); + if (!DPoPHeaderValidator.isValidDPoPProof(httpMethod, httpUrl, dpopHeader)) { + return false; + } + + String thumbprintOfPublicKey = Utils.getThumbprintOfKeyFromDpopProof(dpopHeader); + + if (StringUtils.isBlank(thumbprintOfPublicKey)) { + if (log.isDebugEnabled()) { + log.debug("Thumbprint value of the public key is empty in the DPoP Proof."); + } + return false; + } + + if (!thumbprintOfPublicKey.equalsIgnoreCase(tokenBinding.getBindingValue())) { + if (log.isDebugEnabled()) { + log.debug("Thumbprint value of the public key in the DPoP proof is not equal to binding value" + + " of the responseDTO."); + } + return false; + } + return true; + } +} diff --git a/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/util/Utils.java b/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/util/Utils.java new file mode 100644 index 00000000..ab439e9a --- /dev/null +++ b/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/util/Utils.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2021, WSO2 Inc. (http://www.wso2.com). + * + * WSO2 Inc. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.wso2.carbon.identity.dpop.util; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSVerifier; +import com.nimbusds.jose.crypto.ECDSAVerifier; +import com.nimbusds.jose.crypto.RSASSAVerifier; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jwt.SignedJWT; +import org.apache.axiom.om.OMElement; +import org.apache.commons.lang.StringUtils; +import org.wso2.carbon.database.utils.jdbc.JdbcTemplate; +import org.wso2.carbon.identity.core.persistence.UmPersistenceManager; +import org.wso2.carbon.identity.core.util.IdentityConfigParser; +import org.wso2.carbon.identity.core.util.IdentityCoreConstants; +import org.wso2.carbon.identity.dpop.constant.DPoPConstants; +import org.wso2.carbon.identity.oauth2.IdentityOAuth2ClientException; +import org.wso2.carbon.identity.oauth2.IdentityOAuth2Exception; + +import java.security.interfaces.ECPublicKey; +import java.security.interfaces.RSAPublicKey; +import java.text.ParseException; +import javax.xml.namespace.QName; + +/** + * This class provides utility functions for dpop implementation. + */ +public class Utils { + + public static JdbcTemplate getNewTemplate() { + + return new JdbcTemplate(UmPersistenceManager.getInstance().getDataSource()); + } + + /** + * Get thumbprint value from the jwk header parameter in the dpop proof. + * + * @param dPopProof DPoP proof header. + * @return Thumbprint value. + * @throws IdentityOAuth2ClientException Error while getting the thumbprint value. + */ + public static String getThumbprintOfKeyFromDpopProof(String dPopProof) throws IdentityOAuth2Exception { + + try { + SignedJWT signedJwt = SignedJWT.parse(dPopProof); + JWSHeader header = signedJwt.getHeader(); + return getKeyThumbprintOfKey(header.getJWK().toString(), signedJwt); + } catch (ParseException | JOSEException e) { + throw new IdentityOAuth2ClientException(DPoPConstants.INVALID_DPOP_PROOF, DPoPConstants.INVALID_DPOP_ERROR); + } + } + + private static String getKeyThumbprintOfKey(String jwk, SignedJWT signedJwt) + throws ParseException, JOSEException { + + JWK parseJwk = JWK.parse(jwk); + boolean validSignature; + if (DPoPConstants.ECDSA_ENCRYPTION.equalsIgnoreCase(String.valueOf(parseJwk.getKeyType()))) { + ECKey ecKey = (ECKey) parseJwk; + ECPublicKey ecPublicKey = ecKey.toECPublicKey(); + validSignature = verifySignatureWithPublicKey(new ECDSAVerifier(ecPublicKey), signedJwt); + if (validSignature) { + return computeThumbprintOfECKey(ecKey); + } + } else if (DPoPConstants.RSA_ENCRYPTION.equalsIgnoreCase(String.valueOf(parseJwk.getKeyType()))) { + RSAKey rsaKey = (RSAKey) parseJwk; + RSAPublicKey rsaPublicKey = rsaKey.toRSAPublicKey(); + validSignature = verifySignatureWithPublicKey(new RSASSAVerifier(rsaPublicKey), signedJwt); + if (validSignature) { + return computeThumbprintOfRSAKey(rsaKey); + } + } + return StringUtils.EMPTY; + } + + private static String computeThumbprintOfRSAKey(RSAKey rsaKey) throws JOSEException { + + return rsaKey.computeThumbprint().toString(); + } + + private static String computeThumbprintOfECKey(ECKey ecKey) throws JOSEException { + + return ecKey.computeThumbprint().toString(); + } + + private static boolean verifySignatureWithPublicKey(JWSVerifier jwsVerifier, SignedJWT signedJwt) + throws JOSEException { + + return signedJwt.verify(jwsVerifier); + } +} diff --git a/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/validators/DPoPHeaderValidator.java b/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/validators/DPoPHeaderValidator.java new file mode 100644 index 00000000..789c57e4 --- /dev/null +++ b/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/validators/DPoPHeaderValidator.java @@ -0,0 +1,285 @@ +/* + * Copyright (c) 2021, WSO2 Inc. (http://www.wso2.com). + * + * WSO2 Inc. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.wso2.carbon.identity.dpop.validators; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import org.apache.commons.codec.digest.DigestUtils; +import org.apache.commons.lang.ArrayUtils; +import org.apache.commons.lang.StringUtils; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.json.simple.JSONObject; +import org.wso2.carbon.identity.core.handler.AbstractIdentityHandler; +import org.wso2.carbon.identity.core.util.IdentityUtil; +import org.wso2.carbon.identity.dpop.constant.DPoPConstants; +import org.wso2.carbon.identity.dpop.listener.OauthDPoPInterceptorHandlerProxy; +import org.wso2.carbon.identity.dpop.util.Utils; +import org.wso2.carbon.identity.oauth.common.exception.InvalidOAuthClientException; +import org.wso2.carbon.identity.oauth.dao.OAuthAppDO; +import org.wso2.carbon.identity.oauth2.IdentityOAuth2ClientException; +import org.wso2.carbon.identity.oauth2.IdentityOAuth2Exception; +import org.wso2.carbon.identity.oauth2.dto.OAuth2AccessTokenReqDTO; +import org.wso2.carbon.identity.oauth2.model.HttpRequestHeader; +import org.wso2.carbon.identity.oauth2.token.OAuthTokenReqMessageContext; +import org.wso2.carbon.identity.oauth2.token.bindings.TokenBinding; +import org.wso2.carbon.identity.oauth2.util.OAuth2Util; + +import java.sql.Timestamp; +import java.text.ParseException; +import java.util.Date; + +import javax.servlet.http.HttpServletRequest; + +/** + * DPoP Header validator. + */ +public class DPoPHeaderValidator { + + static final Log log = LogFactory.getLog(DPoPHeaderValidator.class); + + /** + * Extract DPoP header from the headers. + * + * @param tokReqMsgCtx Message context of token request. + * @return DPoP header. + */ + public static String getDPoPHeader(OAuthTokenReqMessageContext tokReqMsgCtx) { + + HttpRequestHeader[] httpRequestHeaders = tokReqMsgCtx.getOauth2AccessTokenReqDTO().getHttpRequestHeaders(); + if (httpRequestHeaders != null) { + for (HttpRequestHeader header : httpRequestHeaders) { + if (header != null && DPoPConstants.OAUTH_DPOP_HEADER.equalsIgnoreCase(header.getName())) { + return ArrayUtils.isNotEmpty(header.getValue()) ? header.getValue()[0] : null; + } + } + } + return StringUtils.EMPTY; + } + + /** + * Get Oauth application Access token binding type. + * + * @param consumerKey Consumer Key. + * @return Access token binding type of the oauth application. + * @throws InvalidOAuthClientException Error while getting the Oauth application information. + * @throws IdentityOAuth2Exception Error while getting the Oauth application information. + */ + public static String getApplicationBindingType(String consumerKey) throws + IdentityOAuth2Exception, InvalidOAuthClientException { + + OAuthAppDO oauthAppDO = OAuth2Util.getAppInformationByClientId(consumerKey); + return oauthAppDO.getTokenBindingType(); + } + + /** + * Validate dpop proof header. + * + * @param httpMethod HTTP method of the request. + * @param httpURL HTTP URL of the request, + * @param dPoPProof DPoP header of the request. + * @return + * @throws ParseException Error while retrieving the signedJwt. + * @throws IdentityOAuth2Exception Error while validating the dpop proof. + */ + public static boolean isValidDPoPProof(String httpMethod, String httpURL, String dPoPProof) + throws ParseException, IdentityOAuth2Exception { + + SignedJWT signedJwt = SignedJWT.parse(dPoPProof); + JWSHeader header = signedJwt.getHeader(); + + return validateDPoPPayload(httpMethod, httpURL, signedJwt.getJWTClaimsSet()) && validateDPoPHeader(header); + } + + /** + * Set token binder information if dpop proof is valid. + * + * @param dPoPProof DPoP proof header. + * @param tokenReqDTO Token request dto. + * @param tokReqMsgCtx Message context of token request. + * @return + * @throws IdentityOAuth2Exception Error while validating the dpop proof. + */ + public static boolean isValidDPoP(String dPoPProof, OAuth2AccessTokenReqDTO tokenReqDTO, + OAuthTokenReqMessageContext tokReqMsgCtx) throws IdentityOAuth2Exception { + + try { + HttpServletRequest request = tokenReqDTO.getHttpServletRequestWrapper(); + String httpMethod = request.getMethod(); + String httpURL = request.getRequestURL().toString(); + if (isValidDPoPProof(httpMethod, httpURL, dPoPProof)) { + String thumbprint = Utils.getThumbprintOfKeyFromDpopProof(dPoPProof); + if (StringUtils.isNotBlank(thumbprint)) { + TokenBinding tokenBinding = new TokenBinding(); + tokenBinding.setBindingType(DPoPConstants.DPOP_TOKEN_TYPE); + tokenBinding.setBindingValue(thumbprint); + tokenBinding.setBindingReference(DigestUtils.md5Hex(thumbprint)); + tokReqMsgCtx.setTokenBinding(tokenBinding); + setCnFValue(tokReqMsgCtx, tokenBinding.getBindingValue()); + return true; + } + } + } catch (ParseException e) { + throw new IdentityOAuth2ClientException(DPoPConstants.INVALID_DPOP_PROOF, DPoPConstants.INVALID_DPOP_ERROR); + } + return false; + } + + private static boolean validateDPoPHeader(JWSHeader header) throws IdentityOAuth2Exception { + + return checkJwk(header) && checkAlg(header) && checkHeaderType(header); + } + + private static boolean validateDPoPPayload(String httpMethod, String httpURL, JWTClaimsSet jwtClaimsSet) + throws IdentityOAuth2Exception { + + return checkJwtClaimSet(jwtClaimsSet) && checkDPoPHeaderValidity(jwtClaimsSet) && checkJti(jwtClaimsSet) && + checkHTTPMethod(httpMethod, jwtClaimsSet) && checkHTTPURI(httpURL, jwtClaimsSet); + } + + private static boolean checkJwk(JWSHeader header) throws IdentityOAuth2ClientException { + + if (header.getJWK() == null) { + if (log.isDebugEnabled()) { + log.debug("'jwk' is not presented in the DPoP Proof header"); + } + throw new IdentityOAuth2ClientException(DPoPConstants.INVALID_DPOP_PROOF, DPoPConstants.INVALID_DPOP_ERROR); + } + return true; + } + + private static boolean checkAlg(JWSHeader header) throws IdentityOAuth2ClientException { + + JWSAlgorithm algorithm = header.getAlgorithm(); + if (algorithm == null) { + if (log.isDebugEnabled()) { + log.debug("'algorithm' is not presented in the DPoP Proof header"); + } + throw new IdentityOAuth2ClientException(DPoPConstants.INVALID_DPOP_PROOF, DPoPConstants.INVALID_DPOP_ERROR); + } + return true; + } + + private static boolean checkHeaderType(JWSHeader header) throws IdentityOAuth2ClientException { + + if (!DPoPConstants.DPOP_JWT_TYPE.equalsIgnoreCase(header.getType().toString())) { + if (log.isDebugEnabled()) { + log.debug(" typ field value in the DPoP Proof header is not equal to 'dpop+jwt'"); + } + throw new IdentityOAuth2ClientException(DPoPConstants.INVALID_DPOP_PROOF, DPoPConstants.INVALID_DPOP_ERROR); + } + + return true; + } + + private static boolean checkJwtClaimSet(JWTClaimsSet jwtClaimsSet) throws IdentityOAuth2ClientException { + + if (jwtClaimsSet == null) { + if (log.isDebugEnabled()) { + log.debug("'jwtClaimsSet' is missing in the body of a DPoP proof."); + } + throw new IdentityOAuth2ClientException(DPoPConstants.INVALID_DPOP_PROOF, DPoPConstants.INVALID_DPOP_ERROR); + } + return true; + } + + private static boolean checkDPoPHeaderValidity(JWTClaimsSet jwtClaimsSet) throws IdentityOAuth2ClientException { + + Timestamp currentTimestamp = new Timestamp(new Date().getTime()); + Date issuedAt = (Date) jwtClaimsSet.getClaim(DPoPConstants.DPOP_ISSUED_AT); + if (issuedAt == null) { + if (log.isDebugEnabled()) { + log.debug("DPoP Proof missing the 'iat' field."); + } + throw new IdentityOAuth2ClientException(DPoPConstants.INVALID_DPOP_PROOF, DPoPConstants.INVALID_DPOP_ERROR); + } + boolean isExpired = (currentTimestamp.getTime() - issuedAt.getTime()) > getDPoPValidityPeriod(); + if (isExpired) { + String error = "Expired DPoP Proof"; + if (log.isDebugEnabled()) { + log.debug(error); + } + throw new IdentityOAuth2ClientException(DPoPConstants.INVALID_DPOP_PROOF, error); + } + return true; + } + + private static boolean checkJti(JWTClaimsSet jwtClaimsSet) throws IdentityOAuth2ClientException { + + if (!jwtClaimsSet.getClaims().containsKey(DPoPConstants.JTI)) { + if (log.isDebugEnabled()) { + log.debug("'jti' is missing in the 'jwtClaimsSet' of the DPoP proof body."); + } + throw new IdentityOAuth2ClientException(DPoPConstants.INVALID_DPOP_PROOF, DPoPConstants.INVALID_DPOP_ERROR); + } + return true; + } + + private static boolean checkHTTPMethod(String httpMethod, JWTClaimsSet jwtClaimsSet) throws IdentityOAuth2ClientException { + + Object dPoPHttpMethod = jwtClaimsSet.getClaim(DPoPConstants.DPOP_HTTP_METHOD); + + // Validate if the DPoP proof HTTP method matches that of the request. + if (!httpMethod.equalsIgnoreCase(dPoPHttpMethod.toString())) { + if (log.isDebugEnabled()) { + log.debug("DPoP Proof HTTP method mismatch."); + } + throw new IdentityOAuth2ClientException(DPoPConstants.INVALID_DPOP_PROOF, DPoPConstants.INVALID_DPOP_ERROR); + } + return true; + } + + private static boolean checkHTTPURI(String httpUrl, JWTClaimsSet jwtClaimsSet) throws IdentityOAuth2ClientException { + + // Validate if the DPoP proof HTTP URI matches that of the request. + Object dPoPContextPath = jwtClaimsSet.getClaim(DPoPConstants.DPOP_HTTP_URI); + if (!httpUrl.equalsIgnoreCase(dPoPContextPath.toString())) { + if (log.isDebugEnabled()) { + log.debug("DPoP Proof context path mismatch."); + } + throw new IdentityOAuth2ClientException(DPoPConstants.INVALID_DPOP_PROOF, DPoPConstants.INVALID_DPOP_ERROR); + } + return true; + } + + private static int getDPoPValidityPeriod() { + + String validityPeriodValue = IdentityUtil.readEventListenerProperty + (AbstractIdentityHandler.class.getName(), OauthDPoPInterceptorHandlerProxy.class.getName()) + .getProperties().get(DPoPConstants.VALIDITY_PERIOD).toString(); + if (StringUtils.isNotBlank(validityPeriodValue)) { + if (StringUtils.isNumeric(validityPeriodValue)) { + return Integer.parseInt(validityPeriodValue.trim()) * 1000; + } + log.info("Configured dpop validity period is set to a invalid value.Hence the default validity " + + "period will be used."); + return DPoPConstants.DEFAULT_HEADER_VALIDITY; + } + return DPoPConstants.DEFAULT_HEADER_VALIDITY; + } + + private static void setCnFValue(OAuthTokenReqMessageContext tokReqMsgCtx, String tokenBindingValue) { + + JSONObject obj = new JSONObject(); + obj.put(DPoPConstants.JWK_THUMBPRINT, tokenBindingValue); + tokReqMsgCtx.addProperty(DPoPConstants.CNF, obj); + } +} diff --git a/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/validators/DPoPTokenValidator.java b/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/validators/DPoPTokenValidator.java new file mode 100644 index 00000000..5667d198 --- /dev/null +++ b/component/org.wso2.carbon.identity.dpop/src/main/java/org/wso2/carbon/identity/dpop/validators/DPoPTokenValidator.java @@ -0,0 +1,380 @@ +/* + * Copyright (c) 2021, WSO2 Inc. (http://www.wso2.com). + * + * WSO2 Inc. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.wso2.carbon.identity.dpop.validators; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSVerifier; +import com.nimbusds.jose.crypto.RSASSAVerifier; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import org.apache.commons.lang.StringUtils; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.wso2.carbon.context.PrivilegedCarbonContext; +import org.wso2.carbon.identity.application.common.model.FederatedAuthenticatorConfig; +import org.wso2.carbon.identity.application.common.model.IdentityProvider; +import org.wso2.carbon.identity.application.common.util.IdentityApplicationConstants; +import org.wso2.carbon.identity.application.common.util.IdentityApplicationManagementUtil; +import org.wso2.carbon.identity.dpop.constant.DPoPConstants; +import org.wso2.carbon.identity.dpop.util.Utils; +import org.wso2.carbon.identity.oauth.config.OAuthServerConfiguration; +import org.wso2.carbon.identity.oauth2.IdentityOAuth2Exception; +import org.wso2.carbon.identity.oauth2.dto.OAuth2TokenValidationRequestDTO; +import org.wso2.carbon.identity.oauth2.model.AccessTokenDO; +import org.wso2.carbon.identity.oauth2.validators.OAuth2TokenValidationMessageContext; +import org.wso2.carbon.identity.oauth2.validators.OAuth2TokenValidator; +import org.wso2.carbon.idp.mgt.IdentityProviderManagementException; +import org.wso2.carbon.idp.mgt.IdentityProviderManager; +import org.wso2.carbon.utils.multitenancy.MultitenantConstants; + +import java.security.PublicKey; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.security.interfaces.RSAPublicKey; +import java.text.ParseException; +import java.util.Date; +import java.util.List; + +/** + * DPoP token validator. + */ +public class DPoPTokenValidator implements OAuth2TokenValidator { + + private static final String ALGO_PREFIX = "RS"; + private static final String DOT_SEPARATOR = "."; + private static final Log log = LogFactory.getLog(DPoPTokenValidator.class); + private static final String OIDC_IDP_ENTITY_ID = "IdPEntityId"; + private static final String ACCESS_TOKEN_DO = "AccessTokenDO"; + + @Override + public boolean validateAccessDelegation(OAuth2TokenValidationMessageContext messageContext) { + + return true; + } + + @Override + public boolean validateScope(OAuth2TokenValidationMessageContext messageContext) { + + return true; + } + + @Override + public boolean validateAccessToken(OAuth2TokenValidationMessageContext validationReqDTO) + throws IdentityOAuth2Exception { + + try { + if (!validateDPoP(validationReqDTO)) { + return false; + } + if (!isJWT(validationReqDTO.getRequestDTO().getAccessToken().getIdentifier())) { + return true; + } + SignedJWT signedJWT = getSignedJWT(validationReqDTO); + JWTClaimsSet claimsSet = signedJWT.getJWTClaimsSet(); + + if (claimsSet == null) { + throw new IdentityOAuth2Exception("Claim values are empty in the given Token."); + } + + validateRequiredFields(validationReqDTO, claimsSet); + + IdentityProvider identityProvider = getResidentIDPForIssuer(claimsSet.getIssuer()); + + if (!validateSignature(signedJWT, identityProvider)) { + return false; + } + if (!checkExpirationTime(claimsSet.getExpirationTime())) { + return false; + } + + checkNotBeforeTime(claimsSet.getNotBeforeTime()); + } catch (JOSEException | ParseException e) { + throw new IdentityOAuth2Exception("Error while validating Token.", e); + } + return true; + } + + @Override + public String getTokenType() { + + return DPoPConstants.DPOP_TOKEN_TYPE; + } + + private SignedJWT getSignedJWT(OAuth2TokenValidationMessageContext validationReqDTO) throws ParseException { + + return SignedJWT.parse(validationReqDTO.getRequestDTO().getAccessToken().getIdentifier()); + } + + private boolean validateRequiredFields(OAuth2TokenValidationMessageContext validationReqDTO, JWTClaimsSet claimsSet) + throws IdentityOAuth2Exception, ParseException { + + AccessTokenDO accessTokenDO = (AccessTokenDO) validationReqDTO.getProperty(ACCESS_TOKEN_DO); + String bindingValue = accessTokenDO.getTokenBinding().getBindingValue(); + String subject = resolveSubject(claimsSet); + + if (StringUtils.isBlank(String.valueOf(claimsSet.getClaims().containsKey(DPoPConstants.CNF))) + && StringUtils.isBlank(claimsSet.getClaim(DPoPConstants.CNF).toString())) { + throw new IdentityOAuth2Exception("Mandatory field cnf is empty in the given Token."); + } + + String jkt = claimsSet.getJSONObjectClaim(DPoPConstants.CNF).getAsString(DPoPConstants.JWK_THUMBPRINT); + if (StringUtils.isBlank(jkt) || !bindingValue.equalsIgnoreCase(jkt)) { + throw new IdentityOAuth2Exception("Mandatory field jkt is empty or invalid in the cnf."); + } + + String jti = claimsSet.getJWTID(); + List audience = claimsSet.getAudience(); + if (StringUtils.isEmpty(claimsSet.getIssuer()) || StringUtils.isEmpty(subject) || + claimsSet.getExpirationTime() == null || audience == null || jti == null) { + throw new IdentityOAuth2Exception("Mandatory fields(Issuer, Subject, Expiration time," + + " jtl or Audience) are empty in the given Token."); + } + return true; + } + + private String resolveSubject(JWTClaimsSet claimsSet) { + + return claimsSet.getSubject(); + } + + /** + * The default implementation resolves one certificate to Identity Provider and ignores the JWT header. + * Override this method, to resolve and enforce the certificate in any other way + * such as x5t attribute of the header. + * + * @param header The JWT header. Some of the x attributes may provide certificate information. + * @param idp The identity provider, if you need it. + * @return the resolved X509 Certificate, to be used to validate the JWT signature. + * @throws IdentityOAuth2Exception something goes wrong. + */ + protected X509Certificate resolveSignerCertificate(JWSHeader header, + IdentityProvider idp) throws IdentityOAuth2Exception { + + X509Certificate x509Certificate; + String tenantDomain = getTenantDomain(); + try { + x509Certificate = (X509Certificate) IdentityApplicationManagementUtil + .decodeCertificate(idp.getCertificate()); + } catch (CertificateException e) { + throw new IdentityOAuth2Exception("Error occurred while decoding public certificate of Identity Provider " + + idp.getIdentityProviderName() + " for tenant domain " + tenantDomain, e); + } + return x509Certificate; + } + + private IdentityProvider getResidentIDPForIssuer(String jwtIssuer) throws IdentityOAuth2Exception { + + String tenantDomain = getTenantDomain(); + String issuer = StringUtils.EMPTY; + IdentityProvider residentIdentityProvider; + try { + residentIdentityProvider = IdentityProviderManager.getInstance().getResidentIdP(tenantDomain); + } catch (IdentityProviderManagementException e) { + String errorMsg = + String.format("Error while getting Resident Identity Provider of '%s' tenant.", tenantDomain); + throw new IdentityOAuth2Exception(errorMsg, e); + } + FederatedAuthenticatorConfig[] fedAuthnConfigs = residentIdentityProvider.getFederatedAuthenticatorConfigs(); + FederatedAuthenticatorConfig oauthAuthenticatorConfig = + IdentityApplicationManagementUtil.getFederatedAuthenticator(fedAuthnConfigs, + IdentityApplicationConstants.Authenticator.OIDC.NAME); + if (oauthAuthenticatorConfig != null) { + issuer = IdentityApplicationManagementUtil.getProperty(oauthAuthenticatorConfig.getProperties(), + OIDC_IDP_ENTITY_ID).getValue(); + } + + if (!jwtIssuer.equals(issuer)) { + throw new IdentityOAuth2Exception("No Registered IDP found for the token with issuer name : " + jwtIssuer); + } + return residentIdentityProvider; + } + + private boolean validateSignature(SignedJWT signedJWT, IdentityProvider idp) + throws JOSEException, IdentityOAuth2Exception { + + JWSVerifier verifier = null; + JWSHeader header = signedJWT.getHeader(); + X509Certificate x509Certificate = resolveSignerCertificate(header, idp); + if (x509Certificate == null) { + throw new IdentityOAuth2Exception("Unable to locate certificate for Identity Provider: " + idp + .getDisplayName()); + } + + String alg = signedJWT.getHeader().getAlgorithm().getName(); + if (StringUtils.isEmpty(alg)) { + throw new IdentityOAuth2Exception("Algorithm must not be null."); + + } else { + if (log.isDebugEnabled()) { + log.debug("Signature Algorithm found in the Token Header: " + alg); + } + if (alg.indexOf(ALGO_PREFIX) == 0) { + // At this point 'x509Certificate' will never be null. + PublicKey publicKey = x509Certificate.getPublicKey(); + if (publicKey instanceof RSAPublicKey) { + verifier = new RSASSAVerifier((RSAPublicKey) publicKey); + } else { + throw new IdentityOAuth2Exception("Public key is not an RSA public key."); + } + } else { + if (log.isDebugEnabled()) { + log.debug("Signature Algorithm not supported yet: " + alg); + } + } + if (verifier == null) { + throw new IdentityOAuth2Exception("Could not create a signature verifier for algorithm type: " + alg); + } + } + + boolean isValid = signedJWT.verify(verifier); + if (log.isDebugEnabled()) { + log.debug("Signature verified: " + isValid); + } + return isValid; + } + + private boolean checkExpirationTime(Date expirationTime) { + + long timeStampSkewMillis = OAuthServerConfiguration.getInstance().getTimeStampSkewInSeconds() * 1000; + long expirationTimeInMillis = expirationTime.getTime(); + long currentTimeInMillis = System.currentTimeMillis(); + if ((currentTimeInMillis + timeStampSkewMillis) > expirationTimeInMillis) { + if (log.isDebugEnabled()) { + log.debug("Token is expired." + + ", Expiration Time(ms) : " + expirationTimeInMillis + + ", TimeStamp Skew : " + timeStampSkewMillis + + ", Current Time : " + currentTimeInMillis + ". Token Rejected and validation terminated."); + } + return false; + } + + if (log.isDebugEnabled()) { + log.debug("Expiration Time(exp) of Token was validated successfully."); + } + return true; + } + + private boolean checkNotBeforeTime(Date notBeforeTime) throws IdentityOAuth2Exception { + + if (notBeforeTime != null) { + long timeStampSkewMillis = OAuthServerConfiguration.getInstance().getTimeStampSkewInSeconds() * 1000; + long notBeforeTimeMillis = notBeforeTime.getTime(); + long currentTimeInMillis = System.currentTimeMillis(); + if (currentTimeInMillis + timeStampSkewMillis < notBeforeTimeMillis) { + if (log.isDebugEnabled()) { + log.debug("Token is used before Not_Before_Time." + + ", Not Before Time(ms) : " + notBeforeTimeMillis + + ", TimeStamp Skew : " + timeStampSkewMillis + + ", Current Time : " + currentTimeInMillis + ". Token Rejected and validation terminated."); + } + throw new IdentityOAuth2Exception("Token is used before Not_Before_Time."); + } + if (log.isDebugEnabled()) { + log.debug("Not Before Time(nbf) of Token was validated successfully."); + } + } + return true; + } + + private String getTenantDomain() { + + String tenantDomain = PrivilegedCarbonContext.getThreadLocalCarbonContext().getTenantDomain(); + if (StringUtils.isEmpty(tenantDomain)) { + tenantDomain = MultitenantConstants.SUPER_TENANT_DOMAIN_NAME; + } + return tenantDomain; + } + + /** + * Return true if the token identifier is JWT. + * + * @param tokenIdentifier String JWT token identifier. + * @return true for a JWT token. + */ + private boolean isJWT(String tokenIdentifier) { + // JWT token contains 3 base64 encoded components separated by periods. + return StringUtils.countMatches(tokenIdentifier, DOT_SEPARATOR) == 2; + } + + private boolean validateDPoP(OAuth2TokenValidationMessageContext validationReqDTO) throws IdentityOAuth2Exception, + ParseException { + + AccessTokenDO accessTokenDO = (AccessTokenDO) validationReqDTO.getProperty(ACCESS_TOKEN_DO); + if (accessTokenDO != null && accessTokenDO.getTokenBinding() != null && + DPoPConstants.OAUTH_DPOP_HEADER.equalsIgnoreCase(accessTokenDO.getTokenBinding().getBindingType())) { + String dpopProof = getResourceFromMessageContext(validationReqDTO, DPoPConstants.OAUTH_DPOP_HEADER); + String httpMethod = getResourceFromMessageContext(validationReqDTO, DPoPConstants.HTTP_METHOD); + String httpUrl = getResourceFromMessageContext(validationReqDTO, DPoPConstants.HTTP_URL); + + if (StringUtils.isBlank(dpopProof)) { + if (log.isDebugEnabled()) { + log.debug("DPoP header is empty."); + } + return false; + } + + if (!DPoPHeaderValidator.isValidDPoPProof(httpMethod, httpUrl, dpopProof)) { + return false; + } + + String thumbprintOfPublicKey = Utils.getThumbprintOfKeyFromDpopProof(dpopProof); + + if (StringUtils.isBlank(thumbprintOfPublicKey)) { + if (log.isDebugEnabled()) { + log.debug("Thumbprint value of the public key is empty in the DPoP Proof."); + } + return false; + } + + if (!thumbprintOfPublicKey.equalsIgnoreCase(accessTokenDO.getTokenBinding().getBindingValue())) { + if (log.isDebugEnabled()) { + log.debug("Thumbprint value of the public key in the DPoP proof is not equal to binding value" + + " of the responseDTO."); + } + return false; + } + return true; + } + return false; + } + + /** + * Extract the passed parameter value from the access token validation request message + * + * @param messageContext Message context of the token validation request + * @return resource + */ + private String getResourceFromMessageContext(OAuth2TokenValidationMessageContext messageContext, String param) { + + String resource = null; + if (messageContext.getRequestDTO().getContext() != null) { + // Iterate the array of context params to find the 'resource' context param. + for (OAuth2TokenValidationRequestDTO.TokenValidationContextParam resourceParam : + messageContext.getRequestDTO().getContext()) { + // If the context param is the resource that is being accessed + if (resourceParam != null && param.equals(resourceParam.getKey())) { + resource = resourceParam.getValue(); + break; + } + } + } + return resource; + } +} diff --git a/component/org.wso2.carbon.identity.oauth2.token.handler.clientauth.mutualtls/src/test/java/org/wso2/carbon/identity/oauth2/token/handler/clientauth/mutualtls/utils/MutualTLSUtilTest.java b/component/org.wso2.carbon.identity.oauth2.token.handler.clientauth.mutualtls/src/test/java/org/wso2/carbon/identity/oauth2/token/handler/clientauth/mutualtls/utils/MutualTLSUtilTest.java index 4de22f62..c4ed4db3 100644 --- a/component/org.wso2.carbon.identity.oauth2.token.handler.clientauth.mutualtls/src/test/java/org/wso2/carbon/identity/oauth2/token/handler/clientauth/mutualtls/utils/MutualTLSUtilTest.java +++ b/component/org.wso2.carbon.identity.oauth2.token.handler.clientauth.mutualtls/src/test/java/org/wso2/carbon/identity/oauth2/token/handler/clientauth/mutualtls/utils/MutualTLSUtilTest.java @@ -85,7 +85,7 @@ public void testGetThumbPrint() throws Exception { X509Certificate Cert = (X509Certificate) factory.generateCertificate( new ByteArrayInputStream(DatatypeConverter.parseBase64Binary(CERTIFICATE_CONTENT))); assertEquals(MutualTLSUtil.getThumbPrint(Cert, null), - "OTE2OWI4MzQ0MTQ5ZDMzMTk3ZmI2NjNjOGYyNjZhNTZhYzgxZWU5Zg"); + "YTJkZTg5OGQ3NWUwMTQ2N2UwYTcwMGE1ZTFmMTcyMjE5ZGUwMDBiMDE2ZWVhOWI0NjY1OWQ4YTZlZjQ3YzJmMQ"); } diff --git a/pom.xml b/pom.xml index 6a78d88f..342039cb 100644 --- a/pom.xml +++ b/pom.xml @@ -16,7 +16,8 @@ ~ specific language governing permissions and limitations ~ under the License. --> - + 4.0.0 org.wso2 @@ -39,6 +40,7 @@ features/org.wso2.carbon.identity.oauth2.token.handler.clientauth.jwt.feature features/org.wso2.carbon.identity.oauth2.validators.xacml.server.feature component/org.wso2.carbon.identity.oauth2.clientauth.privilegeduser + component/org.wso2.carbon.identity.dpop @@ -81,6 +83,11 @@ + + org.wso2.carbon.utils + org.wso2.carbon.database.utils + ${org.wso2.carbon.database.utils.version} + org.wso2.carbon.identity.framework org.wso2.carbon.idp.mgt @@ -91,6 +98,16 @@ org.wso2.carbon.identity.application.common ${carbon.identity.version} + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.event + ${carbon.identity.version} + + + org.wso2.carbon.identity.auth.rest + org.wso2.carbon.identity.auth.service + ${identity.carbon.auth.rest.version} + org.wso2.carbon.identity.framework org.wso2.carbon.identity.user.profile @@ -111,6 +128,11 @@ org.wso2.carbon.identity.application.authentication.framework ${carbon.identity.version} + + org.wso2.carbon.identity.framework + org.wso2.carbon.identity.base + ${carbon.identity.version} + commons-collections commons-collections @@ -131,6 +153,11 @@ org.wso2.carbon.user.core ${carbon.kernel.version} + + org.wso2.carbon + org.wso2.carbon.core.common + ${carbon.kernel.version} + org.wso2.carbon.identity.framework org.wso2.carbon.identity.application.mgt @@ -193,6 +220,11 @@ org.wso2.carbon.identity.oauth ${identity.inbound.auth.oauth.version} + + org.wso2.carbon.identity.inbound.auth.oauth2 + org.wso2.carbon.identity.oauth.common + ${identity.inbound.auth.oauth.version} + org.wso2.securevault org.wso2.securevault @@ -276,6 +308,11 @@ org.wso2.carbon.identity.oauth2.validators.xacml ${project.version} + + com.googlecode.json-simple.wso2 + json-simple + ${json-simple.version} + org.ops4j.pax.logging @@ -351,18 +388,27 @@ + 5.20.234 + [5.17.5, 6.0.0) + + 6.7.70 + 2.0.7 + [2.0.0,2.1.0) - 5.20.66 - 6.2.18 [6.2.18,7.0.0) + 1.4.47 + [1.0.0,2.0.0) + 4.6.0 4.6.0 + [4.5.0, 5.0.0) [1.0.1, 2.0.0) 1.0.0 1.0.1 [1.0.0, 2.0.0) 3.0.0.wso2v1 + [3.0.0.wso2v1, 4.0.0) 3.0-alpha-1 1.2.5 1.1.2 @@ -377,10 +423,10 @@ 3.5.100.v20160504-1419 [5.14.0, 6.0.0) - [1.10.0,2 - ) - [2.4.0,3 - ) + [1.10.0,2) + + [2.4.0,3) + [2.3.1,3.0.0) [4.5.0, 5.0.0) [7.3.0,8.0.0) @@ -419,5 +465,9 @@ 1.10.1 + + 1.7.0 + + 1.1.wso2v1