This article details how to use the Okta MyAccounts API to register Passkeys with Android Applications.
- Okta Identity Engine (OIE)
- Android
- okta-mobile-kotlin SDK
- Passkeys/WebAuthn
- MyAccount API
The Okta IDX pipeline supports registering Passkey Credentials during Authenticator Enrollment. At times, it may be desirable to allow Passkey registration outside of an authentication flow.
The code below builds on the okta-mobile-kotlin IDX Sample Application. Functionality is added to allow Passkey Enrollment once logged into the sample app.
The following Sample App files will be modified:
- AndroidManifest.xml
- DynamicAuthViewModel.kt
- DashboardViewModel.kt
- DashboardFragment.kt
- fragment_dashboard.xml
A prerequisite is to have the sample application set up, along with an Org configured for the Android AssetLinks.json file. Please refer to Configure Passkeys for Native Logins in Android Applications.
- Edit AndroidManifest.xml to allow internet access.
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
- Edit DynamicAuthViewModel to specify required acr_value urn:okta:loa:2fa:any:ifpossible
...
extraRequestParameters["acr_values"] = "urn:okta:loa:2fa:any:ifpossible" // <-- add
val interactionCodeFlow = InteractionCodeFlow()
// Initiate the IDX client and start IDX flow.
...
- Edit SampleCredentialHelper to include the required scope okta.myAccount.webauthn.manage
...
clientId = BuildConfig.CLIENT_ID,
defaultScope = "openid email profile offline_access okta.myAccount.webauthn.manage"
...
- Edit fragment_dashboard.xml to add a register passkey button.
...
</ScrollView>
<Button
android:id="@+id/register_passkey_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:text="@string/register_passkey" />
<Button
...
- Edit DashboardFragment to set up the View / Model connections.
...
binding.signOutButton.setOnClickListener {
viewModel.logout()
}
// add below
binding.registerPasskeyButton.setOnClickListener {
viewModel.registerPasskey(requireActivity(), requireContext())
}
viewModel.passkeyLiveData.observe(viewLifecycleOwner) { value ->
if (value != null && value.length > 0) {
Toast.makeText(requireContext(), value, Toast.LENGTH_LONG).show()
}
}
// end
...
- Edit strings.xml to add the text for the register passkey button.
<string name="register_passkey">Register New Passkey</string>
- Edit DashboardViewModel, which will contain passkey registration logic.
...
private lateinit var credential: Credential
//add below
private val _passkeyLiveData = MutableLiveData<String>("")
val passkeyLiveData: LiveData<String> = _passkeyLiveData
//end
...
...
fun logout() {
_logoutStateLiveData.value = LogoutState.Loading
...
}
//add below
fun registerPasskey(activity: Activity, context: Context) {
viewModelScope.launch {
credential = Credential.default ?: run {
// Null Credential, go back to login screen
_passkeyLiveData.postValue("Access Token not Present")
return@launch
}
val accessToken = credential.getValidAccessToken()
if (accessToken == null) {
_passkeyLiveData.postValue("Access Token not Present")
return@launch
}
val okHttpClient = OkHttpClient()
val request = buildRequest("/idp/myaccount/webauthn/registration", "", accessToken = accessToken.toString())
okHttpClient.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: java.io.IOException) {
e.printStackTrace()
_passkeyLiveData.postValue("Error starting Passkey Request: ${e.toString()}")
}
override fun onResponse(call: okhttp3.Call, response: Response) {
if (!response.isSuccessful) {
_passkeyLiveData.postValue("Error starting Passkey Request: ${response.code}, ${response.body?.string() ?: ""}")
return
}
// Handle success
val activationData = response.body?.string()
if (activationData == null) {
_passkeyLiveData.postValue("ActivationData not Received")
return
}
createPasskey(activationData, activity, context, accessToken.toString())
}
})
}
}
private fun createPasskey(activationData: String, activity: Activity, context: Context, accessToken: String) {
val rootObject = JSONObject(activationData).getJSONObject("options")
val authenticatorSelectionObject = rootObject.getJSONObject("authenticatorSelection")
/*
below line is only needed if the WebAuthn/Fido Authenticator in Okta
does not enable autofill for Passkey support
*/
authenticatorSelectionObject.put("residentKey", "preferred")
viewModelScope.launch {
val createCredentialResponse =
androidx.credentials.CredentialManager
.create(activity)
.createCredential(activity, CreatePublicKeyCredentialRequest(rootObject.toString()))
when (createCredentialResponse) {
is CreatePublicKeyCredentialResponse -> {
Timber.i("CreatePublicKeyCredentialResponse.registrationResponseJson = ${createCredentialResponse.registrationResponseJson}")
val response = JSONObject(createCredentialResponse.registrationResponseJson).getJSONObject("response")
val requestObject = JSONObject()
requestObject.put("clientData", response.getString("clientDataJSON"))
requestObject.put("attestation", response.getString("attestationObject"))
requestObject.put("transports", response.getString("transports"))
val request = buildRequest("/idp/myaccount/webauthn", requestObject.toString(), accessToken)
val okHttpClient = OkHttpClient()
okHttpClient.newCall(request).enqueue(object : Callback {
override fun onFailure(call: okhttp3.Call, e: IOException) {
e.printStackTrace()
_passkeyLiveData.postValue("Error registering Passkey: ${e.toString()}")
}
override fun onResponse(call: okhttp3.Call, response: Response) {
if (!response.isSuccessful) {
_passkeyLiveData.postValue("Error registering Passkey: ${response.code}, ${response.body?.string() ?: ""}")
return
}
_passkeyLiveData.postValue("Passkey Enrolled: ${response.code}")
}
})
}
else -> throw UnsupportedOperationException("Unsupported credential type ${createCredentialResponse::class.java.name}")
}
}
}
private fun buildRequest(uri: String, body: String, accessToken: String): Request {
val request = Request.Builder()
.url("https://${BuildConfig.ISSUER.toUri().host}${uri}")
.header("Authorization", "Bearer $accessToken")
.header("Content-Type", "application/json")
.header("Accept", "application/json; okta-version=1.0.0")
.post(body.toRequestBody("application/json".toMediaType()))
.build()
return request
}
//end
fun acknowledgeLogoutSuccess() {
_logoutStateLiveData.value = LogoutState.Idle
}
...
Summary of Changes
Scope okta.myAccount.webauthn.manage and ACR value urn:okta:loa:2fa:any:ifpossible were added.
okta.myAccount.webauthn.manageis needed for the MyAccount WebAuthn API calls.urn:okta:loa:2fa:any:ifpossibleis used to step up the session, so Okta will grantokta.myAccount.webauthn.manage.
NOTE: *All Okta MyAccount API must include an http 'Accept' header with the value application/json; okta-version=1.0.0 or Okta will reject the request.
The next few edits add the button and messaging to the Dashboard.
DashboardViewModel Modifications:
- registerPasskey():
- Starts the registration process by calling the MyAccount API startWebAuthnEnrollment.
- createPasskey():
- With the Activation Data from Okta, use androidx.credentials.CredentialManager to initiate Credential enrollment
- On successful creation, extracts ClientData and Attestation.
- Finishes the registration process by calling the MyAccount API createWebAuthnEnrollment.
Required Okta Org Changes
In addition to the code changes, a couple of changes will need to be made to the OIDC Application and Authorization Server in Okta.
Authorization Server
- Navigate to Security > API > Authorization Servers >
${Auth_Server}> Scopes. - Click Add Scope.
- For "name", enter okta.myAccount.webauthn.manage.
- Optionally add a "Display name" / "Description" and keep the rest of the defaults.
- Click Save.
OIDC Application
- Navigate to Applications > Applications >
${App}> Okta API Scopes. - Search for
okta.myAccount.webauthn.manage. - Select "Grant" if not already enabled.
