Skip to content

Commit be54de8

Browse files
authored
Added support to enroll passkeys with My Account API (#837)
1 parent e49c585 commit be54de8

16 files changed

+1141
-25
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package com.auth0.android.callback
2+
3+
import com.auth0.android.myaccount.MyAccountException
4+
5+
public interface MyAccountCallback<T> : Callback<T, MyAccountException>
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
package com.auth0.android.myaccount
2+
3+
import androidx.annotation.VisibleForTesting
4+
import com.auth0.android.Auth0
5+
import com.auth0.android.Auth0Exception
6+
import com.auth0.android.NetworkErrorException
7+
import com.auth0.android.authentication.ParameterBuilder
8+
import com.auth0.android.request.ErrorAdapter
9+
import com.auth0.android.request.JsonAdapter
10+
import com.auth0.android.request.PublicKeyCredentials
11+
import com.auth0.android.request.Request
12+
import com.auth0.android.request.internal.GsonAdapter
13+
import com.auth0.android.request.internal.GsonAdapter.Companion.forMap
14+
import com.auth0.android.request.internal.GsonProvider
15+
import com.auth0.android.request.internal.RequestFactory
16+
import com.auth0.android.request.internal.ResponseUtils.isNetworkError
17+
import com.auth0.android.result.PasskeyAuthenticationMethod
18+
import com.auth0.android.result.PasskeyEnrollmentChallenge
19+
import com.auth0.android.result.PasskeyRegistrationChallenge
20+
import com.google.gson.Gson
21+
import okhttp3.HttpUrl
22+
import okhttp3.HttpUrl.Companion.toHttpUrl
23+
import java.io.IOException
24+
import java.io.Reader
25+
import java.net.URLDecoder
26+
27+
28+
/**
29+
* Auth0 My Account API client for managing the current user's account.
30+
*
31+
* You can use the refresh token to get an access token for the My Account API. Refer to [com.auth0.android.authentication.storage.CredentialsManager.getApiCredentials]
32+
* , or alternatively [com.auth0.android.authentication.AuthenticationAPIClient.renewAuth] if you are not using CredentialsManager.
33+
*
34+
* ## Usage
35+
* ```kotlin
36+
* val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN")
37+
* val client = MyAccountAPIClient(auth0,accessToken)
38+
* ```
39+
*
40+
*
41+
*/
42+
public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal constructor(
43+
private val auth0: Auth0,
44+
private val accessToken: String,
45+
private val factory: RequestFactory<MyAccountException>,
46+
private val gson: Gson
47+
) {
48+
49+
/**
50+
* Creates a new MyAccountAPI client instance.
51+
*
52+
* Example usage:
53+
*
54+
* ```
55+
* val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN")
56+
* val client = MyAccountAPIClient(auth0, accessToken)
57+
* ```
58+
* @param auth0 account information
59+
*/
60+
public constructor(
61+
auth0: Auth0,
62+
accessToken: String
63+
) : this(
64+
auth0,
65+
accessToken,
66+
RequestFactory<MyAccountException>(auth0.networkingClient, createErrorAdapter()),
67+
Gson()
68+
)
69+
70+
71+
/**
72+
* Requests a challenge for enrolling a new passkey. This is the first part of the enrollment flow.
73+
*
74+
* You can specify an optional user identity identifier and an optional database connection name.
75+
* If a connection name is not specified, your tenant's default directory will be used.
76+
*
77+
* ## Availability
78+
*
79+
* This feature is currently available in
80+
* [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access).
81+
* Please reach out to Auth0 support to get it enabled for your tenant.
82+
*
83+
* ## Scopes Required
84+
*
85+
* `create:me:authentication_methods`
86+
*
87+
* ## Usage
88+
*
89+
* ```kotlin
90+
* val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN")
91+
* val apiClient = MyAccountAPIClient(auth0, accessToken)
92+
*
93+
* apiClient.passkeyEnrollmentChallenge()
94+
* .start(object : Callback<PasskeyEnrollmentChallenge, MyAccountException> {
95+
* override fun onSuccess(result: PasskeyEnrollmentChallenge) {
96+
* // Use the challenge with Credential Manager API to generate a new passkey credential
97+
* Log.d("MyApp", "Obtained enrollment challenge: $result")
98+
* }
99+
*
100+
* override fun onFailure(error: MyAccountException) {
101+
* Log.e("MyApp", "Failed with: ${error.message}")
102+
* }
103+
* })
104+
* ```
105+
* Use the challenge with [Google Credential Manager API](https://developer.android.com/identity/sign-in/credential-manager) to generate a new passkey credential.
106+
*
107+
* ``` kotlin
108+
* CreatePublicKeyCredentialRequest( Gson().
109+
* toJson( passkeyEnrollmentChallenge.authParamsPublicKey ))
110+
* var response: CreatePublicKeyCredentialResponse?
111+
* credentialManager.createCredentialAsync(
112+
* requireContext(),
113+
* request,
114+
* CancellationSignal(),
115+
* Executors.newSingleThreadExecutor(),
116+
* object :
117+
* CredentialManagerCallback<CreateCredentialResponse, CreateCredentialException> {
118+
* override fun onError(e: CreateCredentialException) {
119+
* }
120+
*
121+
* override fun onResult(result: CreateCredentialResponse) {
122+
* response = result as CreatePublicKeyCredentialResponse
123+
* val credentials = Gson().fromJson(
124+
* response?.registrationResponseJson, PublicKeyCredentials::class.java
125+
* )
126+
* }
127+
* ```
128+
*
129+
* Then, call ``enroll()`` with the created passkey credential and the challenge to complete
130+
* the enrollment.
131+
*
132+
* @param userIdentity Unique identifier of the current user's identity. Needed if the user logged in with a [linked account](https://auth0.com/docs/manage-users/user-accounts/user-account-linking)
133+
* @param connection Name of the database connection where the user is stored
134+
* @return A request to obtain a passkey enrollment challenge
135+
*
136+
* */
137+
public fun passkeyEnrollmentChallenge(
138+
userIdentity: String? = null, connection: String? = null
139+
): Request<PasskeyEnrollmentChallenge, MyAccountException> {
140+
141+
val url = getDomainUrlBuilder()
142+
.addPathSegment(AUTHENTICATION_METHODS)
143+
.build()
144+
145+
val params = ParameterBuilder.newBuilder().apply {
146+
set(TYPE_KEY, "passkey")
147+
userIdentity?.let {
148+
set(USER_IDENTITY_ID_KEY, userIdentity)
149+
}
150+
connection?.let {
151+
set(CONNECTION_KEY, connection)
152+
}
153+
}.asDictionary()
154+
155+
val passkeyEnrollmentAdapter: JsonAdapter<PasskeyEnrollmentChallenge> =
156+
object : JsonAdapter<PasskeyEnrollmentChallenge> {
157+
override fun fromJson(
158+
reader: Reader, metadata: Map<String, Any>
159+
): PasskeyEnrollmentChallenge {
160+
val headers = metadata.mapValues { (_, value) ->
161+
when (value) {
162+
is List<*> -> value.filterIsInstance<String>()
163+
else -> emptyList()
164+
}
165+
}
166+
val locationHeader = headers[LOCATION_KEY]?.get(0)?.split("/")?.lastOrNull()
167+
locationHeader ?: throw MyAccountException("Authentication method ID not found")
168+
val authenticationId =
169+
URLDecoder.decode(
170+
locationHeader,
171+
"UTF-8"
172+
)
173+
174+
val passkeyRegistrationChallenge = gson.fromJson<PasskeyRegistrationChallenge>(
175+
reader, PasskeyRegistrationChallenge::class.java
176+
)
177+
return PasskeyEnrollmentChallenge(
178+
authenticationId,
179+
passkeyRegistrationChallenge.authSession,
180+
passkeyRegistrationChallenge.authParamsPublicKey
181+
)
182+
}
183+
}
184+
val post = factory.post(url.toString(), passkeyEnrollmentAdapter)
185+
.addParameters(params)
186+
.addHeader(AUTHORIZATION_KEY, "Bearer $accessToken")
187+
188+
return post
189+
}
190+
191+
/**
192+
* Enrolls a new passkey credential. This is the last part of the enrollment flow.
193+
*
194+
* ## Availability
195+
*
196+
* This feature is currently available in
197+
* [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access).
198+
* Please reach out to Auth0 support to get it enabled for your tenant.
199+
*
200+
* ## Scopes Required
201+
*
202+
* `create:me:authentication_methods`
203+
*
204+
* ## Usage
205+
*
206+
* ```kotlin
207+
* val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN")
208+
* val apiClient = MyAccountAPIClient(auth0, accessToken)
209+
*
210+
* // After obtaining the passkey credential from the [Credential Manager API](https://developer.android.com/identity/sign-in/credential-manager)
211+
* apiClient.enroll(publicKeyCredentials, enrollmentChallenge)
212+
* .start(object : Callback<PasskeyAuthenticationMethod, MyAccountException> {
213+
* override fun onSuccess(result: AuthenticationMethodVerified) {
214+
* Log.d("MyApp", "Enrolled passkey: $result")
215+
* }
216+
*
217+
* override fun onFailure(error: MyAccountException) {
218+
* Log.e("MyApp", "Failed with: ${error.message}")
219+
* }
220+
* })
221+
* ```
222+
*
223+
* @param credentials The passkey credentials obtained from the [Credential Manager API](https://developer.android.com/identity/sign-in/credential-manager).
224+
* @param challenge The enrollment challenge obtained from the `passkeyEnrollmentChallenge()` method.
225+
* @return A request to enroll the passkey credential.
226+
*/
227+
public fun enroll(
228+
credentials: PublicKeyCredentials, challenge: PasskeyEnrollmentChallenge
229+
): Request<PasskeyAuthenticationMethod, MyAccountException> {
230+
val authMethodId = challenge.authenticationMethodId
231+
val url =
232+
getDomainUrlBuilder()
233+
.addPathSegment(AUTHENTICATION_METHODS)
234+
.addPathSegment(authMethodId)
235+
.addPathSegment(VERIFY)
236+
.build()
237+
238+
val authenticatorResponse = mapOf(
239+
"authenticatorAttachment" to "platform",
240+
"clientExtensionResults" to credentials.clientExtensionResults,
241+
"id" to credentials.id,
242+
"rawId" to credentials.rawId,
243+
"type" to "public-key",
244+
"response" to mapOf(
245+
"clientDataJSON" to credentials.response.clientDataJSON,
246+
"attestationObject" to credentials.response.attestationObject
247+
)
248+
)
249+
250+
val params = ParameterBuilder.newBuilder().apply {
251+
set(AUTH_SESSION_KEY, challenge.authSession)
252+
}.asDictionary()
253+
254+
val passkeyAuthenticationAdapter = GsonAdapter(
255+
PasskeyAuthenticationMethod::class.java
256+
)
257+
258+
val request = factory.post(
259+
url.toString(), passkeyAuthenticationAdapter
260+
).addParameters(params)
261+
.addParameter(AUTHN_RESPONSE_KEY, authenticatorResponse)
262+
.addHeader(AUTHORIZATION_KEY, "Bearer $accessToken")
263+
return request
264+
}
265+
266+
private fun getDomainUrlBuilder(): HttpUrl.Builder {
267+
return auth0.getDomainUrl().toHttpUrl().newBuilder()
268+
.addPathSegment(ME_PATH)
269+
.addPathSegment(API_VERSION)
270+
}
271+
272+
273+
private companion object {
274+
private const val AUTHENTICATION_METHODS = "authentication-methods"
275+
private const val VERIFY = "verify"
276+
private const val API_VERSION = "v1"
277+
private const val ME_PATH = "me"
278+
private const val TYPE_KEY = "type"
279+
private const val USER_IDENTITY_ID_KEY = "identity_user_id"
280+
private const val CONNECTION_KEY = "connection"
281+
private const val AUTHORIZATION_KEY = "Authorization"
282+
private const val LOCATION_KEY = "location"
283+
private const val AUTH_SESSION_KEY = "auth_session"
284+
private const val AUTHN_RESPONSE_KEY = "authn_response"
285+
private fun createErrorAdapter(): ErrorAdapter<MyAccountException> {
286+
val mapAdapter = forMap(GsonProvider.gson)
287+
return object : ErrorAdapter<MyAccountException> {
288+
override fun fromRawResponse(
289+
statusCode: Int, bodyText: String, headers: Map<String, List<String>>
290+
): MyAccountException {
291+
return MyAccountException(bodyText, statusCode)
292+
}
293+
294+
@Throws(IOException::class)
295+
override fun fromJsonResponse(
296+
statusCode: Int, reader: Reader
297+
): MyAccountException {
298+
val values = mapAdapter.fromJson(reader)
299+
return MyAccountException(values, statusCode)
300+
}
301+
302+
override fun fromException(cause: Throwable): MyAccountException {
303+
if (isNetworkError(cause)) {
304+
return MyAccountException(
305+
"Failed to execute the network request", NetworkErrorException(cause)
306+
)
307+
}
308+
return MyAccountException(
309+
cause.message ?: "Something went wrong",
310+
Auth0Exception(cause.message ?: "Something went wrong", cause)
311+
)
312+
}
313+
}
314+
}
315+
}
316+
}

0 commit comments

Comments
 (0)