diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 033013972..2df0a1941 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -43,6 +43,7 @@ horologist = "0.6.22" junit = "4.13.2" kotlin = "2.1.10" kotlinxCoroutinesGuava = "1.9.0" +kotlinCoroutinesOkhttp = "1.0" kotlinxSerializationJson = "1.8.0" ksp = "2.1.10-1.0.30" maps-compose = "6.4.4" @@ -65,6 +66,7 @@ wearComposeFoundation = "1.4.1" wearComposeMaterial = "1.4.1" wearToolingPreview = "1.0.0" activityKtx = "1.10.0" +okHttp = "4.12.0" [libraries] accompanist-adaptive = { module = "com.google.accompanist:accompanist-adaptive", version.ref = "accompanist" } @@ -158,6 +160,7 @@ hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.re horologist-compose-layout = { module = "com.google.android.horologist:horologist-compose-layout", version.ref = "horologist" } horologist-compose-material = { module = "com.google.android.horologist:horologist-compose-material", version.ref = "horologist" } junit = { module = "junit:junit", version.ref = "junit" } +kotlin-coroutines-okhttp = { module = "ru.gildor.coroutines:kotlin-coroutines-okhttp", version.ref = "kotlinCoroutinesOkhttp" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version.ref = "kotlinxCoroutinesGuava" } @@ -165,6 +168,7 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } play-services-wearable = { module = "com.google.android.gms:play-services-wearable", version.ref = "playServicesWearable" } androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okHttp" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } diff --git a/identity/credentialmanager/build.gradle.kts b/identity/credentialmanager/build.gradle.kts index fd4d43e1d..6505c3a00 100644 --- a/identity/credentialmanager/build.gradle.kts +++ b/identity/credentialmanager/build.gradle.kts @@ -1,6 +1,8 @@ plugins { alias(libs.plugins.android.application) + // [START android_identity_fido2_migration_dependency] alias(libs.plugins.kotlin.android) + // [END android_identity_fido2_migration_dependency] alias(libs.plugins.compose.compiler) } @@ -60,6 +62,8 @@ dependencies { implementation(libs.androidx.credentials.play.services.auth) implementation(libs.android.identity.googleid) // [END android_identity_siwg_gradle_dependencies] + implementation(libs.okhttp) + implementation(libs.kotlin.coroutines.okhttp) debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.test.manifest) -} \ No newline at end of file +} diff --git a/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/Fido2ToCredmanMigration.kt b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/Fido2ToCredmanMigration.kt new file mode 100644 index 000000000..2e21bec4d --- /dev/null +++ b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/Fido2ToCredmanMigration.kt @@ -0,0 +1,240 @@ +package com.example.identity.credentialmanager + +import android.app.Activity +import android.content.Context +import android.util.JsonWriter +import android.util.Log +import android.widget.Toast +import androidx.credentials.CreateCredentialRequest +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.CreatePublicKeyCredentialResponse +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import androidx.credentials.GetPasswordOption +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.PublicKeyCredential +import androidx.credentials.exceptions.CreateCredentialException +import com.example.identity.credentialmanager.ApiResult.Success +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request.Builder +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.ResponseBody +import org.json.JSONObject +import java.io.StringWriter +import ru.gildor.coroutines.okhttp.await + +class Fido2ToCredmanMigration( + private val context: Context, + private val client: OkHttpClient, +) { + private val BASE_URL = "" + private val JSON = "".toMediaTypeOrNull() + private val PUBLIC_KEY = "" + + // [START android_identity_fido2_credman_init] + val credMan = CredentialManager.create(context) + // [END android_identity_fido2_credman_init] + + // [START android_identity_fido2_migration_post_request_body] + suspend fun registerRequest() { + // ... + val call = client.newCall( + Builder() + .method("POST", jsonRequestBody { + name("attestation").value("none") + name("authenticatorSelection").objectValue { + name("residentKey").value("required") + } + }).build() + ) + // ... + } + // [END android_identity_fido2_migration_post_request_body] + + // [START android_identity_fido2_migration_register_request] + suspend fun registerRequest(sessionId: String): ApiResult { + val call = client.newCall( + Builder() + .url("$BASE_URL/") + .addHeader("Cookie", formatCookie(sessionId)) + .method("POST", jsonRequestBody { + name("attestation").value("none") + name("authenticatorSelection").objectValue { + name("authenticatorAttachment").value("platform") + name("userVerification").value("required") + name("residentKey").value("required") + } + }).build() + ) + val response = call.await() + return response.result("Error calling the api") { + parsePublicKeyCredentialCreationOptions( + body ?: throw ApiException("Empty response from the api call") + ) + } + } + // [END android_identity_fido2_migration_register_request] + + // [START android_identity_fido2_migration_create_passkey] + suspend fun createPasskey( + activity: Activity, + requestResult: JSONObject + ): CreatePublicKeyCredentialResponse? { + val request = CreatePublicKeyCredentialRequest(requestResult.toString()) + var response: CreatePublicKeyCredentialResponse? = null + try { + response = credMan.createCredential( + request = request as CreateCredentialRequest, + context = activity + ) as CreatePublicKeyCredentialResponse + } catch (e: CreateCredentialException) { + + showErrorAlert(activity, e) + + return null + } + return response + } + // [END android_identity_fido2_migration_create_passkey] + + // [START android_identity_fido2_migration_auth_with_passkeys] + /** + * @param sessionId The session ID to be used for the sign-in. + * @param credentialId The credential ID of this device. + * @return a JSON object. + */ + suspend fun signinRequest(): ApiResult { + val call = client.newCall(Builder().url(buildString { + append("$BASE_URL/signinRequest") + }).method("POST", jsonRequestBody {}) + .build() + ) + val response = call.await() + return response.result("Error calling /signinRequest") { + parsePublicKeyCredentialRequestOptions( + body ?: throw ApiException("Empty response from /signinRequest") + ) + } + } + + /** + * @param sessionId The session ID to be used for the sign-in. + * @param response The JSONObject for signInResponse. + * @param credentialId id/rawId. + * @return A list of all the credentials registered on the server, + * including the newly-registered one. + */ + suspend fun signinResponse( + sessionId: String, response: JSONObject, credentialId: String + ): ApiResult { + + val call = client.newCall( + Builder().url("$BASE_URL/signinResponse") + .addHeader("Cookie",formatCookie(sessionId)) + .method("POST", jsonRequestBody { + name("id").value(credentialId) + name("type").value(PUBLIC_KEY.toString()) + name("rawId").value(credentialId) + name("response").objectValue { + name("clientDataJSON").value( + response.getString("clientDataJSON") + ) + name("authenticatorData").value( + response.getString("authenticatorData") + ) + name("signature").value( + response.getString("signature") + ) + name("userHandle").value( + response.getString("userHandle") + ) + } + }).build() + ) + val apiResponse = call.await() + return apiResponse.result("Error calling /signingResponse") { + } + } + // [END android_identity_fido2_migration_auth_with_passkeys] + + // [START android_identity_fido2_migration_get_passkeys] + suspend fun getPasskey( + activity: Activity, + creationResult: JSONObject + ): GetCredentialResponse? { + Toast.makeText( + activity, + "Fetching previously stored credentials", + Toast.LENGTH_SHORT) + .show() + var result: GetCredentialResponse? = null + try { + val request= GetCredentialRequest( + listOf( + GetPublicKeyCredentialOption( + creationResult.toString(), + null + ), + GetPasswordOption() + ) + ) + result = credMan.getCredential(activity, request) + if (result.credential is PublicKeyCredential) { + val publicKeycredential = result.credential as PublicKeyCredential + Log.i("TAG", "Passkey ${publicKeycredential.authenticationResponseJson}") + return result + } + } catch (e: Exception) { + showErrorAlert(activity, e) + } + return result + } + // [END android_identity_fido2_migration_get_passkeys] + + private fun showErrorAlert( + activity: Activity, + e: Exception + ) {} + + private fun jsonRequestBody(body: JsonWriter.() -> Unit): RequestBody { + val output = StringWriter() + JsonWriter(output).use { writer -> + writer.beginObject() + writer.body() + writer.endObject() + } + return output.toString().toRequestBody(JSON) + } + + private fun JsonWriter.objectValue(body: JsonWriter.() -> Unit) { + beginObject() + body() + endObject() + } + + private fun formatCookie(sessionId: String): String { + return "" + } + + private fun parsePublicKeyCredentialCreationOptions(body: ResponseBody): JSONObject { + return JSONObject() + } + + private fun parsePublicKeyCredentialRequestOptions(body: ResponseBody): JSONObject { + return JSONObject() + } + + private fun Response.result(errorMessage: String, data: Response.() -> T): ApiResult { + return Success() + } +} + +sealed class ApiResult { + class Success: ApiResult() +} + +class ApiException(message: String) : RuntimeException(message)