Skip to content

Commit 14de0ce

Browse files
committed
feat: add passkey signup support (#849)
1 parent 7871c4f commit 14de0ce

47 files changed

Lines changed: 2269 additions & 751 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.

.github/workflows/main.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ concurrency:
1515
env:
1616
ruby: '3.3.1'
1717
flutter: '3.x'
18-
ios-simulator: iPhone 16
18+
ios-simulator: iPhone 17
1919
java: 17
2020

2121
jobs:

auth0_flutter/EXAMPLES.md

Lines changed: 85 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,9 @@
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)
3938
- [Sign up with database connection](#sign-up-with-database-connection)
39+
- [Log in with passkeys](#log-in-with-passkeys)
40+
- [Sign up with passkeys](#sign-up-with-passkeys)
4041
- [Passwordless Login](#passwordless-login)
4142
- [Retrieve user information](#retrieve-user-information)
4243
- [Renew credentials](#renew-credentials)
@@ -1032,8 +1033,9 @@ final credentials = await auth0Web.credentials();
10321033
> 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.
10331034
10341035
- [Login with database connection](#login-with-database-connection)
1035-
- [Log in with passkeys](#log-in-with-passkeys)
10361036
- [Sign up with database connection](#sign-up-with-database-connection)
1037+
- [Log in with passkeys](#log-in-with-passkeys)
1038+
- [Sign up with passkeys](#sign-up-with-passkeys)
10371039
- [Retrieve user information](#retrieve-user-information)
10381040
- [Renew credentials](#renew-credentials)
10391041
- [API client errors](#api-client-errors)
@@ -1090,6 +1092,18 @@ final credentials = await auth0.api.login(
10901092

10911093
</details>
10921094

1095+
### Sign up with database connection
1096+
1097+
```dart
1098+
final databaseUser = await auth0.api.signup(
1099+
email: 'jane.smith@example.com',
1100+
password: 'secret-password',
1101+
connection: 'Username-Password-Authentication',
1102+
userMetadata: {'first_name': 'Jane', 'last_name': 'Smith'});
1103+
```
1104+
1105+
> 💡 You might want to log the user in after signup. See [Login with database connection](#login-with-database-connection) above for an example.
1106+
10931107
### Log in with passkeys
10941108

10951109
> This feature is available on **iOS 16.6+** and **Android 9+ (API 28)** only.
@@ -1101,33 +1115,33 @@ final credentials = await auth0.api.login(
11011115
> - 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).
11021116
> - 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.
11031117
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:
1118+
The SDK exposes **two** methods for passkey login — `passkeyLoginChallenge` and `passkeyCredentialExchange` — and leaves presenting the OS passkey UI to your app. The flow is:
11051119

11061120
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`.
1121+
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 `PasskeyCredential`.
1122+
3. Exchange that credential for Auth0 tokens with `passkeyCredentialExchange`.
11091123

11101124
```dart
11111125
// 1. Request a login challenge from Auth0.
11121126
final challenge = await auth0.api.passkeyLoginChallenge(
11131127
connection: 'Username-Password-Authentication');
11141128
11151129
// 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(
1130+
// `challenge.authParamsPublicKey`, then build a PasskeyCredential from the
1131+
// resulting WebAuthn assertion. All values are base64url-encoded.
1132+
final credential = PasskeyCredential(
11191133
id: '<base64url credentialId>',
11201134
rawId: '<base64url credentialId>',
11211135
type: 'public-key',
11221136
authenticatorAttachment: 'platform',
1123-
response: PasskeyAuthenticatorAssertionResponse(
1137+
response: PasskeyAuthenticatorResponse(
11241138
clientDataJSON: '<base64url clientDataJSON>',
11251139
authenticatorData: '<base64url authenticatorData>',
11261140
signature: '<base64url signature>',
11271141
userHandle: '<base64url userHandle>'));
11281142
11291143
// 3. Exchange the credential for Auth0 tokens.
1130-
final credentials = await auth0.api.passkeyLogin(
1144+
final credentials = await auth0.api.passkeyCredentialExchange(
11311145
challenge: challenge,
11321146
credential: credential,
11331147
connection: 'Username-Password-Authentication');
@@ -1141,7 +1155,7 @@ final didStore =
11411155
<summary>Add an audience and scope values</summary>
11421156

11431157
```dart
1144-
final credentials = await auth0.api.passkeyLogin(
1158+
final credentials = await auth0.api.passkeyCredentialExchange(
11451159
challenge: challenge,
11461160
credential: credential,
11471161
connection: 'Username-Password-Authentication',
@@ -1151,17 +1165,72 @@ final credentials = await auth0.api.passkeyLogin(
11511165

11521166
</details>
11531167

1154-
### Sign up with database connection
1168+
### Sign up with passkeys
1169+
1170+
> This feature is available on **iOS 16.6+** and **Android 9+ (API 28)** only.
1171+
1172+
[Passkeys](https://auth0.com/docs/authenticate/database-connections/passkeys) let users register 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).
1173+
1174+
> ⚠️ Passkeys require additional configuration on both your Auth0 tenant and your app:
1175+
> - 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.
1176+
> - 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).
1177+
> - 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.
1178+
1179+
The SDK exposes **two** methods for passkey signup — `passkeySignupChallenge` and `passkeyCredentialExchange` — and leaves presenting the OS passkey UI to your app. The flow is:
1180+
1181+
1. Request a registration challenge from Auth0 with `passkeySignupChallenge`.
1182+
2. **In your app**, present the platform authenticator using that challenge and obtain a WebAuthn attestation. 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 `PasskeyCredential`.
1183+
3. Exchange that credential for Auth0 tokens with `passkeyCredentialExchange` — the same method used for login.
1184+
1185+
You can identify the new user with any combination of `email`, `phoneNumber`, `username`, `name`, `givenName`, `familyName`, `nickname`, and `picture`, depending on how your connection is configured.
11551186

11561187
```dart
1157-
final databaseUser = await auth0.api.signup(
1188+
// 1. Request a registration challenge from Auth0. You can identify the new
1189+
// user with any combination of email, phoneNumber, username, name,
1190+
// givenName, familyName, nickname, and picture.
1191+
final challenge = await auth0.api.passkeySignupChallenge(
11581192
email: 'jane.smith@example.com',
1159-
password: 'secret-password',
1193+
name: 'Jane Smith',
1194+
givenName: 'Jane',
1195+
familyName: 'Smith',
1196+
connection: 'Username-Password-Authentication');
1197+
1198+
// 2. Present the OS passkey-creation UI in your app (not provided by the SDK)
1199+
// using `challenge.authParamsPublicKey`, then build a PasskeyCredential from
1200+
// the resulting WebAuthn attestation. All values are base64url-encoded.
1201+
final credential = PasskeyCredential(
1202+
id: '<base64url credentialId>',
1203+
rawId: '<base64url credentialId>',
1204+
type: 'public-key',
1205+
authenticatorAttachment: 'platform',
1206+
response: PasskeyAuthenticatorResponse(
1207+
clientDataJSON: '<base64url clientDataJSON>',
1208+
attestationObject: '<base64url attestationObject>'));
1209+
1210+
// 3. Exchange the credential for Auth0 tokens.
1211+
final credentials = await auth0.api.passkeyCredentialExchange(
1212+
challenge: challenge,
1213+
credential: credential,
1214+
connection: 'Username-Password-Authentication');
1215+
1216+
// Store the credentials afterward
1217+
final didStore =
1218+
await auth0.credentialsManager.storeCredentials(credentials);
1219+
```
1220+
1221+
<details>
1222+
<summary>Add an audience and scope values</summary>
1223+
1224+
```dart
1225+
final credentials = await auth0.api.passkeyCredentialExchange(
1226+
challenge: challenge,
1227+
credential: credential,
11601228
connection: 'Username-Password-Authentication',
1161-
userMetadata: {'first_name': 'Jane', 'last_name': 'Smith'});
1229+
audience: 'YOUR_AUTH0_API_IDENTIFIER',
1230+
scopes: {'profile', 'email', 'offline_access', 'read:todos'});
11621231
```
11631232

1164-
> 💡 You might want to log the user in after signup. See [Login with database connection](#login-with-database-connection) above for an example.
1233+
</details>
11651234

11661235
### Passwordless Login
11671236
Passwordless is a two-step authentication flow that requires the **Passwordless OTP** grant to be enabled for your Auth0 application. Check [our documentation](https://auth0.com/docs/get-started/applications/application-grant-types) for more information.

auth0_flutter/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,7 @@ void dispose() {
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.
589589
- [Log in with passkeys](EXAMPLES.md#log-in-with-passkeys) - authenticate an existing user with a passkey using the platform authenticator (iOS/Android only).
590+
- [Sign up with passkeys](EXAMPLES.md#sign-up-with-passkeys) - register a new user with a passkey using the platform authenticator (iOS/Android only).
590591
- [Native to Web SSO](EXAMPLES.md#native-to-web-sso) - obtain a session transfer token to authenticate a WebView without re-prompting the user.
591592
- [Handle Android process death](#android-handle-process-death-during-login) - recover credentials when the OS kills your app during login.
592593

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,8 @@ class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware {
135135
SSOExchangeApiRequestHandler(),
136136
ResetPasswordApiRequestHandler(),
137137
PasskeyLoginChallengeApiRequestHandler(),
138-
PasskeyLoginApiRequestHandler()
138+
PasskeySignupChallengeApiRequestHandler(),
139+
PasskeyCredentialExchangeApiRequestHandler()
139140
)
140141
)
141142
authCallHandler.context = context

auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/PasskeyLoginApiRequestHandler.kt renamed to auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/PasskeyCredentialExchangeApiRequestHandler.kt

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,20 @@ import com.auth0.auth0_flutter.utils.assertHasProperties
1010
import com.google.gson.Gson
1111
import io.flutter.plugin.common.MethodChannel
1212

13-
private const val AUTH_PASSKEY_LOGIN_METHOD = "auth#passkeyLogin"
13+
private const val AUTH_PASSKEY_CREDENTIAL_EXCHANGE_METHOD =
14+
"auth#passkeyCredentialExchange"
1415

1516
/**
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.
17+
* Exchanges an app-supplied passkey credential (a login assertion or a signup
18+
* attestation) and its challenge for Auth0 tokens at the `/oauth/token`
19+
* endpoint. This handler does not present any UI.
20+
*
21+
* Both passkey login and signup finish here: Auth0.Android's `signinWithPasskey`
22+
* accepts the credential as a JSON string and handles both assertion and
23+
* attestation payloads, so a single handler serves both flows.
1924
*/
20-
class PasskeyLoginApiRequestHandler : ApiRequestHandler {
21-
override val method: String = AUTH_PASSKEY_LOGIN_METHOD
25+
class PasskeyCredentialExchangeApiRequestHandler : ApiRequestHandler {
26+
override val method: String = AUTH_PASSKEY_CREDENTIAL_EXCHANGE_METHOD
2227

2328
private val gson = Gson()
2429

@@ -37,16 +42,16 @@ class PasskeyLoginApiRequestHandler : ApiRequestHandler {
3742
val connection = args["connection"] as? String
3843
val organization = args["organization"] as? String
3944
val audience = args["audience"] as? String
40-
val scopes = (args["scopes"] as? List<*>)?.filterIsInstance<String>()?.joinToString(" ") ?: ""
45+
val scopes = (args["scopes"] as? List<*>)?.filterIsInstance<String>()
46+
?.joinToString(" ") ?: ""
4147
val parameters = (args["parameters"] as? Map<*, *>)?.mapKeys { it.key.toString() }
4248
?.mapValues { it.value.toString() } ?: emptyMap()
4349

44-
// signinWithPasskey accepts the WebAuthn authentication response as a
45-
// JSON string in the standard format produced by the create-credential
46-
// step.
50+
// signinWithPasskey accepts the WebAuthn credential response as a JSON
51+
// string in the standard format produced by the platform authenticator.
4752
val authResponseJson = gson.toJson(credentialMap)
4853

49-
val loginBuilder = api.signinWithPasskey(
54+
val builder = api.signinWithPasskey(
5055
authSession,
5156
authResponseJson,
5257
connection,
@@ -55,16 +60,16 @@ class PasskeyLoginApiRequestHandler : ApiRequestHandler {
5560
.validateClaims()
5661

5762
if (scopes.isNotEmpty()) {
58-
loginBuilder.setScope(scopes)
63+
builder.setScope(scopes)
5964
}
6065
if (audience != null) {
61-
loginBuilder.setAudience(audience)
66+
builder.setAudience(audience)
6267
}
6368
if (parameters.isNotEmpty()) {
64-
loginBuilder.addParameters(parameters)
69+
builder.addParameters(parameters)
6570
}
6671

67-
loginBuilder.start(object : Callback<Credentials, AuthenticationException> {
72+
builder.start(object : Callback<Credentials, AuthenticationException> {
6873
override fun onFailure(exception: AuthenticationException) {
6974
result.error(
7075
exception.getCode(),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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.request.UserData
7+
import com.auth0.android.result.PasskeyRegistrationChallenge
8+
import com.auth0.auth0_flutter.request_handlers.MethodCallRequest
9+
import com.auth0.auth0_flutter.toMap
10+
import com.google.gson.Gson
11+
import io.flutter.plugin.common.MethodChannel
12+
13+
private const val AUTH_PASSKEY_SIGNUP_CHALLENGE_METHOD = "auth#passkeySignupChallenge"
14+
15+
class PasskeySignupChallengeApiRequestHandler : ApiRequestHandler {
16+
override val method: String = AUTH_PASSKEY_SIGNUP_CHALLENGE_METHOD
17+
18+
override fun handle(
19+
api: AuthenticationAPIClient,
20+
request: MethodCallRequest,
21+
result: MethodChannel.Result
22+
) {
23+
val args = request.data
24+
val connection = args["connection"] as? String
25+
val organization = args["organization"] as? String
26+
27+
@Suppress("UNCHECKED_CAST")
28+
val userMetadata = args["userMetadata"] as? Map<String, String>
29+
val userData = UserData(
30+
email = args["email"] as? String,
31+
phoneNumber = args["phoneNumber"] as? String,
32+
userName = args["username"] as? String,
33+
name = args["name"] as? String,
34+
givenName = args["givenName"] as? String,
35+
familyName = args["familyName"] as? String,
36+
nickName = args["nickname"] as? String,
37+
picture = args["picture"] as? String,
38+
userMetadata = userMetadata
39+
)
40+
41+
api.signupWithPasskey(userData, connection, organization)
42+
.start(object : Callback<PasskeyRegistrationChallenge, AuthenticationException> {
43+
override fun onFailure(exception: AuthenticationException) {
44+
result.error(
45+
exception.getCode(),
46+
exception.getDescription(),
47+
exception.toMap()
48+
)
49+
}
50+
51+
override fun onSuccess(challenge: PasskeyRegistrationChallenge) {
52+
// Forward the full WebAuthn registration options so the
53+
// create-credential step can pass them to Credential
54+
// Manager's CreatePublicKeyCredentialRequest verbatim.
55+
val authParamsPublicKey: Map<*, *> = Gson().fromJson(
56+
Gson().toJson(challenge.authParamsPublicKey),
57+
Map::class.java
58+
)
59+
result.success(
60+
mapOf(
61+
"authSession" to challenge.authSession,
62+
"authParamsPublicKey" to authParamsPublicKey
63+
)
64+
)
65+
}
66+
})
67+
}
68+
}

0 commit comments

Comments
 (0)