Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ node_modules/
npm-debug.log
yarn-debug.log
yarn-error.log
# This repo uses Yarn; npm's lock file must not be committed
package-lock.json

# BUCK
buck-out/
Expand Down
2 changes: 1 addition & 1 deletion A0Auth0.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Pod::Spec.new do |s|
s.resource_bundles = { 'A0Auth0_privacy' => 'ios/PrivacyInfo.xcprivacy' }
s.requires_arc = true

s.dependency 'Auth0', '3.0.1'
s.dependency 'Auth0', '3.2.0'
s.dependency 'JWTDecode', '4.0.0'
s.dependency 'SimpleKeychain', '1.3.0'

Expand Down
2 changes: 2 additions & 0 deletions EXAMPLES-WEB.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,8 @@ Passkeys are supported on web via `@auth0/auth0-spa-js`. The flow is the same th

Because `navigator.credentials.create()`/`.get()` require a user gesture, call `passkeySignupChallenge` / `passkeyLoginChallenge` from within a click handler (not, for example, from a `useEffect`).

**Identifier verification.** When the database connection requires the user's email or phone number to be verified, `passkeySignupChallenge` returns `verificationRequired`. Collect a one-time code for each identifier **before** calling `navigator.credentials.create()`, then pass them as `verification` to `getTokenByPasskey`. Pass a non-empty code for every identifier in `verificationRequired`: an empty or whitespace-only code is rejected before anything is sent (`PASSKEY_INVALID_PARAMETER`), but on web a missing code is not checked locally, so it is sent and Auth0 rejects the exchange (`PASSKEY_EXCHANGE_FAILED`). `verificationRequired` is accepted but ignored on web (auth0-spa-js works from `verification` alone); passing it keeps one code path for native and web. A wrong code rejects with `PASSKEY_VERIFICATION_RETRYABLE`: read `error.getVerificationRetryPayload()` for `authSession` (the session to retry with) and `verificationRequired` (the identifiers whose code was wrong), then call `getTokenByPasskey` again with the same `PublicKeyCredential` and new codes for those identifiers only. `deliveryMethod` (`'text'` or `'voice'`) chooses how a phone code is delivered. See [Signup with Passkey and Identifier Verification](./EXAMPLES.md#signup-with-passkey-identifier-verification) for the full flow.

```tsx
import { useAuth0, PasskeyError } from 'react-native-auth0';

Expand Down
161 changes: 137 additions & 24 deletions EXAMPLES.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions MIGRATION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,12 @@ The library itself now builds with `targetSdk` 36. That does not change your app

### 4. iOS: Auth0.swift 3.0 ✅

v6 adopts **Auth0.swift 3.0.1**, which is built with the **Swift 6** compiler.
v6 adopts **Auth0.swift 3.2.0**, which is built with the **Swift 6** compiler.

**✅ Action Required:**

1. Your iOS deployment target must meet React Native 0.82's floor (`min_ios_version_supported`, currently **15.1**). The podspec inherits this value, so no explicit `platform :ios` bump is needed beyond what RN 0.82 already requires.
2. Run `pod install --repo-update` in your `ios` directory to pick up Auth0.swift 3.0.1.
2. Run `pod install --repo-update` in your `ios` directory to pick up Auth0.swift 3.2.0.
3. Use **Xcode 16** or later.

> See §9 for the public-API changes that come with these native majors.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Then, you need to run the following command to install the ios app pods with Coc

#### iOS framework linkage (`use_frameworks!`)

This SDK's native dependencies — Auth0 3.0.1, JWTDecode 4.0.0, and SimpleKeychain 1.3.0 — are Swift pods. They install with the default React Native static-library linkage (no `use_frameworks!`), so **most apps need no extra Podfile changes**.
This SDK's native dependencies — Auth0 3.2.0, JWTDecode 4.0.0, and SimpleKeychain 1.3.0 — are Swift pods. They install with the default React Native static-library linkage (no `use_frameworks!`), so **most apps need no extra Podfile changes**.

If your project requires `use_frameworks!` (for example, because another dependency ships as a framework), both linkage modes are supported — this was verified by building the example app under each:

Expand Down Expand Up @@ -1033,9 +1033,9 @@ This library provides a unified API across Native (iOS/Android) and Web platform
| `auth.passwordless...()` | ✅ | ❌ | **Not supported on Web.** Passwordless flows on the web should be configured via Universal Login and initiated with `webAuth.authorize()`. |
| `auth.loginWith...()` (OTP/SMS etc) | ✅ | ❌ | **Not supported on Web.** These direct grant flows are not secure for public clients like browsers. |
| **Passkeys** | | | --- |
| `passkeySignupChallenge()` | ✅ | ✅ | Gets a WebAuthn registration challenge from Auth0. Requires iOS 16.6+, Android API 28+, or a browser with WebAuthn support. |
| `passkeySignupChallenge()` | ✅ | ✅ | Gets a WebAuthn registration challenge from Auth0. Requires iOS 16.6+, Android API 28+, or a browser with WebAuthn support. Supports identifier verification. |
| `passkeyLoginChallenge()` | ✅ | ✅ | Gets a WebAuthn assertion challenge from Auth0. Requires iOS 16.6+, Android API 28+, or a browser with WebAuthn support. |
| `getTokenByPasskey()` | ✅ | ✅ | Exchanges a passkey credential response for Auth0 tokens. On Web, uses `@auth0/auth0-spa-js`. |
| `getTokenByPasskey()` | ✅ | ✅ | Exchanges a passkey credential response for Auth0 tokens. On Web, uses `@auth0/auth0-spa-js`. Accepts identifier verification codes for signup. |
| **Token & User Management** | | | --- |
| `auth.refreshToken()` | ✅ | ❌ | **Not supported on Web.** Token refresh is handled automatically by `getCredentials()` via `getTokenSilently()` on the web. |
| `auth.userInfo()` | ✅ | ✅ | Fetches the user's profile from the `/userinfo` endpoint using an access token. |
Expand Down
56 changes: 53 additions & 3 deletions android/src/main/java/com/auth0/react/A0Auth0Module.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import com.auth0.android.Auth0
import com.auth0.android.Auth0Exception
import com.auth0.android.authentication.AuthenticationAPIClient
import com.auth0.android.authentication.AuthenticationException
import com.auth0.android.authentication.passwordless.DeliveryMethod
import com.auth0.android.authentication.request.ActorToken
import com.auth0.android.authentication.storage.CredentialsManagerException
import com.auth0.android.authentication.storage.LocalAuthenticationOptions
Expand All @@ -29,6 +30,7 @@ import com.auth0.android.result.APICredentials
import com.auth0.android.result.Credentials
import com.auth0.android.result.PasskeyChallenge
import com.auth0.android.result.PasskeyRegistrationChallenge
import com.auth0.android.result.PasskeyVerificationMethod
import com.facebook.react.bridge.ActivityEventListener
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
Expand Down Expand Up @@ -56,6 +58,7 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0

private const val CREDENTIAL_MANAGER_ERROR_CODE = "CREDENTIAL_MANAGER_ERROR"
private const val INVALID_NETWORKING_OPTIONS_CODE = "INVALID_NETWORKING_OPTIONS"
private const val PASSKEY_VERIFICATION_RETRYABLE_CODE = "PASSKEY_VERIFICATION_RETRYABLE"
private const val BIOMETRICS_AUTHENTICATION_ERROR_CODE = "BIOMETRICS_CONFIGURATION_ERROR"

// DPoP-specific error codes
Expand Down Expand Up @@ -101,6 +104,29 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
return builder.build()
}

// An unknown value falls back to the server default.
internal fun deliveryMethodFrom(value: String?): DeliveryMethod? =
DeliveryMethod.values().firstOrNull { it.value == value?.trim() }

// Only the identifiers the SDK recognizes are forwarded, matching the iOS bridge.
internal fun verificationCodes(codes: ReadableMap?): Map<PasskeyVerificationMethod, String> =
codes?.let {
PasskeyVerificationMethod.values().mapNotNull { method ->
val code = if (it.hasKey(method.value)) it.getString(method.value) else null
code?.let { method to it }
}.toMap()
}.orEmpty()

// Only the identifiers the SDK recognizes are kept, in order, matching the iOS bridge.
internal fun verificationMethods(names: ReadableArray?): List<PasskeyVerificationMethod> =
names?.toArrayList().orEmpty().mapNotNull { name ->
PasskeyVerificationMethod.values().firstOrNull { it.value == name }
}

// Gson leaves a null for an identifier the SDK doesn't know, despite the non-null element type.
internal fun verificationMethodNames(methods: List<PasskeyVerificationMethod?>?): List<String> =
methods.orEmpty().filterNotNull().map { it.value }

// Auth0.getInstance() returns a shared singleton per clientId/domain: a sibling client
// (or this same client on re-init) must not inherit another initialization's networking
// config, so this always resolves to a fresh DefaultClient() when options are absent
Expand Down Expand Up @@ -782,6 +808,7 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
userMetadata: ReadableMap?,
realm: String?,
organization: String?,
deliveryMethod: String?,
promise: Promise
) {
val authClient = AuthenticationAPIClient(auth0!!)
Expand All @@ -800,6 +827,7 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
val finalUserMetadata = userMetadata?.toHashMap()?.mapValues { it.value?.toString() ?: "" }?.ifEmpty { null }
val finalRealm = realm?.trim()?.ifEmpty { null }
val finalOrg = organization?.trim()?.ifEmpty { null }
val finalDeliveryMethod = deliveryMethodFrom(deliveryMethod)
val userData = UserData(
email = finalEmail,
phoneNumber = finalPhone,
Expand All @@ -812,13 +840,18 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
userMetadata = finalUserMetadata
)

authClient.signupWithPasskey(userData, finalRealm, finalOrg)
authClient.signupWithPasskey(userData, finalRealm, finalOrg, finalDeliveryMethod)
.start(object : com.auth0.android.callback.Callback<PasskeyRegistrationChallenge, AuthenticationException> {
override fun onSuccess(challenge: PasskeyRegistrationChallenge) {
val result = WritableNativeMap().apply {
putString("authSession", challenge.authSession)
val authParamsJson = Gson().toJson(challenge.authParamsPublicKey)
putMap("authParamsPublicKey", JsonUtils.jsonToWritableMap(authParamsJson))
// Left out when no verification is needed, so existing signups see an unchanged response.
val verificationRequired = verificationMethodNames(challenge.verificationRequired)
if (verificationRequired.isNotEmpty()) {
putArray("verificationRequired", Arguments.fromList(verificationRequired))
}
}
promise.resolve(result)
}
Expand Down Expand Up @@ -868,6 +901,8 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
audience: String?,
scope: String?,
organization: String?,
verification: ReadableMap?,
verificationRequired: ReadableArray?,
promise: Promise
) {
val authClient = AuthenticationAPIClient(auth0!!)
Expand All @@ -879,6 +914,9 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
val finalRealm = realm?.trim()?.ifEmpty { null }
val finalAudience = audience?.trim()?.ifEmpty { null }
val finalOrg = organization?.trim()?.ifEmpty { null }
val finalVerification = verificationCodes(verification)
// Auth0.Android checks the codes against these identifiers before sending the request.
val finalVerificationRequired = verificationMethods(verificationRequired)

val publicKeyCredentials = try {
Gson().fromJson(authResponse, PublicKeyCredentials::class.java)
Expand All @@ -887,7 +925,7 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
return
}

val request = authClient.signinWithPasskey(authSession, publicKeyCredentials, finalRealm, finalOrg)
val request = authClient.signinWithPasskey(authSession, publicKeyCredentials, finalRealm, finalOrg, finalVerification, finalVerificationRequired)
finalAudience?.let { request.setAudience(it) }
request.setScope(finalScope)
request.validateClaims()
Expand All @@ -898,7 +936,19 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
}

override fun onFailure(error: AuthenticationException) {
promise.reject("PASSKEY_EXCHANGE_FAILED", error.getDescription(), error)
if (error.isPasskeyVerificationRetryable) {
// A code was wrong and the session is still usable: hand JS what a retry needs.
val userInfo = WritableNativeMap().apply {
error.passkeyAuthSession?.let { putString("authSession", it) }
val verificationRequired = verificationMethodNames(error.passkeyVerificationRequired)
if (verificationRequired.isNotEmpty()) {
putArray("verificationRequired", Arguments.fromList(verificationRequired))
}
}
promise.reject(PASSKEY_VERIFICATION_RETRYABLE_CODE, error.getDescription(), error, userInfo)
} else {
promise.reject("PASSKEY_EXCHANGE_FAILED", error.getDescription(), error)
}
}
})
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package com.auth0.react

import com.auth0.android.authentication.passwordless.DeliveryMethod
import com.auth0.android.result.PasskeyVerificationMethod
import com.facebook.react.bridge.JavaOnlyArray
import com.facebook.react.bridge.JavaOnlyMap
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test

// Covers the pure helpers behind the passkey identifier-verification bridge code. The retryable
// rejection's userInfo is built from WritableNativeMap/Arguments, which need React Native's
// native libraries and cannot run in a plain JVM unit test.
class A0Auth0ModulePasskeyVerificationTest {

@Test
fun `verificationMethodNames returns the wire value of each method in order`() {
val names = A0Auth0Module.verificationMethodNames(
listOf(PasskeyVerificationMethod.PHONE, PasskeyVerificationMethod.EMAIL)
)

assertEquals(listOf("phone", "email"), names)
}

@Test
fun `verificationMethodNames drops the nulls Gson leaves for unknown identifiers`() {
val names = A0Auth0Module.verificationMethodNames(
listOf(PasskeyVerificationMethod.EMAIL, null)
)

assertEquals(listOf("email"), names)
}

@Test
fun `verificationMethodNames is empty for a null or empty list`() {
assertEquals(emptyList<String>(), A0Auth0Module.verificationMethodNames(null))
assertEquals(emptyList<String>(), A0Auth0Module.verificationMethodNames(emptyList()))
}

@Test
fun `verificationCodes maps the known identifiers to their codes`() {
val codes = A0Auth0Module.verificationCodes(JavaOnlyMap.of("email", "123456", "phone", "654321"))

assertEquals(
mapOf(
PasskeyVerificationMethod.EMAIL to "123456",
PasskeyVerificationMethod.PHONE to "654321"
),
codes
)
}

@Test
fun `verificationCodes drops unknown identifiers`() {
val codes = A0Auth0Module.verificationCodes(JavaOnlyMap.of("email", "123456", "username", "111111"))

assertEquals(mapOf(PasskeyVerificationMethod.EMAIL to "123456"), codes)
}

@Test
fun `verificationCodes is empty for a missing map, an empty map or only unknown identifiers`() {
assertEquals(emptyMap<PasskeyVerificationMethod, String>(), A0Auth0Module.verificationCodes(null))
assertEquals(emptyMap<PasskeyVerificationMethod, String>(), A0Auth0Module.verificationCodes(JavaOnlyMap()))
assertEquals(
emptyMap<PasskeyVerificationMethod, String>(),
A0Auth0Module.verificationCodes(JavaOnlyMap.of("username", "111111"))
)
}

@Test
fun `verificationMethods maps the known identifiers in order`() {
val methods = A0Auth0Module.verificationMethods(JavaOnlyArray.of("phone", "email"))

assertEquals(listOf(PasskeyVerificationMethod.PHONE, PasskeyVerificationMethod.EMAIL), methods)
}

@Test
fun `verificationMethods drops unknown identifiers and values that are not strings`() {
val methods = A0Auth0Module.verificationMethods(JavaOnlyArray.of("email", "username", 7, null))

assertEquals(listOf(PasskeyVerificationMethod.EMAIL), methods)
}

@Test
fun `verificationMethods is empty for a missing array, an empty array or only unknown identifiers`() {
assertEquals(emptyList<PasskeyVerificationMethod>(), A0Auth0Module.verificationMethods(null))
assertEquals(emptyList<PasskeyVerificationMethod>(), A0Auth0Module.verificationMethods(JavaOnlyArray()))
assertEquals(
emptyList<PasskeyVerificationMethod>(),
A0Auth0Module.verificationMethods(JavaOnlyArray.of("username"))
)
}

@Test
fun `deliveryMethodFrom matches the wire value of each DeliveryMethod`() {
DeliveryMethod.values().forEach { method ->
assertEquals(method, A0Auth0Module.deliveryMethodFrom(method.value))
}
}

@Test
fun `deliveryMethodFrom trims whitespace around the value`() {
val method = DeliveryMethod.values().first()

assertEquals(method, A0Auth0Module.deliveryMethodFrom(" ${method.value} "))
}

@Test
fun `deliveryMethodFrom falls back to null for a missing or unknown value`() {
assertNull(A0Auth0Module.deliveryMethodFrom(null))
assertNull(A0Auth0Module.deliveryMethodFrom(""))
assertNull(A0Auth0Module.deliveryMethodFrom("carrier_pigeon"))
}
}
Loading
Loading