diff --git a/A0Auth0.podspec b/A0Auth0.podspec index 1f1ba3b8b..5c1100439 100644 --- a/A0Auth0.podspec +++ b/A0Auth0.podspec @@ -16,7 +16,7 @@ Pod::Spec.new do |s| s.source_files = 'ios/**/*.{h,m,mm,swift}' s.requires_arc = true - s.dependency 'Auth0', '2.21.2' + s.dependency 'Auth0', '2.23.0' s.dependency 'SimpleKeychain', '1.3.0' install_modules_dependencies(s) diff --git a/EXAMPLES.md b/EXAMPLES.md index daea57871..eb25c7333 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -7,6 +7,7 @@ - [Using custom scheme for web authentication redirection](#using-custom-scheme-for-web-authentication-redirection) - [Login using MFA with One Time Password code](#login-using-mfa-with-one-time-password-code) - [Login with Passwordless](#login-with-passwordless) + - [Login with Passwordless OTP (Database Connections)](#login-with-passwordless-otp-database-connections) - [Create user in database connection](#create-user-in-database-connection) - [Using HTTPS callback URLs](#using-https-callback-urls) - [Using Custom Headers](#using-custom-headers) @@ -310,6 +311,55 @@ const subscription = Linking.addEventListener('url', ({ url }) => { > [!NOTE] > For native and Expo apps, the **code flow is recommended** because the SDK handles the full exchange for you. Use the magic link flow only if your use case specifically requires magic links, and be aware that you are responsible for handling the deep link and securely storing the returned tokens. +### Login with Passwordless OTP (Database Connections) + +> [!NOTE] +> This feature is currently in [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). Reach out to Auth0 support to have it enabled for your tenant. +> +> Native only (iOS, Android). Calling `auth0.passwordless.*` on the web platform rejects with an `UnsupportedOperation` error. + +This flow lets a user authenticate with a one-time code sent to their email or phone against a standard **database** connection (`auth0` strategy) that has `email_otp` or `phone_otp` enabled. It is distinct from [Login with Passwordless](#login-with-passwordless), which targets dedicated `email` / `sms` connections. + +It is a two-step, challenge-response flow: request a challenge (which delivers the OTP and returns an opaque `auth_session`), then exchange the challenge and the user-entered code for credentials. + +#### Email challenge + +```js +// 1. Request a challenge — Auth0 emails the OTP and returns the challenge. +const challenge = await auth0.passwordless.challengeWithEmail({ + email: 'info@auth0.com', + connection: 'Username-Password-Authentication', // required; must have email_otp enabled + // allowSignup defaults to false +}); + +// 2. Exchange the challenge and the code the user received for credentials. +const credentials = await auth0.passwordless.loginWithOTP({ + challenge, + otp: '123456', +}); +``` + +#### Phone challenge + +```js +// 1. Request a challenge — delivered by SMS ('text') or voice call ('voice'). +const challenge = await auth0.passwordless.challengeWithPhoneNumber({ + phoneNumber: '+15555550123', + connection: 'Username-Password-Authentication', // required; must have phone_otp enabled + deliveryMethod: 'text', // defaults to 'text' +}); + +// 2. Exchange the challenge and the code for credentials. +const credentials = await auth0.passwordless.loginWithOTP({ + challenge, + otp: '123456', +}); +``` + +Both challenge methods accept an optional `allowSignup` parameter (defaults to `false`). When set to `true`, a new user is created for the given email or phone number if one does not already exist on the connection; when `false`, the challenge is rejected for unknown identifiers. + +The `challenge` object returned from a challenge call is opaque — pass it as-is to `loginWithOTP`. You can optionally provide `audience` and `scope` to `loginWithOTP`. + ### Create user in database connection ```js diff --git a/android/build.gradle b/android/build.gradle index 0c162f0ef..787abd6fb 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -96,7 +96,7 @@ dependencies { implementation "com.facebook.react:react-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "androidx.browser:browser:1.2.0" - implementation 'com.auth0.android:auth0:3.19.0' + implementation 'com.auth0.android:auth0:3.20.0' } if (isNewArchitectureEnabled()) { diff --git a/android/src/main/java/com/auth0/react/A0Auth0Module.kt b/android/src/main/java/com/auth0/react/A0Auth0Module.kt index e92eaa283..dbd67c9a0 100644 --- a/android/src/main/java/com/auth0/react/A0Auth0Module.kt +++ b/android/src/main/java/com/auth0/react/A0Auth0Module.kt @@ -109,6 +109,7 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0 private var auth0: Auth0? = null private var myAccount: MyAccount? = null + private var passwordless: Passwordless? = null private lateinit var secureCredentialsManager: SecureCredentialsManager private var webAuthPromise: Promise? = null @@ -279,6 +280,7 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0 this.useDPoP = useDPoP ?: true auth0 = Auth0.getInstance(clientId, domain) myAccount = MyAccount(auth0!!, this.useDPoP, reactContext) + passwordless = Passwordless(auth0!!, this.useDPoP, reactContext) val authAPI = AuthenticationAPIClient(auth0!!) if (this.useDPoP) { @@ -774,6 +776,38 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0 } + @ReactMethod + override fun passwordlessChallengeWithEmail( + email: String, + connection: String, + allowSignup: Boolean, + promise: Promise + ) { + passwordless!!.challengeWithEmail(email, connection, allowSignup, promise) + } + + @ReactMethod + override fun passwordlessChallengeWithPhoneNumber( + phoneNumber: String, + connection: String, + deliveryMethod: String, + allowSignup: Boolean, + promise: Promise + ) { + passwordless!!.challengeWithPhoneNumber(phoneNumber, connection, deliveryMethod, allowSignup, promise) + } + + @ReactMethod + override fun passwordlessLoginWithOTP( + authSession: String, + otp: String, + audience: String?, + scope: String?, + promise: Promise + ) { + passwordless!!.loginWithOTP(authSession, otp, audience, scope, promise) + } + @ReactMethod override fun passkeyEnrollmentChallenge( accessToken: String, diff --git a/android/src/main/java/com/auth0/react/Passwordless.kt b/android/src/main/java/com/auth0/react/Passwordless.kt new file mode 100644 index 000000000..dd501320d --- /dev/null +++ b/android/src/main/java/com/auth0/react/Passwordless.kt @@ -0,0 +1,97 @@ +package com.auth0.react + +import com.auth0.android.Auth0 +import com.auth0.android.authentication.AuthenticationAPIClient +import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.authentication.passwordless.DeliveryMethod +import com.auth0.android.result.Credentials +import com.auth0.android.result.PasswordlessChallenge +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.WritableNativeMap + +class Passwordless( + auth0: Auth0, + useDPoP: Boolean, + reactContext: ReactApplicationContext +) { + + private val client: AuthenticationAPIClient = AuthenticationAPIClient(auth0).also { + if (useDPoP) { + it.useDPoP(reactContext) + } + } + + fun challengeWithEmail( + email: String, + connection: String, + allowSignup: Boolean = false, + promise: Promise + ) { + client.passwordlessClient() + .challengeWithEmail(email, connection, allowSignup) + .start(object : com.auth0.android.callback.Callback { + override fun onSuccess(result: PasswordlessChallenge) { + val map = WritableNativeMap().apply { + putString("authSession", result.authSession) + } + promise.resolve(map) + } + + override fun onFailure(error: AuthenticationException) { + promise.reject("PASSWORDLESS_CHALLENGE_FAILED", error.getDescription(), error) + } + }) + } + + fun challengeWithPhoneNumber( + phoneNumber: String, + connection: String, + deliveryMethod: String, + allowSignup: Boolean = false, + promise: Promise + ) { + val method = if (deliveryMethod == "voice") DeliveryMethod.VOICE else DeliveryMethod.TEXT + + client.passwordlessClient() + .challengeWithPhoneNumber(phoneNumber, connection, method, allowSignup) + .start(object : com.auth0.android.callback.Callback { + override fun onSuccess(result: PasswordlessChallenge) { + val map = WritableNativeMap().apply { + putString("authSession", result.authSession) + } + promise.resolve(map) + } + + override fun onFailure(error: AuthenticationException) { + promise.reject("PASSWORDLESS_CHALLENGE_FAILED", error.getDescription(), error) + } + }) + } + + fun loginWithOTP( + authSession: String, + otp: String, + audience: String?, + scope: String?, + promise: Promise + ) { + val finalScope = if (scope.isNullOrBlank()) "openid profile email" else scope + val finalAudience = audience?.trim()?.ifEmpty { null } + + val request = client.passwordlessClient() + .loginWithOTP(PasswordlessChallenge(authSession), otp) + finalAudience?.let { request.setAudience(it) } + request.setScope(finalScope) + + request.start(object : com.auth0.android.callback.Callback { + override fun onSuccess(result: Credentials) { + promise.resolve(CredentialsParser.toMap(result)) + } + + override fun onFailure(error: AuthenticationException) { + promise.reject("PASSWORDLESS_LOGIN_FAILED", error.getDescription(), error) + } + }) + } +} diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 0a2df0961..cc13fb453 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,6 +1,6 @@ PODS: - - A0Auth0 (5.7.0): - - Auth0 (= 2.21.2) + - A0Auth0 (5.8.0): + - Auth0 (= 2.23.0) - hermes-engine - RCTRequired - RCTTypeSafety @@ -23,7 +23,7 @@ PODS: - ReactNativeDependencies - SimpleKeychain (= 1.3.0) - Yoga - - Auth0 (2.21.2): + - Auth0 (2.23.0): - JWTDecode (= 3.3.0) - SimpleKeychain (= 1.3.0) - FBLazyVector (0.86.0) @@ -2242,8 +2242,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - A0Auth0: 3f1b87e65cd0fd4620431b93c8ed2e6eebcdae00 - Auth0: e15bc9c1e39a53efc8853d16460b9be409d5346f + A0Auth0: e14195294c028c4ebce21e45a17ee11749e7a943 + Auth0: dd46c309079f995e91dfd7af38e8fefd1a43bf4e FBLazyVector: b3e7ad108f0d882e30445c5527d774e3fd432f3d hermes-engine: 4ba8c4848d46c6a441e09d1c62f883ba69bc5b76 JWTDecode: 1ca6f765844457d0dd8690436860fecee788f631 diff --git a/example/src/screens/class-based/ClassLogin.tsx b/example/src/screens/class-based/ClassLogin.tsx index 8a9bd8e8f..ba9ed0460 100644 --- a/example/src/screens/class-based/ClassLogin.tsx +++ b/example/src/screens/class-based/ClassLogin.tsx @@ -1,12 +1,22 @@ // example/src/screens/class-based/ClassLogin.tsx import React, { useState } from 'react'; -import { SafeAreaView, View, StyleSheet } from 'react-native'; +import { + SafeAreaView, + ScrollView, + View, + Text, + StyleSheet, + Alert, + Platform, +} from 'react-native'; import { useNavigation } from '@react-navigation/native'; import type { StackNavigationProp } from '@react-navigation/stack'; +import type { PasswordlessChallenge } from 'react-native-auth0'; import auth0 from '../../api/auth0'; // Import our singleton instance import Button from '../../components/Button'; import Header from '../../components/Header'; +import LabeledInput from '../../components/LabeledInput'; import Result from '../../components/Result'; import type { ClassDemoStackParamList } from '../../navigation/ClassDemoNavigator'; import config from '../../auth0-configuration'; @@ -21,6 +31,14 @@ const ClassLoginScreen = () => { const [loading, setLoading] = useState(false); const navigation = useNavigation(); + const [otpMethod, setOtpMethod] = useState<'email' | 'phone'>('email'); + const [otpEmail, setOtpEmail] = useState(''); + const [otpPhone, setOtpPhone] = useState(''); + const [otp, setOtp] = useState(''); + const [challenge, setChallenge] = useState( + null + ); + const onLogin = async () => { setLoading(true); setError(null); @@ -39,13 +57,139 @@ const ClassLoginScreen = () => { } }; + const onSendOtpChallenge = async () => { + setLoading(true); + setError(null); + try { + const result = + otpMethod === 'email' + ? await auth0.passwordless.challengeWithEmail({ + email: otpEmail, + connection: 'Username-Password-Authentication', + allowSignup: true, + }) + : await auth0.passwordless.challengeWithPhoneNumber({ + phoneNumber: otpPhone, + connection: 'Username-Password-Authentication', + deliveryMethod: 'text', + allowSignup: true, + }); + setChallenge(result); + Alert.alert( + 'Success', + otpMethod === 'email' + ? 'Check your email for the one-time code.' + : 'Check your phone for the one-time code.' + ); + } catch (e) { + setError(e as Error); + } finally { + setLoading(false); + } + }; + + const onLoginWithOtp = async () => { + if (!challenge) { + return; + } + setLoading(true); + setError(null); + try { + const credentials = await auth0.passwordless.loginWithOTP({ + challenge, + otp, + audience: `https://${config.domain}/api/v2/`, + }); + await auth0.credentialsManager.saveCredentials(credentials); + navigation.replace('ClassProfile', { credentials }); + } catch (e) { + setError(e as Error); + } finally { + setLoading(false); + } + }; + return (
- + -