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