diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 749d306c1..848e98d0b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -309,7 +309,7 @@ jobs: test-windows-unit: name: Run native Windows unit tests - runs-on: windows-2022 + runs-on: windows-latest environment: ${{ github.event.pull_request.head.repo.fork && 'external' || 'internal' }} steps: @@ -349,7 +349,7 @@ jobs: ${{ github.workspace }}\vcpkg\vcpkg install cpprestsdk:x64-windows openssl:x64-windows boost-system:x64-windows boost-date-time:x64-windows boost-regex:x64-windows shell: cmd env: - VCPKG_BINARY_SOURCES: 'clear;files,${{ github.workspace }}/vcpkg-binary-cache,readwrite' + VCPKG_BINARY_SOURCES: 'clear;files,${{ github.workspace }}/vcpkg-binary-cache,readwrite;x-gha,readwrite' - name: Cache Windows example app build uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # pin@v5.0.5 diff --git a/auth0_flutter/EXAMPLES.md b/auth0_flutter/EXAMPLES.md index 1cccbada1..91101aec5 100644 --- a/auth0_flutter/EXAMPLES.md +++ b/auth0_flutter/EXAMPLES.md @@ -36,6 +36,8 @@ - [📱 Authentication API](#-authentication-api) - [Login with database connection](#login-with-database-connection) - [Sign up with database connection](#sign-up-with-database-connection) + - [Log in with passkeys](#log-in-with-passkeys) + - [Sign up with passkeys](#sign-up-with-passkeys) - [Passwordless Login](#passwordless-login) - [Retrieve user information](#retrieve-user-information) - [Renew credentials](#renew-credentials) @@ -50,6 +52,7 @@ - [Listing and managing authentication methods](#listing-and-managing-authentication-methods) - [Enrolling a factor with OTP (phone, email, TOTP)](#enrolling-a-factor-with-otp-phone-email-totp) - [Enrolling a factor without OTP (push, recovery code)](#enrolling-a-factor-without-otp-push-recovery-code) + - [Enrolling a passkey](#enrolling-a-passkey) - [Using DPoP](#using-dpop) - [Errors](#errors-3) @@ -1032,6 +1035,8 @@ final credentials = await auth0Web.credentials(); - [Login with database connection](#login-with-database-connection) - [Sign up with database connection](#sign-up-with-database-connection) +- [Log in with passkeys](#log-in-with-passkeys) +- [Sign up with passkeys](#sign-up-with-passkeys) - [Retrieve user information](#retrieve-user-information) - [Renew credentials](#renew-credentials) - [API client errors](#api-client-errors) @@ -1100,6 +1105,134 @@ final databaseUser = await auth0.api.signup( > 💡 You might want to log the user in after signup. See [Login with database connection](#login-with-database-connection) above for an example. +### Log in with passkeys + +> This feature is available on **iOS 16.6+** and **Android 9+ (API 28)** only. + +[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). + +> ⚠️ Passkeys require additional configuration on both your Auth0 tenant and your app: +> - 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. +> - 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). +> - 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. + +The SDK exposes **two** methods for passkey login — `passkeyLoginChallenge` and `passkeyCredentialExchange` — and leaves presenting the OS passkey UI to your app. The flow is: + +1. Request a login challenge from Auth0 with `passkeyLoginChallenge`. +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`. +3. Exchange that credential for Auth0 tokens with `passkeyCredentialExchange`. + +```dart +// 1. Request a login challenge from Auth0. +final challenge = await auth0.api.passkeyLoginChallenge( + connection: 'Username-Password-Authentication'); + +// 2. Present the OS passkey UI in your app (not provided by the SDK) using +// `challenge.authParamsPublicKey`, then build a PasskeyCredential from the +// resulting WebAuthn assertion. All values are base64url-encoded. +final credential = PasskeyCredential( + id: '', + rawId: '', + type: 'public-key', + authenticatorAttachment: 'platform', + response: PasskeyAuthenticatorResponse( + clientDataJSON: '', + authenticatorData: '', + signature: '', + userHandle: '')); + +// 3. Exchange the credential for Auth0 tokens. +final credentials = await auth0.api.passkeyCredentialExchange( + challenge: challenge, + credential: credential, + connection: 'Username-Password-Authentication'); + +// Store the credentials afterward +final didStore = + await auth0.credentialsManager.storeCredentials(credentials); +``` + +
+ Add an audience and scope values + +```dart +final credentials = await auth0.api.passkeyCredentialExchange( + challenge: challenge, + credential: credential, + connection: 'Username-Password-Authentication', + audience: 'YOUR_AUTH0_API_IDENTIFIER', + scopes: {'profile', 'email', 'offline_access', 'read:todos'}); +``` + +
+ +### Sign up with passkeys + +> This feature is available on **iOS 16.6+** and **Android 9+ (API 28)** only. + +[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). + +> ⚠️ Passkeys require additional configuration on both your Auth0 tenant and your app: +> - 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. +> - 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). +> - 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. + +The SDK exposes **two** methods for passkey signup — `passkeySignupChallenge` and `passkeyCredentialExchange` — and leaves presenting the OS passkey UI to your app. The flow is: + +1. Request a registration challenge from Auth0 with `passkeySignupChallenge`. +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`. +3. Exchange that credential for Auth0 tokens with `passkeyCredentialExchange` — the same method used for login. + +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. + +```dart +// 1. Request a registration challenge from Auth0. You can identify the new +// user with any combination of email, phoneNumber, username, name, +// givenName, familyName, nickname, and picture. +final challenge = await auth0.api.passkeySignupChallenge( + email: 'jane.smith@example.com', + name: 'Jane Smith', + givenName: 'Jane', + familyName: 'Smith', + connection: 'Username-Password-Authentication'); + +// 2. Present the OS passkey-creation UI in your app (not provided by the SDK) +// using `challenge.authParamsPublicKey`, then build a PasskeyCredential from +// the resulting WebAuthn attestation. All values are base64url-encoded. +final credential = PasskeyCredential( + id: '', + rawId: '', + type: 'public-key', + authenticatorAttachment: 'platform', + response: PasskeyAuthenticatorResponse( + clientDataJSON: '', + attestationObject: '')); + +// 3. Exchange the credential for Auth0 tokens. +final credentials = await auth0.api.passkeyCredentialExchange( + challenge: challenge, + credential: credential, + connection: 'Username-Password-Authentication'); + +// Store the credentials afterward +final didStore = + await auth0.credentialsManager.storeCredentials(credentials); +``` + +
+ Add an audience and scope values + +```dart +final credentials = await auth0.api.passkeyCredentialExchange( + challenge: challenge, + credential: credential, + connection: 'Username-Password-Authentication', + audience: 'YOUR_AUTH0_API_IDENTIFIER', + scopes: {'profile', 'email', 'offline_access', 'read:todos'}); +``` + +
+ ### Passwordless Login 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. @@ -1445,6 +1578,44 @@ final method = await myAccount.confirmEnrollment( The same flow applies to `enrollRecoveryCode` (`factorType: 'recovery-code'`). +### Enrolling a passkey + +A signed-in user can add a passkey as a new authentication method. Like passkey login and signup, this is a two-step flow and the SDK leaves presenting the OS passkey UI to your app: + +1. Request an enrollment challenge with `enrollPasskeyChallenge`. +2. **In your app**, present the platform authenticator using `challenge.authParamsPublicKey` to create a passkey, and map the resulting WebAuthn attestation into a `PasskeyCredential`. 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). +3. Submit the credential with `enrollPasskey` to complete the enrollment. + +> ⚠️ Passkeys require a [custom domain](https://auth0.com/docs/customize/custom-domains) on your tenant and additional configuration. See [Sign up with passkeys](#sign-up-with-passkeys) for details. + +The access token must include the `create:me:authentication_methods` scope. + +```dart +// 1. Request an enrollment challenge. +final challenge = await myAccount.enrollPasskeyChallenge(); + +// 2. Present the OS passkey creation UI in your app (not provided by the SDK) +// using `challenge.authParamsPublicKey`, then build a PasskeyCredential from +// the resulting WebAuthn attestation. +final credential = PasskeyCredential( + id: '...', + rawId: '...', + type: 'public-key', + response: PasskeyAuthenticatorResponse( + clientDataJSON: '...', + attestationObject: '...', + ), +); + +// 3. Submit the credential to complete the enrollment. +final method = await myAccount.enrollPasskey( + challenge: challenge, + credential: credential, +); + +print('Enrolled passkey: ${method.id} (${method.relyingPartyId})'); +``` + ### Using DPoP To secure My Account API requests with [DPoP](https://www.rfc-editor.org/rfc/rfc9449.html) (Demonstrating Proof-of-Possession) sender-constrained tokens, set `useDPoP` to `true` when creating the client. It defaults to `false`. The DPoP key pair is generated and stored securely on the device (Keychain on iOS, Keystore on Android). diff --git a/auth0_flutter/README.md b/auth0_flutter/README.md index 862e8c278..af2661d8c 100644 --- a/auth0_flutter/README.md +++ b/auth0_flutter/README.md @@ -586,6 +586,8 @@ void dispose() { - [Check for stored credentials](EXAMPLES.md#check-for-stored-credentials) - check if the user is already logged in when your app starts up. - [Retrieve stored credentials](EXAMPLES.md#retrieve-stored-credentials) - fetch the user's credentials from the storage, automatically renewing them if they have expired. - [Retrieve user information](EXAMPLES.md#retrieve-user-information) - fetch the latest user information from the `/userinfo` endpoint. +- [Log in with passkeys](EXAMPLES.md#log-in-with-passkeys) - authenticate an existing user with a passkey using the platform authenticator (iOS/Android only). +- [Sign up with passkeys](EXAMPLES.md#sign-up-with-passkeys) - register a new user with a passkey using the platform authenticator (iOS/Android only). - [Native to Web SSO](EXAMPLES.md#native-to-web-sso) - obtain a session transfer token to authenticate a WebView without re-prompting the user. - [Handle Android process death](#android-handle-process-death-during-login) - recover credentials when the OS kills your app during login. @@ -621,6 +623,9 @@ void dispose() { - [resetPassword](https://pub.dev/documentation/auth0_flutter/latest/auth0_flutter/AuthenticationApi/resetPassword.html) - [signup](https://pub.dev/documentation/auth0_flutter/latest/auth0_flutter/AuthenticationApi/signup.html) - [userProfile](https://pub.dev/documentation/auth0_flutter/latest/auth0_flutter/AuthenticationApi/userProfile.html) +- [passkeyLoginChallenge](https://pub.dev/documentation/auth0_flutter/latest/auth0_flutter/AuthenticationApi/passkeyLoginChallenge.html) - request a WebAuthn assertion challenge to log in an existing user with a passkey (iOS 16.6+ / Android 9+) +- [passkeySignupChallenge](https://pub.dev/documentation/auth0_flutter/latest/auth0_flutter/AuthenticationApi/passkeySignupChallenge.html) - request a WebAuthn attestation challenge to register a new user with a passkey (iOS 16.6+ / Android 9+) +- [passkeyCredentialExchange](https://pub.dev/documentation/auth0_flutter/latest/auth0_flutter/AuthenticationApi/passkeyCredentialExchange.html) - exchange a passkey credential (assertion or attestation) for Auth0 tokens #### Credentials Manager @@ -715,6 +720,36 @@ await myAccount.confirmEnrollment( ); ``` +##### Passkey enrollment + +A signed-in user can add a passkey as a new authentication method. Like passkey login and signup, the SDK handles only the Auth0 API calls — presenting the OS passkey UI is left to your app: + +```dart +// 1. Request an enrollment challenge. +final challenge = await myAccount.enrollPasskeyChallenge(); + +// 2. Present the OS passkey-creation UI in your app (not provided by the SDK) +// using `challenge.authParamsPublicKey`, then build a PasskeyCredential from +// the resulting WebAuthn attestation. +final credential = PasskeyCredential( + id: '...', + rawId: '...', + type: 'public-key', + response: PasskeyAuthenticatorResponse( + clientDataJSON: '...', + attestationObject: '...', + ), +); + +// 3. Submit the credential to complete enrollment. +final method = await myAccount.enrollPasskey( + challenge: challenge, + credential: credential, +); +``` + +The access token must include the `create:me:authentication_methods` scope. See [Enrolling a passkey](EXAMPLES.md#enrolling-a-passkey) for the full example. + ##### DPoP To secure My Account API requests with [DPoP](https://www.rfc-editor.org/rfc/rfc9449.html) (Demonstrating Proof-of-Possession) sender-constrained tokens, set `useDPoP` to `true` when creating the client. It defaults to `false`. The DPoP key pair is generated and stored securely on the device (Keychain on iOS, Keystore on Android). diff --git a/auth0_flutter/android/build.gradle b/auth0_flutter/android/build.gradle index 531eddcc6..cec326c79 100644 --- a/auth0_flutter/android/build.gradle +++ b/auth0_flutter/android/build.gradle @@ -74,6 +74,7 @@ android { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation 'com.auth0.android:auth0:3.18.0' + implementation 'com.google.code.gson:gson:2.10.1' testImplementation 'junit:junit:4.13.2' testImplementation 'org.hamcrest:java-hamcrest:2.0.0.0' testImplementation "org.mockito.kotlin:mockito-kotlin:4.1.0" diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterAuthMethodCallHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterAuthMethodCallHandler.kt index cc1626f67..5afd1d581 100644 --- a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterAuthMethodCallHandler.kt +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterAuthMethodCallHandler.kt @@ -14,14 +14,14 @@ class Auth0FlutterAuthMethodCallHandler( private val apiRequestHandlers: List ) : MethodCallHandler { lateinit var context: Context - + override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { val request = MethodCallRequest.fromCall(call) - + val apiHandler = apiRequestHandlers.find { it.method == call.method } if (apiHandler != null) { val api = AuthenticationAPIClient(request.account) - + val useDPoP = request.data["useDPoP"] as? Boolean ?: false if (useDPoP) { api.useDPoP(context) diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterPlugin.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterPlugin.kt index 1f3d9fc4a..7129ab672 100644 --- a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterPlugin.kt +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterPlugin.kt @@ -58,6 +58,8 @@ class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware { GetAuthenticationMethodRequestHandler(), DeleteAuthenticationMethodRequestHandler(), GetFactorsRequestHandler(), + EnrollPasskeyChallengeRequestHandler(), + EnrollPasskeyRequestHandler(), EnrollPhoneRequestHandler(), EnrollEmailRequestHandler(), EnrollTotpRequestHandler(), @@ -133,7 +135,10 @@ class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware { RenewApiRequestHandler(), CustomTokenExchangeApiRequestHandler(), SSOExchangeApiRequestHandler(), - ResetPasswordApiRequestHandler() + ResetPasswordApiRequestHandler(), + PasskeyLoginChallengeApiRequestHandler(), + PasskeySignupChallengeApiRequestHandler(), + PasskeyCredentialExchangeApiRequestHandler() ) ) authCallHandler.context = context diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterWebAuthMethodCallHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterWebAuthMethodCallHandler.kt index db1bd0957..32286aaa1 100644 --- a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterWebAuthMethodCallHandler.kt +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterWebAuthMethodCallHandler.kt @@ -9,7 +9,7 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.Result class Auth0FlutterWebAuthMethodCallHandler(private val requestHandlers: List) : MethodCallHandler { - lateinit var activity: Activity + lateinit var activity: Activity override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { val requestHandler = requestHandlers.find { it.method == call.method } diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/CredentialsManagerMethodCallHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/CredentialsManagerMethodCallHandler.kt index 3b153f8f7..8b5deadf6 100644 --- a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/CredentialsManagerMethodCallHandler.kt +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/CredentialsManagerMethodCallHandler.kt @@ -27,7 +27,10 @@ private object BiometricAuthLevel { } class CredentialsManagerMethodCallHandler(private val requestHandlers: List) : MethodCallHandler { - lateinit var activity: Activity + // Null while the plugin is detached from an Activity (see ActivityAware + // callbacks in Auth0FlutterPlugin). Only required for biometric/local + // authentication, which needs a FragmentActivity. + var activity: Activity? = null lateinit var context: Context private data class ManagerCacheKey( diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/MyAccountExtensions.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/MyAccountExtensions.kt index a53c56ef1..a8c616ce8 100644 --- a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/MyAccountExtensions.kt +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/MyAccountExtensions.kt @@ -5,11 +5,15 @@ import com.auth0.android.result.EmailAuthenticationMethod import com.auth0.android.result.EnrollmentChallenge import com.auth0.android.result.MfaAuthenticationMethod import com.auth0.android.result.MfaEnrollmentChallenge +import com.auth0.android.result.PasskeyAuthenticationMethod +import com.auth0.android.result.PasskeyEnrollmentChallenge import com.auth0.android.result.PhoneAuthenticationMethod import com.auth0.android.result.PushNotificationAuthenticationMethod import com.auth0.android.result.RecoveryCodeEnrollmentChallenge import com.auth0.android.result.TotpAuthenticationMethod import com.auth0.android.result.TotpEnrollmentChallenge +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken fun AuthenticationMethod.toMyAccountMethodMap(): Map { return buildMap { @@ -44,6 +48,40 @@ fun AuthenticationMethod.toMyAccountMethodMap(): Map { } } +fun PasskeyAuthenticationMethod.toMyAccountPasskeyMethodMap(): Map { + return buildMap { + put("id", id) + put("type", type) + put("created_at", createdAt) + put("usage", usage) + put("identity_user_id", identityUserId) + put("user_agent", userAgent) + put("key_id", keyId) + put("public_key", publicKey) + put("user_handle", userHandle) + put("credential_device_type", credentialDeviceType) + put("credential_backed_up", credentialBackedUp) + put("transports", transports) + put("aaguid", aaguid) + put("relying_party_id", relyingPartyId) + } +} + +fun PasskeyEnrollmentChallenge.toMyAccountPasskeyChallengeMap(): Map { + val gson = Gson() + // AuthnParamsPublicKey is a typed data class; convert via JSON tree to avoid + // a toJson()/fromJson(String) round-trip through an intermediate String. + val authParamsPublicKeyMap: Map = gson.fromJson( + gson.toJsonTree(authParamsPublicKey), + object : TypeToken>() {}.type + ) + return buildMap { + put("authenticationMethodId", authenticationMethodId) + put("authSession", authSession) + put("authParamsPublicKey", authParamsPublicKeyMap) + } +} + fun EnrollmentChallenge.toMyAccountChallengeMap(): Map { return buildMap { put("id", id) diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/PasskeyCredentialExchangeApiRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/PasskeyCredentialExchangeApiRequestHandler.kt new file mode 100644 index 000000000..a3aaa47f8 --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/PasskeyCredentialExchangeApiRequestHandler.kt @@ -0,0 +1,102 @@ +package com.auth0.auth0_flutter.request_handlers.api + +import com.auth0.android.authentication.AuthenticationAPIClient +import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.callback.Callback +import com.auth0.android.result.Credentials +import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import com.auth0.auth0_flutter.toMap +import com.auth0.auth0_flutter.utils.assertHasProperties +import com.google.gson.Gson +import io.flutter.plugin.common.MethodChannel + +private const val AUTH_PASSKEY_CREDENTIAL_EXCHANGE_METHOD = + "auth#passkeyCredentialExchange" + +/** + * Exchanges an app-supplied passkey credential (a login assertion or a signup + * attestation) and its challenge for Auth0 tokens at the `/oauth/token` + * endpoint. This handler does not present any UI. + * + * Both passkey login and signup finish here: Auth0.Android's `signinWithPasskey` + * accepts the credential as a JSON string and handles both assertion and + * attestation payloads, so a single handler serves both flows. + */ +class PasskeyCredentialExchangeApiRequestHandler : ApiRequestHandler { + override val method: String = AUTH_PASSKEY_CREDENTIAL_EXCHANGE_METHOD + + private val gson = Gson() + + override fun handle( + api: AuthenticationAPIClient, + request: MethodCallRequest, + result: MethodChannel.Result + ) { + val args = request.data + + assertHasProperties(listOf("challenge.authSession", "credential"), args) + + val challenge = args["challenge"] as? Map<*, *> + ?: throw IllegalArgumentException("Required property 'challenge' must be a map.") + val authSession = challenge["authSession"] as? String + ?: throw IllegalArgumentException("Required property 'challenge.authSession' must be a string.") + val credentialMap = args["credential"] as? Map<*, *> + ?: throw IllegalArgumentException("Required property 'credential' must be a map.") + + val connection = args["connection"] as? String + val organization = args["organization"] as? String + val audience = args["audience"] as? String + val scopes = (args["scopes"] as? List<*>)?.filterIsInstance() + ?.joinToString(" ") ?: "" + val parameters = (args["parameters"] as? Map<*, *>)?.mapKeys { it.key.toString() } + ?.mapValues { it.value.toString() } ?: emptyMap() + + // signinWithPasskey accepts the WebAuthn credential response as a JSON + // string in the standard format produced by the platform authenticator. + val authResponseJson = gson.toJson(credentialMap) + + val builder = api.signinWithPasskey( + authSession, + authResponseJson, + connection, + organization + ) + .validateClaims() + + if (scopes.isNotEmpty()) { + builder.setScope(scopes) + } + if (audience != null) { + builder.setAudience(audience) + } + if (parameters.isNotEmpty()) { + builder.addParameters(parameters) + } + + builder.start(object : Callback { + override fun onFailure(exception: AuthenticationException) { + result.error( + exception.getCode(), + exception.getDescription(), + exception.toMap() + ) + } + + override fun onSuccess(credentials: Credentials) { + val scope = credentials.scope?.split(" ") ?: listOf() + val formattedDate = credentials.expiresAt.toInstant().toString() + result.success( + mapOf( + "accessToken" to credentials.accessToken, + "idToken" to credentials.idToken, + "refreshToken" to credentials.refreshToken, + "userProfile" to credentials.user.toMap(), + "expiresAt" to formattedDate, + "scopes" to scope, + "tokenType" to credentials.type + ) + ) + } + }) + } +} \ No newline at end of file diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/PasskeyLoginChallengeApiRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/PasskeyLoginChallengeApiRequestHandler.kt new file mode 100644 index 000000000..0a132cf3e --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/PasskeyLoginChallengeApiRequestHandler.kt @@ -0,0 +1,51 @@ +package com.auth0.auth0_flutter.request_handlers.api + +import com.auth0.android.authentication.AuthenticationAPIClient +import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.callback.Callback +import com.auth0.android.result.PasskeyChallenge +import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import com.auth0.auth0_flutter.toMap +import io.flutter.plugin.common.MethodChannel + +private const val AUTH_PASSKEY_LOGIN_CHALLENGE_METHOD = "auth#passkeyLoginChallenge" + +class PasskeyLoginChallengeApiRequestHandler : ApiRequestHandler { + override val method: String = AUTH_PASSKEY_LOGIN_CHALLENGE_METHOD + + override fun handle( + api: AuthenticationAPIClient, + request: MethodCallRequest, + result: MethodChannel.Result + ) { + val args = request.data + val connection = args["connection"] as? String + val organization = args["organization"] as? String + + api.passkeyChallenge(connection, organization) + .start(object : Callback { + override fun onFailure(exception: AuthenticationException) { + result.error( + exception.getCode(), + exception.getDescription(), + exception.toMap() + ) + } + + override fun onSuccess(challenge: PasskeyChallenge) { + val authParamsPublicKey = mapOf( + "challenge" to challenge.authParamsPublicKey.challenge, + "rpId" to challenge.authParamsPublicKey.rpId, + "timeout" to challenge.authParamsPublicKey.timeout, + "userVerification" to challenge.authParamsPublicKey.userVerification + ) + result.success( + mapOf( + "authSession" to challenge.authSession, + "authParamsPublicKey" to authParamsPublicKey + ) + ) + } + }) + } +} diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/PasskeySignupChallengeApiRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/PasskeySignupChallengeApiRequestHandler.kt new file mode 100644 index 000000000..48adb4426 --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/api/PasskeySignupChallengeApiRequestHandler.kt @@ -0,0 +1,69 @@ +package com.auth0.auth0_flutter.request_handlers.api + +import com.auth0.android.authentication.AuthenticationAPIClient +import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.callback.Callback +import com.auth0.android.request.UserData +import com.auth0.android.result.PasskeyRegistrationChallenge +import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import com.auth0.auth0_flutter.toMap +import com.google.gson.Gson +import io.flutter.plugin.common.MethodChannel + +private const val AUTH_PASSKEY_SIGNUP_CHALLENGE_METHOD = "auth#passkeySignupChallenge" + +class PasskeySignupChallengeApiRequestHandler : ApiRequestHandler { + override val method: String = AUTH_PASSKEY_SIGNUP_CHALLENGE_METHOD + + override fun handle( + api: AuthenticationAPIClient, + request: MethodCallRequest, + result: MethodChannel.Result + ) { + val args = request.data + val connection = args["connection"] as? String + val organization = args["organization"] as? String + + val userMetadata = (args["userMetadata"] as? Map<*, *>) + ?.mapKeys { it.key.toString() } + ?.mapValues { it.value.toString() } + val userData = UserData( + email = args["email"] as? String, + phoneNumber = args["phoneNumber"] as? String, + userName = args["username"] as? String, + name = args["name"] as? String, + givenName = args["givenName"] as? String, + familyName = args["familyName"] as? String, + nickName = args["nickname"] as? String, + picture = args["picture"] as? String, + userMetadata = userMetadata + ) + + api.signupWithPasskey(userData, connection, organization) + .start(object : Callback { + override fun onFailure(exception: AuthenticationException) { + result.error( + exception.getCode(), + exception.getDescription(), + exception.toMap() + ) + } + + override fun onSuccess(challenge: PasskeyRegistrationChallenge) { + // Forward the full WebAuthn registration options so the + // create-credential step can pass them to Credential + // Manager's CreatePublicKeyCredentialRequest verbatim. + val authParamsPublicKey: Map<*, *> = Gson().fromJson( + Gson().toJson(challenge.authParamsPublicKey), + Map::class.java + ) + result.success( + mapOf( + "authSession" to challenge.authSession, + "authParamsPublicKey" to authParamsPublicKey + ) + ) + } + }) + } +} diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollPasskeyChallengeRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollPasskeyChallengeRequestHandler.kt new file mode 100644 index 000000000..fd36ed5bc --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollPasskeyChallengeRequestHandler.kt @@ -0,0 +1,46 @@ +package com.auth0.auth0_flutter.request_handlers.my_account + +import com.auth0.android.callback.Callback +import com.auth0.android.myaccount.MyAccountAPIClient +import com.auth0.android.myaccount.MyAccountException +import com.auth0.android.result.PasskeyEnrollmentChallenge +import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import com.auth0.auth0_flutter.toMyAccountMap +import com.auth0.auth0_flutter.toMyAccountPasskeyChallengeMap +import io.flutter.plugin.common.MethodChannel + +private const val MY_ACCOUNT_ENROLL_PASSKEY_CHALLENGE_METHOD = + "myAccount#enrollPasskeyChallenge" + +class EnrollPasskeyChallengeRequestHandler : MyAccountRequestHandler { + override val method: String = MY_ACCOUNT_ENROLL_PASSKEY_CHALLENGE_METHOD + + override fun handle( + client: MyAccountAPIClient, + request: MethodCallRequest, + result: MethodChannel.Result + ) { + val userIdentityId = request.data["userIdentityId"] as? String + val connection = request.data["connection"] as? String + + client.passkeyEnrollmentChallenge(userIdentityId, connection) + .start(object : + Callback { + override fun onFailure( + exception: MyAccountException + ) { + result.error( + exception.getCode(), + exception.getDescription(), + exception.toMyAccountMap() + ) + } + + override fun onSuccess( + res: PasskeyEnrollmentChallenge + ) { + result.success(res.toMyAccountPasskeyChallengeMap()) + } + }) + } +} diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollPasskeyRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollPasskeyRequestHandler.kt new file mode 100644 index 000000000..5b712f92e --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollPasskeyRequestHandler.kt @@ -0,0 +1,138 @@ +package com.auth0.auth0_flutter.request_handlers.my_account + +import com.auth0.android.callback.Callback +import com.auth0.android.myaccount.MyAccountAPIClient +import com.auth0.android.myaccount.MyAccountException +import com.auth0.android.request.ClientExtensionResults +import com.auth0.android.request.CredProps +import com.auth0.android.request.PublicKeyCredentials +import com.auth0.android.request.Response +import com.auth0.android.result.AuthnParamsPublicKey +import com.auth0.android.result.PasskeyAuthenticationMethod +import com.auth0.android.result.PasskeyEnrollmentChallenge +import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import com.auth0.auth0_flutter.toMyAccountMap +import com.auth0.auth0_flutter.toMyAccountPasskeyMethodMap +import com.auth0.auth0_flutter.utils.assertHasProperties +import com.google.gson.Gson +import io.flutter.plugin.common.MethodChannel + +private const val MY_ACCOUNT_ENROLL_PASSKEY_METHOD = + "myAccount#enrollPasskey" + +class EnrollPasskeyRequestHandler : MyAccountRequestHandler { + override val method: String = MY_ACCOUNT_ENROLL_PASSKEY_METHOD + + override fun handle( + client: MyAccountAPIClient, + request: MethodCallRequest, + result: MethodChannel.Result + ) { + assertHasProperties( + listOf( + "challenge", + "challenge.authSession", + "challenge.authenticationMethodId", + "challenge.authParamsPublicKey", + "credential", + "credential.id", + "credential.rawId", + "credential.response", + "credential.response.clientDataJSON", + "credential.response.attestationObject", + ), + request.data + ) + + val challengeMap = request.data["challenge"] as? Map<*, *> + ?: throw IllegalArgumentException("Required property 'challenge' must be a map.") + val credentialMap = request.data["credential"] as? Map<*, *> + ?: throw IllegalArgumentException("Required property 'credential' must be a map.") + + val challenge = reconstructChallenge(challengeMap) + val credentials = reconstructCredentials(credentialMap) + + client.enroll(credentials, challenge) + .start(object : + Callback { + override fun onFailure( + exception: MyAccountException + ) { + result.error( + exception.getCode(), + exception.getDescription(), + exception.toMyAccountMap() + ) + } + + override fun onSuccess( + res: PasskeyAuthenticationMethod + ) { + result.success(res.toMyAccountPasskeyMethodMap()) + } + }) + } + + private fun reconstructChallenge( + challengeMap: Map<*, *> + ): PasskeyEnrollmentChallenge { + val gson = Gson() + // The Flutter layer forwards `authParamsPublicKey` as the verbatim + // WebAuthn creation options map; convert it back into the SDK's typed + // representation. + val authParamsPublicKey = gson.fromJson( + gson.toJson(challengeMap["authParamsPublicKey"]), + AuthnParamsPublicKey::class.java + ) + return PasskeyEnrollmentChallenge( + challengeMap["authenticationMethodId"] as? String + ?: throw IllegalArgumentException( + "Required property 'challenge.authenticationMethodId' must be a string."), + challengeMap["authSession"] as? String + ?: throw IllegalArgumentException( + "Required property 'challenge.authSession' must be a string."), + authParamsPublicKey + ) + } + + private fun reconstructCredentials( + credentialMap: Map<*, *> + ): PublicKeyCredentials { + val response = credentialMap["response"] as? Map<*, *> + ?: throw IllegalArgumentException( + "Required property 'credential.response' must be a map.") + val clientExtensionResults = + credentialMap["clientExtensionResults"] as? Map<*, *> + val credProps = clientExtensionResults?.get("credProps") as? Map<*, *> + val residentKey = (credProps?.get("rk") as? Boolean) ?: false + + return PublicKeyCredentials( + authenticatorAttachment = + (credentialMap["authenticatorAttachment"] as? String) + ?: "platform", + clientExtensionResults = ClientExtensionResults( + credProps = CredProps(rk = residentKey) + ), + id = credentialMap["id"] as? String + ?: throw IllegalArgumentException( + "Required property 'credential.id' must be a string."), + rawId = credentialMap["rawId"] as? String + ?: throw IllegalArgumentException( + "Required property 'credential.rawId' must be a string."), + response = Response( + attestationObject = response["attestationObject"] as? String + ?: throw IllegalArgumentException( + "Required property 'credential.response.attestationObject' must be a string."), + authenticatorData = (response["authenticatorData"] as? String) ?: "", + clientDataJSON = response["clientDataJSON"] as? String + ?: throw IllegalArgumentException( + "Required property 'credential.response.clientDataJSON' must be a string."), + transports = (response["transports"] as? List<*>) + ?.filterIsInstance() ?: emptyList(), + signature = (response["signature"] as? String) ?: "", + userHandle = (response["userHandle"] as? String) ?: "" + ), + type = (credentialMap["type"] as? String) ?: "public-key" + ) + } +} diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/PasskeyCredentialExchangeApiRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/PasskeyCredentialExchangeApiRequestHandlerTest.kt new file mode 100644 index 000000000..fd6167860 --- /dev/null +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/PasskeyCredentialExchangeApiRequestHandlerTest.kt @@ -0,0 +1,327 @@ +package com.auth0.auth0_flutter.request_handlers.api + +import android.os.Build +import com.auth0.android.Auth0 +import com.auth0.android.authentication.AuthenticationAPIClient +import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.callback.Callback +import com.auth0.android.request.AuthenticationRequest +import com.auth0.android.result.Credentials +import com.auth0.auth0_flutter.JwtTestUtils +import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import io.flutter.plugin.common.MethodChannel.Result +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.* +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.* + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P]) +class PasskeyCredentialExchangeApiRequestHandlerTest { + private fun challengeMap() = mapOf( + "authSession" to "test-auth-session", + "authParamsPublicKey" to mapOf( + "challenge" to "test-challenge", + "rpId" to "test-rp-id" + ) + ) + + // A login assertion credential. + private fun loginCredentialMap() = mapOf( + "id" to "test-id", + "rawId" to "test-raw-id", + "type" to "public-key", + "authenticatorAttachment" to "platform", + "response" to mapOf( + "clientDataJSON" to "test-client-data", + "authenticatorData" to "test-authenticator-data", + "signature" to "test-signature", + "userHandle" to "test-user-handle" + ) + ) + + // A signup attestation credential. + private fun signupCredentialMap() = mapOf( + "id" to "test-id", + "rawId" to "test-raw-id", + "type" to "public-key", + "authenticatorAttachment" to "platform", + "response" to mapOf( + "clientDataJSON" to "test-client-data", + "attestationObject" to "test-attestation" + ) + ) + + @Test + fun `should throw when challenge is missing`() { + val options = hashMapOf("credential" to loginCredentialMap()) + val handler = PasskeyCredentialExchangeApiRequestHandler() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val request = MethodCallRequest(account = mockAccount, options) + + val exception = Assert.assertThrows(IllegalArgumentException::class.java) { + handler.handle(mockApi, request, mockResult) + } + + assertThat( + exception.message, + equalTo("Required property 'challenge.authSession' is not provided.") + ) + } + + @Test + fun `should throw when authSession is missing`() { + val options = hashMapOf( + "challenge" to mapOf( + "authParamsPublicKey" to mapOf("challenge" to "c", "rpId" to "r") + ), + "credential" to loginCredentialMap() + ) + val handler = PasskeyCredentialExchangeApiRequestHandler() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val request = MethodCallRequest(account = mockAccount, options) + + val exception = Assert.assertThrows(IllegalArgumentException::class.java) { + handler.handle(mockApi, request, mockResult) + } + + assertThat( + exception.message, + equalTo("Required property 'challenge.authSession' is not provided.") + ) + } + + @Test + fun `should throw when credential is missing`() { + val options = hashMapOf("challenge" to challengeMap()) + val handler = PasskeyCredentialExchangeApiRequestHandler() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val request = MethodCallRequest(account = mockAccount, options) + + val exception = Assert.assertThrows(IllegalArgumentException::class.java) { + handler.handle(mockApi, request, mockResult) + } + + assertThat( + exception.message, + equalTo("Required property 'credential' is not provided.") + ) + } + + @Test + fun `should throw when challenge is not a map`() { + // assertHasProperties fires first because tryGetByKey returns null for a + // non-Map; the resulting message still clearly identifies the bad field. + val options = hashMapOf( + "challenge" to "not-a-map", + "credential" to loginCredentialMap() + ) + val handler = PasskeyCredentialExchangeApiRequestHandler() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val request = MethodCallRequest(account = mockAccount, options) + + val exception = Assert.assertThrows(IllegalArgumentException::class.java) { + handler.handle(mockApi, request, mockResult) + } + + assertThat( + exception.message, + equalTo("Required property 'challenge.authSession' is not provided.") + ) + } + + @Test + fun `should throw when authSession is not a string`() { + val options = hashMapOf( + "challenge" to mapOf("authSession" to 42), + "credential" to loginCredentialMap() + ) + val handler = PasskeyCredentialExchangeApiRequestHandler() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val request = MethodCallRequest(account = mockAccount, options) + + val exception = Assert.assertThrows(IllegalArgumentException::class.java) { + handler.handle(mockApi, request, mockResult) + } + + assertThat( + exception.message, + equalTo("Required property 'challenge.authSession' must be a string.") + ) + } + + @Test + fun `should throw when credential is not a map`() { + val options = hashMapOf( + "challenge" to challengeMap(), + "credential" to "not-a-map" + ) + val handler = PasskeyCredentialExchangeApiRequestHandler() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val request = MethodCallRequest(account = mockAccount, options) + + val exception = Assert.assertThrows(IllegalArgumentException::class.java) { + handler.handle(mockApi, request, mockResult) + } + + assertThat( + exception.message, + equalTo("Required property 'credential' must be a map.") + ) + } + + @Test + fun `should call signinWithPasskey and configure scope, audience and parameters`() { + val options = hashMapOf( + "challenge" to challengeMap(), + "credential" to loginCredentialMap(), + "connection" to "test-connection", + "organization" to "test-org", + "audience" to "test-audience", + "scopes" to arrayListOf("openid", "profile"), + "parameters" to mapOf("test" to "test-value") + ) + val handler = PasskeyCredentialExchangeApiRequestHandler() + val mockBuilder = mock() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val request = MethodCallRequest(account = mockAccount, options) + + doReturn(mockBuilder).`when`(mockApi) + .signinWithPasskey(any(), any(), anyOrNull(), anyOrNull()) + doReturn(mockBuilder).`when`(mockBuilder).validateClaims() + doReturn(mockBuilder).`when`(mockBuilder).setScope(any()) + doReturn(mockBuilder).`when`(mockBuilder).setAudience(any()) + doReturn(mockBuilder).`when`(mockBuilder).addParameters(any()) + + handler.handle(mockApi, request, mockResult) + + verify(mockApi).signinWithPasskey( + eq("test-auth-session"), + any(), + eq("test-connection"), + eq("test-org") + ) + verify(mockBuilder).validateClaims() + verify(mockBuilder).setScope("openid profile") + verify(mockBuilder).setAudience("test-audience") + verify(mockBuilder).addParameters(mapOf("test" to "test-value")) + verify(mockBuilder).start(any()) + } + + @Test + fun `should exchange a signup attestation credential`() { + val options = hashMapOf( + "challenge" to challengeMap(), + "credential" to signupCredentialMap() + ) + val handler = PasskeyCredentialExchangeApiRequestHandler() + val mockBuilder = mock() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val request = MethodCallRequest(account = mockAccount, options) + + doReturn(mockBuilder).`when`(mockApi) + .signinWithPasskey(any(), any(), anyOrNull(), anyOrNull()) + doReturn(mockBuilder).`when`(mockBuilder).validateClaims() + + handler.handle(mockApi, request, mockResult) + + verify(mockApi).signinWithPasskey( + eq("test-auth-session"), + any(), + anyOrNull(), + anyOrNull() + ) + verify(mockBuilder).validateClaims() + verify(mockBuilder).start(any()) + } + + @Test + fun `should call result error on failure`() { + val options = hashMapOf( + "challenge" to challengeMap(), + "credential" to loginCredentialMap() + ) + val handler = PasskeyCredentialExchangeApiRequestHandler() + val mockBuilder = mock() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val request = MethodCallRequest(account = mockAccount, options) + val exception = + AuthenticationException(code = "test-code", description = "test-description") + + doReturn(mockBuilder).`when`(mockApi) + .signinWithPasskey(any(), any(), anyOrNull(), anyOrNull()) + doReturn(mockBuilder).`when`(mockBuilder).validateClaims() + doAnswer { + val cb = it.getArgument>(0) + cb.onFailure(exception) + }.`when`(mockBuilder).start(any()) + + handler.handle(mockApi, request, mockResult) + + verify(mockResult).error(eq("test-code"), eq("test-description"), any()) + } + + @Test + fun `should call result success with credentials on success`() { + val options = hashMapOf( + "challenge" to challengeMap(), + "credential" to loginCredentialMap() + ) + val handler = PasskeyCredentialExchangeApiRequestHandler() + val mockBuilder = mock() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val request = MethodCallRequest(account = mockAccount, options) + + val credentials = Credentials( + JwtTestUtils.createJwt(), + "test-access-token", + "Bearer", + "test-refresh-token", + Date(), + "openid profile" + ) + + doReturn(mockBuilder).`when`(mockApi) + .signinWithPasskey(any(), any(), anyOrNull(), anyOrNull()) + doReturn(mockBuilder).`when`(mockBuilder).validateClaims() + doAnswer { + val cb = it.getArgument>(0) + cb.onSuccess(credentials) + }.`when`(mockBuilder).start(any()) + + handler.handle(mockApi, request, mockResult) + + val captor = argumentCaptor>() + verify(mockResult).success(captor.capture()) + val resultMap = captor.firstValue + assertThat(resultMap["accessToken"] as String, equalTo("test-access-token")) + assertThat(resultMap["tokenType"] as String, equalTo("Bearer")) + assertThat(resultMap["refreshToken"] as String, equalTo("test-refresh-token")) + assertThat(resultMap["scopes"], equalTo(listOf("openid", "profile"))) + } +} diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/PasskeyLoginChallengeApiRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/PasskeyLoginChallengeApiRequestHandlerTest.kt new file mode 100644 index 000000000..a3549b526 --- /dev/null +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/PasskeyLoginChallengeApiRequestHandlerTest.kt @@ -0,0 +1,120 @@ +package com.auth0.auth0_flutter.request_handlers.api + +import com.auth0.android.Auth0 +import com.auth0.android.authentication.AuthenticationAPIClient +import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.callback.Callback +import com.auth0.android.request.Request +import com.auth0.android.result.AuthParamsPublicKey +import com.auth0.android.result.PasskeyChallenge +import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import io.flutter.plugin.common.MethodChannel.Result +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.* +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PasskeyLoginChallengeApiRequestHandlerTest { + private fun challenge() = PasskeyChallenge( + authSession = "test-auth-session", + authParamsPublicKey = AuthParamsPublicKey( + challenge = "test-challenge", + rpId = "test-rp-id", + timeout = 60000, + userVerification = "required" + ) + ) + + @Test + fun `should call passkeyChallenge with the correct parameters`() { + val options = hashMapOf( + "connection" to "test-connection", + "organization" to "test-org" + ) + val handler = PasskeyLoginChallengeApiRequestHandler() + val mockBuilder = mock>() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val request = MethodCallRequest(account = mockAccount, options) + + doReturn(mockBuilder).`when`(mockApi).passkeyChallenge(any(), any()) + + handler.handle(mockApi, request, mockResult) + + verify(mockApi).passkeyChallenge("test-connection", "test-org") + verify(mockBuilder).start(any()) + } + + @Test + fun `should call passkeyChallenge with null parameters when omitted`() { + val options = hashMapOf() + val handler = PasskeyLoginChallengeApiRequestHandler() + val mockBuilder = mock>() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val request = MethodCallRequest(account = mockAccount, options) + + doReturn(mockBuilder).`when`(mockApi).passkeyChallenge(anyOrNull(), anyOrNull()) + + handler.handle(mockApi, request, mockResult) + + verify(mockApi).passkeyChallenge(null, null) + } + + @Test + fun `should call result success with the mapped challenge`() { + val options = hashMapOf("connection" to "test-connection") + val handler = PasskeyLoginChallengeApiRequestHandler() + val mockBuilder = mock>() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val request = MethodCallRequest(account = mockAccount, options) + + doReturn(mockBuilder).`when`(mockApi).passkeyChallenge(any(), anyOrNull()) + doAnswer { + val cb = it.getArgument>(0) + cb.onSuccess(challenge()) + }.`when`(mockBuilder).start(any()) + + handler.handle(mockApi, request, mockResult) + + verify(mockResult).success( + mapOf( + "authSession" to "test-auth-session", + "authParamsPublicKey" to mapOf( + "challenge" to "test-challenge", + "rpId" to "test-rp-id", + "timeout" to 60000, + "userVerification" to "required" + ) + ) + ) + } + + @Test + fun `should call result error on failure`() { + val options = hashMapOf("connection" to "test-connection") + val handler = PasskeyLoginChallengeApiRequestHandler() + val mockBuilder = mock>() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val request = MethodCallRequest(account = mockAccount, options) + val exception = + AuthenticationException(code = "test-code", description = "test-description") + + doReturn(mockBuilder).`when`(mockApi).passkeyChallenge(any(), anyOrNull()) + doAnswer { + val cb = it.getArgument>(0) + cb.onFailure(exception) + }.`when`(mockBuilder).start(any()) + + handler.handle(mockApi, request, mockResult) + + verify(mockResult).error(eq("test-code"), eq("test-description"), any()) + } +} diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/PasskeySignupChallengeApiRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/PasskeySignupChallengeApiRequestHandlerTest.kt new file mode 100644 index 000000000..7770165e4 --- /dev/null +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/api/PasskeySignupChallengeApiRequestHandlerTest.kt @@ -0,0 +1,134 @@ +package com.auth0.auth0_flutter.request_handlers.api + +import com.auth0.android.Auth0 +import com.auth0.android.authentication.AuthenticationAPIClient +import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.callback.Callback +import com.auth0.android.request.Request +import com.auth0.android.request.UserData +import com.auth0.android.result.AuthenticatorSelection +import com.auth0.android.result.AuthnParamsPublicKey +import com.auth0.android.result.PasskeyRegistrationChallenge +import com.auth0.android.result.PasskeyUser +import com.auth0.android.result.PubKeyCredParam +import com.auth0.android.result.RelyingParty +import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import io.flutter.plugin.common.MethodChannel.Result +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.* +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PasskeySignupChallengeApiRequestHandlerTest { + private fun challenge() = PasskeyRegistrationChallenge( + authSession = "test-auth-session", + authParamsPublicKey = AuthnParamsPublicKey( + authenticatorSelection = AuthenticatorSelection( + residentKey = "required", + userVerification = "required" + ), + challenge = "test-challenge", + pubKeyCredParams = listOf(PubKeyCredParam(alg = -7, type = "public-key")), + relyingParty = RelyingParty(id = "test-rp-id", name = "test-rp"), + timeout = 60000, + user = PasskeyUser( + displayName = "test-display", + id = "test-user-id", + name = "test-user-name" + ) + ) + ) + + @Test + fun `should call signupWithPasskey with the correct parameters`() { + val options = hashMapOf( + "email" to "test-email", + "phoneNumber" to "test-phone", + "username" to "test-username", + "name" to "test-name", + "givenName" to "test-given-name", + "familyName" to "test-family-name", + "nickname" to "test-nickname", + "picture" to "https://www.okta.com", + "connection" to "test-connection", + "organization" to "test-org" + ) + val handler = PasskeySignupChallengeApiRequestHandler() + val mockBuilder = mock>() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val request = MethodCallRequest(account = mockAccount, options) + + doReturn(mockBuilder).`when`(mockApi).signupWithPasskey(any(), anyOrNull(), anyOrNull()) + + handler.handle(mockApi, request, mockResult) + + verify(mockApi).signupWithPasskey( + argThat { + this.email == "test-email" && + this.phoneNumber == "test-phone" && + this.userName == "test-username" && + this.name == "test-name" && + this.givenName == "test-given-name" && + this.familyName == "test-family-name" && + this.nickName == "test-nickname" && + this.picture == "https://www.okta.com" + }, + eq("test-connection"), + eq("test-org") + ) + verify(mockBuilder).start(any()) + } + + @Test + fun `should call result success with the mapped challenge`() { + val options = hashMapOf("connection" to "test-connection") + val handler = PasskeySignupChallengeApiRequestHandler() + val mockBuilder = mock>() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val request = MethodCallRequest(account = mockAccount, options) + + doReturn(mockBuilder).`when`(mockApi).signupWithPasskey(any(), anyOrNull(), anyOrNull()) + doAnswer { + val cb = it.getArgument>(0) + cb.onSuccess(challenge()) + }.`when`(mockBuilder).start(any()) + + handler.handle(mockApi, request, mockResult) + + val captor = argumentCaptor>() + verify(mockResult).success(captor.capture()) + val resultMap = captor.firstValue + assert(resultMap["authSession"] == "test-auth-session") + val publicKey = resultMap["authParamsPublicKey"] as Map<*, *> + assert((publicKey["challenge"]) == "test-challenge") + assert((publicKey["user"] as Map<*, *>)["name"] == "test-user-name") + } + + @Test + fun `should call result error on failure`() { + val options = hashMapOf("connection" to "test-connection") + val handler = PasskeySignupChallengeApiRequestHandler() + val mockBuilder = mock>() + val mockApi = mock() + val mockAccount = mock() + val mockResult = mock() + val request = MethodCallRequest(account = mockAccount, options) + val exception = + AuthenticationException(code = "test-code", description = "test-description") + + doReturn(mockBuilder).`when`(mockApi).signupWithPasskey(any(), anyOrNull(), anyOrNull()) + doAnswer { + val cb = it.getArgument>(0) + cb.onFailure(exception) + }.`when`(mockBuilder).start(any()) + + handler.handle(mockApi, request, mockResult) + + verify(mockResult).error(eq("test-code"), eq("test-description"), any()) + } +} diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollPasskeyChallengeRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollPasskeyChallengeRequestHandlerTest.kt new file mode 100644 index 000000000..8b056271e --- /dev/null +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollPasskeyChallengeRequestHandlerTest.kt @@ -0,0 +1,126 @@ +package com.auth0.auth0_flutter.request_handlers.my_account + +import com.auth0.android.Auth0 +import com.auth0.android.callback.Callback +import com.auth0.android.myaccount.MyAccountAPIClient +import com.auth0.android.myaccount.MyAccountException +import com.auth0.android.request.Request +import com.auth0.android.result.AuthenticatorSelection +import com.auth0.android.result.AuthnParamsPublicKey +import com.auth0.android.result.PasskeyEnrollmentChallenge +import com.auth0.android.result.PubKeyCredParam +import com.auth0.android.result.PasskeyUser +import com.auth0.android.result.RelyingParty +import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import io.flutter.plugin.common.MethodChannel.Result +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.* +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class EnrollPasskeyChallengeRequestHandlerTest { + + private fun sampleChallenge() = PasskeyEnrollmentChallenge( + "auth_method_123", + "session123", + AuthnParamsPublicKey( + authenticatorSelection = AuthenticatorSelection( + residentKey = "required", + userVerification = "preferred" + ), + challenge = "challenge-data", + pubKeyCredParams = listOf(PubKeyCredParam(alg = -7, type = "public-key")), + relyingParty = RelyingParty(id = "example.com", name = "Example"), + timeout = 60000, + user = PasskeyUser(displayName = "John", id = "user-id", name = "john@example.com") + ) + ) + + @Test + fun `should call passkeyEnrollmentChallenge on the client`() { + val handler = EnrollPasskeyChallengeRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock>() + val request = MethodCallRequest(account = mockAccount, hashMapOf()) + + whenever(mockClient.passkeyEnrollmentChallenge(anyOrNull(), anyOrNull())) + .thenReturn(mockRequest) + + handler.handle(mockClient, request, mockResult) + + verify(mockClient).passkeyEnrollmentChallenge(null, null) + } + + @Test + fun `should forward userIdentityId and connection`() { + val handler = EnrollPasskeyChallengeRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock>() + val request = MethodCallRequest( + account = mockAccount, + hashMapOf("userIdentityId" to "uid", "connection" to "db") + ) + + whenever(mockClient.passkeyEnrollmentChallenge(anyOrNull(), anyOrNull())) + .thenReturn(mockRequest) + + handler.handle(mockClient, request, mockResult) + + verify(mockClient).passkeyEnrollmentChallenge("uid", "db") + } + + @Test + fun `should call result success with challenge map on success`() { + val handler = EnrollPasskeyChallengeRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock>() + val request = MethodCallRequest(account = mockAccount, hashMapOf()) + + whenever(mockClient.passkeyEnrollmentChallenge(anyOrNull(), anyOrNull())) + .thenReturn(mockRequest) + doAnswer { + val callback = + it.getArgument>(0) + callback.onSuccess(sampleChallenge()) + }.whenever(mockRequest).start(any()) + + handler.handle(mockClient, request, mockResult) + + verify(mockResult).success(any()) + } + + @Test + fun `should call result error on failure`() { + val handler = EnrollPasskeyChallengeRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock>() + val request = MethodCallRequest(account = mockAccount, hashMapOf()) + val exception = mock() + + whenever(exception.getCode()).thenReturn("server_error") + whenever(exception.getDescription()).thenReturn("Server error") + whenever(exception.statusCode).thenReturn(500) + whenever(exception.isNetworkError).thenReturn(false) + + whenever(mockClient.passkeyEnrollmentChallenge(anyOrNull(), anyOrNull())) + .thenReturn(mockRequest) + doAnswer { + val callback = + it.getArgument>(0) + callback.onFailure(exception) + }.whenever(mockRequest).start(any()) + + handler.handle(mockClient, request, mockResult) + + verify(mockResult).error(eq("server_error"), eq("Server error"), any()) + } +} diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollPasskeyRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollPasskeyRequestHandlerTest.kt new file mode 100644 index 000000000..db12c9d23 --- /dev/null +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollPasskeyRequestHandlerTest.kt @@ -0,0 +1,287 @@ +package com.auth0.auth0_flutter.request_handlers.my_account + +import com.auth0.android.Auth0 +import com.auth0.android.callback.Callback +import com.auth0.android.myaccount.MyAccountAPIClient +import com.auth0.android.myaccount.MyAccountException +import com.auth0.android.request.PublicKeyCredentials +import com.auth0.android.request.Request +import com.auth0.android.result.PasskeyAuthenticationMethod +import com.auth0.android.result.PasskeyEnrollmentChallenge +import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import io.flutter.plugin.common.MethodChannel.Result +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.* +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class EnrollPasskeyRequestHandlerTest { + + private fun challengeMap() = hashMapOf( + "authenticationMethodId" to "auth_method_123", + "authSession" to "session123", + "authParamsPublicKey" to mapOf( + "authenticatorSelection" to mapOf( + "residentKey" to "required", + "userVerification" to "preferred" + ), + "challenge" to "challenge-data", + "pubKeyCredParams" to listOf(mapOf("alg" to -7.0, "type" to "public-key")), + "rp" to mapOf("id" to "example.com", "name" to "Example"), + "timeout" to 60000.0, + "user" to mapOf( + "displayName" to "John", + "id" to "user-id", + "name" to "john@example.com" + ) + ) + ) + + private fun credentialMap() = hashMapOf( + "id" to "credential-id", + "rawId" to "raw-credential-id", + "type" to "public-key", + "authenticatorAttachment" to "platform", + "response" to mapOf( + "clientDataJSON" to "client-data", + "attestationObject" to "attestation" + ), + "clientExtensionResults" to mapOf("credProps" to mapOf("rk" to true)) + ) + + private fun fullData() = hashMapOf( + "challenge" to challengeMap(), + "credential" to credentialMap() + ) + + @Test + fun `should call enroll on the client`() { + val handler = EnrollPasskeyRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock>() + val request = MethodCallRequest(account = mockAccount, fullData()) + + whenever(mockClient.enroll(any(), any())).thenReturn(mockRequest) + + handler.handle(mockClient, request, mockResult) + + verify(mockClient).enroll(any(), any()) + } + + @Test + fun `should call result success with method map on success`() { + val handler = EnrollPasskeyRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock>() + val request = MethodCallRequest(account = mockAccount, fullData()) + + val method = PasskeyAuthenticationMethod( + id = "passkey|123", + type = "passkey", + createdAt = "2024-01-01T00:00:00.000Z", + usage = listOf("mfa"), + credentialBackedUp = true, + credentialDeviceType = "multi_device", + identityUserId = "user-id", + keyId = "key-id", + publicKey = "public-key", + transports = listOf("internal"), + userAgent = "agent", + userHandle = "handle", + aaguid = "aaguid", + relyingPartyId = "example.com" + ) + + whenever(mockClient.enroll(any(), any())).thenReturn(mockRequest) + doAnswer { + val callback = + it.getArgument>(0) + callback.onSuccess(method) + }.whenever(mockRequest).start(any()) + + handler.handle(mockClient, request, mockResult) + + verify(mockResult).success(any()) + } + + @Test + fun `should call result error on failure`() { + val handler = EnrollPasskeyRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock>() + val request = MethodCallRequest(account = mockAccount, fullData()) + val exception = mock() + + whenever(exception.getCode()).thenReturn("server_error") + whenever(exception.getDescription()).thenReturn("Server error") + whenever(exception.statusCode).thenReturn(500) + whenever(exception.isNetworkError).thenReturn(false) + + whenever(mockClient.enroll(any(), any())).thenReturn(mockRequest) + doAnswer { + val callback = + it.getArgument>(0) + callback.onFailure(exception) + }.whenever(mockRequest).start(any()) + + handler.handle(mockClient, request, mockResult) + + verify(mockResult).error(eq("server_error"), eq("Server error"), any()) + } + + @Test + fun `should throw when challenge is not a map`() { + // assertHasProperties fires first because tryGetByKey returns null for a + // non-Map value; the message still identifies the bad field. + val handler = EnrollPasskeyRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val request = MethodCallRequest( + account = mockAccount, + hashMapOf( + "challenge" to "not-a-map", + "credential" to credentialMap() + ) + ) + + val exception = Assert.assertThrows(IllegalArgumentException::class.java) { + handler.handle(mockClient, request, mockResult) + } + + assertThat( + exception.message, + equalTo("Required property 'challenge.authSession' is not provided.") + ) + } + + @Test + fun `should throw when credential is not a map`() { + // assertHasProperties fires first because tryGetByKey returns null for a + // non-Map value; the message identifies the first missing nested field. + val handler = EnrollPasskeyRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val request = MethodCallRequest( + account = mockAccount, + hashMapOf( + "challenge" to challengeMap(), + "credential" to "not-a-map" + ) + ) + + val exception = Assert.assertThrows(IllegalArgumentException::class.java) { + handler.handle(mockClient, request, mockResult) + } + + assertThat( + exception.message, + equalTo("Required property 'credential.id' is not provided.") + ) + } + + @Test + fun `should throw when authenticationMethodId is not a string`() { + val handler = EnrollPasskeyRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val challenge = challengeMap() + challenge["authenticationMethodId"] = 42 + val request = MethodCallRequest( + account = mockAccount, + hashMapOf( + "challenge" to challenge, + "credential" to credentialMap() + ) + ) + + val exception = Assert.assertThrows(IllegalArgumentException::class.java) { + handler.handle(mockClient, request, mockResult) + } + + assertThat( + exception.message, + equalTo("Required property 'challenge.authenticationMethodId' must be a string.") + ) + } + + @Test + fun `should throw when authSession is not a string`() { + val handler = EnrollPasskeyRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val challenge = challengeMap() + challenge["authSession"] = 42 + val request = MethodCallRequest( + account = mockAccount, + hashMapOf( + "challenge" to challenge, + "credential" to credentialMap() + ) + ) + + val exception = Assert.assertThrows(IllegalArgumentException::class.java) { + handler.handle(mockClient, request, mockResult) + } + + assertThat( + exception.message, + equalTo("Required property 'challenge.authSession' must be a string.") + ) + } + + @Test(expected = IllegalArgumentException::class) + fun `should throw when challenge is missing`() { + val handler = EnrollPasskeyRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val request = MethodCallRequest( + account = mockAccount, + hashMapOf("credential" to credentialMap()) + ) + + handler.handle(mockClient, request, mockResult) + } + + @Test + fun `should throw when required credential response field is missing`() { + val handler = EnrollPasskeyRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val credential = credentialMap() + credential["response"] = mapOf( + "clientDataJSON" to "client-data" + ) + val request = MethodCallRequest( + account = mockAccount, + hashMapOf( + "challenge" to challengeMap(), + "credential" to credential + ) + ) + + val exception = Assert.assertThrows(IllegalArgumentException::class.java) { + handler.handle(mockClient, request, mockResult) + } + + assertThat( + exception.message, + equalTo("Required property 'credential.response.attestationObject' is not provided.") + ) + } +} diff --git a/auth0_flutter/darwin/Classes/AuthAPI/AuthAPIHandler.swift b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPIHandler.swift index 4e8d18985..eb41b2cb9 100644 --- a/auth0_flutter/darwin/Classes/AuthAPI/AuthAPIHandler.swift +++ b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPIHandler.swift @@ -28,6 +28,9 @@ public class AuthAPIHandler: NSObject, FlutterPlugin { case loginWithEmailCode = "auth#loginWithEmail" case loginWithSMSCode = "auth#loginWithPhoneNumber" case ssoExchange = "auth#ssoExchange" + case passkeyLoginChallenge = "auth#passkeyLoginChallenge" + case passkeySignupChallenge = "auth#passkeySignupChallenge" + case passkeyCredentialExchange = "auth#passkeyCredentialExchange" } private static let channelName = "auth0.com/auth0_flutter/auth" @@ -73,6 +76,26 @@ public class AuthAPIHandler: NSObject, FlutterPlugin { case .loginWithEmailCode: return AuthAPILoginWithEmailMethodHandler(client: client) case .loginWithSMSCode: return AuthAPILoginWithPhoneNumberMethodHandler(client: client) case .ssoExchange: return SSOExchangeMethodHandler(client: client) + #if PASSKEYS_PLATFORM + case .passkeyLoginChallenge: + if #available(iOS 16.6, macOS 13.5, visionOS 1.0, *) { + return AuthAPIPasskeyLoginChallengeMethodHandler(client: client) + } + return UnsupportedMethodHandler() + case .passkeySignupChallenge: + if #available(iOS 16.6, macOS 13.5, visionOS 1.0, *) { + return AuthAPIPasskeySignupChallengeMethodHandler(client: client) + } + return UnsupportedMethodHandler() + case .passkeyCredentialExchange: + if #available(iOS 16.6, macOS 13.5, visionOS 1.0, *) { + return AuthAPIPasskeyCredentialExchangeMethodHandler(client: client) + } + return UnsupportedMethodHandler() + #else + case .passkeyLoginChallenge, .passkeySignupChallenge, .passkeyCredentialExchange: + return UnsupportedMethodHandler() + #endif } } diff --git a/auth0_flutter/darwin/Classes/AuthAPI/AuthAPIPasskeyCredentialExchangeMethodHandler.swift b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPIPasskeyCredentialExchangeMethodHandler.swift new file mode 100644 index 000000000..5849d52eb --- /dev/null +++ b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPIPasskeyCredentialExchangeMethodHandler.swift @@ -0,0 +1,235 @@ +#if PASSKEYS_PLATFORM +import Auth0 +import AuthenticationServices + +#if os(iOS) +import Flutter +#else +import FlutterMacOS +#endif + +/// A ``LoginPasskey`` reconstructed from a credential map provided by the +/// Flutter layer (the app presents the OS passkey UI and passes the resulting +/// assertion). +@available(iOS 16.6, macOS 13.5, visionOS 1.0, *) +private struct ReconstructedLoginPasskey: LoginPasskey { + let userID: Data! + let credentialID: Data + let attachment: ASAuthorizationPublicKeyCredentialAttachment + let rawClientDataJSON: Data + let rawAuthenticatorData: Data! + let signature: Data! +} + +/// A ``SignupPasskey`` reconstructed from a credential map supplied by the app +/// (for example, from `ASAuthorizationController`). +@available(iOS 16.6, macOS 13.5, visionOS 1.0, *) +private struct ReconstructedSignupPasskey: SignupPasskey { + let credentialID: Data + let attachment: ASAuthorizationPublicKeyCredentialAttachment + let rawClientDataJSON: Data + let rawAttestationObject: Data? +} + +/// Exchanges an app-supplied passkey credential (a login assertion or a signup +/// attestation) and its challenge for Auth0 tokens at the `/oauth/token` +/// endpoint. This handler does not present any UI. +/// +/// Both passkey login and signup finish here. The credential's `response` +/// determines the flow: an `attestationObject` indicates a signup attestation, +/// while `authenticatorData` + `signature` indicate a login assertion. The +/// matching `LoginPasskey`/`SignupPasskey` and challenge are reconstructed and +/// passed to the shared `Authentication.login(passkey:challenge:...)` call. +@available(iOS 16.6, macOS 13.5, visionOS 1.0, *) +struct AuthAPIPasskeyCredentialExchangeMethodHandler: MethodHandler { + enum Argument: String { + case challenge + case credential + case connection + case audience + case scopes + case organization + case parameters + } + + let client: Authentication + + func handle(with arguments: [String: Any], callback: @escaping FlutterResult) { + guard let challengeMap = arguments[Argument.challenge.rawValue] as? [String: Any] else { + return callback(FlutterError(from: .requiredArgumentMissing(Argument.challenge.rawValue))) + } + guard let credentialMap = arguments[Argument.credential.rawValue] as? [String: Any] else { + return callback(FlutterError(from: .requiredArgumentMissing(Argument.credential.rawValue))) + } + + let connection = arguments[Argument.connection.rawValue] as? String + let audience = arguments[Argument.audience.rawValue] as? String + let scopes = (arguments[Argument.scopes.rawValue] as? [String]) ?? [] + let organization = arguments[Argument.organization.rawValue] as? String + let parameters = (arguments[Argument.parameters.rawValue] as? [String: Any]) ?? [:] + + let response = credentialMap["response"] as? [String: Any] + let isSignup = response?["attestationObject"] != nil + + let request: Request + if isSignup { + guard let challenge = Self.reconstructSignupChallenge(from: challengeMap) else { + return callback(FlutterError(code: "PASSKEY_ERROR", + message: "Failed to reconstruct signup challenge", + details: nil)) + } + guard let passkey = Self.reconstructSignupPasskey(from: credentialMap) else { + return callback(FlutterError(code: "PASSKEY_ERROR", + message: "Failed to reconstruct passkey credential", + details: nil)) + } + request = client.login(passkey: passkey, + challenge: challenge, + connection: connection, + audience: audience, + scope: scopes.asSpaceSeparatedString, + organization: organization) + } else { + guard let challenge = Self.reconstructLoginChallenge(from: challengeMap) else { + return callback(FlutterError(code: "PASSKEY_ERROR", + message: "Failed to reconstruct login challenge", + details: nil)) + } + guard let passkey = Self.reconstructLoginPasskey(from: credentialMap) else { + return callback(FlutterError(code: "PASSKEY_ERROR", + message: "Failed to reconstruct passkey credential", + details: nil)) + } + request = client.login(passkey: passkey, + challenge: challenge, + connection: connection, + audience: audience, + scope: scopes.asSpaceSeparatedString, + organization: organization) + } + + request + .parameters(parameters) + .start { + switch $0 { + case let .success(credentials): + callback(self.result(from: credentials)) + case let .failure(error): + callback(FlutterError(from: error)) + } + } + } + + // MARK: - Login reconstruction + + private static func reconstructLoginChallenge( + from challengeMap: [String: Any]) -> PasskeyLoginChallenge? { + guard let authParamsPublicKey = challengeMap["authParamsPublicKey"] as? [String: Any], + let authSession = challengeMap["authSession"] as? String, + let challengeString = authParamsPublicKey["challenge"] as? String, + let relyingPartyId = authParamsPublicKey["rpId"] as? String else { + return nil + } + + let challengeJson: [String: Any] = [ + "auth_session": authSession, + "authn_params_public_key": [ + "rpId": relyingPartyId, + "challenge": challengeString + ] + ] + + guard let jsonData = try? JSONSerialization.data(withJSONObject: challengeJson) else { + return nil + } + return try? JSONDecoder().decode(PasskeyLoginChallenge.self, from: jsonData) + } + + private static func reconstructLoginPasskey( + from credentialMap: [String: Any]) -> LoginPasskey? { + guard let credentialIdString = credentialMap["rawId"] as? String + ?? credentialMap["id"] as? String, + let credentialID = Data.fromBase64URLEncoded(credentialIdString), + let response = credentialMap["response"] as? [String: Any], + let clientDataString = response["clientDataJSON"] as? String, + let rawClientDataJSON = Data.fromBase64URLEncoded(clientDataString), + let authenticatorDataString = response["authenticatorData"] as? String, + let rawAuthenticatorData = Data.fromBase64URLEncoded(authenticatorDataString), + let signatureString = response["signature"] as? String, + let signature = Data.fromBase64URLEncoded(signatureString) else { + return nil + } + + // Auth0.swift force-unwraps `userID` when building the request, so fall + // back to empty data rather than nil when no user handle was returned. + let userID = (response["userHandle"] as? String) + .flatMap { Data.fromBase64URLEncoded($0) } ?? Data() + + let attachment: ASAuthorizationPublicKeyCredentialAttachment = + (credentialMap["authenticatorAttachment"] as? String) == "crossPlatform" + ? .crossPlatform : .platform + + return ReconstructedLoginPasskey( + userID: userID, + credentialID: credentialID, + attachment: attachment, + rawClientDataJSON: rawClientDataJSON, + rawAuthenticatorData: rawAuthenticatorData, + signature: signature + ) + } + + // MARK: - Signup reconstruction + + private static func reconstructSignupChallenge( + from challengeMap: [String: Any]) -> PasskeySignupChallenge? { + guard let authParamsPublicKey = challengeMap["authParamsPublicKey"] as? [String: Any], + let authSession = challengeMap["authSession"] as? String, + let challengeString = authParamsPublicKey["challenge"] as? String, + let relyingPartyId = authParamsPublicKey["rpId"] as? String, + let userId = authParamsPublicKey["userId"] as? String, + let userName = authParamsPublicKey["userName"] as? String else { + return nil + } + + let challengeJson: [String: Any] = [ + "auth_session": authSession, + "authn_params_public_key": [ + "rp": ["id": relyingPartyId], + "user": ["id": userId, "name": userName], + "challenge": challengeString + ] + ] + + guard let jsonData = try? JSONSerialization.data(withJSONObject: challengeJson) else { + return nil + } + return try? JSONDecoder().decode(PasskeySignupChallenge.self, from: jsonData) + } + + private static func reconstructSignupPasskey( + from credentialMap: [String: Any]) -> SignupPasskey? { + guard let credentialIdString = credentialMap["rawId"] as? String + ?? credentialMap["id"] as? String, + let credentialID = Data.fromBase64URLEncoded(credentialIdString), + let response = credentialMap["response"] as? [String: Any], + let clientDataString = response["clientDataJSON"] as? String, + let rawClientDataJSON = Data.fromBase64URLEncoded(clientDataString), + let attestationString = response["attestationObject"] as? String, + let rawAttestationObject = Data.fromBase64URLEncoded(attestationString) else { + return nil + } + + let attachment: ASAuthorizationPublicKeyCredentialAttachment = + (credentialMap["authenticatorAttachment"] as? String) == "crossPlatform" + ? .crossPlatform : .platform + + return ReconstructedSignupPasskey( + credentialID: credentialID, + attachment: attachment, + rawClientDataJSON: rawClientDataJSON, + rawAttestationObject: rawAttestationObject + ) + } +} +#endif diff --git a/auth0_flutter/darwin/Classes/AuthAPI/AuthAPIPasskeyExtensions.swift b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPIPasskeyExtensions.swift new file mode 100644 index 000000000..213681b3a --- /dev/null +++ b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPIPasskeyExtensions.swift @@ -0,0 +1,21 @@ +#if PASSKEYS_PLATFORM +import Foundation + +extension Data { + func base64URLEncodedString() -> String { + return self.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .trimmingCharacters(in: CharacterSet(charactersIn: "=")) + } + + static func fromBase64URLEncoded(_ string: String) -> Data? { + var base64 = string + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let paddingLength = (4 - base64.count % 4) % 4 + base64 += String(repeating: "=", count: paddingLength) + return Data(base64Encoded: base64) + } +} +#endif diff --git a/auth0_flutter/darwin/Classes/AuthAPI/AuthAPIPasskeyLoginChallengeMethodHandler.swift b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPIPasskeyLoginChallengeMethodHandler.swift new file mode 100644 index 000000000..5f0ed3928 --- /dev/null +++ b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPIPasskeyLoginChallengeMethodHandler.swift @@ -0,0 +1,47 @@ +#if PASSKEYS_PLATFORM +import Auth0 + +#if os(iOS) +import Flutter +#else +import FlutterMacOS +#endif + +@available(iOS 16.6, macOS 13.5, visionOS 1.0, *) +struct AuthAPIPasskeyLoginChallengeMethodHandler: MethodHandler { + enum Argument: String { + case connection + case organization + } + + let client: Authentication + + func handle(with arguments: [String: Any], callback: @escaping FlutterResult) { + let connection = arguments[Argument.connection] as? String + let organization = arguments[Argument.organization] as? String + + client + .passkeyLoginChallenge(connection: connection, organization: organization) + .start { + switch $0 { + case let .success(challenge): + // Unlike Android, the iOS Auth0.swift challenge only exposes + // the relying-party id and challenge data; `timeout` and + // `userVerification` are not surfaced by the native API and + // are not needed to build the assertion request here. The + // asymmetry with the Android payload is intentional. + let response: [String: Any] = [ + "authSession": challenge.authenticationSession, + "authParamsPublicKey": [ + "challenge": challenge.challengeData.base64URLEncodedString(), + "rpId": challenge.relyingPartyId + ] + ] + callback(response) + case let .failure(error): + callback(FlutterError(from: error)) + } + } + } +} +#endif diff --git a/auth0_flutter/darwin/Classes/AuthAPI/AuthAPIPasskeySignupChallengeMethodHandler.swift b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPIPasskeySignupChallengeMethodHandler.swift new file mode 100644 index 000000000..3e7fe20cf --- /dev/null +++ b/auth0_flutter/darwin/Classes/AuthAPI/AuthAPIPasskeySignupChallengeMethodHandler.swift @@ -0,0 +1,72 @@ +#if PASSKEYS_PLATFORM +import Auth0 + +#if os(iOS) +import Flutter +#else +import FlutterMacOS +#endif + +@available(iOS 16.6, macOS 13.5, visionOS 1.0, *) +struct AuthAPIPasskeySignupChallengeMethodHandler: MethodHandler { + enum Argument: String { + case email + case phoneNumber + case username + case name + case givenName + case familyName + case nickname + case picture + case connection + case organization + case userMetadata + } + + let client: Authentication + + func handle(with arguments: [String: Any], callback: @escaping FlutterResult) { + let email = arguments[Argument.email] as? String + let phoneNumber = arguments[Argument.phoneNumber] as? String + let username = arguments[Argument.username] as? String + let name = arguments[Argument.name] as? String + let givenName = arguments[Argument.givenName] as? String + let familyName = arguments[Argument.familyName] as? String + let nickname = arguments[Argument.nickname] as? String + let picture = arguments[Argument.picture] as? String + let connection = arguments[Argument.connection] as? String + let organization = arguments[Argument.organization] as? String + let userMetadata = arguments[Argument.userMetadata] as? [String: String] + + client + .passkeySignupChallenge(email: email, + phoneNumber: phoneNumber, + username: username, + name: name, + givenName: givenName, + familyName: familyName, + nickname: nickname, + picture: picture, + userMetadata: userMetadata, + connection: connection, + organization: organization) + .start { + switch $0 { + case let .success(challenge): + let response: [String: Any] = [ + "authSession": challenge.authenticationSession, + "authParamsPublicKey": [ + "challenge": challenge.challengeData.base64URLEncodedString(), + "rpId": challenge.relyingPartyId, + "userId": challenge.userId.base64URLEncodedString(), + "userName": challenge.userName + ] + ] + callback(response) + case let .failure(error): + callback(FlutterError(from: error)) + } + } + } +} +#endif diff --git a/auth0_flutter/darwin/Classes/MethodHandler.swift b/auth0_flutter/darwin/Classes/MethodHandler.swift index 6e8b561b8..8743c6b5b 100644 --- a/auth0_flutter/darwin/Classes/MethodHandler.swift +++ b/auth0_flutter/darwin/Classes/MethodHandler.swift @@ -19,3 +19,11 @@ extension MethodHandler { } } } + +struct UnsupportedMethodHandler: MethodHandler { + func handle(with arguments: [String: Any], callback: @escaping FlutterResult) { + callback(FlutterError(code: "UNSUPPORTED_PLATFORM", + message: "This method is not supported on this platform version.", + details: nil)) + } +} diff --git a/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountEnrollPasskeyChallengeMethodHandler.swift b/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountEnrollPasskeyChallengeMethodHandler.swift new file mode 100644 index 000000000..12f2c3979 --- /dev/null +++ b/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountEnrollPasskeyChallengeMethodHandler.swift @@ -0,0 +1,39 @@ +#if PASSKEYS_PLATFORM +import Auth0 + +#if os(iOS) +import Flutter +#else +import FlutterMacOS +#endif + +/// Requests a passkey enrollment challenge from the My Account API. This is the +/// first part of the enrollment flow; the app then presents the OS passkey +/// creation UI and finishes with `myAccount#enrollPasskey`. +@available(iOS 16.6, macOS 13.5, visionOS 1.0, *) +struct MyAccountEnrollPasskeyChallengeMethodHandler: MethodHandler { + enum Argument: String { + case userIdentityId + case connection + } + + let client: MyAccount + + func handle(with arguments: [String: Any], callback: @escaping FlutterResult) { + let userIdentityId = arguments[Argument.userIdentityId.rawValue] as? String + let connection = arguments[Argument.connection.rawValue] as? String + + client + .authenticationMethods + .passkeyEnrollmentChallenge(userIdentityId: userIdentityId, connection: connection) + .start { + switch $0 { + case let .success(challenge): + callback(challenge.asDictionary()) + case let .failure(error): + callback(FlutterError(from: error)) + } + } + } +} +#endif diff --git a/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountEnrollPasskeyMethodHandler.swift b/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountEnrollPasskeyMethodHandler.swift new file mode 100644 index 000000000..8a908157a --- /dev/null +++ b/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountEnrollPasskeyMethodHandler.swift @@ -0,0 +1,131 @@ +#if PASSKEYS_PLATFORM +import Auth0 +import AuthenticationServices + +#if os(iOS) +import Flutter +#else +import FlutterMacOS +#endif + +/// A ``NewPasskey`` reconstructed from a credential map supplied by the app +/// (for example, from `ASAuthorizationController`). +@available(iOS 16.6, macOS 13.5, visionOS 1.0, *) +private struct ReconstructedNewPasskey: NewPasskey { + let credentialID: Data + let attachment: ASAuthorizationPublicKeyCredentialAttachment + let rawClientDataJSON: Data + let rawAttestationObject: Data? +} + +/// Enrolls an app-supplied passkey credential (a signup attestation) against a +/// previously obtained enrollment challenge via the My Account API. This is the +/// last part of the enrollment flow. This handler does not present any UI. +@available(iOS 16.6, macOS 13.5, visionOS 1.0, *) +struct MyAccountEnrollPasskeyMethodHandler: MethodHandler { + enum Argument: String { + case challenge + case credential + } + + let client: MyAccount + + func handle(with arguments: [String: Any], callback: @escaping FlutterResult) { + guard let challengeMap = arguments[Argument.challenge.rawValue] as? [String: Any] else { + return callback(FlutterError(from: .requiredArgumentMissing(Argument.challenge.rawValue))) + } + guard let credentialMap = arguments[Argument.credential.rawValue] as? [String: Any] else { + return callback(FlutterError(from: .requiredArgumentMissing(Argument.credential.rawValue))) + } + + guard let challenge = Self.reconstructChallenge(from: challengeMap) else { + return callback(FlutterError(code: "PASSKEY_ERROR", + message: "Failed to reconstruct enrollment challenge", + details: nil)) + } + guard let passkey = Self.reconstructPasskey(from: credentialMap) else { + return callback(FlutterError(code: "PASSKEY_ERROR", + message: "Failed to reconstruct passkey credential", + details: nil)) + } + + client + .authenticationMethods + .enroll(passkey: passkey, challenge: challenge) + .start { + switch $0 { + case let .success(method): + callback(method.asDictionary()) + case let .failure(error): + callback(FlutterError(from: error)) + } + } + } + + // MARK: - Reconstruction + + private static func reconstructChallenge( + from challengeMap: [String: Any]) -> PasskeyEnrollmentChallenge? { + guard let authenticationMethodId = challengeMap["authenticationMethodId"] as? String, + let authSession = challengeMap["authSession"] as? String, + let authParamsPublicKey = challengeMap["authParamsPublicKey"] as? [String: Any], + let challengeString = authParamsPublicKey["challenge"] as? String, + let relyingPartyId = authParamsPublicKey["rpId"] as? String, + let userIdString = authParamsPublicKey["userId"] as? String, + let userName = authParamsPublicKey["userName"] as? String else { + return nil + } + + // `PasskeyEnrollmentChallenge`'s memberwise initializer is internal to + // Auth0.swift, so reconstruct it the way the SDK itself does: decode + // from the WebAuthn JSON with the authentication method id supplied via + // the `locationHeader` decoder userInfo key. + let challengeJson: [String: Any] = [ + "auth_session": authSession, + "authn_params_public_key": [ + "rp": ["id": relyingPartyId], + "user": ["id": userIdString, "name": userName], + "challenge": challengeString + ] + ] + + guard let jsonData = try? JSONSerialization.data(withJSONObject: challengeJson) else { + return nil + } + + let decoder = JSONDecoder() + // Matches Auth0.swift's internal `CodingUserInfoKey.locationHeaderKey`, + // whose raw value is "locationHeader". The decoder reads the last path + // component as the authentication method id. + if let locationHeaderKey = CodingUserInfoKey(rawValue: "locationHeader") { + decoder.userInfo[locationHeaderKey] = authenticationMethodId + } + return try? decoder.decode(PasskeyEnrollmentChallenge.self, from: jsonData) + } + + private static func reconstructPasskey( + from credentialMap: [String: Any]) -> NewPasskey? { + guard let credentialIdString = credentialMap["rawId"] as? String + ?? credentialMap["id"] as? String, + let credentialID = Data.fromBase64URLEncoded(credentialIdString), + let response = credentialMap["response"] as? [String: Any], + let clientDataString = response["clientDataJSON"] as? String, + let rawClientDataJSON = Data.fromBase64URLEncoded(clientDataString), + let attestationString = response["attestationObject"] as? String, + let rawAttestationObject = Data.fromBase64URLEncoded(attestationString) else { + return nil + } + + let attachment: ASAuthorizationPublicKeyCredentialAttachment = + (credentialMap["authenticatorAttachment"] as? String) == "crossPlatform" + ? .crossPlatform : .platform + + return ReconstructedNewPasskey( + credentialID: credentialID, + attachment: attachment, + rawClientDataJSON: rawClientDataJSON, + rawAttestationObject: rawAttestationObject + ) + } +} +#endif diff --git a/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountExtensions.swift b/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountExtensions.swift index 40e84f459..0f73e0391 100644 --- a/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountExtensions.swift +++ b/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountExtensions.swift @@ -105,3 +105,47 @@ extension RecoveryCodeEnrollmentChallenge { ] } } + +#if PASSKEYS_PLATFORM +@available(iOS 16.6, macOS 13.5, visionOS 1.0, *) +extension PasskeyEnrollmentChallenge { + func asDictionary() -> [String: Any?] { + // Forward the WebAuthn creation options in the flat shape the app's + // platform-authenticator ceremony consumes, plus the authentication + // method id needed to finish enrollment. + return [ + "authenticationMethodId": authenticationMethodId, + "authSession": authenticationSession, + "authParamsPublicKey": [ + "challenge": challengeData.base64URLEncodedString(), + "rpId": relyingPartyId, + "userId": userId.base64URLEncodedString(), + "userName": userName + ] + ] + } +} + +@available(iOS 16.6, macOS 13.5, visionOS 1.0, *) +extension PasskeyAuthenticationMethod { + func asDictionary() -> [String: Any?] { + let createdAtFormatter = ISO8601DateFormatter() + createdAtFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + return [ + "id": id, + "type": type, + "identity_user_id": userIdentityId, + "user_agent": userAgent, + "key_id": credential.id, + "public_key": credential.publicKey.base64EncodedString(), + "user_handle": credential.userHandle.base64URLEncodedString(), + "credential_device_type": credential.deviceType.rawValue, + "credential_backed_up": credential.isBackedUp, + "aaguid": aaguid, + "relying_party_id": relyingPartyIdentifier, + "created_at": createdAtFormatter.string(from: createdAt) + ] + } +} +#endif diff --git a/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountHandler.swift b/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountHandler.swift index ebe4b2dfc..3371614c0 100644 --- a/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountHandler.swift +++ b/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountHandler.swift @@ -15,6 +15,8 @@ public class MyAccountHandler: NSObject, FlutterPlugin { case getAuthenticationMethod = "myAccount#getAuthenticationMethod" case deleteAuthenticationMethod = "myAccount#deleteAuthenticationMethod" case getFactors = "myAccount#getFactors" + case enrollPasskeyChallenge = "myAccount#enrollPasskeyChallenge" + case enrollPasskey = "myAccount#enrollPasskey" case enrollPhone = "myAccount#enrollPhone" case enrollEmail = "myAccount#enrollEmail" case enrollTotp = "myAccount#enrollTotp" @@ -55,6 +57,21 @@ public class MyAccountHandler: NSObject, FlutterPlugin { case .getAuthenticationMethod: return MyAccountGetAuthMethodMethodHandler(client: client) case .deleteAuthenticationMethod: return MyAccountDeleteAuthMethodMethodHandler(client: client) case .getFactors: return MyAccountGetFactorsMethodHandler(client: client) + #if PASSKEYS_PLATFORM + case .enrollPasskeyChallenge: + if #available(iOS 16.6, macOS 13.5, visionOS 1.0, *) { + return MyAccountEnrollPasskeyChallengeMethodHandler(client: client) + } + return UnsupportedMethodHandler() + case .enrollPasskey: + if #available(iOS 16.6, macOS 13.5, visionOS 1.0, *) { + return MyAccountEnrollPasskeyMethodHandler(client: client) + } + return UnsupportedMethodHandler() + #else + case .enrollPasskeyChallenge, .enrollPasskey: + return UnsupportedMethodHandler() + #endif case .enrollPhone: return MyAccountEnrollPhoneMethodHandler(client: client) case .enrollEmail: return MyAccountEnrollEmailMethodHandler(client: client) case .enrollTotp: return MyAccountEnrollTotpMethodHandler(client: client) diff --git a/auth0_flutter/darwin/auth0_flutter.podspec b/auth0_flutter/darwin/auth0_flutter.podspec index 8ec2d808e..e9528d5ee 100644 --- a/auth0_flutter/darwin/auth0_flutter.podspec +++ b/auth0_flutter/darwin/auth0_flutter.podspec @@ -24,6 +24,6 @@ Pod::Spec.new do |s| s.dependency 'SimpleKeychain', '1.3.0' # Flutter.framework does not contain a i386 slice. - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386', 'SWIFT_ACTIVE_COMPILATION_CONDITIONS' => '$(inherited) PASSKEYS_PLATFORM' } s.swift_version = ['5.7', '5.8', '5.9'] end diff --git a/auth0_flutter/example/android/app/src/main/kotlin/com/auth0/auth0_flutter_example/MainActivity.kt b/auth0_flutter/example/android/app/src/main/kotlin/com/auth0/auth0_flutter_example/MainActivity.kt index 3726d88b8..d1eb59d15 100644 --- a/auth0_flutter/example/android/app/src/main/kotlin/com/auth0/auth0_flutter_example/MainActivity.kt +++ b/auth0_flutter/example/android/app/src/main/kotlin/com/auth0/auth0_flutter_example/MainActivity.kt @@ -1,6 +1,11 @@ package com.auth0.auth0_flutter_example import io.flutter.embedding.android.FlutterFragmentActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel class MainActivity: FlutterFragmentActivity() { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + } } diff --git a/auth0_flutter/example/ios/Runner.xcodeproj/project.pbxproj b/auth0_flutter/example/ios/Runner.xcodeproj/project.pbxproj index 615410f6c..5793356ec 100644 --- a/auth0_flutter/example/ios/Runner.xcodeproj/project.pbxproj +++ b/auth0_flutter/example/ios/Runner.xcodeproj/project.pbxproj @@ -46,6 +46,7 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + A09B2E3F1D4C7891BA000002 /* CredentialsManagerSSOCredentialsMethodHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A09B2E3F1D4C7891BA000001 /* CredentialsManagerSSOCredentialsMethodHandlerTests.swift */; }; A128A3E84540D9257B15491D /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 03C97032096FE7868ACD2E52 /* Pods_Runner.framework */; }; B0CA00020000000000000001 /* MyAccountSpies.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0CA00010000000000000001 /* MyAccountSpies.swift */; }; B0CA00020000000000000002 /* MyAccountDeleteAuthMethodMethodHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0CA00010000000000000002 /* MyAccountDeleteAuthMethodMethodHandlerTests.swift */; }; @@ -60,6 +61,12 @@ B0CA0002000000000000000B /* MyAccountVerifyOtpMethodHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0CA0001000000000000000B /* MyAccountVerifyOtpMethodHandlerTests.swift */; }; B0CA0002000000000000000C /* MyAccountConfirmEnrollmentMethodHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0CA0001000000000000000C /* MyAccountConfirmEnrollmentMethodHandlerTests.swift */; }; B0CA0002000000000000000D /* MyAccountUpdateAuthMethodMethodHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0CA0001000000000000000D /* MyAccountUpdateAuthMethodMethodHandlerTests.swift */; }; + B0CA0002000000000000000E /* MyAccountEnrollPasskeyChallengeMethodHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0CA0001000000000000000E /* MyAccountEnrollPasskeyChallengeMethodHandlerTests.swift */; }; + B0CA0002000000000000000F /* MyAccountEnrollPasskeyMethodHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0CA0001000000000000000F /* MyAccountEnrollPasskeyMethodHandlerTests.swift */; }; + PK00000000000000000001 /* AuthAPIPasskeyLoginChallengeMethodHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = PK00000000000000000003 /* AuthAPIPasskeyLoginChallengeMethodHandlerTests.swift */; }; + PK00000000000000000005 /* AuthAPIPasskeyExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = PK00000000000000000006 /* AuthAPIPasskeyExtensionsTests.swift */; }; + PK10000000000000000001 /* AuthAPIPasskeySignupChallengeMethodHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = PK10000000000000000003 /* AuthAPIPasskeySignupChallengeMethodHandlerTests.swift */; }; + PK20000000000000000001 /* AuthAPIPasskeyCredentialExchangeMethodHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = PK20000000000000000002 /* AuthAPIPasskeyCredentialExchangeMethodHandlerTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -149,6 +156,7 @@ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 99BD47CB4C5EF89E894B6230 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 9B5F0CE7EB2CCC504C7678ED /* Pods-Runner-RunnerUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner-RunnerUITests.release.xcconfig"; path = "Target Support Files/Pods-Runner-RunnerUITests/Pods-Runner-RunnerUITests.release.xcconfig"; sourceTree = ""; }; + A09B2E3F1D4C7891BA000001 /* CredentialsManagerSSOCredentialsMethodHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialsManagerSSOCredentialsMethodHandlerTests.swift; sourceTree = ""; }; C16443EA2BA08C71AE68B41B /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; C7412C9A0F502116EC6D51D0 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; C8270E3F40357C2A1DB27BB8 /* Pods_Runner_RunnerUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner_RunnerUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -165,6 +173,12 @@ B0CA0001000000000000000B /* MyAccountVerifyOtpMethodHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyAccountVerifyOtpMethodHandlerTests.swift; sourceTree = ""; }; B0CA0001000000000000000C /* MyAccountConfirmEnrollmentMethodHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyAccountConfirmEnrollmentMethodHandlerTests.swift; sourceTree = ""; }; B0CA0001000000000000000D /* MyAccountUpdateAuthMethodMethodHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyAccountUpdateAuthMethodMethodHandlerTests.swift; sourceTree = ""; }; + B0CA0001000000000000000E /* MyAccountEnrollPasskeyChallengeMethodHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyAccountEnrollPasskeyChallengeMethodHandlerTests.swift; sourceTree = ""; }; + B0CA0001000000000000000F /* MyAccountEnrollPasskeyMethodHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyAccountEnrollPasskeyMethodHandlerTests.swift; sourceTree = ""; }; + PK00000000000000000003 /* AuthAPIPasskeyLoginChallengeMethodHandlerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthAPIPasskeyLoginChallengeMethodHandlerTests.swift; sourceTree = ""; }; + PK00000000000000000006 /* AuthAPIPasskeyExtensionsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthAPIPasskeyExtensionsTests.swift; sourceTree = ""; }; + PK10000000000000000003 /* AuthAPIPasskeySignupChallengeMethodHandlerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthAPIPasskeySignupChallengeMethodHandlerTests.swift; sourceTree = ""; }; + PK20000000000000000002 /* AuthAPIPasskeyCredentialExchangeMethodHandlerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthAPIPasskeyCredentialExchangeMethodHandlerTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -243,7 +257,11 @@ 5C59DA6828011D4F00365CDB /* AuthAPISignupMethodHandlerTests.swift */, 5C59DA822809214200365CDB /* AuthAPIUserInfoMethodHandlerTests.swift */, 5C59DA702807B19800365CDB /* AuthAPIRenewMethodHandlerTests.swift */, + PK00000000000000000003 /* AuthAPIPasskeyLoginChallengeMethodHandlerTests.swift */, + PK00000000000000000006 /* AuthAPIPasskeyExtensionsTests.swift */, 5C59DA722807B1A300365CDB /* AuthAPIResetPasswordMethodHandlerTests.swift */, + PK10000000000000000003 /* AuthAPIPasskeySignupChallengeMethodHandlerTests.swift */, + PK20000000000000000002 /* AuthAPIPasskeyCredentialExchangeMethodHandlerTests.swift */, 5C59DA8A2809386B00365CDB /* AuthAPIExtensionsTests.swift */, 5CA2853929C14EBA008A06B8 /* AuthAPIMultifactorChallengeMethodHandlerTests.swift */, ); @@ -357,6 +375,8 @@ B0CA0001000000000000000B /* MyAccountVerifyOtpMethodHandlerTests.swift */, B0CA0001000000000000000C /* MyAccountConfirmEnrollmentMethodHandlerTests.swift */, B0CA0001000000000000000D /* MyAccountUpdateAuthMethodMethodHandlerTests.swift */, + B0CA0001000000000000000E /* MyAccountEnrollPasskeyChallengeMethodHandlerTests.swift */, + B0CA0001000000000000000F /* MyAccountEnrollPasskeyMethodHandlerTests.swift */, ); path = MyAccount; sourceTree = ""; @@ -651,6 +671,8 @@ 5C59DA6527FFCF0600365CDB /* AuthAPIHandlerTests.swift in Sources */, 5C4E65C7286D24A900141449 /* CredentialsManagerSaveMethodHandlerTests.swift in Sources */, 5C59DA712807B19900365CDB /* AuthAPIRenewMethodHandlerTests.swift in Sources */, + PK00000000000000000001 /* AuthAPIPasskeyLoginChallengeMethodHandlerTests.swift in Sources */, + PK00000000000000000005 /* AuthAPIPasskeyExtensionsTests.swift in Sources */, 5C328B5027F7B0E700451E70 /* Utilities.swift in Sources */, 5C328B5527F7B1F300451E70 /* WebAuthLoginMethodHandlerTests.swift in Sources */, 5C4E65C3286D19DC00141449 /* CredentialsManagerHandlerTests.swift in Sources */, @@ -664,6 +686,8 @@ A0AC1D0F2E0000000000AC04 /* CredentialsManagerClearApiCredentialsMethodHandlerTests.swift in Sources */, 5C335E4127FBD2FE00EDDE3A /* ExtensionsTests.swift in Sources */, 5C59DA732807B1A300365CDB /* AuthAPIResetPasswordMethodHandlerTests.swift in Sources */, + PK10000000000000000001 /* AuthAPIPasskeySignupChallengeMethodHandlerTests.swift in Sources */, + PK20000000000000000001 /* AuthAPIPasskeyCredentialExchangeMethodHandlerTests.swift in Sources */, 5C59DA832809214200365CDB /* AuthAPIUserInfoMethodHandlerTests.swift in Sources */, 5C4E65C1286D15AF00141449 /* CredentialsManagerSpies.swift in Sources */, B0CA00020000000000000001 /* MyAccountSpies.swift in Sources */, @@ -679,6 +703,8 @@ B0CA0002000000000000000B /* MyAccountVerifyOtpMethodHandlerTests.swift in Sources */, B0CA0002000000000000000C /* MyAccountConfirmEnrollmentMethodHandlerTests.swift in Sources */, B0CA0002000000000000000D /* MyAccountUpdateAuthMethodMethodHandlerTests.swift in Sources */, + B0CA0002000000000000000E /* MyAccountEnrollPasskeyChallengeMethodHandlerTests.swift in Sources */, + B0CA0002000000000000000F /* MyAccountEnrollPasskeyMethodHandlerTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -790,7 +816,9 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/RunnerDebug.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; @@ -827,7 +855,7 @@ MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.auth0.sdks.Flutter.Tests; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG PASSKEYS_PLATFORM"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -1088,6 +1116,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/RunnerDebug.entitlements; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = NO; @@ -1099,6 +1128,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = com.auth0.auth0FlutterExample; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; diff --git a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIHandlerTests.swift b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIHandlerTests.swift index 0f5af907e..24d6d34ee 100644 --- a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIHandlerTests.swift +++ b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIHandlerTests.swift @@ -109,7 +109,7 @@ extension AuthAPIHandlerTests { extension AuthAPIHandlerTests { func testReturnsMethodHandlers() { var expectations: [XCTestExpectation] = [] - let methodHandlers: [AuthAPIHandler.Method: MethodHandler.Type] = [ + var methodHandlers: [AuthAPIHandler.Method: MethodHandler.Type] = [ .loginWithUsernameOrEmail: AuthAPILoginUsernameOrEmailMethodHandler.self, .loginWithOTP: AuthAPILoginWithOTPMethodHandler.self, .signup: AuthAPISignupMethodHandler.self, @@ -119,6 +119,13 @@ extension AuthAPIHandlerTests { .resetPassword: AuthAPIResetPasswordMethodHandler.self, .ssoExchange: SSOExchangeMethodHandler.self ] + #if PASSKEYS_PLATFORM + if #available(iOS 16.6, macOS 13.5, visionOS 1.0, *) { + methodHandlers[.passkeyLoginChallenge] = AuthAPIPasskeyLoginChallengeMethodHandler.self + methodHandlers[.passkeySignupChallenge] = AuthAPIPasskeySignupChallengeMethodHandler.self + methodHandlers[.passkeyCredentialExchange] = AuthAPIPasskeyCredentialExchangeMethodHandler.self + } + #endif methodHandlers.forEach { method, methodHandler in let methodCall = FlutterMethodCall(methodName: method.rawValue, arguments: arguments()) let expectation = self.expectation(description: "Returned \(methodHandler)") diff --git a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIPasskeyCredentialExchangeMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIPasskeyCredentialExchangeMethodHandlerTests.swift new file mode 100644 index 000000000..e7db3a60b --- /dev/null +++ b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIPasskeyCredentialExchangeMethodHandlerTests.swift @@ -0,0 +1,303 @@ +#if PASSKEYS_PLATFORM +import XCTest +import Auth0 + +@testable import auth0_flutter + +@available(iOS 16.6, macOS 13.5, visionOS 1.0, *) +fileprivate typealias Argument = AuthAPIPasskeyCredentialExchangeMethodHandler.Argument + +@available(iOS 16.6, macOS 13.5, visionOS 1.0, *) +class AuthAPIPasskeyCredentialExchangeMethodHandlerTests: XCTestCase { + var spy: SpyAuthentication! + var sut: AuthAPIPasskeyCredentialExchangeMethodHandler! + + override func setUpWithError() throws { + spy = SpyAuthentication() + sut = AuthAPIPasskeyCredentialExchangeMethodHandler(client: spy) + } +} + +// MARK: - Required Arguments Error + +@available(iOS 16.6, macOS 13.5, visionOS 1.0, *) +extension AuthAPIPasskeyCredentialExchangeMethodHandlerTests { + func testProducesErrorWhenChallengeIsMissing() { + let expectation = self.expectation(description: "challenge is missing") + sut.handle(with: arguments(without: .challenge)) { result in + assert(result: result, isError: .requiredArgumentMissing(Argument.challenge.rawValue)) + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesErrorWhenCredentialIsMissing() { + let expectation = self.expectation(description: "credential is missing") + sut.handle(with: arguments(without: .credential)) { result in + assert(result: result, isError: .requiredArgumentMissing(Argument.credential.rawValue)) + expectation.fulfill() + } + wait(for: [expectation]) + } +} + +// MARK: - Reconstruction Errors + +@available(iOS 16.6, macOS 13.5, visionOS 1.0, *) +extension AuthAPIPasskeyCredentialExchangeMethodHandlerTests { + func testProducesErrorWhenLoginChallengeCannotBeReconstructed() { + var args = loginArguments() + // Drop `rpId` so the challenge cannot be reconstructed. + args[Argument.challenge.rawValue] = [ + "authSession": "test-auth-session", + "authParamsPublicKey": ["challenge": "dGVzdC1jaGFsbGVuZ2U"] + ] + let expectation = self.expectation(description: "login challenge cannot be reconstructed") + sut.handle(with: args) { result in + guard let error = result as? FlutterError else { + return XCTFail("The handler did not produce a FlutterError") + } + XCTAssertEqual(error.code, "PASSKEY_ERROR") + XCTAssertEqual(error.message, "Failed to reconstruct login challenge") + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesErrorWhenLoginPasskeyCannotBeReconstructed() { + var args = loginArguments() + // Drop the `response` object so the passkey cannot be reconstructed. + args[Argument.credential.rawValue] = [ + "id": "dGVzdC1jcmVkZW50aWFs", + "rawId": "dGVzdC1jcmVkZW50aWFs", + "type": "public-key" + ] + let expectation = self.expectation(description: "login passkey cannot be reconstructed") + sut.handle(with: args) { result in + guard let error = result as? FlutterError else { + return XCTFail("The handler did not produce a FlutterError") + } + XCTAssertEqual(error.code, "PASSKEY_ERROR") + XCTAssertEqual(error.message, "Failed to reconstruct passkey credential") + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesErrorWhenSignupChallengeCannotBeReconstructed() { + var args = signupArguments() + // Drop `userId` so the challenge cannot be reconstructed. + args[Argument.challenge.rawValue] = [ + "authSession": "test-auth-session", + "authParamsPublicKey": [ + "challenge": "dGVzdC1jaGFsbGVuZ2U", + "rpId": "test-rp-id", + "userName": "test-user-name" + ] + ] + let expectation = self.expectation(description: "signup challenge cannot be reconstructed") + sut.handle(with: args) { result in + guard let error = result as? FlutterError else { + return XCTFail("The handler did not produce a FlutterError") + } + XCTAssertEqual(error.code, "PASSKEY_ERROR") + XCTAssertEqual(error.message, "Failed to reconstruct signup challenge") + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesErrorWhenSignupPasskeyCannotBeReconstructed() { + var args = signupArguments() + // Drop the `response` object so the passkey cannot be reconstructed. + args[Argument.credential.rawValue] = [ + "id": "dGVzdC1jcmVkZW50aWFs", + "type": "public-key" + ] + let expectation = self.expectation(description: "signup passkey cannot be reconstructed") + sut.handle(with: args) { result in + guard let error = result as? FlutterError else { + return XCTFail("The handler did not produce a FlutterError") + } + XCTAssertEqual(error.code, "PASSKEY_ERROR") + XCTAssertEqual(error.message, "Failed to reconstruct passkey credential") + expectation.fulfill() + } + wait(for: [expectation]) + } +} + +// MARK: - Flow Discrimination (Login vs Signup) + +@available(iOS 16.6, macOS 13.5, visionOS 1.0, *) +extension AuthAPIPasskeyCredentialExchangeMethodHandlerTests { + func testDetectsLoginCredentialAndCallsLoginPasskeyMethod() { + sut.handle(with: loginArguments()) { _ in } + XCTAssertTrue(spy.calledLoginWithPasskey) + XCTAssertFalse(spy.calledSignupWithPasskey) + } + + func testDetectsSignupCredentialAndCallsLoginPasskeyMethodWithSignupPasskey() { + sut.handle(with: signupArguments()) { _ in } + // A credential whose response carries an attestationObject is routed to + // the SignupPasskey overload of client.login(passkey:challenge:...). + XCTAssertTrue(spy.calledSignupWithPasskey) + XCTAssertFalse(spy.calledLoginWithPasskey) + } +} + +// MARK: - Arguments Forwarding + +@available(iOS 16.6, macOS 13.5, visionOS 1.0, *) +extension AuthAPIPasskeyCredentialExchangeMethodHandlerTests { + func testForwardsConnectionAudienceScopeAndOrganizationForLogin() { + var args = loginArguments() + args[Argument.connection.rawValue] = "test-connection" + args[Argument.audience.rawValue] = "test-audience" + args[Argument.scopes.rawValue] = ["a", "b"] + args[Argument.organization.rawValue] = "test-org" + sut.handle(with: args) { _ in } + XCTAssertEqual(spy.arguments["connection"] as? String, "test-connection") + XCTAssertEqual(spy.arguments["audience"] as? String, "test-audience") + XCTAssertEqual(spy.arguments["scope"] as? String, "a b") + XCTAssertEqual(spy.arguments["organization"] as? String, "test-org") + } + + func testForwardsConnectionAudienceScopeAndOrganizationForSignup() { + var args = signupArguments() + args[Argument.connection.rawValue] = "test-connection" + args[Argument.audience.rawValue] = "test-audience" + args[Argument.scopes.rawValue] = ["a", "b"] + args[Argument.organization.rawValue] = "test-org" + sut.handle(with: args) { _ in } + XCTAssertEqual(spy.arguments["connection"] as? String, "test-connection") + XCTAssertEqual(spy.arguments["audience"] as? String, "test-audience") + XCTAssertEqual(spy.arguments["scope"] as? String, "a b") + XCTAssertEqual(spy.arguments["organization"] as? String, "test-org") + } + + func testForwardsParametersForLogin() { + var args = loginArguments() + args[Argument.parameters.rawValue] = ["custom_key": "custom_value"] + sut.handle(with: args) { _ in } + // The spy doesn't track parameters directly, but the handler calls + // request.parameters(...) before starting, so we verify the call completes + XCTAssertTrue(spy.calledLoginWithPasskey) + } + + func testForwardsParametersForSignup() { + var args = signupArguments() + args[Argument.parameters.rawValue] = ["custom_key": "custom_value"] + sut.handle(with: args) { _ in } + XCTAssertTrue(spy.calledSignupWithPasskey) + } +} + +// MARK: - Results + +@available(iOS 16.6, macOS 13.5, visionOS 1.0, *) +extension AuthAPIPasskeyCredentialExchangeMethodHandlerTests { + func testProducesCredentialsForLoginSuccess() { + let credentials = Credentials(idToken: testIdToken) + spy.credentialsResult = .success(credentials) + let expectation = self.expectation(description: "Produced credentials for login") + sut.handle(with: loginArguments()) { result in + XCTAssertNotNil(result as? [String: Any]) + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesAuthenticationErrorForLoginFailure() { + let error = AuthenticationError(info: [:], statusCode: 0) + let expectation = self.expectation(description: "Produced AuthenticationError for login") + spy.credentialsResult = .failure(error) + sut.handle(with: loginArguments()) { result in + assert(result: result, isError: error) + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesCredentialsForSignupSuccess() { + let credentials = Credentials(idToken: testIdToken) + spy.credentialsResult = .success(credentials) + let expectation = self.expectation(description: "Produced credentials for signup") + sut.handle(with: signupArguments()) { result in + XCTAssertNotNil(result as? [String: Any]) + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesAuthenticationErrorForSignupFailure() { + let error = AuthenticationError(info: [:], statusCode: 0) + let expectation = self.expectation(description: "Produced AuthenticationError for signup") + spy.credentialsResult = .failure(error) + sut.handle(with: signupArguments()) { result in + assert(result: result, isError: error) + expectation.fulfill() + } + wait(for: [expectation]) + } +} + +// MARK: - Helpers + +@available(iOS 16.6, macOS 13.5, visionOS 1.0, *) +extension AuthAPIPasskeyCredentialExchangeMethodHandlerTests { + fileprivate func loginArguments() -> [String: Any] { + return [ + Argument.challenge.rawValue: [ + "authSession": "test-auth-session", + "authParamsPublicKey": [ + "challenge": "dGVzdC1jaGFsbGVuZ2U", + "rpId": "test-rp-id" + ] + ], + Argument.credential.rawValue: [ + "id": "dGVzdC1jcmVkZW50aWFs", + "rawId": "dGVzdC1jcmVkZW50aWFs", + "type": "public-key", + "authenticatorAttachment": "platform", + "response": [ + "clientDataJSON": "dGVzdC1jbGllbnQtZGF0YQ", + "authenticatorData": "dGVzdC1hdXRoLWRhdGE", + "signature": "dGVzdC1zaWduYXR1cmU", + "userHandle": "dGVzdC11c2VyLWhhbmRsZQ" + ] + ] + ] + } + + fileprivate func signupArguments() -> [String: Any] { + return [ + Argument.challenge.rawValue: [ + "authSession": "test-auth-session", + "authParamsPublicKey": [ + "challenge": "dGVzdC1jaGFsbGVuZ2U", + "rpId": "test-rp-id", + "userId": "dGVzdC11c2VyLWlk", + "userName": "test-user-name" + ] + ], + Argument.credential.rawValue: [ + "id": "dGVzdC1jcmVkZW50aWFs", + "rawId": "dGVzdC1jcmVkZW50aWFs", + "type": "public-key", + "authenticatorAttachment": "platform", + "response": [ + "clientDataJSON": "dGVzdC1jbGllbnQtZGF0YQ", + "attestationObject": "dGVzdC1hdHRlc3RhdGlvbg" + ] + ] + ] + } + + fileprivate func arguments(without argument: Argument) -> [String: Any] { + var args = loginArguments() + args.removeValue(forKey: argument.rawValue) + return args + } +} +#endif diff --git a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIPasskeyExtensionsTests.swift b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIPasskeyExtensionsTests.swift new file mode 100644 index 000000000..2681d51a0 --- /dev/null +++ b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIPasskeyExtensionsTests.swift @@ -0,0 +1,60 @@ +#if PASSKEYS_PLATFORM +import XCTest + +@testable import auth0_flutter + +class AuthAPIPasskeyExtensionsTests: XCTestCase { + + // MARK: - base64URLEncodedString + + func testEncodesUsingURLSafeAlphabetWithoutPadding() { + // 0xFB 0xFF produces "+/" in standard base64, which must be mapped to + // the URL-safe "-_" and have its padding stripped. + let data = Data([0xFB, 0xFF]) + XCTAssertEqual(data.base64URLEncodedString(), "-_8") + } + + func testEncodesEmptyDataAsEmptyString() { + XCTAssertEqual(Data().base64URLEncodedString(), "") + } + + // MARK: - fromBase64URLEncoded + + func testDecodesURLSafeAlphabet() { + XCTAssertEqual(Data.fromBase64URLEncoded("-_8"), Data([0xFB, 0xFF])) + } + + func testDecodesInputRequiringTwoPaddingChars() { + // 1 byte encodes to 2 significant chars and needs "==" padding restored. + let data = Data([0x66]) + XCTAssertEqual(Data.fromBase64URLEncoded(data.base64URLEncodedString()), data) + } + + func testDecodesInputRequiringOnePaddingChar() { + // 2 bytes encode to 3 significant chars and need "=" padding restored. + let data = Data([0x66, 0x6F]) + XCTAssertEqual(Data.fromBase64URLEncoded(data.base64URLEncodedString()), data) + } + + func testDecodesInputRequiringNoPadding() { + // 3 bytes encode to 4 chars with no padding required. + let data = Data([0x66, 0x6F, 0x6F]) + XCTAssertEqual(Data.fromBase64URLEncoded(data.base64URLEncodedString()), data) + } + + func testReturnsNilForInvalidBase64() { + XCTAssertNil(Data.fromBase64URLEncoded("not base64!!")) + } + + // MARK: - Round trip + + func testRoundTripsArbitraryData() { + let data = Data((0..<256).map { UInt8($0) }) + let encoded = data.base64URLEncodedString() + XCTAssertFalse(encoded.contains("+")) + XCTAssertFalse(encoded.contains("/")) + XCTAssertFalse(encoded.contains("=")) + XCTAssertEqual(Data.fromBase64URLEncoded(encoded), data) + } +} +#endif diff --git a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIPasskeyLoginChallengeMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIPasskeyLoginChallengeMethodHandlerTests.swift new file mode 100644 index 000000000..acb9469f3 --- /dev/null +++ b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIPasskeyLoginChallengeMethodHandlerTests.swift @@ -0,0 +1,87 @@ +#if PASSKEYS_PLATFORM +import XCTest +import Auth0 + +@testable import auth0_flutter + +@available(iOS 16.6, macOS 13.5, visionOS 1.0, *) +fileprivate typealias Argument = AuthAPIPasskeyLoginChallengeMethodHandler.Argument + +@available(iOS 16.6, macOS 13.5, visionOS 1.0, *) +class AuthAPIPasskeyLoginChallengeMethodHandlerTests: XCTestCase { + var spy: SpyAuthentication! + var sut: AuthAPIPasskeyLoginChallengeMethodHandler! + + override func setUpWithError() throws { + spy = SpyAuthentication() + sut = AuthAPIPasskeyLoginChallengeMethodHandler(client: spy) + } +} + +// MARK: - Arguments + +@available(iOS 16.6, macOS 13.5, visionOS 1.0, *) +extension AuthAPIPasskeyLoginChallengeMethodHandlerTests { + func testAddsConnection() { + let value = "test-connection" + sut.handle(with: arguments(withKey: Argument.connection, value: value)) { _ in } + XCTAssertEqual(spy.arguments["connection"] as? String, value) + } + + func testAddsOrganization() { + let value = "test-org" + sut.handle(with: arguments(withKey: Argument.organization, value: value)) { _ in } + XCTAssertEqual(spy.arguments["organization"] as? String, value) + } + + func testConnectionAndOrganizationAreOptional() { + sut.handle(with: [:]) { _ in } + XCTAssertTrue(spy.calledPasskeyLoginChallenge) + XCTAssertNil(spy.arguments["connection"] as? String) + XCTAssertNil(spy.arguments["organization"] as? String) + } +} + +// MARK: - Challenge Result + +@available(iOS 16.6, macOS 13.5, visionOS 1.0, *) +extension AuthAPIPasskeyLoginChallengeMethodHandlerTests { + func testCallsSDKPasskeyLoginChallengeMethod() { + sut.handle(with: arguments()) { _ in } + XCTAssertTrue(spy.calledPasskeyLoginChallenge) + } + + func testProducesChallengeResponse() { + let expectation = self.expectation(description: "Produced a challenge response") + sut.handle(with: arguments()) { result in + let dictionary = result as? [String: Any] + XCTAssertEqual(dictionary?["authSession"] as? String, "test-auth-session") + let publicKey = dictionary?["authParamsPublicKey"] as? [String: Any] + XCTAssertEqual(publicKey?["rpId"] as? String, "test-rp-id") + XCTAssertNotNil(publicKey?["challenge"] as? String) + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesAuthenticationError() { + let error = AuthenticationError(info: [:], statusCode: 0) + let expectation = self.expectation(description: "Produced the AuthenticationError \(error)") + spy.passkeyLoginChallengeResultOverride = .failure(error) + sut.handle(with: arguments()) { result in + assert(result: result, isError: error) + expectation.fulfill() + } + wait(for: [expectation]) + } +} + +// MARK: - Helpers + +@available(iOS 16.6, macOS 13.5, visionOS 1.0, *) +extension AuthAPIPasskeyLoginChallengeMethodHandlerTests { + override func arguments() -> [String: Any] { + return [:] + } +} +#endif diff --git a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIPasskeySignupChallengeMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIPasskeySignupChallengeMethodHandlerTests.swift new file mode 100644 index 000000000..7e6635e0f --- /dev/null +++ b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPIPasskeySignupChallengeMethodHandlerTests.swift @@ -0,0 +1,111 @@ +#if PASSKEYS_PLATFORM +import XCTest +import Auth0 + +@testable import auth0_flutter + +@available(iOS 16.6, macOS 13.5, visionOS 1.0, *) +fileprivate typealias Argument = AuthAPIPasskeySignupChallengeMethodHandler.Argument + +@available(iOS 16.6, macOS 13.5, visionOS 1.0, *) +class AuthAPIPasskeySignupChallengeMethodHandlerTests: XCTestCase { + var spy: SpyAuthentication! + var sut: AuthAPIPasskeySignupChallengeMethodHandler! + + override func setUpWithError() throws { + spy = SpyAuthentication() + sut = AuthAPIPasskeySignupChallengeMethodHandler(client: spy) + } +} + +// MARK: - Arguments + +@available(iOS 16.6, macOS 13.5, visionOS 1.0, *) +extension AuthAPIPasskeySignupChallengeMethodHandlerTests { + func testAddsEmail() { + let value = "test-email" + sut.handle(with: arguments(withKey: Argument.email, value: value)) { _ in } + XCTAssertEqual(spy.arguments["email"] as? String, value) + } + + func testAddsConnectionAndOrganization() { + var args: [String: Any] = [:] + args[Argument.connection.rawValue] = "test-connection" + args[Argument.organization.rawValue] = "test-org" + sut.handle(with: args) { _ in } + XCTAssertEqual(spy.arguments["connection"] as? String, "test-connection") + XCTAssertEqual(spy.arguments["organization"] as? String, "test-org") + } + + func testAllArgumentsAreOptional() { + sut.handle(with: [:]) { _ in } + XCTAssertTrue(spy.calledPasskeySignupChallenge) + XCTAssertNil(spy.arguments["email"] as? String) + } + + func testAddsUserMetadata() { + var args: [String: Any] = [:] + args[Argument.userMetadata.rawValue] = ["plan": "gold"] + sut.handle(with: args) { _ in } + XCTAssertEqual(spy.arguments["userMetadata"] as? [String: String], ["plan": "gold"]) + } + + func testAddsProfileIdentifiers() { + var args: [String: Any] = [:] + args[Argument.givenName.rawValue] = "test-given-name" + args[Argument.familyName.rawValue] = "test-family-name" + args[Argument.nickname.rawValue] = "test-nickname" + args[Argument.picture.rawValue] = "https://www.okta.com" + sut.handle(with: args) { _ in } + XCTAssertEqual(spy.arguments["givenName"] as? String, "test-given-name") + XCTAssertEqual(spy.arguments["familyName"] as? String, "test-family-name") + XCTAssertEqual(spy.arguments["nickname"] as? String, "test-nickname") + XCTAssertEqual(spy.arguments["picture"] as? String, "https://www.okta.com") + } +} + +// MARK: - Challenge Result + +@available(iOS 16.6, macOS 13.5, visionOS 1.0, *) +extension AuthAPIPasskeySignupChallengeMethodHandlerTests { + func testCallsSDKPasskeySignupChallengeMethod() { + sut.handle(with: arguments()) { _ in } + XCTAssertTrue(spy.calledPasskeySignupChallenge) + } + + func testProducesChallengeResponse() { + let expectation = self.expectation(description: "Produced a challenge response") + sut.handle(with: arguments()) { result in + let dictionary = result as? [String: Any] + XCTAssertEqual(dictionary?["authSession"] as? String, "test-auth-session") + let publicKey = dictionary?["authParamsPublicKey"] as? [String: Any] + XCTAssertEqual(publicKey?["rpId"] as? String, "test-rp-id") + XCTAssertEqual(publicKey?["userName"] as? String, "test-user-name") + XCTAssertNotNil(publicKey?["challenge"] as? String) + XCTAssertNotNil(publicKey?["userId"] as? String) + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesAuthenticationError() { + let error = AuthenticationError(info: [:], statusCode: 0) + let expectation = self.expectation(description: "Produced the AuthenticationError \(error)") + spy.passkeySignupChallengeResultOverride = .failure(error) + sut.handle(with: arguments()) { result in + assert(result: result, isError: error) + expectation.fulfill() + } + wait(for: [expectation]) + } +} + +// MARK: - Helpers + +@available(iOS 16.6, macOS 13.5, visionOS 1.0, *) +extension AuthAPIPasskeySignupChallengeMethodHandlerTests { + override func arguments() -> [String: Any] { + return [:] + } +} +#endif diff --git a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPISpies.swift b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPISpies.swift index 2e3233e1c..e46e669b9 100644 --- a/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPISpies.swift +++ b/auth0_flutter/example/ios/Tests/AuthAPI/AuthAPISpies.swift @@ -33,6 +33,24 @@ class SpyAuthentication: Authentication { var calledUserInfo = false var calledRenew = false var calledResetPassword = false + var calledPasskeyLoginChallenge = false + var calledLoginWithPasskey = false + var calledPasskeySignupChallenge = false + var calledSignupWithPasskey = false + #if PASSKEYS_PLATFORM + private var _passkeyLoginChallengeResultOverride: Any? + @available(iOS 16.6, macOS 13.5, visionOS 1.0, *) + var passkeyLoginChallengeResultOverride: AuthenticationResult? { + get { _passkeyLoginChallengeResultOverride as? AuthenticationResult } + set { _passkeyLoginChallengeResultOverride = newValue } + } + private var _passkeySignupChallengeResultOverride: Any? + @available(iOS 16.6, macOS 13.5, visionOS 1.0, *) + var passkeySignupChallengeResultOverride: AuthenticationResult? { + get { _passkeySignupChallengeResultOverride as? AuthenticationResult } + set { _passkeySignupChallengeResultOverride = newValue } + } + #endif var arguments: [String: Any] = [:] init() {} @@ -107,7 +125,7 @@ class SpyAuthentication: Authentication { redirectURI: String) -> Request { return request(credentialsResult) } - + func renew(withRefreshToken refreshToken: String, audience: String?, scope: String?) -> Request { arguments["refreshToken"] = refreshToken arguments["scope"] = scope @@ -128,7 +146,117 @@ class SpyAuthentication: Authentication { func jwks() -> Request { return request(.success(JWKS(keys: []))) } + + #if PASSKEYS_PLATFORM + @available(iOS 16.6, macOS 13.5, visionOS 1.0, *) + func passkeyLoginChallenge(connection: String?, + organization: String?) -> Request { + arguments["connection"] = connection + arguments["organization"] = organization + calledPasskeyLoginChallenge = true + return request(passkeyLoginChallengeResultOverride + ?? SpyAuthentication.passkeyLoginChallengeResult) + } + + @available(iOS 16.6, macOS 13.5, visionOS 1.0, *) + func login(passkey: LoginPasskey, + challenge: PasskeyLoginChallenge, + connection: String?, + audience: String?, + scope: String, + organization: String?) -> Request { + arguments["connection"] = connection + arguments["audience"] = audience + arguments["scope"] = scope + arguments["organization"] = organization + calledLoginWithPasskey = true + return request(credentialsResult) + } + + @available(iOS 16.6, macOS 13.5, visionOS 1.0, *) + func passkeySignupChallenge(email: String?, + phoneNumber: String?, + username: String?, + name: String?, + givenName: String?, + familyName: String?, + nickname: String?, + picture: String?, + userMetadata: [String: String]?, + connection: String?, + organization: String?) -> Request { + arguments["email"] = email + arguments["phoneNumber"] = phoneNumber + arguments["username"] = username + arguments["name"] = name + arguments["givenName"] = givenName + arguments["familyName"] = familyName + arguments["nickname"] = nickname + arguments["picture"] = picture + arguments["connection"] = connection + arguments["organization"] = organization + arguments["userMetadata"] = userMetadata + calledPasskeySignupChallenge = true + return request(passkeySignupChallengeResultOverride + ?? SpyAuthentication.passkeySignupChallengeResult) + } + + @available(iOS 16.6, macOS 13.5, visionOS 1.0, *) + func login(passkey: SignupPasskey, + challenge: PasskeySignupChallenge, + connection: String?, + audience: String?, + scope: String, + organization: String?) -> Request { + arguments["connection"] = connection + arguments["audience"] = audience + arguments["scope"] = scope + arguments["organization"] = organization + calledSignupWithPasskey = true + return request(credentialsResult) + } + #endif +} + +#if PASSKEYS_PLATFORM +@available(iOS 16.6, macOS 13.5, visionOS 1.0, *) +extension SpyAuthentication { + /// A decoded `PasskeyLoginChallenge` for use as a spy result. The type only + /// exposes a `Decodable` initializer, so it is built from JSON. + static var passkeyLoginChallengeResult: AuthenticationResult { + let json = """ + { + "auth_session": "test-auth-session", + "authn_params_public_key": { + "rpId": "test-rp-id", + "challenge": "dGVzdC1jaGFsbGVuZ2U" + } + } + """.data(using: .utf8)! + // swiftlint:disable:next force_try + let challenge = try! JSONDecoder().decode(PasskeyLoginChallenge.self, from: json) + return .success(challenge) + } + + /// A decoded `PasskeySignupChallenge` for use as a spy result. The type + /// only exposes a `Decodable` initializer, so it is built from JSON. + static var passkeySignupChallengeResult: AuthenticationResult { + let json = """ + { + "auth_session": "test-auth-session", + "authn_params_public_key": { + "rp": { "id": "test-rp-id" }, + "user": { "id": "dGVzdC11c2VyLWlk", "name": "test-user-name" }, + "challenge": "dGVzdC1jaGFsbGVuZ2U" + } + } + """.data(using: .utf8)! + // swiftlint:disable:next force_try + let challenge = try! JSONDecoder().decode(PasskeySignupChallenge.self, from: json) + return .success(challenge) + } } +#endif private extension SpyAuthentication { func request(_ result: AuthenticationResult) -> Request { diff --git a/auth0_flutter/example/ios/Tests/MyAccount/MyAccountEnrollPasskeyChallengeMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/MyAccount/MyAccountEnrollPasskeyChallengeMethodHandlerTests.swift new file mode 100644 index 000000000..bcc5b3d98 --- /dev/null +++ b/auth0_flutter/example/ios/Tests/MyAccount/MyAccountEnrollPasskeyChallengeMethodHandlerTests.swift @@ -0,0 +1,65 @@ +#if PASSKEYS_PLATFORM +import XCTest +import Auth0 + +@testable import auth0_flutter + +@available(iOS 16.6, macOS 13.5, visionOS 1.0, *) +class MyAccountEnrollPasskeyChallengeMethodHandlerTests: XCTestCase { + var spy: SpyMyAccountAuthenticationMethods! + var sut: MyAccountEnrollPasskeyChallengeMethodHandler! + + override func setUpWithError() throws { + spy = SpyMyAccountAuthenticationMethods() + let client = SpyMyAccount(spy: spy) + sut = MyAccountEnrollPasskeyChallengeMethodHandler(client: client) + } + + func testCallsSDKMethod() { + let expectation = self.expectation(description: "Called SDK method") + sut.handle(with: [:]) { _ in + XCTAssertTrue(self.spy.calledEnrollPasskeyChallenge) + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testForwardsUserIdentityIdAndConnection() { + let expectation = self.expectation(description: "Forwarded arguments") + sut.handle(with: ["userIdentityId": "uid", "connection": "db"]) { _ in + XCTAssertEqual(self.spy.enrollPasskeyChallengeUserIdentityIdArg, "uid") + XCTAssertEqual(self.spy.enrollPasskeyChallengeConnectionArg, "db") + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesChallengeDictionary() { + let expectation = self.expectation(description: "Produced passkey challenge") + sut.handle(with: [:]) { result in + guard let dict = result as? [String: Any?] else { + return XCTFail("Did not produce dictionary") + } + XCTAssertEqual(dict["authenticationMethodId"] as? String, "passkey|test") + XCTAssertEqual(dict["authSession"] as? String, "session123") + let publicKey = dict["authParamsPublicKey"] as? [String: Any] + XCTAssertEqual(publicKey?["rpId"] as? String, "example.com") + XCTAssertEqual(publicKey?["userName"] as? String, "john@example.com") + XCTAssertNotNil(publicKey?["challenge"]) + XCTAssertNotNil(publicKey?["userId"]) + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesFlutterErrorOnFailure() { + let expectation = self.expectation(description: "Produced FlutterError") + spy.enrollPasskeyChallengeShouldFail = true + sut.handle(with: [:]) { result in + XCTAssertTrue(result is FlutterError) + expectation.fulfill() + } + wait(for: [expectation]) + } +} +#endif diff --git a/auth0_flutter/example/ios/Tests/MyAccount/MyAccountEnrollPasskeyMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/MyAccount/MyAccountEnrollPasskeyMethodHandlerTests.swift new file mode 100644 index 000000000..43aa3ba17 --- /dev/null +++ b/auth0_flutter/example/ios/Tests/MyAccount/MyAccountEnrollPasskeyMethodHandlerTests.swift @@ -0,0 +1,110 @@ +#if PASSKEYS_PLATFORM +import XCTest +import Auth0 + +@testable import auth0_flutter + +@available(iOS 16.6, macOS 13.5, visionOS 1.0, *) +class MyAccountEnrollPasskeyMethodHandlerTests: XCTestCase { + var spy: SpyMyAccountAuthenticationMethods! + var sut: MyAccountEnrollPasskeyMethodHandler! + + override func setUpWithError() throws { + spy = SpyMyAccountAuthenticationMethods() + let client = SpyMyAccount(spy: spy) + sut = MyAccountEnrollPasskeyMethodHandler(client: client) + } + + private func validChallenge() -> [String: Any] { + return [ + "authenticationMethodId": "passkey|test", + "authSession": "session123", + "authParamsPublicKey": [ + "challenge": "Y2hhbGxlbmdl", + "rpId": "example.com", + "userId": "dXNlci1pZA", + "userName": "john@example.com" + ] + ] + } + + private func validCredential() -> [String: Any] { + return [ + "id": "Y3JlZGVudGlhbA", + "rawId": "Y3JlZGVudGlhbA", + "type": "public-key", + "authenticatorAttachment": "platform", + "response": [ + "clientDataJSON": "Y2xpZW50RGF0YQ", + "attestationObject": "YXR0ZXN0YXRpb24" + ] + ] + } + + func testCallsSDKMethod() { + let expectation = self.expectation(description: "Called SDK method") + sut.handle(with: ["challenge": validChallenge(), + "credential": validCredential()]) { _ in + XCTAssertTrue(self.spy.calledEnrollPasskey) + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesMethodDictionary() { + let expectation = self.expectation(description: "Produced passkey method") + sut.handle(with: ["challenge": validChallenge(), + "credential": validCredential()]) { result in + guard let dict = result as? [String: Any?] else { + return XCTFail("Did not produce dictionary") + } + XCTAssertEqual(dict["id"] as? String, "passkey|test") + XCTAssertEqual(dict["type"] as? String, "passkey") + XCTAssertEqual(dict["relying_party_id"] as? String, "example.com") + XCTAssertEqual(dict["credential_device_type"] as? String, "multi_device") + XCTAssertEqual(dict["credential_backed_up"] as? Bool, true) + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesErrorWhenChallengeMissing() { + let expectation = self.expectation(description: "Produced FlutterError") + sut.handle(with: ["credential": validCredential()]) { result in + XCTAssertTrue(result is FlutterError) + XCTAssertFalse(self.spy.calledEnrollPasskey) + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesErrorWhenCredentialMalformed() { + let expectation = self.expectation(description: "Produced FlutterError") + // Missing attestationObject in the response. + let badCredential: [String: Any] = [ + "id": "Y3JlZGVudGlhbA", + "rawId": "Y3JlZGVudGlhbA", + "type": "public-key", + "response": ["clientDataJSON": "Y2xpZW50RGF0YQ"] + ] + sut.handle(with: ["challenge": validChallenge(), + "credential": badCredential]) { result in + XCTAssertTrue(result is FlutterError) + XCTAssertFalse(self.spy.calledEnrollPasskey) + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesFlutterErrorOnFailure() { + let expectation = self.expectation(description: "Produced FlutterError") + spy.enrollPasskeyShouldFail = true + sut.handle(with: ["challenge": validChallenge(), + "credential": validCredential()]) { result in + XCTAssertTrue(result is FlutterError) + expectation.fulfill() + } + wait(for: [expectation]) + } +} +#endif diff --git a/auth0_flutter/example/ios/Tests/MyAccount/MyAccountSpies.swift b/auth0_flutter/example/ios/Tests/MyAccount/MyAccountSpies.swift index 25ed6d5f3..c432c3eae 100644 --- a/auth0_flutter/example/ios/Tests/MyAccount/MyAccountSpies.swift +++ b/auth0_flutter/example/ios/Tests/MyAccount/MyAccountSpies.swift @@ -55,6 +55,13 @@ class SpyMyAccountAuthenticationMethods: MyAccountAuthenticationMethods { var confirmResult: Result = .success(makeAuthMethod(confirmed: true)) var updateResult: Result = .success(makeAuthMethod()) + var calledEnrollPasskeyChallenge = false + var calledEnrollPasskey = false + var enrollPasskeyChallengeUserIdentityIdArg: String? + var enrollPasskeyChallengeConnectionArg: String? + var enrollPasskeyChallengeShouldFail = false + var enrollPasskeyShouldFail = false + var calledGetAuthMethods = false var calledGetAuthMethod = false var calledDelete = false @@ -156,13 +163,48 @@ class SpyMyAccountAuthenticationMethods: MyAccountAuthenticationMethods { @available(iOS 16.6, macOS 13.5, visionOS 1.0, *) func passkeyEnrollmentChallenge(userIdentityId: String?, connection: String?) -> Request { - fatalError("Not implemented in tests") + calledEnrollPasskeyChallenge = true + enrollPasskeyChallengeUserIdentityIdArg = userIdentityId + enrollPasskeyChallengeConnectionArg = connection + if enrollPasskeyChallengeShouldFail { + return request(.failure(MyAccountError(info: [:], statusCode: 401))) + } + let challenge = PasskeyEnrollmentChallenge( + authenticationMethodId: "passkey|test", + authenticationSession: "session123", + relyingPartyId: "example.com", + userId: Data("user-id".utf8), + userName: "john@example.com", + challengeData: Data("challenge-data".utf8) + ) + return request(.success(challenge)) } @available(iOS 16.6, macOS 13.5, visionOS 1.0, *) func enroll(passkey: NewPasskey, challenge: PasskeyEnrollmentChallenge) -> Request { - fatalError("Not implemented in tests") + calledEnrollPasskey = true + if enrollPasskeyShouldFail { + return request(.failure(MyAccountError(info: [:], statusCode: 401))) + } + let credential = PasskeyCredential( + id: "key-id", + publicKey: Data("public-key".utf8), + userHandle: Data("user-handle".utf8), + deviceType: .multiDevice, + isBackedUp: true + ) + let method = PasskeyAuthenticationMethod( + id: "passkey|test", + type: "passkey", + userIdentityId: "user-id", + userAgent: "test-agent", + credential: credential, + createdAt: Date(timeIntervalSince1970: 0), + aaguid: "aaguid", + relyingPartyIdentifier: "example.com" + ) + return request(.success(method)) } func confirmTOTPEnrollment(id: String, authSession: String, diff --git a/auth0_flutter/example/lib/example_app.dart b/auth0_flutter/example/lib/example_app.dart index 513b674a5..e857632b0 100644 --- a/auth0_flutter/example/lib/example_app.dart +++ b/auth0_flutter/example/lib/example_app.dart @@ -232,8 +232,7 @@ class _ExampleAppState extends State { 'Token Type: ${ssoCredentials.tokenType}\n' 'Expires In: ${ssoCredentials.expiresIn}s\n' 'ID Token: ${ssoCredentials.idToken != null ? '****' : 'N/A'}\n' - 'Refresh Token: ' - '${ssoCredentials.refreshToken != null ? '****' : 'N/A'}'; + 'Refresh Token: ${ssoCredentials.refreshToken != null ? '****' : 'N/A'}'; } on CredentialsManagerException catch (e) { output = 'SSO Error: ${e.code}\n${e.message}'; } catch (e) { diff --git a/auth0_flutter/ios/Classes/AuthAPI/AuthAPIPasskeyCredentialExchangeMethodHandler.swift b/auth0_flutter/ios/Classes/AuthAPI/AuthAPIPasskeyCredentialExchangeMethodHandler.swift new file mode 120000 index 000000000..6f0c0bd69 --- /dev/null +++ b/auth0_flutter/ios/Classes/AuthAPI/AuthAPIPasskeyCredentialExchangeMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/AuthAPI/AuthAPIPasskeyCredentialExchangeMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/ios/Classes/AuthAPI/AuthAPIPasskeyExtensions.swift b/auth0_flutter/ios/Classes/AuthAPI/AuthAPIPasskeyExtensions.swift new file mode 120000 index 000000000..d7be4d7d8 --- /dev/null +++ b/auth0_flutter/ios/Classes/AuthAPI/AuthAPIPasskeyExtensions.swift @@ -0,0 +1 @@ +../../../darwin/Classes/AuthAPI/AuthAPIPasskeyExtensions.swift \ No newline at end of file diff --git a/auth0_flutter/ios/Classes/AuthAPI/AuthAPIPasskeyLoginChallengeMethodHandler.swift b/auth0_flutter/ios/Classes/AuthAPI/AuthAPIPasskeyLoginChallengeMethodHandler.swift new file mode 120000 index 000000000..c3386515d --- /dev/null +++ b/auth0_flutter/ios/Classes/AuthAPI/AuthAPIPasskeyLoginChallengeMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/AuthAPI/AuthAPIPasskeyLoginChallengeMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/ios/Classes/AuthAPI/AuthAPIPasskeySignupChallengeMethodHandler.swift b/auth0_flutter/ios/Classes/AuthAPI/AuthAPIPasskeySignupChallengeMethodHandler.swift new file mode 120000 index 000000000..dfbff772f --- /dev/null +++ b/auth0_flutter/ios/Classes/AuthAPI/AuthAPIPasskeySignupChallengeMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/AuthAPI/AuthAPIPasskeySignupChallengeMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountEnrollPasskeyChallengeMethodHandler.swift b/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountEnrollPasskeyChallengeMethodHandler.swift new file mode 120000 index 000000000..a5cad86f0 --- /dev/null +++ b/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountEnrollPasskeyChallengeMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MyAccountAPI/MyAccountEnrollPasskeyChallengeMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountEnrollPasskeyMethodHandler.swift b/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountEnrollPasskeyMethodHandler.swift new file mode 120000 index 000000000..2f1ce0c2d --- /dev/null +++ b/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountEnrollPasskeyMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MyAccountAPI/MyAccountEnrollPasskeyMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/ios/auth0_flutter.podspec b/auth0_flutter/ios/auth0_flutter.podspec index 8ec2d808e..e9528d5ee 100644 --- a/auth0_flutter/ios/auth0_flutter.podspec +++ b/auth0_flutter/ios/auth0_flutter.podspec @@ -24,6 +24,6 @@ Pod::Spec.new do |s| s.dependency 'SimpleKeychain', '1.3.0' # Flutter.framework does not contain a i386 slice. - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386', 'SWIFT_ACTIVE_COMPILATION_CONDITIONS' => '$(inherited) PASSKEYS_PLATFORM' } s.swift_version = ['5.7', '5.8', '5.9'] end diff --git a/auth0_flutter/lib/auth0_flutter.dart b/auth0_flutter/lib/auth0_flutter.dart index e098e11f0..2e2cc5a3a 100644 --- a/auth0_flutter/lib/auth0_flutter.dart +++ b/auth0_flutter/lib/auth0_flutter.dart @@ -23,6 +23,9 @@ export 'package:auth0_flutter_platform_interface/auth0_flutter_platform_interfac LocalAuthenticationLevel, MyAccountException, PasswordlessType, + PasskeyAuthenticatorResponse, + PasskeyChallenge, + PasskeyCredential, PhoneType, SafariViewController, SafariViewControllerPresentationStyle, @@ -30,7 +33,9 @@ export 'package:auth0_flutter_platform_interface/auth0_flutter_platform_interfac SSOCredentials, ApiCredentials, UserProfile, - WebAuthenticationException; + WebAuthenticationException, + PasskeyEnrollmentChallenge, + MyAccountPasskeyAuthenticationMethod; export 'src/desktop/windows_web_authentication.dart'; export 'src/mobile/authentication_api.dart'; diff --git a/auth0_flutter/lib/src/mobile/authentication_api.dart b/auth0_flutter/lib/src/mobile/authentication_api.dart index 9b3ec33ab..acc781946 100644 --- a/auth0_flutter/lib/src/mobile/authentication_api.dart +++ b/auth0_flutter/lib/src/mobile/authentication_api.dart @@ -443,6 +443,132 @@ class AuthenticationApi { AuthResetPasswordOptions( email: email, connection: connection, parameters: parameters))); + /// Requests a challenge for logging in with an existing passkey. + /// + /// This is the first step of the passkey login flow. Use the returned + /// [PasskeyChallenge] to present the OS passkey UI in your app, then pass the + /// resulting credential to [passkeyCredentialExchange] to exchange it for + /// tokens. + /// + /// ## Endpoint + /// https://auth0.com/docs/api/authentication#passkey + /// + /// ## Notes + /// + /// * [connection] is the name of the database connection configured with + /// passkeys. Defaults to the application's first passkey connection when + /// omitted. + /// * [organization] is the optional Auth0 organization to log in to. + /// + /// ## Usage example + /// + /// ```dart + /// final challenge = await auth0.api.passkeyLoginChallenge( + /// connection: 'Username-Password-Authentication', + /// ); + /// ``` + Future passkeyLoginChallenge({ + final String? connection, + final String? organization, + }) => + Auth0FlutterAuthPlatform.instance.passkeyLoginChallenge( + _createApiRequest(AuthPasskeyLoginChallengeOptions( + connection: connection, + organization: organization, + ))); + + /// Requests a challenge for signing up a new user with a passkey. + /// + /// This is the first step of the passkey signup flow. Use the returned + /// [PasskeyChallenge] to present the OS passkey creation UI in your app, then + /// pass the resulting credential to [passkeyCredentialExchange] to exchange + /// it for tokens. + /// + /// ## Endpoint + /// https://auth0.com/docs/api/authentication#passkey + /// + /// ## Notes + /// + /// * Identify the new user with any combination of [email], [phoneNumber], + /// [username], [name], [givenName], [familyName], [nickname], and [picture], + /// depending on how your connection is configured. + /// * [connection] is the name of the database connection configured with + /// passkeys. Defaults to the application's first passkey connection when + /// omitted. + /// * [organization] is the optional Auth0 organization to sign up to. + /// * [userMetadata] is optional metadata to associate with the new user. + /// + /// ## Usage example + /// + /// ```dart + /// final challenge = await auth0.api.passkeySignupChallenge( + /// email: 'jane.smith@example.com', + /// connection: 'Username-Password-Authentication', + /// ); + /// ``` + Future passkeySignupChallenge({ + final String? email, + final String? phoneNumber, + final String? username, + final String? name, + final String? givenName, + final String? familyName, + final String? nickname, + final String? picture, + final String? connection, + final String? organization, + final Map? userMetadata, + }) => + Auth0FlutterAuthPlatform.instance.passkeySignupChallenge( + _createApiRequest(AuthPasskeySignupChallengeOptions( + email: email, + phoneNumber: phoneNumber, + username: username, + name: name, + givenName: givenName, + familyName: familyName, + nickname: nickname, + picture: picture, + connection: connection, + organization: organization, + userMetadata: userMetadata, + ))); + + /// Exchanges a passkey [credential] for Auth0 tokens. + /// + /// This is the final step of both the passkey login and signup flows. The + /// [credential] is the WebAuthn assertion (login) or attestation (signup) + /// obtained from the operating system's authentication UI (for example, via + /// Apple's `ASAuthorizationController` or Android's Credential Manager in + /// your app), using the [PasskeyChallenge] returned by + /// [passkeyLoginChallenge] or [passkeySignupChallenge]. This call exchanges + /// that credential at the + /// `/oauth/token` endpoint. + Future passkeyCredentialExchange({ + required final PasskeyChallenge challenge, + required final PasskeyCredential credential, + final String? connection, + final String? audience, + final Set scopes = const { + 'openid', + 'profile', + 'email', + 'offline_access' + }, + final String? organization, + final Map parameters = const {}, + }) => + Auth0FlutterAuthPlatform.instance.passkeyCredentialExchange( + _createApiRequest(AuthPasskeyExchangeOptions( + challenge: challenge, + credential: credential, + connection: connection, + audience: audience, + scopes: scopes, + organization: organization, + parameters: parameters, + ))); + ApiRequest _createApiRequest( final TOptions options) => ApiRequest( diff --git a/auth0_flutter/lib/src/mobile/my_account_api.dart b/auth0_flutter/lib/src/mobile/my_account_api.dart index 5b7e188c0..a6bd7470c 100644 --- a/auth0_flutter/lib/src/mobile/my_account_api.dart +++ b/auth0_flutter/lib/src/mobile/my_account_api.dart @@ -101,6 +101,70 @@ class MyAccountApi { Auth0FlutterMyAccountPlatform.instance.getFactors(_createApiRequest( MyAccountGetFactorsOptions(accessToken: _accessToken))); + /// Requests a challenge for enrolling a new passkey. This is the first part + /// of the passkey enrollment flow. + /// + /// Returns a [PasskeyEnrollmentChallenge] containing the WebAuthn creation + /// options. Use it to present the OS passkey creation UI in your app, then + /// pass the challenge together with the resulting [PasskeyCredential] to + /// [enrollPasskey] to complete the enrollment. + /// + /// Optionally pass a [userIdentityId] (needed if the user logged in with a + /// linked account) and/or a database [connection] name. If a connection name + /// is not specified, your tenant's default directory is used. + /// + /// Requires an access token with the `create:me:authentication_methods` + /// scope and audience `https://{domain}/me/`. + /// + /// ## Usage example + /// + /// ```dart + /// final challenge = await myAccount.enrollPasskeyChallenge(); + /// // Present the OS passkey creation UI with challenge.authParamsPublicKey, + /// // then call enrollPasskey with the resulting credential. + /// ``` + Future enrollPasskeyChallenge({ + final String? userIdentityId, + final String? connection, + }) => + Auth0FlutterMyAccountPlatform.instance.enrollPasskeyChallenge( + _createApiRequest(MyAccountEnrollPasskeyChallengeOptions( + accessToken: _accessToken, + userIdentityId: userIdentityId, + connection: connection))); + + /// Enrolls a new passkey [credential]. This is the last part of the passkey + /// enrollment flow. + /// + /// Call this after [enrollPasskeyChallenge] and after obtaining the passkey + /// [credential] from the platform authenticator (for example, via Apple's + /// `ASAuthorizationController` or Android's Credential Manager). Pass the + /// same [challenge] returned by [enrollPasskeyChallenge]. + /// + /// Returns the enrolled [MyAccountPasskeyAuthenticationMethod]. + /// + /// Requires an access token with the `create:me:authentication_methods` + /// scope and audience `https://{domain}/me/`. + /// + /// + /// ## Usage example + /// + /// ```dart + /// final method = await myAccount.enrollPasskey( + /// challenge: challenge, + /// credential: credential, + /// ); + /// ``` + Future enrollPasskey({ + required final PasskeyEnrollmentChallenge challenge, + required final PasskeyCredential credential, + }) => + Auth0FlutterMyAccountPlatform.instance.enrollPasskey(_createApiRequest( + MyAccountEnrollPasskeyOptions( + accessToken: _accessToken, + challenge: challenge, + credential: credential))); + /// Initiates phone enrollment with the specified [phoneNumber] and [type]. /// /// Returns an [EnrollmentChallenge] containing the enrollment `id` and @@ -163,9 +227,8 @@ class MyAccountApi { /// // Use challenge.totpUri to display a QR code /// ``` Future enrollTotp() => - Auth0FlutterMyAccountPlatform.instance.enrollTotp( - _createApiRequest(MyAccountEnrollTotpOptions( - accessToken: _accessToken))); + Auth0FlutterMyAccountPlatform.instance.enrollTotp(_createApiRequest( + MyAccountEnrollTotpOptions(accessToken: _accessToken))); /// Initiates push notification enrollment. /// @@ -181,9 +244,8 @@ class MyAccountApi { /// final challenge = await myAccount.enrollPush(); /// ``` Future enrollPush() => - Auth0FlutterMyAccountPlatform.instance.enrollPush( - _createApiRequest(MyAccountEnrollPushOptions( - accessToken: _accessToken))); + Auth0FlutterMyAccountPlatform.instance.enrollPush(_createApiRequest( + MyAccountEnrollPushOptions(accessToken: _accessToken))); /// Initiates recovery code enrollment. /// @@ -200,8 +262,8 @@ class MyAccountApi { /// ``` Future enrollRecoveryCode() => Auth0FlutterMyAccountPlatform.instance.enrollRecoveryCode( - _createApiRequest(MyAccountEnrollRecoveryCodeOptions( - accessToken: _accessToken))); + _createApiRequest( + MyAccountEnrollRecoveryCodeOptions(accessToken: _accessToken))); /// Verifies an enrollment using a one-time password [otp]. /// diff --git a/auth0_flutter/macos/Classes/AuthAPI/AuthAPIPasskeyCredentialExchangeMethodHandler.swift b/auth0_flutter/macos/Classes/AuthAPI/AuthAPIPasskeyCredentialExchangeMethodHandler.swift new file mode 120000 index 000000000..6f0c0bd69 --- /dev/null +++ b/auth0_flutter/macos/Classes/AuthAPI/AuthAPIPasskeyCredentialExchangeMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/AuthAPI/AuthAPIPasskeyCredentialExchangeMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/macos/Classes/AuthAPI/AuthAPIPasskeyExtensions.swift b/auth0_flutter/macos/Classes/AuthAPI/AuthAPIPasskeyExtensions.swift new file mode 120000 index 000000000..d7be4d7d8 --- /dev/null +++ b/auth0_flutter/macos/Classes/AuthAPI/AuthAPIPasskeyExtensions.swift @@ -0,0 +1 @@ +../../../darwin/Classes/AuthAPI/AuthAPIPasskeyExtensions.swift \ No newline at end of file diff --git a/auth0_flutter/macos/Classes/AuthAPI/AuthAPIPasskeyLoginChallengeMethodHandler.swift b/auth0_flutter/macos/Classes/AuthAPI/AuthAPIPasskeyLoginChallengeMethodHandler.swift new file mode 120000 index 000000000..c3386515d --- /dev/null +++ b/auth0_flutter/macos/Classes/AuthAPI/AuthAPIPasskeyLoginChallengeMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/AuthAPI/AuthAPIPasskeyLoginChallengeMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/macos/Classes/AuthAPI/AuthAPIPasskeySignupChallengeMethodHandler.swift b/auth0_flutter/macos/Classes/AuthAPI/AuthAPIPasskeySignupChallengeMethodHandler.swift new file mode 120000 index 000000000..dfbff772f --- /dev/null +++ b/auth0_flutter/macos/Classes/AuthAPI/AuthAPIPasskeySignupChallengeMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/AuthAPI/AuthAPIPasskeySignupChallengeMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountEnrollPasskeyChallengeMethodHandler.swift b/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountEnrollPasskeyChallengeMethodHandler.swift new file mode 120000 index 000000000..a5cad86f0 --- /dev/null +++ b/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountEnrollPasskeyChallengeMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MyAccountAPI/MyAccountEnrollPasskeyChallengeMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountEnrollPasskeyMethodHandler.swift b/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountEnrollPasskeyMethodHandler.swift new file mode 120000 index 000000000..2f1ce0c2d --- /dev/null +++ b/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountEnrollPasskeyMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MyAccountAPI/MyAccountEnrollPasskeyMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/macos/auth0_flutter.podspec b/auth0_flutter/macos/auth0_flutter.podspec index 8ec2d808e..e9528d5ee 100644 --- a/auth0_flutter/macos/auth0_flutter.podspec +++ b/auth0_flutter/macos/auth0_flutter.podspec @@ -24,6 +24,6 @@ Pod::Spec.new do |s| s.dependency 'SimpleKeychain', '1.3.0' # Flutter.framework does not contain a i386 slice. - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386', 'SWIFT_ACTIVE_COMPILATION_CONDITIONS' => '$(inherited) PASSKEYS_PLATFORM' } s.swift_version = ['5.7', '5.8', '5.9'] end diff --git a/auth0_flutter/test/mobile/authentication_api_test.dart b/auth0_flutter/test/mobile/authentication_api_test.dart index 2329800b5..b58a12a93 100644 --- a/auth0_flutter/test/mobile/authentication_api_test.dart +++ b/auth0_flutter/test/mobile/authentication_api_test.dart @@ -1,5 +1,6 @@ import 'package:auth0_flutter/auth0_flutter.dart'; -import 'package:auth0_flutter_platform_interface/auth0_flutter_platform_interface.dart'; +import 'package:auth0_flutter_platform_interface/auth0_flutter_platform_interface.dart' + hide PasskeyAuthenticatorResponse, PasskeyChallenge, PasskeyCredential; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; @@ -46,6 +47,48 @@ class TestPlatform extends Mock idToken: 'id-token', refreshToken: 'new-refresh-token', ); + + static const PasskeyChallenge passkeyLoginChallengeResult = PasskeyChallenge( + authSession: 'test-auth-session', + authParamsPublicKey: { + 'challenge': 'test-challenge', + 'rpId': 'test-rp-id', + }, + ); + + static const PasskeyCredential passkeyLoginCredential = PasskeyCredential( + id: 'test-credential-id', + rawId: 'test-raw-id', + type: 'public-key', + authenticatorAttachment: 'platform', + response: PasskeyAuthenticatorResponse( + clientDataJSON: 'test-client-data', + authenticatorData: 'test-authenticator-data', + signature: 'test-signature', + userHandle: 'test-user-handle', + ), + ); + + static const PasskeyChallenge passkeySignupChallengeResult = PasskeyChallenge( + authSession: 'test-auth-session', + authParamsPublicKey: { + 'challenge': 'test-challenge', + 'rpId': 'test-rp-id', + 'userId': 'test-user-id', + 'userName': 'test-user-name', + }, + ); + + static const PasskeyCredential passkeySignupCredential = PasskeyCredential( + id: 'test-credential-id', + rawId: 'test-raw-id', + type: 'public-key', + authenticatorAttachment: 'platform', + response: PasskeyAuthenticatorResponse( + clientDataJSON: 'test-client-data', + attestationObject: 'test-attestation', + ), + ); } @GenerateMocks([TestPlatform]) @@ -306,9 +349,9 @@ void main() { parameters: {'param1': 'value1'}, headers: {'X-Custom': 'custom-value'}); - final verificationResult = - verify(mockedPlatform.ssoExchange(captureAny)).captured.single - as ApiRequest; + final verificationResult = verify(mockedPlatform.ssoExchange(captureAny)) + .captured + .single as ApiRequest; expect(verificationResult.account.domain, 'test-domain'); expect(verificationResult.account.clientId, 'test-clientId'); expect(verificationResult.options.refreshToken, 'test-refresh-token'); @@ -325,9 +368,9 @@ void main() { .api .ssoExchange(refreshToken: 'test-refresh-token'); - final verificationResult = - verify(mockedPlatform.ssoExchange(captureAny)).captured.single - as ApiRequest; + final verificationResult = verify(mockedPlatform.ssoExchange(captureAny)) + .captured + .single as ApiRequest; expect(verificationResult.options.parameters, isEmpty); expect(verificationResult.options.headers, isEmpty); }); @@ -391,4 +434,173 @@ void main() { expect(verificationResult.options?.tokenType, 'DPoP'); }); }); + + group('passkeyLoginChallenge', () { + test('passes through properties to the platform', () async { + when(mockedPlatform.passkeyLoginChallenge(any)).thenAnswer( + (final _) async => TestPlatform.passkeyLoginChallengeResult); + + final result = await Auth0('test-domain', 'test-clientId') + .api + .passkeyLoginChallenge( + connection: 'test-connection', organization: 'test-org'); + + final verificationResult = + verify(mockedPlatform.passkeyLoginChallenge(captureAny)) + .captured + .single as ApiRequest; + expect(verificationResult.account.domain, 'test-domain'); + expect(verificationResult.account.clientId, 'test-clientId'); + expect(verificationResult.options.connection, 'test-connection'); + expect(verificationResult.options.organization, 'test-org'); + expect(result, TestPlatform.passkeyLoginChallengeResult); + }); + + test('sets connection and organization to null when omitted', () async { + when(mockedPlatform.passkeyLoginChallenge(any)).thenAnswer( + (final _) async => TestPlatform.passkeyLoginChallengeResult); + + await Auth0('test-domain', 'test-clientId').api.passkeyLoginChallenge(); + + final verificationResult = + verify(mockedPlatform.passkeyLoginChallenge(captureAny)) + .captured + .single as ApiRequest; + expect(verificationResult.options.connection, isNull); + expect(verificationResult.options.organization, isNull); + }); + }); + + group('passkeyCredentialExchange', () { + test('passes through properties to the platform with login credential', + () async { + when(mockedPlatform.passkeyCredentialExchange(any)) + .thenAnswer((final _) async => TestPlatform.loginResult); + + final result = await Auth0('test-domain', 'test-clientId') + .api + .passkeyCredentialExchange( + challenge: TestPlatform.passkeyLoginChallengeResult, + credential: TestPlatform.passkeyLoginCredential, + connection: 'test-connection', + audience: 'test-audience', + scopes: {'test-scope1', 'test-scope2'}, + organization: 'test-org', + parameters: {'test': 'test-parameter'}, + ); + + final verificationResult = + verify(mockedPlatform.passkeyCredentialExchange(captureAny)) + .captured + .single as ApiRequest; + expect(verificationResult.account.domain, 'test-domain'); + expect(verificationResult.account.clientId, 'test-clientId'); + expect(verificationResult.options.challenge.authSession, + 'test-auth-session'); + expect(verificationResult.options.credential.id, 'test-credential-id'); + expect(verificationResult.options.connection, 'test-connection'); + expect(verificationResult.options.audience, 'test-audience'); + expect(verificationResult.options.scopes, {'test-scope1', 'test-scope2'}); + expect(verificationResult.options.organization, 'test-org'); + expect(verificationResult.options.parameters['test'], 'test-parameter'); + expect(result, TestPlatform.loginResult); + }); + + test('uses default scopes and empty params/null fields when omitted', + () async { + when(mockedPlatform.passkeyCredentialExchange(any)) + .thenAnswer((final _) async => TestPlatform.loginResult); + + await Auth0('test-domain', 'test-clientId').api.passkeyCredentialExchange( + challenge: TestPlatform.passkeyLoginChallengeResult, + credential: TestPlatform.passkeyLoginCredential, + ); + + final verificationResult = + verify(mockedPlatform.passkeyCredentialExchange(captureAny)) + .captured + .single as ApiRequest; + expect(verificationResult.options.scopes, + {'openid', 'profile', 'email', 'offline_access'}); + expect(verificationResult.options.parameters, isEmpty); + expect(verificationResult.options.connection, isNull); + expect(verificationResult.options.audience, isNull); + expect(verificationResult.options.organization, isNull); + }); + + test('passes through properties to the platform with signup credential', + () async { + when(mockedPlatform.passkeyCredentialExchange(any)) + .thenAnswer((final _) async => TestPlatform.loginResult); + + final result = await Auth0('test-domain', 'test-clientId') + .api + .passkeyCredentialExchange( + challenge: TestPlatform.passkeySignupChallengeResult, + credential: TestPlatform.passkeySignupCredential, + connection: 'test-connection', + audience: 'test-audience', + scopes: {'test-scope1', 'test-scope2'}, + organization: 'test-org', + parameters: {'test': 'test-parameter'}, + ); + + final verificationResult = + verify(mockedPlatform.passkeyCredentialExchange(captureAny)) + .captured + .single as ApiRequest; + expect(verificationResult.account.domain, 'test-domain'); + expect(verificationResult.account.clientId, 'test-clientId'); + expect(verificationResult.options.challenge.authSession, + 'test-auth-session'); + expect(verificationResult.options.credential.id, 'test-credential-id'); + expect(verificationResult.options.connection, 'test-connection'); + expect(verificationResult.options.audience, 'test-audience'); + expect(verificationResult.options.scopes, {'test-scope1', 'test-scope2'}); + expect(verificationResult.options.organization, 'test-org'); + expect(verificationResult.options.parameters['test'], 'test-parameter'); + expect(result, TestPlatform.loginResult); + }); + }); + + group('passkeySignupChallenge', () { + test('passes through properties to the platform', () async { + when(mockedPlatform.passkeySignupChallenge(any)).thenAnswer( + (final _) async => TestPlatform.passkeySignupChallengeResult); + + final result = await Auth0('test-domain', 'test-clientId') + .api + .passkeySignupChallenge( + email: 'test-email', + phoneNumber: 'test-phone', + username: 'test-username', + name: 'test-name', + givenName: 'test-given-name', + familyName: 'test-family-name', + nickname: 'test-nickname', + picture: 'https://www.okta.com', + connection: 'test-connection', + organization: 'test-org', + userMetadata: {'plan': 'gold'}, + ); + + final verificationResult = + verify(mockedPlatform.passkeySignupChallenge(captureAny)) + .captured + .single as ApiRequest; + expect(verificationResult.account.domain, 'test-domain'); + expect(verificationResult.options.email, 'test-email'); + expect(verificationResult.options.phoneNumber, 'test-phone'); + expect(verificationResult.options.username, 'test-username'); + expect(verificationResult.options.name, 'test-name'); + expect(verificationResult.options.givenName, 'test-given-name'); + expect(verificationResult.options.familyName, 'test-family-name'); + expect(verificationResult.options.nickname, 'test-nickname'); + expect(verificationResult.options.picture, 'https://www.okta.com'); + expect(verificationResult.options.connection, 'test-connection'); + expect(verificationResult.options.organization, 'test-org'); + expect(verificationResult.options.userMetadata, {'plan': 'gold'}); + expect(result, TestPlatform.passkeySignupChallengeResult); + }); + }); } diff --git a/auth0_flutter/test/mobile/authentication_api_test.mocks.dart b/auth0_flutter/test/mobile/authentication_api_test.mocks.dart index 4639ffe32..911d0cf41 100644 --- a/auth0_flutter/test/mobile/authentication_api_test.mocks.dart +++ b/auth0_flutter/test/mobile/authentication_api_test.mocks.dart @@ -78,6 +78,17 @@ class _FakeSSOCredentials_4 extends _i1.SmartFake ); } +class _FakePasskeyChallenge_5 extends _i1.SmartFake + implements _i3.PasskeyChallenge { + _FakePasskeyChallenge_5( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + /// A class which mocks [TestPlatform]. /// /// See the documentation for Mockito's code generation for more information. @@ -291,4 +302,57 @@ class MockTestPlatform extends _i1.Mock implements _i4.TestPlatform { returnValue: _i5.Future.value(), returnValueForMissingStub: _i5.Future.value(), ) as _i5.Future); + + @override + _i5.Future<_i3.PasskeyChallenge> passkeyLoginChallenge( + _i3.ApiRequest<_i3.AuthPasskeyLoginChallengeOptions>? request) => + (super.noSuchMethod( + Invocation.method( + #passkeyLoginChallenge, + [request], + ), + returnValue: + _i5.Future<_i3.PasskeyChallenge>.value(_FakePasskeyChallenge_5( + this, + Invocation.method( + #passkeyLoginChallenge, + [request], + ), + )), + ) as _i5.Future<_i3.PasskeyChallenge>); + + @override + _i5.Future<_i3.PasskeyChallenge> passkeySignupChallenge( + _i3.ApiRequest<_i3.AuthPasskeySignupChallengeOptions>? request) => + (super.noSuchMethod( + Invocation.method( + #passkeySignupChallenge, + [request], + ), + returnValue: + _i5.Future<_i3.PasskeyChallenge>.value(_FakePasskeyChallenge_5( + this, + Invocation.method( + #passkeySignupChallenge, + [request], + ), + )), + ) as _i5.Future<_i3.PasskeyChallenge>); + + @override + _i5.Future<_i2.Credentials> passkeyCredentialExchange( + _i3.ApiRequest<_i3.AuthPasskeyExchangeOptions>? request) => + (super.noSuchMethod( + Invocation.method( + #passkeyCredentialExchange, + [request], + ), + returnValue: _i5.Future<_i2.Credentials>.value(_FakeCredentials_0( + this, + Invocation.method( + #passkeyCredentialExchange, + [request], + ), + )), + ) as _i5.Future<_i2.Credentials>); } diff --git a/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart b/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart index 527702e64..cd634c977 100644 --- a/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart +++ b/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart @@ -7,6 +7,9 @@ export 'src/auth/auth_login_code_options.dart'; export 'src/auth/auth_login_options.dart'; export 'src/auth/auth_login_with_otp_options.dart'; export 'src/auth/auth_multifactor_challenge_options.dart'; +export 'src/auth/auth_passkey_exchange_options.dart'; +export 'src/auth/auth_passkey_login_challenge_options.dart'; +export 'src/auth/auth_passkey_signup_challenge_options.dart'; export 'src/auth/auth_passwordless_login_options.dart'; export 'src/auth/auth_passwordless_type.dart'; export 'src/auth/auth_renew_access_token_options.dart'; @@ -18,6 +21,8 @@ export 'src/auth/challenge.dart'; export 'src/auth/challenge_type.dart'; export 'src/auth/dpop_headers.dart'; export 'src/auth/empty_request_options.dart'; +export 'src/auth/passkey_challenge.dart'; +export 'src/auth/passkey_credential.dart'; export 'src/auth0_exception.dart'; export 'src/auth0_flutter_auth_platform.dart'; export 'src/auth0_flutter_dpop_platform.dart'; @@ -51,6 +56,8 @@ export 'src/myaccount/method_channel_auth0_flutter_my_account.dart'; export 'src/myaccount/my_account_confirm_enrollment_options.dart'; export 'src/myaccount/my_account_delete_auth_method_options.dart'; export 'src/myaccount/my_account_enroll_email_options.dart'; +export 'src/myaccount/my_account_enroll_passkey_challenge_options.dart'; +export 'src/myaccount/my_account_enroll_passkey_options.dart'; export 'src/myaccount/my_account_enroll_phone_options.dart'; export 'src/myaccount/my_account_enroll_push_options.dart'; export 'src/myaccount/my_account_enroll_recovery_code_options.dart'; @@ -59,6 +66,8 @@ export 'src/myaccount/my_account_exception.dart'; export 'src/myaccount/my_account_get_auth_method_options.dart'; export 'src/myaccount/my_account_get_auth_methods_options.dart'; export 'src/myaccount/my_account_get_factors_options.dart'; +export 'src/myaccount/my_account_passkey_authentication_method.dart'; +export 'src/myaccount/my_account_passkey_enrollment_challenge.dart'; export 'src/myaccount/my_account_update_auth_method_options.dart'; export 'src/myaccount/my_account_verify_otp_options.dart'; export 'src/myaccount/phone_type.dart'; diff --git a/auth0_flutter_platform_interface/lib/src/auth/auth_passkey_exchange_options.dart b/auth0_flutter_platform_interface/lib/src/auth/auth_passkey_exchange_options.dart new file mode 100644 index 000000000..1c86adfac --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/auth/auth_passkey_exchange_options.dart @@ -0,0 +1,34 @@ +import '../request/request_options.dart'; +import 'passkey_challenge.dart'; +import 'passkey_credential.dart'; + +class AuthPasskeyExchangeOptions implements RequestOptions { + final PasskeyChallenge challenge; + final PasskeyCredential credential; + final String? connection; + final String? audience; + final Set scopes; + final String? organization; + final Map parameters; + + AuthPasskeyExchangeOptions({ + required this.challenge, + required this.credential, + this.connection, + this.audience, + this.scopes = const {'openid', 'profile', 'email', 'offline_access'}, + this.organization, + this.parameters = const {}, + }); + + @override + Map toMap() => { + 'challenge': challenge.toMap(), + 'credential': credential.toMap(), + 'connection': connection, + 'audience': audience, + 'scopes': scopes.toList(), + 'organization': organization, + 'parameters': parameters, + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/auth/auth_passkey_login_challenge_options.dart b/auth0_flutter_platform_interface/lib/src/auth/auth_passkey_login_challenge_options.dart new file mode 100644 index 000000000..29ad905c6 --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/auth/auth_passkey_login_challenge_options.dart @@ -0,0 +1,17 @@ +import '../request/request_options.dart'; + +class AuthPasskeyLoginChallengeOptions implements RequestOptions { + final String? connection; + final String? organization; + + AuthPasskeyLoginChallengeOptions({ + this.connection, + this.organization, + }); + + @override + Map toMap() => { + 'connection': connection, + 'organization': organization, + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/auth/auth_passkey_signup_challenge_options.dart b/auth0_flutter_platform_interface/lib/src/auth/auth_passkey_signup_challenge_options.dart new file mode 100644 index 000000000..a93cd185a --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/auth/auth_passkey_signup_challenge_options.dart @@ -0,0 +1,44 @@ +import '../request/request_options.dart'; + +class AuthPasskeySignupChallengeOptions implements RequestOptions { + final String? email; + final String? phoneNumber; + final String? username; + final String? name; + final String? givenName; + final String? familyName; + final String? nickname; + final String? picture; + final String? connection; + final String? organization; + final Map? userMetadata; + + AuthPasskeySignupChallengeOptions({ + this.email, + this.phoneNumber, + this.username, + this.name, + this.givenName, + this.familyName, + this.nickname, + this.picture, + this.connection, + this.organization, + this.userMetadata, + }); + + @override + Map toMap() => { + 'email': email, + 'phoneNumber': phoneNumber, + 'username': username, + 'name': name, + 'givenName': givenName, + 'familyName': familyName, + 'nickname': nickname, + 'picture': picture, + 'connection': connection, + 'organization': organization, + 'userMetadata': userMetadata, + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/auth/passkey_challenge.dart b/auth0_flutter_platform_interface/lib/src/auth/passkey_challenge.dart new file mode 100644 index 000000000..58512b1b7 --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/auth/passkey_challenge.dart @@ -0,0 +1,32 @@ +/// A challenge issued by Auth0 to begin a passkey ceremony. +/// +/// Returned by `passkeyLoginChallenge` (for an existing user) or +/// `passkeySignupChallenge` (for a new user). Use it to present the OS passkey +/// UI in your app, then pass it together with the resulting credential to +/// `passkeyCredentialExchange` to exchange them for tokens. +class PasskeyChallenge { + /// The authentication session token that ties the challenge to the + /// subsequent token exchange. + final String authSession; + + /// The WebAuthn public-key options (e.g. `challenge`, `rpId`, and — for + /// signup — `userId`/`userName`) used to drive the platform authenticator. + final Map authParamsPublicKey; + + const PasskeyChallenge({ + required this.authSession, + required this.authParamsPublicKey, + }); + + factory PasskeyChallenge.fromMap(final Map result) => + PasskeyChallenge( + authSession: result['authSession'] as String, + authParamsPublicKey: Map.from( + result['authParamsPublicKey'] as Map), + ); + + Map toMap() => { + 'authSession': authSession, + 'authParamsPublicKey': authParamsPublicKey, + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/auth/passkey_credential.dart b/auth0_flutter_platform_interface/lib/src/auth/passkey_credential.dart new file mode 100644 index 000000000..f39da5b25 --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/auth/passkey_credential.dart @@ -0,0 +1,109 @@ +/// The authenticator's response within a [PasskeyCredential]. +/// +/// Follows the standard WebAuthn response format. A login (assertion) response +/// carries [authenticatorData], [signature], and optionally [userHandle]; a +/// signup (attestation) response carries [attestationObject]. The fields not +/// relevant to a given ceremony are left null. +class PasskeyAuthenticatorResponse { + /// Base64URL-encoded client data JSON. Present for both login and signup. + final String clientDataJSON; + + /// Base64URL-encoded authenticator data (login assertion only). + final String? authenticatorData; + + /// Base64URL-encoded assertion signature (login assertion only). + final String? signature; + + /// Base64URL-encoded user handle, if returned by the authenticator + /// (login assertion only). + final String? userHandle; + + /// Base64URL-encoded attestation object (signup registration only). + final String? attestationObject; + + const PasskeyAuthenticatorResponse({ + required this.clientDataJSON, + this.authenticatorData, + this.signature, + this.userHandle, + this.attestationObject, + }); + + factory PasskeyAuthenticatorResponse.fromMap( + final Map map) => + PasskeyAuthenticatorResponse( + clientDataJSON: map['clientDataJSON'] as String, + authenticatorData: map['authenticatorData'] as String?, + signature: map['signature'] as String?, + userHandle: map['userHandle'] as String?, + attestationObject: map['attestationObject'] as String?, + ); + + Map toMap() => { + 'clientDataJSON': clientDataJSON, + if (authenticatorData != null) 'authenticatorData': authenticatorData, + if (signature != null) 'signature': signature, + if (userHandle != null) 'userHandle': userHandle, + if (attestationObject != null) 'attestationObject': attestationObject, + }; +} + +/// A passkey credential obtained from the platform authenticator. +/// +/// Your app presents the OS passkey UI (for example, via Apple's +/// `ASAuthorizationController` or Android's Credential Manager) and constructs +/// this from the resulting assertion (login) or attestation (signup), then +/// passes it to `passkeyCredentialExchange` to exchange it for Auth0 tokens. It +/// follows the standard WebAuthn public key credential format. +class PasskeyCredential { + /// Base64URL-encoded credential identifier. + final String id; + + /// Base64URL-encoded raw credential identifier. + final String rawId; + + /// Credential type, typically `public-key`. + final String type; + + /// How the authenticator is attached (`platform` or `cross-platform`). + final String? authenticatorAttachment; + + /// The authenticator's response (assertion for login, attestation for + /// signup). + final PasskeyAuthenticatorResponse response; + + /// Results of any requested client extensions. + final Map? clientExtensionResults; + + const PasskeyCredential({ + required this.id, + required this.rawId, + required this.type, + required this.response, + this.authenticatorAttachment, + this.clientExtensionResults, + }); + + factory PasskeyCredential.fromMap(final Map map) => + PasskeyCredential( + id: map['id'] as String, + rawId: map['rawId'] as String, + type: map['type'] as String? ?? 'public-key', + authenticatorAttachment: map['authenticatorAttachment'] as String?, + response: PasskeyAuthenticatorResponse.fromMap( + map['response'] as Map), + clientExtensionResults: map['clientExtensionResults'] == null + ? null + : Map.from( + map['clientExtensionResults'] as Map), + ); + + Map toMap() => { + 'id': id, + 'rawId': rawId, + 'type': type, + 'authenticatorAttachment': authenticatorAttachment, + 'response': response.toMap(), + 'clientExtensionResults': clientExtensionResults, + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/auth0_flutter_auth_platform.dart b/auth0_flutter_platform_interface/lib/src/auth0_flutter_auth_platform.dart index 63f657454..b908204de 100644 --- a/auth0_flutter_platform_interface/lib/src/auth0_flutter_auth_platform.dart +++ b/auth0_flutter_platform_interface/lib/src/auth0_flutter_auth_platform.dart @@ -6,6 +6,9 @@ import 'auth/auth_login_code_options.dart'; import 'auth/auth_login_options.dart'; import 'auth/auth_login_with_otp_options.dart'; import 'auth/auth_multifactor_challenge_options.dart'; +import 'auth/auth_passkey_exchange_options.dart'; +import 'auth/auth_passkey_login_challenge_options.dart'; +import 'auth/auth_passkey_signup_challenge_options.dart'; import 'auth/auth_passwordless_login_options.dart'; import 'auth/auth_renew_access_token_options.dart'; import 'auth/auth_reset_password_options.dart'; @@ -13,6 +16,7 @@ import 'auth/auth_signup_options.dart'; import 'auth/auth_sso_exchange_options.dart'; import 'auth/auth_user_info_options.dart'; import 'auth/challenge.dart'; +import 'auth/passkey_challenge.dart'; import 'credentials.dart'; import 'database_user.dart'; import 'method_channel_auth0_flutter_auth.dart'; @@ -93,4 +97,22 @@ abstract class Auth0FlutterAuthPlatform extends PlatformInterface { final ApiRequest request) { throw UnimplementedError('authResetPassword() has not been implemented'); } + + Future passkeyLoginChallenge( + final ApiRequest request) { + throw UnimplementedError( + 'passkeyLoginChallenge() has not been implemented'); + } + + Future passkeySignupChallenge( + final ApiRequest request) { + throw UnimplementedError( + 'passkeySignupChallenge() has not been implemented'); + } + + Future passkeyCredentialExchange( + final ApiRequest request) { + throw UnimplementedError( + 'passkeyCredentialExchange() has not been implemented'); + } } diff --git a/auth0_flutter_platform_interface/lib/src/method_channel_auth0_flutter_auth.dart b/auth0_flutter_platform_interface/lib/src/method_channel_auth0_flutter_auth.dart index e59cfaaa1..efb5a36d0 100644 --- a/auth0_flutter_platform_interface/lib/src/method_channel_auth0_flutter_auth.dart +++ b/auth0_flutter_platform_interface/lib/src/method_channel_auth0_flutter_auth.dart @@ -6,6 +6,9 @@ import 'auth/auth_login_code_options.dart'; import 'auth/auth_login_options.dart'; import 'auth/auth_login_with_otp_options.dart'; import 'auth/auth_multifactor_challenge_options.dart'; +import 'auth/auth_passkey_exchange_options.dart'; +import 'auth/auth_passkey_login_challenge_options.dart'; +import 'auth/auth_passkey_signup_challenge_options.dart'; import 'auth/auth_passwordless_login_options.dart'; import 'auth/auth_renew_access_token_options.dart'; import 'auth/auth_reset_password_options.dart'; @@ -13,6 +16,7 @@ import 'auth/auth_signup_options.dart'; import 'auth/auth_sso_exchange_options.dart'; import 'auth/auth_user_info_options.dart'; import 'auth/challenge.dart'; +import 'auth/passkey_challenge.dart'; import 'auth0_flutter_auth_platform.dart'; import 'credentials.dart'; import 'database_user.dart'; @@ -39,6 +43,10 @@ const String authCustomTokenExchangeMethod = 'auth#customTokenExchange'; const String authSSOExchangeMethod = 'auth#ssoExchange'; const String authResetPasswordMethod = 'auth#resetPassword'; +const String authPasskeyLoginChallengeMethod = 'auth#passkeyLoginChallenge'; +const String authPasskeySignupChallengeMethod = 'auth#passkeySignupChallenge'; +const String authPasskeyCredentialExchangeMethod = + 'auth#passkeyCredentialExchange'; class MethodChannelAuth0FlutterAuth extends Auth0FlutterAuthPlatform { @override @@ -158,6 +166,30 @@ class MethodChannelAuth0FlutterAuth extends Auth0FlutterAuthPlatform { method: authResetPasswordMethod, request: request, throwOnNull: false); } + @override + Future passkeyLoginChallenge( + final ApiRequest request) async { + final Map result = await invokeRequest( + method: authPasskeyLoginChallengeMethod, request: request); + return PasskeyChallenge.fromMap(result); + } + + @override + Future passkeySignupChallenge( + final ApiRequest request) async { + final Map result = await invokeRequest( + method: authPasskeySignupChallengeMethod, request: request); + return PasskeyChallenge.fromMap(result); + } + + @override + Future passkeyCredentialExchange( + final ApiRequest request) async { + final Map result = await invokeRequest( + method: authPasskeyCredentialExchangeMethod, request: request); + return Credentials.fromMap(result); + } + Future> invokeRequest({ required final String method, required final ApiRequest request, diff --git a/auth0_flutter_platform_interface/lib/src/myaccount/auth0_flutter_my_account_platform.dart b/auth0_flutter_platform_interface/lib/src/myaccount/auth0_flutter_my_account_platform.dart index c5ff94754..d770795d2 100644 --- a/auth0_flutter_platform_interface/lib/src/myaccount/auth0_flutter_my_account_platform.dart +++ b/auth0_flutter_platform_interface/lib/src/myaccount/auth0_flutter_my_account_platform.dart @@ -8,6 +8,8 @@ import 'method_channel_auth0_flutter_my_account.dart'; import 'my_account_confirm_enrollment_options.dart'; import 'my_account_delete_auth_method_options.dart'; import 'my_account_enroll_email_options.dart'; +import 'my_account_enroll_passkey_challenge_options.dart'; +import 'my_account_enroll_passkey_options.dart'; import 'my_account_enroll_phone_options.dart'; import 'my_account_enroll_push_options.dart'; import 'my_account_enroll_recovery_code_options.dart'; @@ -15,6 +17,8 @@ import 'my_account_enroll_totp_options.dart'; import 'my_account_get_auth_method_options.dart'; import 'my_account_get_auth_methods_options.dart'; import 'my_account_get_factors_options.dart'; +import 'my_account_passkey_authentication_method.dart'; +import 'my_account_passkey_enrollment_challenge.dart'; import 'my_account_update_auth_method_options.dart'; import 'my_account_verify_otp_options.dart'; @@ -54,6 +58,17 @@ abstract class Auth0FlutterMyAccountPlatform extends PlatformInterface { throw UnimplementedError('getFactors() has not been implemented'); } + Future enrollPasskeyChallenge( + final ApiRequest request) { + throw UnimplementedError( + 'enrollPasskeyChallenge() has not been implemented'); + } + + Future enrollPasskey( + final ApiRequest request) { + throw UnimplementedError('enrollPasskey() has not been implemented'); + } + Future enrollPhone( final ApiRequest request) { throw UnimplementedError('enrollPhone() has not been implemented'); diff --git a/auth0_flutter_platform_interface/lib/src/myaccount/method_channel_auth0_flutter_my_account.dart b/auth0_flutter_platform_interface/lib/src/myaccount/method_channel_auth0_flutter_my_account.dart index b732a7c1c..16e8c420f 100644 --- a/auth0_flutter_platform_interface/lib/src/myaccount/method_channel_auth0_flutter_my_account.dart +++ b/auth0_flutter_platform_interface/lib/src/myaccount/method_channel_auth0_flutter_my_account.dart @@ -9,6 +9,8 @@ import 'factor.dart'; import 'my_account_confirm_enrollment_options.dart'; import 'my_account_delete_auth_method_options.dart'; import 'my_account_enroll_email_options.dart'; +import 'my_account_enroll_passkey_challenge_options.dart'; +import 'my_account_enroll_passkey_options.dart'; import 'my_account_enroll_phone_options.dart'; import 'my_account_enroll_push_options.dart'; import 'my_account_enroll_recovery_code_options.dart'; @@ -17,6 +19,8 @@ import 'my_account_exception.dart'; import 'my_account_get_auth_method_options.dart'; import 'my_account_get_auth_methods_options.dart'; import 'my_account_get_factors_options.dart'; +import 'my_account_passkey_authentication_method.dart'; +import 'my_account_passkey_enrollment_challenge.dart'; import 'my_account_update_auth_method_options.dart'; import 'my_account_verify_otp_options.dart'; @@ -30,6 +34,9 @@ const String myAccountGetAuthMethodMethod = const String myAccountDeleteAuthMethodMethod = 'myAccount#deleteAuthenticationMethod'; const String myAccountGetFactorsMethod = 'myAccount#getFactors'; +const String myAccountEnrollPasskeyChallengeMethod = + 'myAccount#enrollPasskeyChallenge'; +const String myAccountEnrollPasskeyMethod = 'myAccount#enrollPasskey'; const String myAccountEnrollPhoneMethod = 'myAccount#enrollPhone'; const String myAccountEnrollEmailMethod = 'myAccount#enrollEmail'; const String myAccountEnrollTotpMethod = 'myAccount#enrollTotp'; @@ -84,6 +91,24 @@ class MethodChannelAuth0FlutterMyAccount .toList(); } + @override + Future enrollPasskeyChallenge( + final ApiRequest request) async { + final Map result = await invokeMapRequest( + method: myAccountEnrollPasskeyChallengeMethod, request: request); + + return PasskeyEnrollmentChallenge.fromMap(result); + } + + @override + Future enrollPasskey( + final ApiRequest request) async { + final Map result = await invokeMapRequest( + method: myAccountEnrollPasskeyMethod, request: request); + + return MyAccountPasskeyAuthenticationMethod.fromMap(result); + } + @override Future enrollPhone( final ApiRequest request) async { diff --git a/auth0_flutter_platform_interface/lib/src/myaccount/my_account_enroll_passkey_challenge_options.dart b/auth0_flutter_platform_interface/lib/src/myaccount/my_account_enroll_passkey_challenge_options.dart new file mode 100644 index 000000000..a9e62fa09 --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/myaccount/my_account_enroll_passkey_challenge_options.dart @@ -0,0 +1,20 @@ +import '../request/request_options.dart'; + +class MyAccountEnrollPasskeyChallengeOptions implements RequestOptions { + final String accessToken; + final String? userIdentityId; + final String? connection; + + MyAccountEnrollPasskeyChallengeOptions({ + required this.accessToken, + this.userIdentityId, + this.connection, + }); + + @override + Map toMap() => { + 'accessToken': accessToken, + if (userIdentityId != null) 'userIdentityId': userIdentityId, + if (connection != null) 'connection': connection, + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/myaccount/my_account_enroll_passkey_options.dart b/auth0_flutter_platform_interface/lib/src/myaccount/my_account_enroll_passkey_options.dart new file mode 100644 index 000000000..1ae3159b7 --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/myaccount/my_account_enroll_passkey_options.dart @@ -0,0 +1,22 @@ +import '../auth/passkey_credential.dart'; +import '../request/request_options.dart'; +import 'my_account_passkey_enrollment_challenge.dart'; + +class MyAccountEnrollPasskeyOptions implements RequestOptions { + final String accessToken; + final PasskeyEnrollmentChallenge challenge; + final PasskeyCredential credential; + + MyAccountEnrollPasskeyOptions({ + required this.accessToken, + required this.challenge, + required this.credential, + }); + + @override + Map toMap() => { + 'accessToken': accessToken, + 'challenge': challenge.toMap(), + 'credential': credential.toMap(), + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/myaccount/my_account_passkey_authentication_method.dart b/auth0_flutter_platform_interface/lib/src/myaccount/my_account_passkey_authentication_method.dart new file mode 100644 index 000000000..03c8797e4 --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/myaccount/my_account_passkey_authentication_method.dart @@ -0,0 +1,109 @@ +/// An enrolled passkey authentication method returned by the My Account API. +/// +/// Returned by `enrollPasskey` after the passkey creation ceremony completes. +/// Exposes the passkey-specific metadata associated with the credential, in +/// addition to the common authentication-method fields. +class MyAccountPasskeyAuthenticationMethod { + /// Unique identifier of the authentication method. + final String id; + + /// Type of the authentication method. Equals `passkey`. + final String type; + + /// Unique identifier of the user identity linked with the authentication + /// method. + final String? userIdentityId; + + /// The user agent of the browser or device used to enroll the passkey. + final String? userAgent; + + /// Identifier of the passkey credential. + final String? keyId; + + /// Public key of the passkey credential (base64-encoded). + final String? publicKey; + + /// User handle associated with the passkey credential (base64url-encoded). + final String? userHandle; + + /// Kind of device the credential is stored on (e.g. `single_device` or + /// `multi_device`). + final String? credentialDeviceType; + + /// Whether the passkey credential was backed up. + final bool? credentialBackedUp; + + /// Authenticator Attestation GUID for the passkey provider. + final String? aaguid; + + /// Relying party identifier for the domain. + final String? relyingPartyId; + + /// Transports supported by the authenticator, if reported. + final List? transports; + + /// Creation date of the authentication method. + final DateTime? createdAt; + + /// Usages of the authentication method (e.g. `mfa`). + final List? usage; + + const MyAccountPasskeyAuthenticationMethod({ + required this.id, + required this.type, + this.userIdentityId, + this.userAgent, + this.keyId, + this.publicKey, + this.userHandle, + this.credentialDeviceType, + this.credentialBackedUp, + this.aaguid, + this.relyingPartyId, + this.transports, + this.createdAt, + this.usage, + }); + + factory MyAccountPasskeyAuthenticationMethod.fromMap( + final Map result) => + MyAccountPasskeyAuthenticationMethod( + id: result['id'] as String, + type: result['type'] as String, + userIdentityId: result['identity_user_id'] as String?, + userAgent: result['user_agent'] as String?, + keyId: result['key_id'] as String?, + publicKey: result['public_key'] as String?, + userHandle: result['user_handle'] as String?, + credentialDeviceType: result['credential_device_type'] as String?, + credentialBackedUp: result['credential_backed_up'] as bool?, + aaguid: result['aaguid'] as String?, + relyingPartyId: result['relying_party_id'] as String?, + transports: (result['transports'] as List?) + ?.map((final e) => e as String) + .toList(), + createdAt: result['created_at'] != null + ? DateTime.parse(result['created_at'].toString()) + : null, + usage: (result['usage'] as List?) + ?.map((final e) => e as String) + .toList(), + ); + + Map toMap() => { + 'id': id, + 'type': type, + 'identity_user_id': userIdentityId, + 'user_agent': userAgent, + 'key_id': keyId, + 'public_key': publicKey, + 'user_handle': userHandle, + 'credential_device_type': credentialDeviceType, + 'credential_backed_up': credentialBackedUp, + 'aaguid': aaguid, + 'relying_party_id': relyingPartyId, + 'transports': transports, + 'created_at': createdAt?.toUtc().toIso8601String(), + 'usage': usage, + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/myaccount/my_account_passkey_enrollment_challenge.dart b/auth0_flutter_platform_interface/lib/src/myaccount/my_account_passkey_enrollment_challenge.dart new file mode 100644 index 000000000..21ad04614 --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/myaccount/my_account_passkey_enrollment_challenge.dart @@ -0,0 +1,40 @@ +/// A challenge issued by the My Account API to begin a passkey enrollment +/// ceremony. +/// +/// Returned by `enrollPasskeyChallenge`. Use it to present the OS passkey +/// creation UI in your app, then pass it together with the resulting +/// credential to `enrollPasskey` to complete the enrollment. +class PasskeyEnrollmentChallenge { + /// Unique identifier of the authentication method being enrolled. Needed to + /// complete the enrollment via `enrollPasskey`. + final String authenticationMethodId; + + /// The authentication session token that ties the challenge to the + /// subsequent enrollment. + final String authSession; + + /// The WebAuthn public-key creation options (e.g. `challenge`, `rp`, + /// `user`, `pubKeyCredParams`) used to drive the platform authenticator. + final Map authParamsPublicKey; + + const PasskeyEnrollmentChallenge({ + required this.authenticationMethodId, + required this.authSession, + required this.authParamsPublicKey, + }); + + factory PasskeyEnrollmentChallenge.fromMap( + final Map result) => + PasskeyEnrollmentChallenge( + authenticationMethodId: result['authenticationMethodId'] as String, + authSession: result['authSession'] as String, + authParamsPublicKey: Map.from( + result['authParamsPublicKey'] as Map), + ); + + Map toMap() => { + 'authenticationMethodId': authenticationMethodId, + 'authSession': authSession, + 'authParamsPublicKey': authParamsPublicKey, + }; +} diff --git a/auth0_flutter_platform_interface/test/method_channel_auth0_flutter_auth_test.dart b/auth0_flutter_platform_interface/test/method_channel_auth0_flutter_auth_test.dart index 5b1894a46..c79d956ce 100644 --- a/auth0_flutter_platform_interface/test/method_channel_auth0_flutter_auth_test.dart +++ b/auth0_flutter_platform_interface/test/method_channel_auth0_flutter_auth_test.dart @@ -61,9 +61,69 @@ class MethodCallHandler { 'refreshToken': 'new-refresh-token' }; + static const Map passkeyLoginChallengeResult = { + 'authSession': 'test-auth-session', + 'authParamsPublicKey': { + 'challenge': 'test-challenge', + 'rpId': 'test-rp-id', + }, + }; + + static const Map passkeySignupChallengeResult = { + 'authSession': 'test-auth-session', + 'authParamsPublicKey': { + 'challenge': 'test-challenge', + 'rpId': 'test-rp-id', + 'userId': 'test-user-id', + 'userName': 'test-user-name', + }, + }; + Future? methodCallHandler(final MethodCall? methodCall) async {} } +const PasskeyChallenge _testChallenge = PasskeyChallenge( + authSession: 'test-auth-session', + authParamsPublicKey: { + 'challenge': 'test-challenge', + 'rpId': 'test-rp-id', + }, +); + +const PasskeyCredential _testLoginCredential = PasskeyCredential( + id: 'test-credential-id', + rawId: 'test-raw-id', + type: 'public-key', + authenticatorAttachment: 'platform', + response: PasskeyAuthenticatorResponse( + clientDataJSON: 'test-client-data', + authenticatorData: 'test-authenticator-data', + signature: 'test-signature', + userHandle: 'test-user-handle', + ), +); + +const PasskeyChallenge _testSignupChallenge = PasskeyChallenge( + authSession: 'test-auth-session', + authParamsPublicKey: { + 'challenge': 'test-challenge', + 'rpId': 'test-rp-id', + 'userId': 'test-user-id', + 'userName': 'test-user-name', + }, +); + +const PasskeyCredential _testSignupCredential = PasskeyCredential( + id: 'test-credential-id', + rawId: 'test-raw-id', + type: 'public-key', + authenticatorAttachment: 'platform', + response: PasskeyAuthenticatorResponse( + clientDataJSON: 'test-client-data', + attestationObject: 'test-attestation', + ), +); + @GenerateMocks([MethodCallHandler]) void main() { const MethodChannel channel = MethodChannel('auth0.com/auth0_flutter/auth'); @@ -918,12 +978,13 @@ void main() { expect(verificationResult.arguments['_userAgent']['name'], 'test-name'); expect(verificationResult.arguments['_userAgent']['version'], 'test-version'); - expect( - verificationResult.arguments['subjectToken'], 'existing-token'); + expect(verificationResult.arguments['subjectToken'], 'existing-token'); expect(verificationResult.arguments['subjectTokenType'], 'http://acme.com/legacy-token'); - expect(verificationResult.arguments['audience'], 'https://example.com/api'); - expect(verificationResult.arguments['scopes'], ['openid', 'profile', 'email']); + expect( + verificationResult.arguments['audience'], 'https://example.com/api'); + expect(verificationResult.arguments['scopes'], + ['openid', 'profile', 'email']); }); test( @@ -991,15 +1052,14 @@ void main() { when(mocked.methodCallHandler(any)).thenAnswer((final _) async => null); Future actual() async { - final result = - await MethodChannelAuth0FlutterAuth().customTokenExchange( - ApiRequest( - account: const Account('test-domain', 'test-clientId'), - userAgent: - UserAgent(name: 'test-name', version: 'test-version'), - options: const AuthCustomTokenExchangeOptions( - subjectToken: 'existing-token', - subjectTokenType: 'http://acme.com/legacy-token'))); + final result = await MethodChannelAuth0FlutterAuth() + .customTokenExchange(ApiRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: + UserAgent(name: 'test-name', version: 'test-version'), + options: const AuthCustomTokenExchangeOptions( + subjectToken: 'existing-token', + subjectTokenType: 'http://acme.com/legacy-token'))); return result; } @@ -1014,15 +1074,14 @@ void main() { .thenThrow(PlatformException(code: '123')); Future actual() async { - final result = - await MethodChannelAuth0FlutterAuth().customTokenExchange( - ApiRequest( - account: const Account('test-domain', 'test-clientId'), - userAgent: - UserAgent(name: 'test-name', version: 'test-version'), - options: const AuthCustomTokenExchangeOptions( - subjectToken: 'existing-token', - subjectTokenType: 'http://acme.com/legacy-token'))); + final result = await MethodChannelAuth0FlutterAuth() + .customTokenExchange(ApiRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: + UserAgent(name: 'test-name', version: 'test-version'), + options: const AuthCustomTokenExchangeOptions( + subjectToken: 'existing-token', + subjectTokenType: 'http://acme.com/legacy-token'))); return result; } @@ -1241,9 +1300,9 @@ void main() { expect(verificationResult.arguments['_userAgent']['name'], 'test-name'); expect(verificationResult.arguments['_userAgent']['version'], 'test-version'); - expect(verificationResult.arguments['refreshToken'], 'test-refresh-token'); expect( - verificationResult.arguments['parameters']['param1'], 'value1'); + verificationResult.arguments['refreshToken'], 'test-refresh-token'); + expect(verificationResult.arguments['parameters']['param1'], 'value1'); expect( verificationResult.arguments['headers']['X-Custom'], 'custom-value'); }); @@ -1292,12 +1351,11 @@ void main() { when(mocked.methodCallHandler(any)).thenAnswer((final _) async => null); Future actual() => MethodChannelAuth0FlutterAuth() - .ssoExchange(ApiRequest( - account: const Account('test-domain', 'test-clientId'), - userAgent: - UserAgent(name: 'test-name', version: 'test-version'), - options: AuthSSOExchangeOptions( - refreshToken: 'test-refresh-token'))); + .ssoExchange(ApiRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: + AuthSSOExchangeOptions(refreshToken: 'test-refresh-token'))); await expectLater(actual, throwsA(isA())); }); @@ -1309,12 +1367,285 @@ void main() { .thenThrow(PlatformException(code: '123')); Future actual() => MethodChannelAuth0FlutterAuth() - .ssoExchange(ApiRequest( - account: const Account('test-domain', 'test-clientId'), - userAgent: - UserAgent(name: 'test-name', version: 'test-version'), - options: AuthSSOExchangeOptions( - refreshToken: 'test-refresh-token'))); + .ssoExchange(ApiRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: + AuthSSOExchangeOptions(refreshToken: 'test-refresh-token'))); + + await expectLater(actual, throwsA(isA())); + }); + }); + + group('passkeyLoginChallenge', () { + test('calls the correct MethodChannel method', () async { + when(mocked.methodCallHandler(any)).thenAnswer( + (final _) async => MethodCallHandler.passkeyLoginChallengeResult); + + await MethodChannelAuth0FlutterAuth().passkeyLoginChallenge( + ApiRequest( + account: const Account('', ''), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: AuthPasskeyLoginChallengeOptions( + connection: 'test-connection', organization: 'test-org')), + ); + + expect( + verify(mocked.methodCallHandler(captureAny)).captured.single.method, + 'auth#passkeyLoginChallenge'); + }); + + test('correctly maps all properties', () async { + when(mocked.methodCallHandler(any)).thenAnswer( + (final _) async => MethodCallHandler.passkeyLoginChallengeResult); + + await MethodChannelAuth0FlutterAuth().passkeyLoginChallenge( + ApiRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: AuthPasskeyLoginChallengeOptions( + connection: 'test-connection', organization: 'test-org')), + ); + + final verificationResult = + verify(mocked.methodCallHandler(captureAny)).captured.single; + expect(verificationResult.arguments['_account']['domain'], 'test-domain'); + expect(verificationResult.arguments['connection'], 'test-connection'); + expect(verificationResult.arguments['organization'], 'test-org'); + }); + + test('correctly returns the challenge from the Method Channel', () async { + when(mocked.methodCallHandler(any)).thenAnswer( + (final _) async => MethodCallHandler.passkeyLoginChallengeResult); + + final result = + await MethodChannelAuth0FlutterAuth().passkeyLoginChallenge( + ApiRequest( + account: const Account('', ''), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: AuthPasskeyLoginChallengeOptions()), + ); + + expect(result.authSession, 'test-auth-session'); + expect(result.authParamsPublicKey['rpId'], 'test-rp-id'); + }); + + test('throws an ApiException when the method channel throws', () async { + when(mocked.methodCallHandler(any)) + .thenThrow(PlatformException(code: '123')); + + Future actual() => MethodChannelAuth0FlutterAuth() + .passkeyLoginChallenge(ApiRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: AuthPasskeyLoginChallengeOptions())); + + await expectLater(actual, throwsA(isA())); + }); + }); + + group('passkeyCredentialExchange', () { + test('calls the correct MethodChannel method for login', () async { + when(mocked.methodCallHandler(any)) + .thenAnswer((final _) async => MethodCallHandler.loginResult); + + await MethodChannelAuth0FlutterAuth().passkeyCredentialExchange( + ApiRequest( + account: const Account('', ''), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: AuthPasskeyExchangeOptions( + challenge: _testChallenge, credential: _testLoginCredential)), + ); + + expect( + verify(mocked.methodCallHandler(captureAny)).captured.single.method, + 'auth#passkeyCredentialExchange'); + }); + + test('correctly maps all properties for login', () async { + when(mocked.methodCallHandler(any)) + .thenAnswer((final _) async => MethodCallHandler.loginResult); + + await MethodChannelAuth0FlutterAuth().passkeyCredentialExchange( + ApiRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: AuthPasskeyExchangeOptions( + challenge: _testChallenge, + credential: _testLoginCredential, + connection: 'test-connection', + audience: 'test-audience', + scopes: {'a', 'b'}, + organization: 'test-org', + parameters: {'test': 'test-123'})), + ); + + final verificationResult = + verify(mocked.methodCallHandler(captureAny)).captured.single; + expect(verificationResult.arguments['_account']['domain'], 'test-domain'); + expect(verificationResult.arguments['challenge']['authSession'], + 'test-auth-session'); + expect(verificationResult.arguments['credential']['id'], + 'test-credential-id'); + expect( + verificationResult.arguments['credential']['response']['signature'], + 'test-signature'); + expect(verificationResult.arguments['connection'], 'test-connection'); + expect(verificationResult.arguments['audience'], 'test-audience'); + expect(verificationResult.arguments['scopes'], ['a', 'b']); + expect(verificationResult.arguments['organization'], 'test-org'); + expect(verificationResult.arguments['parameters']['test'], 'test-123'); + }); + + test('correctly maps all properties for signup', () async { + when(mocked.methodCallHandler(any)) + .thenAnswer((final _) async => MethodCallHandler.loginResult); + + await MethodChannelAuth0FlutterAuth().passkeyCredentialExchange( + ApiRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: AuthPasskeyExchangeOptions( + challenge: _testSignupChallenge, + credential: _testSignupCredential, + connection: 'test-connection', + audience: 'test-audience', + scopes: {'a', 'b'}, + organization: 'test-org', + parameters: {'test': 'test-123'})), + ); + + final verificationResult = + verify(mocked.methodCallHandler(captureAny)).captured.single; + expect(verificationResult.arguments['_account']['domain'], 'test-domain'); + expect(verificationResult.arguments['challenge']['authSession'], + 'test-auth-session'); + expect(verificationResult.arguments['credential']['id'], + 'test-credential-id'); + expect( + verificationResult.arguments['credential']['response'] + ['attestationObject'], + 'test-attestation'); + expect(verificationResult.arguments['connection'], 'test-connection'); + expect(verificationResult.arguments['audience'], 'test-audience'); + expect(verificationResult.arguments['scopes'], ['a', 'b']); + expect(verificationResult.arguments['organization'], 'test-org'); + expect(verificationResult.arguments['parameters']['test'], 'test-123'); + }); + + test('correctly returns credentials from Method Channel', () async { + when(mocked.methodCallHandler(any)) + .thenAnswer((final _) async => MethodCallHandler.loginResult); + + final result = + await MethodChannelAuth0FlutterAuth().passkeyCredentialExchange( + ApiRequest( + account: const Account('', ''), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: AuthPasskeyExchangeOptions( + challenge: _testChallenge, credential: _testLoginCredential)), + ); + + expect(result.accessToken, 'accessToken'); + expect(result.idToken, 'idToken'); + }); + + test('throws an ApiException when the method channel throws', () async { + when(mocked.methodCallHandler(any)) + .thenThrow(PlatformException(code: '123')); + + Future actual() => MethodChannelAuth0FlutterAuth() + .passkeyCredentialExchange(ApiRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: AuthPasskeyExchangeOptions( + challenge: _testChallenge, + credential: _testLoginCredential))); + + await expectLater(actual, throwsA(isA())); + }); + }); + + group('passkeySignupChallenge', () { + test('calls the correct MethodChannel method', () async { + when(mocked.methodCallHandler(any)).thenAnswer( + (final _) async => MethodCallHandler.passkeySignupChallengeResult); + + await MethodChannelAuth0FlutterAuth().passkeySignupChallenge( + ApiRequest( + account: const Account('', ''), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: AuthPasskeySignupChallengeOptions( + email: 'test-email', connection: 'test-connection')), + ); + + expect( + verify(mocked.methodCallHandler(captureAny)).captured.single.method, + 'auth#passkeySignupChallenge'); + }); + + test('correctly maps all properties', () async { + when(mocked.methodCallHandler(any)).thenAnswer( + (final _) async => MethodCallHandler.passkeySignupChallengeResult); + + await MethodChannelAuth0FlutterAuth().passkeySignupChallenge( + ApiRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: AuthPasskeySignupChallengeOptions( + email: 'test-email', + phoneNumber: 'test-phone', + username: 'test-username', + name: 'test-name', + givenName: 'test-given-name', + familyName: 'test-family-name', + nickname: 'test-nickname', + picture: 'https://www.okta.com', + connection: 'test-connection', + organization: 'test-org', + userMetadata: {'plan': 'gold'})), + ); + + final verificationResult = + verify(mocked.methodCallHandler(captureAny)).captured.single; + expect(verificationResult.arguments['email'], 'test-email'); + expect(verificationResult.arguments['phoneNumber'], 'test-phone'); + expect(verificationResult.arguments['username'], 'test-username'); + expect(verificationResult.arguments['name'], 'test-name'); + expect(verificationResult.arguments['givenName'], 'test-given-name'); + expect(verificationResult.arguments['familyName'], 'test-family-name'); + expect(verificationResult.arguments['nickname'], 'test-nickname'); + expect(verificationResult.arguments['picture'], 'https://www.okta.com'); + expect(verificationResult.arguments['connection'], 'test-connection'); + expect(verificationResult.arguments['organization'], 'test-org'); + expect(verificationResult.arguments['userMetadata'], {'plan': 'gold'}); + }); + + test('correctly returns the challenge from the Method Channel', () async { + when(mocked.methodCallHandler(any)).thenAnswer( + (final _) async => MethodCallHandler.passkeySignupChallengeResult); + + final result = + await MethodChannelAuth0FlutterAuth().passkeySignupChallenge( + ApiRequest( + account: const Account('', ''), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: AuthPasskeySignupChallengeOptions()), + ); + + expect(result.authSession, 'test-auth-session'); + expect(result.authParamsPublicKey['userName'], 'test-user-name'); + }); + + test('throws an ApiException when the method channel throws', () async { + when(mocked.methodCallHandler(any)) + .thenThrow(PlatformException(code: '123')); + + Future actual() => MethodChannelAuth0FlutterAuth() + .passkeySignupChallenge(ApiRequest( + account: const Account('test-domain', 'test-clientId'), + userAgent: UserAgent(name: 'test-name', version: 'test-version'), + options: AuthPasskeySignupChallengeOptions())); await expectLater(actual, throwsA(isA())); }); diff --git a/auth0_flutter_platform_interface/test/method_channel_auth0_flutter_my_account_test.dart b/auth0_flutter_platform_interface/test/method_channel_auth0_flutter_my_account_test.dart index 6c808babb..460f1d4b4 100644 --- a/auth0_flutter_platform_interface/test/method_channel_auth0_flutter_my_account_test.dart +++ b/auth0_flutter_platform_interface/test/method_channel_auth0_flutter_my_account_test.dart @@ -72,6 +72,98 @@ void main() { expect(map['authSession'], 'session'); expect(map.containsKey('otp'), isFalse); }); + + test('enroll passkey challenge options omit nulls', () { + final map = + MyAccountEnrollPasskeyChallengeOptions(accessToken: 'token').toMap(); + expect(map['accessToken'], 'token'); + expect(map.containsKey('userIdentityId'), isFalse); + expect(map.containsKey('connection'), isFalse); + }); + + test('enroll passkey challenge options serialize optional values', () { + final map = MyAccountEnrollPasskeyChallengeOptions( + accessToken: 'token', + userIdentityId: 'uid', + connection: 'db', + ).toMap(); + expect(map['userIdentityId'], 'uid'); + expect(map['connection'], 'db'); + }); + + test('enroll passkey options serialize challenge and credential', () { + final map = MyAccountEnrollPasskeyOptions( + accessToken: 'token', + challenge: const PasskeyEnrollmentChallenge( + authenticationMethodId: 'method-id', + authSession: 'session', + authParamsPublicKey: {'challenge': 'abc', 'rpId': 'example.com'}, + ), + credential: const PasskeyCredential( + id: 'cred-id', + rawId: 'raw-id', + type: 'public-key', + response: PasskeyAuthenticatorResponse( + clientDataJSON: 'client-data', + attestationObject: 'attestation', + ), + ), + ).toMap(); + expect(map['accessToken'], 'token'); + final challenge = map['challenge'] as Map; + expect(challenge['authenticationMethodId'], 'method-id'); + expect(challenge['authSession'], 'session'); + final credential = map['credential'] as Map; + expect(credential['id'], 'cred-id'); + expect((credential['response'] as Map)['attestationObject'], + 'attestation'); + }); + }); + + group('PasskeyEnrollmentChallenge', () { + test('fromMap / toMap round-trip', () { + const map = { + 'authenticationMethodId': 'method-id', + 'authSession': 'session', + 'authParamsPublicKey': {'challenge': 'abc', 'rpId': 'example.com'}, + }; + final challenge = PasskeyEnrollmentChallenge.fromMap(map); + expect(challenge.authenticationMethodId, 'method-id'); + expect(challenge.authSession, 'session'); + expect(challenge.authParamsPublicKey['rpId'], 'example.com'); + expect(challenge.toMap(), map); + }); + }); + + group('MyAccountPasskeyAuthenticationMethod', () { + test('fromMap parses passkey-specific fields', () { + final method = MyAccountPasskeyAuthenticationMethod.fromMap(const { + 'id': 'passkey|1', + 'type': 'passkey', + 'identity_user_id': 'uid', + 'user_agent': 'agent', + 'key_id': 'key-id', + 'public_key': 'pub', + 'user_handle': 'handle', + 'credential_device_type': 'multi_device', + 'credential_backed_up': true, + 'aaguid': 'aaguid', + 'relying_party_id': 'example.com', + 'transports': ['internal'], + 'created_at': '2024-01-01T00:00:00.000Z', + 'usage': ['mfa'], + }); + expect(method.id, 'passkey|1'); + expect(method.type, 'passkey'); + expect(method.userIdentityId, 'uid'); + expect(method.credentialDeviceType, 'multi_device'); + expect(method.credentialBackedUp, true); + expect(method.aaguid, 'aaguid'); + expect(method.relyingPartyId, 'example.com'); + expect(method.transports, ['internal']); + expect(method.usage, ['mfa']); + expect(method.createdAt, DateTime.parse('2024-01-01T00:00:00.000Z')); + }); }); group('ApiRequest useDPoP', () { @@ -151,6 +243,74 @@ void main() { expect(method.id, 'method-id'); }); + test('enrollPasskeyChallenge invokes the correct method and parses result', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (final call) async { + log.add(call); + return { + 'authenticationMethodId': 'method-id', + 'authSession': 'session', + 'authParamsPublicKey': {'challenge': 'abc', 'rpId': 'example.com'}, + }; + }); + + final challenge = await platform.enrollPasskeyChallenge( + ApiRequest( + account: account, + userAgent: userAgent, + options: MyAccountEnrollPasskeyChallengeOptions( + accessToken: 'token', connection: 'db'), + ), + ); + + expect(log.single.method, 'myAccount#enrollPasskeyChallenge'); + expect((log.single.arguments as Map)['connection'], 'db'); + expect(challenge.authenticationMethodId, 'method-id'); + expect(challenge.authParamsPublicKey['rpId'], 'example.com'); + }); + + test('enrollPasskey invokes the correct method and parses result', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (final call) async { + log.add(call); + return { + 'id': 'passkey|1', + 'type': 'passkey', + 'relying_party_id': 'example.com', + }; + }); + + final method = await platform.enrollPasskey( + ApiRequest( + account: account, + userAgent: userAgent, + options: MyAccountEnrollPasskeyOptions( + accessToken: 'token', + challenge: const PasskeyEnrollmentChallenge( + authenticationMethodId: 'method-id', + authSession: 'session', + authParamsPublicKey: {'challenge': 'abc'}, + ), + credential: const PasskeyCredential( + id: 'cred-id', + rawId: 'raw-id', + type: 'public-key', + response: PasskeyAuthenticatorResponse( + clientDataJSON: 'client-data', + attestationObject: 'attestation', + ), + ), + ), + ), + ); + + expect(log.single.method, 'myAccount#enrollPasskey'); + expect(method.id, 'passkey|1'); + expect(method.relyingPartyId, 'example.com'); + }); + test('getAuthenticationMethods forwards the type filter', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (final call) async { diff --git a/auth0_flutter_platform_interface/test/passkey_credential_test.dart b/auth0_flutter_platform_interface/test/passkey_credential_test.dart new file mode 100644 index 000000000..b12661bbc --- /dev/null +++ b/auth0_flutter_platform_interface/test/passkey_credential_test.dart @@ -0,0 +1,149 @@ +import 'package:auth0_flutter_platform_interface/auth0_flutter_platform_interface.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('PasskeyAuthenticatorResponse', () { + test('toMap includes assertion (login) properties', () { + const response = PasskeyAuthenticatorResponse( + clientDataJSON: 'client-data', + authenticatorData: 'authenticator-data', + signature: 'signature', + userHandle: 'user-handle', + ); + + final map = response.toMap(); + + expect(map['clientDataJSON'], 'client-data'); + expect(map['authenticatorData'], 'authenticator-data'); + expect(map['signature'], 'signature'); + expect(map['userHandle'], 'user-handle'); + expect(map.containsKey('attestationObject'), isFalse); + }); + + test('toMap includes attestation (signup) properties', () { + const response = PasskeyAuthenticatorResponse( + clientDataJSON: 'client-data', + attestationObject: 'attestation-object', + ); + + final map = response.toMap(); + + expect(map['clientDataJSON'], 'client-data'); + expect(map['attestationObject'], 'attestation-object'); + expect(map.containsKey('authenticatorData'), isFalse); + expect(map.containsKey('signature'), isFalse); + }); + + test('fromMap parses assertion properties', () { + final response = PasskeyAuthenticatorResponse.fromMap(const { + 'clientDataJSON': 'client-data', + 'authenticatorData': 'authenticator-data', + 'signature': 'signature', + 'userHandle': 'user-handle', + }); + + expect(response.clientDataJSON, 'client-data'); + expect(response.authenticatorData, 'authenticator-data'); + expect(response.signature, 'signature'); + expect(response.userHandle, 'user-handle'); + expect(response.attestationObject, isNull); + }); + + test('fromMap parses attestation properties', () { + final response = PasskeyAuthenticatorResponse.fromMap(const { + 'clientDataJSON': 'client-data', + 'attestationObject': 'attestation-object', + }); + + expect(response.clientDataJSON, 'client-data'); + expect(response.attestationObject, 'attestation-object'); + expect(response.authenticatorData, isNull); + expect(response.signature, isNull); + }); + + test('fromMap tolerates a missing userHandle', () { + final response = PasskeyAuthenticatorResponse.fromMap(const { + 'clientDataJSON': 'client-data', + 'authenticatorData': 'authenticator-data', + 'signature': 'signature', + }); + + expect(response.userHandle, isNull); + }); + }); + + group('PasskeyCredential', () { + const credential = PasskeyCredential( + id: 'credential-id', + rawId: 'raw-id', + type: 'public-key', + authenticatorAttachment: 'platform', + response: PasskeyAuthenticatorResponse( + clientDataJSON: 'client-data', + authenticatorData: 'authenticator-data', + signature: 'signature', + userHandle: 'user-handle', + ), + ); + + test('toMap includes the nested response', () { + final map = credential.toMap(); + + expect(map['id'], 'credential-id'); + expect(map['rawId'], 'raw-id'); + expect(map['type'], 'public-key'); + expect(map['authenticatorAttachment'], 'platform'); + final response = map['response'] as Map; + expect(response['clientDataJSON'], 'client-data'); + expect(response['signature'], 'signature'); + }); + + test('fromMap parses the nested response', () { + final parsed = PasskeyCredential.fromMap(const { + 'id': 'credential-id', + 'rawId': 'raw-id', + 'type': 'public-key', + 'authenticatorAttachment': 'platform', + 'response': { + 'clientDataJSON': 'client-data', + 'authenticatorData': 'authenticator-data', + 'signature': 'signature', + 'userHandle': 'user-handle', + }, + }); + + expect(parsed.id, 'credential-id'); + expect(parsed.rawId, 'raw-id'); + expect(parsed.type, 'public-key'); + expect(parsed.authenticatorAttachment, 'platform'); + expect(parsed.response.clientDataJSON, 'client-data'); + expect(parsed.response.authenticatorData, 'authenticator-data'); + expect(parsed.response.signature, 'signature'); + expect(parsed.response.userHandle, 'user-handle'); + }); + + test('survives a toMap/fromMap round trip', () { + final parsed = PasskeyCredential.fromMap(credential.toMap()); + + expect(parsed.id, credential.id); + expect(parsed.rawId, credential.rawId); + expect(parsed.response.clientDataJSON, + credential.response.clientDataJSON); + expect(parsed.response.signature, credential.response.signature); + }); + + test('defaults type to public-key when missing', () { + final parsed = PasskeyCredential.fromMap(const { + 'id': 'credential-id', + 'rawId': 'raw-id', + 'response': { + 'clientDataJSON': 'client-data', + 'attestationObject': 'attestation-object', + }, + }); + + expect(parsed.type, 'public-key'); + expect(parsed.response.attestationObject, 'attestation-object'); + }); + }); +}