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

Add Restore Credential Support for Shrine app #104

Draft
wants to merge 1 commit into
base: shrine-compose
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all 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
4 changes: 2 additions & 2 deletions Shrine/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,12 @@ keystoreProperties.load(FileInputStream(keystorePropertiesFile))

android {
namespace = "com.example.android.authentication.shrine"
compileSdk = 34
compileSdk = 35
Copy link
Contributor

Choose a reason for hiding this comment

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

Is the app tested on both Android 14 & lower and 15?


defaultConfig {
applicationId = "com.example.android.authentication.shrine"
minSdk = 24
targetSdk = 34
targetSdk = 35
versionCode = 1
versionName = "1.0"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,19 @@ package com.example.android.authentication.shrine

import android.annotation.SuppressLint
import android.content.Context
import androidx.credentials.ClearCredentialStateRequest
import androidx.credentials.CreateCredentialRequest
import androidx.credentials.CreatePasswordRequest
import androidx.credentials.CreatePasswordResponse
import androidx.credentials.CreatePublicKeyCredentialRequest
import androidx.credentials.CreatePublicKeyCredentialResponse
import androidx.credentials.CreateRestoreCredentialRequest
import androidx.credentials.CredentialManager
import androidx.credentials.GetCredentialRequest
import androidx.credentials.GetCredentialResponse
import androidx.credentials.GetPasswordOption
import androidx.credentials.GetPublicKeyCredentialOption
import androidx.credentials.GetRestoreCredentialOption
import androidx.credentials.exceptions.CreateCredentialException
import org.json.JSONObject
import javax.inject.Inject
Expand Down Expand Up @@ -124,6 +127,52 @@ class CredentialManagerUtils @Inject constructor(
}
return GenericCredentialManagerResponse.CreatePasskeySuccess(createPasskeyResponse = credentialResponse)
}

suspend fun createRestoreKey(
neelanshsahai marked this conversation as resolved.
Show resolved Hide resolved
requestResult: JSONObject,
context: Context,
) {
val passkeysEligibility = PasskeysEligibility.isPasskeySupported(context)
if (passkeysEligibility.isEligible) {
neelanshsahai marked this conversation as resolved.
Show resolved Hide resolved
val restoreCredentialRequest = CreateRestoreCredentialRequest(requestResult.toString())
Copy link
Contributor

Choose a reason for hiding this comment

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

can this be null?

try {
credentialManager.createCredential(
context,
restoreCredentialRequest,
)
} catch (e: Exception) {
e.printStackTrace()
}
}
}

suspend fun getRestoreKey(
authenticationJson: JSONObject,
activity: Context,
neelanshsahai marked this conversation as resolved.
Show resolved Hide resolved
): GenericCredentialManagerResponse {
val passkeysEligibility = PasskeysEligibility.isPasskeySupported(activity)
neelanshsahai marked this conversation as resolved.
Show resolved Hide resolved
if (!passkeysEligibility.isEligible) {
return GenericCredentialManagerResponse.Error(errorMessage = passkeysEligibility.reason)
}

val options = GetRestoreCredentialOption(authenticationJson.toString())
val getRestoreKeyRequest = GetCredentialRequest(listOf(options))
val result: GetCredentialResponse?
try {
result = credentialManager.getCredential(
activity,
getRestoreKeyRequest,
)
} catch (e: Exception) {
return GenericCredentialManagerResponse.Error(errorMessage = e.message ?: "")
}
return GenericCredentialManagerResponse.GetPasskeySuccess(result)
}

suspend fun deleteRestoreKey() {
val clearRequest = ClearCredentialStateRequest()
neelanshsahai marked this conversation as resolved.
Show resolved Hide resolved
credentialManager.clearCredentialState(clearRequest)
}
}

sealed class GenericCredentialManagerResponse {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,20 +69,33 @@ fun AuthenticationScreen(
val errorDialogViewModel: ErrorDialogViewModel = hiltViewModel()

// Passing in the lambda / context to the VM
val activityContext = LocalContext.current
val context = LocalContext.current
val onSignInWithPasskeysRequest = {
viewModel.signInWithPasskeysRequest(
onSuccess = { flag ->
navigateToHome(flag)
},
) { jsonObject ->
credentialManagerUtils.getPasskey(
context = activityContext,
creationResult = jsonObject,
)
}
getPasskey = { jsonObject ->
credentialManagerUtils.getPasskey(
context = context,
creationResult = jsonObject,
)
},
createRestoreCredential = { createRestoreCredObject ->
credentialManagerUtils.createRestoreKey(
context = context,
requestResult = createRestoreCredObject,
)
},
)
}

viewModel.checkForStoredRestoreKey(
getRestoreKey = { requestResult ->
credentialManagerUtils.getRestoreKey(requestResult, context)
},
)

AuthenticationScreen(
onSignInWithPasskeysRequest = onSignInWithPasskeysRequest,
navigateToRegister = navigateToRegister,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.example.android.authentication.shrine.CredentialManagerUtils
import com.example.android.authentication.shrine.R
import com.example.android.authentication.shrine.ui.common.LogoHeading
import com.example.android.authentication.shrine.ui.common.ShrineButton
Expand All @@ -55,13 +56,20 @@ fun MainMenuScreen(
navigateToLogin: () -> Unit,
viewModel: HomeViewModel,
modifier: Modifier = Modifier,
credentialManagerUtils: CredentialManagerUtils,
) {
val onSignOut = {
viewModel.signOut(
deleteRestoreKey = credentialManagerUtils::deleteRestoreKey,
)
}

MainMenuScreen(
onShrineButtonClicked = onShrineButtonClicked,
onSettingsButtonClicked = onSettingsButtonClicked,
onHelpButtonClicked = onHelpButtonClicked,
navigateToLogin = navigateToLogin,
onSignOut = viewModel::signOut,
onSignOut = onSignOut,
modifier = modifier,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,20 @@ fun RegisterScreen(
onSuccess = { flag ->
navigateToHome(flag)
},
) { username: String, password: String ->
credentialManagerUtils.createPassword(
username = username,
password = password,
context = activityContext,
)
}
createPassword = { username: String, password: String ->
credentialManagerUtils.createPassword(
username = username,
password = password,
activity = activityContext,
)
},
createRestoreCredential = { createRestoreCredObject ->
credentialManagerUtils.createRestoreKey(
createRestoreCredObject,
activityContext,
neelanshsahai marked this conversation as resolved.
Show resolved Hide resolved
)
},
)
}

RegisterScreen(
Expand Down Expand Up @@ -157,7 +164,7 @@ fun RegisterScreen(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
ShrineTextHeader(
TextHeader(
text = stringResource(R.string.create_account),
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ fun ShrineNavGraph(
onHelpButtonClicked = { navController.navigate(ShrineAppDestinations.Help.name) },
navigateToLogin = navigateToLogin,
viewModel = hiltViewModel(),
credentialManagerUtils = credentialManagerUtils,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,13 @@ class AuthenticationViewModel @Inject constructor(
* Requests a sign-in challenge from the server.
*
* @param onSuccess Lambda that handles actions on successful passkey sign-in
* @param getPasskey Lambda function that calls CredManUtil's getPasskey method with Activity reference
* @param getPasskey Lambda that calls CredManUtil's getPasskey method with Activity reference
* @param createRestoreCredential Lambda that
*/
fun signInWithPasskeysRequest(
onSuccess: (Boolean) -> Unit,
getPasskey: suspend (JSONObject) -> GenericCredentialManagerResponse,
createRestoreCredential: suspend (JSONObject) -> Unit,
) {
_uiState.value = AuthenticationUiState(isLoading = true)
viewModelScope.launch {
Expand All @@ -72,6 +74,7 @@ class AuthenticationViewModel @Inject constructor(
signInWithPasskeysResponse(
passkeyResponse.getPasskeyResponse,
onSuccess,
createRestoreCredential,
)
} else if (passkeyResponse is GenericCredentialManagerResponse.Error) {
_uiState.update {
Expand All @@ -93,13 +96,20 @@ class AuthenticationViewModel @Inject constructor(
private fun signInWithPasskeysResponse(
response: GetCredentialResponse,
onSuccess: (navigateToHome: Boolean) -> Unit,
createRestoreCredential: suspend (JSONObject) -> Unit,
) {
viewModelScope.launch {
val isSuccess = repository.signInWithPasskeysResponse(response)
if (isSuccess) {
val isPasswordCredential = response.credential is PasswordCredential
repository.setSignedInState(!isPasswordCredential)
onSuccess(isPasswordCredential)

// On Success
repository.registerPasskeyCreationRequest()?.let { data ->
createRestoreCredential(data)
}

_uiState.update {
AuthenticationUiState(
isSignInWithPasskeysSuccess = true,
Expand All @@ -116,11 +126,35 @@ class AuthenticationViewModel @Inject constructor(
}
}
}

fun checkForStoredRestoreKey(
getRestoreKey: suspend (JSONObject) -> GenericCredentialManagerResponse,
) {
viewModelScope.launch {
repository.signInWithPasskeysRequest()?.let { data ->
val restoreKeyResponse = getRestoreKey(data)
if (restoreKeyResponse is GenericCredentialManagerResponse.GetPasskeySuccess) {
signInWithPasskeysResponse(
restoreKeyResponse.getPasskeyResponse,
{ },
{ },
)
} else if (restoreKeyResponse is GenericCredentialManagerResponse.Error) {
_uiState.update {
AuthenticationUiState(
passkeyRequestErrorMessage = restoreKeyResponse.errorMessage,
)
}
}
}
}
}
}

data class AuthenticationUiState(
val isLoading: Boolean = false,
@StringRes val passkeyResponseMessageResourceId: Int = R.string.empty_string,
val passkeyRequestErrorMessage: String? = null,
val isSignInWithPasskeysSuccess: Boolean = false,
val isRestoreCredentialFound: Boolean = false,
)
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,14 @@ class HomeViewModel @Inject constructor(
/**
* Signs out the user.
*/
fun signOut() {
fun signOut(
deleteRestoreKey: suspend () -> Unit,
) {
viewModelScope.launch {
repository.signOut()

// Delete Restore Key
deleteRestoreKey()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.json.JSONObject
import javax.inject.Inject

/**
Expand All @@ -51,12 +52,14 @@ class RegistrationViewModel @Inject constructor(
* @param onSuccess Lambda to be invoked when the registration is successful.
* The boolean parameter indicates whether the user should be navigated to the home screen.
* @param createPassword Lambda to be invoked when login is success and password needs to be saved
* @param createRestoreCredential Lambda that invokes CredManUtil's createRestoreKey method
*/
fun onRegister(
username: String,
password: String,
onSuccess: (navigateToHome: Boolean) -> Unit,
createPassword: suspend (String, String) -> Unit,
createRestoreCredential: suspend (JSONObject) -> Unit,
) {
_uiState.value = RegisterUiState(isLoading = true)

Expand All @@ -71,6 +74,11 @@ class RegistrationViewModel @Inject constructor(
messageResourceId = R.string.password_created_and_saved,
)
}

repository.registerPasskeyCreationRequest()?.let { data ->
createRestoreCredential(data)
}

onSuccess(true)
} else {
_uiState.update {
Expand Down
6 changes: 3 additions & 3 deletions Shrine/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[versions]
coreSplashscreen = "1.0.1"
credentials = "1.3.0-alpha03"
credentials = "1.5.0-beta01"
googleid = "1.1.1"
googleServicesPlugin = "4.4.1"
hilt = "2.51"
Expand Down Expand Up @@ -37,8 +37,8 @@ espressoCore = "3.5.1"

[libraries]
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
credentials = { module = "androidx.credentials:credentials", version.ref = "credentials" }
credentials-play-services-auth = { module = "androidx.credentials:credentials-play-services-auth", version.ref = "credentials" }
credentials = { group = "androidx.credentials", name = "credentials", version.ref = "credentials" }
credentials-play-services-auth = { group = "androidx.credentials", name = "credentials-play-services-auth", version.ref = "credentials" }
google-services-plugin = { group = "com.google.gms", name = "google-services", version.ref = "googleServicesPlugin" }
google-id = { group = "com.google.android.libraries.identity.googleid", name = "googleid", version.ref = "googleid" }
hilt-android-plugin = { group = "com.google.dagger", name = "hilt-android-gradle-plugin", version.ref = "hilt" }
Expand Down