From 401547cff55a10b74f4b52adc2c66b2529816c66 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Wed, 16 Jul 2025 14:17:55 +0530 Subject: [PATCH 01/20] feat: Add support for DPoP --- .../authentication/AuthenticationAPIClient.kt | 29 ++- .../com/auth0/android/dpop/DPoPException.kt | 20 ++ .../com/auth0/android/dpop/DPoPProvider.kt | 232 ++++++++++++++++++ .../auth0/android/dpop/DefaultPoPKeyStore.kt | 161 ++++++++++++ .../auth0/android/request/DefaultClient.kt | 11 +- .../com/auth0/android/request/ErrorBody.kt | 25 ++ .../auth0/android/request/ProfileRequest.kt | 49 +++- .../java/com/auth0/android/request/Request.kt | 16 +- .../auth0/android/request/RetryInterceptor.kt | 43 ++++ .../auth0/android/request/SignUpRequest.kt | 11 +- .../internal/BaseAuthenticationRequest.kt | 9 + .../android/request/internal/BaseRequest.kt | 16 +- 12 files changed, 604 insertions(+), 18 deletions(-) create mode 100644 auth0/src/main/java/com/auth0/android/dpop/DPoPException.kt create mode 100644 auth0/src/main/java/com/auth0/android/dpop/DPoPProvider.kt create mode 100644 auth0/src/main/java/com/auth0/android/dpop/DefaultPoPKeyStore.kt create mode 100644 auth0/src/main/java/com/auth0/android/request/ErrorBody.kt create mode 100644 auth0/src/main/java/com/auth0/android/request/RetryInterceptor.kt 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..832556a58 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -4,6 +4,7 @@ import androidx.annotation.VisibleForTesting import com.auth0.android.Auth0 import com.auth0.android.Auth0Exception import com.auth0.android.NetworkErrorException +import com.auth0.android.dpop.DPoPProvider import com.auth0.android.request.* import com.auth0.android.request.internal.* import com.auth0.android.request.internal.GsonAdapter.Companion.forMap @@ -561,9 +562,22 @@ 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 { - return profileRequest() - .addHeader(HEADER_AUTHORIZATION, "Bearer $accessToken") + public fun userInfo( + accessToken: String, tokenType: String + ): Request { + return profileRequest().apply { + val headerData = DPoPProvider.getHeaderData( + getHttpMethod().toString(), + getUrl(), + accessToken, + tokenType, + DPoPProvider.auth0Nonce + ) + addHeader(HEADER_AUTHORIZATION, headerData.authorizationHeader) + headerData.dpopProof?.let { + addHeader(DPoPProvider.DPOP_HEADER, it) + } + } } /** @@ -928,6 +942,9 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe ) val request = factory.post(url.toString(), credentialsAdapter) request.addParameters(parameters) + DPoPProvider.generateProof(request.getUrl(), request.getHttpMethod().toString())?.let { + request.addHeader(DPoPProvider.DPOP_HEADER, it) + } return request } @@ -994,6 +1011,9 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe ) val request = factory.post(url.toString(), adapter) request.addParameters(requestParameters) + DPoPProvider.generateProof(request.getUrl(), request.getHttpMethod().toString())?.let { + request.addHeader(DPoPProvider.DPOP_HEADER, it) + } return request } @@ -1017,6 +1037,9 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe factory.post(url.toString(), credentialsAdapter), clientId, baseURL ) request.addParameters(requestParameters) + DPoPProvider.generateProof(request.getUrl(), request.getHttpMethod().toString())?.let { + request.addHeader(DPoPProvider.DPOP_HEADER, it) + } return request } 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..a410fb7b4 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoPException.kt @@ -0,0 +1,20 @@ +package com.auth0.android.dpop + +import com.auth0.android.Auth0Exception + +public class DPoPException : Auth0Exception { + private var code: String? = null + private var description: String? = null + + + public constructor(code: String, description: String) : this(DEFAULT_MESSAGE) { + this.code = code + this.description = description + } + + public constructor(message: String, cause: Exception? = null) : super(message, cause) + + private companion object { + private const val DEFAULT_MESSAGE = "Unknown error" + } +} \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/dpop/DPoPProvider.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoPProvider.kt new file mode 100644 index 000000000..ff6d9df2e --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoPProvider.kt @@ -0,0 +1,232 @@ +package com.auth0.android.dpop + +import android.content.Context +import android.util.Base64 +import android.util.Log +import androidx.annotation.RequiresApi +import com.auth0.android.request.getErrorBody +import okhttp3.Response +import org.json.JSONObject +import java.math.BigInteger +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 + +public data class HeaderData(val authorizationHeader: String, val dpopProof: String?) + +public object DPoPProvider { + + private const val TAG = "DPoPManager" + private const val NONCE_REQUIRED_ERROR = "use_dpop_nonce" + private const val NONCE_HEADER = "dpop-nonce" + public const val DPOP_HEADER: String = "DPoP" + + private val keyStore = DefaultPoPKeyStore() + + public const val MAX_RETRY_COUNT: Int = 1 + + public var auth0Nonce: String? = null + private set + + @RequiresApi(android.os.Build.VERSION_CODES.M) + public fun generateProof( + httpUrl: String, + httpMethod: String, + accessToken: String? = null, + nonce: String? = null + ): String? { + if (!keyStore.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") + return null + } + 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 payloadJson = JSONObject().apply { + put("jti", UUID.randomUUID().toString()) + put("htm", httpMethod.uppercase()) + put("htu", httpUrl) + 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}" + } + + @RequiresApi(android.os.Build.VERSION_CODES.M) + public fun clearKeyPair() { + keyStore.deleteKeyPair() + } + + @RequiresApi(android.os.Build.VERSION_CODES.M) + public fun getPublicKeyJWK(context: Context): String? { + if (!keyStore.hasKeyPair()) { + keyStore.generateKeyPair(context) + } + + 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()) + } + + 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) + } + + public fun isNonceRequiredError(response: Response): Boolean { + return try { + (response.code == 400 || response.code == 401) && response.getErrorBody().errorCode == NONCE_REQUIRED_ERROR + } catch (e: Exception) { + Log.d( + TAG, + "isNonceRequiredError: Exception parsing the response for error ${e.stackTraceToString()}" + ) + false + } + } + + public fun storeNonce(response: Response) { + auth0Nonce = response.headers[NONCE_HEADER] + } + + 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 Base64.encodeToString(hash, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + } + + 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()}") + } + return null + } + + 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) + } +} \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/dpop/DefaultPoPKeyStore.kt b/auth0/src/main/java/com/auth0/android/dpop/DefaultPoPKeyStore.kt new file mode 100644 index 000000000..720cb6faf --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/dpop/DefaultPoPKeyStore.kt @@ -0,0 +1,161 @@ +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 androidx.annotation.RequiresApi +import com.auth0.android.authentication.storage.IncompatibleDeviceException +import com.auth0.android.request.AuthenticationRequest +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 + + +public interface PoPKeyStore { + +} + +@RequiresApi(Build.VERSION_CODES.M) +public class DefaultPoPKeyStore : PoPKeyStore { + + private val keyStore: KeyStore by lazy { + KeyStore.getInstance(ANDROID_KEYSTORE).apply { + load(null) + } + } + + public fun generateKeyPair(context: Context) { + 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 (isStrongBoxEnabled(context)) { + setIsStrongBoxBacked(true) + } + } + + keyPairGenerator.initialize(builder.build()) + keyPairGenerator.generateKeyPair() + Log.d(TAG, "Key pair generated successfully.") + } catch (e: Exception) { + + //TODO : Handle the exceptions beeter with bettr type + + /* + * This exceptions are safe to be ignored: + * + * - CertificateException: + * Thrown when certificate has expired (25 years..) or couldn't be loaded + * - KeyStoreException: + * - NoSuchProviderException: + * Thrown when "AndroidKeyStore" is not available. Was introduced on API 18. + * - NoSuchAlgorithmException: + * Thrown when "RSA" algorithm is not available. Was introduced on API 18. + * - InvalidAlgorithmParameterException: + * Thrown if Key Size is other than 512, 768, 1024, 2048, 3072, 4096 + * or if Padding is other than RSA/ECB/PKCS1Padding, introduced on API 18 + * or if Block Mode is other than ECB + * - ProviderException: + * Thrown on some modified devices when KeyPairGenerator#generateKeyPair is called. + * See: https://www.bountysource.com/issues/45527093-keystore-issues + * + * However if any of this exceptions happens to be thrown (OEMs often change their Android distribution source code), + * all the checks performed in this class wouldn't matter and the device would not be compatible at all with it. + * + * Read more in https://developer.android.com/training/articles/keystore#SupportedAlgorithms + */ + when (e) { + is CertificateException, + is InvalidAlgorithmParameterException, + is NoSuchProviderException, + is NoSuchAlgorithmException, + is KeyStoreException, + is ProviderException -> { + Log.e(TAG, "The device can't generate a new EC Key pair.", e) + throw IncompatibleDeviceException(e) + } + + else -> throw e + } + } + } + + public fun getKeyPair(): Pair? { + try { + val privateKey = keyStore.getKey(KEY_ALIAS, null) as PrivateKey + val publicKey = keyStore.getCertificate(KEY_ALIAS)?.publicKey + if (privateKey != null && publicKey != null) { + return Pair(privateKey, publicKey) + } + } catch (e: KeyStoreException) { + Log.e(TAG, "getKeyPair: Error getting key pair ${e.stackTraceToString()}") + } + Log.e(TAG, "Returning null key pair ") + return null + } + + + public fun addHeaders(request: AuthenticationRequest, tokenType: String) { + + + } + + public fun hasKeyPair(): Boolean { + try { + return keyStore.containsAlias(KEY_ALIAS) + } catch (e: KeyStoreException) { + e.printStackTrace() + } + return false + } + + public fun deleteKeyPair() { + try { + keyStore.deleteEntry(KEY_ALIAS) + } catch (e: KeyStoreException) { + e.printStackTrace() + } + } + + 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/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..df7c8df64 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/request/ErrorBody.kt @@ -0,0 +1,25 @@ +package com.auth0.android.request + +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 InputStreamReader(body?.byteStream(), Charsets.UTF_8).use { reader -> + Gson().fromJson(reader, ErrorBody::class.java) + } +} 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..e7c766ea6 100755 --- a/auth0/src/main/java/com/auth0/android/request/ProfileRequest.kt +++ b/auth0/src/main/java/com/auth0/android/request/ProfileRequest.kt @@ -3,6 +3,7 @@ package com.auth0.android.request import com.auth0.android.Auth0Exception import com.auth0.android.authentication.AuthenticationException import com.auth0.android.callback.Callback +import com.auth0.android.dpop.DPoPProvider import com.auth0.android.result.Authentication import com.auth0.android.result.Credentials import com.auth0.android.result.UserProfile @@ -79,8 +80,16 @@ public class ProfileRequest override fun start(callback: Callback) { authenticationRequest.start(object : Callback { override fun onSuccess(credentials: Credentials) { - userInfoRequest - .addHeader(HEADER_AUTHORIZATION, "Bearer " + credentials.accessToken) + val headerData = DPoPProvider.getHeaderData( + getHttpMethod().toString(), getUrl(), + credentials.accessToken, credentials.type, DPoPProvider.auth0Nonce + ) + userInfoRequest.apply { + addHeader(HEADER_AUTHORIZATION, headerData.authorizationHeader) + headerData.dpopProof?.let { + addHeader(DPoPProvider.DPOP_HEADER, it) + } + } .start(object : Callback { override fun onSuccess(profile: UserProfile) { callback.onSuccess(Authentication(profile, credentials)) @@ -107,9 +116,17 @@ public class ProfileRequest @Throws(Auth0Exception::class) override fun execute(): Authentication { val credentials = authenticationRequest.execute() - val profile = userInfoRequest - .addHeader(HEADER_AUTHORIZATION, "Bearer " + credentials.accessToken) - .execute() + val headerData = DPoPProvider.getHeaderData( + getHttpMethod().toString(), getUrl(), + credentials.accessToken, credentials.type, DPoPProvider.auth0Nonce + ) + val profile = userInfoRequest.run { + addHeader(HEADER_AUTHORIZATION, headerData.authorizationHeader) + headerData.dpopProof?.let { + addHeader(DPoPProvider.DPOP_HEADER, it) + } + execute() + } return Authentication(profile, credentials) } @@ -124,12 +141,28 @@ public class ProfileRequest @Throws(Auth0Exception::class) override suspend fun await(): Authentication { val credentials = authenticationRequest.await() - val profile = userInfoRequest - .addHeader(HEADER_AUTHORIZATION, "Bearer " + credentials.accessToken) - .await() + val headerData = DPoPProvider.getHeaderData( + getHttpMethod().toString(), getUrl(), + credentials.accessToken, credentials.type, DPoPProvider.auth0Nonce + ) + val profile = userInfoRequest.run { + addHeader(HEADER_AUTHORIZATION, headerData.authorizationHeader) + headerData.dpopProof?.let { + addHeader(DPoPProvider.DPOP_HEADER, it) + } + await() + } return Authentication(profile, credentials) } + override fun getUrl(): String { + return userInfoRequest.getUrl() + } + + override fun getHttpMethod(): HttpMethod { + return userInfoRequest.getHttpMethod() + } + private companion object { private const val HEADER_AUTHORIZATION = "Authorization" } 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..63c39198e 100755 --- a/auth0/src/main/java/com/auth0/android/request/Request.kt +++ b/auth0/src/main/java/com/auth0/android/request/Request.kt @@ -2,6 +2,7 @@ package com.auth0.android.request import com.auth0.android.Auth0Exception import com.auth0.android.callback.Callback +import okhttp3.HttpUrl /** * Defines a request that can be started @@ -64,7 +65,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 } @@ -76,4 +77,17 @@ public interface Request { * @return itself */ public fun addHeader(name: String, value: String): Request + + + /** + * Returns the URL of this request. + * @return the URL + */ + public fun getUrl(): String + + /** + * Returns the [HttpMethod] of this request + * @return the [HttpMethod] + */ + public fun getHttpMethod(): HttpMethod } \ No newline at end of file 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..f2396163e --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/request/RetryInterceptor.kt @@ -0,0 +1,43 @@ +package com.auth0.android.request + +import com.auth0.android.dpop.DPoPProvider +import okhttp3.Interceptor +import okhttp3.Response + +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) + + //Handling DPoP Nonce retry + if (DPoPProvider.isNonceRequiredError(response) && currentRetryCount < DPoPProvider.MAX_RETRY_COUNT) { + DPoPProvider.storeNonce(response) + val accessToken = + request.headers[AUTHORIZATION_HEADER]?.substringAfter(DPOP_LIMITER)?.trim() + val dpopProof = DPoPProvider.generateProof( + httpUrl = request.url.toString(), + httpMethod = request.method, + accessToken = accessToken, + nonce = DPoPProvider.auth0Nonce + ) + if (dpopProof != null) { + response.close() + val newRequest = request.newBuilder() + .header(DPoPProvider.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..08845a675 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. @@ -122,6 +120,15 @@ public class SignUpRequest return this } + override fun getUrl(): String { + return signUpRequest.getUrl() + } + + + override fun getHttpMethod(): HttpMethod { + return signUpRequest.getHttpMethod() + } + /** * Starts to execute create user request and then logs the user 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..3c987e377 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 @@ -11,6 +11,7 @@ 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.HttpMethod import com.auth0.android.request.Request import com.auth0.android.result.Credentials import java.util.* @@ -134,6 +135,14 @@ internal open class BaseAuthenticationRequest( return this } + override fun getHttpMethod(): HttpMethod { + return request.getHttpMethod() + } + + override fun getUrl(): String { + return request.getUrl() + } + override fun start(callback: Callback) { warnClaimValidation() request.start(object : Callback { 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..140bc81cf 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,13 @@ 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.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,7 +26,7 @@ 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, @@ -60,6 +66,10 @@ internal open class BaseRequest( return this } + override fun getUrl(): String = url + + override fun getHttpMethod(): HttpMethod = method + /** * Runs asynchronously and executes the network request, without blocking the current thread. * The result is parsed into a value and posted in the callback's onSuccess method or a @@ -129,7 +139,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 From 0663272a306fbc1a0d9c2b65f062aa090afdcc7f Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Thu, 24 Jul 2025 11:59:34 +0530 Subject: [PATCH 02/20] Added API to enable DPoP to the WebAuthProvider and AuthenticationAPI client class --- .../authentication/AuthenticationAPIClient.kt | 64 ++++++++++++++----- .../com/auth0/android/dpop/DPoPException.kt | 40 ++++++++++-- ...{DefaultPoPKeyStore.kt => DPoPKeyStore.kt} | 55 ++-------------- .../com/auth0/android/dpop/DPoPProvider.kt | 53 ++++++++++----- .../auth0/android/provider/OAuthManager.kt | 26 ++++++-- .../auth0/android/provider/WebAuthProvider.kt | 18 +++++- .../com/auth0/android/request/ErrorBody.kt | 8 ++- 7 files changed, 169 insertions(+), 95 deletions(-) rename auth0/src/main/java/com/auth0/android/dpop/{DefaultPoPKeyStore.kt => DPoPKeyStore.kt} (63%) 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 832556a58..52fac51a1 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -1,10 +1,16 @@ package com.auth0.android.authentication +import android.content.Context +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi import androidx.annotation.VisibleForTesting import com.auth0.android.Auth0 import com.auth0.android.Auth0Exception import com.auth0.android.NetworkErrorException +import com.auth0.android.dpop.DPoPException import com.auth0.android.dpop.DPoPProvider +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 @@ -36,7 +42,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe private val auth0: Auth0, private val factory: RequestFactory, private val gson: Gson -) { +) : SenderConstraining { /** * Creates a new API client instance providing Auth0 account info. @@ -60,6 +66,13 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe public val baseURL: String get() = auth0.getDomainUrl() + + @RequiresApi(Build.VERSION_CODES.M) + override fun enableDPoP(context: Context): AuthenticationAPIClient { + DPoPProvider.generateKeyPair(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 @@ -566,16 +579,20 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe accessToken: String, tokenType: String ): Request { return profileRequest().apply { - val headerData = DPoPProvider.getHeaderData( - getHttpMethod().toString(), - getUrl(), - accessToken, - tokenType, - DPoPProvider.auth0Nonce - ) - addHeader(HEADER_AUTHORIZATION, headerData.authorizationHeader) - headerData.dpopProof?.let { - addHeader(DPoPProvider.DPOP_HEADER, it) + try { + val headerData = DPoPProvider.getHeaderData( + getHttpMethod().toString(), + getUrl(), + accessToken, + tokenType, + DPoPProvider.auth0Nonce + ) + addHeader(HEADER_AUTHORIZATION, headerData.authorizationHeader) + headerData.dpopProof?.let { + addHeader(DPoPProvider.DPOP_HEADER, it) + } + } catch (exception: DPoPException) { + Log.e(TAG, "Error generating DPoP proof: ${exception.stackTraceToString()}") } } } @@ -942,8 +959,12 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe ) val request = factory.post(url.toString(), credentialsAdapter) request.addParameters(parameters) - DPoPProvider.generateProof(request.getUrl(), request.getHttpMethod().toString())?.let { - request.addHeader(DPoPProvider.DPOP_HEADER, it) + try { + DPoPProvider.generateProof(request.getUrl(), request.getHttpMethod().toString())?.let { + request.addHeader(DPoPProvider.DPOP_HEADER, it) + } + } catch (exception: DPoPException) { + Log.e(TAG, "Error generating DPoP proof: ${exception.stackTraceToString()}") } return request } @@ -1011,8 +1032,12 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe ) val request = factory.post(url.toString(), adapter) request.addParameters(requestParameters) - DPoPProvider.generateProof(request.getUrl(), request.getHttpMethod().toString())?.let { - request.addHeader(DPoPProvider.DPOP_HEADER, it) + try { + DPoPProvider.generateProof(request.getUrl(), request.getHttpMethod().toString())?.let { + request.addHeader(DPoPProvider.DPOP_HEADER, it) + } + } catch (exception: DPoPException) { + Log.e(TAG, "Error generating DPoP proof: ${exception.stackTraceToString()}") } return request } @@ -1037,8 +1062,12 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe factory.post(url.toString(), credentialsAdapter), clientId, baseURL ) request.addParameters(requestParameters) - DPoPProvider.generateProof(request.getUrl(), request.getHttpMethod().toString())?.let { - request.addHeader(DPoPProvider.DPOP_HEADER, it) + try { + DPoPProvider.generateProof(request.getUrl(), request.getHttpMethod().toString())?.let { + request.addHeader(DPoPProvider.DPOP_HEADER, it) + } + } catch (exception: DPoPException) { + Log.e(TAG, "Error generating DPoP proof: ${exception.stackTraceToString()}") } return request } @@ -1109,6 +1138,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 { diff --git a/auth0/src/main/java/com/auth0/android/dpop/DPoPException.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoPException.kt index a410fb7b4..41195cb7c 100644 --- a/auth0/src/main/java/com/auth0/android/dpop/DPoPException.kt +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoPException.kt @@ -3,18 +3,46 @@ package com.auth0.android.dpop import com.auth0.android.Auth0Exception public class DPoPException : Auth0Exception { - private var code: String? = null - private var description: String? = null + public enum class Code { + KEY_GENERATION_ERROR, + KEY_STORE_ERROR, + UNKNOWN, + } + + private var code: Code? = null + + internal constructor( + code: Code, + cause: Throwable? = null + ) : this( + code, + getMessage(code), + cause + ) - public constructor(code: String, description: String) : this(DEFAULT_MESSAGE) { + internal constructor( + code: Code, + message: String, + cause: Throwable? = null + ) : super( + message, + cause + ) { this.code = code - this.description = description } - public constructor(message: String, cause: Exception? = null) : super(message, cause) private companion object { - private const val DEFAULT_MESSAGE = "Unknown error" + 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.KEY_GENERATION_ERROR -> "Error generating DPoP key pair." + Code.KEY_STORE_ERROR -> "Error while accessing the key pair in the keystore." + Code.UNKNOWN -> DEFAULT_MESSAGE + } + } } } \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/dpop/DefaultPoPKeyStore.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoPKeyStore.kt similarity index 63% rename from auth0/src/main/java/com/auth0/android/dpop/DefaultPoPKeyStore.kt rename to auth0/src/main/java/com/auth0/android/dpop/DPoPKeyStore.kt index 720cb6faf..1b3b5cd6f 100644 --- a/auth0/src/main/java/com/auth0/android/dpop/DefaultPoPKeyStore.kt +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoPKeyStore.kt @@ -7,8 +7,6 @@ import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.Log import androidx.annotation.RequiresApi -import com.auth0.android.authentication.storage.IncompatibleDeviceException -import com.auth0.android.request.AuthenticationRequest import java.security.InvalidAlgorithmParameterException import java.security.KeyPairGenerator import java.security.KeyStore @@ -23,13 +21,7 @@ import java.util.Calendar import javax.security.auth.x500.X500Principal import javax.security.cert.CertificateException - -public interface PoPKeyStore { - -} - -@RequiresApi(Build.VERSION_CODES.M) -public class DefaultPoPKeyStore : PoPKeyStore { +public class DPoPKeyStore { private val keyStore: KeyStore by lazy { KeyStore.getInstance(ANDROID_KEYSTORE).apply { @@ -67,32 +59,6 @@ public class DefaultPoPKeyStore : PoPKeyStore { keyPairGenerator.generateKeyPair() Log.d(TAG, "Key pair generated successfully.") } catch (e: Exception) { - - //TODO : Handle the exceptions beeter with bettr type - - /* - * This exceptions are safe to be ignored: - * - * - CertificateException: - * Thrown when certificate has expired (25 years..) or couldn't be loaded - * - KeyStoreException: - * - NoSuchProviderException: - * Thrown when "AndroidKeyStore" is not available. Was introduced on API 18. - * - NoSuchAlgorithmException: - * Thrown when "RSA" algorithm is not available. Was introduced on API 18. - * - InvalidAlgorithmParameterException: - * Thrown if Key Size is other than 512, 768, 1024, 2048, 3072, 4096 - * or if Padding is other than RSA/ECB/PKCS1Padding, introduced on API 18 - * or if Block Mode is other than ECB - * - ProviderException: - * Thrown on some modified devices when KeyPairGenerator#generateKeyPair is called. - * See: https://www.bountysource.com/issues/45527093-keystore-issues - * - * However if any of this exceptions happens to be thrown (OEMs often change their Android distribution source code), - * all the checks performed in this class wouldn't matter and the device would not be compatible at all with it. - * - * Read more in https://developer.android.com/training/articles/keystore#SupportedAlgorithms - */ when (e) { is CertificateException, is InvalidAlgorithmParameterException, @@ -101,10 +67,10 @@ public class DefaultPoPKeyStore : PoPKeyStore { is KeyStoreException, is ProviderException -> { Log.e(TAG, "The device can't generate a new EC Key pair.", e) - throw IncompatibleDeviceException(e) + throw DPoPException(DPoPException.Code.KEY_GENERATION_ERROR, e) } - else -> throw e + else -> throw DPoPException(DPoPException.Code.UNKNOWN, e) } } } @@ -113,36 +79,29 @@ public class DefaultPoPKeyStore : PoPKeyStore { try { val privateKey = keyStore.getKey(KEY_ALIAS, null) as PrivateKey val publicKey = keyStore.getCertificate(KEY_ALIAS)?.publicKey - if (privateKey != null && publicKey != null) { + if (publicKey != null) { return Pair(privateKey, publicKey) } } catch (e: KeyStoreException) { - Log.e(TAG, "getKeyPair: Error getting key pair ${e.stackTraceToString()}") + throw DPoPException(DPoPException.Code.KEY_STORE_ERROR, e) } Log.e(TAG, "Returning null key pair ") return null } - - public fun addHeaders(request: AuthenticationRequest, tokenType: String) { - - - } - public fun hasKeyPair(): Boolean { try { return keyStore.containsAlias(KEY_ALIAS) } catch (e: KeyStoreException) { - e.printStackTrace() + throw DPoPException(DPoPException.Code.KEY_STORE_ERROR, e) } - return false } public fun deleteKeyPair() { try { keyStore.deleteEntry(KEY_ALIAS) } catch (e: KeyStoreException) { - e.printStackTrace() + throw DPoPException(DPoPException.Code.KEY_STORE_ERROR, e) } } diff --git a/auth0/src/main/java/com/auth0/android/dpop/DPoPProvider.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoPProvider.kt index ff6d9df2e..a90838ebb 100644 --- a/auth0/src/main/java/com/auth0/android/dpop/DPoPProvider.kt +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoPProvider.kt @@ -3,7 +3,6 @@ package com.auth0.android.dpop import android.content.Context import android.util.Base64 import android.util.Log -import androidx.annotation.RequiresApi import com.auth0.android.request.getErrorBody import okhttp3.Response import org.json.JSONObject @@ -15,6 +14,12 @@ import java.security.SignatureException import java.security.interfaces.ECPublicKey import java.util.UUID +public interface SenderConstraining { + + public fun enableDPoP(context: Context): T + +} + public data class HeaderData(val authorizationHeader: String, val dpopProof: String?) public object DPoPProvider { @@ -24,14 +29,14 @@ public object DPoPProvider { private const val NONCE_HEADER = "dpop-nonce" public const val DPOP_HEADER: String = "DPoP" - private val keyStore = DefaultPoPKeyStore() + private val keyStore = DPoPKeyStore() public const val MAX_RETRY_COUNT: Int = 1 public var auth0Nonce: String? = null private set - @RequiresApi(android.os.Build.VERSION_CODES.M) + @Throws(DPoPException::class) public fun generateProof( httpUrl: String, httpMethod: String, @@ -81,15 +86,16 @@ public object DPoPProvider { return "$headerEncoded.$payloadEncoded.${signature}" } - @RequiresApi(android.os.Build.VERSION_CODES.M) + @Throws(DPoPException::class) public fun clearKeyPair() { keyStore.deleteKeyPair() } - @RequiresApi(android.os.Build.VERSION_CODES.M) - public fun getPublicKeyJWK(context: Context): String? { + @Throws(DPoPException::class) + public fun getPublicKeyJWK(): String? { if (!keyStore.hasKeyPair()) { - keyStore.generateKeyPair(context) + Log.d(TAG, "getPublicKeyJWK: Key pair is not present to generate JWK") + return null } val publicKey = keyStore.getKeyPair()?.second @@ -102,6 +108,15 @@ public object DPoPProvider { return createSHA256Hash(jwkJson.toString()) } + @Throws(DPoPException::class) + public fun generateKeyPair(context: Context) { + if (keyStore.hasKeyPair()) { + return + } + keyStore.generateKeyPair(context) + } + + @Throws(DPoPException::class) public fun getHeaderData( httpMethod: String, httpUrl: String, @@ -116,15 +131,8 @@ public object DPoPProvider { } public fun isNonceRequiredError(response: Response): Boolean { - return try { - (response.code == 400 || response.code == 401) && response.getErrorBody().errorCode == NONCE_REQUIRED_ERROR - } catch (e: Exception) { - Log.d( - TAG, - "isNonceRequiredError: Exception parsing the response for error ${e.stackTraceToString()}" - ) - false - } + return (response.code == 400 && response.getErrorBody().errorCode == NONCE_REQUIRED_ERROR) || + (response.code == 401 && isResourceServerNonceError(response)) } public fun storeNonce(response: Response) { @@ -229,4 +237,17 @@ public object DPoPProvider { } return Pair(len, bytesConsumed) } + + private 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 + } } \ 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..c5599a415 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,18 @@ package com.auth0.android.provider import android.content.Context import android.net.Uri -import android.os.Bundle +import android.os.Build import android.text.TextUtils import android.util.Base64 import android.util.Log -import androidx.annotation.ChecksSdkIntAtLeast +import androidx.annotation.RequiresApi 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.DPoPProvider import com.auth0.android.request.internal.Jwt import com.auth0.android.request.internal.OidcUtils import com.auth0.android.result.Credentials @@ -59,10 +60,12 @@ internal class OAuthManager( idTokenVerificationIssuer = if (TextUtils.isEmpty(issuer)) apiClient.baseURL else issuer } + @RequiresApi(Build.VERSION_CODES.M) fun startAuthentication(context: Context, redirectUri: String, requestCode: Int) { OidcUtils.includeDefaultScope(parameters) addPKCEParameters(parameters, redirectUri, headers) addClientParameters(parameters, redirectUri) + addDPoPJWKParameters(parameters) addValidationParameters(parameters) val uri = buildAuthorizeUri() this.requestCode = requestCode @@ -220,13 +223,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, @@ -279,6 +291,12 @@ internal class OAuthManager( } } + private fun addDPoPJWKParameters(parameters: MutableMap) { + DPoPProvider.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/WebAuthProvider.kt b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt index dff8a48a9..bfd12220c 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -3,12 +3,16 @@ package com.auth0.android.provider import android.content.Context import android.content.Intent import android.net.Uri +import android.os.Build import android.os.Bundle import android.util.Log +import androidx.annotation.RequiresApi 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.DPoPProvider +import com.auth0.android.dpop.SenderConstraining import com.auth0.android.result.Credentials import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine @@ -25,7 +29,7 @@ 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" @@ -46,6 +50,12 @@ public object WebAuthProvider { callbacks -= callback } + @RequiresApi(Build.VERSION_CODES.M) + override fun enableDPoP(context: Context): WebAuthProvider { + DPoPProvider.generateKeyPair(context) + return this + } + // Public methods /** * Initialize the WebAuthProvider instance for logging out the user using an account. Additional settings can be configured @@ -580,8 +590,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 + ) manager.setHeaders(headers) manager.setPKCE(pkce) manager.setIdTokenVerificationLeeway(leeway) diff --git a/auth0/src/main/java/com/auth0/android/request/ErrorBody.kt b/auth0/src/main/java/com/auth0/android/request/ErrorBody.kt index df7c8df64..a767319d2 100644 --- a/auth0/src/main/java/com/auth0/android/request/ErrorBody.kt +++ b/auth0/src/main/java/com/auth0/android/request/ErrorBody.kt @@ -1,5 +1,6 @@ package com.auth0.android.request +import android.util.Log import com.google.gson.Gson import com.google.gson.annotations.SerializedName import okhttp3.Response @@ -20,6 +21,11 @@ public data class ErrorBody( */ public fun Response.getErrorBody(): ErrorBody { return InputStreamReader(body?.byteStream(), Charsets.UTF_8).use { reader -> - Gson().fromJson(reader, ErrorBody::class.java) + try { + Gson().fromJson(reader, ErrorBody::class.java) + } catch (error: Exception) { + Log.e("ErrorBody", "Error parsing the error body ${error.stackTraceToString()}") + return ErrorBody("", "") + } } } From cb038bf43a718774ccbbf0926042ac3dd212e142 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Fri, 25 Jul 2025 17:26:29 +0530 Subject: [PATCH 03/20] added the dpop support in the token renew flow --- .../android/authentication/AuthenticationAPIClient.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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 52fac51a1..80764fb75 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -821,8 +821,16 @@ 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) .addParameters(parameters) + try { + DPoPProvider.generateProof(request.getUrl(), request.getHttpMethod().toString())?.let { + request.addHeader(DPoPProvider.DPOP_HEADER, it) + } + } catch (exception: DPoPException) { + Log.e(TAG, "Error generating DPoP proof: ${exception.stackTraceToString()}") + } + return request } /** From e1e70337eb369a959cd9577d4580a9a750f6bd67 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 28 Jul 2025 14:09:28 +0530 Subject: [PATCH 04/20] Added comments to api methods --- .../authentication/AuthenticationAPIClient.kt | 5 +- .../com/auth0/android/dpop/DPoPKeyStore.kt | 13 +- .../com/auth0/android/dpop/DPoPProvider.kt | 146 +++++++++++++++++- .../auth0/android/dpop/SenderConstraining.kt | 15 ++ .../auth0/android/provider/WebAuthProvider.kt | 2 +- .../auth0/android/request/RetryInterceptor.kt | 3 + 6 files changed, 172 insertions(+), 12 deletions(-) create mode 100644 auth0/src/main/java/com/auth0/android/dpop/SenderConstraining.kt 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 80764fb75..9c05a201b 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -67,8 +67,11 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe get() = auth0.getDomainUrl() + /** + * Enable DPoP for this client. + */ @RequiresApi(Build.VERSION_CODES.M) - override fun enableDPoP(context: Context): AuthenticationAPIClient { + public override fun enableDPoP(context: Context): AuthenticationAPIClient { DPoPProvider.generateKeyPair(context) return this } diff --git a/auth0/src/main/java/com/auth0/android/dpop/DPoPKeyStore.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoPKeyStore.kt index 1b3b5cd6f..b893e2c0e 100644 --- a/auth0/src/main/java/com/auth0/android/dpop/DPoPKeyStore.kt +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoPKeyStore.kt @@ -21,7 +21,10 @@ import java.util.Calendar import javax.security.auth.x500.X500Principal import javax.security.cert.CertificateException -public class DPoPKeyStore { +/** + * Class to handle all DPoP related keystore operations + */ +internal class DPoPKeyStore { private val keyStore: KeyStore by lazy { KeyStore.getInstance(ANDROID_KEYSTORE).apply { @@ -29,7 +32,7 @@ public class DPoPKeyStore { } } - public fun generateKeyPair(context: Context) { + fun generateKeyPair(context: Context) { try { val keyPairGenerator = KeyPairGenerator.getInstance( KeyProperties.KEY_ALGORITHM_EC, @@ -75,7 +78,7 @@ public class DPoPKeyStore { } } - public fun getKeyPair(): Pair? { + fun getKeyPair(): Pair? { try { val privateKey = keyStore.getKey(KEY_ALIAS, null) as PrivateKey val publicKey = keyStore.getCertificate(KEY_ALIAS)?.publicKey @@ -89,7 +92,7 @@ public class DPoPKeyStore { return null } - public fun hasKeyPair(): Boolean { + fun hasKeyPair(): Boolean { try { return keyStore.containsAlias(KEY_ALIAS) } catch (e: KeyStoreException) { @@ -97,7 +100,7 @@ public class DPoPKeyStore { } } - public fun deleteKeyPair() { + fun deleteKeyPair() { try { keyStore.deleteEntry(KEY_ALIAS) } catch (e: KeyStoreException) { diff --git a/auth0/src/main/java/com/auth0/android/dpop/DPoPProvider.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoPProvider.kt index a90838ebb..35988b62d 100644 --- a/auth0/src/main/java/com/auth0/android/dpop/DPoPProvider.kt +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoPProvider.kt @@ -14,14 +14,19 @@ import java.security.SignatureException import java.security.interfaces.ECPublicKey import java.util.UUID -public interface SenderConstraining { - - public fun enableDPoP(context: Context): T - -} +/** + * 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?) + +/** + * Util class for securing requests with DPoP (Demonstrating Proof of Possession) as described in + * [RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449). + */ public object DPoPProvider { private const val TAG = "DPoPManager" @@ -36,6 +41,27 @@ public object DPoPProvider { public var auth0Nonce: String? = null private set + /** + * This method constructs a DPoP proof JWT that includes the HTTP method, URL, and an optional access token and nonce. + * + * ```kotlin + * + * try { + * DPoPProvider.generateProof("{url}", "POST")?.let { + * // Add to the URL request header + * } + * } catch (exception: DPoPException) { + * Log.e(TAG, "Error generating DPoP proof: ${exception.stackTraceToString()}") + * } + * + * ``` + * + * @param httpUrl The URL of the HTTP request for which the DPoP proof is being generated. + * @param httpMethod The HTTP method (e.g., "GET", "POST") of the request. + * @param accessToken An optional access token to be included in the proof. If provided, it will be hashed and included in the payload. + * @param nonce An optional nonce value to be included in the proof. This can be used to prevent replay attacks. + * @throws DPoPException if there is an error generating the DPoP proof or accessing the key pair. + */ @Throws(DPoPException::class) public fun generateProof( httpUrl: String, @@ -86,11 +112,44 @@ public object DPoPProvider { return "$headerEncoded.$payloadEncoded.${signature}" } + /** + * Method to clear the DPoP key pair from the keystore. It must be called when the user logs out from a session + * to prevent reuse of the key pair in subsequent sessions. + * + * ```kotlin + * + * try { + * DPoPProvider.clearKeyPair() + * } catch (exception: DPoPException) { + * Log.e(TAG,"Error clearing the key pair from the keystore: ${exception.stackTraceToString()}") + * } + * + * ``` + * **Note** : It is the developers responsibility to invoke this method to clear the keystore when logging out a session. + * @throws DPoPException if there is an error deleting the key pair. + */ @Throws(DPoPException::class) public fun clearKeyPair() { keyStore.deleteKeyPair() } + /** + * Method to get the public key in JWK format. This is used to generate the `jwk` field in the DPoP proof header. + * + * ```kotlin + * + * try { + * val publicKeyJWK = DPoPProvider.getPublicKeyJWK() + * Log.d(TAG, "Public Key JWK: $publicKeyJWK") + * } catch (exception: DPoPException) { + * Log.e(TAG,"Error getting public key JWK: ${exception.stackTraceToString()}") + * } + * + * ``` + * + * @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) public fun getPublicKeyJWK(): String? { if (!keyStore.hasKeyPair()) { @@ -108,6 +167,22 @@ public object DPoPProvider { return createSHA256Hash(jwkJson.toString()) } + /** + * Generates a new key pair for DPoP if it does not already exist. This should be called before making any requests that require DPoP proof. + * + * ```kotlin + * + * try { + * DPoPProvider.generateKeyPair(context) + * } catch (exception: DPoPException) { + * Log.e(TAG,"Error generating key pair: ${exception.stackTraceToString()}") + * } + * + * ``` + * + * @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) public fun generateKeyPair(context: Context) { if (keyStore.hasKeyPair()) { @@ -116,6 +191,37 @@ public object DPoPProvider { keyStore.generateKeyPair(context) } + /** + * 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 = DPoPProvider.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) public fun getHeaderData( httpMethod: String, @@ -130,11 +236,41 @@ public object DPoPProvider { return HeaderData(token, proof) } + /** + * 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 (DPoPProvider.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. + */ public fun isNonceRequiredError(response: Response): Boolean { return (response.code == 400 && response.getErrorBody().errorCode == NONCE_REQUIRED_ERROR) || (response.code == 401 && isResourceServerNonceError(response)) } + /** + * Stores the nonce value from the Okhttp3 [Response] headers. + * + * ```kotlin + * + * try { + * DPoPProvider.storeNonce(response) + * } catch (exception: Exception) { + * Log.e(TAG, "Error storing nonce: ${exception.stackTraceToString()}") + * } + * + * ``` + * + * @param response The HTTP response containing the nonce header. + */ public fun storeNonce(response: Response) { auth0Nonce = response.headers[NONCE_HEADER] } 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..cdfcd9931 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/dpop/SenderConstraining.kt @@ -0,0 +1,15 @@ +package com.auth0.android.dpop + +import android.content.Context + +/** + * Interface for SenderConstraining + */ +public interface SenderConstraining { + + /** + * Enables DPoP for authentication requests. + */ + public fun enableDPoP(context: Context): T + +} \ No newline at end of file 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 bfd12220c..645537576 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -51,7 +51,7 @@ public object WebAuthProvider : SenderConstraining { } @RequiresApi(Build.VERSION_CODES.M) - override fun enableDPoP(context: Context): WebAuthProvider { + public override fun enableDPoP(context: Context): WebAuthProvider { DPoPProvider.generateKeyPair(context) 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 index f2396163e..2280ce4ee 100644 --- a/auth0/src/main/java/com/auth0/android/request/RetryInterceptor.kt +++ b/auth0/src/main/java/com/auth0/android/request/RetryInterceptor.kt @@ -4,6 +4,9 @@ import com.auth0.android.dpop.DPoPProvider 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() From 0498227e9d141d5d6336089791a4a67b48d1fe9a Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Wed, 30 Jul 2025 12:16:41 +0530 Subject: [PATCH 05/20] Added test cases for the DPoPProvider class --- .../com/auth0/android/dpop/DPoPException.kt | 14 +- .../com/auth0/android/dpop/DPoPProvider.kt | 10 +- .../com/auth0/android/request/internal/Jwt.kt | 4 +- .../AuthenticationAPIClientTest.kt | 6 +- .../request/AuthenticationRequestMock.java | 13 + .../authentication/request/RequestMock.java | 13 + .../auth0/android/dpop/DPoPProviderTest.kt | 421 ++++++++++++++++++ .../test/java/com/auth0/android/dpop/Fakes.kt | 82 ++++ 8 files changed, 552 insertions(+), 11 deletions(-) create mode 100644 auth0/src/test/java/com/auth0/android/dpop/DPoPProviderTest.kt create mode 100644 auth0/src/test/java/com/auth0/android/dpop/Fakes.kt diff --git a/auth0/src/main/java/com/auth0/android/dpop/DPoPException.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoPException.kt index 41195cb7c..d28f870dd 100644 --- a/auth0/src/main/java/com/auth0/android/dpop/DPoPException.kt +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoPException.kt @@ -4,12 +4,13 @@ import com.auth0.android.Auth0Exception public class DPoPException : Auth0Exception { - public enum class Code { + internal enum class Code { KEY_GENERATION_ERROR, KEY_STORE_ERROR, + SIGNING_FAILURE, UNKNOWN, } - + private var code: Code? = null internal constructor( @@ -33,7 +34,13 @@ public class DPoPException : Auth0Exception { } - private companion object { + public companion object { + + 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_FAILURE: DPoPException = DPoPException(Code.SIGNING_FAILURE) + public val UNKNOWN: DPoPException = DPoPException(Code.UNKNOWN) + private const val DEFAULT_MESSAGE = "An unknown error has occurred. Please check the error cause for more details." @@ -41,6 +48,7 @@ public class DPoPException : Auth0Exception { return when (code) { Code.KEY_GENERATION_ERROR -> "Error generating DPoP key pair." Code.KEY_STORE_ERROR -> "Error while accessing the key pair in the keystore." + Code.SIGNING_FAILURE -> "Error while signing the DPoP proof." Code.UNKNOWN -> DEFAULT_MESSAGE } } diff --git a/auth0/src/main/java/com/auth0/android/dpop/DPoPProvider.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoPProvider.kt index 35988b62d..329f397bf 100644 --- a/auth0/src/main/java/com/auth0/android/dpop/DPoPProvider.kt +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoPProvider.kt @@ -3,6 +3,7 @@ package com.auth0.android.dpop import android.content.Context import android.util.Base64 import android.util.Log +import androidx.annotation.VisibleForTesting import com.auth0.android.request.getErrorBody import okhttp3.Response import org.json.JSONObject @@ -34,13 +35,16 @@ public object DPoPProvider { private const val NONCE_HEADER = "dpop-nonce" public const val DPOP_HEADER: String = "DPoP" - private val keyStore = DPoPKeyStore() public const val MAX_RETRY_COUNT: Int = 1 public var auth0Nonce: String? = null private set + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + @Volatile + internal var keyStore = DPoPKeyStore() + /** * This method constructs a DPoP proof JWT that includes the HTTP method, URL, and an optional access token and nonce. * @@ -153,7 +157,7 @@ public object DPoPProvider { @Throws(DPoPException::class) public fun getPublicKeyJWK(): String? { if (!keyStore.hasKeyPair()) { - Log.d(TAG, "getPublicKeyJWK: Key pair is not present to generate JWK") + Log.e(TAG, "getPublicKeyJWK: Key pair is not present to generate JWK") return null } @@ -324,8 +328,8 @@ public object DPoPProvider { return encodeBase64Url(convertDerToRawSignature(signatureBytes)) } catch (e: Exception) { Log.e(TAG, "Error signing data: ${e.stackTraceToString()}") + throw DPoPException(DPoPException.Code.SIGNING_FAILURE, e) } - return null } private fun convertDerToRawSignature(derSignature: ByteArray): ByteArray { 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/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt index 3c42da6f6..2bff8a0ce 100755 --- a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt @@ -592,7 +592,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 +617,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 +638,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() diff --git a/auth0/src/test/java/com/auth0/android/authentication/request/AuthenticationRequestMock.java b/auth0/src/test/java/com/auth0/android/authentication/request/AuthenticationRequestMock.java index 4b5d6d0e2..b7eafc8be 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/request/AuthenticationRequestMock.java +++ b/auth0/src/test/java/com/auth0/android/authentication/request/AuthenticationRequestMock.java @@ -6,6 +6,7 @@ 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.Request; import com.auth0.android.result.Credentials; @@ -112,4 +113,16 @@ public AuthenticationRequest withIdTokenVerificationLeeway(int leeway) { public AuthenticationRequest withIdTokenVerificationIssuer(@NonNull String issuer) { return this; } + + @NonNull + @Override + public String getUrl() { + return ""; + } + + @NonNull + @Override + public HttpMethod getHttpMethod() { + return HttpMethod.GET.INSTANCE; + } } 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..84450e844 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; @@ -40,6 +41,18 @@ public Request addHeader(@NonNull String name, @NonNull String value) { return this; } + @NonNull + @Override + public String getUrl() { + return ""; + } + + @NonNull + @Override + public HttpMethod getHttpMethod() { + return HttpMethod.GET.INSTANCE; + } + @Override public void start(@NonNull Callback callback) { started = true; diff --git a/auth0/src/test/java/com/auth0/android/dpop/DPoPProviderTest.kt b/auth0/src/test/java/com/auth0/android/dpop/DPoPProviderTest.kt new file mode 100644 index 000000000..3884df536 --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/dpop/DPoPProviderTest.kt @@ -0,0 +1,421 @@ +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.Headers +import okhttp3.Response +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.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 DPoPProviderTest { + + 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() + + DPoPProvider.keyStore = mockKeyStore + } + + @Test + public fun `generateProof should return null when keyStore has no key pair`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(false) + + val result = DPoPProvider.generateProof(testHttpUrl, testHttpMethod) + + assertThat(result, `is`(nullValue())) + verify(mockKeyStore).hasKeyPair() + verifyNoMoreInteractions(mockKeyStore) + } + + @Test + public fun `generateProof should return null when keyStore returns null key pair`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(null) + + val result = DPoPProvider.generateProof(testHttpUrl, testHttpMethod) + + assertThat(result, `is`(nullValue())) + verify(mockKeyStore).hasKeyPair() + verify(mockKeyStore).getKeyPair() + } + + @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 = DPoPProvider.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 = DPoPProvider.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 = + DPoPProvider.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 = DPoPProvider.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 = + DPoPProvider.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) { + DPoPProvider.generateProof(testHttpUrl, testHttpMethod) + } + Assert.assertEquals("Error while signing the DPoP proof.", exception.message) + } + + @Test + public fun `clearKeyPair should delegate to keyStore`() { + DPoPProvider.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) { + DPoPProvider.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 = DPoPProvider.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 = DPoPProvider.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 = DPoPProvider.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 = DPoPProvider.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) + DPoPProvider.generateKeyPair(mockContext) + verify(mockKeyStore).hasKeyPair() + verify(mockKeyStore, never()).generateKeyPair(any()) + } + + @Test + public fun `generateKeyPair should generate new key pair when none exists`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(false) + DPoPProvider.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) { + DPoPProvider.generateKeyPair(mockContext) + } + Assert.assertEquals("Error generating DPoP key pair.", exception.message) + } + + @Test + public fun `getHeaderData should return bearer token when tokenType is not DPoP`() { + val tokenType = "Bearer" + val result = + DPoPProvider.getHeaderData(testHttpMethod, testHttpUrl, testAccessToken, tokenType) + 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`() { + val tokenType = "DPoP" + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + val result = + DPoPProvider.getHeaderData(testHttpMethod, testHttpUrl, testAccessToken, tokenType) + + assertThat(result.authorizationHeader, `is`("DPoP $testAccessToken")) + assertThat(result.dpopProof, `is`(notNullValue())) + val proof = Jwt(result.dpopProof!!) + + 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) + } + + @Test + public fun `getHeaderData should return DPoP token with proof including nonce when provided`() { + val tokenType = "DPoP" + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + val result = DPoPProvider.getHeaderData( + testHttpMethod, + testHttpUrl, + testAccessToken, + tokenType, + testNonce + ) + + assertThat(result.authorizationHeader, `is`("DPoP $testAccessToken")) + assertThat(result.dpopProof, `is`(notNullValue())) + + val proof = Jwt(result.dpopProof!!) + 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 `isNonceRequiredError should return true for 400 response with nonce required error`() { + whenever(mockResponse.body).thenReturn("{\"error\":\"use_dpop_nonce\"}".toResponseBody()) + whenever(mockResponse.code).thenReturn(400) + + val result = DPoPProvider.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 = DPoPProvider.isNonceRequiredError(mockResponse) + assertThat(result, `is`(true)) + } + + @Test + public fun `isNonceRequiredError should return false for 400 response with different error`() { + whenever(mockResponse.body).thenReturn("{\"error\":\"different_error\"}".toResponseBody()) + whenever(mockResponse.code).thenReturn(400) + + val result = DPoPProvider.isNonceRequiredError(mockResponse) + assertThat(result, `is`(false)) + } + + @Test + public fun `isNonceRequiredError should return false for 401 response without WWW-Authenticate header`() { + whenever(mockResponse.headers).thenReturn( + Headers.Builder().build() + ) + whenever(mockResponse.code).thenReturn(401) + + val result = DPoPProvider.isNonceRequiredError(mockResponse) + assertThat(result, `is`(false)) + } + + @Test + public fun `isNonceRequiredError should return false for 401 response with different WWW-Authenticate error`() { + whenever(mockResponse.headers).thenReturn( + Headers.Builder().add("WWW-Authenticate", "error=\"different_error\"").build() + ) + whenever(mockResponse.code).thenReturn(401) + + val result = DPoPProvider.isNonceRequiredError(mockResponse) + assertThat(result, `is`(false)) + } + + @Test + public fun `isNonceRequiredError should return false for different response codes`() { + whenever(mockResponse.code).thenReturn(500) + + val result = DPoPProvider.isNonceRequiredError(mockResponse) + assertThat(result, `is`(false)) + } + + @Test + public fun `storeNonce should store nonce from response headers`() { + whenever(mockResponse.headers).thenReturn( + Headers.Builder().add("dpop-nonce", "stored-nonce-value").build() + ) + + DPoPProvider.storeNonce(mockResponse) + assertThat(DPoPProvider.auth0Nonce, `is`("stored-nonce-value")) + } + + @Test + public fun `isResourceServerNonceError should parse WWW-Authenticate header correctly`() { + whenever(mockResponse.headers).thenReturn( + Headers.Builder().add( + "WWW-Authenticate", + "DPoP error=\"use_dpop_nonce\", error_description=\"DPoP proof requires nonce\"" + ).build() + ) + whenever(mockResponse.code).thenReturn(401) + + val result = DPoPProvider.isNonceRequiredError(mockResponse) + assertThat(result, `is`(true)) + } +} \ 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..2ce1b6f47 --- /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 From a96a2f9e735fd2622ad0e8b241e609afeda0074a Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Wed, 30 Jul 2025 13:03:14 +0530 Subject: [PATCH 06/20] Minor code refactoring in the AuthenticationAPIClient class --- .../authentication/AuthenticationAPIClient.kt | 50 ++++++++----------- 1 file changed, 20 insertions(+), 30 deletions(-) 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 9c05a201b..638462249 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -826,13 +826,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe ) val request = factory.post(url.toString(), credentialsAdapter) .addParameters(parameters) - try { - DPoPProvider.generateProof(request.getUrl(), request.getHttpMethod().toString())?.let { - request.addHeader(DPoPProvider.DPOP_HEADER, it) - } - } catch (exception: DPoPException) { - Log.e(TAG, "Error generating DPoP proof: ${exception.stackTraceToString()}") - } + .addDPoPHeader() return request } @@ -969,14 +963,8 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe Credentials::class.java, gson ) val request = factory.post(url.toString(), credentialsAdapter) - request.addParameters(parameters) - try { - DPoPProvider.generateProof(request.getUrl(), request.getHttpMethod().toString())?.let { - request.addHeader(DPoPProvider.DPOP_HEADER, it) - } - } catch (exception: DPoPException) { - Log.e(TAG, "Error generating DPoP proof: ${exception.stackTraceToString()}") - } + .addParameters(parameters) + .addDPoPHeader() return request } @@ -1042,14 +1030,8 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe T::class.java, gson ) val request = factory.post(url.toString(), adapter) - request.addParameters(requestParameters) - try { - DPoPProvider.generateProof(request.getUrl(), request.getHttpMethod().toString())?.let { - request.addHeader(DPoPProvider.DPOP_HEADER, it) - } - } catch (exception: DPoPException) { - Log.e(TAG, "Error generating DPoP proof: ${exception.stackTraceToString()}") - } + .addParameters(requestParameters) + .addDPoPHeader() return request } @@ -1073,13 +1055,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe factory.post(url.toString(), credentialsAdapter), clientId, baseURL ) request.addParameters(requestParameters) - try { - DPoPProvider.generateProof(request.getUrl(), request.getHttpMethod().toString())?.let { - request.addHeader(DPoPProvider.DPOP_HEADER, it) - } - } catch (exception: DPoPException) { - Log.e(TAG, "Error generating DPoP proof: ${exception.stackTraceToString()}") - } + .addDPoPHeader() return request } @@ -1109,6 +1085,20 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe return factory.get(url.toString(), userProfileAdapter) } + /** + * Helper method to add DPoP proof to all the [Request] + */ + private fun Request.addDPoPHeader(): Request { + try { + DPoPProvider.generateProof(getUrl(), getHttpMethod().toString())?.let { + addHeader(DPoPProvider.DPOP_HEADER, it) + } + } catch (exception: DPoPException) { + Log.e(TAG, "Error generating DPoP proof: ${exception.stackTraceToString()}") + } + return this + } + private companion object { private const val SMS_CONNECTION = "sms" private const val EMAIL_CONNECTION = "email" From 96f3b7827b50cb21a273369703a9c833ad28f823 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Wed, 30 Jul 2025 15:48:25 +0530 Subject: [PATCH 07/20] Added test cases for the DPoPKeyStore class --- .../com/auth0/android/dpop/DPoPException.kt | 15 +- .../com/auth0/android/dpop/DPoPKeyStore.kt | 19 +- .../com/auth0/android/dpop/DPoPProvider.kt | 2 +- .../auth0/android/dpop/DPoPKeyStoreTest.kt | 228 ++++++++++++++++++ 4 files changed, 247 insertions(+), 17 deletions(-) create mode 100644 auth0/src/test/java/com/auth0/android/dpop/DPoPKeyStoreTest.kt diff --git a/auth0/src/main/java/com/auth0/android/dpop/DPoPException.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoPException.kt index d28f870dd..2008c8e56 100644 --- a/auth0/src/main/java/com/auth0/android/dpop/DPoPException.kt +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoPException.kt @@ -5,10 +5,11 @@ import com.auth0.android.Auth0Exception public class DPoPException : Auth0Exception { internal enum class Code { + UNSUPPORTED_ERROR, KEY_GENERATION_ERROR, KEY_STORE_ERROR, - SIGNING_FAILURE, - UNKNOWN, + SIGNING_ERROR, + UNKNOWN_ERROR, } private var code: Code? = null @@ -36,20 +37,22 @@ public class DPoPException : Auth0Exception { 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_FAILURE: DPoPException = DPoPException(Code.SIGNING_FAILURE) - public val UNKNOWN: DPoPException = DPoPException(Code.UNKNOWN) + public val SIGNING_ERROR: DPoPException = DPoPException(Code.SIGNING_ERROR) + public val UNKNOWN_ERROR: DPoPException = DPoPException(Code.UNKNOWN_ERROR) 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_FAILURE -> "Error while signing the DPoP proof." - Code.UNKNOWN -> DEFAULT_MESSAGE + Code.SIGNING_ERROR -> "Error while signing the DPoP proof." + Code.UNKNOWN_ERROR -> DEFAULT_MESSAGE } } } diff --git a/auth0/src/main/java/com/auth0/android/dpop/DPoPKeyStore.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoPKeyStore.kt index b893e2c0e..fa3e137d2 100644 --- a/auth0/src/main/java/com/auth0/android/dpop/DPoPKeyStore.kt +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoPKeyStore.kt @@ -6,7 +6,6 @@ import android.os.Build import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.Log -import androidx.annotation.RequiresApi import java.security.InvalidAlgorithmParameterException import java.security.KeyPairGenerator import java.security.KeyStore @@ -24,15 +23,15 @@ import javax.security.cert.CertificateException /** * Class to handle all DPoP related keystore operations */ -internal class DPoPKeyStore { - - private val keyStore: KeyStore by lazy { - KeyStore.getInstance(ANDROID_KEYSTORE).apply { - load(null) - } - } +internal class DPoPKeyStore( + private val keyStore: KeyStore = + KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } +) { fun generateKeyPair(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + throw DPoPException.UNSUPPORTED_ERROR + } try { val keyPairGenerator = KeyPairGenerator.getInstance( KeyProperties.KEY_ALGORITHM_EC, @@ -73,7 +72,7 @@ internal class DPoPKeyStore { throw DPoPException(DPoPException.Code.KEY_GENERATION_ERROR, e) } - else -> throw DPoPException(DPoPException.Code.UNKNOWN, e) + else -> throw DPoPException(DPoPException.Code.UNKNOWN_ERROR, e) } } } @@ -88,7 +87,7 @@ internal class DPoPKeyStore { } catch (e: KeyStoreException) { throw DPoPException(DPoPException.Code.KEY_STORE_ERROR, e) } - Log.e(TAG, "Returning null key pair ") + Log.d(TAG, "Returning null key pair ") return null } diff --git a/auth0/src/main/java/com/auth0/android/dpop/DPoPProvider.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoPProvider.kt index 329f397bf..2072170ca 100644 --- a/auth0/src/main/java/com/auth0/android/dpop/DPoPProvider.kt +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoPProvider.kt @@ -328,7 +328,7 @@ public object DPoPProvider { return encodeBase64Url(convertDerToRawSignature(signatureBytes)) } catch (e: Exception) { Log.e(TAG, "Error signing data: ${e.stackTraceToString()}") - throw DPoPException(DPoPException.Code.SIGNING_FAILURE, e) + throw DPoPException(DPoPException.Code.SIGNING_ERROR, e) } } 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..4065bf76d --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/dpop/DPoPKeyStoreTest.kt @@ -0,0 +1,228 @@ +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.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.PublicKey +import java.security.cert.Certificate +import javax.security.auth.x500.X500Principal + +@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) + + // Configure mocks + 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 = DPoPKeyStore(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)) + } +} \ No newline at end of file From a701dbf91b18b7f8664ff1032126b0b03827778c Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Wed, 30 Jul 2025 19:45:09 +0530 Subject: [PATCH 08/20] Fixed failing tests --- .../java/com/auth0/android/dpop/DPoPKeyStore.kt | 7 ++++--- .../com/auth0/android/provider/OAuthManager.kt | 1 - .../java/com/auth0/android/request/ErrorBody.kt | 14 +++++++------- .../com/auth0/android/dpop/DPoPKeyStoreTest.kt | 10 ++++++++-- .../android/util/AuthenticationAPIMockServer.kt | 2 +- 5 files changed, 20 insertions(+), 14 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/dpop/DPoPKeyStore.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoPKeyStore.kt index fa3e137d2..9ece74f66 100644 --- a/auth0/src/main/java/com/auth0/android/dpop/DPoPKeyStore.kt +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoPKeyStore.kt @@ -23,10 +23,11 @@ import javax.security.cert.CertificateException /** * Class to handle all DPoP related keystore operations */ -internal class DPoPKeyStore( - private val keyStore: KeyStore = +internal open class DPoPKeyStore { + + protected open val keyStore: KeyStore by lazy { KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } -) { + } fun generateKeyPair(context: Context) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { 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 c5599a415..668ab00d6 100644 --- a/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt +++ b/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt @@ -60,7 +60,6 @@ internal class OAuthManager( idTokenVerificationIssuer = if (TextUtils.isEmpty(issuer)) apiClient.baseURL else issuer } - @RequiresApi(Build.VERSION_CODES.M) fun startAuthentication(context: Context, redirectUri: String, requestCode: Int) { OidcUtils.includeDefaultScope(parameters) addPKCEParameters(parameters, redirectUri, headers) diff --git a/auth0/src/main/java/com/auth0/android/request/ErrorBody.kt b/auth0/src/main/java/com/auth0/android/request/ErrorBody.kt index a767319d2..a99e4ee87 100644 --- a/auth0/src/main/java/com/auth0/android/request/ErrorBody.kt +++ b/auth0/src/main/java/com/auth0/android/request/ErrorBody.kt @@ -20,12 +20,12 @@ public data class ErrorBody( * Extension method to parse [ErrorBody] from [Response] */ public fun Response.getErrorBody(): ErrorBody { - return InputStreamReader(body?.byteStream(), Charsets.UTF_8).use { reader -> - try { - Gson().fromJson(reader, ErrorBody::class.java) - } catch (error: Exception) { - Log.e("ErrorBody", "Error parsing the error body ${error.stackTraceToString()}") - return 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/test/java/com/auth0/android/dpop/DPoPKeyStoreTest.kt b/auth0/src/test/java/com/auth0/android/dpop/DPoPKeyStoreTest.kt index 4065bf76d..1d63f9cfa 100644 --- a/auth0/src/test/java/com/auth0/android/dpop/DPoPKeyStoreTest.kt +++ b/auth0/src/test/java/com/auth0/android/dpop/DPoPKeyStoreTest.kt @@ -35,6 +35,13 @@ 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, @@ -73,7 +80,6 @@ public class DPoPKeyStoreTest { PowerMockito.whenNew(KeyGenParameterSpec.Builder::class.java).withAnyArguments() .thenReturn(mockSpecBuilder) - // Configure mocks PowerMockito.`when`(KeyStore.getInstance("AndroidKeyStore")).thenReturn(mockKeyStore) doNothing().whenever(mockKeyStore).load(anyOrNull()) PowerMockito.`when`( @@ -94,7 +100,7 @@ public class DPoPKeyStoreTest { true ) - dpopKeyStore = DPoPKeyStore(mockKeyStore) + dpopKeyStore = MockableDPoPKeyStore(mockKeyStore) } @Test 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 From 75a4241a7145b28d76019aa5d46198d52717cfcc Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Wed, 30 Jul 2025 20:42:03 +0530 Subject: [PATCH 09/20] Fixed the remaining failing errors --- .../request/ProfileRequestTest.java | 37 +++++++++++-------- .../auth0/android/dpop/DPoPProviderTest.kt | 4 +- .../android/provider/WebAuthProviderTest.kt | 11 ++++++ .../android/request/DefaultClientTest.kt | 6 +-- 4 files changed, 38 insertions(+), 20 deletions(-) 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..d821d6743 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,9 +39,13 @@ 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); + when(userInfoMockRequest.getHttpMethod()).thenReturn(HttpMethod.GET.INSTANCE); + when(userInfoMockRequest.getUrl()).thenReturn("www.api.com/example"); authenticationMockRequest = mock(AuthenticationRequest.class); profileRequest = new ProfileRequest(authenticationMockRequest, userInfoMockRequest); } @@ -87,7 +94,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 +137,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 +155,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/dpop/DPoPProviderTest.kt b/auth0/src/test/java/com/auth0/android/dpop/DPoPProviderTest.kt index 3884df536..a97f1bec4 100644 --- a/auth0/src/test/java/com/auth0/android/dpop/DPoPProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/dpop/DPoPProviderTest.kt @@ -339,7 +339,7 @@ public class DPoPProviderTest { @Test public fun `isNonceRequiredError should return true for 400 response with nonce required error`() { - whenever(mockResponse.body).thenReturn("{\"error\":\"use_dpop_nonce\"}".toResponseBody()) + whenever(mockResponse.peekBody(Long.MAX_VALUE)).thenReturn("{\"error\":\"use_dpop_nonce\"}".toResponseBody()) whenever(mockResponse.code).thenReturn(400) val result = DPoPProvider.isNonceRequiredError(mockResponse) @@ -358,7 +358,7 @@ public class DPoPProviderTest { @Test public fun `isNonceRequiredError should return false for 400 response with different error`() { - whenever(mockResponse.body).thenReturn("{\"error\":\"different_error\"}".toResponseBody()) + whenever(mockResponse.peekBody(Long.MAX_VALUE)).thenReturn("{\"error\":\"different_error\"}".toResponseBody()) whenever(mockResponse.code).thenReturn(400) val result = DPoPProvider.isNonceRequiredError(mockResponse) 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..e048e5080 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt @@ -9,6 +9,8 @@ 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.DPoPKeyStore +import com.auth0.android.dpop.DPoPProvider import com.auth0.android.provider.WebAuthProvider.login import com.auth0.android.provider.WebAuthProvider.logout import com.auth0.android.provider.WebAuthProvider.resume @@ -65,6 +67,7 @@ public class WebAuthProviderTest { private lateinit var voidCallback: Callback private lateinit var activity: Activity private lateinit var account: Auth0 + private lateinit var mockKeyStore: DPoPKeyStore private val authExceptionCaptor: KArgumentCaptor = argumentCaptor() private val intentCaptor: KArgumentCaptor = argumentCaptor() @@ -83,6 +86,10 @@ public class WebAuthProviderTest { Auth0.getInstance(JwtTestUtils.EXPECTED_AUDIENCE, JwtTestUtils.EXPECTED_BASE_DOMAIN) account.networkingClient = SSLTestUtils.testClient + mockKeyStore = mock() + + DPoPProvider.keyStore = mockKeyStore + //Next line is needed to avoid CustomTabService from being bound to Test environment Mockito.doReturn(false).`when`(activity).bindService( any(), @@ -95,8 +102,11 @@ public class WebAuthProviderTest { null, null ) + + `when`(mockKeyStore.hasKeyPair()).thenReturn(false) } + //** ** ** ** ** ** **// //** ** ** ** ** ** **// //** LOG IN FEATURE **// @@ -107,6 +117,7 @@ public class WebAuthProviderTest { login(account) .start(activity, callback) Assert.assertNotNull(WebAuthProvider.managerInstance) + } @Test 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..27482e3da 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,15 @@ public class DefaultClientTest { @Test public fun shouldHaveLoggingDisabledByDefault() { - assertThat(DefaultClient().okHttpClient.interceptors, empty()) + assertThat(DefaultClient().okHttpClient.interceptors, hasSize(1)) } @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) From f14c30fc0271339eaedf0260c20d580a070a78f9 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Wed, 30 Jul 2025 21:20:49 +0530 Subject: [PATCH 10/20] Exisiting test classess updated --- .../com/auth0/android/dpop/DPoPProvider.kt | 7 +++ .../AuthenticationAPIClientTest.kt | 59 ++++++++++++++++--- .../auth0/android/dpop/DPoPProviderTest.kt | 2 +- .../test/java/com/auth0/android/dpop/Fakes.kt | 2 +- .../android/provider/WebAuthProviderTest.kt | 48 +++++++++++++++ .../android/request/DefaultClientTest.kt | 10 ++++ 6 files changed, 119 insertions(+), 9 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/dpop/DPoPProvider.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoPProvider.kt index 2072170ca..cad8a2058 100644 --- a/auth0/src/main/java/com/auth0/android/dpop/DPoPProvider.kt +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoPProvider.kt @@ -67,6 +67,7 @@ public object DPoPProvider { * @throws DPoPException if there is an error generating the DPoP proof or accessing the key pair. */ @Throws(DPoPException::class) + @JvmStatic public fun generateProof( httpUrl: String, httpMethod: String, @@ -133,6 +134,7 @@ public object DPoPProvider { * @throws DPoPException if there is an error deleting the key pair. */ @Throws(DPoPException::class) + @JvmStatic public fun clearKeyPair() { keyStore.deleteKeyPair() } @@ -155,6 +157,7 @@ public object DPoPProvider { * @throws DPoPException if there is an error accessing the key pair. */ @Throws(DPoPException::class) + @JvmStatic public fun getPublicKeyJWK(): String? { if (!keyStore.hasKeyPair()) { Log.e(TAG, "getPublicKeyJWK: Key pair is not present to generate JWK") @@ -188,6 +191,7 @@ public object DPoPProvider { * @throws DPoPException if there is an error generating the key pair or accessing the keystore. */ @Throws(DPoPException::class) + @JvmStatic public fun generateKeyPair(context: Context) { if (keyStore.hasKeyPair()) { return @@ -227,6 +231,7 @@ public object DPoPProvider { * @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, @@ -255,6 +260,7 @@ public object DPoPProvider { * @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)) @@ -275,6 +281,7 @@ public object DPoPProvider { * * @param response The HTTP response containing the nonce header. */ + @JvmStatic public fun storeNonce(response: Response) { auth0Nonce = response.headers[NONCE_HEADER] } 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 2bff8a0ce..46154504f 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,10 @@ 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.DPoPKeyStore +import com.auth0.android.dpop.DPoPProvider +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 @@ -44,6 +48,7 @@ import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.Mockito.`when` import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowLooper @@ -59,13 +64,16 @@ public class AuthenticationAPIClientTest { private lateinit var client: AuthenticationAPIClient private lateinit var gson: Gson private lateinit var mockAPI: AuthenticationAPIMockServer + private lateinit var mockKeyStore: DPoPKeyStore @Before public fun setUp() { mockAPI = AuthenticationAPIMockServer() + mockKeyStore = mock() val auth0 = auth0 client = AuthenticationAPIClient(auth0) gson = GsonBuilder().serializeNulls().create() + DPoPProvider.keyStore = mockKeyStore } @After @@ -193,8 +201,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 +602,7 @@ public class AuthenticationAPIClientTest { public fun shouldFetchUserInfo() { mockAPI.willReturnUserInfo() val callback = MockAuthenticationCallback() - client.userInfo("ACCESS_TOKEN","Bearer") + client.userInfo("ACCESS_TOKEN", "Bearer") .start(callback) ShadowLooper.idleMainLooper() assertThat( @@ -617,7 +627,7 @@ public class AuthenticationAPIClientTest { public fun shouldFetchUserInfoSync() { mockAPI.willReturnUserInfo() val profile = client - .userInfo("ACCESS_TOKEN","Bearer") + .userInfo("ACCESS_TOKEN", "Bearer") .execute() assertThat(profile, Matchers.`is`(Matchers.notNullValue())) val request = mockAPI.takeRequest() @@ -638,7 +648,7 @@ public class AuthenticationAPIClientTest { public fun shouldAwaitFetchUserInfo(): Unit = runTest { mockAPI.willReturnUserInfo() val profile = client - .userInfo("ACCESS_TOKEN","Bearer") + .userInfo("ACCESS_TOKEN", "Bearer") .await() assertThat(profile, Matchers.`is`(Matchers.notNullValue())) val request = mockAPI.takeRequest() @@ -2470,6 +2480,40 @@ public class AuthenticationAPIClientTest { ) } + @Test + public fun shouldRenewAuthWithDpopHeaderIfDpopEnabled() { + `when`(mockKeyStore.hasKeyPair()).thenReturn(true) + `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + val auth0 = auth0 + val client = AuthenticationAPIClient(auth0) + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + client.renewAuth("refreshToken") + .start(callback) + ShadowLooper.idleMainLooper() + val request = mockAPI.takeRequest() + assertThat( + request.getHeader("Accept-Language"), Matchers.`is`( + defaultLocale + ) + ) + assertThat( + request.getHeader("DPoP"), + Matchers.notNullValue() + ) + assertThat(request.path, Matchers.equalTo("/oauth/token")) + val body = bodyFromRequest(request) + assertThat(body, Matchers.not(Matchers.hasKey("scope"))) + assertThat(body, Matchers.hasEntry("client_id", CLIENT_ID)) + assertThat(body, Matchers.hasEntry("refresh_token", "refreshToken")) + assertThat(body, Matchers.hasEntry("grant_type", "refresh_token")) + assertThat( + callback, AuthenticationCallbackMatcher.hasPayloadOfType( + Credentials::class.java + ) + ) + } + @Test public fun shouldRenewAuthWithOAuthTokenSync() { val auth0 = auth0 @@ -2566,8 +2610,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`( diff --git a/auth0/src/test/java/com/auth0/android/dpop/DPoPProviderTest.kt b/auth0/src/test/java/com/auth0/android/dpop/DPoPProviderTest.kt index a97f1bec4..b68ccd9cb 100644 --- a/auth0/src/test/java/com/auth0/android/dpop/DPoPProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/dpop/DPoPProviderTest.kt @@ -37,7 +37,7 @@ public class DPoPProviderTest { private val testHttpMethod = "POST" private val testAccessToken = "test-access-token" private val testNonce = "test-nonce" - private val fakePrivateKey = FakeEcPrivateKey() + private val fakePrivateKey = FakeECPrivateKey() private val fakePublicKey = FakeECPublicKey() private val testEncodedAccessToken = "WXSA1LYsphIZPxnnP-TMOtF_C_nPwWp8v0tQZBMcSAU" private val testPublicJwkHash = "KQ-r0YQMCm0yVnGippcsZK4zO7oGIjOkNRbvILjjBAo" diff --git a/auth0/src/test/java/com/auth0/android/dpop/Fakes.kt b/auth0/src/test/java/com/auth0/android/dpop/Fakes.kt index 2ce1b6f47..d09e3db4c 100644 --- a/auth0/src/test/java/com/auth0/android/dpop/Fakes.kt +++ b/auth0/src/test/java/com/auth0/android/dpop/Fakes.kt @@ -11,7 +11,7 @@ import java.security.spec.ECPoint /** * Fake Private key used for testing DPoP */ -public class FakeEcPrivateKey : ECPrivateKey { +public class FakeECPrivateKey : ECPrivateKey { private companion object { private val S = 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 e048e5080..133b86f8c 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 @@ -11,6 +12,7 @@ import com.auth0.android.authentication.AuthenticationException import com.auth0.android.callback.Callback import com.auth0.android.dpop.DPoPKeyStore import com.auth0.android.dpop.DPoPProvider +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 @@ -317,6 +319,52 @@ public class WebAuthProviderTest { ) } + //jwk + + @Test + public fun enablingDPoPWillGenerateNEwKEyPairIfOneDoesNotExist() { + `when`(mockKeyStore.hasKeyPair()).thenReturn(false) + val context: Context = mock() + WebAuthProvider.enableDPoP(context) + login(account) + .start(activity, callback) + verify(mockKeyStore).generateKeyPair(context) + } + + @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 shouldNotHaveDpopJwkOnLoginIfDPoPIsEnabled() { + `when`(mockKeyStore.hasKeyPair()).thenReturn(true) + `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) + + WebAuthProvider.enableDPoP(mock()) + 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() { 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 27482e3da..daef5ee37 100644 --- a/auth0/src/test/java/com/auth0/android/request/DefaultClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/request/DefaultClientTest.kt @@ -82,6 +82,16 @@ public class DefaultClientTest { @Test public fun shouldHaveLoggingDisabledByDefault() { + val netClient = DefaultClient(enableLogging = false) + assertThat(DefaultClient().okHttpClient.interceptors, hasSize(1)) + val interceptor: Interceptor = netClient.okHttpClient.interceptors[0] + assert( + interceptor is RetryInterceptor, + ) + } + + @Test + public fun shouldHaveRetryInterceptorEnabled() { assertThat(DefaultClient().okHttpClient.interceptors, hasSize(1)) } From 8f19da1bb4deca70fe9cf549808f6b8307910a61 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Wed, 30 Jul 2025 23:18:42 +0530 Subject: [PATCH 11/20] More tests added --- .../android/request/RetryInterceptorTest.kt | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 auth0/src/test/java/com/auth0/android/request/RetryInterceptorTest.kt 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..022ecd8a8 --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/request/RetryInterceptorTest.kt @@ -0,0 +1,173 @@ +package com.auth0.android.request + +import com.auth0.android.dpop.DPoPKeyStore +import com.auth0.android.dpop.DPoPProvider +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() + + DPoPProvider.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(DPoPProvider.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 From 75ca5a11091fe0dd71add74016917c8a110b64fd Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Wed, 30 Jul 2025 23:54:12 +0530 Subject: [PATCH 12/20] Updated the examples.md file --- EXAMPLES.md | 128 ++++++++++++++++++ .../authentication/AuthenticationAPIClient.kt | 2 +- .../auth0/android/dpop/SenderConstraining.kt | 2 +- .../auth0/android/provider/WebAuthProvider.kt | 2 +- .../android/provider/WebAuthProviderTest.kt | 4 +- 5 files changed, 133 insertions(+), 5 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 71c21b611..a12f8d9c5 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 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(context:Context)` method. + +```kotlin +WebAuthProvider + .useDPoP(requireContext()) + .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 = DPoPProvider.getHeaderData( + httpMethod, url, + accessToken, tokenType + ) +httpRequest.apply{ + addHeader("Authorization", headerData.authorizationHeader) + headerData.dpopProof?.let { + addHeader("DPoP", it) + } +} +``` +If your API is issuing DPoP nonce's to prevent replay attacks, you can pass the nonce value to the `getHeaderData()` method to include it in the DPoP proof. Use the `DPoPProvider.isNonceRequiredError(response: Response)` method to check if a particular API response failed because a nonce is required. + +```kotlin +if (DPoPProvider.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 `DPoPProvider.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(context: Context)` method. This ensures that DPoP proofs are generated for requests made through the AuthenticationAPI client. + +```kotlin +val client = AuthenticationAPIClient(account).useDPoP(context) +``` + +[!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 = DPoPProvider.getHeaderData( + httpMethod, url, + accessToken, tokenType + ) +httpRequest.apply{ + addHeader("Authorization", headerData.authorizationHeader) + headerData.dpopProof?.let { + addHeader("DPoP", it) + } +} +``` +If your API is issuing DPoP nonce's to prevent replay attacks, you can pass the nonce value to the `getHeaderData()` method to include it in the DPoP proof. Use the `DPoPProvider.isNonceRequiredError(response: Response)` method to check if a particular API response failed because a nonce is required. + +```kotlin +if (DPoPProvider.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 `DPoPProvider.clearKeyPair()` to delete the user's key pair from the Keychain. + +```kotlin + +DPoPProvider.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 638462249..e5c8706d8 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -71,7 +71,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * Enable DPoP for this client. */ @RequiresApi(Build.VERSION_CODES.M) - public override fun enableDPoP(context: Context): AuthenticationAPIClient { + public override fun useDPoP(context: Context): AuthenticationAPIClient { DPoPProvider.generateKeyPair(context) return this } diff --git a/auth0/src/main/java/com/auth0/android/dpop/SenderConstraining.kt b/auth0/src/main/java/com/auth0/android/dpop/SenderConstraining.kt index cdfcd9931..2bf24d672 100644 --- a/auth0/src/main/java/com/auth0/android/dpop/SenderConstraining.kt +++ b/auth0/src/main/java/com/auth0/android/dpop/SenderConstraining.kt @@ -10,6 +10,6 @@ public interface SenderConstraining { /** * Enables DPoP for authentication requests. */ - public fun enableDPoP(context: Context): T + public fun useDPoP(context: Context): T } \ No newline at end of file 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 645537576..f38a52586 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -51,7 +51,7 @@ public object WebAuthProvider : SenderConstraining { } @RequiresApi(Build.VERSION_CODES.M) - public override fun enableDPoP(context: Context): WebAuthProvider { + public override fun useDPoP(context: Context): WebAuthProvider { DPoPProvider.generateKeyPair(context) return this } 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 133b86f8c..14a26b266 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt @@ -325,7 +325,7 @@ public class WebAuthProviderTest { public fun enablingDPoPWillGenerateNEwKEyPairIfOneDoesNotExist() { `when`(mockKeyStore.hasKeyPair()).thenReturn(false) val context: Context = mock() - WebAuthProvider.enableDPoP(context) + WebAuthProvider.useDPoP(context) login(account) .start(activity, callback) verify(mockKeyStore).generateKeyPair(context) @@ -352,7 +352,7 @@ public class WebAuthProviderTest { `when`(mockKeyStore.hasKeyPair()).thenReturn(true) `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) - WebAuthProvider.enableDPoP(mock()) + WebAuthProvider.useDPoP(mock()) login(account) .start(activity, callback) verify(activity).startActivity(intentCaptor.capture()) From 93bf422ccb09621267bd0576f0dee66c716ecefa Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 4 Aug 2025 18:19:20 +0530 Subject: [PATCH 13/20] Addressed review comments --- EXAMPLES.md | 6 ++-- .../com/auth0/android/dpop/DPoPProvider.kt | 8 ++--- .../auth0/android/request/RetryInterceptor.kt | 34 ++++++++++--------- .../android/provider/WebAuthProviderTest.kt | 4 +-- .../android/request/DefaultClientTest.kt | 9 +++-- 5 files changed, 34 insertions(+), 27 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index a12f8d9c5..f2c12b505 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -215,7 +215,7 @@ WebAuthProvider.login(account) > [!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(context:Context)` method. +[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(context:Context)` method. ```kotlin WebAuthProvider @@ -252,7 +252,7 @@ httpRequest.apply{ } } ``` -If your API is issuing DPoP nonce's to prevent replay attacks, you can pass the nonce value to the `getHeaderData()` method to include it in the DPoP proof. Use the `DPoPProvider.isNonceRequiredError(response: Response)` method to check if a particular API response failed because a nonce is required. +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 `DPoPProvider.isNonceRequiredError(response: Response)` method to check if a particular API response failed because a nonce is required. ```kotlin if (DPoPProvider.isNonceRequiredError(response)) { @@ -755,7 +755,7 @@ httpRequest.apply{ } } ``` -If your API is issuing DPoP nonce's to prevent replay attacks, you can pass the nonce value to the `getHeaderData()` method to include it in the DPoP proof. Use the `DPoPProvider.isNonceRequiredError(response: Response)` method to check if a particular API response failed because a nonce is required. +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 `DPoPProvider.isNonceRequiredError(response: Response)` method to check if a particular API response failed because a nonce is required. ```kotlin if (DPoPProvider.isNonceRequiredError(response)) { diff --git a/auth0/src/main/java/com/auth0/android/dpop/DPoPProvider.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoPProvider.kt index cad8a2058..1535ce2e5 100644 --- a/auth0/src/main/java/com/auth0/android/dpop/DPoPProvider.kt +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoPProvider.kt @@ -118,7 +118,7 @@ public object DPoPProvider { } /** - * Method to clear the DPoP key pair from the keystore. It must be called when the user logs out from a session + * 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 @@ -130,7 +130,7 @@ public object DPoPProvider { * } * * ``` - * **Note** : It is the developers responsibility to invoke this method to clear the keystore when logging out a session. + * **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) @@ -175,7 +175,7 @@ public object DPoPProvider { } /** - * Generates a new key pair for DPoP if it does not already exist. This should be called before making any requests that require DPoP proof. + * Generates a new key pair for DPoP if it does not already exist. This should be called before making any requests that require a DPoP proof. * * ```kotlin * @@ -201,7 +201,7 @@ public object DPoPProvider { /** * 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 + * using the access token and token type. The `DPoP` header value contains the generated DPoP proof. * * ```kotlin * diff --git a/auth0/src/main/java/com/auth0/android/request/RetryInterceptor.kt b/auth0/src/main/java/com/auth0/android/request/RetryInterceptor.kt index 2280ce4ee..ee2ca409c 100644 --- a/auth0/src/main/java/com/auth0/android/request/RetryInterceptor.kt +++ b/auth0/src/main/java/com/auth0/android/request/RetryInterceptor.kt @@ -16,22 +16,24 @@ internal class RetryInterceptor : Interceptor { //Handling DPoP Nonce retry if (DPoPProvider.isNonceRequiredError(response) && currentRetryCount < DPoPProvider.MAX_RETRY_COUNT) { - DPoPProvider.storeNonce(response) - val accessToken = - request.headers[AUTHORIZATION_HEADER]?.substringAfter(DPOP_LIMITER)?.trim() - val dpopProof = DPoPProvider.generateProof( - httpUrl = request.url.toString(), - httpMethod = request.method, - accessToken = accessToken, - nonce = DPoPProvider.auth0Nonce - ) - if (dpopProof != null) { - response.close() - val newRequest = request.newBuilder() - .header(DPoPProvider.DPOP_HEADER, dpopProof) - .header(RETRY_COUNT_HEADER, (currentRetryCount + 1).toString()) - .build() - return chain.proceed(newRequest) + synchronized(this) { + DPoPProvider.storeNonce(response) + val accessToken = + request.headers[AUTHORIZATION_HEADER]?.substringAfter(DPOP_LIMITER)?.trim() + val dpopProof = DPoPProvider.generateProof( + httpUrl = request.url.toString(), + httpMethod = request.method, + accessToken = accessToken, + nonce = DPoPProvider.auth0Nonce + ) + if (dpopProof != null) { + response.close() + val newRequest = request.newBuilder() + .header(DPoPProvider.DPOP_HEADER, dpopProof) + .header(RETRY_COUNT_HEADER, (currentRetryCount + 1).toString()) + .build() + return chain.proceed(newRequest) + } } } return response 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 14a26b266..0376e2194 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt @@ -322,7 +322,7 @@ public class WebAuthProviderTest { //jwk @Test - public fun enablingDPoPWillGenerateNEwKEyPairIfOneDoesNotExist() { + public fun enablingDPoPWillGenerateNewKeyPairIfOneDoesNotExist() { `when`(mockKeyStore.hasKeyPair()).thenReturn(false) val context: Context = mock() WebAuthProvider.useDPoP(context) @@ -348,7 +348,7 @@ public class WebAuthProviderTest { } @Test - public fun shouldNotHaveDpopJwkOnLoginIfDPoPIsEnabled() { + public fun shouldHaveDpopJwkOnLoginIfDPoPIsEnabled() { `when`(mockKeyStore.hasKeyPair()).thenReturn(true) `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) 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 daef5ee37..3b1dccd77 100644 --- a/auth0/src/test/java/com/auth0/android/request/DefaultClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/request/DefaultClientTest.kt @@ -83,7 +83,7 @@ public class DefaultClientTest { @Test public fun shouldHaveLoggingDisabledByDefault() { val netClient = DefaultClient(enableLogging = false) - assertThat(DefaultClient().okHttpClient.interceptors, hasSize(1)) + assertThat(netClient.okHttpClient.interceptors, hasSize(1)) val interceptor: Interceptor = netClient.okHttpClient.interceptors[0] assert( interceptor is RetryInterceptor, @@ -92,7 +92,12 @@ public class DefaultClientTest { @Test public fun shouldHaveRetryInterceptorEnabled() { - assertThat(DefaultClient().okHttpClient.interceptors, hasSize(1)) + val netClient = DefaultClient(enableLogging = false) + assertThat(netClient.okHttpClient.interceptors, hasSize(1)) + val interceptor: Interceptor = netClient.okHttpClient.interceptors[0] + assert( + interceptor is RetryInterceptor, + ) } @Test From e33d3d7d9101ef5fdf9351be7c80301fba7a6b42 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Wed, 6 Aug 2025 16:58:14 +0530 Subject: [PATCH 14/20] Review comments addressed and changed the DPoP flow a bit --- EXAMPLES.md | 26 +- .../authentication/AuthenticationAPIClient.kt | 68 +-- .../main/java/com/auth0/android/dpop/DPoP.kt | 238 ++++++++ .../com/auth0/android/dpop/DPoPProvider.kt | 400 -------------- .../java/com/auth0/android/dpop/DPoPUtil.kt | 259 +++++++++ .../auth0/android/dpop/SenderConstraining.kt | 9 +- .../auth0/android/provider/OAuthManager.kt | 30 +- .../android/provider/OAuthManagerState.kt | 16 +- .../auth0/android/provider/WebAuthProvider.kt | 14 +- .../auth0/android/request/ProfileRequest.kt | 46 +- .../auth0/android/request/RetryInterceptor.kt | 41 +- .../android/request/internal/BaseRequest.kt | 13 +- .../request/internal/RequestFactory.kt | 39 +- .../AuthenticationAPIClientTest.kt | 315 +++++++++-- .../java/com/auth0/android/dpop/DPoPTest.kt | 518 ++++++++++++++++++ .../{DPoPProviderTest.kt => DPoPUtilTest.kt} | 291 +++++----- .../android/provider/OAuthManagerTest.java | 38 +- .../android/provider/WebAuthProviderTest.kt | 314 ++++++++++- .../android/request/RetryInterceptorTest.kt | 7 +- .../request/internal/BaseRequestTest.kt | 264 ++++++++- .../request/internal/RequestFactoryTest.java | 53 +- 21 files changed, 2188 insertions(+), 811 deletions(-) create mode 100644 auth0/src/main/java/com/auth0/android/dpop/DPoP.kt delete mode 100644 auth0/src/main/java/com/auth0/android/dpop/DPoPProvider.kt create mode 100644 auth0/src/main/java/com/auth0/android/dpop/DPoPUtil.kt create mode 100644 auth0/src/test/java/com/auth0/android/dpop/DPoPTest.kt rename auth0/src/test/java/com/auth0/android/dpop/{DPoPProviderTest.kt => DPoPUtilTest.kt} (60%) diff --git a/EXAMPLES.md b/EXAMPLES.md index f2c12b505..567d95ca0 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -215,11 +215,11 @@ WebAuthProvider.login(account) > [!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(context:Context)` method. +[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(requireContext()) + .useDPoP() .login(account) .start(requireContext(), object : Callback { override fun onSuccess(result: Credentials) { @@ -241,7 +241,7 @@ When making requests to your own APIs, use the `DPoP.getHeaderData()` method to ```kotlin val url ="https://example.com/api/endpoint" val httpMethod = "GET" - val headerData = DPoPProvider.getHeaderData( + val headerData = DPoP.getHeaderData( httpMethod, url, accessToken, tokenType ) @@ -252,10 +252,10 @@ httpRequest.apply{ } } ``` -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 `DPoPProvider.isNonceRequiredError(response: Response)` method to check if a particular API response failed because a nonce is required. +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 (DPoPProvider.isNonceRequiredError(response)) { +if (DPoP.isNonceRequiredError(response)) { val nonce = response.headers["DPoP-Nonce"] val dpopProof = DPoPProvider.generateProof( url, httpMethod, accessToken, nonce @@ -264,7 +264,7 @@ if (DPoPProvider.isNonceRequiredError(response)) { } ``` -On logout, you should call `DPoPProvider.clearKeyPair()` to delete the user's key pair from the Keychain. +On logout, you should call `DPoP.clearKeyPair()` to delete the user's key pair from the Keychain. ```kotlin WebAuthProvider.logout(account) @@ -728,10 +728,10 @@ authentication > [!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(context: Context)` method. This ensures that DPoP proofs are generated for requests made through the AuthenticationAPI client. +[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(context) +val client = AuthenticationAPIClient(account).useDPoP() ``` [!IMPORTANT] @@ -744,7 +744,7 @@ When making requests to your own APIs, use the `DPoP.getHeaderData()` method to ```kotlin val url ="https://example.com/api/endpoint" val httpMethod = "GET" - val headerData = DPoPProvider.getHeaderData( + val headerData = DPoP.getHeaderData( httpMethod, url, accessToken, tokenType ) @@ -755,10 +755,10 @@ httpRequest.apply{ } } ``` -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 `DPoPProvider.isNonceRequiredError(response: Response)` method to check if a particular API response failed because a nonce is required. +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 (DPoPProvider.isNonceRequiredError(response)) { +if (DPoP.isNonceRequiredError(response)) { val nonce = response.headers["DPoP-Nonce"] val dpopProof = DPoPProvider.generateProof( url, httpMethod, accessToken, nonce @@ -767,11 +767,11 @@ if (DPoPProvider.isNonceRequiredError(response)) { } ``` -On logout, you should call `DPoPProvider.clearKeyPair()` to delete the user's key pair from the Keychain. +On logout, you should call `DPoP.clearKeyPair()` to delete the user's key pair from the Keychain. ```kotlin -DPoPProvider.clearKeyPair() +DPoP.clearKeyPair() ``` 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 e5c8706d8..671b4b2df 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -1,15 +1,11 @@ package com.auth0.android.authentication -import android.content.Context -import android.os.Build -import android.util.Log -import androidx.annotation.RequiresApi 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.DPoPProvider import com.auth0.android.dpop.SenderConstraining import com.auth0.android.request.* import com.auth0.android.request.internal.* @@ -44,6 +40,8 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe private val gson: Gson ) : SenderConstraining { + private var dPoP: DPoP? = null + /** * Creates a new API client instance providing Auth0 account info. * @@ -66,13 +64,11 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe public val baseURL: String get() = auth0.getDomainUrl() - /** * Enable DPoP for this client. */ - @RequiresApi(Build.VERSION_CODES.M) - public override fun useDPoP(context: Context): AuthenticationAPIClient { - DPoPProvider.generateKeyPair(context) + public override fun useDPoP(): AuthenticationAPIClient { + dPoP = DPoP() return this } @@ -579,25 +575,10 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * @return a request to start */ public fun userInfo( - accessToken: String, tokenType: String + accessToken: String, tokenType: String = "Bearer" ): Request { - return profileRequest().apply { - try { - val headerData = DPoPProvider.getHeaderData( - getHttpMethod().toString(), - getUrl(), - accessToken, - tokenType, - DPoPProvider.auth0Nonce - ) - addHeader(HEADER_AUTHORIZATION, headerData.authorizationHeader) - headerData.dpopProof?.let { - addHeader(DPoPProvider.DPOP_HEADER, it) - } - } catch (exception: DPoPException) { - Log.e(TAG, "Error generating DPoP proof: ${exception.stackTraceToString()}") - } - } + return profileRequest() + .addHeader(HEADER_AUTHORIZATION, "$tokenType $accessToken") } /** @@ -824,9 +805,8 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe val credentialsAdapter = GsonAdapter( Credentials::class.java, gson ) - val request = factory.post(url.toString(), credentialsAdapter) + val request = factory.post(url.toString(), credentialsAdapter, dPoP) .addParameters(parameters) - .addDPoPHeader() return request } @@ -962,9 +942,8 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe val credentialsAdapter: JsonAdapter = GsonAdapter( Credentials::class.java, gson ) - val request = factory.post(url.toString(), credentialsAdapter) + val request = factory.post(url.toString(), credentialsAdapter, dPoP) .addParameters(parameters) - .addDPoPHeader() return request } @@ -1029,9 +1008,8 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe val adapter: JsonAdapter = GsonAdapter( T::class.java, gson ) - val request = factory.post(url.toString(), adapter) + val request = factory.post(url.toString(), adapter, dPoP) .addParameters(requestParameters) - .addDPoPHeader() return request } @@ -1052,10 +1030,9 @@ 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) - .addDPoPHeader() return request } @@ -1082,21 +1059,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe val userProfileAdapter: JsonAdapter = GsonAdapter( UserProfile::class.java, gson ) - return factory.get(url.toString(), userProfileAdapter) - } - - /** - * Helper method to add DPoP proof to all the [Request] - */ - private fun Request.addDPoPHeader(): Request { - try { - DPoPProvider.generateProof(getUrl(), getHttpMethod().toString())?.let { - addHeader(DPoPProvider.DPOP_HEADER, it) - } - } catch (exception: DPoPException) { - Log.e(TAG, "Error generating DPoP proof: ${exception.stackTraceToString()}") - } - return this + return factory.get(url.toString(), userProfileAdapter, dPoP) } private companion object { @@ -1163,6 +1126,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..828b09610 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoP.kt @@ -0,0 +1,238 @@ +package com.auth0.android.dpop + +import android.content.Context +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 + + +/** + * 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 { + + /** + * Determines whether a DPoP proof should be generated for the given URL and parameters. + * + * @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. + * + * ```kotlin + * + * try { + * DPoP.generateKeyPair(context) + * } catch (exception: DPoPException) { + * Log.e(TAG,"Error generating key pair: ${exception.stackTraceToString()}") + * } + * + * ``` + * + * @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) + public fun generateKeyPair(context: Context) { + 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. + * + * ```kotlin + * + * try { + * val dPoP = DPoP() + * val publicKeyJWK = dPoP.getPublicKeyJWK(context) + * Log.d(TAG, "Public Key JWK: $publicKeyJWK") + * } catch (exception: DPoPException) { + * Log.e(TAG,"Error getting public key JWK: ${exception.stackTraceToString()}") + * } + * + * ``` + * + * @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) + public fun getPublicKeyJWK(context: Context): String? { + generateKeyPair(context) + return DPoPUtil.getPublicKeyJWK() + } + + public companion object { + + private const val AUTHORIZATION_HEADER = "Authorization" + private const val NONCE_HEADER = "DPoP-Nonce" + + @Volatile + private 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 + public fun storeNonce(response: Response) { + _auth0Nonce = response.headers[NONCE_HEADER] + } + + /** + * 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/DPoPProvider.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoPProvider.kt deleted file mode 100644 index 1535ce2e5..000000000 --- a/auth0/src/main/java/com/auth0/android/dpop/DPoPProvider.kt +++ /dev/null @@ -1,400 +0,0 @@ -package com.auth0.android.dpop - -import android.content.Context -import android.util.Base64 -import android.util.Log -import androidx.annotation.VisibleForTesting -import com.auth0.android.request.getErrorBody -import okhttp3.Response -import org.json.JSONObject -import java.math.BigInteger -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 - - -/** - * 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?) - - -/** - * Util class for securing requests with DPoP (Demonstrating Proof of Possession) as described in - * [RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449). - */ -public object DPoPProvider { - - private const val TAG = "DPoPManager" - private const val NONCE_REQUIRED_ERROR = "use_dpop_nonce" - private const val NONCE_HEADER = "dpop-nonce" - public const val DPOP_HEADER: String = "DPoP" - - - public const val MAX_RETRY_COUNT: Int = 1 - - public var auth0Nonce: String? = null - private set - - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - @Volatile - internal var keyStore = DPoPKeyStore() - - /** - * This method constructs a DPoP proof JWT that includes the HTTP method, URL, and an optional access token and nonce. - * - * ```kotlin - * - * try { - * DPoPProvider.generateProof("{url}", "POST")?.let { - * // Add to the URL request header - * } - * } catch (exception: DPoPException) { - * Log.e(TAG, "Error generating DPoP proof: ${exception.stackTraceToString()}") - * } - * - * ``` - * - * @param httpUrl The URL of the HTTP request for which the DPoP proof is being generated. - * @param httpMethod The HTTP method (e.g., "GET", "POST") of the request. - * @param accessToken An optional access token to be included in the proof. If provided, it will be hashed and included in the payload. - * @param nonce An optional nonce value to be included in the proof. This can be used to prevent replay attacks. - * @throws DPoPException if there is an error generating the DPoP proof or accessing the key pair. - */ - @Throws(DPoPException::class) - @JvmStatic - public fun generateProof( - httpUrl: String, - httpMethod: String, - accessToken: String? = null, - nonce: String? = null - ): String? { - if (!keyStore.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") - return null - } - 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 payloadJson = JSONObject().apply { - put("jti", UUID.randomUUID().toString()) - put("htm", httpMethod.uppercase()) - put("htu", httpUrl) - 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}" - } - - /** - * 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 { - * DPoPProvider.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() { - keyStore.deleteKeyPair() - } - - /** - * Method to get the public key in JWK format. This is used to generate the `jwk` field in the DPoP proof header. - * - * ```kotlin - * - * try { - * val publicKeyJWK = DPoPProvider.getPublicKeyJWK() - * Log.d(TAG, "Public Key JWK: $publicKeyJWK") - * } catch (exception: DPoPException) { - * Log.e(TAG,"Error getting public key JWK: ${exception.stackTraceToString()}") - * } - * - * ``` - * - * @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) - @JvmStatic - public fun getPublicKeyJWK(): String? { - if (!keyStore.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()) - } - - /** - * Generates a new key pair for DPoP if it does not already exist. This should be called before making any requests that require a DPoP proof. - * - * ```kotlin - * - * try { - * DPoPProvider.generateKeyPair(context) - * } catch (exception: DPoPException) { - * Log.e(TAG,"Error generating key pair: ${exception.stackTraceToString()}") - * } - * - * ``` - * - * @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) - @JvmStatic - public fun generateKeyPair(context: Context) { - if (keyStore.hasKeyPair()) { - return - } - keyStore.generateKeyPair(context) - } - - /** - * 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 = DPoPProvider.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) - } - - /** - * 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 (DPoPProvider.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)) - } - - /** - * Stores the nonce value from the Okhttp3 [Response] headers. - * - * ```kotlin - * - * try { - * DPoPProvider.storeNonce(response) - * } catch (exception: Exception) { - * Log.e(TAG, "Error storing nonce: ${exception.stackTraceToString()}") - * } - * - * ``` - * - * @param response The HTTP response containing the nonce header. - */ - @JvmStatic - public fun storeNonce(response: Response) { - auth0Nonce = response.headers[NONCE_HEADER] - } - - 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 Base64.encodeToString(hash, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) - } - - 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 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 - } -} \ 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..671d75493 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoPUtil.kt @@ -0,0 +1,259 @@ +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.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") + return null + } + 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 Base64.encodeToString(hash, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + } + + 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: Exception) { + Log.w(TAG, "Failed to parse URL, using original: $url", e) + 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 index 2bf24d672..3f1d25a14 100644 --- a/auth0/src/main/java/com/auth0/android/dpop/SenderConstraining.kt +++ b/auth0/src/main/java/com/auth0/android/dpop/SenderConstraining.kt @@ -1,15 +1,12 @@ package com.auth0.android.dpop -import android.content.Context - /** * Interface for SenderConstraining */ -public interface SenderConstraining { +public interface SenderConstraining> { /** - * Enables DPoP for authentication requests. + * Method to enable DPoP in the request. */ - public fun useDPoP(context: Context): T - + public fun useDPoP(): 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 668ab00d6..8c428b40d 100644 --- a/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt +++ b/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt @@ -2,18 +2,17 @@ package com.auth0.android.provider import android.content.Context import android.net.Uri -import android.os.Build import android.text.TextUtils import android.util.Base64 import android.util.Log -import androidx.annotation.RequiresApi 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.DPoPProvider +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 @@ -26,7 +25,8 @@ internal class OAuthManager( parameters: Map, ctOptions: CustomTabsOptions, private val launchAsTwa: Boolean = false, - private val customAuthorizeUrl: String? = null + private val customAuthorizeUrl: String? = null, + private val dPoP: DPoP? = null ) : ResumableManager() { private val parameters: MutableMap private val headers: MutableMap @@ -64,7 +64,17 @@ internal class OAuthManager( OidcUtils.includeDefaultScope(parameters) addPKCEParameters(parameters, redirectUri, headers) addClientParameters(parameters, redirectUri) - addDPoPJWKParameters(parameters) + try { + addDPoPJWKParameters(parameters, context) + } catch (ex: DPoPException) { + callback.onFailure( + AuthenticationException( + ex.message ?: "Error generating the JWK", + ex + ) + ) + return + } addValidationParameters(parameters) val uri = buildAuthorizeUri() this.requestCode = requestCode @@ -290,8 +300,8 @@ internal class OAuthManager( } } - private fun addDPoPJWKParameters(parameters: MutableMap) { - DPoPProvider.getPublicKeyJWK()?.let { + private fun addDPoPJWKParameters(parameters: MutableMap, context: Context) { + dPoP?.getPublicKeyJWK(context)?.let { parameters["dpop_jkt"] = it } } @@ -365,6 +375,10 @@ internal class OAuthManager( this.parameters = parameters.toMutableMap() this.parameters[KEY_RESPONSE_TYPE] = RESPONSE_TYPE_CODE apiClient = AuthenticationAPIClient(account) + // Enable DPoP on the AuthenticationClient if DPoP is set in the WebAuthProvider class + dPoP?.let { + apiClient.useDPoP() + } this.ctOptions = ctOptions } } @@ -383,7 +397,7 @@ internal fun OAuthManager.Companion.fromState( setHeaders( state.headers ) - setPKCE(state.pkce) + setPKCE(state.pkce) setIdTokenVerificationIssuer(state.idTokenVerificationIssuer) setIdTokenVerificationLeeway(state.idTokenVerificationLeeway) } 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 f38a52586..601e18008 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -3,15 +3,13 @@ package com.auth0.android.provider import android.content.Context import android.content.Intent import android.net.Uri -import android.os.Build import android.os.Bundle import android.util.Log -import androidx.annotation.RequiresApi 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.DPoPProvider +import com.auth0.android.dpop.DPoP import com.auth0.android.dpop.SenderConstraining import com.auth0.android.result.Credentials import kotlinx.coroutines.Dispatchers @@ -32,6 +30,7 @@ import kotlin.coroutines.resumeWithException 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>() @@ -50,13 +49,12 @@ public object WebAuthProvider : SenderConstraining { callbacks -= callback } - @RequiresApi(Build.VERSION_CODES.M) - public override fun useDPoP(context: Context): WebAuthProvider { - DPoPProvider.generateKeyPair(context) + // Public methods + public override fun useDPoP(): WebAuthProvider { + dPoP = DPoP() return this } - // Public methods /** * 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. @@ -592,7 +590,7 @@ public object WebAuthProvider : SenderConstraining { } val manager = OAuthManager( account, callback, values, ctOptions, launchAsTwa, - customAuthorizeUrl + customAuthorizeUrl, dPoP ) manager.setHeaders(headers) manager.setPKCE(pkce) 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 e7c766ea6..2ef6a7078 100755 --- a/auth0/src/main/java/com/auth0/android/request/ProfileRequest.kt +++ b/auth0/src/main/java/com/auth0/android/request/ProfileRequest.kt @@ -3,12 +3,9 @@ package com.auth0.android.request import com.auth0.android.Auth0Exception import com.auth0.android.authentication.AuthenticationException import com.auth0.android.callback.Callback -import com.auth0.android.dpop.DPoPProvider 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,16 +77,11 @@ public class ProfileRequest override fun start(callback: Callback) { authenticationRequest.start(object : Callback { override fun onSuccess(credentials: Credentials) { - val headerData = DPoPProvider.getHeaderData( - getHttpMethod().toString(), getUrl(), - credentials.accessToken, credentials.type, DPoPProvider.auth0Nonce - ) - userInfoRequest.apply { - addHeader(HEADER_AUTHORIZATION, headerData.authorizationHeader) - headerData.dpopProof?.let { - addHeader(DPoPProvider.DPOP_HEADER, it) - } - } + userInfoRequest + .addHeader( + HEADER_AUTHORIZATION, + "${credentials.type} ${credentials.accessToken}" + ) .start(object : Callback { override fun onSuccess(profile: UserProfile) { callback.onSuccess(Authentication(profile, credentials)) @@ -116,17 +108,9 @@ public class ProfileRequest @Throws(Auth0Exception::class) override fun execute(): Authentication { val credentials = authenticationRequest.execute() - val headerData = DPoPProvider.getHeaderData( - getHttpMethod().toString(), getUrl(), - credentials.accessToken, credentials.type, DPoPProvider.auth0Nonce - ) - val profile = userInfoRequest.run { - addHeader(HEADER_AUTHORIZATION, headerData.authorizationHeader) - headerData.dpopProof?.let { - addHeader(DPoPProvider.DPOP_HEADER, it) - } - execute() - } + val profile = userInfoRequest + .addHeader(HEADER_AUTHORIZATION, "${credentials.type} ${credentials.accessToken}") + .execute() return Authentication(profile, credentials) } @@ -141,17 +125,9 @@ public class ProfileRequest @Throws(Auth0Exception::class) override suspend fun await(): Authentication { val credentials = authenticationRequest.await() - val headerData = DPoPProvider.getHeaderData( - getHttpMethod().toString(), getUrl(), - credentials.accessToken, credentials.type, DPoPProvider.auth0Nonce - ) - val profile = userInfoRequest.run { - addHeader(HEADER_AUTHORIZATION, headerData.authorizationHeader) - headerData.dpopProof?.let { - addHeader(DPoPProvider.DPOP_HEADER, it) - } - await() - } + val profile = userInfoRequest + .addHeader(HEADER_AUTHORIZATION, "${credentials.type} ${credentials.accessToken}") + .await() return Authentication(profile, credentials) } diff --git a/auth0/src/main/java/com/auth0/android/request/RetryInterceptor.kt b/auth0/src/main/java/com/auth0/android/request/RetryInterceptor.kt index ee2ca409c..e733b02f1 100644 --- a/auth0/src/main/java/com/auth0/android/request/RetryInterceptor.kt +++ b/auth0/src/main/java/com/auth0/android/request/RetryInterceptor.kt @@ -1,6 +1,7 @@ package com.auth0.android.request -import com.auth0.android.dpop.DPoPProvider +import com.auth0.android.dpop.DPoP +import com.auth0.android.dpop.DPoPUtil import okhttp3.Interceptor import okhttp3.Response @@ -14,26 +15,26 @@ internal class RetryInterceptor : Interceptor { 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 (DPoPProvider.isNonceRequiredError(response) && currentRetryCount < DPoPProvider.MAX_RETRY_COUNT) { - synchronized(this) { - DPoPProvider.storeNonce(response) - val accessToken = - request.headers[AUTHORIZATION_HEADER]?.substringAfter(DPOP_LIMITER)?.trim() - val dpopProof = DPoPProvider.generateProof( - httpUrl = request.url.toString(), - httpMethod = request.method, - accessToken = accessToken, - nonce = DPoPProvider.auth0Nonce - ) - if (dpopProof != null) { - response.close() - val newRequest = request.newBuilder() - .header(DPoPProvider.DPOP_HEADER, dpopProof) - .header(RETRY_COUNT_HEADER, (currentRetryCount + 1).toString()) - .build() - return chain.proceed(newRequest) - } + 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 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 140bc81cf..eec5cc44d 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,6 +3,9 @@ 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.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 @@ -31,7 +34,8 @@ internal open class BaseRequest( 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) @@ -128,7 +132,14 @@ internal open class BaseRequest( override fun execute(): T { val response: ServerResponse try { + if (dPoP?.shouldGenerateProof(url, options.parameters) == true) { + 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) 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 46154504f..493c4760e 100755 --- a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt @@ -5,7 +5,7 @@ import android.content.res.Resources import com.auth0.android.Auth0 import com.auth0.android.authentication.ParameterBuilder.Companion.newBuilder import com.auth0.android.dpop.DPoPKeyStore -import com.auth0.android.dpop.DPoPProvider +import com.auth0.android.dpop.DPoPUtil import com.auth0.android.dpop.FakeECPrivateKey import com.auth0.android.dpop.FakeECPublicKey import com.auth0.android.provider.JwtTestUtils @@ -73,7 +73,7 @@ public class AuthenticationAPIClientTest { val auth0 = auth0 client = AuthenticationAPIClient(auth0) gson = GsonBuilder().serializeNulls().create() - DPoPProvider.keyStore = mockKeyStore + DPoPUtil.keyStore = mockKeyStore } @After @@ -2480,40 +2480,6 @@ public class AuthenticationAPIClientTest { ) } - @Test - public fun shouldRenewAuthWithDpopHeaderIfDpopEnabled() { - `when`(mockKeyStore.hasKeyPair()).thenReturn(true) - `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) - val auth0 = auth0 - val client = AuthenticationAPIClient(auth0) - mockAPI.willReturnSuccessfulLogin() - val callback = MockAuthenticationCallback() - client.renewAuth("refreshToken") - .start(callback) - ShadowLooper.idleMainLooper() - val request = mockAPI.takeRequest() - assertThat( - request.getHeader("Accept-Language"), Matchers.`is`( - defaultLocale - ) - ) - assertThat( - request.getHeader("DPoP"), - Matchers.notNullValue() - ) - assertThat(request.path, Matchers.equalTo("/oauth/token")) - val body = bodyFromRequest(request) - assertThat(body, Matchers.not(Matchers.hasKey("scope"))) - assertThat(body, Matchers.hasEntry("client_id", CLIENT_ID)) - assertThat(body, Matchers.hasEntry("refresh_token", "refreshToken")) - assertThat(body, Matchers.hasEntry("grant_type", "refresh_token")) - assertThat( - callback, AuthenticationCallbackMatcher.hasPayloadOfType( - Credentials::class.java - ) - ) - } - @Test public fun shouldRenewAuthWithOAuthTokenSync() { val auth0 = auth0 @@ -2772,6 +2738,283 @@ public class AuthenticationAPIClientTest { ) } + //DPoP + + @Test + public fun shouldNotAddDpopHeaderWhenDpopNotEnabled() { + 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 shouldAddDpopHeaderWhenDpopEnabledAndKeyPairExists() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + + // Enable DPoP + client.useDPoP().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 shouldNotAddDpopHeaderWhenDpopEnabledButNoKeyPair() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(false) + + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + + client.useDPoP().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 shouldAddDpopHeaderToTokenExchangeWhenEnabled() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + + client.useDPoP().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")) + assertThat( + callback, AuthenticationCallbackMatcher.hasPayloadOfType( + Credentials::class.java + ) + ) + } + + @Test + public fun shouldNotAddDpopHeaderToTokenExchangeWhenNotEnabled() { + 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 shouldAddDpopHeaderToUserInfoWhenEnabled() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + + mockAPI.willReturnUserInfo() + val callback = MockAuthenticationCallback() + + client.useDPoP().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 shouldNotAddDpopHeaderToNonTokenEndpoints() { + 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().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().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().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().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().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 shouldNotAddDpopHeaderWhenKeyPairGenerationFails() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(null) + + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + + client.useDPoP().login(SUPPORT_AUTH0_COM, "some-password", MY_CONNECTION) + .start(callback) + ShadowLooper.idleMainLooper() + + val request = mockAPI.takeRequest() + // Should not have DPoP header when key pair retrieval fails + assertThat(request.getHeader("DPoP"), Matchers.nullValue()) + assertThat(request.path, Matchers.equalTo("/oauth/token")) + } + 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/dpop/DPoPTest.kt b/auth0/src/test/java/com/auth0/android/dpop/DPoPTest.kt new file mode 100644 index 000000000..53538b7c6 --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/dpop/DPoPTest.kt @@ -0,0 +1,518 @@ +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.any +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.never +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.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@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() + + 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 false for token endpoint with refresh grant type`() { + 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(mockContext) + + 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(mockContext) + 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(mockContext) + + 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(mockContext) + + verify(mockKeyStore, never()).generateKeyPair(any()) + 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(mockContext) + + 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 `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 `nonce storage should be thread safe`() { + val numThreads = 10 + val numIterations = 100 + val threads = mutableListOf() + + repeat(numThreads) { threadIndex -> + threads.add(Thread { + repeat(numIterations) { iteration -> + val nonce = "nonce-$threadIndex-$iteration" + val response = mock() + whenever(response.headers).thenReturn( + Headers.Builder().add("DPoP-Nonce", nonce).build() + ) + DPoP.storeNonce(response) + } + }) + } + + threads.forEach { it.start() } + threads.forEach { it.join() } + + // Should not crash and should have some nonce value + val finalNonce = DPoP.auth0Nonce + assertThat(finalNonce, `is`(notNullValue())) + assertThat(finalNonce!!.startsWith("nonce-"), `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(mockContext) + verify(mockKeyStore).generateKeyPair(mockContext) + + // 2. Get JWK thumbprint + val jwkThumbprint = dPoP.getPublicKeyJWK(mockContext) + 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/DPoPProviderTest.kt b/auth0/src/test/java/com/auth0/android/dpop/DPoPUtilTest.kt similarity index 60% rename from auth0/src/test/java/com/auth0/android/dpop/DPoPProviderTest.kt rename to auth0/src/test/java/com/auth0/android/dpop/DPoPUtilTest.kt index b68ccd9cb..44fafccfc 100644 --- a/auth0/src/test/java/com/auth0/android/dpop/DPoPProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/dpop/DPoPUtilTest.kt @@ -9,9 +9,7 @@ import com.nhaarman.mockitokotlin2.never import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions import com.nhaarman.mockitokotlin2.whenever -import okhttp3.Headers import okhttp3.Response -import okhttp3.ResponseBody.Companion.toResponseBody import org.hamcrest.CoreMatchers.`is` import org.hamcrest.CoreMatchers.notNullValue import org.hamcrest.CoreMatchers.nullValue @@ -26,7 +24,7 @@ import org.robolectric.RobolectricTestRunner import java.security.PrivateKey @RunWith(RobolectricTestRunner::class) -public class DPoPProviderTest { +public class DPoPUtilTest { private lateinit var mockContext: Context private lateinit var mockPrivateKey: PrivateKey @@ -54,14 +52,14 @@ public class DPoPProviderTest { mockContext = mock() mockResponse = mock() - DPoPProvider.keyStore = mockKeyStore + DPoPUtil.keyStore = mockKeyStore } @Test public fun `generateProof should return null when keyStore has no key pair`() { whenever(mockKeyStore.hasKeyPair()).thenReturn(false) - val result = DPoPProvider.generateProof(testHttpUrl, testHttpMethod) + val result = DPoPUtil.generateProof(testHttpUrl, testHttpMethod) assertThat(result, `is`(nullValue())) verify(mockKeyStore).hasKeyPair() @@ -73,19 +71,125 @@ public class DPoPProviderTest { whenever(mockKeyStore.hasKeyPair()).thenReturn(true) whenever(mockKeyStore.getKeyPair()).thenReturn(null) - val result = DPoPProvider.generateProof(testHttpUrl, testHttpMethod) + val result = DPoPUtil.generateProof(testHttpUrl, testHttpMethod) assertThat(result, `is`(nullValue())) 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 = DPoPProvider.generateProof(testHttpUrl, testHttpMethod) + val result = DPoPUtil.generateProof(testHttpUrl, testHttpMethod) assertThat(result, `is`(notNullValue())) val proof = Jwt(result!!) @@ -106,7 +210,7 @@ public class DPoPProviderTest { whenever(mockKeyStore.hasKeyPair()).thenReturn(true) whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) - val proof = DPoPProvider.generateProof(testHttpUrl, testHttpMethod) + val proof = DPoPUtil.generateProof(testHttpUrl, testHttpMethod) assertThat(proof, `is`(notNullValue())) val decodedProof = Jwt(proof!!) assertNotNull(decodedProof.decodedHeader["typ"]) @@ -120,7 +224,7 @@ public class DPoPProviderTest { whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) val proof = - DPoPProvider.generateProof(testHttpUrl, testHttpMethod, testAccessToken, testNonce) + DPoPUtil.generateProof(testHttpUrl, testHttpMethod, testAccessToken, testNonce) assertThat(proof, `is`(notNullValue())) val decodedProof = Jwt(proof!!) assertNotNull(decodedProof.decodedPayload["jti"]) @@ -136,7 +240,7 @@ public class DPoPProviderTest { whenever(mockKeyStore.hasKeyPair()).thenReturn(true) whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) - val result = DPoPProvider.generateProof(testHttpUrl, testHttpMethod, testAccessToken) + val result = DPoPUtil.generateProof(testHttpUrl, testHttpMethod, testAccessToken) assertThat(result, `is`(notNullValue())) val proof = Jwt(result!!) @@ -159,7 +263,7 @@ public class DPoPProviderTest { whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) val result = - DPoPProvider.generateProof(testHttpUrl, testHttpMethod, testAccessToken, testNonce) + DPoPUtil.generateProof(testHttpUrl, testHttpMethod, testAccessToken, testNonce) assertThat(result, `is`(notNullValue())) assertThat(result, `is`(notNullValue())) @@ -183,14 +287,14 @@ public class DPoPProviderTest { whenever(mockKeyStore.hasKeyPair()).thenReturn(true) whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(mockPrivateKey, fakePublicKey)) val exception = assertThrows(DPoPException::class.java) { - DPoPProvider.generateProof(testHttpUrl, testHttpMethod) + DPoPUtil.generateProof(testHttpUrl, testHttpMethod) } Assert.assertEquals("Error while signing the DPoP proof.", exception.message) } @Test public fun `clearKeyPair should delegate to keyStore`() { - DPoPProvider.clearKeyPair() + DPoPUtil.clearKeyPair() verify(mockKeyStore).deleteKeyPair() } @@ -198,7 +302,7 @@ public class DPoPProviderTest { public fun `clearKeyPair should propagate DPoPException from keyStore`() { whenever(mockKeyStore.deleteKeyPair()).thenThrow(DPoPException(DPoPException.Code.KEY_STORE_ERROR)) val exception = assertThrows(DPoPException::class.java) { - DPoPProvider.clearKeyPair() + DPoPUtil.clearKeyPair() } Assert.assertEquals( "Error while accessing the key pair in the keystore.", @@ -209,7 +313,7 @@ public class DPoPProviderTest { @Test public fun `getPublicKeyJWK should return null when no key pair exists`() { whenever(mockKeyStore.hasKeyPair()).thenReturn(false) - val result = DPoPProvider.getPublicKeyJWK() + val result = DPoPUtil.getPublicKeyJWK() assertThat(result, `is`(nullValue())) verify(mockKeyStore).hasKeyPair() verifyNoMoreInteractions(mockKeyStore) @@ -219,7 +323,7 @@ public class DPoPProviderTest { public fun `getPublicKeyJWK should return null when key pair is null`() { whenever(mockKeyStore.hasKeyPair()).thenReturn(true) whenever(mockKeyStore.getKeyPair()).thenReturn(null) - val result = DPoPProvider.getPublicKeyJWK() + val result = DPoPUtil.getPublicKeyJWK() assertThat(result, `is`(nullValue())) verify(mockKeyStore).hasKeyPair() verify(mockKeyStore).getKeyPair() @@ -230,7 +334,7 @@ public class DPoPProviderTest { val mockNonECKey = mock() whenever(mockKeyStore.hasKeyPair()).thenReturn(true) whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(mockPrivateKey, mockNonECKey)) - val result = DPoPProvider.getPublicKeyJWK() + val result = DPoPUtil.getPublicKeyJWK() assertThat(result, `is`(nullValue())) verify(mockKeyStore).hasKeyPair() verify(mockKeyStore).getKeyPair() @@ -241,7 +345,7 @@ public class DPoPProviderTest { whenever(mockKeyStore.hasKeyPair()).thenReturn(true) whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) - val result = DPoPProvider.getPublicKeyJWK() + val result = DPoPUtil.getPublicKeyJWK() assertThat(result, `is`(notNullValue())) assertThat(result, `is`(testPublicJwkHash)) @@ -250,7 +354,7 @@ public class DPoPProviderTest { @Test public fun `generateKeyPair should return early when key pair already exists`() { whenever(mockKeyStore.hasKeyPair()).thenReturn(true) - DPoPProvider.generateKeyPair(mockContext) + DPoPUtil.generateKeyPair(mockContext) verify(mockKeyStore).hasKeyPair() verify(mockKeyStore, never()).generateKeyPair(any()) } @@ -258,7 +362,7 @@ public class DPoPProviderTest { @Test public fun `generateKeyPair should generate new key pair when none exists`() { whenever(mockKeyStore.hasKeyPair()).thenReturn(false) - DPoPProvider.generateKeyPair(mockContext) + DPoPUtil.generateKeyPair(mockContext) verify(mockKeyStore).hasKeyPair() verify(mockKeyStore).generateKeyPair(mockContext) } @@ -269,153 +373,8 @@ public class DPoPProviderTest { whenever(mockKeyStore.generateKeyPair(mockContext)).thenThrow(DPoPException(DPoPException.Code.KEY_GENERATION_ERROR)) val exception = assertThrows(DPoPException::class.java) { - DPoPProvider.generateKeyPair(mockContext) + DPoPUtil.generateKeyPair(mockContext) } Assert.assertEquals("Error generating DPoP key pair.", exception.message) } - - @Test - public fun `getHeaderData should return bearer token when tokenType is not DPoP`() { - val tokenType = "Bearer" - val result = - DPoPProvider.getHeaderData(testHttpMethod, testHttpUrl, testAccessToken, tokenType) - 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`() { - val tokenType = "DPoP" - whenever(mockKeyStore.hasKeyPair()).thenReturn(true) - whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) - - val result = - DPoPProvider.getHeaderData(testHttpMethod, testHttpUrl, testAccessToken, tokenType) - - assertThat(result.authorizationHeader, `is`("DPoP $testAccessToken")) - assertThat(result.dpopProof, `is`(notNullValue())) - val proof = Jwt(result.dpopProof!!) - - 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) - } - - @Test - public fun `getHeaderData should return DPoP token with proof including nonce when provided`() { - val tokenType = "DPoP" - whenever(mockKeyStore.hasKeyPair()).thenReturn(true) - whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) - - val result = DPoPProvider.getHeaderData( - testHttpMethod, - testHttpUrl, - testAccessToken, - tokenType, - testNonce - ) - - assertThat(result.authorizationHeader, `is`("DPoP $testAccessToken")) - assertThat(result.dpopProof, `is`(notNullValue())) - - val proof = Jwt(result.dpopProof!!) - 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 `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 = DPoPProvider.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 = DPoPProvider.isNonceRequiredError(mockResponse) - assertThat(result, `is`(true)) - } - - @Test - public fun `isNonceRequiredError should return false for 400 response with different error`() { - whenever(mockResponse.peekBody(Long.MAX_VALUE)).thenReturn("{\"error\":\"different_error\"}".toResponseBody()) - whenever(mockResponse.code).thenReturn(400) - - val result = DPoPProvider.isNonceRequiredError(mockResponse) - assertThat(result, `is`(false)) - } - - @Test - public fun `isNonceRequiredError should return false for 401 response without WWW-Authenticate header`() { - whenever(mockResponse.headers).thenReturn( - Headers.Builder().build() - ) - whenever(mockResponse.code).thenReturn(401) - - val result = DPoPProvider.isNonceRequiredError(mockResponse) - assertThat(result, `is`(false)) - } - - @Test - public fun `isNonceRequiredError should return false for 401 response with different WWW-Authenticate error`() { - whenever(mockResponse.headers).thenReturn( - Headers.Builder().add("WWW-Authenticate", "error=\"different_error\"").build() - ) - whenever(mockResponse.code).thenReturn(401) - - val result = DPoPProvider.isNonceRequiredError(mockResponse) - assertThat(result, `is`(false)) - } - - @Test - public fun `isNonceRequiredError should return false for different response codes`() { - whenever(mockResponse.code).thenReturn(500) - - val result = DPoPProvider.isNonceRequiredError(mockResponse) - assertThat(result, `is`(false)) - } - - @Test - public fun `storeNonce should store nonce from response headers`() { - whenever(mockResponse.headers).thenReturn( - Headers.Builder().add("dpop-nonce", "stored-nonce-value").build() - ) - - DPoPProvider.storeNonce(mockResponse) - assertThat(DPoPProvider.auth0Nonce, `is`("stored-nonce-value")) - } - - @Test - public fun `isResourceServerNonceError should parse WWW-Authenticate header correctly`() { - whenever(mockResponse.headers).thenReturn( - Headers.Builder().add( - "WWW-Authenticate", - "DPoP error=\"use_dpop_nonce\", error_description=\"DPoP proof requires nonce\"" - ).build() - ) - whenever(mockResponse.code).thenReturn(401) - - val result = DPoPProvider.isNonceRequiredError(mockResponse) - assertThat(result, `is`(true)) - } } \ 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..38ad21884 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,28 @@ package com.auth0.android.provider; +import static org.mockito.ArgumentMatchers.any; +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.dpop.DPoPException; 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 +50,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 +100,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 +117,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 +138,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 +156,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 0376e2194..08286c5db 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt @@ -1,7 +1,6 @@ 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 @@ -10,8 +9,10 @@ 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.DPoPProvider +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 @@ -32,6 +33,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 @@ -90,7 +93,7 @@ public class WebAuthProviderTest { mockKeyStore = mock() - DPoPProvider.keyStore = mockKeyStore + DPoPUtil.keyStore = mockKeyStore //Next line is needed to avoid CustomTabService from being bound to Test environment Mockito.doReturn(false).`when`(activity).bindService( @@ -324,11 +327,10 @@ public class WebAuthProviderTest { @Test public fun enablingDPoPWillGenerateNewKeyPairIfOneDoesNotExist() { `when`(mockKeyStore.hasKeyPair()).thenReturn(false) - val context: Context = mock() - WebAuthProvider.useDPoP(context) - login(account) + WebAuthProvider.useDPoP() + .login(account) .start(activity, callback) - verify(mockKeyStore).generateKeyPair(context) + verify(mockKeyStore).generateKeyPair(any()) } @Test @@ -352,9 +354,10 @@ public class WebAuthProviderTest { `when`(mockKeyStore.hasKeyPair()).thenReturn(true) `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) - WebAuthProvider.useDPoP(mock()) - login(account) + WebAuthProvider.useDPoP() + .login(account) .start(activity, callback) + verify(activity).startActivity(intentCaptor.capture()) val uri = intentCaptor.firstValue.getParcelableExtra(AuthenticationActivity.EXTRA_AUTHORIZE_URI) @@ -2724,6 +2727,299 @@ public class WebAuthProviderTest { assertThat(uri, UriMatchers.hasParamWithName("returnTo")) } + + //DPoP + + public fun shouldReturnSameInstanceWhenCallingUseDPoPMultipleTimes() { + val provider1 = WebAuthProvider.useDPoP() + val provider2 = WebAuthProvider.useDPoP() + + assertThat(provider1, `is`(provider2)) + assertThat(WebAuthProvider.useDPoP(), `is`(provider1)) + } + + @Test + public fun shouldPassDPoPInstanceToOAuthManagerWhenDPoPIsEnabled() { + `when`(mockKeyStore.hasKeyPair()).thenReturn(true) + `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) + + WebAuthProvider.useDPoP() + .login(account) + .start(activity, callback) + + val managerInstance = WebAuthProvider.managerInstance as OAuthManager + // Verify that the manager has DPoP configured (this would require exposing the dPoP field in OAuthManager for testing) + assertThat(managerInstance, `is`(notNullValue())) + } + + @Test + public fun shouldNotPassDPoPInstanceToOAuthManagerWhenDPoPIsNotEnabled() { + login(account) + .start(activity, callback) + + val managerInstance = WebAuthProvider.managerInstance as OAuthManager + assertThat(managerInstance, `is`(notNullValue())) + } + + @Test + public fun shouldGenerateKeyPairWhenDPoPIsEnabledAndNoKeyPairExists() { + `when`(mockKeyStore.hasKeyPair()).thenReturn(false) + + WebAuthProvider.useDPoP() + .login(account) + .start(activity, callback) + + verify(mockKeyStore).generateKeyPair(any()) + } + + @Test + public fun shouldNotGenerateKeyPairWhenDPoPIsEnabledAndKeyPairExists() { + `when`(mockKeyStore.hasKeyPair()).thenReturn(true) + `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) + + WebAuthProvider.useDPoP() + .login(account) + .start(activity, callback) + + verify(mockKeyStore, never()).generateKeyPair(any()) + } + + @Test + public fun shouldNotGenerateKeyPairWhenDPoPIsNotEnabled() { + `when`(mockKeyStore.hasKeyPair()).thenReturn(false) + + login(account) + .start(activity, callback) + + verify(mockKeyStore, never()).generateKeyPair(any()) + } + + @Test + public fun shouldIncludeDPoPJWKThumbprintInAuthorizeURLWhenDPoPIsEnabledAndKeyPairExists() { + `when`(mockKeyStore.hasKeyPair()).thenReturn(true) + `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) + + WebAuthProvider.useDPoP() + .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 shouldNotIncludeDPoPJWKThumbprintWhenDPoPIsEnabledButNoKeyPair() { + `when`(mockKeyStore.hasKeyPair()).thenReturn(false) + + WebAuthProvider.useDPoP() + .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 shouldChainDPoPWithOtherLoginOptions() { + `when`(mockKeyStore.hasKeyPair()).thenReturn(true) + `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) + + WebAuthProvider.useDPoP() + .login(account) + .withConnection("test-connection") + .withScope("openid profile email custom") + .withState("custom-state") + .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.hasParamWithValue("scope", "openid profile email custom")) + assertThat(uri, UriMatchers.hasParamWithValue("state", "custom-state")) + assertThat(uri, UriMatchers.hasParamWithName("dpop_jkt")) + } + + @Test + public fun shouldWorkWithLoginBuilderPatternWhenDPoPIsEnabled() { + `when`(mockKeyStore.hasKeyPair()).thenReturn(true) + `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) + + val builder = WebAuthProvider.useDPoP() + .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() + .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()) + + WebAuthProvider.useDPoP() + .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.")) + + 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() + .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() + } + + // Update the existing test that checks DPoP key generation +// @Test +// public fun enablingDPoPWillGenerateNewKeyPairIfOneDoesNotExist() { +// `when`(mockKeyStore.hasKeyPair()).thenReturn(false) +// +// WebAuthProvider.useDPoP() +// .login(account) +// .start(activity, callback) +// +// verify(mockKeyStore).generateKeyPair(any()) +// } +// +// // Update the existing test for DPoP JWK parameter +// @Test +// public fun shouldHaveDpopJwkOnLoginIfDPoPIsEnabled() { +// `when`(mockKeyStore.hasKeyPair()).thenReturn(true) +// `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) +// +// WebAuthProvider.useDPoP() +// .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")) +// } + + @Test + public fun shouldResetDPoPStateWhenManagerInstanceIsReset() { + WebAuthProvider.useDPoP() + + // Verify DPoP is enabled + val provider1 = WebAuthProvider.useDPoP() + assertThat(provider1, `is`(notNullValue())) + + // Reset manager instance (this should also reset DPoP state if needed) + WebAuthProvider.resetManagerInstance() + + // DPoP should still work after reset + val provider2 = WebAuthProvider.useDPoP() + assertThat(provider2, `is`(notNullValue())) + } + //** ** ** ** ** ** **// //** ** ** ** ** ** **// //** Helpers Functions**// diff --git a/auth0/src/test/java/com/auth0/android/request/RetryInterceptorTest.kt b/auth0/src/test/java/com/auth0/android/request/RetryInterceptorTest.kt index 022ecd8a8..bb10f1ce5 100644 --- a/auth0/src/test/java/com/auth0/android/request/RetryInterceptorTest.kt +++ b/auth0/src/test/java/com/auth0/android/request/RetryInterceptorTest.kt @@ -1,7 +1,8 @@ package com.auth0.android.request +import com.auth0.android.dpop.DPoP import com.auth0.android.dpop.DPoPKeyStore -import com.auth0.android.dpop.DPoPProvider +import com.auth0.android.dpop.DPoPUtil import com.auth0.android.dpop.FakeECPrivateKey import com.auth0.android.dpop.FakeECPublicKey import com.nhaarman.mockitokotlin2.any @@ -38,7 +39,7 @@ public class RetryInterceptorTest { mockChain = mock() mockKeyStore = mock() - DPoPProvider.keyStore = mockKeyStore + DPoPUtil.keyStore = mockKeyStore retryInterceptor = RetryInterceptor() } @@ -80,7 +81,7 @@ public class RetryInterceptorTest { val retriedRequest = newRequestCaptor.secondValue assertThat(retriedRequest.header("DPoP"), not(nullValue())) assertThat(retriedRequest.header("X-Internal-Retry-Count"), `is`("1")) - assertThat(DPoPProvider.auth0Nonce, `is`("new-nonce-from-header")) + assertThat(DPoP.auth0Nonce, `is`("new-nonce-from-header")) } @Test 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 From abd631e552de353294c15e691a7f878ad42b51ef Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Wed, 6 Aug 2025 18:17:35 +0530 Subject: [PATCH 15/20] Made few methods as internal --- .../main/java/com/auth0/android/dpop/DPoP.kt | 28 ++----------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/dpop/DPoP.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoP.kt index 828b09610..abed952cc 100644 --- a/auth0/src/main/java/com/auth0/android/dpop/DPoP.kt +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoP.kt @@ -71,21 +71,11 @@ public class DPoP { /** * 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. * - * ```kotlin - * - * try { - * DPoP.generateKeyPair(context) - * } catch (exception: DPoPException) { - * Log.e(TAG,"Error generating key pair: ${exception.stackTraceToString()}") - * } - * - * ``` - * * @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) - public fun generateKeyPair(context: Context) { + internal fun generateKeyPair(context: Context) { DPoPUtil.generateKeyPair(context) } @@ -93,23 +83,11 @@ public class DPoP { * 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. * - * ```kotlin - * - * try { - * val dPoP = DPoP() - * val publicKeyJWK = dPoP.getPublicKeyJWK(context) - * Log.d(TAG, "Public Key JWK: $publicKeyJWK") - * } catch (exception: DPoPException) { - * Log.e(TAG,"Error getting public key JWK: ${exception.stackTraceToString()}") - * } - * - * ``` - * * @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) - public fun getPublicKeyJWK(context: Context): String? { + internal fun getPublicKeyJWK(context: Context): String? { generateKeyPair(context) return DPoPUtil.getPublicKeyJWK() } @@ -141,7 +119,7 @@ public class DPoP { * @param response The HTTP response containing the nonce header. */ @JvmStatic - public fun storeNonce(response: Response) { + internal fun storeNonce(response: Response) { _auth0Nonce = response.headers[NONCE_HEADER] } From d6026e40c861c48cca70718e69c3116d880ac84d Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Thu, 7 Aug 2025 11:07:45 +0530 Subject: [PATCH 16/20] Removed unwanted code added as part of initial implementation --- .../com/auth0/android/request/ProfileRequest.kt | 8 -------- .../main/java/com/auth0/android/request/Request.kt | 14 -------------- .../com/auth0/android/request/SignUpRequest.kt | 9 --------- .../request/internal/BaseAuthenticationRequest.kt | 11 ----------- .../auth0/android/request/internal/BaseRequest.kt | 4 ---- .../request/AuthenticationRequestMock.java | 13 ------------- .../authentication/request/ProfileRequestTest.java | 2 -- .../authentication/request/RequestMock.java | 12 ------------ 8 files changed, 73 deletions(-) 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 2ef6a7078..d8cbc1e35 100755 --- a/auth0/src/main/java/com/auth0/android/request/ProfileRequest.kt +++ b/auth0/src/main/java/com/auth0/android/request/ProfileRequest.kt @@ -131,14 +131,6 @@ public class ProfileRequest return Authentication(profile, credentials) } - override fun getUrl(): String { - return userInfoRequest.getUrl() - } - - override fun getHttpMethod(): HttpMethod { - return userInfoRequest.getHttpMethod() - } - private companion object { private const val HEADER_AUTHORIZATION = "Authorization" } 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 63c39198e..b06c1bc23 100755 --- a/auth0/src/main/java/com/auth0/android/request/Request.kt +++ b/auth0/src/main/java/com/auth0/android/request/Request.kt @@ -2,7 +2,6 @@ package com.auth0.android.request import com.auth0.android.Auth0Exception import com.auth0.android.callback.Callback -import okhttp3.HttpUrl /** * Defines a request that can be started @@ -77,17 +76,4 @@ public interface Request { * @return itself */ public fun addHeader(name: String, value: String): Request - - - /** - * Returns the URL of this request. - * @return the URL - */ - public fun getUrl(): String - - /** - * Returns the [HttpMethod] of this request - * @return the [HttpMethod] - */ - public fun getHttpMethod(): HttpMethod } \ 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 08845a675..285664ef0 100755 --- a/auth0/src/main/java/com/auth0/android/request/SignUpRequest.kt +++ b/auth0/src/main/java/com/auth0/android/request/SignUpRequest.kt @@ -120,15 +120,6 @@ public class SignUpRequest return this } - override fun getUrl(): String { - return signUpRequest.getUrl() - } - - - override fun getHttpMethod(): HttpMethod { - return signUpRequest.getHttpMethod() - } - /** * Starts to execute create user request and then logs the user 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 3c987e377..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,10 +8,7 @@ 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.HttpMethod import com.auth0.android.request.Request import com.auth0.android.result.Credentials import java.util.* @@ -135,14 +132,6 @@ internal open class BaseAuthenticationRequest( return this } - override fun getHttpMethod(): HttpMethod { - return request.getHttpMethod() - } - - override fun getUrl(): String { - return request.getUrl() - } - override fun start(callback: Callback) { warnClaimValidation() request.start(object : Callback { 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 eec5cc44d..4d8123cff 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 @@ -70,10 +70,6 @@ internal open class BaseRequest( return this } - override fun getUrl(): String = url - - override fun getHttpMethod(): HttpMethod = method - /** * Runs asynchronously and executes the network request, without blocking the current thread. * The result is parsed into a value and posted in the callback's onSuccess method or a diff --git a/auth0/src/test/java/com/auth0/android/authentication/request/AuthenticationRequestMock.java b/auth0/src/test/java/com/auth0/android/authentication/request/AuthenticationRequestMock.java index b7eafc8be..4b5d6d0e2 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/request/AuthenticationRequestMock.java +++ b/auth0/src/test/java/com/auth0/android/authentication/request/AuthenticationRequestMock.java @@ -6,7 +6,6 @@ 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.Request; import com.auth0.android.result.Credentials; @@ -113,16 +112,4 @@ public AuthenticationRequest withIdTokenVerificationLeeway(int leeway) { public AuthenticationRequest withIdTokenVerificationIssuer(@NonNull String issuer) { return this; } - - @NonNull - @Override - public String getUrl() { - return ""; - } - - @NonNull - @Override - public HttpMethod getHttpMethod() { - return HttpMethod.GET.INSTANCE; - } } 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 d821d6743..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 @@ -44,8 +44,6 @@ public class ProfileRequestTest { @Before public void setUp() { userInfoMockRequest = mock(Request.class); - when(userInfoMockRequest.getHttpMethod()).thenReturn(HttpMethod.GET.INSTANCE); - when(userInfoMockRequest.getUrl()).thenReturn("www.api.com/example"); authenticationMockRequest = mock(AuthenticationRequest.class); profileRequest = new ProfileRequest(authenticationMockRequest, 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 84450e844..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 @@ -41,18 +41,6 @@ public Request addHeader(@NonNull String name, @NonNull String value) { return this; } - @NonNull - @Override - public String getUrl() { - return ""; - } - - @NonNull - @Override - public HttpMethod getHttpMethod() { - return HttpMethod.GET.INSTANCE; - } - @Override public void start(@NonNull Callback callback) { started = true; From 6265184388dc4dbe09d434b0d5675af36a618f7c Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Thu, 7 Aug 2025 14:56:31 +0530 Subject: [PATCH 17/20] Handling the key store failure due to strongbox error --- .../com/auth0/android/dpop/DPoPKeyStore.kt | 20 +++++-- .../auth0/android/dpop/DPoPKeyStoreTest.kt | 56 +++++++++++++++++++ 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/dpop/DPoPKeyStore.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoPKeyStore.kt index 9ece74f66..742e5beaf 100644 --- a/auth0/src/main/java/com/auth0/android/dpop/DPoPKeyStore.kt +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoPKeyStore.kt @@ -29,7 +29,7 @@ internal open class DPoPKeyStore { KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } } - fun generateKeyPair(context: Context) { + fun generateKeyPair(context: Context, useStrongBox: Boolean = true) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { throw DPoPException.UNSUPPORTED_ERROR } @@ -53,7 +53,7 @@ internal open class DPoPKeyStore { setCertificateSubject(principal) setCertificateNotBefore(start.time) setCertificateNotAfter(end.time) - if (isStrongBoxEnabled(context)) { + if (useStrongBox && isStrongBoxEnabled(context)) { setIsStrongBoxBacked(true) } } @@ -67,12 +67,24 @@ internal open class DPoPKeyStore { is InvalidAlgorithmParameterException, is NoSuchProviderException, is NoSuchAlgorithmException, - is KeyStoreException, - is ProviderException -> { + 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) } } diff --git a/auth0/src/test/java/com/auth0/android/dpop/DPoPKeyStoreTest.kt b/auth0/src/test/java/com/auth0/android/dpop/DPoPKeyStoreTest.kt index 1d63f9cfa..c8a8c6a4c 100644 --- a/auth0/src/test/java/com/auth0/android/dpop/DPoPKeyStoreTest.kt +++ b/auth0/src/test/java/com/auth0/android/dpop/DPoPKeyStoreTest.kt @@ -22,6 +22,8 @@ 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 @@ -31,6 +33,7 @@ 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 @@ -231,4 +234,57 @@ public class DPoPKeyStoreTest { 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 From 1da729d8851f0d4343e247ebb745f805a0036c9e Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Thu, 7 Aug 2025 16:28:26 +0530 Subject: [PATCH 18/20] Review comments addressed with respect to UTs --- .../main/java/com/auth0/android/dpop/DPoP.kt | 11 ++- .../com/auth0/android/dpop/DPoPException.kt | 3 + .../java/com/auth0/android/dpop/DPoPUtil.kt | 9 ++- .../auth0/android/provider/OAuthManager.kt | 3 +- .../AuthenticationAPIClientTest.kt | 6 +- .../java/com/auth0/android/dpop/DPoPTest.kt | 58 +++++++++----- .../android/provider/WebAuthProviderTest.kt | 76 ++----------------- 7 files changed, 67 insertions(+), 99 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/dpop/DPoP.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoP.kt index abed952cc..4f0679693 100644 --- a/auth0/src/main/java/com/auth0/android/dpop/DPoP.kt +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoP.kt @@ -1,12 +1,14 @@ 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 /** @@ -98,7 +100,8 @@ public class DPoP { private const val NONCE_HEADER = "DPoP-Nonce" @Volatile - private var _auth0Nonce: String? = null + @VisibleForTesting(otherwise = PRIVATE) + internal var _auth0Nonce: String? = null public val auth0Nonce: String? get() = _auth0Nonce @@ -120,7 +123,11 @@ public class DPoP { */ @JvmStatic internal fun storeNonce(response: Response) { - _auth0Nonce = response.headers[NONCE_HEADER] + response.headers[NONCE_HEADER]?.let { + synchronized(this) { + _auth0Nonce = it + } + } } /** diff --git a/auth0/src/main/java/com/auth0/android/dpop/DPoPException.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoPException.kt index 2008c8e56..225e86978 100644 --- a/auth0/src/main/java/com/auth0/android/dpop/DPoPException.kt +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoPException.kt @@ -9,6 +9,7 @@ public class DPoPException : Auth0Exception { KEY_GENERATION_ERROR, KEY_STORE_ERROR, SIGNING_ERROR, + MALFORMED_URL, UNKNOWN_ERROR, } @@ -42,6 +43,7 @@ public class DPoPException : Auth0Exception { 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) private const val DEFAULT_MESSAGE = "An unknown error has occurred. Please check the error cause for more details." @@ -52,6 +54,7 @@ public class DPoPException : Auth0Exception { 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.UNKNOWN_ERROR -> DEFAULT_MESSAGE } } diff --git a/auth0/src/main/java/com/auth0/android/dpop/DPoPUtil.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoPUtil.kt index 671d75493..e517288f3 100644 --- a/auth0/src/main/java/com/auth0/android/dpop/DPoPUtil.kt +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoPUtil.kt @@ -7,6 +7,7 @@ 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 @@ -157,7 +158,7 @@ internal object DPoPUtil { private fun createSHA256Hash(input: String): String { val digest = MessageDigest.getInstance("SHA-256") val hash = digest.digest(input.toByteArray(Charsets.UTF_8)) - return Base64.encodeToString(hash, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + return encodeBase64Url(hash) } private fun padTo32Bytes(coordinate: BigInteger): ByteArray { @@ -250,9 +251,9 @@ internal object DPoPUtil { null // Remove fragment ) cleanedUri.toString() - } catch (e: Exception) { - Log.w(TAG, "Failed to parse URL, using original: $url", e) - url + } catch (e: URISyntaxException) { + Log.d(TAG, "Failed to parse URL", e) + throw DPoPException.MALFORMED_URL } } 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 8c428b40d..ea3575503 100644 --- a/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt +++ b/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt @@ -26,7 +26,8 @@ internal class OAuthManager( ctOptions: CustomTabsOptions, private val launchAsTwa: Boolean = false, private val customAuthorizeUrl: String? = null, - private val dPoP: DPoP? = null + @get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal val dPoP: DPoP? = null ) : ResumableManager() { private val parameters: MutableMap private val headers: MutableMap 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 493c4760e..4f3a281cc 100755 --- a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt @@ -2888,7 +2888,7 @@ public class AuthenticationAPIClientTest { } @Test - public fun shouldNotAddDpopHeaderToNonTokenEndpoints() { + public fun shouldNotAddDpopHeaderToSignupEndpoint() { whenever(mockKeyStore.hasKeyPair()).thenReturn(true) whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) @@ -2998,14 +2998,14 @@ public class AuthenticationAPIClientTest { } @Test - public fun shouldNotAddDpopHeaderWhenKeyPairGenerationFails() { + public fun shouldNotAddDpopHeaderWhenKeyPairRetrievalFails() { whenever(mockKeyStore.hasKeyPair()).thenReturn(true) whenever(mockKeyStore.getKeyPair()).thenReturn(null) mockAPI.willReturnSuccessfulLogin() val callback = MockAuthenticationCallback() - client.useDPoP().login(SUPPORT_AUTH0_COM, "some-password", MY_CONNECTION) + client.useDPoP().login(SUPPORT_AUTH0_COM, PASSWORD, MY_CONNECTION) .start(callback) ShadowLooper.idleMainLooper() diff --git a/auth0/src/test/java/com/auth0/android/dpop/DPoPTest.kt b/auth0/src/test/java/com/auth0/android/dpop/DPoPTest.kt index 53538b7c6..a743dba35 100644 --- a/auth0/src/test/java/com/auth0/android/dpop/DPoPTest.kt +++ b/auth0/src/test/java/com/auth0/android/dpop/DPoPTest.kt @@ -3,9 +3,7 @@ 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.any import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.never import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.whenever import okhttp3.Headers @@ -16,12 +14,14 @@ import org.hamcrest.CoreMatchers.`is` import org.hamcrest.CoreMatchers.notNullValue import org.hamcrest.CoreMatchers.nullValue import org.hamcrest.MatcherAssert.assertThat -import org.junit.After 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 { @@ -49,6 +49,8 @@ public class DPoPTest { mockResponseBody = mock() dPoP = DPoP() + DPoP._auth0Nonce = null + DPoPUtil.keyStore = mockKeyStore } @@ -164,7 +166,8 @@ public class DPoPTest { @Test public fun `generateKeyPair should propagate DPoPException`() { whenever(mockKeyStore.hasKeyPair()).thenReturn(false) - val exception = DPoPException(DPoPException.Code.KEY_GENERATION_ERROR, "Key generation failed") + val exception = + DPoPException(DPoPException.Code.KEY_GENERATION_ERROR, "Key generation failed") whenever(mockKeyStore.generateKeyPair(mockContext)).thenThrow( exception ) @@ -196,7 +199,6 @@ public class DPoPTest { val result = dPoP.getPublicKeyJWK(mockContext) - verify(mockKeyStore, never()).generateKeyPair(any()) assertThat(result, `is`(testPublicJwkHash)) } @@ -449,31 +451,51 @@ public class DPoPTest { } @Test - public fun `nonce storage should be thread safe`() { - val numThreads = 10 - val numIterations = 100 + 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 { - repeat(numIterations) { iteration -> - val nonce = "nonce-$threadIndex-$iteration" - val response = mock() - whenever(response.headers).thenReturn( - Headers.Builder().add("DPoP-Nonce", nonce).build() - ) - DPoP.storeNonce(response) + 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() } - threads.forEach { it.join() } + startLatch.countDown() // Release all threads at once + + val completed = completionLatch.await(10, TimeUnit.SECONDS) + assertThat("All threads should complete within timeout", completed, `is`(true)) - // Should not crash and should have some nonce value + // Verify final state val finalNonce = DPoP.auth0Nonce assertThat(finalNonce, `is`(notNullValue())) - assertThat(finalNonce!!.startsWith("nonce-"), `is`(true)) + assertThat(allStoredNonces.contains(finalNonce), `is`(true)) } @Test 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 08286c5db..5add51cdf 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt @@ -2748,8 +2748,7 @@ public class WebAuthProviderTest { .start(activity, callback) val managerInstance = WebAuthProvider.managerInstance as OAuthManager - // Verify that the manager has DPoP configured (this would require exposing the dPoP field in OAuthManager for testing) - assertThat(managerInstance, `is`(notNullValue())) + assertThat(managerInstance.dPoP, `is`(notNullValue())) } @Test @@ -2758,7 +2757,7 @@ public class WebAuthProviderTest { .start(activity, callback) val managerInstance = WebAuthProvider.managerInstance as OAuthManager - assertThat(managerInstance, `is`(notNullValue())) + Assert.assertNull(managerInstance.dPoP) } @Test @@ -2815,40 +2814,19 @@ public class WebAuthProviderTest { } @Test - public fun shouldNotIncludeDPoPJWKThumbprintWhenDPoPIsEnabledButNoKeyPair() { - `when`(mockKeyStore.hasKeyPair()).thenReturn(false) - - WebAuthProvider.useDPoP() - .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 shouldChainDPoPWithOtherLoginOptions() { + public fun shouldNotIncludeDPoPJWKThumbprintWhenDPoPIsEnabledButGetKeyPairReturnNull() { `when`(mockKeyStore.hasKeyPair()).thenReturn(true) - `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) + `when`(mockKeyStore.getKeyPair()).thenReturn(null) WebAuthProvider.useDPoP() .login(account) - .withConnection("test-connection") - .withScope("openid profile email custom") - .withState("custom-state") .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.hasParamWithValue("scope", "openid profile email custom")) - assertThat(uri, UriMatchers.hasParamWithValue("state", "custom-state")) - assertThat(uri, UriMatchers.hasParamWithName("dpop_jkt")) + assertThat(uri, not(UriMatchers.hasParamWithName("dpop_jkt"))) } @Test @@ -2976,50 +2954,6 @@ public class WebAuthProviderTest { mockAPI.shutdown() } - // Update the existing test that checks DPoP key generation -// @Test -// public fun enablingDPoPWillGenerateNewKeyPairIfOneDoesNotExist() { -// `when`(mockKeyStore.hasKeyPair()).thenReturn(false) -// -// WebAuthProvider.useDPoP() -// .login(account) -// .start(activity, callback) -// -// verify(mockKeyStore).generateKeyPair(any()) -// } -// -// // Update the existing test for DPoP JWK parameter -// @Test -// public fun shouldHaveDpopJwkOnLoginIfDPoPIsEnabled() { -// `when`(mockKeyStore.hasKeyPair()).thenReturn(true) -// `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) -// -// WebAuthProvider.useDPoP() -// .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")) -// } - - @Test - public fun shouldResetDPoPStateWhenManagerInstanceIsReset() { - WebAuthProvider.useDPoP() - - // Verify DPoP is enabled - val provider1 = WebAuthProvider.useDPoP() - assertThat(provider1, `is`(notNullValue())) - - // Reset manager instance (this should also reset DPoP state if needed) - WebAuthProvider.resetManagerInstance() - - // DPoP should still work after reset - val provider2 = WebAuthProvider.useDPoP() - assertThat(provider2, `is`(notNullValue())) - } - //** ** ** ** ** ** **// //** ** ** ** ** ** **// //** Helpers Functions**// From dfddd775b53070d4523cabe5e17b5c32ca78b82f Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Thu, 7 Aug 2025 20:24:51 +0530 Subject: [PATCH 19/20] Added case to generate key pair for embedded login flow --- .../authentication/AuthenticationAPIClient.kt | 5 ++- .../main/java/com/auth0/android/dpop/DPoP.kt | 11 ++--- .../auth0/android/dpop/SenderConstraining.kt | 4 +- .../auth0/android/provider/OAuthManager.kt | 25 +++++------ .../auth0/android/provider/WebAuthProvider.kt | 4 +- .../android/request/internal/BaseRequest.kt | 1 + .../AuthenticationAPIClientTest.kt | 23 +++++----- .../java/com/auth0/android/dpop/DPoPTest.kt | 16 +++---- .../com/auth0/android/dpop/DPoPUtilTest.kt | 2 +- .../android/provider/WebAuthProviderTest.kt | 42 ++++++++++--------- 10 files changed, 71 insertions(+), 62 deletions(-) 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 671b4b2df..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,5 +1,6 @@ package com.auth0.android.authentication +import android.content.Context import androidx.annotation.VisibleForTesting import com.auth0.android.Auth0 import com.auth0.android.Auth0Exception @@ -67,8 +68,8 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe /** * Enable DPoP for this client. */ - public override fun useDPoP(): AuthenticationAPIClient { - dPoP = DPoP() + public override fun useDPoP(context: Context): AuthenticationAPIClient { + dPoP = DPoP(context) return this } diff --git a/auth0/src/main/java/com/auth0/android/dpop/DPoP.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoP.kt index 4f0679693..c74b0982b 100644 --- a/auth0/src/main/java/com/auth0/android/dpop/DPoP.kt +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoP.kt @@ -22,10 +22,11 @@ public data class HeaderData(val authorizationHeader: String, val dpopProof: Str * 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 { +public class DPoP(private val context: Context) { /** - * Determines whether a DPoP proof should be generated for the given URL and parameters. + * 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 @@ -77,7 +78,7 @@ public class DPoP { * @throws DPoPException if there is an error generating the key pair or accessing the keystore. */ @Throws(DPoPException::class) - internal fun generateKeyPair(context: Context) { + internal fun generateKeyPair() { DPoPUtil.generateKeyPair(context) } @@ -89,8 +90,8 @@ public class DPoP { * @throws DPoPException if there is an error accessing the key pair. */ @Throws(DPoPException::class) - internal fun getPublicKeyJWK(context: Context): String? { - generateKeyPair(context) + internal fun getPublicKeyJWK(): String? { + generateKeyPair() return DPoPUtil.getPublicKeyJWK() } diff --git a/auth0/src/main/java/com/auth0/android/dpop/SenderConstraining.kt b/auth0/src/main/java/com/auth0/android/dpop/SenderConstraining.kt index 3f1d25a14..7e08de910 100644 --- a/auth0/src/main/java/com/auth0/android/dpop/SenderConstraining.kt +++ b/auth0/src/main/java/com/auth0/android/dpop/SenderConstraining.kt @@ -1,5 +1,7 @@ package com.auth0.android.dpop +import android.content.Context + /** * Interface for SenderConstraining */ @@ -8,5 +10,5 @@ public interface SenderConstraining> { /** * Method to enable DPoP in the request. */ - public fun useDPoP(): T + 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 ea3575503..acc2b2864 100644 --- a/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt +++ b/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt @@ -63,10 +63,10 @@ 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, context) + addDPoPJWKParameters(parameters) } catch (ex: DPoPException) { callback.onFailure( AuthenticationException( @@ -273,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 @@ -295,14 +296,18 @@ 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, context: Context) { - dPoP?.getPublicKeyJWK(context)?.let { + private fun addDPoPJWKParameters(parameters: MutableMap) { + dPoP?.getPublicKeyJWK()?.let { parameters["dpop_jkt"] = it } } @@ -376,10 +381,6 @@ internal class OAuthManager( this.parameters = parameters.toMutableMap() this.parameters[KEY_RESPONSE_TYPE] = RESPONSE_TYPE_CODE apiClient = AuthenticationAPIClient(account) - // Enable DPoP on the AuthenticationClient if DPoP is set in the WebAuthProvider class - dPoP?.let { - apiClient.useDPoP() - } this.ctOptions = ctOptions } } @@ -398,7 +399,7 @@ internal fun OAuthManager.Companion.fromState( setHeaders( state.headers ) - setPKCE(state.pkce) + setPKCE(state.pkce) setIdTokenVerificationIssuer(state.idTokenVerificationIssuer) setIdTokenVerificationLeeway(state.idTokenVerificationLeeway) } 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 601e18008..955eec9c5 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -50,8 +50,8 @@ public object WebAuthProvider : SenderConstraining { } // Public methods - public override fun useDPoP(): WebAuthProvider { - dPoP = DPoP() + public override fun useDPoP(context: Context): WebAuthProvider { + dPoP = DPoP(context) return this } 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 4d8123cff..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 @@ -129,6 +129,7 @@ internal open class BaseRequest( 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 } 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 4f3a281cc..41fa6dad3 100755 --- a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt @@ -48,7 +48,6 @@ import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.mockito.Mockito.`when` import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowLooper @@ -65,11 +64,13 @@ public class AuthenticationAPIClientTest { 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() @@ -2768,7 +2769,7 @@ public class AuthenticationAPIClientTest { val callback = MockAuthenticationCallback() // Enable DPoP - client.useDPoP().login(SUPPORT_AUTH0_COM, PASSWORD, MY_CONNECTION) + client.useDPoP(mockContext).login(SUPPORT_AUTH0_COM, PASSWORD, MY_CONNECTION) .start(callback) ShadowLooper.idleMainLooper() @@ -2789,7 +2790,7 @@ public class AuthenticationAPIClientTest { mockAPI.willReturnSuccessfulLogin() val callback = MockAuthenticationCallback() - client.useDPoP().login(SUPPORT_AUTH0_COM, PASSWORD, MY_CONNECTION) + client.useDPoP(mockContext).login(SUPPORT_AUTH0_COM, PASSWORD, MY_CONNECTION) .start(callback) ShadowLooper.idleMainLooper() @@ -2811,7 +2812,7 @@ public class AuthenticationAPIClientTest { mockAPI.willReturnSuccessfulLogin() val callback = MockAuthenticationCallback() - client.useDPoP().token("auth-code", "code-verifier", "http://redirect.uri") + client.useDPoP(mockContext).token("auth-code", "code-verifier", "http://redirect.uri") .start(callback) ShadowLooper.idleMainLooper() @@ -2852,7 +2853,7 @@ public class AuthenticationAPIClientTest { mockAPI.willReturnUserInfo() val callback = MockAuthenticationCallback() - client.useDPoP().userInfo("ACCESS_TOKEN", "DPoP") + client.useDPoP(mockContext).userInfo("ACCESS_TOKEN", "DPoP") .start(callback) ShadowLooper.idleMainLooper() @@ -2896,7 +2897,7 @@ public class AuthenticationAPIClientTest { val callback = MockAuthenticationCallback() // DPoP is enabled but signup endpoint should not get DPoP header - client.useDPoP().createUser(SUPPORT_AUTH0_COM, PASSWORD, SUPPORT, MY_CONNECTION) + client.useDPoP(mockContext).createUser(SUPPORT_AUTH0_COM, PASSWORD, SUPPORT, MY_CONNECTION) .start(callback) ShadowLooper.idleMainLooper() @@ -2919,7 +2920,7 @@ public class AuthenticationAPIClientTest { val callback = MockAuthenticationCallback() // DPoP is enabled but passwordless endpoint should not get DPoP header - client.useDPoP().passwordlessWithEmail(SUPPORT_AUTH0_COM, PasswordlessType.CODE) + client.useDPoP(mockContext).passwordlessWithEmail(SUPPORT_AUTH0_COM, PasswordlessType.CODE) .start(callback) ShadowLooper.idleMainLooper() @@ -2938,7 +2939,7 @@ public class AuthenticationAPIClientTest { val callback = MockAuthenticationCallback>() // DPoP is enabled but JWKS endpoint should not get DPoP header - client.useDPoP().fetchJsonWebKeys() + client.useDPoP(mockContext).fetchJsonWebKeys() .start(callback) ShadowLooper.idleMainLooper() @@ -2956,7 +2957,7 @@ public class AuthenticationAPIClientTest { mockAPI.willReturnSuccessfulLogin() val callback = MockAuthenticationCallback() - client.useDPoP().customTokenExchange("subject-token-type", "subject-token") + client.useDPoP(mockContext).customTokenExchange("subject-token-type", "subject-token") .start(callback) ShadowLooper.idleMainLooper() @@ -2983,7 +2984,7 @@ public class AuthenticationAPIClientTest { mockAPI.willReturnSuccessfulLogin() val callback = MockAuthenticationCallback() - client.useDPoP().ssoExchange("refresh-token") + client.useDPoP(mockContext).ssoExchange("refresh-token") .start(callback) ShadowLooper.idleMainLooper() @@ -3005,7 +3006,7 @@ public class AuthenticationAPIClientTest { mockAPI.willReturnSuccessfulLogin() val callback = MockAuthenticationCallback() - client.useDPoP().login(SUPPORT_AUTH0_COM, PASSWORD, MY_CONNECTION) + client.useDPoP(mockContext).login(SUPPORT_AUTH0_COM, PASSWORD, MY_CONNECTION) .start(callback) ShadowLooper.idleMainLooper() diff --git a/auth0/src/test/java/com/auth0/android/dpop/DPoPTest.kt b/auth0/src/test/java/com/auth0/android/dpop/DPoPTest.kt index a743dba35..02c6ae329 100644 --- a/auth0/src/test/java/com/auth0/android/dpop/DPoPTest.kt +++ b/auth0/src/test/java/com/auth0/android/dpop/DPoPTest.kt @@ -47,7 +47,7 @@ public class DPoPTest { mockResponse = mock() mockKeyStore = mock() mockResponseBody = mock() - dPoP = DPoP() + dPoP = DPoP(mockContext) DPoP._auth0Nonce = null @@ -158,7 +158,7 @@ public class DPoPTest { public fun `generateKeyPair should delegate to DPoPUtil`() { whenever(mockKeyStore.hasKeyPair()).thenReturn(false) - dPoP.generateKeyPair(mockContext) + dPoP.generateKeyPair() verify(mockKeyStore).generateKeyPair(mockContext) } @@ -173,7 +173,7 @@ public class DPoPTest { ) try { - dPoP.generateKeyPair(mockContext) + dPoP.generateKeyPair() Assert.fail("Expected DPoPException to be thrown") } catch (e: DPoPException) { assertThat(e, `is`(exception)) @@ -186,7 +186,7 @@ public class DPoPTest { whenever(mockKeyStore.hasKeyPair()).thenReturn(false).thenReturn(true) whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) - val result = dPoP.getPublicKeyJWK(mockContext) + val result = dPoP.getPublicKeyJWK() verify(mockKeyStore).generateKeyPair(mockContext) assertThat(result, `is`(testPublicJwkHash)) @@ -197,7 +197,7 @@ public class DPoPTest { whenever(mockKeyStore.hasKeyPair()).thenReturn(true) whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) - val result = dPoP.getPublicKeyJWK(mockContext) + val result = dPoP.getPublicKeyJWK() assertThat(result, `is`(testPublicJwkHash)) } @@ -206,7 +206,7 @@ public class DPoPTest { public fun `getPublicKeyJWK should return null when key pair generation fails`() { whenever(mockKeyStore.hasKeyPair()).thenReturn(false).thenReturn(false) - val result = dPoP.getPublicKeyJWK(mockContext) + val result = dPoP.getPublicKeyJWK() verify(mockKeyStore).generateKeyPair(mockContext) assertThat(result, `is`(nullValue())) @@ -504,11 +504,11 @@ public class DPoPTest { whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) // 1. Generate key pair - dPoP.generateKeyPair(mockContext) + dPoP.generateKeyPair() verify(mockKeyStore).generateKeyPair(mockContext) // 2. Get JWK thumbprint - val jwkThumbprint = dPoP.getPublicKeyJWK(mockContext) + val jwkThumbprint = dPoP.getPublicKeyJWK() assertThat(jwkThumbprint, `is`(testPublicJwkHash)) // 3. Store nonce from response diff --git a/auth0/src/test/java/com/auth0/android/dpop/DPoPUtilTest.kt b/auth0/src/test/java/com/auth0/android/dpop/DPoPUtilTest.kt index 44fafccfc..f94893b06 100644 --- a/auth0/src/test/java/com/auth0/android/dpop/DPoPUtilTest.kt +++ b/auth0/src/test/java/com/auth0/android/dpop/DPoPUtilTest.kt @@ -356,7 +356,7 @@ public class DPoPUtilTest { whenever(mockKeyStore.hasKeyPair()).thenReturn(true) DPoPUtil.generateKeyPair(mockContext) verify(mockKeyStore).hasKeyPair() - verify(mockKeyStore, never()).generateKeyPair(any()) + verify(mockKeyStore, never()).generateKeyPair(any(), any()) } @Test 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 5add51cdf..a8d6559d3 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 @@ -73,6 +74,7 @@ public class WebAuthProviderTest { 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() @@ -92,6 +94,7 @@ public class WebAuthProviderTest { account.networkingClient = SSLTestUtils.testClient mockKeyStore = mock() + mockContext = mock() DPoPUtil.keyStore = mockKeyStore @@ -322,15 +325,14 @@ public class WebAuthProviderTest { ) } - //jwk @Test public fun enablingDPoPWillGenerateNewKeyPairIfOneDoesNotExist() { `when`(mockKeyStore.hasKeyPair()).thenReturn(false) - WebAuthProvider.useDPoP() + WebAuthProvider.useDPoP(mockContext) .login(account) .start(activity, callback) - verify(mockKeyStore).generateKeyPair(any()) + verify(mockKeyStore).generateKeyPair(any(), any()) } @Test @@ -354,7 +356,7 @@ public class WebAuthProviderTest { `when`(mockKeyStore.hasKeyPair()).thenReturn(true) `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) - WebAuthProvider.useDPoP() + WebAuthProvider.useDPoP(mockContext) .login(account) .start(activity, callback) @@ -2731,11 +2733,11 @@ public class WebAuthProviderTest { //DPoP public fun shouldReturnSameInstanceWhenCallingUseDPoPMultipleTimes() { - val provider1 = WebAuthProvider.useDPoP() - val provider2 = WebAuthProvider.useDPoP() + val provider1 = WebAuthProvider.useDPoP(mockContext) + val provider2 = WebAuthProvider.useDPoP(mockContext) assertThat(provider1, `is`(provider2)) - assertThat(WebAuthProvider.useDPoP(), `is`(provider1)) + assertThat(WebAuthProvider.useDPoP(mockContext), `is`(provider1)) } @Test @@ -2743,7 +2745,7 @@ public class WebAuthProviderTest { `when`(mockKeyStore.hasKeyPair()).thenReturn(true) `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) - WebAuthProvider.useDPoP() + WebAuthProvider.useDPoP(mockContext) .login(account) .start(activity, callback) @@ -2764,11 +2766,11 @@ public class WebAuthProviderTest { public fun shouldGenerateKeyPairWhenDPoPIsEnabledAndNoKeyPairExists() { `when`(mockKeyStore.hasKeyPair()).thenReturn(false) - WebAuthProvider.useDPoP() + WebAuthProvider.useDPoP(mockContext) .login(account) .start(activity, callback) - verify(mockKeyStore).generateKeyPair(any()) + verify(mockKeyStore).generateKeyPair(any(), any()) } @Test @@ -2776,11 +2778,11 @@ public class WebAuthProviderTest { `when`(mockKeyStore.hasKeyPair()).thenReturn(true) `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) - WebAuthProvider.useDPoP() + WebAuthProvider.useDPoP(mockContext) .login(account) .start(activity, callback) - verify(mockKeyStore, never()).generateKeyPair(any()) + verify(mockKeyStore, never()).generateKeyPair(any(), any()) } @Test @@ -2790,7 +2792,7 @@ public class WebAuthProviderTest { login(account) .start(activity, callback) - verify(mockKeyStore, never()).generateKeyPair(any()) + verify(mockKeyStore, never()).generateKeyPair(any(), any()) } @Test @@ -2798,7 +2800,7 @@ public class WebAuthProviderTest { `when`(mockKeyStore.hasKeyPair()).thenReturn(true) `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) - WebAuthProvider.useDPoP() + WebAuthProvider.useDPoP(mockContext) .login(account) .start(activity, callback) @@ -2818,7 +2820,7 @@ public class WebAuthProviderTest { `when`(mockKeyStore.hasKeyPair()).thenReturn(true) `when`(mockKeyStore.getKeyPair()).thenReturn(null) - WebAuthProvider.useDPoP() + WebAuthProvider.useDPoP(mockContext) .login(account) .start(activity, callback) @@ -2834,7 +2836,7 @@ public class WebAuthProviderTest { `when`(mockKeyStore.hasKeyPair()).thenReturn(true) `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) - val builder = WebAuthProvider.useDPoP() + val builder = WebAuthProvider.useDPoP(mockContext) .login(account) .withConnection("test-connection") @@ -2850,7 +2852,7 @@ public class WebAuthProviderTest { @Test public fun shouldNotAffectLogoutWhenDPoPIsEnabled() { - WebAuthProvider.useDPoP() + WebAuthProvider.useDPoP(mockContext) .logout(account) .start(activity, voidCallback) @@ -2866,9 +2868,9 @@ public class WebAuthProviderTest { public fun shouldHandleDPoPKeyGenerationFailureGracefully() { `when`(mockKeyStore.hasKeyPair()).thenReturn(false) doThrow(DPoPException.KEY_GENERATION_ERROR) - .`when`(mockKeyStore).generateKeyPair(any()) + .`when`(mockKeyStore).generateKeyPair(any(), any()) - WebAuthProvider.useDPoP() + WebAuthProvider.useDPoP(mockContext) .login(account) .start(activity, callback) @@ -2897,7 +2899,7 @@ public class WebAuthProviderTest { val proxyAccount: Auth0 = Auth0.getInstance(JwtTestUtils.EXPECTED_AUDIENCE, mockAPI.domain) proxyAccount.networkingClient = SSLTestUtils.testClient - WebAuthProvider.useDPoP() + WebAuthProvider.useDPoP(mockContext) .login(proxyAccount) .withPKCE(pkce) .start(activity, authCallback) From 207ff317c93fc5bd90161657cf752509b8d8ff22 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Thu, 7 Aug 2025 21:41:49 +0530 Subject: [PATCH 20/20] Added more cases to cover the embedded flow key-pair generation flow --- .../com/auth0/android/dpop/DPoPException.kt | 3 + .../java/com/auth0/android/dpop/DPoPUtil.kt | 2 +- .../AuthenticationAPIClientTest.kt | 143 +++++++++++++++--- .../java/com/auth0/android/dpop/DPoPTest.kt | 31 +++- .../com/auth0/android/dpop/DPoPUtilTest.kt | 11 +- .../android/provider/OAuthManagerTest.java | 2 - .../android/provider/WebAuthProviderTest.kt | 1 + 7 files changed, 166 insertions(+), 27 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/dpop/DPoPException.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoPException.kt index 225e86978..c94294512 100644 --- a/auth0/src/main/java/com/auth0/android/dpop/DPoPException.kt +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoPException.kt @@ -10,6 +10,7 @@ public class DPoPException : Auth0Exception { KEY_STORE_ERROR, SIGNING_ERROR, MALFORMED_URL, + KEY_PAIR_NOT_FOUND, UNKNOWN_ERROR, } @@ -44,6 +45,7 @@ public class DPoPException : Auth0Exception { 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." @@ -55,6 +57,7 @@ public class DPoPException : Auth0Exception { 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 } } diff --git a/auth0/src/main/java/com/auth0/android/dpop/DPoPUtil.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoPUtil.kt index e517288f3..23f171bad 100644 --- a/auth0/src/main/java/com/auth0/android/dpop/DPoPUtil.kt +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoPUtil.kt @@ -49,7 +49,7 @@ internal object DPoPUtil { val keyPair = keyStore.getKeyPair() keyPair ?: run { Log.e(TAG, "generateProof: Key pair is null") - return null + throw DPoPException(DPoPException.Code.KEY_PAIR_NOT_FOUND) } val (privateKey, publicKey) = keyPair 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 41fa6dad3..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,7 @@ 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 @@ -45,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 @@ -2740,9 +2745,8 @@ public class AuthenticationAPIClientTest { } //DPoP - @Test - public fun shouldNotAddDpopHeaderWhenDpopNotEnabled() { + public fun shouldNotAddDpopHeaderWhenDpopNotEnabledToTokenEndpoint() { mockAPI.willReturnSuccessfulLogin() val callback = MockAuthenticationCallback() // DPoP is not enabled - dPoP property should be null @@ -2761,14 +2765,40 @@ public class AuthenticationAPIClientTest { } @Test - public fun shouldAddDpopHeaderWhenDpopEnabledAndKeyPairExists() { + 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() - // Enable DPoP client.useDPoP(mockContext).login(SUPPORT_AUTH0_COM, PASSWORD, MY_CONNECTION) .start(callback) ShadowLooper.idleMainLooper() @@ -2784,13 +2814,13 @@ public class AuthenticationAPIClientTest { } @Test - public fun shouldNotAddDpopHeaderWhenDpopEnabledButNoKeyPair() { + public fun shouldNotAddDpopHeaderToTokenExchangeWhenDPoPEnabledAndNoKeyPairExist() { whenever(mockKeyStore.hasKeyPair()).thenReturn(false) mockAPI.willReturnSuccessfulLogin() val callback = MockAuthenticationCallback() - client.useDPoP(mockContext).login(SUPPORT_AUTH0_COM, PASSWORD, MY_CONNECTION) + client.useDPoP(mockContext).renewAuth("refresh_token") .start(callback) ShadowLooper.idleMainLooper() @@ -2805,13 +2835,33 @@ public class AuthenticationAPIClientTest { } @Test - public fun shouldAddDpopHeaderToTokenExchangeWhenEnabled() { - whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + 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() @@ -2819,6 +2869,16 @@ public class AuthenticationAPIClientTest { 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 @@ -2827,17 +2887,62 @@ public class AuthenticationAPIClientTest { } @Test - public fun shouldNotAddDpopHeaderToTokenExchangeWhenNotEnabled() { + public fun shouldNotCreateKeyPairWhenDPoPEnabledButNoKeyPairExistsForRefreshTokenExchange() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(false) + mockAPI.willReturnSuccessfulLogin() val callback = MockAuthenticationCallback() - client.token("auth-code", "code-verifier", "http://redirect.uri") + // 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 @@ -2999,21 +3104,19 @@ public class AuthenticationAPIClientTest { } @Test - public fun shouldNotAddDpopHeaderWhenKeyPairRetrievalFails() { + public fun shouldThrowExceptionWhenKeyPairRetrievalFails() { whenever(mockKeyStore.hasKeyPair()).thenReturn(true) whenever(mockKeyStore.getKeyPair()).thenReturn(null) mockAPI.willReturnSuccessfulLogin() - val callback = MockAuthenticationCallback() - - client.useDPoP(mockContext).login(SUPPORT_AUTH0_COM, PASSWORD, MY_CONNECTION) - .start(callback) - ShadowLooper.idleMainLooper() - val request = mockAPI.takeRequest() - // Should not have DPoP header when key pair retrieval fails - assertThat(request.getHeader("DPoP"), Matchers.nullValue()) - assertThat(request.path, Matchers.equalTo("/oauth/token")) + 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 { diff --git a/auth0/src/test/java/com/auth0/android/dpop/DPoPTest.kt b/auth0/src/test/java/com/auth0/android/dpop/DPoPTest.kt index 02c6ae329..8bd6e836e 100644 --- a/auth0/src/test/java/com/auth0/android/dpop/DPoPTest.kt +++ b/auth0/src/test/java/com/auth0/android/dpop/DPoPTest.kt @@ -65,7 +65,17 @@ public class DPoPTest { } @Test - public fun `shouldGenerateProof should return false for token endpoint with refresh grant type`() { + 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) @@ -249,6 +259,25 @@ public class DPoPTest { 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()) diff --git a/auth0/src/test/java/com/auth0/android/dpop/DPoPUtilTest.kt b/auth0/src/test/java/com/auth0/android/dpop/DPoPUtilTest.kt index f94893b06..1ba8050a9 100644 --- a/auth0/src/test/java/com/auth0/android/dpop/DPoPUtilTest.kt +++ b/auth0/src/test/java/com/auth0/android/dpop/DPoPUtilTest.kt @@ -67,13 +67,18 @@ public class DPoPUtilTest { } @Test - public fun `generateProof should return null when keyStore returns null key pair`() { + public fun `generateProof should throw exception when keyStore returns null key pair`() { whenever(mockKeyStore.hasKeyPair()).thenReturn(true) whenever(mockKeyStore.getKeyPair()).thenReturn(null) - val result = DPoPUtil.generateProof(testHttpUrl, testHttpMethod) + val exception = assertThrows(DPoPException::class.java) { + DPoPUtil.generateProof(testHttpUrl, testHttpMethod) + } - assertThat(result, `is`(nullValue())) + Assert.assertEquals( + "Key pair is not found in the keystore. Please generate a key pair first.", + exception.message + ) verify(mockKeyStore).hasKeyPair() verify(mockKeyStore).getKeyPair() } 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 38ad21884..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,6 +1,5 @@ package com.auth0.android.provider; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -11,7 +10,6 @@ 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.NetworkingClient; import com.auth0.android.result.Credentials; import com.auth0.android.util.Auth0UserAgent; 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 a8d6559d3..17b845fb6 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt @@ -2880,6 +2880,7 @@ public class WebAuthProviderTest { 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()) }