diff --git a/CHANGELOG.md b/CHANGELOG.md
index aec7db4..90237ab 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
+## [0.6.0] - 2024-07-31
+
+### Added
+- SIM Swap API
+
## [0.5.0] - 2024-07-25
### Added
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index e2248e9..2ba8c3a 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -1,6 +1,6 @@
# Contributors
-This is the vonage-java-sdk hall of fame! All contributors of source code, or
+This is the vonage-kotlin-sdk hall of fame! All contributors of source code, or
documentation, or tests are eligible to be added to this list.
- Sina Madani ([@SMadani](https://github.com/SMadani))
\ No newline at end of file
diff --git a/README.md b/README.md
index 3f32d03..247165d 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,7 @@ You'll need to have [created a Vonage account](https://dashboard.nexmo.com/sign-
- [Messages](https://developer.vonage.com/en/messages/overview)
- [Verify](https://developer.vonage.com/en/verify/overview)
- [Voice](https://developer.vonage.com/en/voice/voice-api/overview)
+- [SIM Swap](https://developer.vonage.com/en/sim-swap/overview)
- [Number Insight](https://developer.vonage.com/en/number-insight/overview)
- [SMS](https://developer.vonage.com/en/messaging/sms/overview)
- [Conversion](https://developer.vonage.com/en/messaging/conversion-api/overview)
diff --git a/pom.xml b/pom.xml
index 03f0719..14ebdf1 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,7 +5,7 @@
com.vonage
server-sdk-kotlin
- 0.5.0
+ 0.6.0
Vonage Kotlin Server SDK
Kotlin client for Vonage APIs
diff --git a/src/main/kotlin/com/vonage/client/kt/SimSwap.kt b/src/main/kotlin/com/vonage/client/kt/SimSwap.kt
new file mode 100644
index 0000000..fc5869e
--- /dev/null
+++ b/src/main/kotlin/com/vonage/client/kt/SimSwap.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2024 Vonage
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.vonage.client.kt
+
+import com.vonage.client.camara.simswap.*
+
+class SimSwap(private val simSwapClient: SimSwapClient) {
+
+ fun checkSimSwap(phoneNumber: String, maxAgeHours: Int = 240): Boolean =
+ simSwapClient.checkSimSwap(phoneNumber, maxAgeHours)
+
+ fun retrieveSimSwapDate(phoneNumber: String) =
+ simSwapClient.retrieveSimSwapDate(phoneNumber)
+}
diff --git a/src/main/kotlin/com/vonage/client/kt/Vonage.kt b/src/main/kotlin/com/vonage/client/kt/Vonage.kt
index a5f3447..872fdd7 100644
--- a/src/main/kotlin/com/vonage/client/kt/Vonage.kt
+++ b/src/main/kotlin/com/vonage/client/kt/Vonage.kt
@@ -28,6 +28,7 @@ class Vonage(init: VonageClient.Builder.() -> Unit) {
val redact = Redact(vonageClient.redactClient)
val verifyLegacy = VerifyLegacy(vonageClient.verifyClient)
val numberInsight = NumberInsight(vonageClient.insightClient)
+ val simSwap = SimSwap(vonageClient.simSwapClient)
}
fun VonageClient.Builder.authFromEnv(): VonageClient.Builder {
diff --git a/src/test/kotlin/com/vonage/client/kt/AbstractTest.kt b/src/test/kotlin/com/vonage/client/kt/AbstractTest.kt
index 101a817..7f6c02e 100644
--- a/src/test/kotlin/com/vonage/client/kt/AbstractTest.kt
+++ b/src/test/kotlin/com/vonage/client/kt/AbstractTest.kt
@@ -37,12 +37,17 @@ import kotlin.test.assertEquals
abstract class AbstractTest {
protected val apiKey = "a1b2c3d4"
protected val applicationId = "00000000-0000-4000-8000-000000000000"
+ protected val accessToken = "abc123456def"
private val apiSecret = "1234567890abcdef"
private val apiKeySecretEncoded = "YTFiMmMzZDQ6MTIzNDU2Nzg5MGFiY2RlZg=="
private val privateKeyPath = "src/test/resources/com/vonage/client/kt/application_key"
private val signatureSecretName = "sig"
private val apiSecretName = "api_secret"
private val apiKeyName = "api_key"
+ private val authHeaderName = "Authorization"
+ private val basicSecretEncodedHeader = "Basic $apiKeySecretEncoded"
+ private val jwtBearerPattern = "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9(\\..+){2}"
+ private val accessTokenBearer = "Bearer $accessToken"
protected val testUuidStr = "aaaaaaaa-bbbb-4ccc-8ddd-0123456789ab"
protected val testUuid: UUID = UUID.fromString(testUuidStr)
protected val toNumber = "447712345689"
@@ -106,7 +111,7 @@ abstract class AbstractTest {
}
protected enum class AuthType {
- JWT, API_KEY_SECRET_HEADER, API_KEY_SECRET_QUERY_PARAMS, API_KEY_SIGNATURE_SECRET
+ JWT, API_KEY_SECRET_HEADER, API_KEY_SECRET_QUERY_PARAMS, API_KEY_SIGNATURE_SECRET, ACCESS_TOKEN
}
private fun HttpMethod.toWireMockMethod(): Method = when (this) {
@@ -128,11 +133,21 @@ abstract class AbstractTest {
private fun Map.toJson(): String = ObjectMapper().writeValueAsString(this)
protected fun mockPostQueryParams(expectedUrl: String, expectedRequestParams: Map,
+ authType: AuthType? = AuthType.API_KEY_SECRET_QUERY_PARAMS,
status: Int = 200, expectedResponseParams: Map? = null) {
val stub = post(urlPathEqualTo(expectedUrl))
- .withFormParam(apiKeyName, equalTo(apiKey))
- .withFormParam(apiSecretName, equalTo(apiSecret))
+ when (authType) {
+ AuthType.API_KEY_SECRET_QUERY_PARAMS -> {
+ stub.withFormParam(apiKeyName, equalTo(apiKey))
+ .withFormParam(apiSecretName, equalTo(apiSecret))
+ }
+ AuthType.JWT -> stub.withHeader(authHeaderName, matching(jwtBearerPattern))
+ AuthType.ACCESS_TOKEN -> stub.withHeader(authHeaderName, equalTo(accessTokenBearer))
+ AuthType.API_KEY_SECRET_HEADER -> stub.withHeader(authHeaderName, equalTo(basicSecretEncodedHeader))
+ AuthType.API_KEY_SIGNATURE_SECRET -> stub.withFormParam(apiKeyName, equalTo(apiKey))
+ null -> Unit
+ }
expectedRequestParams.forEach {(k, v) -> stub.withFormParam(k, equalTo(v.toString()))}
@@ -162,13 +177,14 @@ abstract class AbstractTest {
}
if (authType != null) {
- val authHeaderName = "Authorization"
when (authType) {
- AuthType.JWT -> headers contains authHeaderName like
- "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9(\\..+){2}"
+ AuthType.JWT -> headers contains authHeaderName like jwtBearerPattern
+
+ AuthType.ACCESS_TOKEN ->
+ headers contains authHeaderName equalTo accessTokenBearer
AuthType.API_KEY_SECRET_HEADER ->
- headers contains authHeaderName equalTo "Basic $apiKeySecretEncoded"
+ headers contains authHeaderName equalTo basicSecretEncodedHeader
AuthType.API_KEY_SECRET_QUERY_PARAMS -> {
queryParams contains apiKeyName equalTo apiKey
diff --git a/src/test/kotlin/com/vonage/client/kt/SimSwapTest.kt b/src/test/kotlin/com/vonage/client/kt/SimSwapTest.kt
new file mode 100644
index 0000000..729d7ac
--- /dev/null
+++ b/src/test/kotlin/com/vonage/client/kt/SimSwapTest.kt
@@ -0,0 +1,108 @@
+/*
+ * Copyright 2024 Vonage
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.vonage.client.kt
+
+import com.vonage.client.auth.camara.FraudPreventionDetectionScope
+import kotlin.test.*
+
+class SimSwapTest : AbstractTest() {
+ private val simSwapClient = vonage.simSwap
+ private val baseSimSwapUrl = "/camara/sim-swap/v040"
+ private val checkSimSwapUrl = "$baseSimSwapUrl/check"
+ private val retrieveSimSwapDateUrl = "$baseSimSwapUrl/retrieve-date"
+ private val simSwapNumber = toNumber
+ private val phoneNumberMap = mapOf("phoneNumber" to simSwapNumber)
+ private val authReqId = "arid/0dadaeb4-7c79-4d39-b4b0-5a6cc08bf537"
+
+ private fun mockBackendAuth(scope: FraudPreventionDetectionScope) {
+ mockPostQueryParams(
+ expectedUrl = "/oauth2/bc-authorize",
+ authType = AuthType.JWT,
+ expectedRequestParams = mapOf(
+ "login_hint" to "tel:+$simSwapNumber",
+ "scope" to "openid dpv:FraudPreventionAndDetection#$scope"
+ ),
+ expectedResponseParams = mapOf(
+ "auth_req_id" to authReqId,
+ "expires_in" to 120,
+ "interval" to 3
+ )
+ )
+ mockPostQueryParams(
+ expectedUrl = "/oauth2/token",
+ authType = AuthType.JWT,
+ expectedRequestParams = mapOf(
+ "grant_type" to "urn:openid:params:grant-type:ciba",
+ "auth_req_id" to authReqId
+ ),
+ expectedResponseParams = mapOf(
+ "access_token" to accessToken,
+ "refresh_token" to "xyz789012ghi",
+ "token_type" to "Bearer",
+ "expires" to 3600
+ ),
+ )
+ }
+
+ private fun assertCheckSimSwap(maxAge: Int = 240, invocation: SimSwap.() -> Boolean) {
+ mockBackendAuth(FraudPreventionDetectionScope.CHECK_SIM_SWAP)
+ for (result in listOf(true, false, null)) {
+ mockPost(
+ expectedUrl = checkSimSwapUrl,
+ authType = AuthType.ACCESS_TOKEN,
+ expectedRequestParams = phoneNumberMap + mapOf("maxAge" to maxAge),
+ expectedResponseParams = if (result != null) mapOf("swapped" to result) else mapOf()
+ )
+ assertEquals(result ?: false, invocation.invoke(simSwapClient))
+ }
+ }
+
+ private fun assertRetrieveSimSwapDate(includeResponse: Boolean) {
+ mockBackendAuth(FraudPreventionDetectionScope.RETRIEVE_SIM_SWAP_DATE)
+ mockPost(
+ expectedUrl = retrieveSimSwapDateUrl,
+ authType = AuthType.ACCESS_TOKEN,
+ expectedRequestParams = phoneNumberMap,
+ expectedResponseParams = if (includeResponse) mapOf("latestSimChange" to timestampStr) else mapOf()
+ )
+ assertEquals(if (includeResponse) timestamp else null, simSwapClient.retrieveSimSwapDate(simSwapNumber))
+ }
+
+ @Test
+ fun `check sim swap number only`() {
+ assertCheckSimSwap {
+ checkSimSwap(simSwapNumber)
+ }
+ }
+
+ @Test
+ fun `check sim swap with maxAge`() {
+ val maxAge = 1200
+ assertCheckSimSwap(maxAge) {
+ checkSimSwap(simSwapNumber, maxAge)
+ }
+ }
+
+ @Test
+ fun `retrieve sim swap date success`() {
+ assertRetrieveSimSwapDate(true)
+ }
+
+ @Test
+ fun `retrieve sim swap date unknown`() {
+ assertRetrieveSimSwapDate(false)
+ }
+}
\ No newline at end of file