Skip to content

Commit 7871c4f

Browse files
committed
feat: Adds passkey login support (#848)
1 parent dfa02c8 commit 7871c4f

54 files changed

Lines changed: 2448 additions & 16 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

auth0_flutter/EXAMPLES.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
- [Retrieve stored credentials](#retrieve-stored-credentials-1)
3636
- [📱 Authentication API](#-authentication-api)
3737
- [Login with database connection](#login-with-database-connection)
38+
- [Log in with passkeys](#log-in-with-passkeys)
3839
- [Sign up with database connection](#sign-up-with-database-connection)
3940
- [Passwordless Login](#passwordless-login)
4041
- [Retrieve user information](#retrieve-user-information)
@@ -1031,6 +1032,7 @@ final credentials = await auth0Web.credentials();
10311032
> This feature is mobile/macOS only; the [SPA SDK](https://github.com/auth0/auth0-spa-js) used by auth0_flutter does not include an API client.
10321033
10331034
- [Login with database connection](#login-with-database-connection)
1035+
- [Log in with passkeys](#log-in-with-passkeys)
10341036
- [Sign up with database connection](#sign-up-with-database-connection)
10351037
- [Retrieve user information](#retrieve-user-information)
10361038
- [Renew credentials](#renew-credentials)
@@ -1088,6 +1090,67 @@ final credentials = await auth0.api.login(
10881090

10891091
</details>
10901092

1093+
### Log in with passkeys
1094+
1095+
> This feature is available on **iOS 16.6+** and **Android 9+ (API 28)** only.
1096+
1097+
[Passkeys](https://auth0.com/docs/authenticate/database-connections/passkeys) let an existing user log in with a biometric or device PIN instead of a password, using the platform authenticator (Face ID / Touch ID on iOS, the Credential Manager on Android).
1098+
1099+
> ⚠️ Passkeys require additional configuration on both your Auth0 tenant and your app:
1100+
> - Set up a [custom domain](https://auth0.com/docs/customize/custom-domains) for your tenant. Passkeys will **not** work without one, since the relying-party domain must be a domain you own and can host the associated domain / Digital Asset Links file on.
1101+
> - Enable passkeys for your database connection and the **Passkey** grant type for your application. See [Configure passkeys](https://auth0.com/docs/authenticate/database-connections/passkeys/configure-passkeys).
1102+
> - Configure the [associated domain (iOS/macOS)](README.md#iosmacos-configure-the-associated-domain) and the equivalent [Digital Asset Links file](https://developer.android.com/identity/sign-in/credential-manager#add-support-dal) (Android) so the OS associates your app with the relying-party domain.
1103+
1104+
The SDK exposes **two** methods for passkey login — `passkeyLoginChallenge` and `passkeyLogin` — and leaves presenting the OS passkey UI to your app. The flow is:
1105+
1106+
1. Request a login challenge from Auth0 with `passkeyLoginChallenge`.
1107+
2. **In your app**, present the platform authenticator using that challenge and obtain a WebAuthn assertion. The SDK does **not** do this step — call the OS APIs directly (for example, [`ASAuthorizationController`](https://developer.apple.com/documentation/authenticationservices/asauthorizationcontroller) on iOS/macOS or [Credential Manager](https://developer.android.com/identity/sign-in/credential-manager) on Android, typically over your own platform channel), then map the result into a `PasskeyLoginCredential`.
1108+
3. Exchange that credential for Auth0 tokens with `passkeyLogin`.
1109+
1110+
```dart
1111+
// 1. Request a login challenge from Auth0.
1112+
final challenge = await auth0.api.passkeyLoginChallenge(
1113+
connection: 'Username-Password-Authentication');
1114+
1115+
// 2. Present the OS passkey UI in your app (not provided by the SDK) using
1116+
// `challenge.authParamsPublicKey`, then build a PasskeyLoginCredential from
1117+
// the resulting WebAuthn assertion. All values are base64url-encoded.
1118+
final credential = PasskeyLoginCredential(
1119+
id: '<base64url credentialId>',
1120+
rawId: '<base64url credentialId>',
1121+
type: 'public-key',
1122+
authenticatorAttachment: 'platform',
1123+
response: PasskeyAuthenticatorAssertionResponse(
1124+
clientDataJSON: '<base64url clientDataJSON>',
1125+
authenticatorData: '<base64url authenticatorData>',
1126+
signature: '<base64url signature>',
1127+
userHandle: '<base64url userHandle>'));
1128+
1129+
// 3. Exchange the credential for Auth0 tokens.
1130+
final credentials = await auth0.api.passkeyLogin(
1131+
challenge: challenge,
1132+
credential: credential,
1133+
connection: 'Username-Password-Authentication');
1134+
1135+
// Store the credentials afterward
1136+
final didStore =
1137+
await auth0.credentialsManager.storeCredentials(credentials);
1138+
```
1139+
1140+
<details>
1141+
<summary>Add an audience and scope values</summary>
1142+
1143+
```dart
1144+
final credentials = await auth0.api.passkeyLogin(
1145+
challenge: challenge,
1146+
credential: credential,
1147+
connection: 'Username-Password-Authentication',
1148+
audience: 'YOUR_AUTH0_API_IDENTIFIER',
1149+
scopes: {'profile', 'email', 'offline_access', 'read:todos'});
1150+
```
1151+
1152+
</details>
1153+
10911154
### Sign up with database connection
10921155

10931156
```dart

auth0_flutter/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,7 @@ void dispose() {
586586
- [Check for stored credentials](EXAMPLES.md#check-for-stored-credentials) - check if the user is already logged in when your app starts up.
587587
- [Retrieve stored credentials](EXAMPLES.md#retrieve-stored-credentials) - fetch the user's credentials from the storage, automatically renewing them if they have expired.
588588
- [Retrieve user information](EXAMPLES.md#retrieve-user-information) - fetch the latest user information from the `/userinfo` endpoint.
589+
- [Log in with passkeys](EXAMPLES.md#log-in-with-passkeys) - authenticate an existing user with a passkey using the platform authenticator (iOS/Android only).
589590
- [Native to Web SSO](EXAMPLES.md#native-to-web-sso) - obtain a session transfer token to authenticate a WebView without re-prompting the user.
590591
- [Handle Android process death](#android-handle-process-death-during-login) - recover credentials when the OS kills your app during login.
591592

auth0_flutter/android/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ android {
7474
dependencies {
7575
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
7676
implementation 'com.auth0.android:auth0:3.18.0'
77+
implementation 'com.google.code.gson:gson:2.10.1'
7778
testImplementation 'junit:junit:4.13.2'
7879
testImplementation 'org.hamcrest:java-hamcrest:2.0.0.0'
7980
testImplementation "org.mockito.kotlin:mockito-kotlin:4.1.0"

auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterAuthMethodCallHandler.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,14 @@ class Auth0FlutterAuthMethodCallHandler(
1414
private val apiRequestHandlers: List<ApiRequestHandler>
1515
) : MethodCallHandler {
1616
lateinit var context: Context
17-
17+
1818
override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
1919
val request = MethodCallRequest.fromCall(call)
20-
20+
2121
val apiHandler = apiRequestHandlers.find { it.method == call.method }
2222
if (apiHandler != null) {
2323
val api = AuthenticationAPIClient(request.account)
24-
24+
2525
val useDPoP = request.data["useDPoP"] as? Boolean ?: false
2626
if (useDPoP) {
2727
api.useDPoP(context)

auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterPlugin.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,9 @@ class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware {
133133
RenewApiRequestHandler(),
134134
CustomTokenExchangeApiRequestHandler(),
135135
SSOExchangeApiRequestHandler(),
136-
ResetPasswordApiRequestHandler()
136+
ResetPasswordApiRequestHandler(),
137+
PasskeyLoginChallengeApiRequestHandler(),
138+
PasskeyLoginApiRequestHandler()
137139
)
138140
)
139141
authCallHandler.context = context
@@ -182,6 +184,8 @@ class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware {
182184
}
183185

184186
override fun onDetachedFromActivityForConfigChanges() {
187+
webAuthCallHandler.activity = null
188+
credentialsManagerCallHandler.activity = null
185189
WebAuthProvider.removeCallback(processDeathCallback)
186190
}
187191

@@ -192,6 +196,8 @@ class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware {
192196
}
193197

194198
override fun onDetachedFromActivity() {
199+
webAuthCallHandler.activity = null
200+
credentialsManagerCallHandler.activity = null
195201
WebAuthProvider.removeCallback(processDeathCallback)
196202
}
197203
}

auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterWebAuthMethodCallHandler.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,17 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler
99
import io.flutter.plugin.common.MethodChannel.Result
1010

1111
class Auth0FlutterWebAuthMethodCallHandler(private val requestHandlers: List<WebAuthRequestHandler>) : MethodCallHandler {
12-
lateinit var activity: Activity
12+
// Null while the plugin is detached from an Activity (see ActivityAware
13+
// callbacks in Auth0FlutterPlugin).
14+
var activity: Activity? = null
1315

1416
override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
1517
val requestHandler = requestHandlers.find { it.method == call.method }
1618

1719
if (requestHandler != null) {
1820
val request = MethodCallRequest.fromCall(call)
1921

20-
requestHandler.handle(activity, request, result)
22+
requestHandler.handle(activity!!, request, result)
2123
} else {
2224
result.notImplemented()
2325
}

auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/CredentialsManagerMethodCallHandler.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ private object BiometricAuthLevel {
2727
}
2828

2929
class CredentialsManagerMethodCallHandler(private val requestHandlers: List<CredentialsManagerRequestHandler>) : MethodCallHandler {
30-
lateinit var activity: Activity
30+
// Null while the plugin is detached from an Activity (see ActivityAware
31+
// callbacks in Auth0FlutterPlugin). Only required for biometric/local
32+
// authentication, which needs a FragmentActivity.
33+
var activity: Activity? = null
3134
lateinit var context: Context
3235

3336
private data class ManagerCacheKey(
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package com.auth0.auth0_flutter.request_handlers.api
2+
3+
import com.auth0.android.authentication.AuthenticationAPIClient
4+
import com.auth0.android.authentication.AuthenticationException
5+
import com.auth0.android.callback.Callback
6+
import com.auth0.android.result.Credentials
7+
import com.auth0.auth0_flutter.request_handlers.MethodCallRequest
8+
import com.auth0.auth0_flutter.toMap
9+
import com.auth0.auth0_flutter.utils.assertHasProperties
10+
import com.google.gson.Gson
11+
import io.flutter.plugin.common.MethodChannel
12+
13+
private const val AUTH_PASSKEY_LOGIN_METHOD = "auth#passkeyLogin"
14+
15+
/**
16+
* Exchanges a passkey credential (presented by the app) and a login challenge
17+
* for Auth0 tokens by calling the `/oauth/token` endpoint. This handler does
18+
* not present any UI.
19+
*/
20+
class PasskeyLoginApiRequestHandler : ApiRequestHandler {
21+
override val method: String = AUTH_PASSKEY_LOGIN_METHOD
22+
23+
private val gson = Gson()
24+
25+
override fun handle(
26+
api: AuthenticationAPIClient,
27+
request: MethodCallRequest,
28+
result: MethodChannel.Result
29+
) {
30+
val args = request.data
31+
32+
assertHasProperties(listOf("challenge.authSession", "credential"), args)
33+
34+
val authSession = (args["challenge"] as Map<*, *>)["authSession"] as String
35+
val credentialMap = args["credential"] as Map<*, *>
36+
37+
val connection = args["connection"] as? String
38+
val organization = args["organization"] as? String
39+
val audience = args["audience"] as? String
40+
val scopes = (args["scopes"] as? List<*>)?.filterIsInstance<String>()?.joinToString(" ") ?: ""
41+
val parameters = (args["parameters"] as? Map<*, *>)?.mapKeys { it.key.toString() }
42+
?.mapValues { it.value.toString() } ?: emptyMap()
43+
44+
// signinWithPasskey accepts the WebAuthn authentication response as a
45+
// JSON string in the standard format produced by the create-credential
46+
// step.
47+
val authResponseJson = gson.toJson(credentialMap)
48+
49+
val loginBuilder = api.signinWithPasskey(
50+
authSession,
51+
authResponseJson,
52+
connection,
53+
organization
54+
)
55+
.validateClaims()
56+
57+
if (scopes.isNotEmpty()) {
58+
loginBuilder.setScope(scopes)
59+
}
60+
if (audience != null) {
61+
loginBuilder.setAudience(audience)
62+
}
63+
if (parameters.isNotEmpty()) {
64+
loginBuilder.addParameters(parameters)
65+
}
66+
67+
loginBuilder.start(object : Callback<Credentials, AuthenticationException> {
68+
override fun onFailure(exception: AuthenticationException) {
69+
result.error(
70+
exception.getCode(),
71+
exception.getDescription(),
72+
exception.toMap()
73+
)
74+
}
75+
76+
override fun onSuccess(credentials: Credentials) {
77+
val scope = credentials.scope?.split(" ") ?: listOf()
78+
val formattedDate = credentials.expiresAt.toInstant().toString()
79+
result.success(
80+
mapOf(
81+
"accessToken" to credentials.accessToken,
82+
"idToken" to credentials.idToken,
83+
"refreshToken" to credentials.refreshToken,
84+
"userProfile" to credentials.user.toMap(),
85+
"expiresAt" to formattedDate,
86+
"scopes" to scope,
87+
"tokenType" to credentials.type
88+
)
89+
)
90+
}
91+
})
92+
}
93+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package com.auth0.auth0_flutter.request_handlers.api
2+
3+
import com.auth0.android.authentication.AuthenticationAPIClient
4+
import com.auth0.android.authentication.AuthenticationException
5+
import com.auth0.android.callback.Callback
6+
import com.auth0.android.result.PasskeyChallenge
7+
import com.auth0.auth0_flutter.request_handlers.MethodCallRequest
8+
import com.auth0.auth0_flutter.toMap
9+
import io.flutter.plugin.common.MethodChannel
10+
11+
private const val AUTH_PASSKEY_LOGIN_CHALLENGE_METHOD = "auth#passkeyLoginChallenge"
12+
13+
class PasskeyLoginChallengeApiRequestHandler : ApiRequestHandler {
14+
override val method: String = AUTH_PASSKEY_LOGIN_CHALLENGE_METHOD
15+
16+
override fun handle(
17+
api: AuthenticationAPIClient,
18+
request: MethodCallRequest,
19+
result: MethodChannel.Result
20+
) {
21+
val args = request.data
22+
val connection = args["connection"] as? String
23+
val organization = args["organization"] as? String
24+
25+
api.passkeyChallenge(connection, organization)
26+
.start(object : Callback<PasskeyChallenge, AuthenticationException> {
27+
override fun onFailure(exception: AuthenticationException) {
28+
result.error(
29+
exception.getCode(),
30+
exception.getDescription(),
31+
exception.toMap()
32+
)
33+
}
34+
35+
override fun onSuccess(challenge: PasskeyChallenge) {
36+
val authParamsPublicKey = mapOf(
37+
"challenge" to challenge.authParamsPublicKey.challenge,
38+
"rpId" to challenge.authParamsPublicKey.rpId,
39+
"timeout" to challenge.authParamsPublicKey.timeout,
40+
"userVerification" to challenge.authParamsPublicKey.userVerification
41+
)
42+
result.success(
43+
mapOf(
44+
"authSession" to challenge.authSession,
45+
"authParamsPublicKey" to authParamsPublicKey
46+
)
47+
)
48+
}
49+
})
50+
}
51+
}

0 commit comments

Comments
 (0)