Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SDKS-1710 WebAuthn Device Management #415

Merged
merged 6 commits into from
May 22, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/*
* Copyright (c) 2024 ForgeRock. All rights reserved.
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/

package org.forgerock.android.auth

import android.net.Uri
import android.util.Base64
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Request
import okhttp3.Response
import org.forgerock.android.auth.exception.ApiException
import org.forgerock.android.auth.webauthn.PublicKeyCredentialSource
import org.forgerock.android.auth.webauthn.WebAuthnRepository
import org.json.JSONObject
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException

private val TAG = RemoteWebAuthnRepository::class.java.simpleName

internal class RemoteWebAuthnRepository(val serverConfig: ServerConfig = Config.getInstance().serverConfig) :
WebAuthnRepository {

override suspend fun delete(publicKeyCredentialSource: PublicKeyCredentialSource) {
val credentialId = Base64.encodeToString(publicKeyCredentialSource.id,
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
val userId = String(publicKeyCredentialSource.userHandle)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should it be base64?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above


val findResponse = find(userId, credentialId)
val resourceId = findResponse.optString("uuid", "")
if (resourceId.isNotEmpty()) {
val deleteResponse = invokeDelete(userId, resourceId)
if (deleteResponse.code != HttpURLConnection.HTTP_OK) {
throw ApiException(deleteResponse.code, deleteResponse.message,
"Failed to delete resources")
}
}
}

private suspend fun find(userId: String, credentialId: String): JSONObject {
val response = invokeGet(userId, credentialId)
if (response.code != HttpURLConnection.HTTP_OK) {
throw ApiException(response.code, response.message, "Failed to find resource")
} else {
return JSONObject(response.body.toString())
spetrov marked this conversation as resolved.
Show resolved Hide resolved
}
}

private suspend fun invokeGet(userId: String, credentialId: String): Response =
withContext(Dispatchers.IO) {
suspendCancellableCoroutine { continuation ->
val client =
OkHttpClientProvider.getInstance().lookup(serverConfig)
val url = getUrl(userId, credentialId)
val request = Request.Builder()
.header(ServerConfig.ACCEPT_API_VERSION, "resource=1.0")
.url(url)
.get().build()
val call = client.newCall(request)
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
if (continuation.isCancelled) return
Logger.warn(TAG, e, "Delete webauthn device failed." )
continuation.resumeWithException(e)
}

override fun onResponse(call: Call, response: Response) {
continuation.resume(response)
}
})
continuation.invokeOnCancellation {
try {
call.cancel()
} catch (e: Exception) {
Logger.warn(TAG, e, "Cancel API call failed")
}
}
}

}

private suspend fun invokeDelete(userId: String, resourceId:String): Response =
withContext(Dispatchers.IO) {
suspendCancellableCoroutine { continuation ->
val client =
OkHttpClientProvider.getInstance().lookup(serverConfig)
val url = getUrl(userId, resourceId)
val request = Request.Builder()
.header(ServerConfig.ACCEPT_API_VERSION, "resource=1.0")
.url(url)
.delete().build()
val call = client.newCall(request)
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
if (continuation.isCancelled) return
Logger.warn(TAG, e, "Delete webauthn device failed." )
continuation.resumeWithException(e)
}

override fun onResponse(call: Call, response: Response) {
continuation.resume(response)
}
})
continuation.invokeOnCancellation {
try {
call.cancel()
} catch (e: Exception) {
Logger.warn(TAG, e, "Cancel API call failed")
}
}
}

}

private fun getUrl(userId: String, resourceId:String): URL {
val builder = Uri.parse(serverConfig.url).buildUpon()
builder.appendPath("json")
.appendPath("realms")
.appendPath(serverConfig.realm)
.appendPath("users")
.appendPath(userId)
.appendPath("devices")
.appendPath("2fa")
.appendPath("webauthn")
.appendPath(resourceId)
return URL(builder.build().toString())
}

}
spetrov marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2023 ForgeRock. All rights reserved.
* Copyright (c) 2023 - 2024 ForgeRock. All rights reserved.
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
Expand All @@ -8,17 +8,33 @@
package org.forgerock.android.auth.webauthn

import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
spetrov marked this conversation as resolved.
Show resolved Hide resolved
import org.forgerock.android.auth.RemoteWebAuthnRepository
import org.forgerock.android.auth.WebAuthnDataRepository
import org.forgerock.android.auth.exception.ApiException
import java.io.IOException
import kotlin.math.truncate
spetrov marked this conversation as resolved.
Show resolved Hide resolved

interface WebAuthnRepository {

@Throws(ApiException::class, IOException::class)
suspend fun delete(publicKeyCredentialSource: PublicKeyCredentialSource)

}

/**
* Manage [PublicKeyCredentialSource] that generated by the SDK.
* The [PublicKeyCredentialSource] only contains the reference to the actual key,
* deleting the [PublicKeyCredentialSource] does not delete the actual key
*/
class FRWebAuthn(private val context: Context,
class FRWebAuthn @JvmOverloads constructor(private val context: Context,
private val webAuthnDataRepository: WebAuthnDataRepository =
WebAuthnDataRepository.WebAuthnDataRepositoryBuilder().context(context)
.build()) {
.build(),
private val remoteWebAuthnRepository: WebAuthnRepository? = RemoteWebAuthnRepository()) {

/**
* Delete the [PublicKeyCredentialSource] by Relying Party Id
Expand All @@ -34,7 +50,26 @@ class FRWebAuthn(private val context: Context,
* @param publicKeyCredentialSource The [PublicKeyCredentialSource] to be deleted
*/
fun deleteCredentials(publicKeyCredentialSource: PublicKeyCredentialSource) {
webAuthnDataRepository.delete(publicKeyCredentialSource)
CoroutineScope(Dispatchers.IO).launch {
deleteCredentials(publicKeyCredentialSource, true)
spetrov marked this conversation as resolved.
Show resolved Hide resolved
}
}

/**
* Delete the provide [PublicKeyCredentialSource] from local storage and also remotely from Server.
* By default, if failed to delete from server, local storage will not be deleted,
* by providing [forceDelete] to true, it will also delete local keys if server call is failed.
* @param publicKeyCredentialSource The [PublicKeyCredentialSource] to be deleted
* @param forceDelete If true, it will also delete local keys if server call is failed.
*/
suspend fun deleteCredentials(publicKeyCredentialSource: PublicKeyCredentialSource, forceDelete: Boolean = false) {
try {
remoteWebAuthnRepository?.delete(publicKeyCredentialSource)
spetrov marked this conversation as resolved.
Show resolved Hide resolved
} catch (e: Exception) {
if (forceDelete) {
webAuthnDataRepository.delete(publicKeyCredentialSource)
}
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 ForgeRock. All rights reserved.
* Copyright (c) 2022 - 2024 ForgeRock. All rights reserved.
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
Expand Down Expand Up @@ -156,16 +156,17 @@ open class WebAuthnRegistration() : WebAuthn() {
publicKeyCredential.rawId,
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING))

//Extension to support username-less
if (options.authenticatorSelection?.requireResidentKey == true &&
options.authenticatorSelection?.residentKeyRequirement == ResidentKeyRequirement.RESIDENT_KEY_DISCOURAGED) {
//TODO: We are not sure about th side effect of this change, need to test all use cases
spetrov marked this conversation as resolved.
Show resolved Hide resolved
// //Extension to support username-less
// if (options.authenticatorSelection?.requireResidentKey == true &&
// options.authenticatorSelection?.residentKeyRequirement == ResidentKeyRequirement.RESIDENT_KEY_DISCOURAGED) {
val source = PublicKeyCredentialSource.builder()
.id(publicKeyCredential.rawId)
.rpid(options.rp.id)
.userHandle(Base64.decode(options.user.id, Base64.URL_SAFE or Base64.NO_WRAP))
.otherUI(options.user.displayName).build()
persist(context, source)
}
// }
return (sb.toString())
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2023 ForgeRock. All rights reserved.
* Copyright (c) 2023 - 2024 ForgeRock. All rights reserved.
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
Expand All @@ -11,13 +11,21 @@ import android.content.Context
import android.content.SharedPreferences
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialType
import kotlinx.coroutines.runBlocking
import org.assertj.core.api.Assertions.*
import org.forgerock.android.auth.RemoteWebAuthnRepository
import org.forgerock.android.auth.WebAuthnDataRepository
import org.forgerock.android.auth.exception.ApiException
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.given
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
spetrov marked this conversation as resolved.
Show resolved Hide resolved

/**
* This test is only focus on the wrapper [FRWebAuthn], refer to WebAuthnDataRepositoryTest for
Expand All @@ -27,6 +35,7 @@ import org.junit.runner.RunWith
class FRWebAuthnTest {

private val context: Context = ApplicationProvider.getApplicationContext()
private val remoteWebAuthnRepository = mock<RemoteWebAuthnRepository>()
private var sharedPreferences = context.getSharedPreferences("Test", Context.MODE_PRIVATE)
private var repository: WebAuthnDataRepository =
object : WebAuthnDataRepository(context, sharedPreferences) {
Expand All @@ -40,7 +49,7 @@ class FRWebAuthnTest {

@Before
fun prepare() {
frWebAuthn = FRWebAuthn(context, repository)
frWebAuthn = FRWebAuthn(context, repository, remoteWebAuthnRepository)
source1 = PublicKeyCredentialSource.builder()
.id("test1".toByteArray())
.rpid("rpid")
Expand All @@ -59,7 +68,7 @@ class FRWebAuthnTest {
}

@Test
fun deleteCredentialsByRpId() {
fun testDeleteCredentialsByRpId() {
repository.persist(source1)
frWebAuthn.deleteCredentials(source1.rpid)
assertThat(frWebAuthn.loadAllCredentials(source1.rpid)).isEmpty()
Expand All @@ -75,10 +84,83 @@ class FRWebAuthnTest {
}

@Test
fun loadAllCredentials() {
fun testLoadAllCredentials() {
repository.persist(source1)
repository.persist(source2)
val sources = frWebAuthn.loadAllCredentials(source1.rpid)
assertThat(sources).hasSize(2)
}

@Test
fun testDeleteCredentialByPublicKeyCredentialSource() {
val localKey = PublicKeyCredentialSource(
"NbbX-JfFRKW00lCMEK0fKw".toByteArray(),
PublicKeyCredentialType.PUBLIC_KEY.toString(),
"rpid1",
"user1".toByteArray(),
"User One",
1715204825651
)

repository.persist(localKey)

frWebAuthn.deleteCredentials(localKey)
assertThat(frWebAuthn.loadAllCredentials(localKey.rpid)).isEmpty()
}

@Test
fun testDeleteCredentialByPublicKeyCredentialSourceNotForceDelete() : Unit =
runBlocking {
given(remoteWebAuthnRepository.delete(any())).willAnswer { throw ApiException(403,
"Failed",
"Failed") }
val localKey = PublicKeyCredentialSource(
"NbbX-JfFRKW00lCMEK0fKw".toByteArray(),
PublicKeyCredentialType.PUBLIC_KEY.toString(),
"rpid1",
"user1".toByteArray(),
"User One",
1715204825651
)

repository.persist(localKey)

try {
frWebAuthn.deleteCredentials(localKey, false)
} catch (e: Exception) {
//ignore
}

verify(remoteWebAuthnRepository).delete(localKey)
assertThat(frWebAuthn.loadAllCredentials(localKey.rpid)).isNotEmpty
}

@Test
fun testDeleteCredentialByPublicKeyCredentialSourceForceDelete() : Unit =
runBlocking {
given(remoteWebAuthnRepository.delete(any())).willAnswer { throw ApiException(403,
"Failed",
"Failed") }

val localKey = PublicKeyCredentialSource(
"NbbX-JfFRKW00lCMEK0fKw".toByteArray(),
PublicKeyCredentialType.PUBLIC_KEY.toString(),
"rpid2",
"user2".toByteArray(),
"User Two",
1715204825651
)

repository.persist(localKey)

try {
frWebAuthn.deleteCredentials(localKey, true)
} catch (e: Exception) {
//ignore
}

verify(remoteWebAuthnRepository).delete(localKey)
assertThat(frWebAuthn.loadAllCredentials(localKey.rpid)).isEmpty()
}

}
Loading