Skip to content

Commit 691285e

Browse files
committed
Signify to the server when Restore Credential passkeys are created. Don't show them in settins or passkey management UI, and delete from the server when the user signs out
1 parent 2fa69d7 commit 691285e

File tree

6 files changed

+104
-30
lines changed

6 files changed

+104
-30
lines changed

Shrine/app/src/main/java/com/authentication/shrine/api/AuthApiService.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ interface AuthApiService {
8383
* to complete the passkey registration.
8484
*
8585
* @param cookie The session cookie for authentication.
86+
* @param type The type of credential. Only used to specify Restore Credential passkeys to the
87+
* server.
8688
* @param requestBody The request body containing the client's attestation response
8789
* to the registration challenge.
8890
* @return A Retrofit {@link Response} wrapping a {@link GenericAuthResponse},
@@ -91,6 +93,7 @@ interface AuthApiService {
9193
@POST("webauthn/registerResponse")
9294
suspend fun registerResponse(
9395
@Header("Cookie") cookie: String,
96+
@Query("type") type: String?,
9497
@Body requestBody: RegisterResponseRequestBody,
9598
): Response<GenericAuthResponse>
9699

Shrine/app/src/main/java/com/authentication/shrine/repository/AuthRepository.kt

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ import com.authentication.shrine.utility.getSessionId
4343
import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialType
4444
import kotlinx.coroutines.flow.first
4545
import kotlinx.coroutines.flow.map
46-
import okhttp3.HttpUrl
4746
import org.json.JSONObject
4847
import javax.inject.Inject
4948
import javax.inject.Singleton
@@ -68,6 +67,10 @@ class AuthRepository @Inject constructor(
6867
val USERNAME = stringPreferencesKey("username")
6968
val IS_SIGNED_IN_THROUGH_PASSKEYS = booleanPreferencesKey("is_signed_passkeys")
7069
val SESSION_ID = stringPreferencesKey("session_id")
70+
val RESTORE_KEY_CREDENTIAL_ID = stringPreferencesKey("restore_key_credential_id")
71+
72+
// Value for restore credential AuthApiService parameter
73+
const val RESTORE_KEY_TYPE_PARAMETER = "rc"
7174

7275
suspend fun <T> DataStore<Preferences>.read(key: Preferences.Key<T>): T? {
7376
return data.map { it[key] }.first()
@@ -175,6 +178,7 @@ class AuthRepository @Inject constructor(
175178
prefs.remove(USERNAME)
176179
prefs.remove(SESSION_ID)
177180
prefs.remove(IS_SIGNED_IN_THROUGH_PASSKEYS)
181+
prefs.remove(RESTORE_KEY_CREDENTIAL_ID)
178182
}
179183
}
180184

@@ -221,12 +225,16 @@ class AuthRepository @Inject constructor(
221225
credentialResponse: CreateCredentialResponse,
222226
): Boolean {
223227
try {
228+
// Field to pass as query parameter to authApiService.
229+
val typeParam: String?
224230
val registrationResponseJson = when (credentialResponse) {
225231
is CreatePublicKeyCredentialResponse -> {
232+
typeParam = null
226233
JSONObject(credentialResponse.registrationResponseJson)
227234
}
228235

229236
is CreateRestoreCredentialResponse -> {
237+
typeParam = RESTORE_KEY_TYPE_PARAMETER
230238
JSONObject(credentialResponse.responseJson)
231239
}
232240

@@ -241,6 +249,7 @@ class AuthRepository @Inject constructor(
241249
if (!sessionId.isNullOrBlank()) {
242250
val apiResult = authApiService.registerResponse(
243251
cookie = sessionId.createCookieHeader(),
252+
type = typeParam,
244253
requestBody = RegisterResponseRequestBody(
245254
id = rawId,
246255
type = PublicKeyCredentialType.PUBLIC_KEY.toString(),
@@ -253,10 +262,16 @@ class AuthRepository @Inject constructor(
253262
)
254263
if (apiResult.isSuccessful) {
255264
dataStore.edit { prefs ->
265+
if (credentialResponse is CreateRestoreCredentialResponse) {
266+
val responseData = credentialResponse.data.getString(
267+
"androidx.credentials.BUNDLE_KEY_CREATE_RESTORE_CREDENTIAL_RESPONSE"
268+
)
269+
val responseJSON = JSONObject(responseData!!)
270+
val credentialId = responseJSON.getString("rawId")
271+
prefs[RESTORE_KEY_CREDENTIAL_ID] = credentialId
272+
}
256273
apiResult.getSessionId()?.also {
257274
prefs[SESSION_ID] = it
258-
} ?: run {
259-
signOut()
260275
}
261276
}
262277
return true
@@ -469,4 +484,24 @@ class AuthRepository @Inject constructor(
469484
return false
470485
}
471486

487+
suspend fun deleteRestoreKeyFromServer(): Boolean {
488+
val sessionId = dataStore.read(SESSION_ID)
489+
val credentialId = dataStore.read(RESTORE_KEY_CREDENTIAL_ID)
490+
// Construct endpoint for deleting passkeys.
491+
try {
492+
if (!sessionId.isNullOrEmpty() && !credentialId.isNullOrEmpty()) {
493+
val response = authApiService.deletePasskey(
494+
cookie = sessionId.createCookieHeader(),
495+
credentialId = credentialId,
496+
)
497+
if (response.isSuccessful) {
498+
return true
499+
}
500+
}
501+
}catch (e: Exception) {
502+
Log.e(TAG, "Cannot call deleteRestoreKey", e)
503+
}
504+
return false
505+
}
506+
472507
}

Shrine/app/src/main/java/com/authentication/shrine/ui/PasskeyManagementScreen.kt

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -80,16 +80,20 @@ fun PasskeyManagementScreen(
8080
credentialManagerUtils: CredentialManagerUtils,
8181
) {
8282
val uiState = viewModel.uiState.collectAsState().value
83-
val onDeleteClicked = { credentialId: String -> viewModel.deletePasskey(credentialId) }
83+
// remember is added to callbacks so LazyColumn doesn't recompose when each one loads
84+
val onDeleteClicked =
85+
remember { { credentialId: String -> viewModel.deletePasskey(credentialId) } }
8486
val context = LocalContext.current
85-
val onCreatePasskeyClicked = {
86-
viewModel.createPasskey(
87-
{ data ->
88-
credentialManagerUtils.createPasskey(
89-
requestResult = data,
90-
context = context,
91-
)
92-
})
87+
val onCreatePasskeyClicked = remember {
88+
{
89+
viewModel.createPasskey(
90+
{ data ->
91+
credentialManagerUtils.createPasskey(
92+
requestResult = data,
93+
context = context,
94+
)
95+
})
96+
}
9397
}
9498

9599
PasskeyManagementScreen(

Shrine/app/src/main/java/com/authentication/shrine/ui/viewmodel/HomeViewModel.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,9 @@ class HomeViewModel @Inject constructor(
4242
deleteRestoreKey: suspend () -> Unit,
4343
) {
4444
viewModelScope.launch {
45-
repository.signOut()
4645
deleteRestoreKey()
46+
repository.deleteRestoreKeyFromServer()
47+
repository.signOut()
4748
}
4849
}
4950
}

Shrine/app/src/main/java/com/authentication/shrine/ui/viewmodel/PasskeyManagementViewModel.kt

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,13 @@ class PasskeyManagementViewModel @Inject constructor(
9494
try {
9595
val data = authRepository.getListOfPasskeys()
9696
if (data != null) {
97+
val filteredPasskeysList =
98+
data.credentials.filter({ passkey -> passkey.aaguid != RESTORE_CREDENTIAL_AAGUID })
9799
_uiState.update {
98100
it.copy(
99101
isLoading = false,
100-
userHasPasskeys = data.credentials.isNotEmpty(),
101-
passkeysList = data.credentials,
102+
userHasPasskeys = filteredPasskeysList.isNotEmpty(),
103+
passkeysList = filteredPasskeysList,
102104
)
103105
}
104106
} else {
@@ -142,12 +144,23 @@ class PasskeyManagementViewModel @Inject constructor(
142144
authRepository.registerPasskeyCreationResponse(createPasskeyResponse.createPasskeyResponse)
143145
if (isRegisterResponse) {
144146
val passkeysList = authRepository.getListOfPasskeys()
145-
_uiState.update {
146-
it.copy(
147-
isLoading = false,
148-
passkeysList = passkeysList?.credentials ?: emptyList(),
149-
messageResourceId = R.string.passkey_created
150-
)
147+
if (passkeysList != null) {
148+
val filteredPasskeysList =
149+
passkeysList.credentials.filter({ passkey -> passkey.aaguid != RESTORE_CREDENTIAL_AAGUID })
150+
_uiState.update {
151+
it.copy(
152+
isLoading = false,
153+
passkeysList = filteredPasskeysList,
154+
messageResourceId = R.string.passkey_created
155+
)
156+
}
157+
} else {
158+
_uiState.update {
159+
it.copy(
160+
isLoading = false,
161+
messageResourceId = R.string.get_keys_error,
162+
)
163+
}
151164
}
152165
} else {
153166
_uiState.update {
@@ -202,13 +215,24 @@ class PasskeyManagementViewModel @Inject constructor(
202215
if (authRepository.deletePasskey(credentialId)) {
203216
// Refresh passkeys list after deleting a passkey
204217
val data = authRepository.getListOfPasskeys()
205-
_uiState.update {
206-
it.copy(
207-
isLoading = false,
208-
userHasPasskeys = data?.credentials?.isNotEmpty() ?: false,
209-
passkeysList = data?.credentials ?: emptyList(),
210-
messageResourceId = R.string.delete_passkey_successful
211-
)
218+
if (data != null) {
219+
val filteredPasskeysList =
220+
data.credentials.filter({ passkey -> passkey.aaguid != RESTORE_CREDENTIAL_AAGUID })
221+
_uiState.update {
222+
it.copy(
223+
isLoading = false,
224+
userHasPasskeys = filteredPasskeysList.isNotEmpty(),
225+
passkeysList = filteredPasskeysList,
226+
messageResourceId = R.string.delete_passkey_successful
227+
)
228+
}
229+
} else {
230+
_uiState.update {
231+
it.copy(
232+
isLoading = false,
233+
messageResourceId = R.string.get_keys_error,
234+
)
235+
}
212236
}
213237
} else {
214238
_uiState.update {
@@ -228,6 +252,10 @@ class PasskeyManagementViewModel @Inject constructor(
228252
}
229253
}
230254
}
255+
256+
companion object {
257+
const val RESTORE_CREDENTIAL_AAGUID = "restore-credential"
258+
}
231259
}
232260

233261
/**

Shrine/app/src/main/java/com/authentication/shrine/ui/viewmodel/SettingsViewModel.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import androidx.lifecycle.viewModelScope
2121
import com.authentication.shrine.R
2222
import com.authentication.shrine.model.PasskeyCredential
2323
import com.authentication.shrine.repository.AuthRepository
24+
import com.authentication.shrine.ui.viewmodel.PasskeyManagementViewModel.Companion.RESTORE_CREDENTIAL_AAGUID
2425
import dagger.hilt.android.lifecycle.HiltViewModel
2526
import kotlinx.coroutines.flow.MutableStateFlow
2627
import kotlinx.coroutines.flow.asStateFlow
@@ -65,12 +66,14 @@ class SettingsViewModel @Inject constructor(
6566
try {
6667
val data = authRepository.getListOfPasskeys()
6768
if (data != null) {
69+
val filteredPasskeysList =
70+
data.credentials.filter({ passkey -> passkey.aaguid != RESTORE_CREDENTIAL_AAGUID })
6871
_uiState.update {
6972
it.copy(
7073
isLoading = false,
71-
userHasPasskeys = data.credentials.isNotEmpty(),
74+
userHasPasskeys = filteredPasskeysList.isNotEmpty(),
7275
username = authRepository.getUsername(),
73-
passkeysList = data.credentials,
76+
passkeysList = filteredPasskeysList,
7477
)
7578
}
7679
} else {

0 commit comments

Comments
 (0)