diff --git a/EXAMPLES.md b/EXAMPLES.md index 71c21b611..567d95ca0 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -11,6 +11,7 @@ - [Changing the Return To URL scheme](#changing-the-return-to-url-scheme) - [Specify a Custom Logout URL](#specify-a-custom-logout-url) - [Trusted Web Activity](#trusted-web-activity) + - [DPoP [EA]](#dpop-ea) - [Authentication API](#authentication-api) - [Login with database connection](#login-with-database-connection) - [Login using MFA with One Time Password code](#login-using-mfa-with-one-time-password-code) @@ -21,6 +22,7 @@ - [Get user information](#get-user-information) - [Custom Token Exchange](#custom-token-exchange) - [Native to Web SSO login [EA]](#native-to-web-sso-login-ea) + - [DPoP [EA]](#dpop-ea-1) - [My Account API](#my-account-api) - [Enroll a new passkey](#enroll-a-new-passkey) - [Credentials Manager](#credentials-manager) @@ -208,6 +210,76 @@ WebAuthProvider.login(account) .await(this) ``` +## DPoP [EA] + +> [!NOTE] +> This feature is currently available in [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). Please reach out to Auth0 support to get it enabled for your tenant. + +[DPoP](https://www.rfc-editor.org/rfc/rfc9449.html) (Demonstrating Proof of Possession) is an application-level mechanism for sender-constraining OAuth 2.0 access and refresh tokens by proving that the app is in possession of a certain private key. You can enable it by calling the `useDPoP()` method. + +```kotlin +WebAuthProvider + .useDPoP() + .login(account) + .start(requireContext(), object : Callback { + override fun onSuccess(result: Credentials) { + println("Credentials $result") + } + override fun onFailure(error: AuthenticationException) { + print("Error $error") + } + }) +``` + +> [!IMPORTANT] +> DPoP will only be used for new user sessions created after enabling it. DPoP **will not** be applied to any requests involving existing access and refresh tokens (such as exchanging the refresh token for new credentials). +> +> This means that, after you've enabled it in your app, DPoP will only take effect when users log in again. It's up to you to decide how to roll out this change to your users. For example, you might require users to log in again the next time they open your app. You'll need to implement the logic to handle this transition based on your app's requirements. + +When making requests to your own APIs, use the `DPoP.getHeaderData()` method to get the `Authorization` and `DPoP` header values to be used. The `Authorization` header value is generated using the access token and token type, while the `DPoP` header value is the generated DPoP proof. + +```kotlin +val url ="https://example.com/api/endpoint" +val httpMethod = "GET" + val headerData = DPoP.getHeaderData( + httpMethod, url, + accessToken, tokenType + ) +httpRequest.apply{ + addHeader("Authorization", headerData.authorizationHeader) + headerData.dpopProof?.let { + addHeader("DPoP", it) + } +} +``` +If your API is issuing DPoP nonces to prevent replay attacks, you can pass the nonce value to the `getHeaderData()` method to include it in the DPoP proof. Use the `DPoP.isNonceRequiredError(response: Response)` method to check if a particular API response failed because a nonce is required. + +```kotlin +if (DPoP.isNonceRequiredError(response)) { + val nonce = response.headers["DPoP-Nonce"] + val dpopProof = DPoPProvider.generateProof( + url, httpMethod, accessToken, nonce + ) + // Retry the request with the new proof +} +``` + +On logout, you should call `DPoP.clearKeyPair()` to delete the user's key pair from the Keychain. + +```kotlin +WebAuthProvider.logout(account) + .start(requireContext(), object : Callback { + override fun onSuccess(result: Void?) { + DPoPProvider.clearKeyPair() + } + override fun onFailure(error: AuthenticationException) { + } + + }) +``` +> [!NOTE] +> DPoP is supported only on Android version 6.0 (API level 23) and above. Trying to use DPoP in any older versions will result in an exception. + ## Authentication API The client provides methods to authenticate the user against the Auth0 server. @@ -651,6 +723,62 @@ authentication ``` +## DPoP [EA] + +> [!NOTE] +> This feature is currently available in [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). Please reach out to Auth0 support to get it enabled for your tenant. + +[DPoP](https://www.rfc-editor.org/rfc/rfc9449.html) (Demonstrating Proof of Posession) is an application-level mechanism for sender-constraining OAuth 2.0 access and refresh tokens by proving that the app is in possession of a certain private key. You can enable it by calling the `useDPoP()` method. This ensures that DPoP proofs are generated for requests made through the AuthenticationAPI client. + +```kotlin +val client = AuthenticationAPIClient(account).useDPoP() +``` + +[!IMPORTANT] +> DPoP will only be used for new user sessions created after enabling it. DPoP **will not** be applied to any requests involving existing access and refresh tokens (such as exchanging the refresh token for new credentials). +> +> This means that, after you've enabled it in your app, DPoP will only take effect when users log in again. It's up to you to decide how to roll out this change to your users. For example, you might require users to log in again the next time they open your app. You'll need to implement the logic to handle this transition based on your app's requirements. + +When making requests to your own APIs, use the `DPoP.getHeaderData()` method to get the `Authorization` and `DPoP` header values to be used. The `Authorization` header value is generated using the access token and token type, while the `DPoP` header value is the generated DPoP proof. + +```kotlin +val url ="https://example.com/api/endpoint" +val httpMethod = "GET" + val headerData = DPoP.getHeaderData( + httpMethod, url, + accessToken, tokenType + ) +httpRequest.apply{ + addHeader("Authorization", headerData.authorizationHeader) + headerData.dpopProof?.let { + addHeader("DPoP", it) + } +} +``` +If your API is issuing DPoP nonces to prevent replay attacks, you can pass the nonce value to the `getHeaderData()` method to include it in the DPoP proof. Use the `DPoP.isNonceRequiredError(response: Response)` method to check if a particular API response failed because a nonce is required. + +```kotlin +if (DPoP.isNonceRequiredError(response)) { + val nonce = response.headers["DPoP-Nonce"] + val dpopProof = DPoPProvider.generateProof( + url, httpMethod, accessToken, nonce + ) + // Retry the request with the new proof +} +``` + +On logout, you should call `DPoP.clearKeyPair()` to delete the user's key pair from the Keychain. + +```kotlin + +DPoP.clearKeyPair() + +``` + +> [!NOTE] +> DPoP is supported only on Android version 6.0 (API level 23) and above. Trying to use DPoP in any older versions will result in an exception. + + ## My Account API diff --git a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt index a026084c5..394cca3a7 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -1,9 +1,13 @@ package com.auth0.android.authentication +import android.content.Context import androidx.annotation.VisibleForTesting import com.auth0.android.Auth0 import com.auth0.android.Auth0Exception import com.auth0.android.NetworkErrorException +import com.auth0.android.dpop.DPoP +import com.auth0.android.dpop.DPoPException +import com.auth0.android.dpop.SenderConstraining import com.auth0.android.request.* import com.auth0.android.request.internal.* import com.auth0.android.request.internal.GsonAdapter.Companion.forMap @@ -35,7 +39,9 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe private val auth0: Auth0, private val factory: RequestFactory, private val gson: Gson -) { +) : SenderConstraining { + + private var dPoP: DPoP? = null /** * Creates a new API client instance providing Auth0 account info. @@ -59,6 +65,14 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe public val baseURL: String get() = auth0.getDomainUrl() + /** + * Enable DPoP for this client. + */ + public override fun useDPoP(context: Context): AuthenticationAPIClient { + dPoP = DPoP(context) + return this + } + /** * Log in a user with email/username and password for a connection/realm. * It will use the password-realm grant type for the `/oauth/token` endpoint @@ -561,9 +575,11 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * @param accessToken used to fetch it's information * @return a request to start */ - public fun userInfo(accessToken: String): Request { + public fun userInfo( + accessToken: String, tokenType: String = "Bearer" + ): Request { return profileRequest() - .addHeader(HEADER_AUTHORIZATION, "Bearer $accessToken") + .addHeader(HEADER_AUTHORIZATION, "$tokenType $accessToken") } /** @@ -790,8 +806,9 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe val credentialsAdapter = GsonAdapter( Credentials::class.java, gson ) - return factory.post(url.toString(), credentialsAdapter) + val request = factory.post(url.toString(), credentialsAdapter, dPoP) .addParameters(parameters) + return request } /** @@ -926,8 +943,8 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe val credentialsAdapter: JsonAdapter = GsonAdapter( Credentials::class.java, gson ) - val request = factory.post(url.toString(), credentialsAdapter) - request.addParameters(parameters) + val request = factory.post(url.toString(), credentialsAdapter, dPoP) + .addParameters(parameters) return request } @@ -992,8 +1009,8 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe val adapter: JsonAdapter = GsonAdapter( T::class.java, gson ) - val request = factory.post(url.toString(), adapter) - request.addParameters(requestParameters) + val request = factory.post(url.toString(), adapter, dPoP) + .addParameters(requestParameters) return request } @@ -1014,7 +1031,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe Credentials::class.java, gson ) val request = BaseAuthenticationRequest( - factory.post(url.toString(), credentialsAdapter), clientId, baseURL + factory.post(url.toString(), credentialsAdapter, dPoP), clientId, baseURL ) request.addParameters(requestParameters) return request @@ -1043,7 +1060,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe val userProfileAdapter: JsonAdapter = GsonAdapter( UserProfile::class.java, gson ) - return factory.get(url.toString(), userProfileAdapter) + return factory.get(url.toString(), userProfileAdapter, dPoP) } private companion object { @@ -1086,6 +1103,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe private const val HEADER_AUTHORIZATION = "Authorization" private const val WELL_KNOWN_PATH = ".well-known" private const val JWKS_FILE_PATH = "jwks.json" + private const val TAG = "AuthenticationAPIClient" private fun createErrorAdapter(): ErrorAdapter { val mapAdapter = forMap(GsonProvider.gson) return object : ErrorAdapter { @@ -1109,6 +1127,11 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe "Failed to execute the network request", NetworkErrorException(cause) ) } + if (cause is DPoPException) { + return AuthenticationException( + cause.message ?: "Error while attaching DPoP proof", cause + ) + } return AuthenticationException( "Something went wrong", Auth0Exception("Something went wrong", cause) ) diff --git a/auth0/src/main/java/com/auth0/android/dpop/DPoP.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoP.kt new file mode 100644 index 000000000..c74b0982b --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoP.kt @@ -0,0 +1,224 @@ +package com.auth0.android.dpop + +import android.content.Context +import androidx.annotation.VisibleForTesting +import com.auth0.android.dpop.DPoPUtil.NONCE_REQUIRED_ERROR +import com.auth0.android.dpop.DPoPUtil.generateProof +import com.auth0.android.dpop.DPoPUtil.isResourceServerNonceError +import com.auth0.android.request.HttpMethod +import com.auth0.android.request.getErrorBody +import okhttp3.Response +import java.lang.reflect.Modifier.PRIVATE + + +/** + * Data class returning the value that needs to be added to the request for the `Authorization` and `DPoP` headers. + * @param authorizationHeader value for the `Authorization` header key + * @param dpopProof value for the `DPoP header key . This will be generated only for DPoP requests + */ +public data class HeaderData(val authorizationHeader: String, val dpopProof: String?) + +/** + * Class for securing requests with DPoP (Demonstrating Proof of Possession) as described in + * [RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449). + */ +public class DPoP(private val context: Context) { + + /** + * Determines whether a DPoP proof should be generated for the given URL and parameters. The proof should + * only be generated for the `token` endpoint for a fresh login scenario or when a key-pair already exists. + * + * @param url The URL of the request + * @param parameters The request parameters as a map + * @return true if a DPoP proof should be generated, false otherwise + * @throws DPoPException if there's an error checking for existing keypair + */ + @Throws(DPoPException::class) + internal fun shouldGenerateProof(url: String, parameters: Map): Boolean { + if (url.endsWith("/token")) { + val grantType = parameters["grant_type"] as? String + if (grantType != null && grantType != "refresh_token") { + return true + } + } + return DPoPUtil.hasKeyPair() + } + + /** + * Generates a DPoP proof for the given request. + * + * @param request The URL of the request for which to generate the proof. + * @param httpMethod The HTTP method of the request (e.g., GET, POST). + * @param header The headers of the request. + * @return A DPoP proof JWT as a String. + * @throws DPoPException if the proof generation fails. + */ + @Throws(DPoPException::class) + internal fun generateProof( + request: String, + httpMethod: HttpMethod, + header: Map + ): String? { + val authorizationHeader = header[AUTHORIZATION_HEADER] + val accessToken = authorizationHeader?.split(" ")?.lastOrNull() + + return generateProof( + httpUrl = request, + httpMethod = httpMethod.toString(), + nonce = auth0Nonce, + accessToken = accessToken + ) + } + + + /** + * Generates a new key pair for DPoP if it does not exist. This should be called before making any requests that require a DPoP proof. + * + * @param context The application context used to access the keystore. + * @throws DPoPException if there is an error generating the key pair or accessing the keystore. + */ + @Throws(DPoPException::class) + internal fun generateKeyPair() { + DPoPUtil.generateKeyPair(context) + } + + /** + * Method to get the public key in JWK format. This is used to generate the `jwk` field in the DPoP proof header. + * This method will also create a key-pair in the key store if one currently doesn't exist. + * + * @return The public key in JWK format or null if the key pair is not present. + * @throws DPoPException if there is an error accessing the key pair. + */ + @Throws(DPoPException::class) + internal fun getPublicKeyJWK(): String? { + generateKeyPair() + return DPoPUtil.getPublicKeyJWK() + } + + public companion object { + + private const val AUTHORIZATION_HEADER = "Authorization" + private const val NONCE_HEADER = "DPoP-Nonce" + + @Volatile + @VisibleForTesting(otherwise = PRIVATE) + internal var _auth0Nonce: String? = null + + public val auth0Nonce: String? + get() = _auth0Nonce + + /** + * Stores the nonce value from the Okhttp3 [Response] headers. + * + * ```kotlin + * + * try { + * DPoP.storeNonce(response) + * } catch (exception: Exception) { + * Log.e(TAG, "Error storing nonce: ${exception.stackTraceToString()}") + * } + * + * ``` + * + * @param response The HTTP response containing the nonce header. + */ + @JvmStatic + internal fun storeNonce(response: Response) { + response.headers[NONCE_HEADER]?.let { + synchronized(this) { + _auth0Nonce = it + } + } + } + + /** + * Checks if the given [Response] indicates that a nonce is required for DPoP requests. + * This is typically used to determine if the request needs to be retried with a nonce. + * + * ```kotlin + * + * if (DPoP.isNonceRequiredError(response)) { + * // Handle nonce required error + * } + * + * ``` + * + * @param response The HTTP response to check for nonce requirement. + * @return True if the response indicates that a nonce is required, false otherwise. + */ + @JvmStatic + public fun isNonceRequiredError(response: Response): Boolean { + return (response.code == 400 && response.getErrorBody().errorCode == NONCE_REQUIRED_ERROR) || + (response.code == 401 && isResourceServerNonceError(response)) + } + + /** + * Generates the header data for a request that requires DPoP proof of possession. The `Authorization` header value is created + * using the access token and token type. The `DPoP` header value contains the generated DPoP proof. + * + * ```kotlin + * + * try { + * val headerData = DPoP.getHeaderData( + * "{POST}", + * "{request_url}", + * "{access_token}", + * "{DPoP}", + * "{nonce_value}" + * ) + * addHeader("Authorization", headerData.authorizationHeader) //Adding to request header + * headerData.dpopProof?.let { + * addHeader("DPoP", it) + * } + * } catch (exception: DPoPException) { + * Log.e(TAG, "Error generating DPoP proof: ${exception.stackTraceToString()}") + * } + * + * ``` + * + * @param httpMethod Method type of the request + * @param httpUrl Url of the request + * @param accessToken Access token to be included in the `Authorization` header + * @param tokenType Either `DPoP` or `Bearer` + * @param nonce Optional nonce value to be used in the proof + * @throws DPoPException if there is an error generating the DPoP proof or accessing the key pair + */ + @Throws(DPoPException::class) + @JvmStatic + public fun getHeaderData( + httpMethod: String, + httpUrl: String, + accessToken: String, + tokenType: String, + nonce: String? = null + ): HeaderData { + val token = "$tokenType $accessToken" + if (!tokenType.equals("DPoP", ignoreCase = true)) return HeaderData(token, null) + val proof = generateProof(httpUrl, httpMethod, accessToken, nonce) + return HeaderData(token, proof) + } + + /** + * Method to clear the DPoP key pair from the keystore. It must be called when the user logs out + * to prevent reuse of the key pair in subsequent sessions. + * + * ```kotlin + * + * try { + * DPoP.clearKeyPair() + * } catch (exception: DPoPException) { + * Log.e(TAG,"Error clearing the key pair from the keystore: ${exception.stackTraceToString()}") + * } + * + * ``` + * **Note** : It is the developer's responsibility to invoke this method to clear the keystore when logging out . + * @throws DPoPException if there is an error deleting the key pair. + */ + @Throws(DPoPException::class) + @JvmStatic + public fun clearKeyPair() { + DPoPUtil.clearKeyPair() + _auth0Nonce = null + } + } +} \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/dpop/DPoPException.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoPException.kt new file mode 100644 index 000000000..c94294512 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoPException.kt @@ -0,0 +1,65 @@ +package com.auth0.android.dpop + +import com.auth0.android.Auth0Exception + +public class DPoPException : Auth0Exception { + + internal enum class Code { + UNSUPPORTED_ERROR, + KEY_GENERATION_ERROR, + KEY_STORE_ERROR, + SIGNING_ERROR, + MALFORMED_URL, + KEY_PAIR_NOT_FOUND, + UNKNOWN_ERROR, + } + + private var code: Code? = null + + internal constructor( + code: Code, + cause: Throwable? = null + ) : this( + code, + getMessage(code), + cause + ) + + internal constructor( + code: Code, + message: String, + cause: Throwable? = null + ) : super( + message, + cause + ) { + this.code = code + } + + + public companion object { + + public val UNSUPPORTED_ERROR :DPoPException = DPoPException(Code.UNSUPPORTED_ERROR) + public val KEY_GENERATION_ERROR: DPoPException = DPoPException(Code.KEY_GENERATION_ERROR) + public val KEY_STORE_ERROR: DPoPException = DPoPException(Code.KEY_STORE_ERROR) + public val SIGNING_ERROR: DPoPException = DPoPException(Code.SIGNING_ERROR) + public val UNKNOWN_ERROR: DPoPException = DPoPException(Code.UNKNOWN_ERROR) + public val MALFORMED_URL: DPoPException = DPoPException(Code.MALFORMED_URL) + public val KEY_PAIR_NOT_FOUND: DPoPException = DPoPException(Code.KEY_PAIR_NOT_FOUND) + + private const val DEFAULT_MESSAGE = + "An unknown error has occurred. Please check the error cause for more details." + + private fun getMessage(code: Code): String { + return when (code) { + Code.UNSUPPORTED_ERROR -> "DPoP is not supported in versions below Android 9 (API level 28)." + Code.KEY_GENERATION_ERROR -> "Error generating DPoP key pair." + Code.KEY_STORE_ERROR -> "Error while accessing the key pair in the keystore." + Code.SIGNING_ERROR -> "Error while signing the DPoP proof." + Code.MALFORMED_URL -> "The url passed is an invalid URL or malformed." + Code.KEY_PAIR_NOT_FOUND -> "Key pair is not found in the keystore. Please generate a key pair first." + Code.UNKNOWN_ERROR -> DEFAULT_MESSAGE + } + } + } +} \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/dpop/DPoPKeyStore.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoPKeyStore.kt new file mode 100644 index 000000000..742e5beaf --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoPKeyStore.kt @@ -0,0 +1,135 @@ +package com.auth0.android.dpop + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Log +import java.security.InvalidAlgorithmParameterException +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.KeyStoreException +import java.security.NoSuchAlgorithmException +import java.security.NoSuchProviderException +import java.security.PrivateKey +import java.security.ProviderException +import java.security.PublicKey +import java.security.spec.ECGenParameterSpec +import java.util.Calendar +import javax.security.auth.x500.X500Principal +import javax.security.cert.CertificateException + +/** + * Class to handle all DPoP related keystore operations + */ +internal open class DPoPKeyStore { + + protected open val keyStore: KeyStore by lazy { + KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + } + + fun generateKeyPair(context: Context, useStrongBox: Boolean = true) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + throw DPoPException.UNSUPPORTED_ERROR + } + try { + val keyPairGenerator = KeyPairGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_EC, + ANDROID_KEYSTORE + ) + + val start = Calendar.getInstance() + val end = Calendar.getInstance() + end.add(Calendar.YEAR, 25) + val principal = X500Principal("CN=Auth0.Android,O=Auth0") + + val builder = KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY + ).apply { + setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) + setDigests(KeyProperties.DIGEST_SHA256) + setCertificateSubject(principal) + setCertificateNotBefore(start.time) + setCertificateNotAfter(end.time) + if (useStrongBox && isStrongBoxEnabled(context)) { + setIsStrongBoxBacked(true) + } + } + + keyPairGenerator.initialize(builder.build()) + keyPairGenerator.generateKeyPair() + Log.d(TAG, "Key pair generated successfully.") + } catch (e: Exception) { + when (e) { + is CertificateException, + is InvalidAlgorithmParameterException, + is NoSuchProviderException, + is NoSuchAlgorithmException, + is KeyStoreException -> { + Log.e(TAG, "The device can't generate a new EC Key pair.", e) + throw DPoPException(DPoPException.Code.KEY_GENERATION_ERROR, e) + } + + is ProviderException -> { + Log.d( + TAG, + "Key generation failed. Will retry one time before throwing the exception ${e.stackTraceToString()}" + ) + if (useStrongBox) { + // Retry the key-pair generation with strong box disabled + generateKeyPair(context, false) + } else { + throw DPoPException(DPoPException.Code.KEY_GENERATION_ERROR, e) + } + } + + else -> throw DPoPException(DPoPException.Code.UNKNOWN_ERROR, e) + } + } + } + + fun getKeyPair(): Pair? { + try { + val privateKey = keyStore.getKey(KEY_ALIAS, null) as PrivateKey + val publicKey = keyStore.getCertificate(KEY_ALIAS)?.publicKey + if (publicKey != null) { + return Pair(privateKey, publicKey) + } + } catch (e: KeyStoreException) { + throw DPoPException(DPoPException.Code.KEY_STORE_ERROR, e) + } + Log.d(TAG, "Returning null key pair ") + return null + } + + fun hasKeyPair(): Boolean { + try { + return keyStore.containsAlias(KEY_ALIAS) + } catch (e: KeyStoreException) { + throw DPoPException(DPoPException.Code.KEY_STORE_ERROR, e) + } + } + + fun deleteKeyPair() { + try { + keyStore.deleteEntry(KEY_ALIAS) + } catch (e: KeyStoreException) { + throw DPoPException(DPoPException.Code.KEY_STORE_ERROR, e) + } + } + + private fun isStrongBoxEnabled(context: Context): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && context.packageManager.hasSystemFeature( + PackageManager.FEATURE_STRONGBOX_KEYSTORE + ) + } + + private companion object { + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + private const val KEY_ALIAS = "DPoPES256Alias" + private const val TAG = "DefaultPoPKeyStore" + } + +} \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/dpop/DPoPUtil.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoPUtil.kt new file mode 100644 index 000000000..23f171bad --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoPUtil.kt @@ -0,0 +1,260 @@ +package com.auth0.android.dpop + +import android.content.Context +import android.util.Base64 +import android.util.Log +import androidx.annotation.VisibleForTesting +import okhttp3.Response +import org.json.JSONObject +import java.math.BigInteger +import java.net.URISyntaxException +import java.security.MessageDigest +import java.security.PrivateKey +import java.security.Signature +import java.security.SignatureException +import java.security.interfaces.ECPublicKey +import java.util.UUID + + +/** + * Util class for DPoP operations + */ +internal object DPoPUtil { + + private const val TAG = "DPoPUtil" + + internal const val NONCE_REQUIRED_ERROR = "use_dpop_nonce" + internal const val MAX_RETRY_COUNT: Int = 1 + internal const val DPOP_HEADER: String = "DPoP" + + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + @Volatile + internal var keyStore = DPoPKeyStore() + + + @Throws(DPoPException::class) + @JvmStatic + internal fun generateProof( + httpUrl: String, + httpMethod: String, + accessToken: String? = null, + nonce: String? = null + ): String? { + if (!hasKeyPair()) { + Log.d(TAG, "generateProof: Key pair is not present to generate the proof") + return null + } + + val keyPair = keyStore.getKeyPair() + keyPair ?: run { + Log.e(TAG, "generateProof: Key pair is null") + throw DPoPException(DPoPException.Code.KEY_PAIR_NOT_FOUND) + } + val (privateKey, publicKey) = keyPair + + // 1. Construct the header + val headerJson = JSONObject().apply { + put("typ", "dpop+jwt") + put("alg", "ES256") + put("jwk", createJWK(publicKey as ECPublicKey)) + } + val headerEncoded = encodeBase64Url(headerJson.toString().toByteArray(Charsets.UTF_8)) + + //2. Construct the Payload + val cleanedUrl = cleanUrl(httpUrl) + val payloadJson = JSONObject().apply { + put("jti", UUID.randomUUID().toString()) + put("htm", httpMethod.uppercase()) + put("htu", cleanedUrl) + put("iat", System.currentTimeMillis() / 1000) + + accessToken?.let { + put("ath", createSHA256Hash(it)) + } + nonce?.let { + put("nonce", it) + } + } + val payloadEncoded = encodeBase64Url(payloadJson.toString().toByteArray(Charsets.UTF_8)) + + val signatureInput = "$headerEncoded.$payloadEncoded".toByteArray(Charsets.UTF_8) + + //4. Sign the data + val signature = signData(signatureInput, privateKey) + return "$headerEncoded.$payloadEncoded.${signature}" + } + + + @Throws(DPoPException::class) + @JvmStatic + internal fun getPublicKeyJWK(): String? { + if (!hasKeyPair()) { + Log.e(TAG, "getPublicKeyJWK: Key pair is not present to generate JWK") + return null + } + + val publicKey = keyStore.getKeyPair()?.second + publicKey ?: return null + if (publicKey !is ECPublicKey) { + Log.e(TAG, "Key is not a ECPublicKey: ${publicKey.javaClass.name}") + return null + } + val jwkJson = createJWK(publicKey) + return createSHA256Hash(jwkJson.toString()) + } + + + @Throws(DPoPException::class) + @JvmStatic + internal fun generateKeyPair(context: Context) { + if (hasKeyPair()) { + return + } + keyStore.generateKeyPair(context) + } + + @Throws(DPoPException::class) + @JvmStatic + internal fun hasKeyPair(): Boolean { + return keyStore.hasKeyPair() + } + + @Throws(DPoPException::class) + @JvmStatic + internal fun clearKeyPair() { + keyStore.deleteKeyPair() + } + + internal fun isResourceServerNonceError(response: Response): Boolean { + val header = response.headers["WWW-Authenticate"] + header ?: return false + val headerMap = header.split(", ") + .map { it.split("=", limit = 2) } + .associate { + val key = it[0].trim() + val value = it.getOrNull(1)?.trim()?.removeSurrounding("\"") + key to (value ?: "") + } + return headerMap["DPoP error"] == NONCE_REQUIRED_ERROR + } + + private fun createJWK(publicKey: ECPublicKey): JSONObject { + val point = publicKey.w + + val x = point.affineX + val y = point.affineY + + val xBytes = padTo32Bytes(x) + val yBytes = padTo32Bytes(y) + return JSONObject().apply { + put("crv", "P-256") + put("kty", "EC") + put("x", encodeBase64Url(xBytes)) + put("y", encodeBase64Url(yBytes)) + } + } + + private fun createSHA256Hash(input: String): String { + val digest = MessageDigest.getInstance("SHA-256") + val hash = digest.digest(input.toByteArray(Charsets.UTF_8)) + return encodeBase64Url(hash) + } + + private fun padTo32Bytes(coordinate: BigInteger): ByteArray { + var bytes = coordinate.toByteArray() + if (bytes.size > 1 && bytes[0] == 0x00.toByte()) { + bytes = bytes.copyOfRange(1, bytes.size) + } + if (bytes.size < 32) { + val paddedBytes = ByteArray(32) + System.arraycopy(bytes, 0, paddedBytes, 32 - bytes.size, bytes.size) + return paddedBytes + } + return bytes + } + + private fun encodeBase64Url(bytes: ByteArray): String { + return Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) + } + + private fun signData(data: ByteArray, privateKey: PrivateKey): String? { + try { + val signatureBytes = Signature.getInstance("SHA256withECDSA").run { + initSign(privateKey) + update(data) + sign() + } + return encodeBase64Url(convertDerToRawSignature(signatureBytes)) + } catch (e: Exception) { + Log.e(TAG, "Error signing data: ${e.stackTraceToString()}") + throw DPoPException(DPoPException.Code.SIGNING_ERROR, e) + } + } + + private fun convertDerToRawSignature(derSignature: ByteArray): ByteArray { + // DER format: SEQUENCE (0x30) + length + INTEGER (0x02) + length + R + INTEGER (0x02) + length + S + var offset = 0 + if (derSignature[offset++] != 0x30.toByte()) throw SignatureException("Invalid DER signature: Expected SEQUENCE") + val length = decodeLength(derSignature, offset).also { offset += it.second }.first + if (length + offset != derSignature.size) throw SignatureException("Invalid DER signature: Length mismatch") + + if (derSignature[offset++] != 0x02.toByte()) throw SignatureException("Invalid DER signature: Expected INTEGER for R") + val rLength = decodeLength(derSignature, offset).also { offset += it.second }.first + var r = derSignature.copyOfRange(offset, offset + rLength) + offset += rLength + + if (derSignature[offset++] != 0x02.toByte()) throw SignatureException("Invalid DER signature: Expected INTEGER for S") + val sLength = decodeLength(derSignature, offset).also { offset += it.second }.first + var s = derSignature.copyOfRange(offset, offset + sLength) + offset += sLength + + // Remove leading zero if present + if (r.size > 1 && r[0] == 0x00.toByte() && (r[1].toInt() and 0x80) == 0x80) + r = r.copyOfRange(1, r.size) + if (s.size > 1 && s[0] == 0x00.toByte() && (s[1].toInt() and 0x80) == 0x80) + s = s.copyOfRange(1, s.size) + + // Pad with leading zeros to 32 bytes for P-256 + val rawR = ByteArray(32) + System.arraycopy(r, 0, rawR, 32 - r.size, r.size) + val rawS = ByteArray(32) + System.arraycopy(s, 0, rawS, 32 - s.size, s.size) + + return rawR + rawS + } + + private fun decodeLength(data: ByteArray, offset: Int): Pair { + var len = data[offset].toInt() and 0xFF + var bytesConsumed = 1 + if ( (len and 0x80) != 0) { + val numBytes = len and 0x7F + len = 0 + for (i in 0 until numBytes) { + len = (len shl 8) or (data[offset + 1 + i].toInt() and 0xFF) + } + bytesConsumed += numBytes + } + return Pair(len, bytesConsumed) + } + + private fun cleanUrl(url: String): String { + return try { + val uri = java.net.URI(url) + val cleanedUri = java.net.URI( + uri.scheme, + uri.userInfo, + uri.host, + uri.port, + uri.path, + null, // Remove query + null // Remove fragment + ) + cleanedUri.toString() + } catch (e: URISyntaxException) { + Log.d(TAG, "Failed to parse URL", e) + throw DPoPException.MALFORMED_URL + } + } + +} \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/dpop/SenderConstraining.kt b/auth0/src/main/java/com/auth0/android/dpop/SenderConstraining.kt new file mode 100644 index 000000000..7e08de910 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/dpop/SenderConstraining.kt @@ -0,0 +1,14 @@ +package com.auth0.android.dpop + +import android.content.Context + +/** + * Interface for SenderConstraining + */ +public interface SenderConstraining> { + + /** + * Method to enable DPoP in the request. + */ + public fun useDPoP(context: Context): T +} \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt b/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt index 3413c99aa..acc2b2864 100644 --- a/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt +++ b/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt @@ -2,17 +2,17 @@ package com.auth0.android.provider import android.content.Context import android.net.Uri -import android.os.Bundle import android.text.TextUtils import android.util.Base64 import android.util.Log -import androidx.annotation.ChecksSdkIntAtLeast import androidx.annotation.VisibleForTesting import com.auth0.android.Auth0 import com.auth0.android.Auth0Exception import com.auth0.android.authentication.AuthenticationAPIClient import com.auth0.android.authentication.AuthenticationException import com.auth0.android.callback.Callback +import com.auth0.android.dpop.DPoP +import com.auth0.android.dpop.DPoPException import com.auth0.android.request.internal.Jwt import com.auth0.android.request.internal.OidcUtils import com.auth0.android.result.Credentials @@ -25,7 +25,9 @@ internal class OAuthManager( parameters: Map, ctOptions: CustomTabsOptions, private val launchAsTwa: Boolean = false, - private val customAuthorizeUrl: String? = null + private val customAuthorizeUrl: String? = null, + @get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal val dPoP: DPoP? = null ) : ResumableManager() { private val parameters: MutableMap private val headers: MutableMap @@ -61,8 +63,19 @@ internal class OAuthManager( fun startAuthentication(context: Context, redirectUri: String, requestCode: Int) { OidcUtils.includeDefaultScope(parameters) - addPKCEParameters(parameters, redirectUri, headers) + addPKCEParameters(parameters, redirectUri, headers, context) addClientParameters(parameters, redirectUri) + try { + addDPoPJWKParameters(parameters) + } catch (ex: DPoPException) { + callback.onFailure( + AuthenticationException( + ex.message ?: "Error generating the JWK", + ex + ) + ) + return + } addValidationParameters(parameters) val uri = buildAuthorizeUri() this.requestCode = requestCode @@ -220,13 +233,22 @@ internal class OAuthManager( errorDescription ?: "Permissions were not granted. Try again." ) } + ERROR_VALUE_UNAUTHORIZED.equals(errorValue, ignoreCase = true) -> { - throw AuthenticationException(ERROR_VALUE_UNAUTHORIZED, errorDescription ?: unknownErrorDescription) + throw AuthenticationException( + ERROR_VALUE_UNAUTHORIZED, + errorDescription ?: unknownErrorDescription + ) } + ERROR_VALUE_LOGIN_REQUIRED == errorValue -> { //Whitelist to allow SSO errors go through - throw AuthenticationException(errorValue, errorDescription ?: unknownErrorDescription) + throw AuthenticationException( + errorValue, + errorDescription ?: unknownErrorDescription + ) } + else -> { throw AuthenticationException( errorValue, @@ -251,9 +273,10 @@ internal class OAuthManager( private fun addPKCEParameters( parameters: MutableMap, redirectUri: String, - headers: Map + headers: Map, + context: Context ) { - createPKCE(redirectUri, headers) + createPKCE(redirectUri, headers,context) val codeChallenge = pkce!!.codeChallenge parameters[KEY_CODE_CHALLENGE] = codeChallenge parameters[KEY_CODE_CHALLENGE_METHOD] = METHOD_SHA_256 @@ -273,12 +296,22 @@ internal class OAuthManager( parameters[KEY_REDIRECT_URI] = redirectUri } - private fun createPKCE(redirectUri: String, headers: Map) { + private fun createPKCE(redirectUri: String, headers: Map,context: Context) { if (pkce == null) { + // Enable DPoP on the AuthenticationClient if DPoP is set in the WebAuthProvider class + dPoP?.let { + apiClient.useDPoP(context) + } pkce = PKCE(apiClient, redirectUri, headers) } } + private fun addDPoPJWKParameters(parameters: MutableMap) { + dPoP?.getPublicKeyJWK()?.let { + parameters["dpop_jkt"] = it + } + } + companion object { private val TAG = OAuthManager::class.java.simpleName const val KEY_RESPONSE_TYPE = "response_type" diff --git a/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt b/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt index 2a2c71767..ab677af6c 100644 --- a/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt +++ b/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt @@ -6,6 +6,7 @@ import android.util.Base64 import androidx.core.os.ParcelCompat import com.auth0.android.Auth0 import com.auth0.android.authentication.AuthenticationAPIClient +import com.auth0.android.dpop.DPoP import com.auth0.android.request.internal.GsonProvider import com.google.gson.Gson @@ -18,7 +19,8 @@ internal data class OAuthManagerState( val pkce: PKCE?, val idTokenVerificationLeeway: Int?, val idTokenVerificationIssuer: String?, - val customAuthorizeUrl: String? = null + val customAuthorizeUrl: String? = null, + val dPoP: DPoP? = null ) { private class OAuthManagerJson( @@ -34,7 +36,8 @@ internal data class OAuthManagerState( val codeVerifier: String, val idTokenVerificationLeeway: Int?, val idTokenVerificationIssuer: String?, - val customAuthorizeUrl: String? = null + val customAuthorizeUrl: String? = null, + val dPoP: DPoP? = null ) fun serializeToJson( @@ -58,7 +61,8 @@ internal data class OAuthManagerState( codeChallenge = pkce?.codeChallenge.orEmpty(), idTokenVerificationIssuer = idTokenVerificationIssuer, idTokenVerificationLeeway = idTokenVerificationLeeway, - customAuthorizeUrl = this.customAuthorizeUrl + customAuthorizeUrl = this.customAuthorizeUrl, + dPoP = this.dPoP ) return gson.toJson(json) } finally { @@ -75,7 +79,8 @@ internal data class OAuthManagerState( try { val oauthManagerJson = gson.fromJson(json, OAuthManagerJson::class.java) - val decodedCtOptionsBytes = Base64.decode(oauthManagerJson.ctOptions, Base64.DEFAULT) + val decodedCtOptionsBytes = + Base64.decode(oauthManagerJson.ctOptions, Base64.DEFAULT) parcel.unmarshall(decodedCtOptionsBytes, 0, decodedCtOptionsBytes.size) parcel.setDataPosition(0) @@ -106,7 +111,8 @@ internal data class OAuthManagerState( ), idTokenVerificationIssuer = oauthManagerJson.idTokenVerificationIssuer, idTokenVerificationLeeway = oauthManagerJson.idTokenVerificationLeeway, - customAuthorizeUrl = oauthManagerJson.customAuthorizeUrl + customAuthorizeUrl = oauthManagerJson.customAuthorizeUrl, + dPoP = oauthManagerJson.dPoP ) } finally { parcel.recycle() diff --git a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt index dff8a48a9..955eec9c5 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -9,6 +9,8 @@ import androidx.annotation.VisibleForTesting import com.auth0.android.Auth0 import com.auth0.android.authentication.AuthenticationException import com.auth0.android.callback.Callback +import com.auth0.android.dpop.DPoP +import com.auth0.android.dpop.SenderConstraining import com.auth0.android.result.Credentials import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine @@ -25,9 +27,10 @@ import kotlin.coroutines.resumeWithException * * It uses an external browser by sending the [android.content.Intent.ACTION_VIEW] intent. */ -public object WebAuthProvider { +public object WebAuthProvider : SenderConstraining { private val TAG: String? = WebAuthProvider::class.simpleName private const val KEY_BUNDLE_OAUTH_MANAGER_STATE = "oauth_manager_state" + private var dPoP : DPoP? = null private val callbacks = CopyOnWriteArraySet>() @@ -47,6 +50,11 @@ public object WebAuthProvider { } // Public methods + public override fun useDPoP(context: Context): WebAuthProvider { + dPoP = DPoP(context) + return this + } + /** * Initialize the WebAuthProvider instance for logging out the user using an account. Additional settings can be configured * in the LogoutBuilder, like changing the scheme of the return to URL. @@ -580,8 +588,10 @@ public object WebAuthProvider { values[OAuthManager.KEY_ORGANIZATION] = organizationId values[OAuthManager.KEY_INVITATION] = invitationId } - val manager = OAuthManager(account, callback, values, ctOptions, launchAsTwa, - customAuthorizeUrl) + val manager = OAuthManager( + account, callback, values, ctOptions, launchAsTwa, + customAuthorizeUrl, dPoP + ) manager.setHeaders(headers) manager.setPKCE(pkce) manager.setIdTokenVerificationLeeway(leeway) diff --git a/auth0/src/main/java/com/auth0/android/request/DefaultClient.kt b/auth0/src/main/java/com/auth0/android/request/DefaultClient.kt index 4595e1517..928120e63 100644 --- a/auth0/src/main/java/com/auth0/android/request/DefaultClient.kt +++ b/auth0/src/main/java/com/auth0/android/request/DefaultClient.kt @@ -43,7 +43,15 @@ public class DefaultClient @VisibleForTesting(otherwise = VisibleForTesting.PRIV readTimeout: Int = DEFAULT_TIMEOUT_SECONDS, defaultHeaders: Map = mapOf(), enableLogging: Boolean = false, - ) : this(connectTimeout, readTimeout, defaultHeaders, enableLogging, GsonProvider.gson, null, null) + ) : this( + connectTimeout, + readTimeout, + defaultHeaders, + enableLogging, + GsonProvider.gson, + null, + null + ) @get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal val okHttpClient: OkHttpClient @@ -88,6 +96,7 @@ public class DefaultClient @VisibleForTesting(otherwise = VisibleForTesting.PRIV init { // client setup val builder = OkHttpClient.Builder() + builder.addInterceptor(RetryInterceptor()) // logging if (enableLogging) { diff --git a/auth0/src/main/java/com/auth0/android/request/ErrorBody.kt b/auth0/src/main/java/com/auth0/android/request/ErrorBody.kt new file mode 100644 index 000000000..a99e4ee87 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/request/ErrorBody.kt @@ -0,0 +1,31 @@ +package com.auth0.android.request + +import android.util.Log +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import okhttp3.Response +import java.io.InputStreamReader + +/** + * A generic error body that can be returned by the Auth0 API. + */ +public data class ErrorBody( + @SerializedName("error") + val errorCode: String, + @SerializedName("error_description") + val description: String +) + +/** + * Extension method to parse [ErrorBody] from [Response] + */ +public fun Response.getErrorBody(): ErrorBody { + return try { + val peekedBody = peekBody(Long.MAX_VALUE) + val bodyString = peekedBody.string() + Gson().fromJson(bodyString, ErrorBody::class.java) + } catch (error: Exception) { + Log.e("ErrorBody", "Error parsing the error body ${error.stackTraceToString()}") + ErrorBody("", "") + } +} diff --git a/auth0/src/main/java/com/auth0/android/request/ProfileRequest.kt b/auth0/src/main/java/com/auth0/android/request/ProfileRequest.kt index 868e44d2c..d8cbc1e35 100755 --- a/auth0/src/main/java/com/auth0/android/request/ProfileRequest.kt +++ b/auth0/src/main/java/com/auth0/android/request/ProfileRequest.kt @@ -6,8 +6,6 @@ import com.auth0.android.callback.Callback import com.auth0.android.result.Authentication import com.auth0.android.result.Credentials import com.auth0.android.result.UserProfile -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext /** * Request to fetch a profile after a successful authentication with Auth0 Authentication API @@ -80,7 +78,10 @@ public class ProfileRequest authenticationRequest.start(object : Callback { override fun onSuccess(credentials: Credentials) { userInfoRequest - .addHeader(HEADER_AUTHORIZATION, "Bearer " + credentials.accessToken) + .addHeader( + HEADER_AUTHORIZATION, + "${credentials.type} ${credentials.accessToken}" + ) .start(object : Callback { override fun onSuccess(profile: UserProfile) { callback.onSuccess(Authentication(profile, credentials)) @@ -108,7 +109,7 @@ public class ProfileRequest override fun execute(): Authentication { val credentials = authenticationRequest.execute() val profile = userInfoRequest - .addHeader(HEADER_AUTHORIZATION, "Bearer " + credentials.accessToken) + .addHeader(HEADER_AUTHORIZATION, "${credentials.type} ${credentials.accessToken}") .execute() return Authentication(profile, credentials) } @@ -125,7 +126,7 @@ public class ProfileRequest override suspend fun await(): Authentication { val credentials = authenticationRequest.await() val profile = userInfoRequest - .addHeader(HEADER_AUTHORIZATION, "Bearer " + credentials.accessToken) + .addHeader(HEADER_AUTHORIZATION, "${credentials.type} ${credentials.accessToken}") .await() return Authentication(profile, credentials) } diff --git a/auth0/src/main/java/com/auth0/android/request/Request.kt b/auth0/src/main/java/com/auth0/android/request/Request.kt index 287c38e99..b06c1bc23 100755 --- a/auth0/src/main/java/com/auth0/android/request/Request.kt +++ b/auth0/src/main/java/com/auth0/android/request/Request.kt @@ -64,7 +64,7 @@ public interface Request { * @param value of the parameter * @return itself */ - public fun addParameter(name: String,value:Any):Request { + public fun addParameter(name: String, value: Any): Request { return this } diff --git a/auth0/src/main/java/com/auth0/android/request/RetryInterceptor.kt b/auth0/src/main/java/com/auth0/android/request/RetryInterceptor.kt new file mode 100644 index 000000000..e733b02f1 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/request/RetryInterceptor.kt @@ -0,0 +1,49 @@ +package com.auth0.android.request + +import com.auth0.android.dpop.DPoP +import com.auth0.android.dpop.DPoPUtil +import okhttp3.Interceptor +import okhttp3.Response + +/** + * Interceptor that retries requests. + */ +internal class RetryInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val retryCountHeader = request.header(RETRY_COUNT_HEADER) + val currentRetryCount = retryCountHeader?.toIntOrNull() ?: 0 + val response = chain.proceed(request) + + //Storing the DPoP nonce if present in the response + DPoP.storeNonce(response) + + //Handling DPoP Nonce retry + if (DPoP.isNonceRequiredError(response) && currentRetryCount < DPoPUtil.MAX_RETRY_COUNT) { + val accessToken = + request.headers[AUTHORIZATION_HEADER]?.substringAfter(DPOP_LIMITER)?.trim() + val dpopProof = DPoPUtil.generateProof( + httpUrl = request.url.toString(), + httpMethod = request.method, + accessToken = accessToken, + nonce = DPoP.auth0Nonce + ) + if (dpopProof != null) { + response.close() + val newRequest = request.newBuilder() + .header(DPoPUtil.DPOP_HEADER, dpopProof) + .header(RETRY_COUNT_HEADER, (currentRetryCount + 1).toString()) + .build() + return chain.proceed(newRequest) + } + } + return response + } + + private companion object { + private const val RETRY_COUNT_HEADER = "X-Internal-Retry-Count" + private const val AUTHORIZATION_HEADER = "Authorization" + private const val DPOP_LIMITER = "DPoP " + } + +} \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/request/SignUpRequest.kt b/auth0/src/main/java/com/auth0/android/request/SignUpRequest.kt index a4d25ef2f..285664ef0 100755 --- a/auth0/src/main/java/com/auth0/android/request/SignUpRequest.kt +++ b/auth0/src/main/java/com/auth0/android/request/SignUpRequest.kt @@ -6,8 +6,6 @@ import com.auth0.android.authentication.ParameterBuilder import com.auth0.android.callback.Callback import com.auth0.android.result.Credentials import com.auth0.android.result.DatabaseUser -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext /** * Represent a request that creates a user in a Auth0 Database connection and then logs in. diff --git a/auth0/src/main/java/com/auth0/android/request/internal/BaseAuthenticationRequest.kt b/auth0/src/main/java/com/auth0/android/request/internal/BaseAuthenticationRequest.kt index edd7fcebd..a62716dea 100755 --- a/auth0/src/main/java/com/auth0/android/request/internal/BaseAuthenticationRequest.kt +++ b/auth0/src/main/java/com/auth0/android/request/internal/BaseAuthenticationRequest.kt @@ -8,8 +8,6 @@ import com.auth0.android.authentication.AuthenticationException import com.auth0.android.authentication.ParameterBuilder import com.auth0.android.callback.Callback import com.auth0.android.provider.* -import com.auth0.android.provider.IdTokenVerificationOptions -import com.auth0.android.provider.IdTokenVerifier import com.auth0.android.request.AuthenticationRequest import com.auth0.android.request.Request import com.auth0.android.result.Credentials diff --git a/auth0/src/main/java/com/auth0/android/request/internal/BaseRequest.kt b/auth0/src/main/java/com/auth0/android/request/internal/BaseRequest.kt index 9e48edb78..1f0c53e23 100755 --- a/auth0/src/main/java/com/auth0/android/request/internal/BaseRequest.kt +++ b/auth0/src/main/java/com/auth0/android/request/internal/BaseRequest.kt @@ -3,7 +3,16 @@ package com.auth0.android.request.internal import androidx.annotation.VisibleForTesting import com.auth0.android.Auth0Exception import com.auth0.android.callback.Callback -import com.auth0.android.request.* +import com.auth0.android.dpop.DPoP +import com.auth0.android.dpop.DPoPException +import com.auth0.android.dpop.DPoPUtil +import com.auth0.android.request.ErrorAdapter +import com.auth0.android.request.HttpMethod +import com.auth0.android.request.JsonAdapter +import com.auth0.android.request.NetworkingClient +import com.auth0.android.request.Request +import com.auth0.android.request.RequestOptions +import com.auth0.android.request.ServerResponse import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -20,12 +29,13 @@ import java.nio.charset.StandardCharsets * @param errorAdapter the adapter that will convert a failed response into the expected type. */ internal open class BaseRequest( - method: HttpMethod, + private val method: HttpMethod, private val url: String, private val client: NetworkingClient, private val resultAdapter: JsonAdapter, private val errorAdapter: ErrorAdapter, - private val threadSwitcher: ThreadSwitcher = CommonThreadSwitcher.getInstance() + private val threadSwitcher: ThreadSwitcher = CommonThreadSwitcher.getInstance(), + private val dPoP: DPoP? = null ) : Request { private val options: RequestOptions = RequestOptions(method) @@ -118,7 +128,15 @@ internal open class BaseRequest( override fun execute(): T { val response: ServerResponse try { + if (dPoP?.shouldGenerateProof(url, options.parameters) == true) { + dPoP.generateKeyPair() + dPoP.generateProof(url, method, options.headers)?.let { + options.headers[DPoPUtil.DPOP_HEADER] = it + } + } response = client.load(url, options) + } catch (exception: DPoPException) { + throw errorAdapter.fromException(exception) } catch (exception: IOException) { //1. Network exceptions, timeouts, etc val error: U = errorAdapter.fromException(exception) @@ -129,7 +147,7 @@ internal open class BaseRequest( if (response.isSuccess()) { //2. Successful scenario. Response of type T return try { - resultAdapter.fromJson(reader,response.headers) + resultAdapter.fromJson(reader, response.headers) } catch (exception: Exception) { //multi catch IOException and JsonParseException (including JsonIOException) //3. Network exceptions, timeouts, etc reading response body diff --git a/auth0/src/main/java/com/auth0/android/request/internal/Jwt.kt b/auth0/src/main/java/com/auth0/android/request/internal/Jwt.kt index f7488f738..09558f650 100644 --- a/auth0/src/main/java/com/auth0/android/request/internal/Jwt.kt +++ b/auth0/src/main/java/com/auth0/android/request/internal/Jwt.kt @@ -10,8 +10,8 @@ import java.util.* */ internal class Jwt(rawToken: String) { - private val decodedHeader: Map - private val decodedPayload: Map + val decodedHeader: Map + val decodedPayload: Map val parts: Array // header diff --git a/auth0/src/main/java/com/auth0/android/request/internal/RequestFactory.kt b/auth0/src/main/java/com/auth0/android/request/internal/RequestFactory.kt index 4e00befe4..a1918eaa8 100755 --- a/auth0/src/main/java/com/auth0/android/request/internal/RequestFactory.kt +++ b/auth0/src/main/java/com/auth0/android/request/internal/RequestFactory.kt @@ -2,6 +2,7 @@ package com.auth0.android.request.internal import androidx.annotation.VisibleForTesting import com.auth0.android.Auth0Exception +import com.auth0.android.dpop.DPoP import com.auth0.android.request.* import com.auth0.android.util.Auth0UserAgent import java.io.Reader @@ -28,30 +29,34 @@ internal class RequestFactory internal constructor( fun post( url: String, - resultAdapter: JsonAdapter - ): Request = setupRequest(HttpMethod.POST, url, resultAdapter, errorAdapter) + resultAdapter: JsonAdapter, + dPoP: DPoP? = null, + ): Request = setupRequest(HttpMethod.POST, url, resultAdapter, errorAdapter, dPoP) - fun post(url: String): Request = - this.post(url, object : JsonAdapter { + fun post(url: String, dPoP: DPoP? = null): Request = + this.post(url, object : JsonAdapter { override fun fromJson(reader: Reader, metadata: Map): Void? { return null } - }) + },dPoP) fun patch( url: String, - resultAdapter: JsonAdapter - ): Request = setupRequest(HttpMethod.PATCH, url, resultAdapter, errorAdapter) + resultAdapter: JsonAdapter, + dPoP: DPoP? = null + ): Request = setupRequest(HttpMethod.PATCH, url, resultAdapter, errorAdapter, dPoP) fun delete( url: String, - resultAdapter: JsonAdapter - ): Request = setupRequest(HttpMethod.DELETE, url, resultAdapter, errorAdapter) + resultAdapter: JsonAdapter, + dPoP: DPoP? = null + ): Request = setupRequest(HttpMethod.DELETE, url, resultAdapter, errorAdapter, dPoP) fun get( url: String, - resultAdapter: JsonAdapter - ): Request = setupRequest(HttpMethod.GET, url, resultAdapter, errorAdapter) + resultAdapter: JsonAdapter, + dPoP: DPoP? = null + ): Request = setupRequest(HttpMethod.GET, url, resultAdapter, errorAdapter, dPoP) fun setHeader(name: String, value: String) { baseHeaders[name] = value @@ -68,15 +73,18 @@ internal class RequestFactory internal constructor( client: NetworkingClient, resultAdapter: JsonAdapter, errorAdapter: ErrorAdapter, - threadSwitcher: ThreadSwitcher - ): Request = BaseRequest(method, url, client, resultAdapter, errorAdapter, threadSwitcher) + threadSwitcher: ThreadSwitcher, + dPoP: DPoP? = null + ): Request = + BaseRequest(method, url, client, resultAdapter, errorAdapter, threadSwitcher, dPoP) private fun setupRequest( method: HttpMethod, url: String, resultAdapter: JsonAdapter, - errorAdapter: ErrorAdapter + errorAdapter: ErrorAdapter, + dPoP: DPoP? = null ): Request { val request = createRequest( @@ -85,7 +93,8 @@ internal class RequestFactory internal constructor( client, resultAdapter, errorAdapter, - CommonThreadSwitcher.getInstance() + CommonThreadSwitcher.getInstance(), + dPoP ) baseHeaders.map { request.addHeader(it.key, it.value) } return request diff --git a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt index 3c42da6f6..b3e67098f 100755 --- a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt @@ -4,6 +4,11 @@ import android.content.Context import android.content.res.Resources import com.auth0.android.Auth0 import com.auth0.android.authentication.ParameterBuilder.Companion.newBuilder +import com.auth0.android.dpop.DPoPException +import com.auth0.android.dpop.DPoPKeyStore +import com.auth0.android.dpop.DPoPUtil +import com.auth0.android.dpop.FakeECPrivateKey +import com.auth0.android.dpop.FakeECPublicKey import com.auth0.android.provider.JwtTestUtils import com.auth0.android.request.HttpMethod import com.auth0.android.request.NetworkingClient @@ -41,9 +46,13 @@ import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers import org.hamcrest.collection.IsMapContaining import org.junit.After +import org.junit.Assert +import org.junit.Assert.assertThrows import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.Mockito.never +import org.mockito.Mockito.times import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowLooper @@ -59,13 +68,18 @@ public class AuthenticationAPIClientTest { private lateinit var client: AuthenticationAPIClient private lateinit var gson: Gson private lateinit var mockAPI: AuthenticationAPIMockServer + private lateinit var mockKeyStore: DPoPKeyStore + private lateinit var mockContext: Context @Before public fun setUp() { mockAPI = AuthenticationAPIMockServer() + mockKeyStore = mock() + mockContext = mock() val auth0 = auth0 client = AuthenticationAPIClient(auth0) gson = GsonBuilder().serializeNulls().create() + DPoPUtil.keyStore = mockKeyStore } @After @@ -193,8 +207,10 @@ public class AuthenticationAPIClientTest { val callback = MockAuthenticationCallback() val auth0 = auth0 val client = AuthenticationAPIClient(auth0) - client.signinWithPasskey("auth-session", mock(), MY_CONNECTION, - "testOrganisation") + client.signinWithPasskey( + "auth-session", mock(), MY_CONNECTION, + "testOrganisation" + ) .start(callback) ShadowLooper.idleMainLooper() assertThat( @@ -592,7 +608,7 @@ public class AuthenticationAPIClientTest { public fun shouldFetchUserInfo() { mockAPI.willReturnUserInfo() val callback = MockAuthenticationCallback() - client.userInfo("ACCESS_TOKEN") + client.userInfo("ACCESS_TOKEN", "Bearer") .start(callback) ShadowLooper.idleMainLooper() assertThat( @@ -617,7 +633,7 @@ public class AuthenticationAPIClientTest { public fun shouldFetchUserInfoSync() { mockAPI.willReturnUserInfo() val profile = client - .userInfo("ACCESS_TOKEN") + .userInfo("ACCESS_TOKEN", "Bearer") .execute() assertThat(profile, Matchers.`is`(Matchers.notNullValue())) val request = mockAPI.takeRequest() @@ -638,7 +654,7 @@ public class AuthenticationAPIClientTest { public fun shouldAwaitFetchUserInfo(): Unit = runTest { mockAPI.willReturnUserInfo() val profile = client - .userInfo("ACCESS_TOKEN") + .userInfo("ACCESS_TOKEN", "Bearer") .await() assertThat(profile, Matchers.`is`(Matchers.notNullValue())) val request = mockAPI.takeRequest() @@ -2566,8 +2582,9 @@ public class AuthenticationAPIClientTest { val auth0 = auth0 val client = AuthenticationAPIClient(auth0) mockAPI.willReturnSuccessfulLogin() - val credentials = client.renewAuth(refreshToken = "refreshToken", scope = "openid read:data") - .execute() + val credentials = + client.renewAuth(refreshToken = "refreshToken", scope = "openid read:data") + .execute() val request = mockAPI.takeRequest() assertThat( request.getHeader("Accept-Language"), Matchers.`is`( @@ -2727,6 +2744,381 @@ public class AuthenticationAPIClientTest { ) } + //DPoP + @Test + public fun shouldNotAddDpopHeaderWhenDpopNotEnabledToTokenEndpoint() { + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + // DPoP is not enabled - dPoP property should be null + client.login(SUPPORT_AUTH0_COM, PASSWORD, MY_CONNECTION) + .start(callback) + ShadowLooper.idleMainLooper() + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("DPoP"), Matchers.nullValue()) + assertThat(request.path, Matchers.equalTo("/oauth/token")) + assertThat( + callback, AuthenticationCallbackMatcher.hasPayloadOfType( + Credentials::class.java + ) + ) + } + + @Test + public fun shouldNotAddDpopHeaderWhenDpopNotEnabledToNonTokenEndpoint() { + mockAPI.willReturnSuccessfulPasskeyChallenge() + val auth0 = auth0 + val client = AuthenticationAPIClient(auth0) + val challengeResponse = client.passkeyChallenge(MY_CONNECTION, "testOrganization") + .execute() + val request = mockAPI.takeRequest() + assertThat(request.getHeader("DPoP"), Matchers.nullValue()) + assertThat(request.path, Matchers.equalTo("/passkey/challenge")) + assertThat(challengeResponse, Matchers.`is`(Matchers.notNullValue())) + } + + @Test + public fun shouldNotAddDpopHeaderWithDpopEnabledToNonTokenEndpoint() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + mockAPI.willReturnSuccessfulPasskeyChallenge() + val auth0 = auth0 + val client = AuthenticationAPIClient(auth0).useDPoP(mockContext) + val challengeResponse = client.passkeyChallenge(MY_CONNECTION, "testOrganization") + .execute() + val request = mockAPI.takeRequest() + assertThat(request.getHeader("DPoP"), Matchers.nullValue()) + assertThat(request.path, Matchers.equalTo("/passkey/challenge")) + assertThat(challengeResponse, Matchers.`is`(Matchers.notNullValue())) + } + + @Test + public fun shouldAddDpopHeaderWhenDpopEnabledAndKeyPairExistsToTokenEndpoint() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + + client.useDPoP(mockContext).login(SUPPORT_AUTH0_COM, PASSWORD, MY_CONNECTION) + .start(callback) + ShadowLooper.idleMainLooper() + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("DPoP"), Matchers.notNullValue()) + assertThat(request.path, Matchers.equalTo("/oauth/token")) + assertThat( + callback, AuthenticationCallbackMatcher.hasPayloadOfType( + Credentials::class.java + ) + ) + } + + @Test + public fun shouldNotAddDpopHeaderToTokenExchangeWhenDPoPEnabledAndNoKeyPairExist() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(false) + + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + + client.useDPoP(mockContext).renewAuth("refresh_token") + .start(callback) + ShadowLooper.idleMainLooper() + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("DPoP"), Matchers.nullValue()) + assertThat(request.path, Matchers.equalTo("/oauth/token")) + assertThat( + callback, AuthenticationCallbackMatcher.hasPayloadOfType( + Credentials::class.java + ) + ) + } + + @Test + public fun shouldNotAddDpopHeaderToTokenExchangeWhenDPoPNotEnabled() { + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + + client.token("auth-code", "code-verifier", "http://redirect.uri") + .start(callback) + ShadowLooper.idleMainLooper() + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("DPoP"), Matchers.nullValue()) + assertThat(request.path, Matchers.equalTo("/oauth/token")) + assertThat( + callback, AuthenticationCallbackMatcher.hasPayloadOfType( + Credentials::class.java + ) + ) + } + + @Test + public fun shouldCreateKeyPairWhenDPoPEnabledButNoKeyPairExistsTokenEndpoint() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(false).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + + // Enable DPoP but ensure no key pair exists initially + client.useDPoP(mockContext).token("auth-code", "code-verifier", "http://redirect.uri") + .start(callback) + ShadowLooper.idleMainLooper() + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("DPoP"), Matchers.notNullValue()) + assertThat(request.path, Matchers.equalTo("/oauth/token")) + + val body = bodyFromRequest(request) + assertThat(body, Matchers.hasEntry("grant_type", ParameterBuilder.GRANT_TYPE_AUTHORIZATION_CODE)) + assertThat(body, Matchers.hasEntry("code", "auth-code")) + + // Verify that key pair generation was attempted + verify(mockKeyStore, times(2)).hasKeyPair() + verify(mockKeyStore).generateKeyPair(mockContext, true) + verify(mockKeyStore).getKeyPair() + + assertThat( + callback, AuthenticationCallbackMatcher.hasPayloadOfType( + Credentials::class.java + ) + ) + } + + @Test + public fun shouldNotCreateKeyPairWhenDPoPEnabledButNoKeyPairExistsForRefreshTokenExchange() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(false) + + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + + // Enable DPoP but ensure no key pair exists + client.useDPoP(mockContext).renewAuth("refresh-token", "test-audience") + .start(callback) + ShadowLooper.idleMainLooper() + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("DPoP"), Matchers.nullValue()) + assertThat(request.path, Matchers.equalTo("/oauth/token")) + + val body = bodyFromRequest(request) + assertThat(body, Matchers.hasEntry("grant_type", "refresh_token")) + assertThat(body, Matchers.hasEntry("refresh_token", "refresh-token")) + assertThat(body, Matchers.hasEntry("audience", "test-audience")) + + verify(mockKeyStore).hasKeyPair() + verify(mockKeyStore, never()).generateKeyPair(any(), any()) + + assertThat( + callback, AuthenticationCallbackMatcher.hasPayloadOfType( + Credentials::class.java + ) + ) + } + + @Test + public fun shouldCreateKeyPairWhenDPoPEnabledButNoKeyPairExistsForCustomTokenExchange() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(false).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + + // Enable DPoP but ensure no key pair exists initially + client.useDPoP(mockContext).customTokenExchange("subject-token-type", "subject-token") + .start(callback) + ShadowLooper.idleMainLooper() + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("DPoP"), Matchers.notNullValue()) + assertThat(request.path, Matchers.equalTo("/oauth/token")) + + val body = bodyFromRequest(request) + assertThat(body, Matchers.hasEntry("grant_type", ParameterBuilder.GRANT_TYPE_TOKEN_EXCHANGE)) + assertThat(body, Matchers.hasEntry("subject_token_type", "subject-token-type")) + + // Verify that key pair generation was attempted + verify(mockKeyStore, times(2)).hasKeyPair() + verify(mockKeyStore).generateKeyPair(mockContext, true) + verify(mockKeyStore).getKeyPair() + + assertThat( + callback, AuthenticationCallbackMatcher.hasPayloadOfType( + Credentials::class.java + ) + ) + } + + @Test + public fun shouldAddDpopHeaderToUserInfoWhenEnabled() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + + mockAPI.willReturnUserInfo() + val callback = MockAuthenticationCallback() + + client.useDPoP(mockContext).userInfo("ACCESS_TOKEN", "DPoP") + .start(callback) + ShadowLooper.idleMainLooper() + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("DPoP"), Matchers.notNullValue()) + assertThat(request.getHeader("Authorization"), Matchers.`is`("DPoP ACCESS_TOKEN")) + assertThat(request.path, Matchers.equalTo("/userinfo")) + assertThat( + callback, AuthenticationCallbackMatcher.hasPayloadOfType( + UserProfile::class.java + ) + ) + } + + @Test + public fun shouldNotAddDpopHeaderToUserInfoWhenNotEnabled() { + mockAPI.willReturnUserInfo() + val callback = MockAuthenticationCallback() + + client.userInfo("ACCESS_TOKEN", "Bearer") + .start(callback) + ShadowLooper.idleMainLooper() + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("DPoP"), Matchers.nullValue()) + assertThat(request.getHeader("Authorization"), Matchers.`is`("Bearer ACCESS_TOKEN")) + assertThat(request.path, Matchers.equalTo("/userinfo")) + assertThat( + callback, AuthenticationCallbackMatcher.hasPayloadOfType( + UserProfile::class.java + ) + ) + } + + @Test + public fun shouldNotAddDpopHeaderToSignupEndpoint() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + + mockAPI.willReturnSuccessfulSignUp() + val callback = MockAuthenticationCallback() + + // DPoP is enabled but signup endpoint should not get DPoP header + client.useDPoP(mockContext).createUser(SUPPORT_AUTH0_COM, PASSWORD, SUPPORT, MY_CONNECTION) + .start(callback) + ShadowLooper.idleMainLooper() + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("DPoP"), Matchers.nullValue()) + assertThat(request.path, Matchers.equalTo("/dbconnections/signup")) + assertThat( + callback, AuthenticationCallbackMatcher.hasPayloadOfType( + DatabaseUser::class.java + ) + ) + } + + @Test + public fun shouldNotAddDpopHeaderToPasswordlessEndpoints() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + + mockAPI.willReturnSuccessfulPasswordlessStart() + val callback = MockAuthenticationCallback() + + // DPoP is enabled but passwordless endpoint should not get DPoP header + client.useDPoP(mockContext).passwordlessWithEmail(SUPPORT_AUTH0_COM, PasswordlessType.CODE) + .start(callback) + ShadowLooper.idleMainLooper() + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("DPoP"), Matchers.nullValue()) + assertThat(request.path, Matchers.equalTo("/passwordless/start")) + assertThat(callback, AuthenticationCallbackMatcher.hasNoError()) + } + + @Test + public fun shouldNotAddDpopHeaderToJwksEndpoint() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + + mockAPI.willReturnEmptyJsonWebKeys() + val callback = MockAuthenticationCallback>() + + // DPoP is enabled but JWKS endpoint should not get DPoP header + client.useDPoP(mockContext).fetchJsonWebKeys() + .start(callback) + ShadowLooper.idleMainLooper() + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("DPoP"), Matchers.nullValue()) + assertThat(request.path, Matchers.equalTo("/.well-known/jwks.json")) + assertThat(callback, AuthenticationCallbackMatcher.hasPayload(emptyMap())) + } + + @Test + public fun shouldAddDpopHeaderToCustomTokenExchangeWhenEnabled() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + + client.useDPoP(mockContext).customTokenExchange("subject-token-type", "subject-token") + .start(callback) + ShadowLooper.idleMainLooper() + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("DPoP"), Matchers.notNullValue()) + assertThat(request.path, Matchers.equalTo("/oauth/token")) + val body = bodyFromRequest(request) + assertThat( + body, + Matchers.hasEntry("grant_type", ParameterBuilder.GRANT_TYPE_TOKEN_EXCHANGE) + ) + assertThat( + callback, AuthenticationCallbackMatcher.hasPayloadOfType( + Credentials::class.java + ) + ) + } + + @Test + public fun shouldAddDpopHeaderToSsoExchangeWhenEnabled() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + + client.useDPoP(mockContext).ssoExchange("refresh-token") + .start(callback) + ShadowLooper.idleMainLooper() + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("DPoP"), Matchers.notNullValue()) + assertThat(request.path, Matchers.equalTo("/oauth/token")) + assertThat( + callback, AuthenticationCallbackMatcher.hasPayloadOfType( + SSOCredentials::class.java + ) + ) + } + + @Test + public fun shouldThrowExceptionWhenKeyPairRetrievalFails() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(null) + + mockAPI.willReturnSuccessfulLogin() + + val exception = assertThrows(AuthenticationException::class.java) { + client.useDPoP(mockContext).login(SUPPORT_AUTH0_COM, PASSWORD, MY_CONNECTION) + .execute() + } + Assert.assertEquals("Key pair is not found in the keystore. Please generate a key pair first.", exception.message) + assertThat(exception.cause, Matchers.notNullValue()) + assertThat(exception.cause, Matchers.instanceOf(DPoPException::class.java )) + } + private fun bodyFromRequest(request: RecordedRequest): Map { val mapType = object : TypeToken?>() {}.type return gson.fromJson(request.body.readUtf8(), mapType) diff --git a/auth0/src/test/java/com/auth0/android/authentication/request/ProfileRequestTest.java b/auth0/src/test/java/com/auth0/android/authentication/request/ProfileRequestTest.java index 7d5e0bb11..6528d46da 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/request/ProfileRequestTest.java +++ b/auth0/src/test/java/com/auth0/android/authentication/request/ProfileRequestTest.java @@ -1,12 +1,26 @@ package com.auth0.android.authentication.request; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + import com.auth0.android.authentication.AuthenticationException; import com.auth0.android.callback.Callback; import com.auth0.android.request.AuthenticationRequest; +import com.auth0.android.request.HttpMethod; import com.auth0.android.request.ProfileRequest; import com.auth0.android.request.Request; import com.auth0.android.result.Authentication; import com.auth0.android.result.Credentials; +import com.auth0.android.result.CredentialsMock; import com.auth0.android.result.UserProfile; import org.junit.Before; @@ -15,20 +29,9 @@ import org.mockito.ArgumentCaptor; import org.robolectric.RobolectricTestRunner; +import java.util.Date; import java.util.Map; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.instanceOf; -import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.notNullValue; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Matchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - @RunWith(RobolectricTestRunner.class) public class ProfileRequestTest { @@ -36,6 +39,8 @@ public class ProfileRequestTest { private Request userInfoMockRequest; private ProfileRequest profileRequest; + private Credentials dummyCredentials = CredentialsMock.Companion.create("idToken", "accessToken", "Bearer", null, new Date(), null); + @Before public void setUp() { userInfoMockRequest = mock(Request.class); @@ -87,7 +92,7 @@ public void shouldSetConnection() { @Test public void shouldReturnAuthenticationAfterStartingTheRequest() { final UserProfile userProfile = mock(UserProfile.class); - final Credentials credentials = mock(Credentials.class); + final Credentials credentials = dummyCredentials; final AuthenticationRequestMock authenticationRequestMock = new AuthenticationRequestMock(credentials, null); final RequestMock tokenInfoRequestMock = new RequestMock(userProfile, null); @@ -130,7 +135,7 @@ public void shouldReturnErrorAfterStartingTheRequestIfAuthenticationRequestFails @Test public void shouldReturnErrorAfterStartingTheRequestIfTokenInfoRequestFails() { - final Credentials credentials = mock(Credentials.class); + final Credentials credentials = dummyCredentials; final AuthenticationException error = mock(AuthenticationException.class); final AuthenticationRequestMock authenticationRequestMock = new AuthenticationRequestMock(credentials, null); @@ -148,7 +153,7 @@ public void shouldReturnErrorAfterStartingTheRequestIfTokenInfoRequestFails() { @Test public void shouldExecuteTheRequest() { - final Credentials credentials = mock(Credentials.class); + final Credentials credentials = dummyCredentials; when(authenticationMockRequest.execute()).thenAnswer(invocation -> credentials); final UserProfile userProfile = mock(UserProfile.class); when(userInfoMockRequest.addParameter(anyString(), anyString())).thenReturn(userInfoMockRequest); diff --git a/auth0/src/test/java/com/auth0/android/authentication/request/RequestMock.java b/auth0/src/test/java/com/auth0/android/authentication/request/RequestMock.java index 69f77d67c..394043995 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/request/RequestMock.java +++ b/auth0/src/test/java/com/auth0/android/authentication/request/RequestMock.java @@ -4,6 +4,7 @@ import com.auth0.android.Auth0Exception; import com.auth0.android.callback.Callback; +import com.auth0.android.request.HttpMethod; import com.auth0.android.request.Request; import java.util.Map; diff --git a/auth0/src/test/java/com/auth0/android/dpop/DPoPKeyStoreTest.kt b/auth0/src/test/java/com/auth0/android/dpop/DPoPKeyStoreTest.kt new file mode 100644 index 000000000..c8a8c6a4c --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/dpop/DPoPKeyStoreTest.kt @@ -0,0 +1,290 @@ +package com.auth0.android.dpop + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Log +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.anyOrNull +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.never +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.`is` +import org.hamcrest.Matchers.notNullValue +import org.hamcrest.Matchers.nullValue +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doNothing +import org.mockito.Mockito.times +import org.mockito.Mockito.`when` +import org.powermock.api.mockito.PowerMockito +import org.powermock.core.classloader.annotations.PrepareForTest +import org.powermock.modules.junit4.PowerMockRunner +import org.powermock.reflect.Whitebox +import java.security.InvalidAlgorithmParameterException +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.KeyStoreException +import java.security.PrivateKey +import java.security.ProviderException +import java.security.PublicKey +import java.security.cert.Certificate +import javax.security.auth.x500.X500Principal + +/** + * Using a subclass of [DPoPKeyStore] to help with mocking the lazy initialized keyStore property + */ +internal class MockableDPoPKeyStore(private val mockKeyStore: KeyStore) : DPoPKeyStore() { + override val keyStore: KeyStore by lazy { mockKeyStore } +} + +@RunWith(PowerMockRunner::class) +@PrepareForTest( + DPoPKeyStore::class, + KeyStore::class, + KeyPairGenerator::class, + KeyGenParameterSpec.Builder::class, + Build.VERSION::class, + X500Principal::class, + Log::class +) +public class DPoPKeyStoreTest { + + private lateinit var mockKeyStore: KeyStore + private lateinit var mockKeyPairGenerator: KeyPairGenerator + private lateinit var mockContext: Context + private lateinit var mockPackageManager: PackageManager + private lateinit var mockSpecBuilder: KeyGenParameterSpec.Builder + + private lateinit var dpopKeyStore: DPoPKeyStore + + @Before + public fun setUp() { + + mockKeyStore = mock() + mockKeyPairGenerator = mock() + mockContext = mock() + mockPackageManager = mock() + mockSpecBuilder = mock() + + PowerMockito.mockStatic(KeyStore::class.java) + PowerMockito.mockStatic(KeyPairGenerator::class.java) + PowerMockito.mockStatic(Log::class.java) + PowerMockito.mockStatic(Build.VERSION::class.java) + Whitebox.setInternalState(Build.VERSION::class.java, "SDK_INT", Build.VERSION_CODES.P) + + PowerMockito.whenNew(KeyGenParameterSpec.Builder::class.java).withAnyArguments() + .thenReturn(mockSpecBuilder) + + PowerMockito.`when`(KeyStore.getInstance("AndroidKeyStore")).thenReturn(mockKeyStore) + doNothing().whenever(mockKeyStore).load(anyOrNull()) + PowerMockito.`when`( + KeyPairGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_EC, + "AndroidKeyStore" + ) + ).thenReturn(mockKeyPairGenerator) + + whenever(mockSpecBuilder.setAlgorithmParameterSpec(any())).thenReturn(mockSpecBuilder) + whenever(mockSpecBuilder.setDigests(any())).thenReturn(mockSpecBuilder) + whenever(mockSpecBuilder.setCertificateSubject(any())).thenReturn(mockSpecBuilder) + whenever(mockSpecBuilder.setCertificateNotBefore(any())).thenReturn(mockSpecBuilder) + whenever(mockSpecBuilder.setCertificateNotAfter(any())).thenReturn(mockSpecBuilder) + whenever(mockSpecBuilder.setIsStrongBoxBacked(any())).thenReturn(mockSpecBuilder) + whenever(mockContext.packageManager).thenReturn(mockPackageManager) + whenever(mockPackageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)).thenReturn( + true + ) + + dpopKeyStore = MockableDPoPKeyStore(mockKeyStore) + } + + @Test + public fun `generateKeyPair should generate a key pair successfully`() { + whenever(mockPackageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)).thenReturn( + false + ) + dpopKeyStore.generateKeyPair(mockContext) + + verify(mockKeyPairGenerator).initialize(mockSpecBuilder.build()) + verify(mockKeyPairGenerator).generateKeyPair() + verify(mockSpecBuilder, never()).setIsStrongBoxBacked(true) + } + + @Test + public fun `generateKeyPair should enable StrongBox when available`() { + dpopKeyStore.generateKeyPair(mockContext) + verify(mockSpecBuilder).setIsStrongBoxBacked(true) + } + + @Test + public fun `generateKeyPair should throw KEY_GENERATION_ERROR when failed to generate key pair`() { + val cause = InvalidAlgorithmParameterException("Exception") + PowerMockito.`when`( + mockKeyPairGenerator.initialize(mockSpecBuilder.build()) + ).thenThrow(cause) + + val exception = assertThrows(DPoPException::class.java) { + dpopKeyStore.generateKeyPair(mockContext) + } + assertEquals(exception.message, DPoPException.KEY_GENERATION_ERROR.message) + assertThat(exception.cause, `is`(cause)) + } + + @Test + public fun `generateKeyPair should throw UNKNOWN_ERROR when any unhandled exception occurs`() { + val cause = RuntimeException("Exception") + PowerMockito.`when`( + mockKeyPairGenerator.initialize(mockSpecBuilder.build()) + ).thenThrow(cause) + + val exception = assertThrows(DPoPException::class.java) { + dpopKeyStore.generateKeyPair(mockContext) + } + assertEquals(exception.message, DPoPException.UNKNOWN_ERROR.message) + assertThat(exception.cause, `is`(cause)) + } + + @Test + public fun `getKeyPair should return key pair when it exists`() { + val mockPrivateKey = mock() + val mockPublicKey = mock() + val mockCertificate = mock() + + whenever(mockKeyStore.getKey(any(), anyOrNull())).thenReturn(mockPrivateKey) + whenever(mockKeyStore.getCertificate(any())).thenReturn(mockCertificate) + whenever(mockCertificate.publicKey).thenReturn(mockPublicKey) + + val keyPair = dpopKeyStore.getKeyPair() + + assertThat(keyPair, `is`(notNullValue())) + assertThat(keyPair!!.first, `is`(mockPrivateKey)) + assertThat(keyPair.second, `is`(mockPublicKey)) + } + + @Test + public fun `getKeyPair should return null when certificate is null`() { + val mockPrivateKey = mock() + whenever(mockKeyStore.getKey(any(), anyOrNull())).thenReturn(mockPrivateKey) + whenever(mockKeyStore.getCertificate(any())).thenReturn(null) + + val keyPair = dpopKeyStore.getKeyPair() + assertThat(keyPair, `is`(nullValue())) + } + + @Test + public fun `getKeyPair should throw KEY_STORE_ERROR on KeyStoreException`() { + val cause = KeyStoreException("Test Exception") + whenever(mockKeyStore.getKey(any(), anyOrNull())).thenThrow(cause) + + val exception = assertThrows(DPoPException::class.java) { + dpopKeyStore.getKeyPair() + } + assertEquals(exception.message, DPoPException.KEY_STORE_ERROR.message) + assertThat(exception.cause, `is`(cause)) + } + + @Test + public fun `hasKeyPair should return true when alias exists`() { + whenever(mockKeyStore.containsAlias(any())).thenReturn(true) + val result = dpopKeyStore.hasKeyPair() + assertThat(result, `is`(true)) + } + + @Test + public fun `hasKeyPair should return false when alias does not exist`() { + whenever(mockKeyStore.containsAlias(any())).thenReturn(false) + val result = dpopKeyStore.hasKeyPair() + assertThat(result, `is`(false)) + } + + @Test + public fun `hasKeyPair should throw KEY_STORE_ERROR on KeyStoreException`() { + val cause = KeyStoreException("Test Exception") + whenever(mockKeyStore.containsAlias(any())).thenThrow(cause) + + val exception = assertThrows(DPoPException::class.java) { + dpopKeyStore.hasKeyPair() + } + assertEquals(exception.message, DPoPException.KEY_STORE_ERROR.message) + assertThat(exception.cause, `is`(cause)) + } + + @Test + public fun `deleteKeyPair should call deleteEntry`() { + dpopKeyStore.deleteKeyPair() + verify(mockKeyStore).deleteEntry(any()) + } + + @Test + public fun `deleteKeyPair should throw KEY_STORE_ERROR on KeyStoreException`() { + val cause = KeyStoreException("Test Exception") + whenever(mockKeyStore.deleteEntry(any())).thenThrow(cause) + + val exception = assertThrows(DPoPException::class.java) { + dpopKeyStore.deleteKeyPair() + } + assertEquals(exception.message, DPoPException.KEY_STORE_ERROR.message) + assertThat(exception.cause, `is`(cause)) + } + + @Test + public fun `generateKeyPair should retry without StrongBox when ProviderException occurs with StrongBox enabled`() { + val providerException = ProviderException("StrongBox attestation failed") + + `when`(mockKeyPairGenerator.generateKeyPair()).thenThrow(providerException) + .thenReturn(mock()) + + dpopKeyStore.generateKeyPair(mockContext) + + verify(mockKeyPairGenerator, times(2)).initialize(mockSpecBuilder.build()) + verify(mockKeyPairGenerator, times(2)).generateKeyPair() + + verify(mockSpecBuilder).setIsStrongBoxBacked(true) // First attempt + verify( + mockSpecBuilder, + never() + ).setIsStrongBoxBacked(false) + } + + @Test + public fun `generateKeyPair should throw KEY_GENERATION_ERROR when ProviderException occurs without StrongBox`() { + val providerException = ProviderException("Key generation failed") + `when`(mockKeyPairGenerator.initialize(mockSpecBuilder.build())).thenThrow(providerException) + + val exception = assertThrows(DPoPException::class.java) { + dpopKeyStore.generateKeyPair(mockContext, useStrongBox = false) + } + + assertEquals(DPoPException.KEY_GENERATION_ERROR.message, exception.message) + assertThat(exception.cause, `is`(providerException)) + + verify(mockKeyPairGenerator, times(1)).initialize(mockSpecBuilder.build()) + } + + @Test + public fun `generateKeyPair should throw KEY_GENERATION_ERROR when ProviderException occurs on retry`() { + val firstException = ProviderException("StrongBox failed") + val secondException = ProviderException("Retry also failed") + + `when`(mockKeyPairGenerator.initialize(mockSpecBuilder.build())) + .thenThrow(firstException) + .thenThrow(secondException) + + val exception = assertThrows(DPoPException::class.java) { + dpopKeyStore.generateKeyPair(mockContext, useStrongBox = true) + } + + assertEquals(DPoPException.KEY_GENERATION_ERROR.message, exception.message) + assertThat(exception.cause, `is`(secondException)) + + verify(mockKeyPairGenerator, times(2)).initialize(mockSpecBuilder.build()) + } +} \ No newline at end of file diff --git a/auth0/src/test/java/com/auth0/android/dpop/DPoPTest.kt b/auth0/src/test/java/com/auth0/android/dpop/DPoPTest.kt new file mode 100644 index 000000000..8bd6e836e --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/dpop/DPoPTest.kt @@ -0,0 +1,569 @@ +package com.auth0.android.dpop + +import android.content.Context +import com.auth0.android.request.HttpMethod +import com.auth0.android.request.internal.Jwt +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +import okhttp3.Headers +import okhttp3.Response +import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.toResponseBody +import org.hamcrest.CoreMatchers.`is` +import org.hamcrest.CoreMatchers.notNullValue +import org.hamcrest.CoreMatchers.nullValue +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +public class DPoPTest { + + private lateinit var mockContext: Context + private lateinit var mockResponse: Response + private lateinit var mockKeyStore: DPoPKeyStore + private lateinit var mockResponseBody: ResponseBody + private lateinit var dPoP: DPoP + + private val testHttpUrl = "https://api.example.com/token" + private val testNonTokenUrl = "https://api.example.com/userinfo" + private val testAccessToken = "test-access-token" + private val testNonce = "test-nonce" + private val fakePrivateKey = FakeECPrivateKey() + private val fakePublicKey = FakeECPublicKey() + private val testPublicJwkHash = "KQ-r0YQMCm0yVnGippcsZK4zO7oGIjOkNRbvILjjBAo" + private val testEncodedAccessToken = "WXSA1LYsphIZPxnnP-TMOtF_C_nPwWp8v0tQZBMcSAU" + + @Before + public fun setUp() { + mockContext = mock() + mockResponse = mock() + mockKeyStore = mock() + mockResponseBody = mock() + dPoP = DPoP(mockContext) + + DPoP._auth0Nonce = null + + DPoPUtil.keyStore = mockKeyStore + } + + @Test + public fun `shouldGenerateProof should return true for token endpoint with non-refresh grant type`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + + val parameters = mapOf("grant_type" to "authorization_code") + val result = dPoP.shouldGenerateProof(testHttpUrl, parameters) + + assertThat(result, `is`(true)) + } + + @Test + public fun `shouldGenerateProof should return true if key pair exist for token endpoint with refresh grant type`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + val parameters = mapOf("grant_type" to "refresh_token") + val result = dPoP.shouldGenerateProof(testHttpUrl, parameters) + + assertThat(result, `is`(true)) + } + + @Test + public fun `shouldGenerateProof should return false if key pair doesn't exist for token endpoint with refresh grant type`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(false) + val parameters = mapOf("grant_type" to "refresh_token") + val result = dPoP.shouldGenerateProof(testHttpUrl, parameters) + + assertThat(result, `is`(false)) + } + + @Test + public fun `shouldGenerateProof should return false for non-token endpoint`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(false) + + val parameters = mapOf("grant_type" to "authorization_code") + val result = dPoP.shouldGenerateProof(testNonTokenUrl, parameters) + + assertThat(result, `is`(false)) + } + + @Test + public fun `shouldGenerateProof should return hasKeyPair result for non-token endpoint`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + + val parameters = mapOf("grant_type" to "authorization_code") + val result = dPoP.shouldGenerateProof(testNonTokenUrl, parameters) + + assertThat(result, `is`(true)) + verify(mockKeyStore).hasKeyPair() + } + + @Test + public fun `shouldGenerateProof should handle missing grant_type parameter`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + + val parameters = mapOf() + val result = dPoP.shouldGenerateProof(testHttpUrl, parameters) + + assertThat(result, `is`(true)) + } + + @Test + public fun `generateProof should extract access token from Authorization header`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + val headers = mapOf("Authorization" to "Bearer test-access-token") + val result = dPoP.generateProof(testHttpUrl, HttpMethod.POST, headers) + + assertThat(result, `is`(notNullValue())) + val proof = Jwt(result!!) + assertThat(proof.decodedPayload["ath"], `is`(notNullValue())) + Assert.assertEquals(proof.decodedPayload["ath"], testEncodedAccessToken) + } + + @Test + public fun `generateProof should extract access token from DPoP Authorization header`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + val headers = mapOf("Authorization" to "DPoP test-access-token") + val result = dPoP.generateProof(testHttpUrl, HttpMethod.POST, headers) + + assertThat(result, `is`(notNullValue())) + val proof = Jwt(result!!) + assertThat(proof.decodedPayload["ath"], `is`(notNullValue())) + Assert.assertEquals(proof.decodedPayload["ath"], testEncodedAccessToken) + } + + @Test + public fun `generateProof should handle missing Authorization header`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + val headers = mapOf() + val result = dPoP.generateProof(testHttpUrl, HttpMethod.POST, headers) + + assertThat(result, `is`(notNullValue())) + val proof = Jwt(result!!) + assertThat(proof.decodedPayload["ath"], `is`(nullValue())) + } + + @Test + public fun `generateProof should return null when no key pair exists`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(false) + + val headers = mapOf("Authorization" to "Bearer test-token") + val result = dPoP.generateProof(testHttpUrl, HttpMethod.POST, headers) + + assertThat(result, `is`(nullValue())) + } + + @Test + public fun `generateKeyPair should delegate to DPoPUtil`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(false) + + dPoP.generateKeyPair() + + verify(mockKeyStore).generateKeyPair(mockContext) + } + + @Test + public fun `generateKeyPair should propagate DPoPException`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(false) + val exception = + DPoPException(DPoPException.Code.KEY_GENERATION_ERROR, "Key generation failed") + whenever(mockKeyStore.generateKeyPair(mockContext)).thenThrow( + exception + ) + + try { + dPoP.generateKeyPair() + Assert.fail("Expected DPoPException to be thrown") + } catch (e: DPoPException) { + assertThat(e, `is`(exception)) + assertThat(e.message, `is`("Key generation failed")) + } + } + + @Test + public fun `getPublicKeyJWK should generate key pair if not exists`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(false).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + val result = dPoP.getPublicKeyJWK() + + verify(mockKeyStore).generateKeyPair(mockContext) + assertThat(result, `is`(testPublicJwkHash)) + } + + @Test + public fun `getPublicKeyJWK should return JWK hash when key pair exists`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + val result = dPoP.getPublicKeyJWK() + + assertThat(result, `is`(testPublicJwkHash)) + } + + @Test + public fun `getPublicKeyJWK should return null when key pair generation fails`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(false).thenReturn(false) + + val result = dPoP.getPublicKeyJWK() + + verify(mockKeyStore).generateKeyPair(mockContext) + assertThat(result, `is`(nullValue())) + } + + @Test + public fun `storeNonce should store nonce from response headers`() { + val expectedNonce = "stored-nonce-value" + whenever(mockResponse.headers).thenReturn( + Headers.Builder().add("DPoP-Nonce", expectedNonce).build() + ) + DPoP.storeNonce(mockResponse) + assertThat(DPoP.auth0Nonce, `is`(expectedNonce)) + } + + @Test + public fun `storeNonce should handle missing DPoP-Nonce header`() { + whenever(mockResponse.headers).thenReturn(Headers.Builder().build()) + DPoP.storeNonce(mockResponse) + assertThat(DPoP.auth0Nonce, `is`(nullValue())) + } + + @Test + public fun `storeNonce should overwrite existing nonce`() { + val firstNonce = "first-nonce" + val secondNonce = "second-nonce" + + val firstResponse = mock() + whenever(firstResponse.headers).thenReturn( + Headers.Builder().add("DPoP-Nonce", firstNonce).build() + ) + DPoP.storeNonce(firstResponse) + assertThat(DPoP.auth0Nonce, `is`(firstNonce)) + + val secondResponse = mock() + whenever(secondResponse.headers).thenReturn( + Headers.Builder().add("DPoP-Nonce", secondNonce).build() + ) + DPoP.storeNonce(secondResponse) + assertThat(DPoP.auth0Nonce, `is`(secondNonce)) + } + + @Test + public fun `storeNonce should not be overwritten by null value`() { + val firstNonce = "first-nonce" + + val firstResponse = mock() + whenever(firstResponse.headers).thenReturn( + Headers.Builder().add("DPoP-Nonce", firstNonce).build() + ) + DPoP.storeNonce(firstResponse) + assertThat(DPoP.auth0Nonce, `is`(firstNonce)) + + val secondResponse = mock() + whenever(secondResponse.headers).thenReturn( + Headers.Builder().build() + ) + DPoP.storeNonce(secondResponse) + assertThat(DPoP.auth0Nonce, `is`(firstNonce)) + } + + @Test + public fun `isNonceRequiredError should return true for 400 response with nonce required error`() { + whenever(mockResponse.peekBody(Long.MAX_VALUE)).thenReturn("{\"error\":\"use_dpop_nonce\"}".toResponseBody()) + whenever(mockResponse.code).thenReturn(400) + + val result = DPoP.isNonceRequiredError(mockResponse) + assertThat(result, `is`(true)) + } + + @Test + public fun `isNonceRequiredError should return true for 401 response with resource server nonce error`() { + whenever(mockResponse.code).thenReturn(401) + whenever(mockResponse.headers).thenReturn( + Headers.Builder().add("WWW-Authenticate", "DPoP error=\"use_dpop_nonce\"").build() + ) + + val result = DPoP.isNonceRequiredError(mockResponse) + assertThat(result, `is`(true)) + } + + @Test + public fun `isNonceRequiredError should return true for 401 response with unquoted use_dpop_nonce error`() { + whenever(mockResponse.code).thenReturn(401) + whenever(mockResponse.headers).thenReturn( + Headers.Builder().add("WWW-Authenticate", "DPoP error=use_dpop_nonce").build() + ) + + val result = DPoP.isNonceRequiredError(mockResponse) + assertThat(result, `is`(true)) + } + + @Test + public fun `isNonceRequiredError should return false for 400 response with different error`() { + whenever(mockResponse.code).thenReturn(400) + whenever(mockResponse.peekBody(Long.MAX_VALUE)).thenReturn("{\"error\":\"invalid_request\"}".toResponseBody()) + + val result = DPoP.isNonceRequiredError(mockResponse) + assertThat(result, `is`(false)) + } + + @Test + public fun `isNonceRequiredError should return false for 401 response without WWW-Authenticate header`() { + whenever(mockResponse.code).thenReturn(401) + whenever(mockResponse.headers).thenReturn(Headers.Builder().build()) + + val result = DPoP.isNonceRequiredError(mockResponse) + assertThat(result, `is`(false)) + } + + @Test + public fun `isNonceRequiredError should return false for 401 response with different WWW-Authenticate error`() { + whenever(mockResponse.code).thenReturn(401) + whenever(mockResponse.headers).thenReturn( + Headers.Builder().add("WWW-Authenticate", "DPoP error=\"invalid_token\"").build() + ) + + val result = DPoP.isNonceRequiredError(mockResponse) + assertThat(result, `is`(false)) + } + + @Test + public fun `isNonceRequiredError should return false for 401 response with malformed WWW-Authenticate header`() { + whenever(mockResponse.code).thenReturn(401) + whenever(mockResponse.headers).thenReturn( + Headers.Builder().add("WWW-Authenticate", "malformed-header-without-scheme").build() + ) + + val result = DPoP.isNonceRequiredError(mockResponse) + assertThat(result, `is`(false)) + } + + @Test + public fun `isNonceRequiredError should return false for 401 response with missing authentication scheme`() { + whenever(mockResponse.code).thenReturn(401) + whenever(mockResponse.headers).thenReturn( + Headers.Builder().add("WWW-Authenticate", "error=\"use_dpop_nonce\"").build() + ) + + val result = DPoP.isNonceRequiredError(mockResponse) + assertThat(result, `is`(false)) + } + + @Test + public fun `isNonceRequiredError should return false for 401 response with Bearer scheme instead of DPoP`() { + whenever(mockResponse.code).thenReturn(401) + whenever(mockResponse.headers).thenReturn( + Headers.Builder().add("WWW-Authenticate", "Bearer error=\"use_dpop_nonce\"").build() + ) + + val result = DPoP.isNonceRequiredError(mockResponse) + assertThat(result, `is`(false)) + } + + @Test + public fun `isNonceRequiredError should return false for different response codes`() { + whenever(mockResponse.code).thenReturn(500) + + val result = DPoP.isNonceRequiredError(mockResponse) + assertThat(result, `is`(false)) + } + + @Test + public fun `getHeaderData should return bearer token when tokenType is not DPoP`() { + val result = DPoP.getHeaderData( + "POST", testHttpUrl, testAccessToken, "Bearer" + ) + + assertThat(result.authorizationHeader, `is`("Bearer $testAccessToken")) + assertThat(result.dpopProof, `is`(nullValue())) + } + + @Test + public fun `getHeaderData should return DPoP token with proof when tokenType is DPoP`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + val result = DPoP.getHeaderData( + "POST", testHttpUrl, testAccessToken, "DPoP" + ) + + assertThat(result.authorizationHeader, `is`("DPoP $testAccessToken")) + assertThat(result.dpopProof, `is`(notNullValue())) + + val proof = Jwt(result.dpopProof!!) + assertThat(proof.decodedHeader["typ"], `is`("dpop+jwt")) + assertThat(proof.decodedHeader["alg"], `is`("ES256")) + assertThat(proof.decodedPayload["htm"], `is`("POST")) + assertThat(proof.decodedPayload["htu"], `is`(testHttpUrl)) + } + + @Test + public fun `getHeaderData should include nonce when provided`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + val result = DPoP.getHeaderData( + "POST", testHttpUrl, testAccessToken, "DPoP", testNonce + ) + + assertThat(result.authorizationHeader, `is`("DPoP $testAccessToken")) + assertThat(result.dpopProof, `is`(notNullValue())) + + val proof = Jwt(result.dpopProof!!) + assertThat(proof.decodedPayload["nonce"], `is`(testNonce)) + } + + @Test + public fun `getHeaderData should handle case insensitive DPoP token type`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + val result = DPoP.getHeaderData( + "POST", testHttpUrl, testAccessToken, "dpop" + ) + + assertThat(result.authorizationHeader, `is`("dpop $testAccessToken")) + assertThat(result.dpopProof, `is`(notNullValue())) + } + + @Test + public fun `getHeaderData should return null proof when no key pair exists`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(false) + + val result = DPoP.getHeaderData( + "POST", testHttpUrl, testAccessToken, "DPoP" + ) + + assertThat(result.authorizationHeader, `is`("DPoP $testAccessToken")) + assertThat(result.dpopProof, `is`(nullValue())) + } + + @Test + public fun `clearKeyPair should clear both key pair and nonce`() { + val mockNonceResponse = mock() + whenever(mockNonceResponse.headers).thenReturn( + Headers.Builder().add("DPoP-Nonce", testNonce).build() + ) + DPoP.storeNonce(mockNonceResponse) + assertThat(DPoP.auth0Nonce, `is`(testNonce)) + + DPoP.clearKeyPair() + + verify(mockKeyStore).deleteKeyPair() + assertThat(DPoP.auth0Nonce, `is`(nullValue())) + } + + @Test + public fun `clearKeyPair should propagate DPoPException from keyStore`() { + whenever(mockKeyStore.deleteKeyPair()).thenThrow( + DPoPException.KEY_STORE_ERROR + ) + + try { + DPoP.clearKeyPair() + Assert.fail("Expected DPoPException to be thrown") + } catch (e: DPoPException) { + assertThat(e, `is`(DPoPException.KEY_STORE_ERROR)) + assertThat(e.message, `is`("Error while accessing the key pair in the keystore.")) + } + } + + @Test + public fun `storeNonce should handle concurrent access safely with synchronized block`() { + val numThreads = 5 + val numIterations = 10 + val threads = mutableListOf() + val allStoredNonces = Collections.synchronizedList(mutableListOf()) + val startLatch = CountDownLatch(1) + val completionLatch = CountDownLatch(numThreads) + + repeat(numThreads) { threadIndex -> + threads.add(Thread { + try { + // Wait for all threads to be ready + startLatch.await() + + repeat(numIterations) { iteration -> + val nonce = "nonce-thread-$threadIndex-iteration-$iteration" + val response = mock() + whenever(response.headers).thenReturn( + Headers.Builder().add("DPoP-Nonce", nonce).build() + ) + + allStoredNonces.add(nonce) + + DPoP.storeNonce(response) + + Thread.sleep(1) + } + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + } finally { + completionLatch.countDown() + } + }) + } + + threads.forEach { it.start() } + startLatch.countDown() // Release all threads at once + + val completed = completionLatch.await(10, TimeUnit.SECONDS) + assertThat("All threads should complete within timeout", completed, `is`(true)) + + // Verify final state + val finalNonce = DPoP.auth0Nonce + assertThat(finalNonce, `is`(notNullValue())) + assertThat(allStoredNonces.contains(finalNonce), `is`(true)) + } + + @Test + public fun `full DPoP flow should work correctly`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(false).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + // 1. Generate key pair + dPoP.generateKeyPair() + verify(mockKeyStore).generateKeyPair(mockContext) + + // 2. Get JWK thumbprint + val jwkThumbprint = dPoP.getPublicKeyJWK() + assertThat(jwkThumbprint, `is`(testPublicJwkHash)) + + // 3. Store nonce from response + val nonceResponse = mock() + whenever(nonceResponse.headers).thenReturn( + Headers.Builder().add("DPoP-Nonce", testNonce).build() + ) + DPoP.storeNonce(nonceResponse) + + // 4. Generate DPoP proof + val headers = mapOf("Authorization" to "DPoP $testAccessToken") + val proof = dPoP.generateProof(testHttpUrl, HttpMethod.POST, headers) + assertThat(proof, `is`(notNullValue())) + + val decodedProof = Jwt(proof!!) + assertThat(decodedProof.decodedPayload["nonce"], `is`(testNonce)) + assertThat(decodedProof.decodedPayload["ath"], `is`(notNullValue())) + + // 5. Get header data + val headerData = DPoP.getHeaderData("POST", testHttpUrl, testAccessToken, "DPoP") + assertThat(headerData.authorizationHeader, `is`("DPoP $testAccessToken")) + assertThat(headerData.dpopProof, `is`(notNullValue())) + + // 6. Clear key pair + DPoP.clearKeyPair() + verify(mockKeyStore).deleteKeyPair() + assertThat(DPoP.auth0Nonce, `is`(nullValue())) + } +} \ No newline at end of file diff --git a/auth0/src/test/java/com/auth0/android/dpop/DPoPUtilTest.kt b/auth0/src/test/java/com/auth0/android/dpop/DPoPUtilTest.kt new file mode 100644 index 000000000..1ba8050a9 --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/dpop/DPoPUtilTest.kt @@ -0,0 +1,385 @@ +package com.auth0.android.dpop + +import android.content.Context +import com.auth0.android.request.internal.Jwt +import com.google.gson.internal.LinkedTreeMap +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.never +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions +import com.nhaarman.mockitokotlin2.whenever +import okhttp3.Response +import org.hamcrest.CoreMatchers.`is` +import org.hamcrest.CoreMatchers.notNullValue +import org.hamcrest.CoreMatchers.nullValue +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Assert +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.security.PrivateKey + +@RunWith(RobolectricTestRunner::class) +public class DPoPUtilTest { + + private lateinit var mockContext: Context + private lateinit var mockPrivateKey: PrivateKey + private lateinit var mockResponse: Response + private lateinit var mockKeyStore: DPoPKeyStore + + private val testHttpUrl = "https://api.example.com/resource" + private val testHttpMethod = "POST" + private val testAccessToken = "test-access-token" + private val testNonce = "test-nonce" + private val fakePrivateKey = FakeECPrivateKey() + private val fakePublicKey = FakeECPublicKey() + private val testEncodedAccessToken = "WXSA1LYsphIZPxnnP-TMOtF_C_nPwWp8v0tQZBMcSAU" + private val testPublicJwkHash = "KQ-r0YQMCm0yVnGippcsZK4zO7oGIjOkNRbvILjjBAo" + private val testProofJwk = + "{crv=P-256, kty=EC, x=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE, y=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAI}" + private val algTyp = "dpop+jwt" + private val alg = "ES256" + + @Before + public fun setUp() { + + mockKeyStore = mock() + mockPrivateKey = mock() + mockContext = mock() + mockResponse = mock() + + DPoPUtil.keyStore = mockKeyStore + } + + @Test + public fun `generateProof should return null when keyStore has no key pair`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(false) + + val result = DPoPUtil.generateProof(testHttpUrl, testHttpMethod) + + assertThat(result, `is`(nullValue())) + verify(mockKeyStore).hasKeyPair() + verifyNoMoreInteractions(mockKeyStore) + } + + @Test + public fun `generateProof should throw exception when keyStore returns null key pair`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(null) + + val exception = assertThrows(DPoPException::class.java) { + DPoPUtil.generateProof(testHttpUrl, testHttpMethod) + } + + Assert.assertEquals( + "Key pair is not found in the keystore. Please generate a key pair first.", + exception.message + ) + verify(mockKeyStore).hasKeyPair() + verify(mockKeyStore).getKeyPair() + } + + + @Test + public fun `generateProof should remove query parameters from URL`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + val urlWithQuery = "https://api.example.com/resource?param1=value1¶m2=value2" + val expectedCleanUrl = "https://api.example.com/resource" + + val result = DPoPUtil.generateProof(urlWithQuery, testHttpMethod) + + assertThat(result, `is`(notNullValue())) + val proof = Jwt(result!!) + Assert.assertEquals(expectedCleanUrl, proof.decodedPayload["htu"] as String) + } + + @Test + public fun `generateProof should remove fragment from URL`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + val urlWithFragment = "https://api.example.com/resource#section1" + val expectedCleanUrl = "https://api.example.com/resource" + + val result = DPoPUtil.generateProof(urlWithFragment, testHttpMethod) + + assertThat(result, `is`(notNullValue())) + val proof = Jwt(result!!) + Assert.assertEquals(expectedCleanUrl, proof.decodedPayload["htu"] as String) + } + + @Test + public fun `generateProof should remove both query parameters and fragment from URL`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + val urlWithQueryAndFragment = "https://api.example.com/resource?param=value#section" + val expectedCleanUrl = "https://api.example.com/resource" + + val result = DPoPUtil.generateProof(urlWithQueryAndFragment, testHttpMethod) + + assertThat(result, `is`(notNullValue())) + val proof = Jwt(result!!) + Assert.assertEquals(expectedCleanUrl, proof.decodedPayload["htu"] as String) + } + + @Test + public fun `generateProof should preserve path in URL when cleaning`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + val urlWithPath = "https://api.example.com/v1/users/123?fields=name,email#profile" + val expectedCleanUrl = "https://api.example.com/v1/users/123" + + val result = DPoPUtil.generateProof(urlWithPath, testHttpMethod) + + assertThat(result, `is`(notNullValue())) + val proof = Jwt(result!!) + Assert.assertEquals(expectedCleanUrl, proof.decodedPayload["htu"] as String) + } + + @Test + public fun `generateProof should preserve port in URL when cleaning`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + val urlWithPort = "https://api.example.com:8443/resource?query=value#fragment" + val expectedCleanUrl = "https://api.example.com:8443/resource" + + val result = DPoPUtil.generateProof(urlWithPort, testHttpMethod) + + assertThat(result, `is`(notNullValue())) + val proof = Jwt(result!!) + Assert.assertEquals(expectedCleanUrl, proof.decodedPayload["htu"] as String) + } + + @Test + public fun `generateProof should handle malformed URL gracefully`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + val malformedUrl = "not-a-valid-url" + + val result = DPoPUtil.generateProof(malformedUrl, testHttpMethod) + + assertThat(result, `is`(notNullValue())) + val proof = Jwt(result!!) + // Should use the original URL if parsing fails + Assert.assertEquals(malformedUrl, proof.decodedPayload["htu"] as String) + } + + @Test + public fun `generateProof should handle URL with no path`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + val urlWithoutPath = "https://api.example.com?query=value#fragment" + val expectedCleanUrl = "https://api.example.com" + + val result = DPoPUtil.generateProof(urlWithoutPath, testHttpMethod) + + assertThat(result, `is`(notNullValue())) + val proof = Jwt(result!!) + Assert.assertEquals(expectedCleanUrl, proof.decodedPayload["htu"] as String) + } + + @Test + public fun `generateProof should generate valid proof with minimal parameters`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + val result = DPoPUtil.generateProof(testHttpUrl, testHttpMethod) + + assertThat(result, `is`(notNullValue())) + val proof = Jwt(result!!) + Assert.assertEquals(proof.decodedHeader["typ"] as String, algTyp) + Assert.assertEquals(proof.decodedHeader["alg"] as String, alg) + Assert.assertEquals( + (proof.decodedHeader["jwk"] as LinkedTreeMap<*, *>).toString(), + testProofJwk + ) + Assert.assertEquals(proof.decodedPayload["htm"] as String, testHttpMethod) + Assert.assertEquals(proof.decodedPayload["htu"] as String, testHttpUrl) + Assert.assertNull(proof.decodedPayload["ath"]) + Assert.assertNull(proof.decodedPayload["nonce"]) + } + + @Test + public fun `generateProof should include all required header fields`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + val proof = DPoPUtil.generateProof(testHttpUrl, testHttpMethod) + assertThat(proof, `is`(notNullValue())) + val decodedProof = Jwt(proof!!) + assertNotNull(decodedProof.decodedHeader["typ"]) + assertNotNull(decodedProof.decodedHeader["alg"]) + assertNotNull(decodedProof.decodedHeader["jwk"]) + } + + @Test + public fun `generateProof should include all required payload fields`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + val proof = + DPoPUtil.generateProof(testHttpUrl, testHttpMethod, testAccessToken, testNonce) + assertThat(proof, `is`(notNullValue())) + val decodedProof = Jwt(proof!!) + assertNotNull(decodedProof.decodedPayload["jti"]) + assertNotNull(decodedProof.decodedPayload["htm"]) + assertNotNull(decodedProof.decodedPayload["htu"]) + assertNotNull(decodedProof.decodedPayload["iat"]) + assertNotNull(decodedProof.decodedPayload["ath"]) + assertNotNull(decodedProof.decodedPayload["nonce"]) + } + + @Test + public fun `generateProof should generate valid proof with access token`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + val result = DPoPUtil.generateProof(testHttpUrl, testHttpMethod, testAccessToken) + + assertThat(result, `is`(notNullValue())) + val proof = Jwt(result!!) + + Assert.assertEquals(proof.decodedHeader["typ"] as String, algTyp) + Assert.assertEquals(proof.decodedHeader["alg"] as String, alg) + Assert.assertEquals( + (proof.decodedHeader["jwk"] as LinkedTreeMap<*, *>).toString(), + testProofJwk + ) + Assert.assertEquals(proof.decodedPayload["htm"] as String, testHttpMethod) + Assert.assertEquals(proof.decodedPayload["htu"] as String, testHttpUrl) + Assert.assertEquals(proof.decodedPayload["ath"] as String, testEncodedAccessToken) + Assert.assertNull(proof.decodedPayload["nonce"]) + } + + @Test + public fun `generateProof should generate valid proof with nonce`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + val result = + DPoPUtil.generateProof(testHttpUrl, testHttpMethod, testAccessToken, testNonce) + + assertThat(result, `is`(notNullValue())) + assertThat(result, `is`(notNullValue())) + val proof = Jwt(result!!) + + Assert.assertEquals(proof.decodedHeader["typ"] as String, algTyp) + Assert.assertEquals(proof.decodedHeader["alg"] as String, alg) + Assert.assertEquals( + (proof.decodedHeader["jwk"] as LinkedTreeMap<*, *>).toString(), + testProofJwk + ) + Assert.assertEquals(proof.decodedPayload["htm"] as String, testHttpMethod) + Assert.assertEquals(proof.decodedPayload["htu"] as String, testHttpUrl) + Assert.assertEquals(proof.decodedPayload["ath"] as String, testEncodedAccessToken) + Assert.assertEquals(proof.decodedPayload["nonce"] as String, testNonce) + } + + + @Test + public fun `generateProof should throw DPoPException when signature fails`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(mockPrivateKey, fakePublicKey)) + val exception = assertThrows(DPoPException::class.java) { + DPoPUtil.generateProof(testHttpUrl, testHttpMethod) + } + Assert.assertEquals("Error while signing the DPoP proof.", exception.message) + } + + @Test + public fun `clearKeyPair should delegate to keyStore`() { + DPoPUtil.clearKeyPair() + verify(mockKeyStore).deleteKeyPair() + } + + @Test + public fun `clearKeyPair should propagate DPoPException from keyStore`() { + whenever(mockKeyStore.deleteKeyPair()).thenThrow(DPoPException(DPoPException.Code.KEY_STORE_ERROR)) + val exception = assertThrows(DPoPException::class.java) { + DPoPUtil.clearKeyPair() + } + Assert.assertEquals( + "Error while accessing the key pair in the keystore.", + exception.message + ) + } + + @Test + public fun `getPublicKeyJWK should return null when no key pair exists`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(false) + val result = DPoPUtil.getPublicKeyJWK() + assertThat(result, `is`(nullValue())) + verify(mockKeyStore).hasKeyPair() + verifyNoMoreInteractions(mockKeyStore) + } + + @Test + public fun `getPublicKeyJWK should return null when key pair is null`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(null) + val result = DPoPUtil.getPublicKeyJWK() + assertThat(result, `is`(nullValue())) + verify(mockKeyStore).hasKeyPair() + verify(mockKeyStore).getKeyPair() + } + + @Test + public fun `getPublicKeyJWK should return null when public key is not ECPublicKey`() { + val mockNonECKey = mock() + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(mockPrivateKey, mockNonECKey)) + val result = DPoPUtil.getPublicKeyJWK() + assertThat(result, `is`(nullValue())) + verify(mockKeyStore).hasKeyPair() + verify(mockKeyStore).getKeyPair() + } + + @Test + public fun `getPublicKeyJWK should return hash of JWK when valid ECPublicKey exists`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + val result = DPoPUtil.getPublicKeyJWK() + + assertThat(result, `is`(notNullValue())) + assertThat(result, `is`(testPublicJwkHash)) + } + + @Test + public fun `generateKeyPair should return early when key pair already exists`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + DPoPUtil.generateKeyPair(mockContext) + verify(mockKeyStore).hasKeyPair() + verify(mockKeyStore, never()).generateKeyPair(any(), any()) + } + + @Test + public fun `generateKeyPair should generate new key pair when none exists`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(false) + DPoPUtil.generateKeyPair(mockContext) + verify(mockKeyStore).hasKeyPair() + verify(mockKeyStore).generateKeyPair(mockContext) + } + + @Test + public fun `generateKeyPair should propagate DPoPException from keyStore`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(false) + whenever(mockKeyStore.generateKeyPair(mockContext)).thenThrow(DPoPException(DPoPException.Code.KEY_GENERATION_ERROR)) + + val exception = assertThrows(DPoPException::class.java) { + DPoPUtil.generateKeyPair(mockContext) + } + Assert.assertEquals("Error generating DPoP key pair.", exception.message) + } +} \ No newline at end of file diff --git a/auth0/src/test/java/com/auth0/android/dpop/Fakes.kt b/auth0/src/test/java/com/auth0/android/dpop/Fakes.kt new file mode 100644 index 000000000..d09e3db4c --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/dpop/Fakes.kt @@ -0,0 +1,82 @@ +package com.auth0.android.dpop + +import java.math.BigInteger +import java.security.AlgorithmParameters +import java.security.interfaces.ECPrivateKey +import java.security.interfaces.ECPublicKey +import java.security.spec.ECGenParameterSpec +import java.security.spec.ECParameterSpec +import java.security.spec.ECPoint + +/** + * Fake Private key used for testing DPoP + */ +public class FakeECPrivateKey : ECPrivateKey { + + private companion object { + private val S = + BigInteger("7a45666f486007b850d9a65499271a39d803562334533e7f4c4b6a213e27d144", 16) + + private val EC_PARAMETER_SPEC: ECParameterSpec = try { + val params = AlgorithmParameters.getInstance("EC") + params.init(ECGenParameterSpec("secp256r1")) + params.getParameterSpec(ECParameterSpec::class.java) + } catch (e: Exception) { + throw IllegalStateException("Cannot initialize ECParameterSpec for secp256r1", e) + } + + private val PKCS8_ENCODED = BigInteger( + "307702010104207a45666f486007b850d9a65499271a39d803562334533e7f4c4b6a213e27d144a00a06082a8648ce3d030107a144034200049405d454a853686891083c27e873e4497d5a5c68b556b23d9a65349e5480579e4d1f2e245c43d81577918a90184b25e11438992f0724817163f9eb2050b1", + 16 + ).toByteArray() + } + + + /** + * Returns the private value s. + */ + override fun getS(): BigInteger = S + + /** + * The name of the algorithm for this key. + */ + override fun getAlgorithm(): String = "EC" + + /** + * The name of the encoding format. + */ + override fun getFormat(): String = "PKCS#8" + + /** + * Returns the key in its primary encoding format (PKCS#8). + */ + override fun getEncoded(): ByteArray = PKCS8_ENCODED + + /** + * Returns the elliptic curve domain parameters. + */ + override fun getParams(): ECParameterSpec = EC_PARAMETER_SPEC + + override fun toString(): String = "Fake EC Private Key (secp256r1) [Kotlin]" +} + +/** + * Fake Public key used for testing DPoP + */ +public class FakeECPublicKey : ECPublicKey { + override fun getAlgorithm(): String = "EC" + override fun getFormat(): String = "X.509" + override fun getEncoded(): ByteArray = ByteArray(64) { 0x02 } // Dummy encoded key + + override fun getParams(): ECParameterSpec { + val curve = ECParameterSpec( + null, // Replace with a valid EllipticCurve if needed + ECPoint(BigInteger.ONE, BigInteger.TWO), // Dummy generator point + BigInteger.TEN, // Dummy order + 1 // Dummy cofactor + ) + return curve + } + + override fun getW(): ECPoint = ECPoint(BigInteger.ONE, BigInteger.TWO) // Dummy point +} \ No newline at end of file diff --git a/auth0/src/test/java/com/auth0/android/provider/OAuthManagerTest.java b/auth0/src/test/java/com/auth0/android/provider/OAuthManagerTest.java index e7a21947a..9069a88fa 100644 --- a/auth0/src/test/java/com/auth0/android/provider/OAuthManagerTest.java +++ b/auth0/src/test/java/com/auth0/android/provider/OAuthManagerTest.java @@ -1,18 +1,26 @@ package com.auth0.android.provider; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import android.content.Context; import android.net.Uri; + import com.auth0.android.Auth0; import com.auth0.android.authentication.AuthenticationException; import com.auth0.android.callback.Callback; +import com.auth0.android.dpop.DPoP; import com.auth0.android.request.NetworkingClient; -import com.auth0.android.util.Auth0UserAgent; import com.auth0.android.result.Credentials; +import com.auth0.android.util.Auth0UserAgent; import org.junit.After; import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.MockitoAnnotations; @@ -40,6 +48,14 @@ public class OAuthManagerTest { private NetworkingClient mockNetworkingClient; @Mock private Auth0UserAgent mockUserAgent; + @Mock + private DPoP mockDPoP; + + @Mock + private Context mockContext; + + @Captor + private ArgumentCaptor authExceptionArgumentCaptor; @Before public void setUp() { @@ -82,14 +98,15 @@ public void buildAuthorizeUriShouldUseDefaultUrlWhenCustomUrlIsNull() throws Exc final String defaultUrl = "https://default.auth0.com/authorize"; final Map parameters = Collections.singletonMap("param1", "value1"); Mockito.when(mockAccount.getAuthorizeUrl()).thenReturn(defaultUrl); - OAuthManager manager = new OAuthManager(mockAccount, mockCallback, parameters, mockCtOptions, false, null); + OAuthManager manager = new OAuthManager(mockAccount, mockCallback, parameters, mockCtOptions, false, null, null); Uri resultUri = callBuildAuthorizeUri(manager); Assert.assertNotNull(resultUri); Assert.assertEquals("https", resultUri.getScheme()); Assert.assertEquals("default.auth0.com", resultUri.getHost()); Assert.assertEquals("/authorize", resultUri.getPath()); Assert.assertEquals("value1", resultUri.getQueryParameter("param1")); - Mockito.verify(mockAccount).getAuthorizeUrl(); + Assert.assertNull(resultUri.getQueryParameter("dpop_jkt")); + verify(mockAccount).getAuthorizeUrl(); } @Test @@ -98,14 +115,15 @@ public void buildAuthorizeUriShouldUseCustomUrlWhenProvided() throws Exception { final String customUrl = "https://custom.example.com/custom_auth"; final Map parameters = Collections.singletonMap("param1", "value1"); Mockito.when(mockAccount.getAuthorizeUrl()).thenReturn(defaultUrl); - OAuthManager manager = new OAuthManager(mockAccount, mockCallback, parameters, mockCtOptions, false, customUrl); + OAuthManager manager = new OAuthManager(mockAccount, mockCallback, parameters, mockCtOptions, false, customUrl, null); Uri resultUri = callBuildAuthorizeUri(manager); Assert.assertNotNull(resultUri); Assert.assertEquals("https", resultUri.getScheme()); Assert.assertEquals("custom.example.com", resultUri.getHost()); Assert.assertEquals("/custom_auth", resultUri.getPath()); Assert.assertEquals("value1", resultUri.getQueryParameter("param1")); - Mockito.verify(mockAccount, Mockito.never()).getAuthorizeUrl(); + Assert.assertNull(resultUri.getQueryParameter("dpop_jkt")); + verify(mockAccount, never()).getAuthorizeUrl(); } @Test @@ -118,14 +136,14 @@ public void managerRestoredFromStateShouldUseRestoredCustomAuthorizeUrl() throws OAuthManager restoredManager = new OAuthManager( mockState.getAuth0(), mockCallback, mockState.getParameters(), - mockState.getCtOptions(), false, mockState.getCustomAuthorizeUrl() + mockState.getCtOptions(), false, mockState.getCustomAuthorizeUrl(), null ); Uri resultUri = callBuildAuthorizeUri(restoredManager); Assert.assertNotNull(resultUri); Assert.assertEquals("https", resultUri.getScheme()); Assert.assertEquals("restored.com", resultUri.getHost()); Assert.assertEquals("/custom_auth", resultUri.getPath()); - Mockito.verify(mockAccount, Mockito.never()).getAuthorizeUrl(); + verify(mockAccount, never()).getAuthorizeUrl(); } @Test @@ -136,14 +154,14 @@ public void managerRestoredFromStateShouldHandleNullCustomAuthorizeUrl() throws Mockito.when(mockAccount.getAuthorizeUrl()).thenReturn(defaultUrl); OAuthManager restoredManager = new OAuthManager( mockState.getAuth0(), mockCallback, mockState.getParameters(), - mockState.getCtOptions(), false, mockState.getCustomAuthorizeUrl() + mockState.getCtOptions(), false, mockState.getCustomAuthorizeUrl(), null ); Uri resultUri = callBuildAuthorizeUri(restoredManager); Assert.assertNotNull(resultUri); Assert.assertEquals("https", resultUri.getScheme()); Assert.assertEquals("default.auth0.com", resultUri.getHost()); Assert.assertEquals("/authorize", resultUri.getPath()); - Mockito.verify(mockAccount).getAuthorizeUrl(); + verify(mockAccount).getAuthorizeUrl(); } private Uri callBuildAuthorizeUri(OAuthManager instance) throws Exception { diff --git a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt index 4b9fc7e6e..17b845fb6 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt @@ -1,6 +1,7 @@ package com.auth0.android.provider import android.app.Activity +import android.content.Context import android.content.Intent import android.net.Uri import android.os.Parcelable @@ -9,6 +10,11 @@ import androidx.test.espresso.intent.matcher.UriMatchers import com.auth0.android.Auth0 import com.auth0.android.authentication.AuthenticationException import com.auth0.android.callback.Callback +import com.auth0.android.dpop.DPoPException +import com.auth0.android.dpop.DPoPKeyStore +import com.auth0.android.dpop.DPoPUtil +import com.auth0.android.dpop.FakeECPrivateKey +import com.auth0.android.dpop.FakeECPublicKey import com.auth0.android.provider.WebAuthProvider.login import com.auth0.android.provider.WebAuthProvider.logout import com.auth0.android.provider.WebAuthProvider.resume @@ -28,6 +34,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.hamcrest.CoreMatchers +import org.hamcrest.CoreMatchers.containsString +import org.hamcrest.CoreMatchers.instanceOf import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers import org.hamcrest.collection.IsMapContaining @@ -65,6 +73,8 @@ public class WebAuthProviderTest { private lateinit var voidCallback: Callback private lateinit var activity: Activity private lateinit var account: Auth0 + private lateinit var mockKeyStore: DPoPKeyStore + private lateinit var mockContext: Context private val authExceptionCaptor: KArgumentCaptor = argumentCaptor() private val intentCaptor: KArgumentCaptor = argumentCaptor() @@ -83,6 +93,11 @@ public class WebAuthProviderTest { Auth0.getInstance(JwtTestUtils.EXPECTED_AUDIENCE, JwtTestUtils.EXPECTED_BASE_DOMAIN) account.networkingClient = SSLTestUtils.testClient + mockKeyStore = mock() + mockContext = mock() + + DPoPUtil.keyStore = mockKeyStore + //Next line is needed to avoid CustomTabService from being bound to Test environment Mockito.doReturn(false).`when`(activity).bindService( any(), @@ -95,8 +110,11 @@ public class WebAuthProviderTest { null, null ) + + `when`(mockKeyStore.hasKeyPair()).thenReturn(false) } + //** ** ** ** ** ** **// //** ** ** ** ** ** **// //** LOG IN FEATURE **// @@ -107,6 +125,7 @@ public class WebAuthProviderTest { login(account) .start(activity, callback) Assert.assertNotNull(WebAuthProvider.managerInstance) + } @Test @@ -306,6 +325,51 @@ public class WebAuthProviderTest { ) } + + @Test + public fun enablingDPoPWillGenerateNewKeyPairIfOneDoesNotExist() { + `when`(mockKeyStore.hasKeyPair()).thenReturn(false) + WebAuthProvider.useDPoP(mockContext) + .login(account) + .start(activity, callback) + verify(mockKeyStore).generateKeyPair(any(), any()) + } + + @Test + public fun shouldNotHaveDpopJwkOnLoginIfDPoPIsDisabled() { + login(account) + .start(activity, callback) + verify(activity).startActivity(intentCaptor.capture()) + val uri = + intentCaptor.firstValue.getParcelableExtra(AuthenticationActivity.EXTRA_AUTHORIZE_URI) + assertThat(uri, `is`(notNullValue())) + assertThat( + uri, + not( + UriMatchers.hasParamWithName("dpop_jkt") + ) + ) + } + + @Test + public fun shouldHaveDpopJwkOnLoginIfDPoPIsEnabled() { + `when`(mockKeyStore.hasKeyPair()).thenReturn(true) + `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) + + WebAuthProvider.useDPoP(mockContext) + .login(account) + .start(activity, callback) + + verify(activity).startActivity(intentCaptor.capture()) + val uri = + intentCaptor.firstValue.getParcelableExtra(AuthenticationActivity.EXTRA_AUTHORIZE_URI) + assertThat(uri, `is`(notNullValue())) + assertThat( + uri, + UriMatchers.hasParamWithValue("dpop_jkt", "KQ-r0YQMCm0yVnGippcsZK4zO7oGIjOkNRbvILjjBAo") + ) + } + //scope @Test public fun shouldHaveDefaultScopeOnLogin() { @@ -2665,6 +2729,234 @@ public class WebAuthProviderTest { assertThat(uri, UriMatchers.hasParamWithName("returnTo")) } + + //DPoP + + public fun shouldReturnSameInstanceWhenCallingUseDPoPMultipleTimes() { + val provider1 = WebAuthProvider.useDPoP(mockContext) + val provider2 = WebAuthProvider.useDPoP(mockContext) + + assertThat(provider1, `is`(provider2)) + assertThat(WebAuthProvider.useDPoP(mockContext), `is`(provider1)) + } + + @Test + public fun shouldPassDPoPInstanceToOAuthManagerWhenDPoPIsEnabled() { + `when`(mockKeyStore.hasKeyPair()).thenReturn(true) + `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) + + WebAuthProvider.useDPoP(mockContext) + .login(account) + .start(activity, callback) + + val managerInstance = WebAuthProvider.managerInstance as OAuthManager + assertThat(managerInstance.dPoP, `is`(notNullValue())) + } + + @Test + public fun shouldNotPassDPoPInstanceToOAuthManagerWhenDPoPIsNotEnabled() { + login(account) + .start(activity, callback) + + val managerInstance = WebAuthProvider.managerInstance as OAuthManager + Assert.assertNull(managerInstance.dPoP) + } + + @Test + public fun shouldGenerateKeyPairWhenDPoPIsEnabledAndNoKeyPairExists() { + `when`(mockKeyStore.hasKeyPair()).thenReturn(false) + + WebAuthProvider.useDPoP(mockContext) + .login(account) + .start(activity, callback) + + verify(mockKeyStore).generateKeyPair(any(), any()) + } + + @Test + public fun shouldNotGenerateKeyPairWhenDPoPIsEnabledAndKeyPairExists() { + `when`(mockKeyStore.hasKeyPair()).thenReturn(true) + `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) + + WebAuthProvider.useDPoP(mockContext) + .login(account) + .start(activity, callback) + + verify(mockKeyStore, never()).generateKeyPair(any(), any()) + } + + @Test + public fun shouldNotGenerateKeyPairWhenDPoPIsNotEnabled() { + `when`(mockKeyStore.hasKeyPair()).thenReturn(false) + + login(account) + .start(activity, callback) + + verify(mockKeyStore, never()).generateKeyPair(any(), any()) + } + + @Test + public fun shouldIncludeDPoPJWKThumbprintInAuthorizeURLWhenDPoPIsEnabledAndKeyPairExists() { + `when`(mockKeyStore.hasKeyPair()).thenReturn(true) + `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) + + WebAuthProvider.useDPoP(mockContext) + .login(account) + .start(activity, callback) + + verify(activity).startActivity(intentCaptor.capture()) + val uri = + intentCaptor.firstValue.getParcelableExtra(AuthenticationActivity.EXTRA_AUTHORIZE_URI) + assertThat(uri, `is`(notNullValue())) + assertThat(uri, UriMatchers.hasParamWithName("dpop_jkt")) + assertThat( + uri, + UriMatchers.hasParamWithValue("dpop_jkt", "KQ-r0YQMCm0yVnGippcsZK4zO7oGIjOkNRbvILjjBAo") + ) + } + + @Test + public fun shouldNotIncludeDPoPJWKThumbprintWhenDPoPIsEnabledButGetKeyPairReturnNull() { + `when`(mockKeyStore.hasKeyPair()).thenReturn(true) + `when`(mockKeyStore.getKeyPair()).thenReturn(null) + + WebAuthProvider.useDPoP(mockContext) + .login(account) + .start(activity, callback) + + verify(activity).startActivity(intentCaptor.capture()) + val uri = + intentCaptor.firstValue.getParcelableExtra(AuthenticationActivity.EXTRA_AUTHORIZE_URI) + assertThat(uri, `is`(notNullValue())) + assertThat(uri, not(UriMatchers.hasParamWithName("dpop_jkt"))) + } + + @Test + public fun shouldWorkWithLoginBuilderPatternWhenDPoPIsEnabled() { + `when`(mockKeyStore.hasKeyPair()).thenReturn(true) + `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) + + val builder = WebAuthProvider.useDPoP(mockContext) + .login(account) + .withConnection("test-connection") + + builder.start(activity, callback) + + verify(activity).startActivity(intentCaptor.capture()) + val uri = + intentCaptor.firstValue.getParcelableExtra(AuthenticationActivity.EXTRA_AUTHORIZE_URI) + assertThat(uri, `is`(notNullValue())) + assertThat(uri, UriMatchers.hasParamWithValue("connection", "test-connection")) + assertThat(uri, UriMatchers.hasParamWithName("dpop_jkt")) + } + + @Test + public fun shouldNotAffectLogoutWhenDPoPIsEnabled() { + WebAuthProvider.useDPoP(mockContext) + .logout(account) + .start(activity, voidCallback) + + verify(activity).startActivity(intentCaptor.capture()) + val uri = + intentCaptor.firstValue.getParcelableExtra(AuthenticationActivity.EXTRA_AUTHORIZE_URI) + assertThat(uri, `is`(notNullValue())) + // Logout should not have DPoP parameters + assertThat(uri, not(UriMatchers.hasParamWithName("dpop_jkt"))) + } + + @Test + public fun shouldHandleDPoPKeyGenerationFailureGracefully() { + `when`(mockKeyStore.hasKeyPair()).thenReturn(false) + doThrow(DPoPException.KEY_GENERATION_ERROR) + .`when`(mockKeyStore).generateKeyPair(any(), any()) + + WebAuthProvider.useDPoP(mockContext) + .login(account) + .start(activity, callback) + + // Verify that the authentication fails when DPoP key generation fails + verify(callback).onFailure(authExceptionCaptor.capture()) + val capturedException = authExceptionCaptor.firstValue + assertThat(capturedException, `is`(instanceOf(AuthenticationException::class.java))) + + assertThat(capturedException.message, containsString("Error generating DPoP key pair.")) + assertThat(capturedException.cause, Matchers.instanceOf(DPoPException::class.java)) + + verify(activity, never()).startActivity(any()) + } + + @Test + @Throws(Exception::class) + public fun shouldResumeLoginSuccessfullyWithDPoPEnabled() { + `when`(mockKeyStore.hasKeyPair()).thenReturn(true) + `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + + val expiresAt = Date() + val pkce = Mockito.mock(PKCE::class.java) + `when`(pkce.codeChallenge).thenReturn("challenge") + val mockAPI = AuthenticationAPIMockServer() + mockAPI.willReturnValidJsonWebKeys() + val authCallback = mock>() + val proxyAccount: Auth0 = Auth0.getInstance(JwtTestUtils.EXPECTED_AUDIENCE, mockAPI.domain) + proxyAccount.networkingClient = SSLTestUtils.testClient + + WebAuthProvider.useDPoP(mockContext) + .login(proxyAccount) + .withPKCE(pkce) + .start(activity, authCallback) + + val managerInstance = WebAuthProvider.managerInstance as OAuthManager + managerInstance.currentTimeInMillis = JwtTestUtils.FIXED_CLOCK_CURRENT_TIME_MS + verify(activity).startActivity(intentCaptor.capture()) + val uri = + intentCaptor.firstValue.getParcelableExtra(AuthenticationActivity.EXTRA_AUTHORIZE_URI) + assertThat(uri, `is`(notNullValue())) + val sentState = uri?.getQueryParameter(KEY_STATE) + val sentNonce = uri?.getQueryParameter(KEY_NONCE) + + val intent = createAuthIntent( + createHash( + null, + null, + null, + null, + null, + sentState, + null, + null, + "1234" + ) + ) + + val jwtBody = JwtTestUtils.createJWTBody() + jwtBody["nonce"] = sentNonce + jwtBody["aud"] = proxyAccount.clientId + jwtBody["iss"] = proxyAccount.getDomainUrl() + val expectedIdToken = JwtTestUtils.createTestJWT("RS256", jwtBody) + val codeCredentials = Credentials( + expectedIdToken, + "codeAccess", + "DPoP", // Token type should be DPoP when DPoP is enabled + "codeRefresh", + expiresAt, + "codeScope" + ) + + Mockito.doAnswer { + callbackCaptor.firstValue.onSuccess(codeCredentials) + null + }.`when`(pkce).getToken(eq("1234"), callbackCaptor.capture()) + + Assert.assertTrue(resume(intent)) + mockAPI.takeRequest() + ShadowLooper.idleMainLooper() + verify(authCallback).onSuccess(credentialsCaptor.capture()) + val credentials = credentialsCaptor.firstValue + assertThat(credentials, `is`(notNullValue())) + assertThat(credentials.type, `is`("DPoP")) + mockAPI.shutdown() + } + //** ** ** ** ** ** **// //** ** ** ** ** ** **// //** Helpers Functions**// diff --git a/auth0/src/test/java/com/auth0/android/request/DefaultClientTest.kt b/auth0/src/test/java/com/auth0/android/request/DefaultClientTest.kt index a30ca88bc..3b1dccd77 100644 --- a/auth0/src/test/java/com/auth0/android/request/DefaultClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/request/DefaultClientTest.kt @@ -82,15 +82,30 @@ public class DefaultClientTest { @Test public fun shouldHaveLoggingDisabledByDefault() { - assertThat(DefaultClient().okHttpClient.interceptors, empty()) + val netClient = DefaultClient(enableLogging = false) + assertThat(netClient.okHttpClient.interceptors, hasSize(1)) + val interceptor: Interceptor = netClient.okHttpClient.interceptors[0] + assert( + interceptor is RetryInterceptor, + ) + } + + @Test + public fun shouldHaveRetryInterceptorEnabled() { + val netClient = DefaultClient(enableLogging = false) + assertThat(netClient.okHttpClient.interceptors, hasSize(1)) + val interceptor: Interceptor = netClient.okHttpClient.interceptors[0] + assert( + interceptor is RetryInterceptor, + ) } @Test public fun shouldHaveLoggingEnabledIfSpecified() { val netClient = DefaultClient(enableLogging = true) - assertThat(netClient.okHttpClient.interceptors, hasSize(1)) + assertThat(netClient.okHttpClient.interceptors, hasSize(2)) - val interceptor: Interceptor = netClient.okHttpClient.interceptors[0] + val interceptor: Interceptor = netClient.okHttpClient.interceptors[1] assertThat( (interceptor as HttpLoggingInterceptor).level, equalTo(HttpLoggingInterceptor.Level.BODY) diff --git a/auth0/src/test/java/com/auth0/android/request/RetryInterceptorTest.kt b/auth0/src/test/java/com/auth0/android/request/RetryInterceptorTest.kt new file mode 100644 index 000000000..bb10f1ce5 --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/request/RetryInterceptorTest.kt @@ -0,0 +1,174 @@ +package com.auth0.android.request + +import com.auth0.android.dpop.DPoP +import com.auth0.android.dpop.DPoPKeyStore +import com.auth0.android.dpop.DPoPUtil +import com.auth0.android.dpop.FakeECPrivateKey +import com.auth0.android.dpop.FakeECPublicKey +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.argumentCaptor +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.times +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +import okhttp3.Interceptor +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.hamcrest.CoreMatchers.`is` +import org.hamcrest.CoreMatchers.not +import org.hamcrest.CoreMatchers.nullValue +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +public class RetryInterceptorTest { + + private lateinit var mockChain: Interceptor.Chain + private lateinit var mockKeyStore: DPoPKeyStore + + private lateinit var retryInterceptor: RetryInterceptor + + @Before + public fun setUp() { + mockChain = mock() + mockKeyStore = mock() + + DPoPUtil.keyStore = mockKeyStore + retryInterceptor = RetryInterceptor() + } + + @Test + public fun `should proceed without retry if response is not a DPoP nonce error`() { + val request = createRequest() + val okResponse = createOkResponse(request) + whenever(mockChain.request()).thenReturn(request) + whenever(mockChain.proceed(request)).thenReturn(okResponse) + + val result = retryInterceptor.intercept(mockChain) + + assertThat(result, `is`(okResponse)) + verify(mockChain).proceed(request) + } + + @Test + public fun `should retry request when DPoP nonce error occurs and key pair is available`() { + val initialRequest = createRequest(accessToken = "test-access-token") + val errorResponse = createDpopNonceErrorResponse(initialRequest) + val successResponse = createOkResponse(initialRequest) + val newRequestCaptor = argumentCaptor() + + whenever(mockChain.request()).thenReturn(initialRequest) + + whenever(mockChain.proceed(any())) + .thenReturn(errorResponse) + .thenReturn(successResponse) + + val mockKeyPair = Pair(FakeECPrivateKey(), FakeECPublicKey()) + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(mockKeyPair) + + val result = retryInterceptor.intercept(mockChain) + + assertThat(result, `is`(successResponse)) + verify(mockChain, times(2)).proceed(newRequestCaptor.capture()) + + val retriedRequest = newRequestCaptor.secondValue + assertThat(retriedRequest.header("DPoP"), not(nullValue())) + assertThat(retriedRequest.header("X-Internal-Retry-Count"), `is`("1")) + assertThat(DPoP.auth0Nonce, `is`("new-nonce-from-header")) + } + + @Test + public fun `should not retry request when DPoP nonce error occurs and retry count reaches max`() { + val request = createRequest(retryCount = 1) + val errorResponse = createDpopNonceErrorResponse(request) + whenever(mockChain.request()).thenReturn(request) + whenever(mockChain.proceed(request)).thenReturn(errorResponse) + + val result = retryInterceptor.intercept(mockChain) + + assertThat(result, `is`(errorResponse)) + verify(mockChain).proceed(request) + } + + @Test + public fun `should not retry request when DPoP nonce error occurs but proof generation fails`() { + val request = createRequest() + val errorResponse = createDpopNonceErrorResponse(request) + whenever(mockChain.request()).thenReturn(request) + whenever(mockChain.proceed(request)).thenReturn(errorResponse) + + whenever(mockKeyStore.hasKeyPair()).thenReturn(false) + + val result = retryInterceptor.intercept(mockChain) + + assertThat(result, `is`(errorResponse)) + verify(mockChain).proceed(request) + } + + @Test + public fun `should handle initial request with no retry header`() { + val initialRequest = createRequest(accessToken = "test-access-token", retryCount = null) + val errorResponse = createDpopNonceErrorResponse(initialRequest) + val successResponse = createOkResponse(initialRequest) + val newRequestCaptor = argumentCaptor() + + whenever(mockChain.request()).thenReturn(initialRequest) + whenever(mockChain.proceed(any())) + .thenReturn(errorResponse) + .thenReturn(successResponse) + + val mockKeyPair = Pair(FakeECPrivateKey(), FakeECPublicKey()) + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(mockKeyPair) + + val result = retryInterceptor.intercept(mockChain) + + assertThat(result, `is`(successResponse)) + verify(mockChain, times(2)).proceed(newRequestCaptor.capture()) + val retriedRequest = newRequestCaptor.secondValue + assertThat(retriedRequest.header("X-Internal-Retry-Count"), `is`("1")) + } + + private fun createRequest(accessToken: String? = null, retryCount: Int? = 0): Request { + val builder = Request.Builder() + .url("https://test.com/api") + .method("POST", "{}".toRequestBody()) + + if (accessToken != null) { + builder.header("Authorization", "DPoP $accessToken") + } + if (retryCount != null) { + builder.header("X-Internal-Retry-Count", retryCount.toString()) + } + return builder.build() + } + + private fun createOkResponse(request: Request): Response { + return Response.Builder() + .request(request) + .protocol(Protocol.HTTP_2) + .code(200) + .message("OK") + .body("{}".toResponseBody()) + .build() + } + + private fun createDpopNonceErrorResponse(request: Request): Response { + return Response.Builder() + .request(request) + .protocol(Protocol.HTTP_2) + .code(401) + .message("Unauthorized") + .header("WWW-Authenticate", "DPoP error=\"use_dpop_nonce\"") + .header("dpop-nonce", "new-nonce-from-header") + .body("".toResponseBody()) + .build() + } +} \ No newline at end of file diff --git a/auth0/src/test/java/com/auth0/android/request/internal/BaseRequestTest.kt b/auth0/src/test/java/com/auth0/android/request/internal/BaseRequestTest.kt index bfb64de21..c459068f9 100755 --- a/auth0/src/test/java/com/auth0/android/request/internal/BaseRequestTest.kt +++ b/auth0/src/test/java/com/auth0/android/request/internal/BaseRequestTest.kt @@ -2,6 +2,9 @@ package com.auth0.android.request.internal import com.auth0.android.Auth0Exception import com.auth0.android.callback.Callback +import com.auth0.android.dpop.DPoP +import com.auth0.android.dpop.DPoPException +import com.auth0.android.dpop.DPoPUtil.DPOP_HEADER import com.auth0.android.request.* import com.google.gson.Gson import com.google.gson.JsonIOException @@ -39,6 +42,9 @@ public class BaseRequestTest { @Mock private lateinit var client: NetworkingClient + @Mock + private lateinit var mockDPoP: DPoP + /** * Whether the response InputStream was closed; only relevant for tests using the `mock...` * setup methods of this class. @@ -83,7 +89,9 @@ public class BaseRequestTest { BASE_URL, client, resultAdapter, - errorAdapter + errorAdapter, + CommonThreadSwitcher.getInstance(), + null ) @Test @@ -460,6 +468,260 @@ public class BaseRequestTest { ) } + //DPoP + + @Test + @Throws(Exception::class) + public fun shouldAddDPoPHeaderWhenDPoPProofIsGenerated() { + mockSuccessfulServerResponse() + val mockProof = "eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2In0.eyJqdGkiOiJ0ZXN0LWp0aSIsImh0bSI6IlBPU1QiLCJodHUiOiJodHRwczovL2F1dGgwLmNvbSIsImlhdCI6MTY0MDk5NTIwMH0.signature" + + Mockito.`when`(mockDPoP.shouldGenerateProof(eq(BASE_URL), any())).thenReturn(true) + Mockito.`when`(mockDPoP.generateProof(eq(BASE_URL), eq(HttpMethod.POST), any())).thenReturn(mockProof) + + val baseRequest = BaseRequest( + HttpMethod.POST, + BASE_URL, + client, + resultAdapter, + errorAdapter, + CommonThreadSwitcher.getInstance(), + mockDPoP + ) + + baseRequest.execute() + + verify(client).load(eq(BASE_URL), optionsCaptor.capture()) + val headers = optionsCaptor.firstValue.headers + MatcherAssert.assertThat(headers, IsMapContaining.hasEntry(DPOP_HEADER, mockProof)) + } + + @Test + @Throws(Exception::class) + public fun shouldNotAddDPoPHeaderWhenDPoPProofIsNotGenerated() { + mockSuccessfulServerResponse() + + Mockito.`when`(mockDPoP.shouldGenerateProof(eq(BASE_URL), any())).thenReturn(true) + Mockito.`when`(mockDPoP.generateProof(eq(BASE_URL), eq(HttpMethod.POST), any())).thenReturn(null) + + val baseRequest = BaseRequest( + HttpMethod.POST, + BASE_URL, + client, + resultAdapter, + errorAdapter, + CommonThreadSwitcher.getInstance(), + mockDPoP + ) + + baseRequest.execute() + + verify(client).load(eq(BASE_URL), optionsCaptor.capture()) + val headers = optionsCaptor.firstValue.headers + MatcherAssert.assertThat(headers, Matchers.not(IsMapContaining.hasKey(DPOP_HEADER))) + } + + @Test + @Throws(Exception::class) + public fun shouldNotAddDPoPHeaderWhenShouldGenerateProofReturnsFalse() { + mockSuccessfulServerResponse() + + Mockito.`when`(mockDPoP.shouldGenerateProof(eq(BASE_URL), any())).thenReturn(false) + + val baseRequest = BaseRequest( + HttpMethod.POST, + BASE_URL, + client, + resultAdapter, + errorAdapter, + CommonThreadSwitcher.getInstance(), + mockDPoP + ) + + baseRequest.execute() + + verify(client).load(eq(BASE_URL), optionsCaptor.capture()) + val headers = optionsCaptor.firstValue.headers + MatcherAssert.assertThat(headers, Matchers.not(IsMapContaining.hasKey(DPOP_HEADER))) + verify(mockDPoP, never()).generateProof(any(), any(), any()) + } + + @Test + @Throws(Exception::class) + public fun shouldNotCallDPoPMethodsWhenDPoPIsNull() { + mockSuccessfulServerResponse() + + val baseRequest = BaseRequest( + HttpMethod.POST, + BASE_URL, + client, + resultAdapter, + errorAdapter, + CommonThreadSwitcher.getInstance(), + null + ) + + baseRequest.execute() + + verify(client).load(eq(BASE_URL), optionsCaptor.capture()) + val headers = optionsCaptor.firstValue.headers + MatcherAssert.assertThat(headers, Matchers.not(IsMapContaining.hasKey(DPOP_HEADER))) + } + + @Test + @Throws(Exception::class) + public fun shouldPassCorrectParametersToShouldGenerateProof() { + mockSuccessfulServerResponse() + + Mockito.`when`(mockDPoP.shouldGenerateProof(eq(BASE_URL), any())).thenReturn(false) + + val baseRequest = BaseRequest( + HttpMethod.POST, + BASE_URL, + client, + resultAdapter, + errorAdapter, + CommonThreadSwitcher.getInstance(), + mockDPoP + ) + + baseRequest.addParameter("grant_type", "authorization_code") + baseRequest.addParameter("code", "test-code") + baseRequest.execute() + + val parametersCaptor: KArgumentCaptor> = argumentCaptor() + verify(mockDPoP).shouldGenerateProof(eq(BASE_URL), parametersCaptor.capture()) + + val parameters = parametersCaptor.firstValue + MatcherAssert.assertThat(parameters, IsMapContaining.hasEntry("grant_type", "authorization_code")) + MatcherAssert.assertThat(parameters, IsMapContaining.hasEntry("code", "test-code")) + } + + @Test + @Throws(Exception::class) + public fun shouldPassCorrectHeadersToGenerateProof() { + mockSuccessfulServerResponse() + + Mockito.`when`(mockDPoP.shouldGenerateProof(eq(BASE_URL), any())).thenReturn(true) + Mockito.`when`(mockDPoP.generateProof(eq(BASE_URL), eq(HttpMethod.POST), any())).thenReturn("test-proof") + + val baseRequest = BaseRequest( + HttpMethod.POST, + BASE_URL, + client, + resultAdapter, + errorAdapter, + CommonThreadSwitcher.getInstance(), + mockDPoP + ) + + baseRequest.addHeader("Authorization", "Bearer test-token") + baseRequest.addHeader("Content-Type", "application/json") + baseRequest.execute() + + val headersCaptor: KArgumentCaptor> = argumentCaptor() + verify(mockDPoP).generateProof(eq(BASE_URL), eq(HttpMethod.POST), headersCaptor.capture()) + + val headers = headersCaptor.firstValue + MatcherAssert.assertThat(headers, IsMapContaining.hasEntry("Authorization", "Bearer test-token")) + MatcherAssert.assertThat(headers, IsMapContaining.hasEntry("Content-Type", "application/json")) + } + + @Test + @Throws(Exception::class) + public fun shouldHandleDPoPExceptionDuringProofGeneration() { + val dpopException = DPoPException(DPoPException.Code.SIGNING_ERROR, "Signing failed") + + Mockito.`when`(mockDPoP.shouldGenerateProof(eq(BASE_URL), any())).thenReturn(true) + Mockito.`when`(mockDPoP.generateProof(eq(BASE_URL), eq(HttpMethod.POST), any())).thenThrow(dpopException) + + val baseRequest = BaseRequest( + HttpMethod.POST, + BASE_URL, + client, + resultAdapter, + errorAdapter, + CommonThreadSwitcher.getInstance(), + mockDPoP + ) + + var exception: Exception? = null + var result: SimplePojo? = null + try { + result = baseRequest.execute() + } catch (e: Auth0Exception) { + exception = e + } + + MatcherAssert.assertThat(exception, Matchers.`is`(wrappingAuth0Exception)) + MatcherAssert.assertThat(result, Matchers.`is`(Matchers.nullValue())) + verify(errorAdapter).fromException(dpopException) + verify(client, never()).load(any(), any()) + } + + @Test + @Throws(Exception::class) + public fun shouldHandleDPoPExceptionDuringShouldGenerateProofCheck() { + val dpopException = DPoPException.KEY_STORE_ERROR + + Mockito.`when`(mockDPoP.shouldGenerateProof(eq(BASE_URL), any())).thenThrow(dpopException) + + val baseRequest = BaseRequest( + HttpMethod.POST, + BASE_URL, + client, + resultAdapter, + errorAdapter, + CommonThreadSwitcher.getInstance(), + mockDPoP + ) + + var exception: Exception? = null + var result: SimplePojo? = null + try { + result = baseRequest.execute() + } catch (e: Auth0Exception) { + exception = e + } + + MatcherAssert.assertThat(exception, Matchers.`is`(wrappingAuth0Exception)) + MatcherAssert.assertThat(result, Matchers.`is`(Matchers.nullValue())) + verify(errorAdapter).fromException(dpopException) + verify(client, never()).load(any(), any()) + verify(mockDPoP, never()).generateProof(any(), any(), any()) + } + + @Test + @Throws(Exception::class) + public fun shouldAddDPoPHeaderToExistingHeaders() { + mockSuccessfulServerResponse() + val mockProof = "test-dpop-proof" + + Mockito.`when`(mockDPoP.shouldGenerateProof(eq(BASE_URL), any())).thenReturn(true) + Mockito.`when`(mockDPoP.generateProof(eq(BASE_URL), eq(HttpMethod.POST), any())).thenReturn(mockProof) + + val baseRequest = BaseRequest( + HttpMethod.POST, + BASE_URL, + client, + resultAdapter, + errorAdapter, + CommonThreadSwitcher.getInstance(), + mockDPoP + ) + + baseRequest.addHeader("Authorization", "Bearer test-token") + baseRequest.addHeader("Content-Type", "application/json") + baseRequest.execute() + + verify(client).load(eq(BASE_URL), optionsCaptor.capture()) + val headers = optionsCaptor.firstValue.headers + MatcherAssert.assertThat(headers, IsMapWithSize.aMapWithSize(3)) + MatcherAssert.assertThat(headers, IsMapContaining.hasEntry("Authorization", "Bearer test-token")) + MatcherAssert.assertThat(headers, IsMapContaining.hasEntry("Content-Type", "application/json")) + MatcherAssert.assertThat(headers, IsMapContaining.hasEntry(DPOP_HEADER, mockProof)) + } + @Test @ExperimentalCoroutinesApi public fun shouldAwaitOnIODispatcher(): Unit = runTest { diff --git a/auth0/src/test/java/com/auth0/android/request/internal/RequestFactoryTest.java b/auth0/src/test/java/com/auth0/android/request/internal/RequestFactoryTest.java index 630ee2111..9f760c327 100644 --- a/auth0/src/test/java/com/auth0/android/request/internal/RequestFactoryTest.java +++ b/auth0/src/test/java/com/auth0/android/request/internal/RequestFactoryTest.java @@ -24,6 +24,7 @@ import static org.hamcrest.core.Is.is; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; @@ -66,16 +67,16 @@ public void shouldHaveDefaultAcceptLanguageHeader() { // recreate the factory to read the default again RequestFactory factory = createRequestFactory(); - factory.get(BASE_URL, resultAdapter); + factory.get(BASE_URL, resultAdapter,null); verify(getRequest).addHeader("Accept-Language", "en_US"); - factory.post(BASE_URL, resultAdapter); + factory.post(BASE_URL, resultAdapter,null); verify(postRequest).addHeader("Accept-Language", "en_US"); - factory.delete(BASE_URL, resultAdapter); + factory.delete(BASE_URL, resultAdapter,null); verify(deleteRequest).addHeader("Accept-Language", "en_US"); - factory.patch(BASE_URL, resultAdapter); + factory.patch(BASE_URL, resultAdapter,null); verify(patchRequest).addHeader("Accept-Language", "en_US"); } @@ -86,16 +87,16 @@ public void shouldHaveAcceptLanguageHeader() { // recreate the factory to read the default again RequestFactory factory = createRequestFactory(); - factory.get(BASE_URL, resultAdapter); + factory.get(BASE_URL, resultAdapter,null); verify(getRequest).addHeader("Accept-Language", "ja_JP"); - factory.post(BASE_URL, resultAdapter); + factory.post(BASE_URL, resultAdapter,null); verify(postRequest).addHeader("Accept-Language", "ja_JP"); - factory.delete(BASE_URL, resultAdapter); + factory.delete(BASE_URL, resultAdapter,null); verify(deleteRequest).addHeader("Accept-Language", "ja_JP"); - factory.patch(BASE_URL, resultAdapter); + factory.patch(BASE_URL, resultAdapter,null); verify(patchRequest).addHeader("Accept-Language", "ja_JP"); } @@ -104,16 +105,16 @@ public void shouldHaveCustomHeader() { RequestFactory factory = createRequestFactory(); factory.setHeader("the-header", "the-value"); - factory.get(BASE_URL, resultAdapter); + factory.get(BASE_URL, resultAdapter,null); verify(getRequest).addHeader("the-header", "the-value"); - factory.post(BASE_URL, resultAdapter); + factory.post(BASE_URL, resultAdapter,null); verify(postRequest).addHeader("the-header", "the-value"); - factory.delete(BASE_URL, resultAdapter); + factory.delete(BASE_URL, resultAdapter,null); verify(deleteRequest).addHeader("the-header", "the-value"); - factory.patch(BASE_URL, resultAdapter); + factory.patch(BASE_URL, resultAdapter,null); verify(patchRequest).addHeader("the-header", "the-value"); } @@ -122,22 +123,22 @@ public void shouldHaveClientInfoHeader() { RequestFactory factory = createRequestFactory(); factory.setAuth0ClientInfo(CLIENT_INFO); - factory.get(BASE_URL, resultAdapter); + factory.get(BASE_URL, resultAdapter,null); verify(getRequest).addHeader("Auth0-Client", CLIENT_INFO); - factory.post(BASE_URL, resultAdapter); + factory.post(BASE_URL, resultAdapter,null); verify(postRequest).addHeader("Auth0-Client", CLIENT_INFO); - factory.delete(BASE_URL, resultAdapter); + factory.delete(BASE_URL, resultAdapter,null); verify(deleteRequest).addHeader("Auth0-Client", CLIENT_INFO); - factory.patch(BASE_URL, resultAdapter); + factory.patch(BASE_URL, resultAdapter,null); verify(patchRequest).addHeader("Auth0-Client", CLIENT_INFO); } @Test public void shouldCreatePostRequest() { - Request request = factory.post(BASE_URL, resultAdapter); + Request request = factory.post(BASE_URL, resultAdapter,null); assertThat(request, is(notNullValue())); assertThat(request, is(postRequest)); @@ -145,7 +146,7 @@ public void shouldCreatePostRequest() { @Test public void shouldCreateVoidPostRequest() { - Request request = factory.post(BASE_URL); + Request request = factory.post(BASE_URL,null); assertThat(request, is(notNullValue())); assertThat(request, is(emptyPostRequest)); @@ -153,7 +154,7 @@ public void shouldCreateVoidPostRequest() { @Test public void shouldCreatePatchRequest() { - Request request = factory.patch(BASE_URL, resultAdapter); + Request request = factory.patch(BASE_URL, resultAdapter,null); assertThat(request, is(notNullValue())); assertThat(request, is(patchRequest)); @@ -161,7 +162,7 @@ public void shouldCreatePatchRequest() { @Test public void shouldCreateDeleteRequest() { - Request request = factory.delete(BASE_URL, resultAdapter); + Request request = factory.delete(BASE_URL, resultAdapter,null); assertThat(request, is(notNullValue())); assertThat(request, is(deleteRequest)); @@ -169,7 +170,7 @@ public void shouldCreateDeleteRequest() { @Test public void shouldCreateGetRequest() { - Request request = factory.get(BASE_URL, resultAdapter); + Request request = factory.get(BASE_URL, resultAdapter,null); assertThat(request, is(notNullValue())); assertThat(request, is(getRequest)); @@ -178,11 +179,11 @@ public void shouldCreateGetRequest() { @SuppressWarnings("unchecked") private RequestFactory createRequestFactory() { RequestFactory factory = spy(new RequestFactory<>(client, errorAdapter)); - doReturn(postRequest).when(factory).createRequest(any(HttpMethod.POST.class), eq(BASE_URL), eq(client), eq(resultAdapter), eq(errorAdapter), any(ThreadSwitcher.class)); - doReturn(emptyPostRequest).when(factory).createRequest(any(HttpMethod.POST.class), eq(BASE_URL), eq(client), AdditionalMatchers.and(AdditionalMatchers.not(ArgumentMatchers.eq(resultAdapter)), ArgumentMatchers.isA(JsonAdapter.class)), eq(errorAdapter), any(ThreadSwitcher.class)); - doReturn(deleteRequest).when(factory).createRequest(any(HttpMethod.DELETE.class), eq(BASE_URL), eq(client), eq(resultAdapter), eq(errorAdapter), any(ThreadSwitcher.class)); - doReturn(patchRequest).when(factory).createRequest(any(HttpMethod.PATCH.class), eq(BASE_URL), eq(client), eq(resultAdapter), eq(errorAdapter), any(ThreadSwitcher.class)); - doReturn(getRequest).when(factory).createRequest(any(HttpMethod.GET.class), eq(BASE_URL), eq(client), eq(resultAdapter), eq(errorAdapter), any(ThreadSwitcher.class)); + doReturn(postRequest).when(factory).createRequest(any(HttpMethod.POST.class), eq(BASE_URL), eq(client), eq(resultAdapter), eq(errorAdapter), any(ThreadSwitcher.class),isNull()); + doReturn(emptyPostRequest).when(factory).createRequest(any(HttpMethod.POST.class), eq(BASE_URL), eq(client), AdditionalMatchers.and(AdditionalMatchers.not(ArgumentMatchers.eq(resultAdapter)), ArgumentMatchers.isA(JsonAdapter.class)), eq(errorAdapter), any(ThreadSwitcher.class),isNull()); + doReturn(deleteRequest).when(factory).createRequest(any(HttpMethod.DELETE.class), eq(BASE_URL), eq(client), eq(resultAdapter), eq(errorAdapter), any(ThreadSwitcher.class),isNull()); + doReturn(patchRequest).when(factory).createRequest(any(HttpMethod.PATCH.class), eq(BASE_URL), eq(client), eq(resultAdapter), eq(errorAdapter), any(ThreadSwitcher.class),isNull()); + doReturn(getRequest).when(factory).createRequest(any(HttpMethod.GET.class), eq(BASE_URL), eq(client), eq(resultAdapter), eq(errorAdapter), any(ThreadSwitcher.class),isNull()); return factory; } } \ No newline at end of file diff --git a/auth0/src/test/java/com/auth0/android/util/AuthenticationAPIMockServer.kt b/auth0/src/test/java/com/auth0/android/util/AuthenticationAPIMockServer.kt index b95822ed7..e08361e57 100755 --- a/auth0/src/test/java/com/auth0/android/util/AuthenticationAPIMockServer.kt +++ b/auth0/src/test/java/com/auth0/android/util/AuthenticationAPIMockServer.kt @@ -205,7 +205,7 @@ internal class AuthenticationAPIMockServer : APIMockServer() { const val ID_TOKEN = "ID_TOKEN" const val ACCESS_TOKEN = "ACCESS_TOKEN" const val SESSION_ID = "SESSION_ID" - private const val BEARER = "BEARER" + private const val BEARER = "Bearer" private const val CHALLENGE = "CHALLENGE" } } \ No newline at end of file