diff --git a/dev-app/src/main/AndroidManifest.xml b/dev-app/src/main/AndroidManifest.xml
index 068e6ca..d9659a6 100644
--- a/dev-app/src/main/AndroidManifest.xml
+++ b/dev-app/src/main/AndroidManifest.xml
@@ -13,7 +13,8 @@
android:supportsRtl="true"
tools:targetApi="31">
-
+
+
diff --git a/dev-app/src/main/java/com/uid2/dev/network/AppUID2Client.kt b/dev-app/src/main/java/com/uid2/dev/network/AppUID2Client.kt
index 1efa625..ecb9973 100644
--- a/dev-app/src/main/java/com/uid2/dev/network/AppUID2Client.kt
+++ b/dev-app/src/main/java/com/uid2/dev/network/AppUID2Client.kt
@@ -142,7 +142,7 @@ class AppUID2Client(
}
private fun decryptResponse(key: String, data: String) =
- DataEnvelope.decrypt(key, data, false)?.toString(Charsets.UTF_8)
+ DataEnvelope.decrypt(key, data, true)?.toString(Charsets.UTF_8)
private fun Long.toByteArray() = ByteBuffer.allocate(Long.SIZE_BYTES).apply {
order(ByteOrder.BIG_ENDIAN)
diff --git a/dev-app/src/main/java/com/uid2/dev/network/AppUID2ClientException.kt b/dev-app/src/main/java/com/uid2/dev/network/AppUID2ClientException.kt
index 2b3043b..ef2760b 100644
--- a/dev-app/src/main/java/com/uid2/dev/network/AppUID2ClientException.kt
+++ b/dev-app/src/main/java/com/uid2/dev/network/AppUID2ClientException.kt
@@ -1,6 +1,8 @@
package com.uid2.dev.network
+import com.uid2.UID2Exception
+
/**
* The exception thrown when an error occurred in the Development Application's UID2 Client.
*/
-class AppUID2ClientException(message: String? = null, cause: Throwable? = null) : Exception(message, cause)
+class AppUID2ClientException(message: String? = null, cause: Throwable? = null) : UID2Exception(message, cause)
diff --git a/dev-app/src/main/java/com/uid2/dev/ui/MainScreen.kt b/dev-app/src/main/java/com/uid2/dev/ui/MainScreen.kt
index 1a76e81..84b1266 100644
--- a/dev-app/src/main/java/com/uid2/dev/ui/MainScreen.kt
+++ b/dev-app/src/main/java/com/uid2/dev/ui/MainScreen.kt
@@ -1,25 +1,44 @@
package com.uid2.dev.ui
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.material.Checkbox
+import androidx.compose.material.Divider
+import androidx.compose.material.ExperimentalMaterialApi
+import androidx.compose.material.LocalMinimumInteractiveComponentEnforcement
import androidx.compose.material.Scaffold
+import androidx.compose.material.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Email
+import androidx.compose.material.icons.filled.Phone
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.uid2.dev.ui.MainScreenAction.EmailChanged
+import com.uid2.dev.ui.MainScreenAction.PhoneChanged
import com.uid2.dev.ui.MainScreenAction.RefreshButtonPressed
import com.uid2.dev.ui.MainScreenAction.ResetButtonPressed
import com.uid2.dev.ui.MainScreenState.ErrorState
import com.uid2.dev.ui.MainScreenState.LoadingState
import com.uid2.dev.ui.MainScreenState.UserUpdatedState
import com.uid2.dev.ui.views.ActionButtonView
-import com.uid2.dev.ui.views.EmailInputView
import com.uid2.dev.ui.views.ErrorView
+import com.uid2.dev.ui.views.IdentityInputView
import com.uid2.dev.ui.views.LoadingView
import com.uid2.dev.ui.views.UserIdentityView
+import com.uid2.devapp.R
+@OptIn(ExperimentalMaterialApi::class)
@Composable
fun MainScreen(viewModel: MainScreenViewModel) {
val viewState by viewModel.viewState.collectAsState()
@@ -33,9 +52,49 @@ fun MainScreen(viewModel: MainScreenViewModel) {
)
},
) { padding ->
+ val checkedState = remember { mutableStateOf(true) }
+
Column(modifier = Modifier.padding(10.dp, 10.dp, 10.dp, 10.dp + padding.calculateBottomPadding())) {
// The top of the View provides a way for the Email Address to be entered.
- EmailInputView(Modifier, onEmailEntered = { viewModel.processAction(EmailChanged(it)) })
+ IdentityInputView(
+ modifier = Modifier.padding(bottom = 6.dp),
+ label = stringResource(R.string.email),
+ icon = Icons.Default.Email,
+ onEntered = { viewModel.processAction(EmailChanged(it, checkedState.value)) },
+ )
+
+ IdentityInputView(
+ label = stringResource(R.string.phone),
+ icon = Icons.Default.Phone,
+ onEntered = { viewModel.processAction(PhoneChanged(it, checkedState.value)) },
+ )
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) {
+ Checkbox(
+ modifier = Modifier
+ .padding(vertical = 4.dp)
+ .padding(end = 4.dp),
+ checked = checkedState.value,
+ onCheckedChange = { checkedState.value = it },
+ )
+ }
+
+ Text(text = stringResource(id = R.string.generate_client_side))
+ }
+
+ Divider(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 10.dp),
+ thickness = 2.dp,
+ color = Color.Black,
+ )
// Depending on the state of the View Model, we will switch in different content view.
when (val state = viewState) {
diff --git a/dev-app/src/main/java/com/uid2/dev/ui/MainScreenViewModel.kt b/dev-app/src/main/java/com/uid2/dev/ui/MainScreenViewModel.kt
index 291b732..cbb6351 100644
--- a/dev-app/src/main/java/com/uid2/dev/ui/MainScreenViewModel.kt
+++ b/dev-app/src/main/java/com/uid2/dev/ui/MainScreenViewModel.kt
@@ -4,7 +4,9 @@ import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
+import com.uid2.UID2Exception
import com.uid2.UID2Manager
+import com.uid2.UID2Manager.GenerateIdentityResult
import com.uid2.UID2ManagerState.Established
import com.uid2.UID2ManagerState.Expired
import com.uid2.UID2ManagerState.Loading
@@ -12,6 +14,7 @@ import com.uid2.UID2ManagerState.NoIdentity
import com.uid2.UID2ManagerState.OptOut
import com.uid2.UID2ManagerState.RefreshExpired
import com.uid2.UID2ManagerState.Refreshed
+import com.uid2.data.IdentityRequest
import com.uid2.data.IdentityStatus
import com.uid2.data.IdentityStatus.ESTABLISHED
import com.uid2.data.IdentityStatus.EXPIRED
@@ -22,8 +25,8 @@ import com.uid2.data.IdentityStatus.REFRESHED
import com.uid2.data.IdentityStatus.REFRESH_EXPIRED
import com.uid2.data.UID2Identity
import com.uid2.dev.network.AppUID2Client
-import com.uid2.dev.network.AppUID2ClientException
import com.uid2.dev.network.RequestType.EMAIL
+import com.uid2.dev.network.RequestType.PHONE
import com.uid2.dev.ui.MainScreenState.ErrorState
import com.uid2.dev.ui.MainScreenState.LoadingState
import com.uid2.dev.ui.MainScreenState.UserUpdatedState
@@ -33,7 +36,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
sealed interface MainScreenAction : ViewModelAction {
- data class EmailChanged(val address: String) : MainScreenAction
+ data class EmailChanged(val address: String, val clientSide: Boolean) : MainScreenAction
+ data class PhoneChanged(val number: String, val clientSide: Boolean) : MainScreenAction
data object ResetButtonPressed : MainScreenAction
data object RefreshButtonPressed : MainScreenAction
}
@@ -76,21 +80,60 @@ class MainScreenViewModel(
override fun processAction(action: MainScreenAction) {
Log.d(TAG, "Action: $action")
+ // If we are reported an error from generateIdentity's onResult callback, we will update our state to reflect it
+ val onGenerateResult: (GenerateIdentityResult) -> Unit = { result ->
+ when (result) {
+ is GenerateIdentityResult.Error -> viewModelScope.launch { _viewState.emit(ErrorState(result.ex)) }
+ else -> Unit
+ }
+ }
+
viewModelScope.launch {
when (action) {
is MainScreenAction.EmailChanged -> {
+ _viewState.emit(LoadingState)
+
try {
- // For Development purposes, we are required to generate the initial Identity before then
- // passing it onto the SDK to be managed.
- _viewState.emit(LoadingState)
- api.generateIdentity(action.address, EMAIL)?.let {
- manager.setIdentity(it)
+ if (action.clientSide) {
+ // Generate the identity via Client Side Integration (client side token generation).
+ manager.generateIdentity(
+ IdentityRequest.Email(action.address),
+ SUBSCRIPTION_ID,
+ PUBLIC_KEY,
+ onGenerateResult,
+ )
+ } else {
+ // We're going to generate the identity as if we've obtained it via a backend service.
+ api.generateIdentity(action.address, EMAIL)?.let {
+ manager.setIdentity(it)
+ }
}
- } catch (ex: AppUID2ClientException) {
+ } catch (ex: UID2Exception) {
_viewState.emit(ErrorState(ex))
}
}
+ is MainScreenAction.PhoneChanged -> {
+ _viewState.emit(LoadingState)
+ try {
+ if (action.clientSide) {
+ // Generate the identity via Client Side Integration (client side token generation).
+ manager.generateIdentity(
+ IdentityRequest.Phone(action.number),
+ SUBSCRIPTION_ID,
+ PUBLIC_KEY,
+ onGenerateResult,
+ )
+ } else {
+ // We're going to generate the identity as if we've obtained it via a backend service.
+ api.generateIdentity(action.number, PHONE)?.let {
+ manager.setIdentity(it)
+ }
+ }
+ } catch (ex: UID2Exception) {
+ _viewState.emit(ErrorState(ex))
+ }
+ }
MainScreenAction.RefreshButtonPressed -> {
manager.currentIdentity?.let { _viewState.emit(LoadingState) }
manager.refreshIdentity()
@@ -105,6 +148,11 @@ class MainScreenViewModel(
private companion object {
const val TAG = "MainScreenViewModel"
+
+ const val SUBSCRIPTION_ID = "toPh8vgJgt"
+
+ @Suppress("ktlint:standard:max-line-length")
+ const val PUBLIC_KEY = "UID2-X-I-MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEKAbPfOz7u25g1fL6riU7p2eeqhjmpALPeYoyjvZmZ1xM2NM8UeOmDZmCIBnKyRZ97pz5bMCjrs38WM22O7LJuw=="
}
}
diff --git a/dev-app/src/main/java/com/uid2/dev/ui/views/ActionButtonView.kt b/dev-app/src/main/java/com/uid2/dev/ui/views/ActionButtonView.kt
index f04dcc0..f8bfc1c 100644
--- a/dev-app/src/main/java/com/uid2/dev/ui/views/ActionButtonView.kt
+++ b/dev-app/src/main/java/com/uid2/dev/ui/views/ActionButtonView.kt
@@ -7,6 +7,8 @@ import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import com.uid2.devapp.R
@Composable
fun ActionButtonView(modifier: Modifier, onResetClick: () -> Unit, onRefreshClick: () -> Unit) {
@@ -15,11 +17,11 @@ fun ActionButtonView(modifier: Modifier, onResetClick: () -> Unit, onRefreshClic
horizontalArrangement = Arrangement.SpaceEvenly,
) {
Button(onClick = onResetClick) {
- Text("Reset")
+ Text(stringResource(id = R.string.action_reset))
}
Button(onClick = onRefreshClick) {
- Text("Manual Refresh")
+ Text(stringResource(id = R.string.action_refresh))
}
}
}
diff --git a/dev-app/src/main/java/com/uid2/dev/ui/views/EmailInputView.kt b/dev-app/src/main/java/com/uid2/dev/ui/views/IdentityInputView.kt
similarity index 65%
rename from dev-app/src/main/java/com/uid2/dev/ui/views/EmailInputView.kt
rename to dev-app/src/main/java/com/uid2/dev/ui/views/IdentityInputView.kt
index f1f7b22..441bfa0 100644
--- a/dev-app/src/main/java/com/uid2/dev/ui/views/EmailInputView.kt
+++ b/dev-app/src/main/java/com/uid2/dev/ui/views/IdentityInputView.kt
@@ -9,50 +9,47 @@ import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextField
-import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.Icons.AutoMirrored.Filled
import androidx.compose.material.icons.automirrored.filled.ArrowForward
-import androidx.compose.material.icons.filled.Email
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
-import com.uid2.devapp.R
@Composable
-fun EmailInputView(modifier: Modifier, onEmailEntered: (String) -> Unit) {
+fun IdentityInputView(modifier: Modifier = Modifier, label: String, icon: ImageVector, onEntered: (String) -> Unit) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
- val emailAddress = remember { mutableStateOf(TextFieldValue()) }
+ val identityData = remember { mutableStateOf(TextFieldValue()) }
TextField(
- value = emailAddress.value,
- onValueChange = { emailAddress.value = it },
- label = { Text(stringResource(R.string.email)) },
+ value = identityData.value,
+ onValueChange = { identityData.value = it },
+ label = { Text(label) },
singleLine = true,
modifier = Modifier.weight(1f),
leadingIcon = {
Icon(
- imageVector = Icons.Default.Email,
- contentDescription = stringResource(R.string.email_icon_content_description),
+ imageVector = icon,
+ contentDescription = null,
)
},
)
FloatingActionButton(
- onClick = { onEmailEntered(emailAddress.value.text) },
+ onClick = { onEntered(identityData.value.text) },
shape = CircleShape,
backgroundColor = MaterialTheme.colors.primary,
) {
Icon(
imageVector = Filled.ArrowForward,
- contentDescription = stringResource(R.string.email_submit_content_description),
+ contentDescription = null,
tint = Color.White,
)
}
diff --git a/dev-app/src/main/java/com/uid2/dev/ui/views/UserIdentityView.kt b/dev-app/src/main/java/com/uid2/dev/ui/views/UserIdentityView.kt
index 03e654e..d584ec9 100644
--- a/dev-app/src/main/java/com/uid2/dev/ui/views/UserIdentityView.kt
+++ b/dev-app/src/main/java/com/uid2/dev/ui/views/UserIdentityView.kt
@@ -32,6 +32,13 @@ fun UserIdentityView(modifier: Modifier, identity: UID2Identity?, status: Identi
.padding(0.dp, 10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
+ Text(
+ modifier = Modifier.padding(bottom = 10.dp),
+ text = stringResource(id = R.string.current_identity),
+ fontSize = 28.sp,
+ fontWeight = FontWeight.Bold,
+ )
+
identity?.let {
UserIdentityParameter(stringResource(R.string.identity_advertising_token), identity.advertisingToken)
UserIdentityParameter(stringResource(R.string.identity_refresh_token), identity.refreshToken)
diff --git a/dev-app/src/main/java/com/uid2/dev/utils/ByteArrayEx.kt b/dev-app/src/main/java/com/uid2/dev/utils/ByteArrayEx.kt
new file mode 100644
index 0000000..c413c40
--- /dev/null
+++ b/dev-app/src/main/java/com/uid2/dev/utils/ByteArrayEx.kt
@@ -0,0 +1,9 @@
+package com.uid2.dev.utils
+
+import android.util.Base64
+
+/**
+ * Extension method to encode a ByteArray to a String. This uses the android.util version of Base64 to keep our minimum
+ * SDK low.
+ */
+fun ByteArray.encodeBase64(): String = Base64.encodeToString(this, Base64.NO_WRAP)
diff --git a/dev-app/src/main/res/values/strings.xml b/dev-app/src/main/res/values/strings.xml
index 0c30b50..71d8e9b 100644
--- a/dev-app/src/main/res/values/strings.xml
+++ b/dev-app/src/main/res/values/strings.xml
@@ -2,8 +2,10 @@
UID2 SDK Dev App
Email
- Email Icon
- Submit Email
+ Phone Number
+ Client Side
+
+ Current Identity
Advertising Token
Refresh Token
@@ -21,4 +23,7 @@
Refresh Expired
OptOut
+ Reset
+ Manual Refresh
+
diff --git a/sdk/src/main/java/com/uid2/UID2Client.kt b/sdk/src/main/java/com/uid2/UID2Client.kt
index 530c1e7..d96a792 100644
--- a/sdk/src/main/java/com/uid2/UID2Client.kt
+++ b/sdk/src/main/java/com/uid2/UID2Client.kt
@@ -1,12 +1,17 @@
package com.uid2
+import com.uid2.data.IdentityRequest
+import com.uid2.data.toPayload
+import com.uid2.extensions.encodeBase64
import com.uid2.network.DataEnvelope
import com.uid2.network.NetworkRequest
import com.uid2.network.NetworkRequestType
import com.uid2.network.NetworkSession
-import com.uid2.network.RefreshPackage
import com.uid2.network.RefreshResponse
+import com.uid2.network.ResponsePackage
+import com.uid2.utils.KeyUtils
import com.uid2.utils.Logger
+import com.uid2.utils.TimeUtils
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -22,31 +27,115 @@ import java.net.URL
internal class UID2Client(
private val apiUrl: String,
private val session: NetworkSession,
+ private val applicationId: String,
+ private val dataEnvelope: DataEnvelope = DataEnvelope,
+ private val timeUtils: TimeUtils = TimeUtils,
+ private val keyUtils: KeyUtils = KeyUtils,
private val logger: Logger = Logger(),
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
+ private val paramsFactory: (Map) -> String = { params ->
+ JSONObject(params).toString()
+ },
) {
- // The refresh endpoint is built from the given API root, along with our known refresh path appended. If the
- // consumer has incorrectly configured the SDK, it's possible this could be null.
- private val apiRefreshUrl: URL? by lazy {
- runCatching {
- URL(
- URI(apiUrl)
- .resolve(API_REFRESH_PATH)
- .toString(),
- )
- }.getOrNull()
- }
+ // The endpoints are built from the given API root, along with our known path appended. If the consumer has
+ // incorrectly configured the SDK, it's possible this could be null.
+ private val apiGenerateUrl: URL? by lazy { getApiUrl(API_GENERATE_PATH) }
+ private val apiRefreshUrl: URL? by lazy { getApiUrl(API_REFRESH_PATH) }
// We expect the Client to report a Version that is in the following format: Android-X.Y.Z
private val clientVersion: String by lazy { "Android-${UID2.getVersion()}" }
@Throws(
InvalidApiUrlException::class,
- RefreshTokenException::class,
+ CryptoException::class,
+ RequestFailureException::class,
PayloadDecryptException::class,
InvalidPayloadException::class,
)
- suspend fun refreshIdentity(refreshToken: String, refreshResponseKey: String): RefreshPackage =
+ suspend fun generateIdentity(
+ identityRequest: IdentityRequest,
+ subscriptionId: String,
+ publicKey: String,
+ ): ResponsePackage = withContext(ioDispatcher) {
+ logger.i(TAG) { "Generating Identity" }
+
+ // Check to make sure we have a valid endpoint to hit.
+ val url = apiGenerateUrl ?: run {
+ logger.e(TAG) { "Error determining identity generation API" }
+ throw InvalidApiUrlException()
+ }
+
+ // Generate the required Server and Client keys.
+ val serverPublicKey = keyUtils.generateServerPublicKey(publicKey)
+ val clientKeyPair = keyUtils.generateKeyPair()
+ if (serverPublicKey == null || clientKeyPair == null) {
+ logger.e(TAG) { "Error generating server and client keys" }
+ throw CryptoException()
+ }
+
+ // Generate our temporary shared secret.
+ val sharedSecret = keyUtils.generateSharedSecret(serverPublicKey, clientKeyPair) ?: run {
+ logger.e(TAG) { "Error generating temporary shared secret" }
+ throw CryptoException()
+ }
+
+ val iv = keyUtils.generateIv(IV_LENGTH_BYTES)
+ val now = timeUtils.now()
+ val aad = keyUtils.generateAad(now, applicationId)
+
+ // Build and encrypt the payload containing the identity generation request.
+ val payload = identityRequest.toPayload()
+ val encryptedPayload = dataEnvelope.encrypt(sharedSecret, payload, iv, aad.toByteArray()) ?: run {
+ logger.e(TAG) { "Error encrypting payload" }
+ throw CryptoException()
+ }
+
+ // Build the request to generate the token.
+ val request = NetworkRequest(
+ NetworkRequestType.POST,
+ mapOf(
+ HEADER_CLIENT_VERSION to clientVersion,
+ ),
+ paramsFactory(
+ mapOf(
+ "payload" to encryptedPayload.encodeBase64(),
+ "iv" to iv.encodeBase64(),
+ "public_key" to clientKeyPair.public.encoded.encodeBase64(),
+ "timestamp" to now.toString(),
+ "subscription_id" to subscriptionId,
+ "app_name" to applicationId,
+ ),
+ ),
+ )
+
+ // Attempt to make the request via the provided NetworkSession.
+ val response = session.loadData(url, request)
+ if (response.code != HttpURLConnection.HTTP_OK) {
+ logger.e(TAG) { "Client details failure: ${response.code}" }
+ throw RequestFailureException(response.code)
+ }
+
+ // The response should be an encrypted payload. Let's attempt to decrypt it using the key we were provided.
+ val envelope = dataEnvelope.decrypt(sharedSecret.encoded, response.data, false) ?: run {
+ logger.e(TAG) { "Error decrypting response from client details" }
+ throw PayloadDecryptException()
+ }
+
+ // The decrypted payload should be JSON which we can parse.
+ val generateResponse = RefreshResponse.fromJson(JSONObject(String(envelope, Charsets.UTF_8)))
+ return@withContext generateResponse?.toResponsePackage(false) ?: run {
+ logger.e(TAG) { "Error parsing response from client details" }
+ throw InvalidPayloadException()
+ }
+ }
+
+ @Throws(
+ InvalidApiUrlException::class,
+ RequestFailureException::class,
+ PayloadDecryptException::class,
+ InvalidPayloadException::class,
+ )
+ suspend fun refreshIdentity(refreshToken: String, refreshResponseKey: String): ResponsePackage =
withContext(ioDispatcher) {
logger.i(TAG) { "Refreshing identity" }
@@ -60,7 +149,7 @@ internal class UID2Client(
val request = NetworkRequest(
NetworkRequestType.POST,
mapOf(
- "X-UID2-Client-Version" to clientVersion,
+ HEADER_CLIENT_VERSION to clientVersion,
"Content-Type" to "application/x-www-form-urlencoded",
),
refreshToken,
@@ -70,27 +159,49 @@ internal class UID2Client(
val response = session.loadData(url, request)
if (response.code != HttpURLConnection.HTTP_OK) {
logger.e(TAG) { "Client details failure: ${response.code}" }
- throw RefreshTokenException(response.code)
+ throw RequestFailureException(response.code)
}
// The response should be an encrypted payload. Let's attempt to decrypt it using the key we were provided.
- val payload = DataEnvelope.decrypt(refreshResponseKey, response.data, true) ?: run {
+ val payload = dataEnvelope.decrypt(refreshResponseKey, response.data, false) ?: run {
logger.e(TAG) { "Error decrypting response from client details" }
throw PayloadDecryptException()
}
// The decrypted payload should be JSON which we can parse.
val refreshResponse = RefreshResponse.fromJson(JSONObject(String(payload, Charsets.UTF_8)))
- return@withContext refreshResponse?.toRefreshPackage() ?: run {
+ return@withContext refreshResponse?.toResponsePackage(true) ?: run {
logger.e(TAG) { "Error parsing response from client details" }
throw InvalidPayloadException()
}
}
+ /**
+ * Builds a [URL] for the configured API server with the given (relative) path.
+ */
+ private fun getApiUrl(path: String): URL? {
+ return runCatching {
+ URL(
+ URI(apiUrl)
+ .resolve(path)
+ .toString(),
+ )
+ }.getOrNull()
+ }
+
private companion object {
const val TAG = "UID2Client"
+ // The relative path of the API's generate endpoint
+ const val API_GENERATE_PATH = "/v2/token/client-generate"
+
// The relative path of the API's refresh endpoint
const val API_REFRESH_PATH = "/v2/token/refresh"
+
+ // The header used to provide the client version.
+ const val HEADER_CLIENT_VERSION = "X-UID2-Client-Version"
+
+ // The length, in bytes, of the IV used when generating an identity.
+ const val IV_LENGTH_BYTES = 12
}
}
diff --git a/sdk/src/main/java/com/uid2/UID2Exception.kt b/sdk/src/main/java/com/uid2/UID2Exception.kt
index b9312bc..946dbcd 100644
--- a/sdk/src/main/java/com/uid2/UID2Exception.kt
+++ b/sdk/src/main/java/com/uid2/UID2Exception.kt
@@ -3,7 +3,7 @@ package com.uid2
/**
* Base class for all custom exceptions reported by the UID2 SDK.
*/
-internal open class UID2Exception(message: String? = null, cause: Throwable? = null) : Exception(message, cause)
+public open class UID2Exception(message: String? = null, cause: Throwable? = null) : Exception(message, cause)
/**
* The SDK has been initialized *after* it's been created.
@@ -16,9 +16,19 @@ internal class InitializationException(message: String? = null) : UID2Exception(
internal class InvalidApiUrlException : UID2Exception()
/**
- * The attempt to refresh the token/identity via the API failed.
+ * An attempt to generate one of the required keys (for token generation) failed.
*/
-internal class RefreshTokenException(val statusCode: Int) : UID2Exception()
+internal class CryptoException : UID2Exception()
+
+/**
+ * The given input appears to be invalid.
+ */
+public class InputValidationException(description: String?) : UID2Exception(description)
+
+/**
+ * An attempt was made to the API that resulted in a failure.
+ */
+internal class RequestFailureException(val statusCode: Int) : UID2Exception()
/**
* The encrypted payload could not be decrypted successfully.
diff --git a/sdk/src/main/java/com/uid2/UID2Manager.kt b/sdk/src/main/java/com/uid2/UID2Manager.kt
index 1f30f4a..4f2b7fd 100644
--- a/sdk/src/main/java/com/uid2/UID2Manager.kt
+++ b/sdk/src/main/java/com/uid2/UID2Manager.kt
@@ -10,6 +10,9 @@ import com.uid2.UID2ManagerState.OptOut
import com.uid2.UID2ManagerState.RefreshExpired
import com.uid2.UID2ManagerState.Refreshed
import com.uid2.data.IdentityPackage
+import com.uid2.data.IdentityRequest
+import com.uid2.data.IdentityRequest.Email
+import com.uid2.data.IdentityRequest.Phone
import com.uid2.data.IdentityStatus
import com.uid2.data.IdentityStatus.ESTABLISHED
import com.uid2.data.IdentityStatus.EXPIRED
@@ -22,6 +25,7 @@ import com.uid2.data.UID2Identity
import com.uid2.network.DefaultNetworkSession
import com.uid2.network.NetworkSession
import com.uid2.storage.StorageManager
+import com.uid2.utils.InputUtils
import com.uid2.utils.Logger
import com.uid2.utils.TimeUtils
import kotlinx.coroutines.CoroutineDispatcher
@@ -83,6 +87,7 @@ public class UID2Manager internal constructor(
private val client: UID2Client,
private val storageManager: StorageManager,
private val timeUtils: TimeUtils,
+ private val inputUtils: InputUtils,
defaultDispatcher: CoroutineDispatcher,
initialAutomaticRefreshEnabled: Boolean,
private val logger: Logger,
@@ -142,8 +147,8 @@ public class UID2Manager internal constructor(
}
/**
- * Gets whether or not [UID2Manager] has a known [UID2Identity]. If not, a new identity should be generated and set
- * via [setIdentity].
+ * Gets whether or not [UID2Manager] has a known [UID2Identity]. If not, a new identity should be generated either
+ * via [generateIdentity] or generated externally and set via [setIdentity].
*/
public fun hasIdentity(): Boolean = currentIdentity != null
@@ -188,6 +193,74 @@ public class UID2Manager internal constructor(
}
}
+ /**
+ * Represents the result of a request to [generateIdentity].
+ */
+ public sealed class GenerateIdentityResult {
+
+ /**
+ * The identity was generated successfully and the [UID2Manager] as updated.
+ */
+ public data object Success : GenerateIdentityResult()
+
+ /**
+ * The generation of the identity failed.
+ *
+ * @param ex The exception which caused the request to fail.
+ */
+ public data class Error(public val ex: UID2Exception) : GenerateIdentityResult()
+ }
+
+ /**
+ * Generates a new identity.
+ *
+ * Once set, assuming it's valid, it will be monitored so that we automatically refresh the token(s) when required.
+ * This will also be persisted locally, so that when the application re-launches, we reload this Identity.
+ *
+ * @param identityRequest The identify for which the [UID2Identity] is required for.
+ * @param subscriptionId The subscription id that was obtained when configuring your account.
+ * @param publicKey The public key that was obtained when configuring your account.
+ *
+ * @throws InputValidationException Thrown if the given [IdentityRequest] is not valid. For a
+ * [IdentityRequest.Phone] we expect the given number to conform to the ITU E.164 Standard
+ * (https://en.wikipedia.org/wiki/E.164).
+ */
+ @Throws(InputValidationException::class)
+ public fun generateIdentity(
+ identityRequest: IdentityRequest,
+ subscriptionId: String,
+ publicKey: String,
+ onResult: (GenerateIdentityResult) -> Unit,
+ ): Unit = afterInitialized {
+ // Normalize any given input to validate it.
+ val request = when (identityRequest) {
+ is Email -> inputUtils.normalize(identityRequest)
+ is Phone -> inputUtils.normalize(identityRequest)
+ else -> identityRequest
+ }
+
+ scope.launch {
+ try {
+ // Attempt to generate the new identity.
+ val identity = client.generateIdentity(request, subscriptionId, publicKey)
+
+ // Cancel any in-flight refresh job that could be processing a previously set identity.
+ refreshJob?.cancel()
+ refreshJob = null
+
+ // Update our identity.
+ validateAndSetIdentity(identity.identity, identity.status)
+
+ // Report our result.
+ onResult(GenerateIdentityResult.Success)
+ } catch (ex: UID2Exception) {
+ // The identity generation failed, so we will not modify our current state and report this to the
+ // caller.
+ onResult(GenerateIdentityResult.Error(ex))
+ }
+ }
+ }
+
/**
* Sets the current Identity.
*
@@ -447,6 +520,8 @@ public class UID2Manager internal constructor(
// The default API server.
private const val UID2_API_URL_DEFAULT = "https://prod.uidapi.com"
+ private const val APPLICATION_ID_DEFAULT = "unknown"
+
private const val PACKAGE_NOT_AVAILABLE = "Identity not available"
private const val PACKAGE_AD_TOKEN_NOT_AVAILABLE = "advertising_token is not available or is not valid"
private const val PACKAGE_REFRESH_TOKEN_NOT_AVAILABLE = "refresh_token is not available or is not valid"
@@ -465,6 +540,7 @@ public class UID2Manager internal constructor(
private const val EXPIRATION_CHECK_TOLERANCE_MS = 50
private var serverUrl: String = UID2_API_URL_DEFAULT
+ private var applicationId: String = APPLICATION_ID_DEFAULT
private var networkSession: NetworkSession = DefaultNetworkSession()
private var storageManager: StorageManager? = null
private var isLoggingEnabled: Boolean = false
@@ -494,6 +570,7 @@ public class UID2Manager internal constructor(
}
this.serverUrl = serverUrl
+ this.applicationId = context.packageName
this.networkSession = networkSession
this.storageManager = StorageManager.getInstance(context.applicationContext)
this.isLoggingEnabled = isLoggingEnabled
@@ -517,12 +594,14 @@ public class UID2Manager internal constructor(
return instance ?: UID2Manager(
UID2Client(
- serverUrl,
- networkSession,
- logger,
+ apiUrl = serverUrl,
+ session = networkSession,
+ applicationId = applicationId,
+ logger = logger,
),
storage,
- TimeUtils(),
+ TimeUtils,
+ InputUtils(),
Dispatchers.Default,
true,
logger,
diff --git a/sdk/src/main/java/com/uid2/data/IdentityRequest.kt b/sdk/src/main/java/com/uid2/data/IdentityRequest.kt
new file mode 100644
index 0000000..1304b4a
--- /dev/null
+++ b/sdk/src/main/java/com/uid2/data/IdentityRequest.kt
@@ -0,0 +1,64 @@
+package com.uid2.data
+
+import com.uid2.data.IdentityRequest.Email
+import com.uid2.data.IdentityRequest.EmailHash
+import com.uid2.data.IdentityRequest.Phone
+import com.uid2.data.IdentityRequest.PhoneHash
+import com.uid2.extensions.toSha256
+import org.json.JSONObject
+
+/**
+ * This class represents the different types of identity requests that are supported client side.
+ */
+public sealed class IdentityRequest(internal val data: String) {
+
+ /**
+ * A raw email address.
+ */
+ public data class Email(private var email: String) : IdentityRequest(email)
+
+ /**
+ * A SHA-256 hashed email address.
+ */
+ public data class EmailHash(private var hash: String) : IdentityRequest(hash)
+
+ /**
+ * A raw telephone number.
+ */
+ public data class Phone(private val phone: String) : IdentityRequest(phone)
+
+ /**
+ * A SHA-256 hashed telephone number.
+ */
+ public data class PhoneHash(private var hash: String) : IdentityRequest(hash)
+}
+
+/**
+ * Extension method to convert the associated [IdentityRequest] into the expected payload.
+ *
+ * The payload should only contained a SHA-256 hashed representation of the data. If a raw email or telephone number has
+ * been provided, then we will has it ourselves when building the payload.
+ */
+internal fun IdentityRequest.toPayload(): String {
+ val payloadKey = when (this) {
+ is Email, is EmailHash -> PARAM_EMAIL_HASH
+ is Phone, is PhoneHash -> PARAM_PHONE_HASH
+ }
+
+ val payloadValue = when (this) {
+ is Email -> data.toSha256()
+ is Phone -> data.toSha256()
+ else -> data
+ }
+
+ return JSONObject().apply {
+ put(payloadKey, payloadValue)
+
+ // If the identity has opted out, we will get an opt-out response.
+ put(PARAM_OPT_OUT_CHECK, 1)
+ }.toString()
+}
+
+private const val PARAM_EMAIL_HASH = "email_hash"
+private const val PARAM_PHONE_HASH = "phone_hash"
+private const val PARAM_OPT_OUT_CHECK = "optout_check"
diff --git a/sdk/src/main/java/com/uid2/extensions/ByteArrayEx.kt b/sdk/src/main/java/com/uid2/extensions/ByteArrayEx.kt
new file mode 100644
index 0000000..e8cb0b8
--- /dev/null
+++ b/sdk/src/main/java/com/uid2/extensions/ByteArrayEx.kt
@@ -0,0 +1,9 @@
+package com.uid2.extensions
+
+import android.util.Base64
+
+/**
+ * Extension method to encode a ByteArray to a String. This uses the android.util version of Base64 to keep our minimum
+ * SDK low.
+ */
+internal fun ByteArray.encodeBase64(): String = Base64.encodeToString(this, Base64.NO_WRAP)
diff --git a/sdk/src/main/java/com/uid2/extensions/StringEx.kt b/sdk/src/main/java/com/uid2/extensions/StringEx.kt
index 8dd6dc9..53fa76b 100644
--- a/sdk/src/main/java/com/uid2/extensions/StringEx.kt
+++ b/sdk/src/main/java/com/uid2/extensions/StringEx.kt
@@ -2,11 +2,12 @@ package com.uid2.extensions
import android.util.Base64
import org.json.JSONObject
+import java.security.MessageDigest
/**
* Extension method to decode a String base Base64.
*/
-internal fun String.decodeBase64(): ByteArray? = runCatching { Base64.decode(this, Base64.DEFAULT) }.getOrNull()
+internal fun String.decodeBase64(): ByteArray? = runCatching { Base64.decode(this, Base64.NO_WRAP) }.getOrNull()
/**
* Extension to parse a given String as JSON and convert to a Map. If parsing fails, e.g. the JSON
@@ -19,3 +20,13 @@ internal fun String.decodeJsonToMap(): Map? {
it to json.get(it)
}.toMap()
}
+
+/**
+ * Extension method to hash a string (via SHA-256) and return the Base64 representation of it.
+ */
+internal fun String.toSha256(): String {
+ return MessageDigest
+ .getInstance("SHA-256")
+ .digest(toByteArray())
+ .encodeBase64()
+}
diff --git a/sdk/src/main/java/com/uid2/network/DataEnvelope.kt b/sdk/src/main/java/com/uid2/network/DataEnvelope.kt
index 1163d01..d0c5d1c 100644
--- a/sdk/src/main/java/com/uid2/network/DataEnvelope.kt
+++ b/sdk/src/main/java/com/uid2/network/DataEnvelope.kt
@@ -2,6 +2,7 @@ package com.uid2.network
import com.uid2.extensions.decodeBase64
import javax.crypto.Cipher
+import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
@@ -12,21 +13,17 @@ import javax.crypto.spec.SecretKeySpec
* **See Also:**
* [GitHub](https://github.com/IABTechLab/uid2docs/blob/main/api/v2/getting-started/gs-encryption-decryption.md)
*/
-public object DataEnvelope {
- // The name and transformation of the encryption algorithm used.
- private const val ALGORITHM_NAME = "AES"
- private const val ALGORITHM_TRANSFORMATION = "AES/GCM/NoPadding"
+public interface DataEnvelope {
- // The length of the authentication tag, in bits.
- private const val AUTHENTICATION_TAG_LENGTH_BITS = 128
-
- // The length of the IV, in bytes.
- private const val IV_LENGTH_BYTES = 12
-
- // The number of bytes expected in the decoded payload that represents the timestamp and nonce (used in the original
- // request.)
- private const val PAYLOAD_TIMESTAMP_LENGTH_BYTES = 8
- private const val PAYLOAD_NONCE_LENGTH_BYTES = 8
+ /**
+ * Encrypts the given data with the provided key.
+ *
+ * @param key The key, as represented by a [SecretKey], required to encrypt the given data.
+ * @param data The data to encrypt.
+ * @param iv The initialization vector.
+ * @param aad Any additional authentication data.
+ */
+ public fun encrypt(key: SecretKey, data: String, iv: ByteArray, aad: ByteArray): ByteArray?
/**
* Decrypts the given data with the provided key.
@@ -36,37 +33,90 @@ public object DataEnvelope {
*
* @param key The key, in Base64 format, required to decode the given data.
* @param data The data, in Base64 format, that needs to be decoded.
+ * @param includesNonce If a nonce (and timestamp) is expected in the decrypted data.
+ * @return The unencrypted data. If this decryption fails, null is returned.
+ */
+ public fun decrypt(key: String, data: String, includesNonce: Boolean): ByteArray?
+
+ /**
+ * Decrypts the given data with the provided key.
+ *
+ * This relies on the format of the data matching that spec-ed in the API documentation. We assume that it's AES
+ * encrypted, and includes the IV in the first 12 bytes of the buffer.
+ *
+ * @param key The key, in bytes, required to decode the given data.
+ * @param data The data, in Base64 format, that needs to be decoded.
+ * @param includesNonce If a nonce (and timestamp) is expected in the decrypted data.
* @return The unencrypted data. If this decryption fails, null is returned.
*/
- public fun decrypt(key: String, data: String, isRefresh: Boolean): ByteArray? {
- // Attempt to decrypt the given data with the provided key. Both the key and data are expected to be in Base64
- // format. If this fails, then null will be returned.
- var payload = decryptWithCipher(key.decodeBase64(), data.decodeBase64()) ?: return null
-
- // If we are not refreshing, we expect the decoded payload to include both Timestamp and Nonce values.
- if (!isRefresh) {
- payload = payload.copyOfRange(
- PAYLOAD_TIMESTAMP_LENGTH_BYTES + PAYLOAD_NONCE_LENGTH_BYTES,
- payload.size,
- )
+ public fun decrypt(key: ByteArray?, data: String, includesNonce: Boolean): ByteArray?
+
+ public companion object Default : DataEnvelope {
+ override fun encrypt(key: SecretKey, data: String, iv: ByteArray, aad: ByteArray): ByteArray? {
+ return encryptWithCipher(key, data.toByteArray(), iv, aad)
}
- return payload
- }
+ override fun decrypt(key: String, data: String, includesNonce: Boolean): ByteArray? {
+ return decrypt(key.decodeBase64(), data, includesNonce)
+ }
+
+ override fun decrypt(key: ByteArray?, data: String, includesNonce: Boolean): ByteArray? {
+ // Attempt to decrypt the given data with the provided key. Both the key and data are expected to be in Base64
+ // format. If this fails, then null will be returned.
+ var payload = decryptWithCipher(key, data.decodeBase64()) ?: return null
+
+ // If a nonce (and timestamp) is included in the payload, we should remove them.
+ if (includesNonce) {
+ payload = payload.copyOfRange(
+ PAYLOAD_TIMESTAMP_LENGTH_BYTES + PAYLOAD_NONCE_LENGTH_BYTES,
+ payload.size,
+ )
+ }
+
+ return payload
+ }
+
+ private fun encryptWithCipher(key: SecretKey, data: ByteArray, iv: ByteArray, aad: ByteArray): ByteArray? {
+ val spec = GCMParameterSpec(AUTHENTICATION_TAG_LENGTH_BITS, iv)
+
+ // Initialise the appropriate AES Cipher.
+ val cipher = Cipher.getInstance(ALGORITHM_TRANSFORMATION)?.apply {
+ init(Cipher.ENCRYPT_MODE, key, spec)
+ updateAAD(aad)
+ } ?: return null
+
+ return cipher.doFinal(data)
+ }
+
+ private fun decryptWithCipher(key: ByteArray?, data: ByteArray?): ByteArray? {
+ key ?: return null
+ data ?: return null
+
+ val secret = SecretKeySpec(key, ALGORITHM_NAME)
+ val spec = GCMParameterSpec(AUTHENTICATION_TAG_LENGTH_BITS, data, 0, IV_LENGTH_BYTES)
+
+ // Initialise the appropriate AES Cipher.
+ val cipher = Cipher.getInstance(ALGORITHM_TRANSFORMATION)?.apply {
+ init(Cipher.DECRYPT_MODE, secret, spec)
+ } ?: return null
+
+ // Decrypt the data, skipping the first 12 bytes since that contains our IV.
+ return cipher.doFinal(data, IV_LENGTH_BYTES, data.size - IV_LENGTH_BYTES)
+ }
- private fun decryptWithCipher(key: ByteArray?, data: ByteArray?): ByteArray? {
- key ?: return null
- data ?: return null
+ // The name and transformation of the encryption algorithm used.
+ private const val ALGORITHM_NAME = "AES"
+ private const val ALGORITHM_TRANSFORMATION = "AES/GCM/NoPadding"
- val secret = SecretKeySpec(key, ALGORITHM_NAME)
- val spec = GCMParameterSpec(AUTHENTICATION_TAG_LENGTH_BITS, data, 0, IV_LENGTH_BYTES)
+ // The length of the authentication tag, in bits.
+ private const val AUTHENTICATION_TAG_LENGTH_BITS = 128
- // Initialise the appropriate AES Cipher.
- val cipher = Cipher.getInstance(ALGORITHM_TRANSFORMATION)?.apply {
- init(Cipher.DECRYPT_MODE, secret, spec)
- } ?: return null
+ // The length of the IV, in bytes.
+ private const val IV_LENGTH_BYTES = 12
- // Decrypt the data, skipping the first 12 bytes since that contains our IV.
- return cipher.doFinal(data, IV_LENGTH_BYTES, data.size - IV_LENGTH_BYTES)
+ // The number of bytes expected in the decoded payload that represents the timestamp and nonce (used in the
+ // original request.)
+ private const val PAYLOAD_TIMESTAMP_LENGTH_BYTES = 8
+ private const val PAYLOAD_NONCE_LENGTH_BYTES = 8
}
}
diff --git a/sdk/src/main/java/com/uid2/network/RefreshResponse.kt b/sdk/src/main/java/com/uid2/network/RefreshResponse.kt
index 5c94d4d..9fe9264 100644
--- a/sdk/src/main/java/com/uid2/network/RefreshResponse.kt
+++ b/sdk/src/main/java/com/uid2/network/RefreshResponse.kt
@@ -1,5 +1,6 @@
package com.uid2.network
+import com.uid2.data.IdentityStatus.ESTABLISHED
import com.uid2.data.IdentityStatus.OPT_OUT
import com.uid2.data.IdentityStatus.REFRESHED
import com.uid2.data.IdentityStatus.REFRESH_EXPIRED
@@ -12,7 +13,7 @@ import org.json.JSONObject
* This class defines the expected response from the Identity API when refreshing. The results could include a new
* (refreshed) Identity, or represent a failure/error.
*
- * https://github.com/IABTechLab/uid2docs/blob/main/api/v2/endpoints/post-token-refresh.md#decrypted-json-response-format
+ * https://unifiedid.com/docs/endpoints/post-token-refresh
*/
internal data class RefreshResponse(
val body: UID2Identity?,
@@ -32,17 +33,23 @@ internal data class RefreshResponse(
;
companion object {
- fun forStatus(status: String) = Status.values().first { it.text == status }
+ fun forStatus(status: String) = entries.first { it.text == status }
}
}
/**
- * Converts the response into the equivalent RefreshPackage.
+ * Converts the response into the equivalent [ResponsePackage].
*/
- fun toRefreshPackage(): RefreshPackage? = when (status) {
- SUCCESS -> RefreshPackage(body, REFRESHED, "Identity refreshed")
- Status.OPT_OUT -> RefreshPackage(null, OPT_OUT, "User opt out")
- EXPIRED_TOKEN -> RefreshPackage(null, REFRESH_EXPIRED, "Refresh token expired")
+ fun toResponsePackage(isRefresh: Boolean): ResponsePackage? = when (status) {
+ SUCCESS -> {
+ if (isRefresh) {
+ ResponsePackage(body, REFRESHED, "Identity refreshed")
+ } else {
+ ResponsePackage(body, ESTABLISHED, "Identity established")
+ }
+ }
+ Status.OPT_OUT -> ResponsePackage(null, OPT_OUT, "User opt out")
+ EXPIRED_TOKEN -> ResponsePackage(null, REFRESH_EXPIRED, "Refresh token expired")
else -> null
}
diff --git a/sdk/src/main/java/com/uid2/network/RefreshPackage.kt b/sdk/src/main/java/com/uid2/network/ResponsePackage.kt
similarity index 63%
rename from sdk/src/main/java/com/uid2/network/RefreshPackage.kt
rename to sdk/src/main/java/com/uid2/network/ResponsePackage.kt
index c24fc56..a8a9608 100644
--- a/sdk/src/main/java/com/uid2/network/RefreshPackage.kt
+++ b/sdk/src/main/java/com/uid2/network/ResponsePackage.kt
@@ -4,9 +4,9 @@ import com.uid2.data.IdentityStatus
import com.uid2.data.UID2Identity
/**
- * The data available after attempting to refresh the Identity.
+ * The data available after attempting to generate or refresh the Identity.
*/
-internal data class RefreshPackage(
+internal data class ResponsePackage(
val identity: UID2Identity?,
val status: IdentityStatus,
val message: String,
diff --git a/sdk/src/main/java/com/uid2/utils/InputUtils.kt b/sdk/src/main/java/com/uid2/utils/InputUtils.kt
new file mode 100644
index 0000000..2b975d3
--- /dev/null
+++ b/sdk/src/main/java/com/uid2/utils/InputUtils.kt
@@ -0,0 +1,163 @@
+package com.uid2.utils
+
+import com.uid2.InputValidationException
+import com.uid2.data.IdentityRequest
+import com.uid2.utils.InputUtils.EmailParsingState.Starting
+import com.uid2.utils.InputUtils.EmailParsingState.SubDomain
+import java.util.Locale
+
+/**
+ * This class contains a set of methods that will attempt to normalize a given [IdentityRequest]. This is to validate
+ * what is provided to us to ensure it's in the expected format, before we attempt to consume it.
+ */
+internal class InputUtils {
+
+ /**
+ * Attempts to normalize the given [IdentityRequest.Phone]
+ *
+ * @throws InputValidationException Thrown if the input could not be normalized.
+ */
+ @Throws(InputValidationException::class)
+ fun normalize(request: IdentityRequest.Phone): IdentityRequest.Phone {
+ if (!isNormalized(request)) {
+ throw InputValidationException("Phone number is not normalized to ITU E.164 standard")
+ }
+
+ return request
+ }
+
+ /**
+ * Attempts to normalize the given [IdentityRequest.Email]
+ *
+ * @throws InputValidationException Thrown if the input could not be normalized.
+ */
+ @Throws(InputValidationException::class)
+ fun normalize(request: IdentityRequest.Email): IdentityRequest.Email {
+ val normalized = normalizeEmail(request.data)
+ ?: throw InputValidationException("Invalid email address detected")
+
+ return IdentityRequest.Email(normalized)
+ }
+
+ /**
+ * Returns whether or not the given [IdentityRequest.Phone] has already been normalized. This will check against
+ * the ITU E.164 Standard (https://en.wikipedia.org/wiki/E.164).
+ */
+ private fun isNormalized(request: IdentityRequest.Phone): Boolean {
+ val number = request.data
+
+ // Firstly, let's check to make sure we have a non-empty string
+ if (number.isEmpty()) {
+ return false
+ }
+
+ // The first character should be a '+'.
+ if (number[0] != '+') {
+ return false
+ }
+
+ // Check to make sure that only digits are contained.
+ val allDigits = number.substring(1).all { isAsciiDigit(it) }
+ if (!allDigits) {
+ return false
+ }
+
+ // The number of digits (excluding the '+') should be in the expected range.
+ val totalDigits = number.length - 1
+ return !(totalDigits < MIN_PHONE_NUMBER_DIGITS || totalDigits> MAX_PHONE_NUMBER_DIGITS)
+ }
+
+ /**
+ * Returns whether or not the given [String] contains only ASCII digits.
+ */
+ private fun isAsciiDigit(digit: Char): Boolean {
+ return digit in '0'..'9'
+ }
+
+ private enum class EmailParsingState {
+ Starting,
+ SubDomain,
+ }
+
+ /**
+ * This code will attempt to normalize a given email address. It's been translated from a Java reference, and
+ * therefore been kept as close to the original implementation as possible (with some unused conditions removed).
+ *
+ * https://github.com/IABTechLab/uid2-operator/blob/a331b88bcb1d7a1a9f0128a7ca0ff4b1de6f0779/src/main/java/com/uid2/operator/service/InputUtil.java#L96
+ */
+ private fun normalizeEmail(email: String): String? {
+ val preSubDomain = StringBuilder()
+ val preSubDomainSpecialized = StringBuilder()
+ val subDomain = StringBuilder()
+ val subDomainWhiteSpace = StringBuilder()
+
+ var parsingState = Starting
+ var inExtension = false
+
+ // Let's start by converting the given address to lower case, before iterating over the individual characters.
+ val lower = email.lowercase(Locale.getDefault())
+ lower.forEach { char ->
+ when (parsingState) {
+ Starting -> {
+ if (char == ' ') {
+ return@forEach
+ } else if (char == '@') {
+ parsingState = SubDomain
+ } else if (char == '.') {
+ preSubDomain.append(char)
+ } else if (char == '+') {
+ preSubDomain.append(char)
+ inExtension = true
+ } else {
+ preSubDomain.append(char)
+ if (!inExtension) {
+ preSubDomainSpecialized.append(char)
+ }
+ }
+ }
+
+ SubDomain -> {
+ if (char == '@') {
+ return null
+ } else if (char == ' ') {
+ subDomainWhiteSpace.append(char)
+ return@forEach
+ }
+
+ if (subDomainWhiteSpace.isNotEmpty()) {
+ subDomain.append(subDomainWhiteSpace.toString())
+ subDomainWhiteSpace.clear()
+ }
+
+ subDomain.append(char)
+ }
+ }
+ }
+
+ // Verify that we've parsed the subdomain correctly.
+ if (subDomain.isEmpty()) {
+ return null
+ }
+
+ // Verify that we've parsed the address part correctly.
+ val addressPartToUse = if (DOMAIN_GMAIL == subDomain.toString()) {
+ preSubDomainSpecialized
+ } else {
+ preSubDomain
+ }
+
+ if (addressPartToUse.isEmpty()) {
+ return null
+ }
+
+ // Build the normalized version of the email address.
+ return addressPartToUse.append('@').append(subDomain.toString()).toString()
+ }
+
+ private companion object {
+ const val MIN_PHONE_NUMBER_DIGITS = 10
+ const val MAX_PHONE_NUMBER_DIGITS = 15
+
+ const val DOMAIN_GMAIL = "gmail.com"
+ }
+}
diff --git a/sdk/src/main/java/com/uid2/utils/KeyUtils.kt b/sdk/src/main/java/com/uid2/utils/KeyUtils.kt
new file mode 100644
index 0000000..fc8cd03
--- /dev/null
+++ b/sdk/src/main/java/com/uid2/utils/KeyUtils.kt
@@ -0,0 +1,92 @@
+package com.uid2.utils
+
+import com.uid2.extensions.decodeBase64
+import org.json.JSONArray
+import java.security.KeyFactory
+import java.security.KeyPair
+import java.security.KeyPairGenerator
+import java.security.PublicKey
+import java.security.SecureRandom
+import java.security.spec.ECGenParameterSpec
+import java.security.spec.X509EncodedKeySpec
+import javax.crypto.KeyAgreement
+import javax.crypto.SecretKey
+import javax.crypto.spec.SecretKeySpec
+
+/**
+ * An class containing utility methods for generating Security based keys, e.g. [PublicKey], [KeyPair] and [SecretKey].
+ */
+internal interface KeyUtils {
+ /**
+ * Generates the additional authentication data required when generating an identity.
+ */
+ fun generateAad(now: Long, applicationId: String): String
+
+ /**
+ * Generates a new IV of the given length.
+ */
+ fun generateIv(length: Int): ByteArray
+
+ /**
+ * Generates the [PublicKey] that was provided by the UID2 API server.
+ */
+ fun generateServerPublicKey(publicKey: String): PublicKey?
+
+ /**
+ * Generates a new Public/Private [KeyPair].
+ */
+ fun generateKeyPair(): KeyPair?
+
+ /**
+ * For a given [PublicKey] and [KeyPair], generates a [SecretKey].
+ */
+ fun generateSharedSecret(serverPublicKey: PublicKey, clientKeyPair: KeyPair): SecretKey?
+
+ companion object Default : KeyUtils {
+
+ override fun generateAad(now: Long, applicationId: String): String {
+ return JSONArray().apply {
+ put(now)
+ put(applicationId)
+ }.toString()
+ }
+
+ override fun generateIv(length: Int): ByteArray {
+ return ByteArray(length).apply { random.nextBytes(this) }
+ }
+
+ override fun generateServerPublicKey(publicKey: String): PublicKey? {
+ val serverPublicKeyBytes =
+ publicKey.substring(SERVER_PUBLIC_KEY_PREFIX_LENGTH).decodeBase64() ?: return null
+
+ return KeyFactory.getInstance("EC")
+ .generatePublic(
+ X509EncodedKeySpec(
+ serverPublicKeyBytes,
+ ),
+ )
+ }
+
+ override fun generateKeyPair(): KeyPair? {
+ return runCatching {
+ KeyPairGenerator.getInstance("EC").apply {
+ initialize(ECGenParameterSpec("secp256r1"))
+ }.genKeyPair()
+ }.getOrNull()
+ }
+
+ override fun generateSharedSecret(serverPublicKey: PublicKey, clientKeyPair: KeyPair): SecretKey? {
+ return runCatching {
+ val secretKey = KeyAgreement.getInstance("ECDH").apply {
+ init(clientKeyPair.private)
+ doPhase(serverPublicKey, true)
+ }.generateSecret()
+
+ SecretKeySpec(secretKey, "AES")
+ }.getOrNull()
+ }
+
+ private val random: SecureRandom by lazy { SecureRandom() }
+ private const val SERVER_PUBLIC_KEY_PREFIX_LENGTH = 9
+ }
+}
diff --git a/sdk/src/main/java/com/uid2/utils/TimeUtils.kt b/sdk/src/main/java/com/uid2/utils/TimeUtils.kt
index de38416..4d1b695 100644
--- a/sdk/src/main/java/com/uid2/utils/TimeUtils.kt
+++ b/sdk/src/main/java/com/uid2/utils/TimeUtils.kt
@@ -3,15 +3,28 @@ package com.uid2.utils
/**
* A class containing utility methods around the current time.
*/
-internal class TimeUtils {
+internal interface TimeUtils {
+
+ /**
+ * Returns the current epoch time (in milliseconds).
+ */
+ fun now(): Long
/**
* Returns whether or not the given (epoch) time in milliseconds, is in the past.
*/
- fun hasExpired(expiryMs: Long) = expiryMs <= System.currentTimeMillis()
+ fun hasExpired(expiryMs: Long): Boolean
/**
* Returns the number of milliseconds difference between the given time and "now".
*/
- fun diffToNow(fromMs: Long) = fromMs - System.currentTimeMillis()
+ fun diffToNow(fromMs: Long): Long
+
+ companion object Default : TimeUtils {
+ override fun now() = System.currentTimeMillis()
+
+ override fun hasExpired(expiryMs: Long) = expiryMs <= System.currentTimeMillis()
+
+ override fun diffToNow(fromMs: Long) = fromMs - System.currentTimeMillis()
+ }
}
diff --git a/sdk/src/test/java/android/util/Base64.kt b/sdk/src/test/java/android/util/Base64.kt
index 03c84f1..b44dcdc 100644
--- a/sdk/src/test/java/android/util/Base64.kt
+++ b/sdk/src/test/java/android/util/Base64.kt
@@ -13,4 +13,9 @@ object Base64 {
fun decode(str: String?, flags: Int): ByteArray {
return Base64.getDecoder().decode(str)
}
+
+ @JvmStatic
+ fun encodeToString(input: ByteArray, flags: Int): String {
+ return Base64.getEncoder().encodeToString(input)
+ }
}
diff --git a/sdk/src/test/java/com/uid2/UID2ClientTest.kt b/sdk/src/test/java/com/uid2/UID2ClientTest.kt
index 43ab5fa..865ead9 100644
--- a/sdk/src/test/java/com/uid2/UID2ClientTest.kt
+++ b/sdk/src/test/java/com/uid2/UID2ClientTest.kt
@@ -1,96 +1,253 @@
package com.uid2
+import com.uid2.data.IdentityRequest
import com.uid2.data.IdentityStatus
import com.uid2.data.TestData
import com.uid2.data.UID2Identity
+import com.uid2.network.DataEnvelope
import com.uid2.network.NetworkRequest
import com.uid2.network.NetworkResponse
import com.uid2.network.NetworkSession
+import com.uid2.utils.KeyUtils
import com.uid2.utils.Logger
+import com.uid2.utils.TimeUtils
import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestDispatcher
+import kotlinx.coroutines.test.runTest
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
+import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.junit.MockitoJUnitRunner
import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
+import java.security.KeyPair
+import java.security.PublicKey
@RunWith(MockitoJUnitRunner::class)
class UID2ClientTest {
+ private val testDispatcher: TestDispatcher = StandardTestDispatcher()
+
private val networkSession: NetworkSession = mock()
+ private val packageName = "com.uid2.devapp"
+ private val dataEnvelope: DataEnvelope = mock()
+ private val keyUtils: KeyUtils = mock()
+ private val timeUtils: TimeUtils = mock()
private val logger: Logger = mock()
private val url = "https://test.dev"
private val refreshToken = "RefreshToken"
private val refreshKey = "RefreshKey"
+ private val keyPair: KeyPair = mock()
+ private val keyPairPublic: PublicKey = mock()
+ private val keyPairPublicEncoded = ByteArray(12)
+
+ private val SUBSCRIPTION_ID = "subscription_id"
+ private val PUBLIC_KEY = "public_key"
+
+ @Before
+ fun before() {
+ // By default, don't encrypt the data. Just convert it directly to a ByteArray.
+ whenever(dataEnvelope.encrypt(any(), any(), any(), any())).thenAnswer {
+ return@thenAnswer (it.arguments[1] as String).toByteArray()
+ }
+
+ whenever(keyUtils.generateServerPublicKey(any())).thenReturn(mock())
+ whenever(keyUtils.generateKeyPair()).thenReturn(keyPair)
+ whenever(keyUtils.generateSharedSecret(any(), any())).thenReturn(mock())
+ whenever(keyUtils.generateIv(any())).thenAnswer { ByteArray(it.arguments[0] as Int) }
+ whenever(keyUtils.generateAad(any(), any())).thenReturn("")
+
+ whenever(keyPairPublic.encoded).thenReturn(keyPairPublicEncoded)
+ whenever(keyPair.public).thenReturn(keyPairPublic)
+
+ whenever(timeUtils.now()).thenReturn(0)
+ }
+
+ //region generateIdentity
+
@Test
- fun `test invalid api url`() {
- val client = UID2Client(
- "this is not a url",
- networkSession,
- logger,
- )
+ fun `test generate with invalid api url`() = runTest(testDispatcher) {
+ testInvalidClientApi { client ->
+ client.generateIdentity(
+ IdentityRequest.Email("test@test.com"),
+ SUBSCRIPTION_ID,
+ PUBLIC_KEY,
+ )
+ }
+ }
- // Verify that when we have configured the client with an invalid URL, that it throws the appropriate exception
- // when we try to refresh the Identity.
- assertThrows(InvalidApiUrlException::class.java) {
- runBlocking { client.refreshIdentity(refreshToken, refreshKey) }
+ @Test
+ fun `test generate with public key failure`() = runTest(testDispatcher) {
+ val client = withClient()
+
+ // Mock the KeyUtils to fail to generate the required PublicKey.
+ whenever(keyUtils.generateServerPublicKey(any())).thenReturn(null)
+
+ // Verify the expected CryptoException is thrown.
+ assertThrows(CryptoException::class.java) {
+ runTest(testDispatcher) {
+ client.generateIdentity(
+ IdentityRequest.Email("test@test.com"),
+ SUBSCRIPTION_ID,
+ PUBLIC_KEY,
+ )
+ }
}
}
@Test
- fun `test network failure`() {
- val client = UID2Client(
- url,
- networkSession,
- logger,
- )
+ fun `test generate with key pair failure`() = runTest(testDispatcher) {
+ val client = withClient()
- // Configure the network session to report a failure.
- whenever(networkSession.loadData(any(), any())).thenReturn(NetworkResponse(400))
+ // Mock the KeyUtils to fail to generate the required KeyPair.
+ whenever(keyUtils.generateKeyPair()).thenReturn(null)
- // Verify that when a network failure occurs, the appropriate exception is thrown.
- assertThrows(RefreshTokenException::class.java) {
- runBlocking { client.refreshIdentity(refreshToken, refreshKey) }
+ // Verify the expected CryptoException is thrown.
+ assertThrows(CryptoException::class.java) {
+ runTest(testDispatcher) {
+ client.generateIdentity(
+ IdentityRequest.Email("test@test.com"),
+ SUBSCRIPTION_ID,
+ PUBLIC_KEY,
+ )
+ }
}
}
@Test
- fun `test invalid data failure`() {
- val client = UID2Client(
- url,
- networkSession,
- logger,
- )
+ fun `test generate with shared secret failure`() = runTest(testDispatcher) {
+ val client = withClient()
- whenever(networkSession.loadData(any(), any())).thenReturn(
- NetworkResponse(200, "This is not encrypted"),
- )
+ // Mock the KeyUtils to fail to generate the required SharedSecret.
+ whenever(keyUtils.generateSharedSecret(any(), any())).thenReturn(null)
- // Verify that when an unexpected response is returned, the appropriate exception is thrown.
+ // Verify the expected CryptoException is thrown.
+ assertThrows(CryptoException::class.java) {
+ runTest(testDispatcher) {
+ client.generateIdentity(
+ IdentityRequest.Email("test@test.com"),
+ SUBSCRIPTION_ID,
+ PUBLIC_KEY,
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `test generate with encryption failure`() = runTest(testDispatcher) {
+ val client = withClient()
+
+ // Mock the DataEnvelope to fail to encrypt the given payload.
+ whenever(dataEnvelope.encrypt(any(), any(), any(), any())).thenReturn(null)
+
+ // Verify the expected CryptoException is thrown.
+ assertThrows(CryptoException::class.java) {
+ runTest(testDispatcher) {
+ client.generateIdentity(
+ IdentityRequest.Email("test@test.com"),
+ SUBSCRIPTION_ID,
+ PUBLIC_KEY,
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `test generate with network failure`() = runTest(testDispatcher) {
+ testNetworkFailure { client ->
+ client.generateIdentity(
+ IdentityRequest.Email("test@test.com"),
+ SUBSCRIPTION_ID,
+ PUBLIC_KEY,
+ )
+ }
+ }
+
+ @Test
+ fun `test generate with decryption failure`() = runTest(testDispatcher) {
+ val client = withClient()
+
+ // Mock the DataEnvelope to fail to decrypt the given payload.
+ whenever(networkSession.loadData(any(), any())).thenReturn(NetworkResponse(200, "somedata"))
+ whenever(dataEnvelope.decrypt(anyOrNull(), any(), any())).thenReturn(null)
+
+ // Verify the expected CryptoException is thrown.
assertThrows(PayloadDecryptException::class.java) {
- runBlocking { client.refreshIdentity(refreshToken, refreshKey) }
+ runTest(testDispatcher) {
+ client.generateIdentity(
+ IdentityRequest.Email("test@test.com"),
+ SUBSCRIPTION_ID,
+ PUBLIC_KEY,
+ )
+ }
}
}
@Test
- fun `test invalid data key`() {
- val client = UID2Client(
- url,
- networkSession,
- logger,
+ fun `test generate success`() = runTest(testDispatcher) {
+ val client = withClient()
+
+ val unencrypted = JSONObject(TestData.REFRESH_TOKEN_SUCCESS_DECRYPTED)
+ whenever(dataEnvelope.decrypt(anyOrNull(), any(), any())).thenReturn(
+ unencrypted.toString().toByteArray(),
)
+ whenever(networkSession.loadData(any(), any())).thenReturn(NetworkResponse(200, "some data"))
- whenever(networkSession.loadData(any(), any())).thenReturn(
- NetworkResponse(200, TestData.REFRESH_TOKEN_SUCCESS_ENCRYPTED),
+ val response = client.generateIdentity(
+ IdentityRequest.Email("test@test.com"),
+ SUBSCRIPTION_ID,
+ PUBLIC_KEY,
)
+ assertNotNull(response)
+
+ // Verify that the returned package has an identity that matches what we included in the body of the response.
+ val expectedIdentity = unencrypted.getJSONObject("body").let {
+ UID2Identity.fromJson(it)
+ }
+ assertEquals(expectedIdentity, response.identity)
+ }
+
+ //endregion
+
+ //region refreshIdentity
+
+ @Test
+ fun `test refresh with invalid api url`() = runTest(testDispatcher) {
+ testInvalidClientApi { client ->
+ client.refreshIdentity(refreshToken, refreshKey)
+ }
+ }
+
+ @Test
+ fun `test refresh with network failure`() = runTest(testDispatcher) {
+ testNetworkFailure { client ->
+ client.refreshIdentity(refreshToken, refreshKey)
+ }
+ }
+
+ @Test
+ fun `test refresh with invalid data failure`() = runTest(testDispatcher) {
+ testInvalidNetworkResponse { client ->
+ client.refreshIdentity(refreshToken, refreshKey)
+ }
+ }
+
+ @Test
+ fun `test refresh with invalid data key`() = runTest(testDispatcher) {
+ val client = withClient()
+
+ whenever(dataEnvelope.decrypt(any(), any(), any())).thenReturn(null)
+ whenever(networkSession.loadData(any(), any())).thenReturn(NetworkResponse(200, "some data"))
// Verify that when an unexpected response is returned, the appropriate exception is thrown.
assertThrows(PayloadDecryptException::class.java) {
@@ -99,41 +256,34 @@ class UID2ClientTest {
}
@Test
- fun `test successful refresh`() = runBlocking {
- val client = UID2Client(
- url,
- networkSession,
- logger,
- )
+ fun `test successful refresh`() = runTest(testDispatcher) {
+ val client = withClient()
- // Configure the network session to return a valid (encrypted) payload.
- whenever(networkSession.loadData(any(), any())).thenReturn(
- NetworkResponse(200, TestData.REFRESH_TOKEN_SUCCESS_ENCRYPTED),
- )
+ val unencrypted = JSONObject(TestData.REFRESH_TOKEN_SUCCESS_DECRYPTED)
+ whenever(dataEnvelope.decrypt(any(), any(), any())).thenReturn(unencrypted.toString().toByteArray())
+ whenever(networkSession.loadData(any(), any())).thenReturn(NetworkResponse(200, "some data"))
// Verify that the payload was successfully decrypted, and parsed.
val identity = client.refreshIdentity(refreshToken, TestData.REFRESH_TOKEN_ENCRYPTED_SUCCESS_KEY)
assertEquals(IdentityStatus.REFRESHED, identity.status)
// Verify that the returned package has an identity that matches what we included in the body of the response.
- val expectedIdentity = JSONObject(TestData.REFRESH_TOKEN_SUCCESS_DECRYPTED).getJSONObject("body").let {
+ val expectedIdentity = unencrypted.getJSONObject("body").let {
UID2Identity.fromJson(it)
}
assertEquals(expectedIdentity, identity.identity)
}
+ //endregion
+
@Test
- fun `test successful optout`() = runBlocking {
- val client = UID2Client(
- url,
- networkSession,
- logger,
- )
+ fun `test successful opt-out`() = runBlocking {
+ val client = withClient()
- // Configure the network session to return a valid (encrypted) payload.
- whenever(networkSession.loadData(any(), any())).thenReturn(
- NetworkResponse(200, TestData.REFRESH_TOKEN_OPT_OUT_ENCRYPTED),
- )
+ // Configure the network session to return a valid payload.
+ val unencrypted = JSONObject(TestData.REFRESH_TOKEN_OPT_OUT_DECRYPTED)
+ whenever(dataEnvelope.decrypt(any(), any(), any())).thenReturn(unencrypted.toString().toByteArray())
+ whenever(networkSession.loadData(any(), any())).thenReturn(NetworkResponse(200, "some data"))
// Verify that the payload was successfully decrypted, and parsed.
val identity = client.refreshIdentity(refreshToken, TestData.REFRESH_TOKEN_ENCRYPTED_OPT_OUT_KEY)
@@ -142,27 +292,126 @@ class UID2ClientTest {
}
@Test
- fun `test version info`() = runBlocking {
+ fun `test version info - generate identity`() {
+ testVersionInfo { client ->
+ val unencrypted = JSONObject(TestData.REFRESH_TOKEN_SUCCESS_DECRYPTED)
+ whenever(dataEnvelope.decrypt(anyOrNull(), any(), any())).thenReturn(
+ unencrypted.toString().toByteArray(),
+ )
+
+ // Ask the Client to generate a new Identity, so it can make the appropriate request.
+ client.generateIdentity(
+ IdentityRequest.Email("test@test.com"),
+ SUBSCRIPTION_ID,
+ PUBLIC_KEY,
+ )
+ }
+ }
+
+ @Test
+ fun `test version info - refresh identity`() {
+ whenever(dataEnvelope.decrypt(any(), any(), any())).thenReturn(
+ TestData.REFRESH_TOKEN_SUCCESS_DECRYPTED.toByteArray(),
+ )
+
+ testVersionInfo { client ->
+ // Ask the Client to refresh the Identity, so it can make the appropriate request.
+ client.refreshIdentity(refreshToken, TestData.REFRESH_TOKEN_ENCRYPTED_SUCCESS_KEY)
+ }
+ }
+
+ /**
+ * Helper function to test that the given callback will result in the correct exception when the [UID2Client] is
+ * configured with an invalid API URL.
+ */
+ private fun testInvalidClientApi(callback: suspend (client: UID2Client) -> Unit) {
val client = UID2Client(
- url,
+ "this is not a url",
networkSession,
+ packageName,
+ dataEnvelope,
+ timeUtils,
+ keyUtils,
logger,
)
+ // Verify that when we have configured the client with an invalid URL, that it throws the appropriate exception
+ // when we try to fetch the client details.
+ assertThrows(InvalidApiUrlException::class.java) {
+ runTest(testDispatcher) {
+ callback(client)
+ }
+ }
+ }
+
+ /**
+ * Helper function to test that a given callback will result in the correct exception when the [UID2Client]
+ * experiences a network failure.
+ */
+ private fun testNetworkFailure(callback: suspend (client: UID2Client) -> Unit) {
+ val client = withClient()
+
+ // Configure the network session to report a failure.
+ whenever(networkSession.loadData(any(), any())).thenReturn(NetworkResponse(400))
+
+ // Verify that when a network failure occurs, the appropriate exception is thrown.
+ assertThrows(RequestFailureException::class.java) {
+ runTest(testDispatcher) {
+ callback(client)
+ }
+ }
+ }
+
+ /**
+ * Helper function to test that a given callback will result in the correct exception when the [UID2Client]
+ * receives an unexpected response.
+ */
+ private fun testInvalidNetworkResponse(callback: suspend (client: UID2Client) -> Unit) {
+ val client = withClient()
+
+ // Configure the network session to return an invalid response.
+ whenever(networkSession.loadData(any(), any())).thenReturn(
+ NetworkResponse(200, "This is not encrypted"),
+ )
+
+ // Verify that when an unexpected response is received, the appropriate exception is thrown.
+ assertThrows(PayloadDecryptException::class.java) {
+ runTest(testDispatcher) {
+ callback(client)
+ }
+ }
+ }
+
+ /**
+ * Helper function to test whether the expected version headers are included in a specific request, with the
+ * request being invoked via a given callback.
+ */
+ private fun testVersionInfo(callback: suspend (client: UID2Client) -> Unit) = runTest(testDispatcher) {
+ val client = withClient()
+
// Configure the network session to return a valid (encrypted) payload and allows us to capture the given
// NetworkRequest.
var networkRequest: NetworkRequest? = null
whenever(networkSession.loadData(any(), any())).thenAnswer {
networkRequest = it.arguments[1] as NetworkRequest?
- return@thenAnswer NetworkResponse(200, TestData.REFRESH_TOKEN_SUCCESS_ENCRYPTED)
+ return@thenAnswer NetworkResponse(200, "some data")
}
- // Ask the Client to refresh the Identity, so it can make the appropriate request.
- client.refreshIdentity(refreshToken, TestData.REFRESH_TOKEN_ENCRYPTED_SUCCESS_KEY)
+ callback(client)
// Verify that the Client included the expected Version header, and that it ended with our SDK version.
val reportedVersion = networkRequest?.headers?.get("X-UID2-Client-Version")
assertNotNull(reportedVersion)
assertTrue(reportedVersion?.endsWith(UID2.getVersion()) == true)
}
+
+ private fun withClient() = UID2Client(
+ url,
+ networkSession,
+ packageName,
+ dataEnvelope,
+ timeUtils,
+ keyUtils,
+ logger,
+ )
}
diff --git a/sdk/src/test/java/com/uid2/UID2ManagerTest.kt b/sdk/src/test/java/com/uid2/UID2ManagerTest.kt
index e4e4b9d..3a636b3 100644
--- a/sdk/src/test/java/com/uid2/UID2ManagerTest.kt
+++ b/sdk/src/test/java/com/uid2/UID2ManagerTest.kt
@@ -1,5 +1,7 @@
package com.uid2
+import com.uid2.UID2Manager.GenerateIdentityResult
+import com.uid2.data.IdentityRequest
import com.uid2.data.IdentityStatus
import com.uid2.data.IdentityStatus.ESTABLISHED
import com.uid2.data.IdentityStatus.EXPIRED
@@ -8,8 +10,9 @@ import com.uid2.data.IdentityStatus.OPT_OUT
import com.uid2.data.IdentityStatus.REFRESHED
import com.uid2.data.IdentityStatus.REFRESH_EXPIRED
import com.uid2.data.UID2Identity
-import com.uid2.network.RefreshPackage
+import com.uid2.network.ResponsePackage
import com.uid2.storage.StorageManager
+import com.uid2.utils.InputUtils
import com.uid2.utils.Logger
import com.uid2.utils.TimeUtils
import kotlinx.coroutines.CoroutineDispatcher
@@ -30,6 +33,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.anyLong
import org.mockito.ArgumentMatchers.anyString
+import org.mockito.Mockito
import org.mockito.junit.MockitoJUnitRunner
import org.mockito.kotlin.clearInvocations
import org.mockito.kotlin.mock
@@ -51,6 +55,7 @@ class UID2ManagerTest {
private val client: UID2Client = mock()
private val storageManager: StorageManager = mock()
private val timeUtils: TimeUtils = mock()
+ private val inputUtils: InputUtils = mock()
private val logger: Logger = mock()
private lateinit var manager: UID2Manager
@@ -63,8 +68,16 @@ class UID2ManagerTest {
// By default, we won't expire tokens.
whenever(timeUtils.hasExpired(anyLong())).thenReturn(false)
+ whenever(inputUtils.normalize(any(IdentityRequest.Email::class.java))).thenAnswer {
+ it.arguments[0] as IdentityRequest.Email
+ }
+
+ whenever(inputUtils.normalize(any(IdentityRequest.Phone::class.java))).thenAnswer {
+ it.arguments[0] as IdentityRequest.Phone
+ }
+
whenever(storageManager.loadIdentity()).thenReturn(Pair(initialIdentity, initialStatus))
- manager = withManager(client, storageManager, timeUtils, testDispatcher, false, listener)
+ manager = withManager(client, storageManager, timeUtils, inputUtils, testDispatcher, false, listener)
}
@Test
@@ -72,7 +85,7 @@ class UID2ManagerTest {
var isInitialized = false
val onInitialized = { isInitialized = true }
- val manager = UID2Manager(client, storageManager, timeUtils, testDispatcher, false, logger).apply {
+ val manager = UID2Manager(client, storageManager, timeUtils, inputUtils, testDispatcher, false, logger).apply {
this.checkExpiration = false
this.onInitialized = onInitialized
}
@@ -127,6 +140,60 @@ class UID2ManagerTest {
assertManagerState(manager, identity, ESTABLISHED)
}
+ @Test
+ fun `generates identity for different requests`() = runTest(testDispatcher) {
+ val subscriptionId = "sub"
+ val publicKey = "pub"
+
+ listOf(
+ IdentityRequest.Email("test@test.com"),
+ IdentityRequest.EmailHash("a-hash"),
+ IdentityRequest.Phone("+00000000000"),
+ IdentityRequest.PhoneHash("another-hash"),
+ ).forEach { request ->
+ val generated = withRandomIdentity()
+ whenever(client.generateIdentity(request, subscriptionId, publicKey)).thenReturn(
+ ResponsePackage(generated, ESTABLISHED, ""),
+ )
+
+ // Request a new identity should be generated.
+ var result: GenerateIdentityResult? = null
+ manager.generateIdentity(request, subscriptionId, publicKey) { result = it }
+ testDispatcher.scheduler.advanceUntilIdle()
+
+ // Verify that the identity is updated from the one provided via the Client.
+ assertEquals(manager.currentIdentity, generated)
+
+ // Verify that the result callback was invoked.
+ assertEquals(GenerateIdentityResult.Success, result)
+ }
+ }
+
+ @Test
+ fun `existing identity untouched if generation fails`() = runTest(testDispatcher) {
+ val subscriptionId = "sub"
+ val publicKey = "pub"
+
+ val request = IdentityRequest.Email("test@test.com")
+ whenever(client.generateIdentity(request, subscriptionId, publicKey)).thenThrow(
+ PayloadDecryptException::class.java,
+ )
+
+ // Verify that the manager has a known (existing) identity.
+ assertEquals(manager.currentIdentity, initialIdentity)
+
+ // Request a new identity is generated, knowing that this will fail.
+ var result: GenerateIdentityResult? = null
+ manager.generateIdentity(request, subscriptionId, publicKey) { result = it }
+ testDispatcher.scheduler.advanceUntilIdle()
+
+ // Verify that after the failure, the existing identity is still present.
+ assertEquals(manager.currentIdentity, initialIdentity)
+
+ // Verify that the result callback was invoked.
+ assertTrue(result is GenerateIdentityResult.Error)
+ }
+
@Test
fun `resets identity`() = runTest(testDispatcher) {
// Verify that the initial state of the manager reflects the restored Identity.
@@ -145,7 +212,7 @@ class UID2ManagerTest {
@Test
fun `resets identity immediately after initialisation`() = runTest(testDispatcher) {
// Create a new instance of the manager but *don't* allow it to finish initialising (loading previous identity)
- val manager = UID2Manager(client, storageManager, timeUtils, testDispatcher, false, logger).apply {
+ val manager = UID2Manager(client, storageManager, timeUtils, inputUtils, testDispatcher, false, logger).apply {
onIdentityChangedListener = listener
checkExpiration = false
}
@@ -180,7 +247,7 @@ class UID2ManagerTest {
fun `reports when no identity available after opt-out`() = runTest(testDispatcher) {
// Configure the client so that when asked to refresh, it actually reports that the user has now opted out.
whenever(client.refreshIdentity(initialIdentity.refreshToken, initialIdentity.refreshResponseKey)).thenReturn(
- RefreshPackage(
+ ResponsePackage(
null,
OPT_OUT,
"User opt-ed out",
@@ -202,7 +269,7 @@ class UID2ManagerTest {
val storageManager: StorageManager = mock()
whenever(storageManager.loadIdentity()).thenReturn(Pair(null, NO_IDENTITY))
- val manager = withManager(client, storageManager, timeUtils, testDispatcher, false, null)
+ val manager = withManager(client, storageManager, timeUtils, inputUtils, testDispatcher, false, null)
assertNull(manager.currentIdentity)
// Verify that if we attempt to refresh the Identity when one is not set, nothing happens.
@@ -215,7 +282,7 @@ class UID2ManagerTest {
// Configure the client so that when asked to refresh, it returns a new Identity.
val newIdentity = withRandomIdentity()
whenever(client.refreshIdentity(initialIdentity.refreshToken, initialIdentity.refreshResponseKey)).thenReturn(
- RefreshPackage(
+ ResponsePackage(
newIdentity,
REFRESHED,
"Refreshed",
@@ -237,7 +304,7 @@ class UID2ManagerTest {
fun `refresh identities opt out`() = runTest(testDispatcher) {
// Configure the client so that when asked to refresh, it actually reports that the user has now opted out.
whenever(client.refreshIdentity(initialIdentity.refreshToken, initialIdentity.refreshResponseKey)).thenReturn(
- RefreshPackage(
+ ResponsePackage(
null,
OPT_OUT,
"User opt-ed out",
@@ -263,7 +330,7 @@ class UID2ManagerTest {
val newIdentity = withRandomIdentity()
whenever(client.refreshIdentity(initialIdentity.refreshToken, initialIdentity.refreshResponseKey)).thenAnswer {
if (hasErrored) {
- return@thenAnswer RefreshPackage(
+ return@thenAnswer ResponsePackage(
newIdentity,
REFRESHED,
"Refreshed",
@@ -361,7 +428,7 @@ class UID2ManagerTest {
whenever(timeUtils.diffToNow(anyLong())).thenReturn(TimeUnit.SECONDS.toMillis(5))
whenever(client.refreshIdentity(anyString(), anyString())).thenAnswer {
refreshed = true
- RefreshPackage(
+ ResponsePackage(
newIdentity,
REFRESHED,
"User refreshed",
@@ -369,7 +436,7 @@ class UID2ManagerTest {
}
// Build the Manager.
- val manager = withManager(client, storageManager, timeUtils, testDispatcher, true, listener)
+ val manager = withManager(client, storageManager, timeUtils, inputUtils, testDispatcher, true, listener)
testScheduler.advanceTimeBy(10)
assertNull(manager.currentIdentity)
@@ -409,8 +476,8 @@ class UID2ManagerTest {
return@thenAnswer expiryMs <= testDispatcher.scheduler.currentTime
}
- // Build the Manager, allowing it to attempt to load from stroage.
- val manager = withManager(client, storageManager, timeUtils, testDispatcher, false, listener, true)
+ // Build the Manager, allowing it to attempt to load from storage.
+ val manager = withManager(client, storageManager, timeUtils, inputUtils, testDispatcher, false, listener, true)
testScheduler.advanceTimeBy(10)
assertNull(manager.currentIdentity)
@@ -460,6 +527,7 @@ class UID2ManagerTest {
client: UID2Client,
storageManager: StorageManager,
timeUtils: TimeUtils,
+ inputUtils: InputUtils,
dispatcher: CoroutineDispatcher,
initialAutomaticRefreshEnabled: Boolean,
listener: UID2ManagerIdentityChangedListener?,
@@ -469,6 +537,7 @@ class UID2ManagerTest {
client,
storageManager,
timeUtils,
+ inputUtils,
dispatcher,
initialAutomaticRefreshEnabled,
logger,
@@ -506,4 +575,6 @@ class UID2ManagerTest {
private fun randomString(length: Int) = List(length) { charPool.random() }.joinToString("")
private fun randomLong(min: Long) = Random.nextLong(min, Long.MAX_VALUE)
+
+ private fun any(type: Class): T = Mockito.any(type)
}
diff --git a/sdk/src/test/java/com/uid2/data/IdentityRequestTest.kt b/sdk/src/test/java/com/uid2/data/IdentityRequestTest.kt
new file mode 100644
index 0000000..4d8cf06
--- /dev/null
+++ b/sdk/src/test/java/com/uid2/data/IdentityRequestTest.kt
@@ -0,0 +1,66 @@
+package com.uid2.data
+
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class IdentityRequestTest {
+
+ @Test
+ fun `test email payload`() {
+ val payload = IdentityRequest.Email("test.test@test.com").toPayload()
+ val jsonPayload = JSONObject(payload)
+
+ // Verify that we receive the expected JSON payload
+ assertNotNull(jsonPayload)
+ assertTrue(jsonPayload.has(FIELD_EMAIL_HASH))
+ assertEquals("dvECjPKZHya0/SIhSGwP0m8SgTv1vzLxPULUOsm880M=", jsonPayload[FIELD_EMAIL_HASH])
+ }
+
+ @Test
+ fun `test email hash payload`() {
+ val hash = "this-is-a-hash"
+ val payload = IdentityRequest.EmailHash(hash).toPayload()
+ val jsonPayload = JSONObject(payload)
+
+ // Verify that we receive the expected JSON payload
+ assertNotNull(jsonPayload)
+ assertTrue(jsonPayload.has(FIELD_EMAIL_HASH))
+ assertEquals(hash, jsonPayload[FIELD_EMAIL_HASH])
+ assertTrue(jsonPayload.has(FIELD_OPT_OUT_CHECK))
+ assertEquals(1, jsonPayload[FIELD_OPT_OUT_CHECK])
+ }
+
+ @Test
+ fun `test phone payload`() {
+ val payload = IdentityRequest.Phone("+1234567890").toPayload()
+ val jsonPayload = JSONObject(payload)
+
+ // Verify that we receive the expected JSON payload
+ assertNotNull(jsonPayload)
+ assertTrue(jsonPayload.has(FIELD_PHONE_HASH))
+ assertEquals("QizoLG/BckrIeAQvfQVWU6temD0YbmFoJqctQ4S2ivg=", jsonPayload[FIELD_PHONE_HASH])
+ }
+
+ @Test
+ fun `test phone hash payload`() {
+ val hash = "this-is-a-hash"
+ val payload = IdentityRequest.PhoneHash(hash).toPayload()
+ val jsonPayload = JSONObject(payload)
+
+ // Verify that we receive the expected JSON payload
+ assertNotNull(jsonPayload)
+ assertTrue(jsonPayload.has(FIELD_PHONE_HASH))
+ assertEquals(hash, jsonPayload[FIELD_PHONE_HASH])
+ assertTrue(jsonPayload.has(FIELD_OPT_OUT_CHECK))
+ assertEquals(1, jsonPayload[FIELD_OPT_OUT_CHECK])
+ }
+
+ private companion object {
+ const val FIELD_EMAIL_HASH = "email_hash"
+ const val FIELD_PHONE_HASH = "phone_hash"
+ const val FIELD_OPT_OUT_CHECK = "optout_check"
+ }
+}
diff --git a/sdk/src/test/java/com/uid2/extensions/StringExTest.kt b/sdk/src/test/java/com/uid2/extensions/StringExTest.kt
index abac6ac..c718e1f 100644
--- a/sdk/src/test/java/com/uid2/extensions/StringExTest.kt
+++ b/sdk/src/test/java/com/uid2/extensions/StringExTest.kt
@@ -46,4 +46,23 @@ class StringExTest {
assertNotNull(inputDecoded)
assertEquals(input, inputDecoded?.toString(Charsets.UTF_8))
}
+
+ @Test
+ fun `test SHA-256 hashing`() {
+ mapOf(
+ "test.test@test.com" to "dvECjPKZHya0/SIhSGwP0m8SgTv1vzLxPULUOsm880M=",
+ "testtest@gmail.com" to "LkLfFrut8Tc3h/fIvYDiBKSbaMiau/DtaLBPQYszdMw=",
+ "test+test@test.com" to "rQ4yzdOz4uG8N54326QyZD6/JwqrXn4lmy34cVCojB8=",
+ "+test@test.com" to "weFizOVVWKlLfyorbBU8oxYDv4HJtTZCPMyZ4THzUQE=",
+ "test@gmail.com" to "h5JGBrQTGorO7q6IaFMfu5cSqqB6XTp1aybOD11spnQ=",
+ "testtest@test.com" to "d1Lr/s4GLLX3SvQVMoQdIMfbQPMAGZYry+2V+0pZlQg=",
+ "testtest@gmail.com" to "LkLfFrut8Tc3h/fIvYDiBKSbaMiau/DtaLBPQYszdMw=",
+ "\uD83D\uDE0Atesttest@test.com" to "fAFEUqApQ0V/M9mLj/IO54CgKgtQuARKsOMqtFklD4k=",
+ "testtest@\uD83D\uDE0Atest.com" to "tcng5pttf7Y2z4ylZTROvIMw1+IVrMpR4D1KeXSrdiM=",
+ "testtest@test.com\uD83D\uDE0A" to "0qI21FPLkuez/8RswfmircHPYz9Dtf7/Nch1rSWEQf0=",
+ ).forEach {
+ // Hash the given string (with SHA-256) and check that the Base64 encoded output matches what we expect.
+ assertEquals(it.key.toSha256(), it.value)
+ }
+ }
}
diff --git a/sdk/src/test/java/com/uid2/network/DataEnvelopeTest.kt b/sdk/src/test/java/com/uid2/network/DataEnvelopeTest.kt
index 1fcf1b3..588f74b 100644
--- a/sdk/src/test/java/com/uid2/network/DataEnvelopeTest.kt
+++ b/sdk/src/test/java/com/uid2/network/DataEnvelopeTest.kt
@@ -8,12 +8,14 @@ import org.junit.Assert.assertNull
import org.junit.Test
class DataEnvelopeTest {
+ private val dataEnvelope = DataEnvelope
+
@Test
fun `test encrypted opt out`() {
- val payload = DataEnvelope.decrypt(
+ val payload = dataEnvelope.decrypt(
TestData.REFRESH_TOKEN_ENCRYPTED_OPT_OUT_KEY,
TestData.REFRESH_TOKEN_OPT_OUT_ENCRYPTED,
- true,
+ false,
)
// Verify that the payload was actually decoded.
@@ -27,10 +29,10 @@ class DataEnvelopeTest {
@Test
fun `test encrypted refresh`() {
- val payload = DataEnvelope.decrypt(
+ val payload = dataEnvelope.decrypt(
TestData.REFRESH_TOKEN_ENCRYPTED_SUCCESS_KEY,
TestData.REFRESH_TOKEN_SUCCESS_ENCRYPTED,
- true,
+ false,
)
// Verify that the payload was actually decoded.
@@ -44,10 +46,10 @@ class DataEnvelopeTest {
@Test
fun `test invalid key`() {
- val payload = DataEnvelope.decrypt(
+ val payload = dataEnvelope.decrypt(
"This is not a key",
TestData.REFRESH_TOKEN_SUCCESS_ENCRYPTED,
- true,
+ false,
)
// Verify that when attempting to decrypt valid data with an incorrect key, we are returned the expected null.
@@ -56,10 +58,10 @@ class DataEnvelopeTest {
@Test
fun `test invalid data`() {
- val payload = DataEnvelope.decrypt(
+ val payload = dataEnvelope.decrypt(
TestData.REFRESH_TOKEN_ENCRYPTED_SUCCESS_KEY,
"This is not valid",
- true,
+ false,
)
// Verify that when attempting to decrypt invalid data with a valid key, we are returned the expected null.
diff --git a/sdk/src/test/java/com/uid2/network/RefreshResponseTest.kt b/sdk/src/test/java/com/uid2/network/RefreshResponseTest.kt
index f5f48c4..6eb36bb 100644
--- a/sdk/src/test/java/com/uid2/network/RefreshResponseTest.kt
+++ b/sdk/src/test/java/com/uid2/network/RefreshResponseTest.kt
@@ -63,7 +63,7 @@ class RefreshResponseTest {
assertEquals(identity, refresh?.body)
// Verify that when converted to a RefreshPackage, the identity still matches what we expect.
- val refreshPackage = refresh?.toRefreshPackage()
+ val refreshPackage = refresh?.toResponsePackage(true)
assertNotNull(refreshPackage)
assertEquals(identity, refreshPackage?.identity)
assertEquals(REFRESHED, refreshPackage?.status)
@@ -75,7 +75,7 @@ class RefreshResponseTest {
JSONObject(TestData.VALID_REFRESH_OPT_OUT) to OPT_OUT,
JSONObject(TestData.VALID_REFRESH_EXPIRED_TOKEN) to REFRESH_EXPIRED,
).forEach {
- val refresh = RefreshResponse.fromJson(it.key)?.toRefreshPackage()
+ val refresh = RefreshResponse.fromJson(it.key)?.toResponsePackage(true)
// Verify that the converted RefreshPackage includes the expected Status.
assertNotNull(refresh)
diff --git a/sdk/src/test/java/com/uid2/utils/InputUtilsTest.kt b/sdk/src/test/java/com/uid2/utils/InputUtilsTest.kt
new file mode 100644
index 0000000..0c48233
--- /dev/null
+++ b/sdk/src/test/java/com/uid2/utils/InputUtilsTest.kt
@@ -0,0 +1,129 @@
+package com.uid2.utils
+
+import com.uid2.InputValidationException
+import com.uid2.data.IdentityRequest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThrows
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.junit.MockitoJUnitRunner
+
+@RunWith(MockitoJUnitRunner::class)
+class InputUtilsTest {
+
+ @Test
+ fun `test phone number normalization detects invalid input`() {
+ val utils = InputUtils()
+
+ listOf(
+ "",
+ "asdaksjdakfj",
+ "DH5qQFhi5ALrdqcPiib8cy0Hwykx6frpqxWCkR0uijs",
+ "QFhi5ALrdqcPiib8cy0Hwykx6frpqxWCkR0uijs",
+ "06a418f467a14e1631a317b107548a1039d26f12ea45301ab14e7684b36ede58",
+ "0C7E6A405862E402EB76A70F8A26FC732D07C32931E9FAE9AB1582911D2E8A3B",
+ "+",
+ "12345678",
+ "123456789",
+ "1234567890",
+ "+12345678",
+ "+123456789",
+ "+ 12345678",
+ "+ 123456789",
+ "+ 1234 5678",
+ "+ 1234 56789",
+ "+1234567890123456",
+ "+1234567890A",
+ "+1234567890 ",
+ "+1234567890+",
+ "+12345+67890",
+ "555-555-5555",
+ "(555) 555-5555",
+ ).forEach {
+ // Verify that when attempting to normalize these invalid phone numbers, the expected exception is thrown.
+ assertThrows(InputValidationException::class.java) {
+ utils.normalize(IdentityRequest.Phone(it))
+ }
+ }
+ }
+
+ @Test
+ fun `test phone number normalization detects valid input`() {
+ val utils = InputUtils()
+
+ listOf(
+ "+1234567890",
+ "+12345678901",
+ "+123456789012",
+ "+1234567890123",
+ "+12345678901234",
+ "+123456789012345",
+ ).forEach {
+ // Verify that when normalizing these valid phone numbers, they pass as expected (untouched).
+ val normalized = utils.normalize(IdentityRequest.Phone(it))
+ assertEquals(it, normalized.data)
+ }
+ }
+
+ @Test
+ fun `test email normalization detects invalid input`() {
+ val utils = InputUtils()
+
+ listOf(
+ "",
+ " @",
+ "@",
+ "a@",
+ "@b",
+ "@b.com",
+ "+",
+ " ",
+ "+@gmail.com",
+ ".+@gmail.com",
+ "a@ba@z.com",
+ ).forEach {
+ // Verify that when attempting to normalize these invalid email addresses, the expected exception is thrown.
+ assertThrows(InputValidationException::class.java) {
+ utils.normalize(IdentityRequest.Email(it))
+ }
+ }
+ }
+
+ @Test
+ fun `test email normalization detects valid input`() {
+ val utils = InputUtils()
+
+ mapOf(
+ "TEst.TEST@Test.com " to "test.test@test.com",
+ "test.test@test.com" to "test.test@test.com",
+ "test.test@gmail.com" to "testtest@gmail.com",
+ "test+test@test.com" to "test+test@test.com",
+ "+test@test.com" to "+test@test.com",
+ "test+test@gmail.com" to "test@gmail.com",
+ "testtest@test.com" to "testtest@test.com",
+ " testtest@test.com" to "testtest@test.com",
+ "testtest@test.com " to "testtest@test.com",
+ " testtest@test.com " to "testtest@test.com",
+ " testtest@test.com " to "testtest@test.com",
+ " test.test@gmail.com" to "testtest@gmail.com",
+ "test.test@gmail.com " to "testtest@gmail.com",
+ " test.test@gmail.com " to "testtest@gmail.com",
+ " test.test@gmail.com " to "testtest@gmail.com",
+ "TEstTEst@gmail.com " to "testtest@gmail.com",
+ "TEstTEst@GMail.Com " to "testtest@gmail.com",
+ " TEstTEst@GMail.Com " to "testtest@gmail.com",
+ "TEstTEst@GMail.Com" to "testtest@gmail.com",
+ "TEst.TEst@GMail.Com" to "testtest@gmail.com",
+ "TEst.TEst+123@GMail.Com" to "testtest@gmail.com",
+ "TEst.TEST@Test.com " to "test.test@test.com",
+ "TEst.TEST@Test.com " to "test.test@test.com",
+ "\uD83D\uDE0Atesttest@test.com" to "\uD83D\uDE0Atesttest@test.com",
+ "testtest@\uD83D\uDE0Atest.com" to "testtest@\uD83D\uDE0Atest.com",
+ "testtest@test.com\uD83D\uDE0A" to "testtest@test.com\uD83D\uDE0A",
+ ).forEach {
+ // Verify that when normalizing these valid phone numbers, they pass as expected.
+ val normalized = utils.normalize(IdentityRequest.Email(it.key))
+ assertEquals(it.value, normalized.data)
+ }
+ }
+}