diff --git a/DISCOVERY_MY_ACCOUNT_API.md b/DISCOVERY_MY_ACCOUNT_API.md new file mode 100644 index 00000000..a9178a93 --- /dev/null +++ b/DISCOVERY_MY_ACCOUNT_API.md @@ -0,0 +1,240 @@ +# My Account API - Flutter SDK Discovery Document + +**Date:** 2026-05-12 +**Status:** Discovery / Pre-Implementation +**Author:** Utkrisht Sahu + +--- + +## 1. Overview + +The My Account API (`/me/v1/`) enables end-users to self-manage their authentication methods (MFA factors) without requiring Management API tokens or admin intervention. This document outlines the discovery findings and proposed approach for implementing My Account API support in the auth0-flutter SDK. + +--- + +## 2. What is the My Account API? + +The My Account API allows authenticated users to: + +- **List** their enrolled authentication methods +- **Get** details of a specific authentication method +- **Update** an authentication method (e.g., change phone preferences) +- **Delete** an authentication method +- **Enroll** new authentication methods (phone, email, TOTP, push notifications, passkeys, recovery codes) +- **Verify** enrollments via OTP +- **View** enrollable factors available for the tenant + +### Authentication + +- Uses OAuth 2.0 access tokens with audience: `https://{domain}/me/` +- Requires granular scopes: + - `read:me:authentication_methods` + - `create:me:authentication_methods` + - `update:me:authentication_methods` + - `delete:me:authentication_methods` + - `read:me:factors` + +### Security + +- Enforces step-up authentication (2FA within 15 minutes by default) +- Rate limited at 25 requests/second per tenant +- Machine-to-machine access is prohibited + +--- + +## 3. Existing Native SDK Implementations + +### 3.1 Android (`Auth0.Android`) + +**Class:** `MyAccountAPIClient` + +| Method | Description | +|--------|-------------| +| `getAuthenticationMethods()` | List all enrolled methods | +| `getAuthenticationMethodById(id)` | Get specific method | +| `updateAuthenticationMethodById(id, ...)` | Update method name/preferences | +| `deleteAuthenticationMethod(id)` | Delete a method | +| `enrollPhone(phoneNumber, type)` | Enroll phone (SMS/Voice) | +| `enrollEmail(emailAddress)` | Enroll email | +| `enrollTotp()` | Enroll authenticator app | +| `enrollPushNotification()` | Enroll push notifications | +| `enrollRecoveryCode()` | Generate recovery codes | +| `passkeyEnrollmentChallenge(...)` | Get passkey challenge | +| `enroll(passkey)` | Complete passkey enrollment | +| `verifyOtp(id, authSession, otp)` | Verify OTP for enrollment | +| `verify(id, authSession)` | Confirm non-OTP enrollment | +| `getFactors()` | List enrollable factors | + +- Returns `Request` objects +- Access token provided at client construction +- `MyAccountException` includes: `code`, `statusCode`, `description`, `validationErrors` + +### 3.2 Swift (`Auth0.swift`) + +**Classes:** `Auth0MyAccount` + `Auth0MyAccountAuthenticationMethods` + +| Method | Description | +|--------|-------------| +| `getAuthenticationMethods()` | List all enrolled methods | +| `getAuthenticationMethod(by: id)` | Get specific method | +| `deleteAuthenticationMethod(by: id)` | Delete a method | +| `enrollPhone(phoneNumber:, preferredAuthenticationMethod:)` | Enroll phone | +| `confirmPhoneEnrollment(id:, authSession:, otpCode:)` | Confirm phone | +| `enrollEmail(emailAddress:)` | Enroll email | +| `confirmEmailEnrollment(id:, authSession:, otpCode:)` | Confirm email | +| `enrollTOTP()` | Enroll authenticator app | +| `confirmTOTPEnrollment(id:, authSession:, otpCode:)` | Confirm TOTP | +| `enrollPushNotification()` | Enroll push | +| `confirmPushNotificationEnrollment(id:, authSession:)` | Confirm push | +| `enrollRecoveryCode()` | Generate recovery codes | +| `confirmRecoveryCodeEnrollment(id:, authSession:)` | Confirm recovery | +| `passkeyEnrollmentChallenge(...)` | Get passkey challenge | +| `enroll(passkey:, challenge:)` | Complete passkey enrollment | +| `getFactors()` | List enrollable factors | + +- Protocol-based architecture +- Factory: `Auth0.myAccount(token: accessToken, domain: "...")` +- URL pattern: `{domain}/me/v1/authentication-methods` +- Two-step enrollment: challenge then confirm +- `MyAccountError` includes: `code`, `statusCode`, `title`, `detail`, `validationErrors` + +### 3.3 SPA (`auth0-spa-js`) + +**Does NOT implement My Account Authentication Methods.** + +The SPA SDK only implements Connected Accounts (Token Vault), which is a separate feature for linking external provider tokens — not related to My Account MFA management. + +--- + +## 4. Platform Support Matrix + +| Feature | Android | iOS/macOS (Swift) | Web (SPA) | Flutter (Proposed) | +|---------|:-------:|:-----------------:|:---------:|:------------------:| +| List authentication methods | ✅ | ✅ | ❌ | ✅ Mobile only | +| Get authentication method | ✅ | ✅ | ❌ | ✅ Mobile only | +| Update authentication method | ✅ | ❌ | ❌ | ✅ Android only | +| Delete authentication method | ✅ | ✅ | ❌ | ✅ Mobile only | +| Enroll phone (SMS/Voice) | ✅ | ✅ | ❌ | ✅ Mobile only | +| Enroll email | ✅ | ✅ | ❌ | ✅ Mobile only | +| Enroll TOTP | ✅ | ✅ | ❌ | ✅ Mobile only | +| Enroll push notifications | ✅ | ✅ | ❌ | ✅ Mobile only | +| Enroll recovery codes | ✅ | ✅ | ❌ | ✅ Mobile only | +| Passkey enrollment | ✅ | ✅ (iOS 16.6+) | ❌ | ✅ Mobile only | +| Verify OTP | ✅ | ✅ | ❌ | ✅ Mobile only | +| Get factors | ✅ | ✅ | ❌ | ✅ Mobile only | +| **Web support** | — | — | ❌ | **❌ Not supported** | + +--- + +## 5. Flutter Implementation Strategy + +### 5.1 Architecture + +Flutter will act as a **bridge layer** — delegating to the native SDKs (Auth0.Android and Auth0.swift) via method channels. No direct HTTP calls to the My Account API. + +``` +┌─────────────────────────────────────────────────────┐ +│ User Application │ +├─────────────────────────────────────────────────────┤ +│ auth0_flutter (Public Dart API) │ +│ MyAccountApi class │ +├─────────────────────────────────────────────────────┤ +│ auth0_flutter_platform_interface │ +│ Auth0FlutterMyAccountPlatform (abstract) │ +│ MethodChannelAuth0FlutterMyAccount │ +├─────────────────────────────────────────────────────┤ +│ Method Channel Bridge │ +├──────────────────────┬──────────────────────────────┤ +│ Android (Kotlin) │ iOS/macOS (Swift) │ +│ MyAccountAPIClient │ Auth0MyAccount │ +│ (Auth0.Android SDK) │ (Auth0.swift SDK) │ +└──────────────────────┴──────────────────────────────┘ +``` + +### 5.2 Public API Design + +```dart +// Access My Account API +final myAccount = auth0.myAccount(accessToken: token); + +// List methods +final methods = await myAccount.getAuthenticationMethods(); + +// Get specific method +final method = await myAccount.getAuthenticationMethod(id: 'auth_method_id'); + +// Delete method +await myAccount.deleteAuthenticationMethod(id: 'auth_method_id'); + +// Enroll phone +final challenge = await myAccount.enrollPhone( + phoneNumber: '+1234567890', + type: PhoneType.sms, +); + +// Verify enrollment +await myAccount.verifyOtp( + id: challenge.id, + authSession: challenge.authSession, + otp: '123456', +); + +// Get available factors +final factors = await myAccount.getFactors(); +``` + +### 5.3 Key Models + +- `AuthenticationMethod` — represents an enrolled MFA method +- `Factor` — represents an enrollable factor type +- `EnrollmentChallenge` — returned from enrollment methods (contains `id`, `authSession`) +- `MyAccountException` — error with `code`, `statusCode`, `description`, `validationErrors` +- `PhoneType` — enum: `sms`, `voice` + +### 5.4 Web Platform + +**Not supported.** The SPA SDK does not implement My Account Authentication Methods. Calling My Account methods on web will throw `UnsupportedError`. + +--- + +## 6. Phased Rollout Plan + +### Phase 1: Core Read/Delete Operations +- `getAuthenticationMethods()` +- `getAuthenticationMethod(id:)` +- `deleteAuthenticationMethod(id:)` +- `getFactors()` +- `MyAccountException` error handling +- Platform interface + method channel setup + +### Phase 2: Enrollments + Verification +- `enrollPhone(phoneNumber:, type:)` +- `enrollEmail(emailAddress:)` +- `enrollTotp()` +- `enrollPushNotification()` +- `enrollRecoveryCode()` +- `verifyOtp(id:, authSession:, otp:)` +- `verify(id:, authSession:)` + +### Phase 3: Passkey Enrollment +- `passkeyEnrollmentChallenge(userIdentityId:, connection:)` +- `enrollPasskey(passkey:, challenge:)` +- Platform version guards (iOS 16.6+, Android API level checks) + +--- + +## 7. Open Questions + +1. Do we need `updateAuthenticationMethod()` on iOS? (Swift SDK doesn't expose it, Android does) +2. Should we throw `UnsupportedError` on web, or silently degrade? +3. What minimum iOS/Android versions should we target for passkey support? +4. Should the access token be passed per-call or at `MyAccountApi` construction time? + +--- + +## 8. References + +- [My Account API Documentation](https://auth0.com/docs/manage-users/my-account-api) +- [Auth0.Android SDK](https://github.com/auth0/Auth0.Android) +- [Auth0.swift SDK](https://github.com/auth0/Auth0.swift) +- [auth0-spa-js SDK](https://github.com/auth0/auth0-spa-js) diff --git a/PR_835_REVIEW_RESPONSE.md b/PR_835_REVIEW_RESPONSE.md new file mode 100644 index 00000000..8eb0326d --- /dev/null +++ b/PR_835_REVIEW_RESPONSE.md @@ -0,0 +1,120 @@ +# PR #835 Review Response: My Account API + +## Summary + +Consolidated review of both internal and external feedback. Categorized into: **Fixed**, **Acknowledged (Won't Fix)**, and **Deferred**. + +--- + +## Fixed + +### 1. Android: `totp_uri` mapped to wrong value + +**File:** `auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/MyAccountExtensions.kt` + +**Issue:** Both `totp_secret` and `totp_uri` were set to `challenge.manualInputCode`. The `totp_uri` should be `challenge.barcodeUri` (the `otpauth://` URI for QR code scanning). + +**Fix applied:** +```kotlin +// Before +put("totp_uri", challenge.manualInputCode) + +// After +put("totp_uri", challenge.barcodeUri) +``` + +This now matches the iOS implementation which correctly uses `authenticatorQRCodeURI` for `totp_uri`. + +--- + +## Acknowledged - By Design (Won't Fix) + +### 2. iOS `verifyOtp` uses `confirmPhoneEnrollment` + +**Context:** The Auth0.swift SDK has separate confirm methods per factor type (`confirmPhoneEnrollment`, `confirmEmailEnrollment`, `confirmTOTPEnrollment`). However, the OTP confirmation logic is identical across these methods at the API level — they all POST an OTP code with an auth session to the same endpoint pattern. The `confirmPhoneEnrollment` method works generically for all OTP-based confirmations. + +**Decision:** No change. The Android SDK exposes a single generic `verifyOtp(id, otp, authSession)` method which confirms this is the intended API design. If Auth0.swift separates the confirm calls in a future version in a breaking way, we'll update then. + +### 3. `verifyOtp` returns `void` (discards `AuthenticationMethod` on Android) + +**Context:** The Dart API is designed as `Future` for `verifyOtp`. The caller already has the enrollment ID and can call `getAuthenticationMethod(id)` to get the confirmed method if needed. This matches the pattern where enrollment + verify is a two-step flow and the caller tracks the ID themselves. + +**Decision:** No change. This is intentional API design — keeping `verifyOtp` as a void confirmation action rather than a data-fetching operation. + +### 4. iOS `AuthenticationMethod.asDictionary()` — `email`, `totp_secret`, `totp_uri` are nil + +**Context:** The Auth0.swift `AuthenticationMethod` struct is a flat type — it does not have subclasses for email/TOTP like Android's polymorphic `EmailAuthenticationMethod`/`TotpAuthenticationMethod`. The Swift SDK's base `AuthenticationMethod` only exposes `phoneNumber` and `name` as type-specific fields. Email and TOTP details are only available during enrollment (via `EnrollmentChallenge` types, which ARE correctly mapped). + +**Decision:** No change. This is a limitation of the Auth0.swift SDK's type model. The relevant fields are returned during enrollment when they matter. For `getAuthenticationMethods()`, the `type` field identifies the method type and `id` can be used for management operations. + +### 5. `Factor` model uses `name` + `enabled` instead of `type` + `usage` + +**Context:** The native SDK `Factor` has `type` (String) and `usage` (array of strings like `["primary", "secondary"]`). The Flutter model simplifies this to `name` (mapped from `type`) and `enabled` (hardcoded `true`). The rationale: if a factor is returned by the API, it IS enabled — disabled factors are not returned. The `usage` array is not actionable for self-service MFA management. + +**Decision:** No change for v1. The simplified model serves the primary use case (showing available factors for enrollment). If users need `usage` data, we can add it in a minor version bump without breaking changes. + +### 6. iOS `isNetworkError` hardcoded to `false` + +**Context:** The Auth0.swift `MyAccountError` type does not expose a network error flag directly. The error is an API response error (with status code and error code). Network-level failures (no connectivity, timeout) would surface as a different error type entirely and would not reach this code path. + +**Decision:** No change. The `isNetworkError: false` is correct for API errors. True network errors are handled at the URLSession/connectivity layer. + +### 7. iOS `userAgent` not applied to client + +**Context:** The Auth0.swift `myAccount()` factory creates a client with built-in telemetry. The `using()` pattern for custom user agents is not available on `MyAccount` in the current Auth0.swift version. The telemetry parameter is accepted in the handler for future use when the Swift SDK supports it. + +**Decision:** No change now. Will be wired up when Auth0.swift adds telemetry customization to MyAccount. + +--- + +## Deferred (Future Enhancement) + +### 8. Missing `updateAuthenticationMethod` + +The native SDKs support updating an authentication method's name or preferred delivery mechanism. This is a valid feature gap but out of scope for the initial release. + +**Tracked for:** v2 enhancement + +### 9. Missing passkey enrollment + +Both native SDKs support passkey enrollment via WebAuthn/FIDO2. This requires significant platform-specific work (platform credential APIs, attestation handling) and is deferred. + +**Tracked for:** Future release (depends on Flutter passkey ecosystem maturity) + +### 10. Per-factor confirmation methods (push/recovery code without OTP) + +Push notification and recovery code enrollments don't require OTP verification. The current `verifyOtp` method won't work for these. However, the enrollment responses for push and recovery code return all needed data immediately — push enrollment auto-confirms, and recovery code enrollment returns the code directly. + +**Decision:** Deferred. If auto-confirmation doesn't cover these cases in practice, we'll add `confirmPushEnrollment` and `confirmRecoveryCodeEnrollment` methods. + +### 11. Missing `confirmed` field on Dart `AuthenticationMethod` + +The Android side serializes `confirmed` but the Dart model doesn't include it. This is useful for showing enrollment status in UI. + +**Tracked for:** Next minor version (additive, non-breaking) + +### 12. Dart-level unit tests + +No Dart tests for `MethodChannelAuth0FlutterMyAccount`, `MyAccountApi`, or model parsing. Android and iOS have native tests. Dart tests would improve confidence in serialization layer. + +**Tracked for:** Follow-up PR + +### 13. Android empty string fallback for missing `accessToken` + +```kotlin +val accessToken = request.data["accessToken"] as? String ?: "" +``` + +Should fail fast instead of making API calls with empty bearer token. Low risk since the Dart layer always provides the token, but defensive programming suggests erroring early. + +**Tracked for:** Follow-up hardening PR + +--- + +## Changes Made in This Response + +| # | File | Change | +|---|------|--------| +| 1 | `MyAccountExtensions.kt:52` | Fixed `totp_uri` to use `challenge.barcodeUri` instead of `challenge.manualInputCode` | + +--- diff --git a/TESTING_MY_ACCOUNT_API.md b/TESTING_MY_ACCOUNT_API.md new file mode 100644 index 00000000..1d0789e2 --- /dev/null +++ b/TESTING_MY_ACCOUNT_API.md @@ -0,0 +1,294 @@ +# Testing the My Account API Implementation + +## Prerequisites + +### Auth0 Tenant Configuration + +1. **Enable MFA** in your Auth0 tenant: + - Dashboard → Security → Multi-factor Auth + - Enable the factors you want to test (SMS, Email, OTP, Push) + +2. **Create/Configure an Application**: + - Dashboard → Applications → Your App + - Note your `domain` and `clientId` + +3. **Create a Custom API** (if not exists): + - Dashboard → Applications → APIs + - Create API with identifier: `https://{your-domain}/me/` + - This is the My Account API audience + +4. **Grant Scopes to Your Application**: + - In the API settings → Machine to Machine Applications tab + - Or via the Application's API permissions + - Required scopes: + - `read:me:authentication_methods` + - `create:me:authentication_methods` + - `delete:me:authentication_methods` + - `read:me:enrollments` + +5. **Update `.env` file** in `auth0_flutter/example/`: + ``` + AUTH0_DOMAIN=your-tenant.auth0.com + AUTH0_CLIENT_ID=your-client-id + AUTH0_CUSTOM_SCHEME=demo # optional, for Android custom schemes + ``` + +--- + +## Running the Example App + +### Android +```bash +cd auth0_flutter/example +flutter run -d +``` + +### iOS +```bash +cd auth0_flutter/example +flutter run -d +``` + +### Web (My Account NOT supported) +The My Account card will not appear on web — this is expected behavior. + +--- + +## Test Cases + +### Test 1: Login with My Account Audience + +**Steps:** +1. Open the example app +2. Expand "My Account API (MFA)" card +3. Tap "Login for My Account" +4. Complete the Universal Login flow +5. Verify green checkmark appears + +**Expected:** +- Output shows: `Login successful!` with truncated access token +- The card expands to show all action buttons + +**Logs to look for:** +``` +[MyAccount] Logging in with audience: https://{domain}/me/ ... +[MyAccount] Login successful! +``` + +--- + +### Test 2: Get Authentication Methods (Empty) + +**Steps:** +1. After login, tap "Get Auth Methods" + +**Expected (fresh user with no MFA):** +- Output: `No authentication methods enrolled.` + +**Expected (user with existing MFA):** +- Output lists all enrolled methods with ID, type, phone/email, dates + +--- + +### Test 3: Get Available Factors + +**Steps:** +1. Tap "Get Factors" + +**Expected:** +- Lists factors like: `sms: enabled`, `email: enabled`, `otp: enabled`, `push: disabled` +- Matches what you've enabled in Dashboard → Security → MFA + +--- + +### Test 4: Enroll Phone (SMS) + +**Steps:** +1. Enter a valid phone number (e.g., `+14155551234`) +2. Tap "Enroll" next to the phone field +3. Wait for SMS with OTP code +4. Enter the OTP in the "OTP Code" field +5. Tap "Verify OTP" + +**Expected:** +- Step 2 output: Enrollment Challenge with ID and Auth Session +- Step 5 output: `OTP Verified Successfully! Enrollment complete.` + +**Error cases:** +- Invalid phone → `MyAccountException: [400] ...` +- Wrong OTP → `MyAccountException: [401] ...` +- SMS factor disabled → `MyAccountException: [403] ...` + +--- + +### Test 5: Enroll Email + +**Steps:** +1. Enter a valid email address +2. Tap "Enroll" next to the email field +3. Check inbox for OTP code +4. Enter OTP and tap "Verify OTP" + +**Expected:** Same flow as phone enrollment + +--- + +### Test 6: Enroll TOTP (Authenticator App) + +**Steps:** +1. Tap "Enroll TOTP" +2. Copy the `TOTP Secret` or `TOTP URI` from the output +3. Add to an authenticator app (Google Authenticator, Authy, etc.) +4. Enter the 6-digit code from the app +5. Tap "Verify OTP" + +**Expected:** +- Step 2 output includes: `totpSecret`, `totpUri`, `barcodeUri` +- Step 5: `OTP Verified Successfully!` + +--- + +### Test 7: Enroll Push Notifications + +**Steps:** +1. Tap "Enroll Push" +2. Follow platform-specific push enrollment (may need Guardian app) + +**Expected:** +- Returns enrollment challenge with barcode URI +- Note: Full push enrollment requires the Auth0 Guardian app + +--- + +### Test 8: Enroll Recovery Code + +**Steps:** +1. Tap "Enroll Recovery" +2. Note the recovery code in the output + +**Expected:** +- Output includes a `recoveryCode` that can be used as backup MFA + +--- + +### Test 9: Delete Authentication Method + +**Steps:** +1. First enroll at least one method (e.g., TOTP) +2. Tap "Get Auth Methods" to verify it exists +3. Tap "Delete Last Method" +4. Tap "Get Auth Methods" again to verify deletion + +**Expected:** +- Shows which method was deleted (ID and type) +- Subsequent list no longer includes that method + +--- + +### Test 10: Error Handling + +**Test expired/invalid token:** +1. Login, wait for token to expire (or manually invalidate) +2. Try any operation + +**Expected:** `MyAccountException: [401] unauthorized - ...` + +**Test missing scopes:** +1. Login without `create:me:authentication_methods` scope +2. Try to enroll + +**Expected:** `MyAccountException: [403] insufficient_scope - ...` + +**Test network error:** +1. Enable airplane mode +2. Try any operation + +**Expected:** `MyAccountException` with `isNetworkError: true` + +--- + +## Platform-Specific Testing + +### Android-Specific Checks + +1. **Verify method channel registration:** + ``` + adb logcat | grep "auth0.com/auth0_flutter/my_account" + ``` + +2. **Process death recovery:** + - Start enrollment flow + - Kill the app process + - Relaunch and verify state + +3. **Check native bridge:** + ``` + adb logcat | grep "MyAccountAPIClient" + ``` + +### iOS-Specific Checks + +1. **Verify handler registration:** + - Xcode → Debug Navigator → check `MyAccountHandler` is registered + +2. **Console logs:** + ``` + # In Xcode console or via Console.app + filter: "Auth0" or "MyAccount" + ``` + +3. **Test on both iPhone and iPad** + +--- + +## Debugging Tips + +### Enable verbose logging + +All My Account API calls are logged to the debug console with the prefix `[MyAccount]`. Monitor the console output: + +```bash +# Android +adb logcat | grep -i "myaccount\|auth0" + +# iOS (via Xcode console) +# Filter: MyAccount OR Auth0 +``` + +### Common Issues + +| Issue | Cause | Fix | +|-------|-------|-----| +| `Login for My Account` fails | Wrong audience | Ensure API exists with identifier `https://{domain}/me/` | +| `403 insufficient_scope` | Missing scopes | Add required scopes to app in API settings | +| `404` on operations | Feature not enabled | Enable My Account API in tenant settings | +| Enrollment returns error | Factor not enabled | Enable the factor in Security → MFA | +| OTP verification fails | Code expired/wrong | Codes typically expire in 5 min, verify correct code | +| Card doesn't appear | Running on web | Expected — My Account is mobile-only | + +### Verify Native SDK Versions + +The My Account API requires minimum native SDK versions: +- **Android:** Auth0.Android v3.12.0+ (check `auth0_flutter/android/build.gradle`) +- **iOS/macOS:** Auth0.swift v2.18.0+ (check `auth0_flutter/darwin/auth0_flutter.podspec`) + +--- + +## Web Platform + +The My Account API is **NOT supported on web**. The `MyAccountCard` widget automatically hides itself on web (`kIsWeb` check). The SPA SDK does not implement authentication method management. + +If you run the example app on web, the My Account section will not be visible — this is the expected behavior. + +--- + +## Automated Test Coverage + +Unit tests should cover: +1. Method channel serialization/deserialization +2. Exception mapping from PlatformException +3. Options `toMap()` serialization +4. Model `fromMap()` parsing +5. Platform interface method routing + +Integration tests require a real Auth0 tenant and are manual (as described above). diff --git a/auth0_flutter/README.md b/auth0_flutter/README.md index 54a4a64e..a76718b0 100644 --- a/auth0_flutter/README.md +++ b/auth0_flutter/README.md @@ -632,6 +632,66 @@ void dispose() { - [clearCredentials](https://pub.dev/documentation/auth0_flutter/latest/auth0_flutter/DefaultCredentialsManager/clearCredentials.html) - [ssoCredentials](https://pub.dev/documentation/auth0_flutter/latest/auth0_flutter/DefaultCredentialsManager/ssoCredentials.html) +#### My Account API + +The My Account API allows users to manage their own multi-factor authentication (MFA) methods. Available on **mobile (Android/iOS) only**. + +First, authenticate with the `https://{domain}/me/` audience: + +```dart +final credentials = await auth0.webAuthentication().login( + audience: 'https://YOUR_DOMAIN/me/', + scopes: { + 'openid', + 'read:me:authentication_methods', + 'create:me:authentication_methods', + 'delete:me:authentication_methods', + 'read:me:enrollments', + 'read:me:factors', + }, +); +``` + +Then create the My Account client and use it: + +```dart +final myAccount = auth0.myAccount(accessToken: credentials.accessToken); + +// List enrolled MFA methods +final methods = await myAccount.getAuthenticationMethods(); + +// List available factors +final factors = await myAccount.getFactors(); + +// Enroll a new phone factor +final challenge = await myAccount.enrollPhone( + phoneNumber: '+1234567890', + type: PhoneType.sms, +); + +// Verify enrollment with OTP +await myAccount.verifyOtp( + id: challenge.id, + authSession: challenge.authSession, + otp: '123456', +); + +// Delete a method +await myAccount.deleteAuthenticationMethod(id: 'method_id'); +``` + +Other enrollment methods: `enrollEmail`, `enrollTotp`, `enrollPush`, `enrollRecoveryCode`. + +Error handling: + +```dart +try { + await myAccount.getAuthenticationMethods(); +} on MyAccountException catch (e) { + print('${e.code}: ${e.message} (${e.statusCode})'); +} +``` + ### 🌐 Web - [loginWithRedirect](https://pub.dev/documentation/auth0_flutter/latest/auth0_flutter_web/Auth0Web/loginWithRedirect.html) diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterMyAccountMethodCallHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterMyAccountMethodCallHandler.kt new file mode 100644 index 00000000..0a9f615b --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/Auth0FlutterMyAccountMethodCallHandler.kt @@ -0,0 +1,30 @@ +package com.auth0.auth0_flutter + +import android.content.Context +import androidx.annotation.NonNull +import com.auth0.android.myaccount.MyAccountAPIClient +import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import com.auth0.auth0_flutter.request_handlers.my_account.MyAccountRequestHandler +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result + +class Auth0FlutterMyAccountMethodCallHandler( + private val myAccountRequestHandlers: List +) : MethodCallHandler { + lateinit var context: Context + + override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { + val request = MethodCallRequest.fromCall(call) + + val handler = myAccountRequestHandlers.find { it.method == call.method } + if (handler != null) { + val accessToken = request.data["accessToken"] as? String ?: "" + val client = MyAccountAPIClient(request.account, accessToken) + + handler.handle(client, request, result) + } else { + result.notImplemented() + } + } +} 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 bf8f81e8..5494adf6 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 @@ -8,6 +8,7 @@ import com.auth0.android.result.Credentials import com.auth0.auth0_flutter.request_handlers.MethodCallRequest import com.auth0.auth0_flutter.request_handlers.api.* import com.auth0.auth0_flutter.request_handlers.credentials_manager.* +import com.auth0.auth0_flutter.request_handlers.my_account.* import com.auth0.auth0_flutter.request_handlers.web_auth.LoginWebAuthRequestHandler import com.auth0.auth0_flutter.request_handlers.web_auth.LogoutWebAuthRequestHandler import io.flutter.embedding.engine.plugins.FlutterPlugin @@ -27,6 +28,7 @@ class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware { private lateinit var authMethodChannel : MethodChannel private lateinit var credentialsManagerMethodChannel : MethodChannel private lateinit var dpopMethodChannel : MethodChannel + private lateinit var myAccountMethodChannel : MethodChannel private lateinit var binding: FlutterPlugin.FlutterPluginBinding private lateinit var authCallHandler: Auth0FlutterAuthMethodCallHandler private var pendingRecoveredCredentials: Map? = null @@ -49,6 +51,18 @@ class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware { GetDPoPHeadersApiRequestHandler(), ClearDPoPKeyApiRequestHandler() )) + private val myAccountCallHandler = Auth0FlutterMyAccountMethodCallHandler(listOf( + GetAuthenticationMethodsRequestHandler(), + GetAuthenticationMethodRequestHandler(), + DeleteAuthenticationMethodRequestHandler(), + GetFactorsRequestHandler(), + EnrollPhoneRequestHandler(), + EnrollEmailRequestHandler(), + EnrollTotpRequestHandler(), + EnrollPushRequestHandler(), + EnrollRecoveryCodeRequestHandler(), + VerifyOtpRequestHandler() + )) private val processDeathCallback = object : Callback { override fun onSuccess(credentials: Credentials) { @@ -125,6 +139,10 @@ class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware { dpopMethodChannel = MethodChannel(messenger, "auth0.com/auth0_flutter/dpop") dpopMethodChannel.setMethodCallHandler(dpopCallHandler) + + myAccountMethodChannel = MethodChannel(messenger, "auth0.com/auth0_flutter/my_account") + myAccountMethodChannel.setMethodCallHandler(myAccountCallHandler) + myAccountCallHandler.context = context } override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: MethodChannel.Result) { @@ -150,6 +168,7 @@ class Auth0FlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware { authMethodChannel.setMethodCallHandler(null) credentialsManagerMethodChannel.setMethodCallHandler(null) dpopMethodChannel.setMethodCallHandler(null) + myAccountMethodChannel.setMethodCallHandler(null) } override fun onAttachedToActivity(binding: ActivityPluginBinding) { diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/MyAccountExceptionExtensions.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/MyAccountExceptionExtensions.kt new file mode 100644 index 00000000..ce404078 --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/MyAccountExceptionExtensions.kt @@ -0,0 +1,15 @@ +package com.auth0.auth0_flutter + +import com.auth0.android.myaccount.MyAccountException + +fun MyAccountException.toMyAccountMap(): Map { + val exception = this + return buildMap { + put("_statusCode", exception.statusCode) + put("_title", exception.getCode()) + put("_detail", exception.getDescription()) + put("_errorFlags", mapOf( + "isNetworkError" to exception.isNetworkError, + )) + } +} 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 new file mode 100644 index 00000000..309fd882 --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/MyAccountExtensions.kt @@ -0,0 +1,64 @@ +package com.auth0.auth0_flutter + +import com.auth0.android.result.AuthenticationMethod +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.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 + +fun AuthenticationMethod.toMyAccountMethodMap(): Map { + return buildMap { + put("id", id) + put("type", type) + put("created_at", createdAt) + when (val method = this@toMyAccountMethodMap) { + is PhoneAuthenticationMethod -> { + put("name", method.name) + put("phone_number", method.phoneNumber) + put("preferred_authentication_method", + method.preferredAuthenticationMethod) + } + is EmailAuthenticationMethod -> { + put("name", method.name) + put("email", method.email) + } + is TotpAuthenticationMethod -> { + put("name", method.name) + } + is PushNotificationAuthenticationMethod -> { + put("name", method.name) + } + else -> { + put("name", null) + } + } + if (this@toMyAccountMethodMap is MfaAuthenticationMethod) { + put("confirmed", confirmed) + put("usage", usage) + } + } +} + +fun EnrollmentChallenge.toMyAccountChallengeMap(): Map { + return buildMap { + put("id", id) + put("auth_session", authSession) + when (val challenge = this@toMyAccountChallengeMap) { + is TotpEnrollmentChallenge -> { + put("barcode_uri", challenge.barcodeUri) + put("totp_secret", challenge.manualInputCode) + put("totp_uri", challenge.barcodeUri) + } + is RecoveryCodeEnrollmentChallenge -> { + put("recovery_code", challenge.recoveryCode) + } + is MfaEnrollmentChallenge -> {} + else -> {} + } + } +} diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/DeleteAuthenticationMethodRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/DeleteAuthenticationMethodRequestHandler.kt new file mode 100644 index 00000000..094ab494 --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/DeleteAuthenticationMethodRequestHandler.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.auth0_flutter.request_handlers.MethodCallRequest +import com.auth0.auth0_flutter.toMyAccountMap +import com.auth0.auth0_flutter.utils.assertHasProperties +import io.flutter.plugin.common.MethodChannel + +private const val MY_ACCOUNT_DELETE_AUTH_METHOD_METHOD = + "myAccount#deleteAuthenticationMethod" + +class DeleteAuthenticationMethodRequestHandler : + MyAccountRequestHandler { + override val method: String = + MY_ACCOUNT_DELETE_AUTH_METHOD_METHOD + + override fun handle( + client: MyAccountAPIClient, + request: MethodCallRequest, + result: MethodChannel.Result + ) { + assertHasProperties(listOf("id"), request.data) + + val id = request.data["id"] as String + + client.deleteAuthenticationMethod(id) + .start(object : + Callback { + override fun onFailure( + exception: MyAccountException + ) { + result.error( + exception.getCode(), + exception.getDescription(), + exception.toMyAccountMap() + ) + } + + override fun onSuccess(res: Void?) { + result.success(null) + } + }) + } +} diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollEmailRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollEmailRequestHandler.kt new file mode 100644 index 00000000..1581d83c --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollEmailRequestHandler.kt @@ -0,0 +1,50 @@ +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.EnrollmentChallenge +import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import com.auth0.auth0_flutter.toMyAccountChallengeMap +import com.auth0.auth0_flutter.toMyAccountMap +import com.auth0.auth0_flutter.utils.assertHasProperties +import io.flutter.plugin.common.MethodChannel + +private const val MY_ACCOUNT_ENROLL_EMAIL_METHOD = + "myAccount#enrollEmail" + +class EnrollEmailRequestHandler : MyAccountRequestHandler { + override val method: String = MY_ACCOUNT_ENROLL_EMAIL_METHOD + + override fun handle( + client: MyAccountAPIClient, + request: MethodCallRequest, + result: MethodChannel.Result + ) { + assertHasProperties(listOf("email"), request.data) + + val email = request.data["email"] as String + + client.enrollEmail(email) + .start(object : + Callback { + override fun onFailure( + exception: MyAccountException + ) { + result.error( + exception.getCode(), + exception.getDescription(), + exception.toMyAccountMap() + ) + } + + override fun onSuccess( + res: EnrollmentChallenge + ) { + result.success( + res.toMyAccountChallengeMap() + ) + } + }) + } +} diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollPhoneRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollPhoneRequestHandler.kt new file mode 100644 index 00000000..3ef762c9 --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollPhoneRequestHandler.kt @@ -0,0 +1,58 @@ +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.myaccount.PhoneAuthenticationMethodType +import com.auth0.android.result.EnrollmentChallenge +import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import com.auth0.auth0_flutter.toMyAccountChallengeMap +import com.auth0.auth0_flutter.toMyAccountMap +import com.auth0.auth0_flutter.utils.assertHasProperties +import io.flutter.plugin.common.MethodChannel + +private const val MY_ACCOUNT_ENROLL_PHONE_METHOD = + "myAccount#enrollPhone" + +class EnrollPhoneRequestHandler : MyAccountRequestHandler { + override val method: String = MY_ACCOUNT_ENROLL_PHONE_METHOD + + override fun handle( + client: MyAccountAPIClient, + request: MethodCallRequest, + result: MethodChannel.Result + ) { + assertHasProperties( + listOf("phoneNumber", "type"), request.data + ) + + val phoneNumber = request.data["phoneNumber"] as String + val typeString = request.data["type"] as String + val type = when (typeString) { + "voice" -> PhoneAuthenticationMethodType.VOICE + else -> PhoneAuthenticationMethodType.SMS + } + + client.enrollPhone(phoneNumber, type) + .start(object : + Callback { + override fun onFailure( + exception: MyAccountException + ) { + result.error( + exception.getCode(), + exception.getDescription(), + exception.toMyAccountMap() + ) + } + + override fun onSuccess( + res: EnrollmentChallenge + ) { + result.success( + res.toMyAccountChallengeMap() + ) + } + }) + } +} diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollPushRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollPushRequestHandler.kt new file mode 100644 index 00000000..a4b70ae6 --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollPushRequestHandler.kt @@ -0,0 +1,45 @@ +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.TotpEnrollmentChallenge +import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import com.auth0.auth0_flutter.toMyAccountChallengeMap +import com.auth0.auth0_flutter.toMyAccountMap +import io.flutter.plugin.common.MethodChannel + +private const val MY_ACCOUNT_ENROLL_PUSH_METHOD = + "myAccount#enrollPush" + +class EnrollPushRequestHandler : MyAccountRequestHandler { + override val method: String = MY_ACCOUNT_ENROLL_PUSH_METHOD + + override fun handle( + client: MyAccountAPIClient, + request: MethodCallRequest, + result: MethodChannel.Result + ) { + client.enrollPushNotification() + .start(object : + Callback { + override fun onFailure( + exception: MyAccountException + ) { + result.error( + exception.getCode(), + exception.getDescription(), + exception.toMyAccountMap() + ) + } + + override fun onSuccess( + res: TotpEnrollmentChallenge + ) { + result.success( + res.toMyAccountChallengeMap() + ) + } + }) + } +} diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollRecoveryCodeRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollRecoveryCodeRequestHandler.kt new file mode 100644 index 00000000..26d9500d --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollRecoveryCodeRequestHandler.kt @@ -0,0 +1,47 @@ +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.RecoveryCodeEnrollmentChallenge +import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import com.auth0.auth0_flutter.toMyAccountChallengeMap +import com.auth0.auth0_flutter.toMyAccountMap +import io.flutter.plugin.common.MethodChannel + +private const val MY_ACCOUNT_ENROLL_RECOVERY_CODE_METHOD = + "myAccount#enrollRecoveryCode" + +class EnrollRecoveryCodeRequestHandler : MyAccountRequestHandler { + override val method: String = + MY_ACCOUNT_ENROLL_RECOVERY_CODE_METHOD + + override fun handle( + client: MyAccountAPIClient, + request: MethodCallRequest, + result: MethodChannel.Result + ) { + client.enrollRecoveryCode() + .start(object : + Callback { + override fun onFailure( + exception: MyAccountException + ) { + result.error( + exception.getCode(), + exception.getDescription(), + exception.toMyAccountMap() + ) + } + + override fun onSuccess( + res: RecoveryCodeEnrollmentChallenge + ) { + result.success( + res.toMyAccountChallengeMap() + ) + } + }) + } +} diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollTotpRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollTotpRequestHandler.kt new file mode 100644 index 00000000..3ff8ce3a --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollTotpRequestHandler.kt @@ -0,0 +1,45 @@ +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.TotpEnrollmentChallenge +import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import com.auth0.auth0_flutter.toMyAccountChallengeMap +import com.auth0.auth0_flutter.toMyAccountMap +import io.flutter.plugin.common.MethodChannel + +private const val MY_ACCOUNT_ENROLL_TOTP_METHOD = + "myAccount#enrollTotp" + +class EnrollTotpRequestHandler : MyAccountRequestHandler { + override val method: String = MY_ACCOUNT_ENROLL_TOTP_METHOD + + override fun handle( + client: MyAccountAPIClient, + request: MethodCallRequest, + result: MethodChannel.Result + ) { + client.enrollTotp() + .start(object : + Callback { + override fun onFailure( + exception: MyAccountException + ) { + result.error( + exception.getCode(), + exception.getDescription(), + exception.toMyAccountMap() + ) + } + + override fun onSuccess( + res: TotpEnrollmentChallenge + ) { + result.success( + res.toMyAccountChallengeMap() + ) + } + }) + } +} diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/GetAuthenticationMethodRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/GetAuthenticationMethodRequestHandler.kt new file mode 100644 index 00000000..f1f55ca1 --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/GetAuthenticationMethodRequestHandler.kt @@ -0,0 +1,44 @@ +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.AuthenticationMethod +import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import com.auth0.auth0_flutter.toMyAccountMap +import com.auth0.auth0_flutter.toMyAccountMethodMap +import com.auth0.auth0_flutter.utils.assertHasProperties +import io.flutter.plugin.common.MethodChannel + +private const val MY_ACCOUNT_GET_AUTH_METHOD_METHOD = + "myAccount#getAuthenticationMethod" + +class GetAuthenticationMethodRequestHandler : MyAccountRequestHandler { + override val method: String = MY_ACCOUNT_GET_AUTH_METHOD_METHOD + + override fun handle( + client: MyAccountAPIClient, + request: MethodCallRequest, + result: MethodChannel.Result + ) { + assertHasProperties(listOf("id"), request.data) + + val id = request.data["id"] as String + + client.getAuthenticationMethodById(id) + .start(object : + Callback { + override fun onFailure(exception: MyAccountException) { + result.error( + exception.getCode(), + exception.getDescription(), + exception.toMyAccountMap() + ) + } + + override fun onSuccess(res: AuthenticationMethod) { + result.success(res.toMyAccountMethodMap()) + } + }) + } +} diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/GetAuthenticationMethodsRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/GetAuthenticationMethodsRequestHandler.kt new file mode 100644 index 00000000..e287fcf5 --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/GetAuthenticationMethodsRequestHandler.kt @@ -0,0 +1,43 @@ +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.AuthenticationMethod +import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import com.auth0.auth0_flutter.toMyAccountMap +import com.auth0.auth0_flutter.toMyAccountMethodMap +import io.flutter.plugin.common.MethodChannel + +private const val MY_ACCOUNT_GET_AUTH_METHODS_METHOD = + "myAccount#getAuthenticationMethods" + +class GetAuthenticationMethodsRequestHandler : MyAccountRequestHandler { + override val method: String = MY_ACCOUNT_GET_AUTH_METHODS_METHOD + + override fun handle( + client: MyAccountAPIClient, + request: MethodCallRequest, + result: MethodChannel.Result + ) { + client.getAuthenticationMethods() + .start(object : + Callback, MyAccountException> { + override fun onFailure(exception: MyAccountException) { + result.error( + exception.getCode(), + exception.getDescription(), + exception.toMyAccountMap() + ) + } + + override fun onSuccess( + res: List + ) { + result.success( + res.map { it.toMyAccountMethodMap() } + ) + } + }) + } +} diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/GetFactorsRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/GetFactorsRequestHandler.kt new file mode 100644 index 00000000..9a759025 --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/GetFactorsRequestHandler.kt @@ -0,0 +1,43 @@ +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.Factor +import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import com.auth0.auth0_flutter.toMyAccountMap +import io.flutter.plugin.common.MethodChannel + +private const val MY_ACCOUNT_GET_FACTORS_METHOD = + "myAccount#getFactors" + +class GetFactorsRequestHandler : MyAccountRequestHandler { + override val method: String = MY_ACCOUNT_GET_FACTORS_METHOD + + override fun handle( + client: MyAccountAPIClient, + request: MethodCallRequest, + result: MethodChannel.Result + ) { + client.getFactors() + .start(object : + Callback, MyAccountException> { + override fun onFailure(exception: MyAccountException) { + result.error( + exception.getCode(), + exception.getDescription(), + exception.toMyAccountMap() + ) + } + + override fun onSuccess(res: List) { + result.success(res.map { + mapOf( + "type" to it.type, + "usage" to it.usage + ) + }) + } + }) + } +} diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/MyAccountRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/MyAccountRequestHandler.kt new file mode 100644 index 00000000..01cc7e54 --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/MyAccountRequestHandler.kt @@ -0,0 +1,10 @@ +package com.auth0.auth0_flutter.request_handlers.my_account + +import com.auth0.android.myaccount.MyAccountAPIClient +import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import io.flutter.plugin.common.MethodChannel + +interface MyAccountRequestHandler { + val method: String + fun handle(client: MyAccountAPIClient, request: MethodCallRequest, result: MethodChannel.Result) +} diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/VerifyOtpRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/VerifyOtpRequestHandler.kt new file mode 100644 index 00000000..a230c560 --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/VerifyOtpRequestHandler.kt @@ -0,0 +1,52 @@ +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.AuthenticationMethod +import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import com.auth0.auth0_flutter.toMyAccountMap +import com.auth0.auth0_flutter.toMyAccountMethodMap +import com.auth0.auth0_flutter.utils.assertHasProperties +import io.flutter.plugin.common.MethodChannel + +private const val MY_ACCOUNT_VERIFY_OTP_METHOD = + "myAccount#verifyOtp" + +class VerifyOtpRequestHandler : MyAccountRequestHandler { + override val method: String = MY_ACCOUNT_VERIFY_OTP_METHOD + + override fun handle( + client: MyAccountAPIClient, + request: MethodCallRequest, + result: MethodChannel.Result + ) { + assertHasProperties( + listOf("id", "authSession", "otp"), request.data + ) + + val id = request.data["id"] as String + val authSession = request.data["authSession"] as String + val otp = request.data["otp"] as String + + client.verifyOtp(id, otp, authSession) + .start(object : + Callback { + override fun onFailure( + exception: MyAccountException + ) { + result.error( + exception.getCode(), + exception.getDescription(), + exception.toMyAccountMap() + ) + } + + override fun onSuccess( + res: AuthenticationMethod + ) { + result.success(res.toMyAccountMethodMap()) + } + }) + } +} diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/Auth0FlutterPluginTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/Auth0FlutterPluginTest.kt index 3a53c656..6a4ea3b3 100644 --- a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/Auth0FlutterPluginTest.kt +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/Auth0FlutterPluginTest.kt @@ -38,8 +38,9 @@ class Auth0FlutterPluginTest { assertMethodcallHandler(1) assertMethodcallHandler(2) assertMethodcallHandler(3) + assertMethodcallHandler(4) - assert(constructed.size == 4) + assert(constructed.size == 5) } } @@ -69,8 +70,9 @@ class Auth0FlutterPluginTest { assertMethodcallHandler(1) assertMethodcallHandler(2) assertMethodcallHandler(3) + assertMethodcallHandler(4) - assert(constructed.size == 4) + assert(constructed.size == 5) } } @@ -108,7 +110,7 @@ class Auth0FlutterPluginTest { assert(getHandler(1).activity == mockActivity) assert(getHandler(1).context == mockContext) - assert(constructed.size == 4) + assert(constructed.size == 5) } } } diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/Auth0FlutterMyAccountMethodCallHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/Auth0FlutterMyAccountMethodCallHandlerTest.kt new file mode 100644 index 00000000..11b77fc3 --- /dev/null +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/Auth0FlutterMyAccountMethodCallHandlerTest.kt @@ -0,0 +1,68 @@ +package com.auth0.auth0_flutter.request_handlers.my_account + +import com.auth0.android.Auth0 +import com.auth0.android.myaccount.MyAccountAPIClient +import com.auth0.auth0_flutter.Auth0FlutterMyAccountMethodCallHandler +import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel.Result +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.`when` +import org.mockito.kotlin.* +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class Auth0FlutterMyAccountMethodCallHandlerTest { + private val defaultArguments = hashMapOf( + "_account" to mapOf( + "domain" to "test.auth0.com", + "clientId" to "test-client", + ), + "_userAgent" to mapOf( + "name" to "auth0-flutter", + "version" to "1.0.0" + ), + "accessToken" to "test-token" + ) + + @Test + fun `handler should result in notImplemented if no matching handler`() { + val handler = Auth0FlutterMyAccountMethodCallHandler(emptyList()) + val mockResult = mock() + + handler.onMethodCall(MethodCall("myAccount#unknown", defaultArguments), mockResult) + + verify(mockResult).notImplemented() + } + + @Test + fun `handler should call the correct handler when matched`() { + val mockRequestHandler = mock() + `when`(mockRequestHandler.method).thenReturn("myAccount#getFactors") + + val handler = Auth0FlutterMyAccountMethodCallHandler(listOf(mockRequestHandler)) + val mockResult = mock() + + handler.onMethodCall(MethodCall("myAccount#getFactors", defaultArguments), mockResult) + + verify(mockRequestHandler).handle(any(), any(), eq(mockResult)) + } + + @Test + fun `handler should not call non-matching handlers`() { + val getFactorsHandler = mock() + val enrollPhoneHandler = mock() + + `when`(getFactorsHandler.method).thenReturn("myAccount#getFactors") + `when`(enrollPhoneHandler.method).thenReturn("myAccount#enrollPhone") + + val handler = Auth0FlutterMyAccountMethodCallHandler(listOf(getFactorsHandler, enrollPhoneHandler)) + val mockResult = mock() + + handler.onMethodCall(MethodCall("myAccount#getFactors", defaultArguments), mockResult) + + verify(getFactorsHandler).handle(any(), any(), eq(mockResult)) + verify(enrollPhoneHandler, times(0)).handle(any(), any(), any()) + } +} diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/DeleteAuthenticationMethodRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/DeleteAuthenticationMethodRequestHandlerTest.kt new file mode 100644 index 00000000..71ff72d9 --- /dev/null +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/DeleteAuthenticationMethodRequestHandlerTest.kt @@ -0,0 +1,94 @@ +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.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 DeleteAuthenticationMethodRequestHandlerTest { + + @Test + fun `should call deleteAuthenticationMethod with the correct id`() { + val handler = DeleteAuthenticationMethodRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock>() + val options = hashMapOf("id" to "phone|test123") + val request = MethodCallRequest(account = mockAccount, options) + + whenever(mockClient.deleteAuthenticationMethod(any())).thenReturn(mockRequest) + + handler.handle(mockClient, request, mockResult) + + verify(mockClient).deleteAuthenticationMethod(eq("phone|test123")) + } + + @Test + fun `should call result success with null on success`() { + val handler = DeleteAuthenticationMethodRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock>() + val options = hashMapOf("id" to "phone|test123") + val request = MethodCallRequest(account = mockAccount, options) + + whenever(mockClient.deleteAuthenticationMethod(any())).thenReturn(mockRequest) + doAnswer { + val callback = it.getArgument>(0) + callback.onSuccess(null) + }.whenever(mockRequest).start(any()) + + handler.handle(mockClient, request, mockResult) + + verify(mockResult).success(isNull()) + } + + @Test + fun `should call result error on failure`() { + val handler = DeleteAuthenticationMethodRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock>() + val options = hashMapOf("id" to "phone|test123") + val request = MethodCallRequest(account = mockAccount, options) + val exception = mock() + + whenever(exception.getCode()).thenReturn("not_found") + whenever(exception.getDescription()).thenReturn("Method not found") + whenever(exception.statusCode).thenReturn(404) + whenever(exception.isNetworkError).thenReturn(false) + + whenever(mockClient.deleteAuthenticationMethod(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("not_found"), eq("Method not found"), any()) + } + + @Test(expected = IllegalArgumentException::class) + fun `should throw when id is missing`() { + val handler = DeleteAuthenticationMethodRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val options = hashMapOf() + val request = MethodCallRequest(account = mockAccount, options) + + handler.handle(mockClient, request, mockResult) + } +} diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollEmailRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollEmailRequestHandlerTest.kt new file mode 100644 index 00000000..dc2c7bc6 --- /dev/null +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollEmailRequestHandlerTest.kt @@ -0,0 +1,99 @@ +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.EnrollmentChallenge +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 EnrollEmailRequestHandlerTest { + + @Test + fun `should call enrollEmail with the correct email`() { + val handler = EnrollEmailRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock>() + val options = hashMapOf("email" to "test@example.com") + val request = MethodCallRequest(account = mockAccount, options) + + whenever(mockClient.enrollEmail(any())).thenReturn(mockRequest) + + handler.handle(mockClient, request, mockResult) + + verify(mockClient).enrollEmail(eq("test@example.com")) + } + + @Test + fun `should call result success with challenge map on success`() { + val handler = EnrollEmailRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock>() + val options = hashMapOf("email" to "test@example.com") + val request = MethodCallRequest(account = mockAccount, options) + + val mockChallenge = mock() + whenever(mockChallenge.id).thenReturn("email|test123") + whenever(mockChallenge.authSession).thenReturn("session123") + + whenever(mockClient.enrollEmail(any())).thenReturn(mockRequest) + doAnswer { + val callback = it.getArgument>(0) + callback.onSuccess(mockChallenge) + }.whenever(mockRequest).start(any()) + + handler.handle(mockClient, request, mockResult) + + verify(mockResult).success(any()) + } + + @Test + fun `should call result error on failure`() { + val handler = EnrollEmailRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock>() + val options = hashMapOf("email" to "test@example.com") + val request = MethodCallRequest(account = mockAccount, options) + val exception = mock() + + whenever(exception.getCode()).thenReturn("invalid_email") + whenever(exception.getDescription()).thenReturn("Invalid email") + whenever(exception.statusCode).thenReturn(400) + whenever(exception.isNetworkError).thenReturn(false) + + whenever(mockClient.enrollEmail(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("invalid_email"), eq("Invalid email"), any()) + } + + @Test(expected = IllegalArgumentException::class) + fun `should throw when email is missing`() { + val handler = EnrollEmailRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val options = hashMapOf() + val request = MethodCallRequest(account = mockAccount, options) + + handler.handle(mockClient, request, mockResult) + } +} diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollPhoneRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollPhoneRequestHandlerTest.kt new file mode 100644 index 00000000..0bd64907 --- /dev/null +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollPhoneRequestHandlerTest.kt @@ -0,0 +1,141 @@ +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.myaccount.PhoneAuthenticationMethodType +import com.auth0.android.request.Request +import com.auth0.android.result.EnrollmentChallenge +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 EnrollPhoneRequestHandlerTest { + + @Test + fun `should call enrollPhone with sms type`() { + val handler = EnrollPhoneRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock>() + val options = hashMapOf( + "phoneNumber" to "+1234567890", + "type" to "sms" + ) + val request = MethodCallRequest(account = mockAccount, options) + + whenever(mockClient.enrollPhone(any(), any())).thenReturn(mockRequest) + + handler.handle(mockClient, request, mockResult) + + verify(mockClient).enrollPhone(eq("+1234567890"), eq(PhoneAuthenticationMethodType.SMS)) + } + + @Test + fun `should call enrollPhone with voice type`() { + val handler = EnrollPhoneRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock>() + val options = hashMapOf( + "phoneNumber" to "+1234567890", + "type" to "voice" + ) + val request = MethodCallRequest(account = mockAccount, options) + + whenever(mockClient.enrollPhone(any(), any())).thenReturn(mockRequest) + + handler.handle(mockClient, request, mockResult) + + verify(mockClient).enrollPhone(eq("+1234567890"), eq(PhoneAuthenticationMethodType.VOICE)) + } + + @Test + fun `should call result success with challenge map on success`() { + val handler = EnrollPhoneRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock>() + val options = hashMapOf( + "phoneNumber" to "+1234567890", + "type" to "sms" + ) + val request = MethodCallRequest(account = mockAccount, options) + + val mockChallenge = mock() + whenever(mockChallenge.id).thenReturn("phone|test123") + whenever(mockChallenge.authSession).thenReturn("session123") + + whenever(mockClient.enrollPhone(any(), any())).thenReturn(mockRequest) + doAnswer { + val callback = it.getArgument>(0) + callback.onSuccess(mockChallenge) + }.whenever(mockRequest).start(any()) + + handler.handle(mockClient, request, mockResult) + + verify(mockResult).success(any()) + } + + @Test + fun `should call result error on failure`() { + val handler = EnrollPhoneRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock>() + val options = hashMapOf( + "phoneNumber" to "+1234567890", + "type" to "sms" + ) + val request = MethodCallRequest(account = mockAccount, options) + val exception = mock() + + whenever(exception.getCode()).thenReturn("invalid_phone") + whenever(exception.getDescription()).thenReturn("Invalid phone number") + whenever(exception.statusCode).thenReturn(400) + whenever(exception.isNetworkError).thenReturn(false) + + whenever(mockClient.enrollPhone(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("invalid_phone"), eq("Invalid phone number"), any()) + } + + @Test(expected = IllegalArgumentException::class) + fun `should throw when phoneNumber is missing`() { + val handler = EnrollPhoneRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val options = hashMapOf("type" to "sms") + val request = MethodCallRequest(account = mockAccount, options) + + handler.handle(mockClient, request, mockResult) + } + + @Test(expected = IllegalArgumentException::class) + fun `should throw when type is missing`() { + val handler = EnrollPhoneRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val options = hashMapOf("phoneNumber" to "+1234567890") + val request = MethodCallRequest(account = mockAccount, options) + + handler.handle(mockClient, request, mockResult) + } +} diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollPushRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollPushRequestHandlerTest.kt new file mode 100644 index 00000000..6e604d72 --- /dev/null +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollPushRequestHandlerTest.kt @@ -0,0 +1,84 @@ +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.TotpEnrollmentChallenge +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 EnrollPushRequestHandlerTest { + + @Test + fun `should call enrollPushNotification on the client`() { + val handler = EnrollPushRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock>() + val request = MethodCallRequest(account = mockAccount, hashMapOf()) + + whenever(mockClient.enrollPushNotification()).thenReturn(mockRequest) + + handler.handle(mockClient, request, mockResult) + + verify(mockClient).enrollPushNotification() + } + + @Test + fun `should call result success with challenge map on success`() { + val handler = EnrollPushRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock>() + val request = MethodCallRequest(account = mockAccount, hashMapOf()) + + val mockChallenge = mock() + whenever(mockChallenge.id).thenReturn("push|test123") + whenever(mockChallenge.authSession).thenReturn("session123") + + whenever(mockClient.enrollPushNotification()).thenReturn(mockRequest) + doAnswer { + val callback = it.getArgument>(0) + callback.onSuccess(mockChallenge) + }.whenever(mockRequest).start(any()) + + handler.handle(mockClient, request, mockResult) + + verify(mockResult).success(any()) + } + + @Test + fun `should call result error on failure`() { + val handler = EnrollPushRequestHandler() + 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.enrollPushNotification()).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/EnrollRecoveryCodeRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollRecoveryCodeRequestHandlerTest.kt new file mode 100644 index 00000000..07867aad --- /dev/null +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollRecoveryCodeRequestHandlerTest.kt @@ -0,0 +1,84 @@ +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.RecoveryCodeEnrollmentChallenge +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 EnrollRecoveryCodeRequestHandlerTest { + + @Test + fun `should call enrollRecoveryCode on the client`() { + val handler = EnrollRecoveryCodeRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock>() + val request = MethodCallRequest(account = mockAccount, hashMapOf()) + + whenever(mockClient.enrollRecoveryCode()).thenReturn(mockRequest) + + handler.handle(mockClient, request, mockResult) + + verify(mockClient).enrollRecoveryCode() + } + + @Test + fun `should call result success with challenge map on success`() { + val handler = EnrollRecoveryCodeRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock>() + val request = MethodCallRequest(account = mockAccount, hashMapOf()) + + val mockChallenge = mock() + whenever(mockChallenge.id).thenReturn("recovery-code|test123") + whenever(mockChallenge.authSession).thenReturn("session123") + + whenever(mockClient.enrollRecoveryCode()).thenReturn(mockRequest) + doAnswer { + val callback = it.getArgument>(0) + callback.onSuccess(mockChallenge) + }.whenever(mockRequest).start(any()) + + handler.handle(mockClient, request, mockResult) + + verify(mockResult).success(any()) + } + + @Test + fun `should call result error on failure`() { + val handler = EnrollRecoveryCodeRequestHandler() + 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.enrollRecoveryCode()).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/EnrollTotpRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollTotpRequestHandlerTest.kt new file mode 100644 index 00000000..8241c421 --- /dev/null +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/EnrollTotpRequestHandlerTest.kt @@ -0,0 +1,84 @@ +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.TotpEnrollmentChallenge +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 EnrollTotpRequestHandlerTest { + + @Test + fun `should call enrollTotp on the client`() { + val handler = EnrollTotpRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock>() + val request = MethodCallRequest(account = mockAccount, hashMapOf()) + + whenever(mockClient.enrollTotp()).thenReturn(mockRequest) + + handler.handle(mockClient, request, mockResult) + + verify(mockClient).enrollTotp() + } + + @Test + fun `should call result success with challenge map on success`() { + val handler = EnrollTotpRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock>() + val request = MethodCallRequest(account = mockAccount, hashMapOf()) + + val mockChallenge = mock() + whenever(mockChallenge.id).thenReturn("totp|test123") + whenever(mockChallenge.authSession).thenReturn("session123") + + whenever(mockClient.enrollTotp()).thenReturn(mockRequest) + doAnswer { + val callback = it.getArgument>(0) + callback.onSuccess(mockChallenge) + }.whenever(mockRequest).start(any()) + + handler.handle(mockClient, request, mockResult) + + verify(mockResult).success(any()) + } + + @Test + fun `should call result error on failure`() { + val handler = EnrollTotpRequestHandler() + 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.enrollTotp()).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/GetAuthenticationMethodRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/GetAuthenticationMethodRequestHandlerTest.kt new file mode 100644 index 00000000..0b4fa248 --- /dev/null +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/GetAuthenticationMethodRequestHandlerTest.kt @@ -0,0 +1,100 @@ +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.AuthenticationMethod +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 GetAuthenticationMethodRequestHandlerTest { + + @Test + fun `should call getAuthenticationMethodById with the correct id`() { + val handler = GetAuthenticationMethodRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock>() + val options = hashMapOf("id" to "phone|test123") + val request = MethodCallRequest(account = mockAccount, options) + + whenever(mockClient.getAuthenticationMethodById(any())).thenReturn(mockRequest) + + handler.handle(mockClient, request, mockResult) + + verify(mockClient).getAuthenticationMethodById(eq("phone|test123")) + } + + @Test + fun `should call result success with mapped method on success`() { + val handler = GetAuthenticationMethodRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock>() + val options = hashMapOf("id" to "phone|test123") + val request = MethodCallRequest(account = mockAccount, options) + + val mockMethod = mock() + whenever(mockMethod.id).thenReturn("phone|test123") + whenever(mockMethod.type).thenReturn("phone") + whenever(mockMethod.createdAt).thenReturn("2026-01-01T00:00:00.000Z") + + whenever(mockClient.getAuthenticationMethodById(any())).thenReturn(mockRequest) + doAnswer { + val callback = it.getArgument>(0) + callback.onSuccess(mockMethod) + }.whenever(mockRequest).start(any()) + + handler.handle(mockClient, request, mockResult) + + verify(mockResult).success(any()) + } + + @Test + fun `should call result error on failure`() { + val handler = GetAuthenticationMethodRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock>() + val options = hashMapOf("id" to "phone|test123") + val request = MethodCallRequest(account = mockAccount, options) + val exception = mock() + + whenever(exception.getCode()).thenReturn("not_found") + whenever(exception.getDescription()).thenReturn("Not found") + whenever(exception.statusCode).thenReturn(404) + whenever(exception.isNetworkError).thenReturn(false) + + whenever(mockClient.getAuthenticationMethodById(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("not_found"), eq("Not found"), any()) + } + + @Test(expected = IllegalArgumentException::class) + fun `should throw when id is missing`() { + val handler = GetAuthenticationMethodRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val options = hashMapOf() + val request = MethodCallRequest(account = mockAccount, options) + + handler.handle(mockClient, request, mockResult) + } +} diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/GetAuthenticationMethodsRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/GetAuthenticationMethodsRequestHandlerTest.kt new file mode 100644 index 00000000..87a84dd5 --- /dev/null +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/GetAuthenticationMethodsRequestHandlerTest.kt @@ -0,0 +1,85 @@ +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.AuthenticationMethod +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 GetAuthenticationMethodsRequestHandlerTest { + + @Test + fun `should call getAuthenticationMethods on the client`() { + val handler = GetAuthenticationMethodsRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock, MyAccountException>>() + val request = MethodCallRequest(account = mockAccount, hashMapOf()) + + whenever(mockClient.getAuthenticationMethods()).thenReturn(mockRequest) + + handler.handle(mockClient, request, mockResult) + + verify(mockClient).getAuthenticationMethods() + } + + @Test + fun `should call result success with mapped methods on success`() { + val handler = GetAuthenticationMethodsRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock, MyAccountException>>() + val request = MethodCallRequest(account = mockAccount, hashMapOf()) + + val mockMethod = mock() + whenever(mockMethod.id).thenReturn("test-id") + whenever(mockMethod.type).thenReturn("phone") + whenever(mockMethod.createdAt).thenReturn("2026-01-01T00:00:00.000Z") + + whenever(mockClient.getAuthenticationMethods()).thenReturn(mockRequest) + doAnswer { + val callback = it.getArgument, MyAccountException>>(0) + callback.onSuccess(listOf(mockMethod)) + }.whenever(mockRequest).start(any()) + + handler.handle(mockClient, request, mockResult) + + verify(mockResult).success(any()) + } + + @Test + fun `should call result error on failure`() { + val handler = GetAuthenticationMethodsRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock, MyAccountException>>() + val request = MethodCallRequest(account = mockAccount, hashMapOf()) + val exception = mock() + + whenever(exception.getCode()).thenReturn("insufficient_scope") + whenever(exception.getDescription()).thenReturn("Insufficient scope") + whenever(exception.statusCode).thenReturn(403) + whenever(exception.isNetworkError).thenReturn(false) + + whenever(mockClient.getAuthenticationMethods()).thenReturn(mockRequest) + doAnswer { + val callback = it.getArgument, MyAccountException>>(0) + callback.onFailure(exception) + }.whenever(mockRequest).start(any()) + + handler.handle(mockClient, request, mockResult) + + verify(mockResult).error(eq("insufficient_scope"), eq("Insufficient scope"), any()) + } +} diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/GetFactorsRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/GetFactorsRequestHandlerTest.kt new file mode 100644 index 00000000..008871c9 --- /dev/null +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/GetFactorsRequestHandlerTest.kt @@ -0,0 +1,92 @@ +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.Factor +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.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.* +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class GetFactorsRequestHandlerTest { + + @Test + fun `should call getFactors on the client`() { + val handler = GetFactorsRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock, MyAccountException>>() + val request = MethodCallRequest(account = mockAccount, hashMapOf()) + + whenever(mockClient.getFactors()).thenReturn(mockRequest) + + handler.handle(mockClient, request, mockResult) + + verify(mockClient).getFactors() + } + + @Test + fun `should call result success with mapped factors on success`() { + val handler = GetFactorsRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock, MyAccountException>>() + val request = MethodCallRequest(account = mockAccount, hashMapOf()) + + val mockFactor = mock() + whenever(mockFactor.type).thenReturn("sms") + whenever(mockFactor.usage).thenReturn(listOf("secondary")) + + whenever(mockClient.getFactors()).thenReturn(mockRequest) + doAnswer { + val callback = it.getArgument, MyAccountException>>(0) + callback.onSuccess(listOf(mockFactor)) + }.whenever(mockRequest).start(any()) + + handler.handle(mockClient, request, mockResult) + + val captor = argumentCaptor>>() + verify(mockResult).success(captor.capture()) + + val result = captor.firstValue + assertThat(result.size, equalTo(1)) + assertThat(result[0]["type"], equalTo("sms")) + assertThat(result[0]["usage"], equalTo(listOf("secondary"))) + } + + @Test + fun `should call result error on failure`() { + val handler = GetFactorsRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock, MyAccountException>>() + val request = MethodCallRequest(account = mockAccount, hashMapOf()) + val exception = mock() + + whenever(exception.getCode()).thenReturn("access_denied") + whenever(exception.getDescription()).thenReturn("Access denied") + whenever(exception.statusCode).thenReturn(403) + whenever(exception.isNetworkError).thenReturn(false) + + whenever(mockClient.getFactors()).thenReturn(mockRequest) + doAnswer { + val callback = it.getArgument, MyAccountException>>(0) + callback.onFailure(exception) + }.whenever(mockRequest).start(any()) + + handler.handle(mockClient, request, mockResult) + + verify(mockResult).error(eq("access_denied"), eq("Access denied"), any()) + } +} diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/VerifyOtpRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/VerifyOtpRequestHandlerTest.kt new file mode 100644 index 00000000..91816abe --- /dev/null +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/request_handlers/my_account/VerifyOtpRequestHandlerTest.kt @@ -0,0 +1,152 @@ +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.AuthenticationMethod +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.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.* +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class VerifyOtpRequestHandlerTest { + + @Test + fun `should call verifyOtp with correct parameters`() { + val handler = VerifyOtpRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock>() + val options = hashMapOf( + "id" to "phone|test123", + "authSession" to "session456", + "otp" to "123456" + ) + val request = MethodCallRequest(account = mockAccount, options) + + whenever(mockClient.verifyOtp(any(), any(), any())).thenReturn(mockRequest) + + handler.handle(mockClient, request, mockResult) + + verify(mockClient).verifyOtp(eq("phone|test123"), eq("123456"), eq("session456")) + } + + @Test + fun `should call result success with mapped method on success`() { + val handler = VerifyOtpRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock>() + val options = hashMapOf( + "id" to "phone|test123", + "authSession" to "session456", + "otp" to "123456" + ) + val request = MethodCallRequest(account = mockAccount, options) + + val mockMethod = mock() + whenever(mockMethod.id).thenReturn("phone|test123") + whenever(mockMethod.type).thenReturn("phone") + whenever(mockMethod.createdAt).thenReturn("2026-01-01") + + whenever(mockClient.verifyOtp(any(), any(), any())).thenReturn(mockRequest) + doAnswer { + val callback = it.getArgument>(0) + callback.onSuccess(mockMethod) + }.whenever(mockRequest).start(any()) + + handler.handle(mockClient, request, mockResult) + + val captor = argumentCaptor>() + verify(mockResult).success(captor.capture()) + + val result = captor.firstValue + assertThat(result["id"], equalTo("phone|test123")) + assertThat(result["type"], equalTo("phone")) + } + + @Test + fun `should call result error on failure`() { + val handler = VerifyOtpRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val mockRequest = mock>() + val options = hashMapOf( + "id" to "phone|test123", + "authSession" to "session456", + "otp" to "wrong" + ) + val request = MethodCallRequest(account = mockAccount, options) + val exception = mock() + + whenever(exception.getCode()).thenReturn("invalid_code") + whenever(exception.getDescription()).thenReturn("Invalid code") + whenever(exception.statusCode).thenReturn(403) + whenever(exception.isNetworkError).thenReturn(false) + + whenever(mockClient.verifyOtp(any(), 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("invalid_code"), eq("Invalid code"), any()) + } + + @Test(expected = IllegalArgumentException::class) + fun `should throw when id is missing`() { + val handler = VerifyOtpRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val options = hashMapOf( + "authSession" to "session456", + "otp" to "123456" + ) + val request = MethodCallRequest(account = mockAccount, options) + + handler.handle(mockClient, request, mockResult) + } + + @Test(expected = IllegalArgumentException::class) + fun `should throw when authSession is missing`() { + val handler = VerifyOtpRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val options = hashMapOf( + "id" to "phone|test123", + "otp" to "123456" + ) + val request = MethodCallRequest(account = mockAccount, options) + + handler.handle(mockClient, request, mockResult) + } + + @Test(expected = IllegalArgumentException::class) + fun `should throw when otp is missing`() { + val handler = VerifyOtpRequestHandler() + val mockResult = mock() + val mockAccount = mock() + val mockClient = mock() + val options = hashMapOf( + "id" to "phone|test123", + "authSession" to "session456" + ) + val request = MethodCallRequest(account = mockAccount, options) + + handler.handle(mockClient, request, mockResult) + } +} diff --git a/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountDeleteAuthMethodMethodHandler.swift b/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountDeleteAuthMethodMethodHandler.swift new file mode 100644 index 00000000..b00656d0 --- /dev/null +++ b/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountDeleteAuthMethodMethodHandler.swift @@ -0,0 +1,29 @@ +import Auth0 + +#if os(iOS) +import Flutter +#else +import FlutterMacOS +#endif + +struct MyAccountDeleteAuthMethodMethodHandler: MethodHandler { + let client: MyAccount + + func handle(with arguments: [String: Any], callback: @escaping FlutterResult) { + guard let id = arguments["id"] as? String else { + return callback(FlutterError(from: .requiredArgumentMissing("id"))) + } + + client + .authenticationMethods + .deleteAuthenticationMethod(by: id) + .start { + switch $0 { + case .success: + callback(nil) + case let .failure(error): + callback(FlutterError(from: error)) + } + } + } +} diff --git a/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountEnrollEmailMethodHandler.swift b/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountEnrollEmailMethodHandler.swift new file mode 100644 index 00000000..54768136 --- /dev/null +++ b/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountEnrollEmailMethodHandler.swift @@ -0,0 +1,29 @@ +import Auth0 + +#if os(iOS) +import Flutter +#else +import FlutterMacOS +#endif + +struct MyAccountEnrollEmailMethodHandler: MethodHandler { + let client: MyAccount + + func handle(with arguments: [String: Any], callback: @escaping FlutterResult) { + guard let email = arguments["email"] as? String else { + return callback(FlutterError(from: .requiredArgumentMissing("email"))) + } + + client + .authenticationMethods + .enrollEmail(emailAddress: email) + .start { + switch $0 { + case let .success(challenge): + callback(challenge.asDictionary()) + case let .failure(error): + callback(FlutterError(from: error)) + } + } + } +} diff --git a/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountEnrollPhoneMethodHandler.swift b/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountEnrollPhoneMethodHandler.swift new file mode 100644 index 00000000..81538264 --- /dev/null +++ b/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountEnrollPhoneMethodHandler.swift @@ -0,0 +1,34 @@ +import Auth0 + +#if os(iOS) +import Flutter +#else +import FlutterMacOS +#endif + +struct MyAccountEnrollPhoneMethodHandler: MethodHandler { + let client: MyAccount + + func handle(with arguments: [String: Any], callback: @escaping FlutterResult) { + guard let phoneNumber = arguments["phoneNumber"] as? String else { + return callback(FlutterError(from: .requiredArgumentMissing("phoneNumber"))) + } + guard let typeString = arguments["type"] as? String else { + return callback(FlutterError(from: .requiredArgumentMissing("type"))) + } + + let preferredMethod: PreferredAuthenticationMethod = typeString == "voice" ? .voice : .sms + + client + .authenticationMethods + .enrollPhone(phoneNumber: phoneNumber, preferredAuthenticationMethod: preferredMethod) + .start { + switch $0 { + case let .success(challenge): + callback(challenge.asDictionary()) + case let .failure(error): + callback(FlutterError(from: error)) + } + } + } +} diff --git a/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountEnrollPushMethodHandler.swift b/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountEnrollPushMethodHandler.swift new file mode 100644 index 00000000..0318e781 --- /dev/null +++ b/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountEnrollPushMethodHandler.swift @@ -0,0 +1,25 @@ +import Auth0 + +#if os(iOS) +import Flutter +#else +import FlutterMacOS +#endif + +struct MyAccountEnrollPushMethodHandler: MethodHandler { + let client: MyAccount + + func handle(with arguments: [String: Any], callback: @escaping FlutterResult) { + client + .authenticationMethods + .enrollPushNotification() + .start { + switch $0 { + case let .success(challenge): + callback(challenge.asDictionary()) + case let .failure(error): + callback(FlutterError(from: error)) + } + } + } +} diff --git a/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountEnrollRecoveryCodeMethodHandler.swift b/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountEnrollRecoveryCodeMethodHandler.swift new file mode 100644 index 00000000..0bbaefe5 --- /dev/null +++ b/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountEnrollRecoveryCodeMethodHandler.swift @@ -0,0 +1,25 @@ +import Auth0 + +#if os(iOS) +import Flutter +#else +import FlutterMacOS +#endif + +struct MyAccountEnrollRecoveryCodeMethodHandler: MethodHandler { + let client: MyAccount + + func handle(with arguments: [String: Any], callback: @escaping FlutterResult) { + client + .authenticationMethods + .enrollRecoveryCode() + .start { + switch $0 { + case let .success(challenge): + callback(challenge.asDictionary()) + case let .failure(error): + callback(FlutterError(from: error)) + } + } + } +} diff --git a/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountEnrollTotpMethodHandler.swift b/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountEnrollTotpMethodHandler.swift new file mode 100644 index 00000000..307a3b31 --- /dev/null +++ b/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountEnrollTotpMethodHandler.swift @@ -0,0 +1,25 @@ +import Auth0 + +#if os(iOS) +import Flutter +#else +import FlutterMacOS +#endif + +struct MyAccountEnrollTotpMethodHandler: MethodHandler { + let client: MyAccount + + func handle(with arguments: [String: Any], callback: @escaping FlutterResult) { + client + .authenticationMethods + .enrollTOTP() + .start { + switch $0 { + case let .success(challenge): + callback(challenge.asDictionary()) + case let .failure(error): + callback(FlutterError(from: error)) + } + } + } +} diff --git a/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountExtensions.swift b/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountExtensions.swift new file mode 100644 index 00000000..64b87f59 --- /dev/null +++ b/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountExtensions.swift @@ -0,0 +1,107 @@ +import Auth0 + +#if os(iOS) +import Flutter +#else +import FlutterMacOS +#endif + +extension FlutterError { + convenience init(from error: MyAccountError) { + let details: [String: Any] = [ + "_statusCode": error.statusCode, + "_title": error.title, + "_detail": error.detail, + "_errorFlags": [ + "isNetworkError": false + ] + ] + self.init(code: error.code, + message: error.detail.isEmpty ? error.title : error.detail, + details: details) + } +} + +extension AuthenticationMethod { + func asDictionary() -> [String: Any?] { + return [ + "id": id, + "type": type, + "name": name, + "phone_number": phoneNumber, + "email": nil, + "totp_secret": nil, + "totp_uri": nil, + "preferred_authentication_method": preferredAuthenticationMethod, + "created_at": createdAt, + "last_auth_at": nil, + "confirmed": confirmed, + "usage": usage + ] + } +} + +extension PhoneEnrollmentChallenge { + func asDictionary() -> [String: Any?] { + return [ + "id": authenticationId, + "auth_session": authenticationSession, + "totp_secret": nil, + "totp_uri": nil, + "barcode_uri": nil, + "recovery_code": nil + ] + } +} + +extension EmailEnrollmentChallenge { + func asDictionary() -> [String: Any?] { + return [ + "id": authenticationId, + "auth_session": authenticationSession, + "totp_secret": nil, + "totp_uri": nil, + "barcode_uri": nil, + "recovery_code": nil + ] + } +} + +extension TOTPEnrollmentChallenge { + func asDictionary() -> [String: Any?] { + return [ + "id": authenticationId, + "auth_session": authenticationSession, + "totp_secret": authenticatorManualInputCode, + "totp_uri": authenticatorQRCodeURI, + "barcode_uri": authenticatorQRCodeURI, + "recovery_code": nil + ] + } +} + +extension PushEnrollmentChallenge { + func asDictionary() -> [String: Any?] { + return [ + "id": authenticationId, + "auth_session": authenticationSession, + "totp_secret": nil, + "totp_uri": nil, + "barcode_uri": authenticatorQRCodeURI, + "recovery_code": nil + ] + } +} + +extension RecoveryCodeEnrollmentChallenge { + func asDictionary() -> [String: Any?] { + return [ + "id": authenticationId, + "auth_session": authenticationSession, + "totp_secret": nil, + "totp_uri": nil, + "barcode_uri": nil, + "recovery_code": recoveryCode + ] + } +} diff --git a/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountGetAuthMethodMethodHandler.swift b/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountGetAuthMethodMethodHandler.swift new file mode 100644 index 00000000..ac819216 --- /dev/null +++ b/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountGetAuthMethodMethodHandler.swift @@ -0,0 +1,29 @@ +import Auth0 + +#if os(iOS) +import Flutter +#else +import FlutterMacOS +#endif + +struct MyAccountGetAuthMethodMethodHandler: MethodHandler { + let client: MyAccount + + func handle(with arguments: [String: Any], callback: @escaping FlutterResult) { + guard let id = arguments["id"] as? String else { + return callback(FlutterError(from: .requiredArgumentMissing("id"))) + } + + client + .authenticationMethods + .getAuthenticationMethod(by: id) + .start { + switch $0 { + case let .success(method): + callback(method.asDictionary()) + case let .failure(error): + callback(FlutterError(from: error)) + } + } + } +} diff --git a/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountGetAuthMethodsMethodHandler.swift b/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountGetAuthMethodsMethodHandler.swift new file mode 100644 index 00000000..6b77761b --- /dev/null +++ b/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountGetAuthMethodsMethodHandler.swift @@ -0,0 +1,25 @@ +import Auth0 + +#if os(iOS) +import Flutter +#else +import FlutterMacOS +#endif + +struct MyAccountGetAuthMethodsMethodHandler: MethodHandler { + let client: MyAccount + + func handle(with arguments: [String: Any], callback: @escaping FlutterResult) { + client + .authenticationMethods + .getAuthenticationMethods() + .start { + switch $0 { + case let .success(methods): + callback(methods.map { $0.asDictionary() }) + case let .failure(error): + callback(FlutterError(from: error)) + } + } + } +} diff --git a/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountGetFactorsMethodHandler.swift b/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountGetFactorsMethodHandler.swift new file mode 100644 index 00000000..8f35c6ef --- /dev/null +++ b/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountGetFactorsMethodHandler.swift @@ -0,0 +1,25 @@ +import Auth0 + +#if os(iOS) +import Flutter +#else +import FlutterMacOS +#endif + +struct MyAccountGetFactorsMethodHandler: MethodHandler { + let client: MyAccount + + func handle(with arguments: [String: Any], callback: @escaping FlutterResult) { + client + .authenticationMethods + .getFactors() + .start { + switch $0 { + case let .success(factors): + callback(factors.map { ["type": $0.type, "usage": $0.usage as Any] }) + case let .failure(error): + callback(FlutterError(from: error)) + } + } + } +} diff --git a/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountHandler.swift b/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountHandler.swift new file mode 100644 index 00000000..4937adf6 --- /dev/null +++ b/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountHandler.swift @@ -0,0 +1,86 @@ +import Auth0 + +#if os(iOS) +import Flutter +#else +import FlutterMacOS +#endif + +typealias MyAccountClientProvider = (_ account: Account, _ userAgent: UserAgent, _ accessToken: String) -> MyAccount +typealias MyAccountMethodHandlerProvider = (_ method: MyAccountHandler.Method, _ client: MyAccount) -> MethodHandler + +public class MyAccountHandler: NSObject, FlutterPlugin { + enum Method: String, CaseIterable { + case getAuthenticationMethods = "myAccount#getAuthenticationMethods" + case getAuthenticationMethod = "myAccount#getAuthenticationMethod" + case deleteAuthenticationMethod = "myAccount#deleteAuthenticationMethod" + case getFactors = "myAccount#getFactors" + case enrollPhone = "myAccount#enrollPhone" + case enrollEmail = "myAccount#enrollEmail" + case enrollTotp = "myAccount#enrollTotp" + case enrollPush = "myAccount#enrollPush" + case enrollRecoveryCode = "myAccount#enrollRecoveryCode" + case verifyOtp = "myAccount#verifyOtp" + } + + private static let channelName = "auth0.com/auth0_flutter/my_account" + + public static func register(with registrar: FlutterPluginRegistrar) { + let handler = MyAccountHandler() + + #if os(iOS) + let channel = FlutterMethodChannel(name: MyAccountHandler.channelName, + binaryMessenger: registrar.messenger()) + #else + let channel = FlutterMethodChannel(name: MyAccountHandler.channelName, + binaryMessenger: registrar.messenger) + #endif + + registrar.addMethodCallDelegate(handler, channel: channel) + } + + var clientProvider: MyAccountClientProvider = { account, userAgent, accessToken in + var client = Auth0.myAccount(token: accessToken, domain: account.domain) + return client + } + + var methodHandlerProvider: MyAccountMethodHandlerProvider = { method, client in + switch method { + case .getAuthenticationMethods: return MyAccountGetAuthMethodsMethodHandler(client: client) + case .getAuthenticationMethod: return MyAccountGetAuthMethodMethodHandler(client: client) + case .deleteAuthenticationMethod: return MyAccountDeleteAuthMethodMethodHandler(client: client) + case .getFactors: return MyAccountGetFactorsMethodHandler(client: client) + case .enrollPhone: return MyAccountEnrollPhoneMethodHandler(client: client) + case .enrollEmail: return MyAccountEnrollEmailMethodHandler(client: client) + case .enrollTotp: return MyAccountEnrollTotpMethodHandler(client: client) + case .enrollPush: return MyAccountEnrollPushMethodHandler(client: client) + case .enrollRecoveryCode: return MyAccountEnrollRecoveryCodeMethodHandler(client: client) + case .verifyOtp: return MyAccountVerifyOtpMethodHandler(client: client) + } + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let arguments = call.arguments as? [String: Any] else { + return result(FlutterError(from: .argumentsMissing)) + } + guard let accountDictionary = arguments[Account.key] as? [String: String], + let account = Account(from: accountDictionary) else { + return result(FlutterError(from: .accountMissing)) + } + guard let userAgentDictionary = arguments[UserAgent.key] as? [String: String], + let userAgent = UserAgent(from: userAgentDictionary) else { + return result(FlutterError(from: .userAgentMissing)) + } + guard let method = Method(rawValue: call.method) else { + return result(FlutterMethodNotImplemented) + } + guard let accessToken = arguments["accessToken"] as? String else { + return result(FlutterError(from: .requiredArgumentMissing("accessToken"))) + } + + let client = clientProvider(account, userAgent, accessToken) + let methodHandler = methodHandlerProvider(method, client) + + methodHandler.handle(with: arguments, callback: result) + } +} diff --git a/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountVerifyOtpMethodHandler.swift b/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountVerifyOtpMethodHandler.swift new file mode 100644 index 00000000..c261c053 --- /dev/null +++ b/auth0_flutter/darwin/Classes/MyAccountAPI/MyAccountVerifyOtpMethodHandler.swift @@ -0,0 +1,35 @@ +import Auth0 + +#if os(iOS) +import Flutter +#else +import FlutterMacOS +#endif + +struct MyAccountVerifyOtpMethodHandler: MethodHandler { + let client: MyAccount + + func handle(with arguments: [String: Any], callback: @escaping FlutterResult) { + guard let id = arguments["id"] as? String else { + return callback(FlutterError(from: .requiredArgumentMissing("id"))) + } + guard let authSession = arguments["authSession"] as? String else { + return callback(FlutterError(from: .requiredArgumentMissing("authSession"))) + } + guard let otp = arguments["otp"] as? String else { + return callback(FlutterError(from: .requiredArgumentMissing("otp"))) + } + + client + .authenticationMethods + .confirmPhoneEnrollment(id: id, authSession: authSession, otpCode: otp) + .start { + switch $0 { + case let .success(method): + callback(method.asDictionary()) + case let .failure(error): + callback(FlutterError(from: error)) + } + } + } +} diff --git a/auth0_flutter/darwin/Classes/SwiftAuth0FlutterPlugin.swift b/auth0_flutter/darwin/Classes/SwiftAuth0FlutterPlugin.swift index 49043671..74b9b37a 100644 --- a/auth0_flutter/darwin/Classes/SwiftAuth0FlutterPlugin.swift +++ b/auth0_flutter/darwin/Classes/SwiftAuth0FlutterPlugin.swift @@ -8,7 +8,8 @@ public class SwiftAuth0FlutterPlugin: NSObject, FlutterPlugin { static var handlers: [FlutterPlugin.Type] = [WebAuthHandler.self, AuthAPIHandler.self, DPoPHandler.self, - CredentialsManagerHandler.self] + CredentialsManagerHandler.self, + MyAccountHandler.self] public static func register(with registrar: FlutterPluginRegistrar) { handlers.forEach { $0.register(with: registrar) } diff --git a/auth0_flutter/example/android/strings.xml.example b/auth0_flutter/example/android/strings.xml.example new file mode 100644 index 00000000..ccd4e683 --- /dev/null +++ b/auth0_flutter/example/android/strings.xml.example @@ -0,0 +1,5 @@ + + + {DOMAIN} + {SCHEME} + diff --git a/auth0_flutter/example/ios/Tests/Mocks.swift b/auth0_flutter/example/ios/Tests/Mocks.swift index af7de97e..98c7ff56 100644 --- a/auth0_flutter/example/ios/Tests/Mocks.swift +++ b/auth0_flutter/example/ios/Tests/Mocks.swift @@ -136,6 +136,10 @@ class SpyPluginRegistrar: NSObject, FlutterPluginRegistrar { func publish(_ value: NSObject) {} + func valuePublished(byPlugin pluginKey: String) -> NSObject? { + return nil + } + func lookupKey(forAsset asset: String) -> String { return "" } @@ -147,8 +151,4 @@ class SpyPluginRegistrar: NSObject, FlutterPluginRegistrar { func addMethodCallDelegate(_ delegate: FlutterPlugin, channel: FlutterMethodChannel) { self.delegate = delegate } - - func valuePublished(byPlugin pluginKey: String) -> NSObject? { - return nil - } } diff --git a/auth0_flutter/example/ios/Tests/MyAccount/MyAccountDeleteAuthMethodMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/MyAccount/MyAccountDeleteAuthMethodMethodHandlerTests.swift new file mode 100644 index 00000000..d7419a54 --- /dev/null +++ b/auth0_flutter/example/ios/Tests/MyAccount/MyAccountDeleteAuthMethodMethodHandlerTests.swift @@ -0,0 +1,52 @@ +import XCTest +import Auth0 + +@testable import auth0_flutter + +class MyAccountDeleteAuthMethodMethodHandlerTests: XCTestCase { + var spy: SpyMyAccountAuthenticationMethods! + var sut: MyAccountDeleteAuthMethodMethodHandler! + + override func setUpWithError() throws { + spy = SpyMyAccountAuthenticationMethods() + let client = SpyMyAccount(spy: spy) + sut = MyAccountDeleteAuthMethodMethodHandler(client: client) + } + + func testProducesErrorWhenIdMissing() { + let expectation = self.expectation(description: "Missing id") + sut.handle(with: [:]) { result in + XCTAssertTrue(result is FlutterError) + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testPassesIdToSDK() { + let expectation = self.expectation(description: "ID passed to SDK") + sut.handle(with: ["id": "method-123"]) { _ in + XCTAssertEqual(self.spy.deleteIdArg, "method-123") + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesNilOnSuccess() { + let expectation = self.expectation(description: "Produced nil") + sut.handle(with: ["id": "method-123"]) { result in + XCTAssertNil(result) + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesFlutterErrorOnFailure() { + let expectation = self.expectation(description: "Produced FlutterError") + spy.deleteResult = .failure(MyAccountError(info: [:], statusCode: 404)) + sut.handle(with: ["id": "method-123"]) { result in + XCTAssertTrue(result is FlutterError) + expectation.fulfill() + } + wait(for: [expectation]) + } +} diff --git a/auth0_flutter/example/ios/Tests/MyAccount/MyAccountEnrollEmailMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/MyAccount/MyAccountEnrollEmailMethodHandlerTests.swift new file mode 100644 index 00000000..d3748fce --- /dev/null +++ b/auth0_flutter/example/ios/Tests/MyAccount/MyAccountEnrollEmailMethodHandlerTests.swift @@ -0,0 +1,56 @@ +import XCTest +import Auth0 + +@testable import auth0_flutter + +class MyAccountEnrollEmailMethodHandlerTests: XCTestCase { + var spy: SpyMyAccountAuthenticationMethods! + var sut: MyAccountEnrollEmailMethodHandler! + + override func setUpWithError() throws { + spy = SpyMyAccountAuthenticationMethods() + let client = SpyMyAccount(spy: spy) + sut = MyAccountEnrollEmailMethodHandler(client: client) + } + + func testProducesErrorWhenEmailMissing() { + let expectation = self.expectation(description: "Missing email") + sut.handle(with: [:]) { result in + XCTAssertTrue(result is FlutterError) + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testPassesEmailToSDK() { + let expectation = self.expectation(description: "Email passed to SDK") + sut.handle(with: ["email": "test@example.com"]) { _ in + XCTAssertEqual(self.spy.enrollEmailArg, "test@example.com") + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesChallengeOnSuccess() { + let expectation = self.expectation(description: "Produced challenge") + sut.handle(with: ["email": "test@example.com"]) { result in + guard let dict = result as? [String: Any?] else { + return XCTFail("Did not produce dictionary") + } + XCTAssertEqual(dict["id"] as? String, "email|test") + XCTAssertEqual(dict["auth_session"] as? String, "session123") + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesFlutterErrorOnFailure() { + let expectation = self.expectation(description: "Produced FlutterError") + spy.enrollEmailResult = .failure(MyAccountError(info: [:], statusCode: 400)) + sut.handle(with: ["email": "test@example.com"]) { result in + XCTAssertTrue(result is FlutterError) + expectation.fulfill() + } + wait(for: [expectation]) + } +} diff --git a/auth0_flutter/example/ios/Tests/MyAccount/MyAccountEnrollPhoneMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/MyAccount/MyAccountEnrollPhoneMethodHandlerTests.swift new file mode 100644 index 00000000..71060313 --- /dev/null +++ b/auth0_flutter/example/ios/Tests/MyAccount/MyAccountEnrollPhoneMethodHandlerTests.swift @@ -0,0 +1,75 @@ +import XCTest +import Auth0 + +@testable import auth0_flutter + +class MyAccountEnrollPhoneMethodHandlerTests: XCTestCase { + var spy: SpyMyAccountAuthenticationMethods! + var sut: MyAccountEnrollPhoneMethodHandler! + + override func setUpWithError() throws { + spy = SpyMyAccountAuthenticationMethods() + let client = SpyMyAccount(spy: spy) + sut = MyAccountEnrollPhoneMethodHandler(client: client) + } + + func testProducesErrorWhenPhoneNumberMissing() { + let expectation = self.expectation(description: "Missing phoneNumber") + sut.handle(with: ["type": "sms"]) { result in + XCTAssertTrue(result is FlutterError) + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesErrorWhenTypeMissing() { + let expectation = self.expectation(description: "Missing type") + sut.handle(with: ["phoneNumber": "+1234567890"]) { result in + XCTAssertTrue(result is FlutterError) + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testPassesPhoneNumberToSDK() { + let expectation = self.expectation(description: "Phone passed to SDK") + sut.handle(with: ["phoneNumber": "+1234567890", "type": "sms"]) { _ in + XCTAssertEqual(self.spy.enrollPhoneNumberArg, "+1234567890") + XCTAssertEqual(self.spy.enrollPhoneMethodArg, .sms) + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testPassesVoiceTypeToSDK() { + let expectation = self.expectation(description: "Voice type passed to SDK") + sut.handle(with: ["phoneNumber": "+1234567890", "type": "voice"]) { _ in + XCTAssertEqual(self.spy.enrollPhoneMethodArg, .voice) + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesChallengeOnSuccess() { + let expectation = self.expectation(description: "Produced challenge") + sut.handle(with: ["phoneNumber": "+1234567890", "type": "sms"]) { result in + guard let dict = result as? [String: Any?] else { + return XCTFail("Did not produce dictionary") + } + XCTAssertEqual(dict["id"] as? String, "phone|test") + XCTAssertEqual(dict["auth_session"] as? String, "session123") + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesFlutterErrorOnFailure() { + let expectation = self.expectation(description: "Produced FlutterError") + spy.enrollPhoneResult = .failure(MyAccountError(info: [:], statusCode: 400)) + sut.handle(with: ["phoneNumber": "+1234567890", "type": "sms"]) { result in + XCTAssertTrue(result is FlutterError) + expectation.fulfill() + } + wait(for: [expectation]) + } +} diff --git a/auth0_flutter/example/ios/Tests/MyAccount/MyAccountEnrollPushMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/MyAccount/MyAccountEnrollPushMethodHandlerTests.swift new file mode 100644 index 00000000..8b813520 --- /dev/null +++ b/auth0_flutter/example/ios/Tests/MyAccount/MyAccountEnrollPushMethodHandlerTests.swift @@ -0,0 +1,48 @@ +import XCTest +import Auth0 + +@testable import auth0_flutter + +class MyAccountEnrollPushMethodHandlerTests: XCTestCase { + var spy: SpyMyAccountAuthenticationMethods! + var sut: MyAccountEnrollPushMethodHandler! + + override func setUpWithError() throws { + spy = SpyMyAccountAuthenticationMethods() + let client = SpyMyAccount(spy: spy) + sut = MyAccountEnrollPushMethodHandler(client: client) + } + + func testCallsSDKMethod() { + let expectation = self.expectation(description: "Called SDK method") + sut.handle(with: [:]) { _ in + XCTAssertTrue(self.spy.calledEnrollPush) + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesChallengeWithBarcodeUri() { + let expectation = self.expectation(description: "Produced push challenge") + sut.handle(with: [:]) { result in + guard let dict = result as? [String: Any?] else { + return XCTFail("Did not produce dictionary") + } + XCTAssertEqual(dict["id"] as? String, "push|test") + XCTAssertEqual(dict["auth_session"] as? String, "session123") + XCTAssertEqual(dict["barcode_uri"] as? String, "otpauth://push/test") + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesFlutterErrorOnFailure() { + let expectation = self.expectation(description: "Produced FlutterError") + spy.enrollPushResult = .failure(MyAccountError(info: [:], statusCode: 401)) + sut.handle(with: [:]) { result in + XCTAssertTrue(result is FlutterError) + expectation.fulfill() + } + wait(for: [expectation]) + } +} diff --git a/auth0_flutter/example/ios/Tests/MyAccount/MyAccountEnrollRecoveryCodeMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/MyAccount/MyAccountEnrollRecoveryCodeMethodHandlerTests.swift new file mode 100644 index 00000000..7fb40d94 --- /dev/null +++ b/auth0_flutter/example/ios/Tests/MyAccount/MyAccountEnrollRecoveryCodeMethodHandlerTests.swift @@ -0,0 +1,48 @@ +import XCTest +import Auth0 + +@testable import auth0_flutter + +class MyAccountEnrollRecoveryCodeMethodHandlerTests: XCTestCase { + var spy: SpyMyAccountAuthenticationMethods! + var sut: MyAccountEnrollRecoveryCodeMethodHandler! + + override func setUpWithError() throws { + spy = SpyMyAccountAuthenticationMethods() + let client = SpyMyAccount(spy: spy) + sut = MyAccountEnrollRecoveryCodeMethodHandler(client: client) + } + + func testCallsSDKMethod() { + let expectation = self.expectation(description: "Called SDK method") + sut.handle(with: [:]) { _ in + XCTAssertTrue(self.spy.calledEnrollRecovery) + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesChallengeWithRecoveryCode() { + let expectation = self.expectation(description: "Produced recovery challenge") + sut.handle(with: [:]) { result in + guard let dict = result as? [String: Any?] else { + return XCTFail("Did not produce dictionary") + } + XCTAssertEqual(dict["id"] as? String, "recovery|test") + XCTAssertEqual(dict["auth_session"] as? String, "session123") + XCTAssertEqual(dict["recovery_code"] as? String, "RECOVERY123") + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesFlutterErrorOnFailure() { + let expectation = self.expectation(description: "Produced FlutterError") + spy.enrollRecoveryResult = .failure(MyAccountError(info: [:], statusCode: 401)) + sut.handle(with: [:]) { result in + XCTAssertTrue(result is FlutterError) + expectation.fulfill() + } + wait(for: [expectation]) + } +} diff --git a/auth0_flutter/example/ios/Tests/MyAccount/MyAccountEnrollTotpMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/MyAccount/MyAccountEnrollTotpMethodHandlerTests.swift new file mode 100644 index 00000000..162c85ee --- /dev/null +++ b/auth0_flutter/example/ios/Tests/MyAccount/MyAccountEnrollTotpMethodHandlerTests.swift @@ -0,0 +1,49 @@ +import XCTest +import Auth0 + +@testable import auth0_flutter + +class MyAccountEnrollTotpMethodHandlerTests: XCTestCase { + var spy: SpyMyAccountAuthenticationMethods! + var sut: MyAccountEnrollTotpMethodHandler! + + override func setUpWithError() throws { + spy = SpyMyAccountAuthenticationMethods() + let client = SpyMyAccount(spy: spy) + sut = MyAccountEnrollTotpMethodHandler(client: client) + } + + func testCallsSDKMethod() { + let expectation = self.expectation(description: "Called SDK method") + sut.handle(with: [:]) { _ in + XCTAssertTrue(self.spy.calledEnrollTOTP) + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesChallengeWithTotpFields() { + let expectation = self.expectation(description: "Produced TOTP challenge") + sut.handle(with: [:]) { result in + guard let dict = result as? [String: Any?] else { + return XCTFail("Did not produce dictionary") + } + XCTAssertEqual(dict["id"] as? String, "totp|test") + XCTAssertEqual(dict["auth_session"] as? String, "session123") + XCTAssertEqual(dict["totp_secret"] as? String, "SECRET123") + XCTAssertEqual(dict["totp_uri"] as? String, "otpauth://totp/test") + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesFlutterErrorOnFailure() { + let expectation = self.expectation(description: "Produced FlutterError") + spy.enrollTOTPResult = .failure(MyAccountError(info: [:], statusCode: 401)) + sut.handle(with: [:]) { result in + XCTAssertTrue(result is FlutterError) + expectation.fulfill() + } + wait(for: [expectation]) + } +} diff --git a/auth0_flutter/example/ios/Tests/MyAccount/MyAccountGetAuthMethodMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/MyAccount/MyAccountGetAuthMethodMethodHandlerTests.swift new file mode 100644 index 00000000..4f588fab --- /dev/null +++ b/auth0_flutter/example/ios/Tests/MyAccount/MyAccountGetAuthMethodMethodHandlerTests.swift @@ -0,0 +1,56 @@ +import XCTest +import Auth0 + +@testable import auth0_flutter + +class MyAccountGetAuthMethodMethodHandlerTests: XCTestCase { + var spy: SpyMyAccountAuthenticationMethods! + var sut: MyAccountGetAuthMethodMethodHandler! + + override func setUpWithError() throws { + spy = SpyMyAccountAuthenticationMethods() + let client = SpyMyAccount(spy: spy) + sut = MyAccountGetAuthMethodMethodHandler(client: client) + } + + func testProducesErrorWhenIdMissing() { + let expectation = self.expectation(description: "Missing id") + sut.handle(with: [:]) { result in + XCTAssertTrue(result is FlutterError) + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testPassesIdToSDK() { + let expectation = self.expectation(description: "ID passed to SDK") + sut.handle(with: ["id": "method-123"]) { _ in + XCTAssertEqual(self.spy.getAuthMethodIdArg, "method-123") + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesMethodDictionaryOnSuccess() { + let expectation = self.expectation(description: "Produced method") + sut.handle(with: ["id": "method-123"]) { result in + guard let dict = result as? [String: Any?] else { + return XCTFail("Did not produce dictionary") + } + XCTAssertEqual(dict["id"] as? String, "test-id") + XCTAssertEqual(dict["type"] as? String, "phone") + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesFlutterErrorOnFailure() { + let expectation = self.expectation(description: "Produced FlutterError") + spy.getAuthMethodResult = .failure(MyAccountError(info: [:], statusCode: 404)) + sut.handle(with: ["id": "method-123"]) { result in + XCTAssertTrue(result is FlutterError) + expectation.fulfill() + } + wait(for: [expectation]) + } +} diff --git a/auth0_flutter/example/ios/Tests/MyAccount/MyAccountGetAuthMethodsMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/MyAccount/MyAccountGetAuthMethodsMethodHandlerTests.swift new file mode 100644 index 00000000..f0dee168 --- /dev/null +++ b/auth0_flutter/example/ios/Tests/MyAccount/MyAccountGetAuthMethodsMethodHandlerTests.swift @@ -0,0 +1,47 @@ +import XCTest +import Auth0 + +@testable import auth0_flutter + +class MyAccountGetAuthMethodsMethodHandlerTests: XCTestCase { + var spy: SpyMyAccountAuthenticationMethods! + var sut: MyAccountGetAuthMethodsMethodHandler! + + override func setUpWithError() throws { + spy = SpyMyAccountAuthenticationMethods() + let client = SpyMyAccount(spy: spy) + sut = MyAccountGetAuthMethodsMethodHandler(client: client) + } + + func testCallsSDKMethod() { + let expectation = self.expectation(description: "Called SDK method") + sut.handle(with: [:]) { _ in + XCTAssertTrue(self.spy.calledGetAuthMethods) + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesEmptyArrayWhenNoMethods() { + let expectation = self.expectation(description: "Produced empty array") + spy.getAuthMethodsResult = .success([]) + sut.handle(with: [:]) { result in + guard let methods = result as? [[String: Any?]] else { + return XCTFail("Did not produce array") + } + XCTAssertEqual(methods.count, 0) + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesFlutterErrorOnFailure() { + let expectation = self.expectation(description: "Produced FlutterError") + spy.getAuthMethodsResult = .failure(MyAccountError(info: [:], statusCode: 401)) + sut.handle(with: [:]) { result in + XCTAssertTrue(result is FlutterError) + expectation.fulfill() + } + wait(for: [expectation]) + } +} diff --git a/auth0_flutter/example/ios/Tests/MyAccount/MyAccountGetFactorsMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/MyAccount/MyAccountGetFactorsMethodHandlerTests.swift new file mode 100644 index 00000000..f11db76c --- /dev/null +++ b/auth0_flutter/example/ios/Tests/MyAccount/MyAccountGetFactorsMethodHandlerTests.swift @@ -0,0 +1,47 @@ +import XCTest +import Auth0 + +@testable import auth0_flutter + +class MyAccountGetFactorsMethodHandlerTests: XCTestCase { + var spy: SpyMyAccountAuthenticationMethods! + var sut: MyAccountGetFactorsMethodHandler! + + override func setUpWithError() throws { + spy = SpyMyAccountAuthenticationMethods() + let client = SpyMyAccount(spy: spy) + sut = MyAccountGetFactorsMethodHandler(client: client) + } + + func testCallsSDKMethod() { + let expectation = self.expectation(description: "Called SDK method") + sut.handle(with: [:]) { _ in + XCTAssertTrue(self.spy.calledGetFactors) + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesEmptyArrayWhenNoFactors() { + let expectation = self.expectation(description: "Produced empty array") + spy.getFactorsResult = .success([]) + sut.handle(with: [:]) { result in + guard let factors = result as? [[String: Any]] else { + return XCTFail("Did not produce array") + } + XCTAssertEqual(factors.count, 0) + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesFlutterErrorOnFailure() { + let expectation = self.expectation(description: "Produced FlutterError") + spy.getFactorsResult = .failure(MyAccountError(info: [:], statusCode: 403)) + sut.handle(with: [:]) { result in + XCTAssertTrue(result is FlutterError) + expectation.fulfill() + } + wait(for: [expectation]) + } +} diff --git a/auth0_flutter/example/ios/Tests/MyAccount/MyAccountSpies.swift b/auth0_flutter/example/ios/Tests/MyAccount/MyAccountSpies.swift new file mode 100644 index 00000000..38288559 --- /dev/null +++ b/auth0_flutter/example/ios/Tests/MyAccount/MyAccountSpies.swift @@ -0,0 +1,197 @@ +@testable import Auth0 + +#if os(iOS) +import Flutter +#else +import FlutterMacOS +#endif + +// MARK: - Helper + +private func makeAuthMethod(id: String = "test-id", type: String = "phone", + confirmed: Bool? = nil) -> AuthenticationMethod { + AuthenticationMethod(type: type, credentialBackedUp: nil, credentialDeviceType: nil, + identityUserId: nil, keyId: nil, publicKey: nil, transports: nil, + userAgent: nil, userHandle: nil, lastPasswordReset: nil, + id: id, createdAt: "2026-01-01", usage: [], + confirmed: confirmed, name: nil, + preferredAuthenticationMethod: nil, phoneNumber: nil) +} + +// MARK: - Spy MyAccount Authentication Methods + +class SpyMyAccountAuthenticationMethods: MyAccountAuthenticationMethods { + var url: URL { mockURL } + var token: String { "test-token" } + var telemetry = Telemetry() + var logger: Logger? + + var getAuthMethodsResult: Result<[AuthenticationMethod], MyAccountError> = .success([]) + var getAuthMethodResult: Result = .success(makeAuthMethod()) + var deleteResult: Result = .success(()) + var getFactorsResult: Result<[Factor], MyAccountError> = .success([]) + var enrollPhoneResult: Result = .success( + PhoneEnrollmentChallenge(authenticationId: "phone|test", authenticationSession: "session123") + ) + var enrollEmailResult: Result = .success( + EmailEnrollmentChallenge(authenticationId: "email|test", authenticationSession: "session123") + ) + var enrollTOTPResult: Result = .success( + TOTPEnrollmentChallenge(authenticationId: "totp|test", authenticationSession: "session123", + authenticatorQRCodeURI: "otpauth://totp/test", + authenticatorManualInputCode: "SECRET123") + ) + var enrollPushResult: Result = .success( + PushEnrollmentChallenge(authenticationId: "push|test", authenticationSession: "session123", + authenticatorQRCodeURI: "otpauth://push/test", + authenticatorManualInputCode: nil) + ) + var enrollRecoveryResult: Result = .success( + RecoveryCodeEnrollmentChallenge(authenticationId: "recovery|test", + authenticationSession: "session123", + recoveryCode: "RECOVERY123") + ) + var confirmResult: Result = .success(makeAuthMethod(confirmed: true)) + + var calledGetAuthMethods = false + var calledGetAuthMethod = false + var calledDelete = false + var calledGetFactors = false + var calledEnrollPhone = false + var calledEnrollEmail = false + var calledEnrollTOTP = false + var calledEnrollPush = false + var calledEnrollRecovery = false + var calledConfirm = false + + var getAuthMethodIdArg: String? + var deleteIdArg: String? + var enrollPhoneNumberArg: String? + var enrollPhoneMethodArg: PreferredAuthenticationMethod? + var enrollEmailArg: String? + var confirmIdArg: String? + var confirmAuthSessionArg: String? + var confirmOtpArg: String? + + func getAuthenticationMethods() -> Request<[AuthenticationMethod], MyAccountError> { + calledGetAuthMethods = true + return request(getAuthMethodsResult) + } + + func getAuthenticationMethod(by id: String) -> Request { + calledGetAuthMethod = true + getAuthMethodIdArg = id + return request(getAuthMethodResult) + } + + func deleteAuthenticationMethod(by id: String) -> Request { + calledDelete = true + deleteIdArg = id + return request(deleteResult) + } + + func getFactors() -> Request<[Factor], MyAccountError> { + calledGetFactors = true + return request(getFactorsResult) + } + + func enrollPhone(phoneNumber: String, + preferredAuthenticationMethod: PreferredAuthenticationMethod?) -> Request { + calledEnrollPhone = true + enrollPhoneNumberArg = phoneNumber + enrollPhoneMethodArg = preferredAuthenticationMethod + return request(enrollPhoneResult) + } + + func enrollEmail(emailAddress: String) -> Request { + calledEnrollEmail = true + enrollEmailArg = emailAddress + return request(enrollEmailResult) + } + + func enrollTOTP() -> Request { + calledEnrollTOTP = true + return request(enrollTOTPResult) + } + + func enrollPushNotification() -> Request { + calledEnrollPush = true + return request(enrollPushResult) + } + + func enrollRecoveryCode() -> Request { + calledEnrollRecovery = true + return request(enrollRecoveryResult) + } + + func confirmPhoneEnrollment(id: String, authSession: String, + otpCode: String) -> Request { + calledConfirm = true + confirmIdArg = id + confirmAuthSessionArg = authSession + confirmOtpArg = otpCode + return request(confirmResult) + } + + @available(iOS 16.6, macOS 13.5, visionOS 1.0, *) + func passkeyEnrollmentChallenge(userIdentityId: String?, + connection: String?) -> Request { + fatalError("Not implemented in tests") + } + + @available(iOS 16.6, macOS 13.5, visionOS 1.0, *) + func enroll(passkey: NewPasskey, + challenge: PasskeyEnrollmentChallenge) -> Request { + fatalError("Not implemented in tests") + } + + func confirmTOTPEnrollment(id: String, authSession: String, + otpCode: String) -> Request { + return confirmPhoneEnrollment(id: id, authSession: authSession, otpCode: otpCode) + } + + func confirmEmailEnrollment(id: String, authSession: String, + otpCode: String) -> Request { + return confirmPhoneEnrollment(id: id, authSession: authSession, otpCode: otpCode) + } + + func confirmPushNotificationEnrollment(id: String, + authSession: String) -> Request { + return request(confirmResult) + } + + func confirmRecoveryCodeEnrollment(id: String, + authSession: String) -> Request { + return request(confirmResult) + } +} + +// MARK: - Spy MyAccount + +class SpyMyAccount: MyAccount { + static var apiVersion: String { "v1" } + var url: URL { mockURL } + var token: String { "test-token" } + var telemetry = Telemetry() + var logger: Logger? + + let spy: SpyMyAccountAuthenticationMethods + var authenticationMethods: MyAccountAuthenticationMethods { spy } + + init(spy: SpyMyAccountAuthenticationMethods = SpyMyAccountAuthenticationMethods()) { + self.spy = spy + } +} + +// MARK: - Request Helper + +private extension SpyMyAccountAuthenticationMethods { + func request(_ result: Result) -> Request { + Request(session: mockURLSession, + url: url, + method: "", + handle: { _, callback in callback(result) }, + logger: nil, + telemetry: telemetry) + } +} diff --git a/auth0_flutter/example/ios/Tests/MyAccount/MyAccountVerifyOtpMethodHandlerTests.swift b/auth0_flutter/example/ios/Tests/MyAccount/MyAccountVerifyOtpMethodHandlerTests.swift new file mode 100644 index 00000000..c4e4eb17 --- /dev/null +++ b/auth0_flutter/example/ios/Tests/MyAccount/MyAccountVerifyOtpMethodHandlerTests.swift @@ -0,0 +1,76 @@ +import XCTest +import Auth0 + +@testable import auth0_flutter + +class MyAccountVerifyOtpMethodHandlerTests: XCTestCase { + var spy: SpyMyAccountAuthenticationMethods! + var sut: MyAccountVerifyOtpMethodHandler! + + override func setUpWithError() throws { + spy = SpyMyAccountAuthenticationMethods() + let client = SpyMyAccount(spy: spy) + sut = MyAccountVerifyOtpMethodHandler(client: client) + } + + func testProducesErrorWhenIdMissing() { + let expectation = self.expectation(description: "Missing id") + sut.handle(with: ["authSession": "session", "otp": "123456"]) { result in + XCTAssertTrue(result is FlutterError) + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesErrorWhenAuthSessionMissing() { + let expectation = self.expectation(description: "Missing authSession") + sut.handle(with: ["id": "test-id", "otp": "123456"]) { result in + XCTAssertTrue(result is FlutterError) + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesErrorWhenOtpMissing() { + let expectation = self.expectation(description: "Missing otp") + sut.handle(with: ["id": "test-id", "authSession": "session"]) { result in + XCTAssertTrue(result is FlutterError) + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testPassesArgumentsToSDK() { + let expectation = self.expectation(description: "Arguments passed to SDK") + sut.handle(with: ["id": "test-id", "authSession": "session123", "otp": "654321"]) { _ in + XCTAssertEqual(self.spy.confirmIdArg, "test-id") + XCTAssertEqual(self.spy.confirmAuthSessionArg, "session123") + XCTAssertEqual(self.spy.confirmOtpArg, "654321") + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesDictionaryOnSuccess() { + let expectation = self.expectation(description: "Produced dictionary") + sut.handle(with: ["id": "test-id", "authSession": "session", "otp": "123456"]) { result in + guard let dict = result as? [String: Any?] else { + return XCTFail("Did not produce dictionary") + } + XCTAssertEqual(dict["id"] as? String, "test-id") + XCTAssertEqual(dict["confirmed"] as? Bool, true) + expectation.fulfill() + } + wait(for: [expectation]) + } + + func testProducesFlutterErrorOnFailure() { + let expectation = self.expectation(description: "Produced FlutterError") + spy.confirmResult = .failure(MyAccountError(info: [:], statusCode: 403)) + sut.handle(with: ["id": "test-id", "authSession": "session", "otp": "wrong"]) { result in + XCTAssertTrue(result is FlutterError) + expectation.fulfill() + } + wait(for: [expectation]) + } +} diff --git a/auth0_flutter/example/lib/my_account_card.dart b/auth0_flutter/example/lib/my_account_card.dart new file mode 100644 index 00000000..642cec7c --- /dev/null +++ b/auth0_flutter/example/lib/my_account_card.dart @@ -0,0 +1,562 @@ +import 'package:auth0_flutter/auth0_flutter.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class MyAccountCard extends StatefulWidget { + final Auth0 auth0; + final String domain; + final String? scheme; + final void Function(String output) onOutput; + + const MyAccountCard({ + required this.auth0, + required this.domain, + required this.onOutput, + this.scheme, + final Key? key, + }) : super(key: key); + + @override + State createState() => _MyAccountCardState(); +} + +class _MyAccountCardState extends State { + String? _accessToken; + MyAccountApi? _myAccount; + bool _isExpanded = false; + + final _phoneController = TextEditingController(text: '+1234567890'); + final _emailController = TextEditingController(text: 'test@example.com'); + + String? _lastEnrollmentId; + String? _lastAuthSession; + + final _otpController = TextEditingController(); + + @override + void dispose() { + _phoneController.dispose(); + _emailController.dispose(); + _otpController.dispose(); + super.dispose(); + } + + void _log(final String message) { + debugPrint('[MyAccount] $message'); + widget.onOutput(message); + } + + Future _loginForMyAccount() async { + try { + _log('Logging in with audience: ' + 'https://${widget.domain}/me/ ...'); + + final webAuth = widget.auth0.webAuthentication( + scheme: widget.scheme, + ); + final result = await webAuth.login( + audience: 'https://${widget.domain}/me/', + scopes: { + 'openid', + 'profile', + 'email', + 'offline_access', + 'read:me:authentication_methods', + 'create:me:authentication_methods', + 'delete:me:authentication_methods', + 'read:me:enrollments', + 'read:me:factors', + }, + ); + + setState(() { + _accessToken = result.accessToken; + _myAccount = widget.auth0.myAccount(accessToken: _accessToken!); + }); + + _log('Login successful!\n' + 'Access Token: ${_accessToken!.substring(0, 30)}...\n' + 'Scopes obtained. Ready for My Account API calls.'); + } catch (e) { + _log('Login Error: $e'); + } + } + + Future _getAuthMethods() async { + if (_myAccount == null) { + _log('Error: Not logged in. Login first.'); + return; + } + try { + _log('Calling getAuthenticationMethods()...'); + final methods = await _myAccount!.getAuthenticationMethods(); + if (methods.isEmpty) { + _log('No authentication methods enrolled.'); + return; + } + final output = StringBuffer( + 'Authentication Methods (${methods.length}):\n'); + for (final m in methods) { + output.writeln('---'); + output.writeln(' ID: ${m.id}'); + output.writeln(' Type: ${m.type}'); + if (m.name != null) output.writeln(' Name: ${m.name}'); + if (m.phoneNumber != null) output.writeln(' Phone: ${m.phoneNumber}'); + if (m.email != null) output.writeln(' Email: ${m.email}'); + if (m.createdAt != null) output.writeln(' Created: ${m.createdAt}'); + if (m.lastAuthAt != null) { + output.writeln(' Last Auth: ${m.lastAuthAt}'); + } + } + _log(output.toString()); + } on MyAccountException catch (e) { + _log('MyAccountException: [${e.statusCode}] ${e.code} - ${e.message}'); + } catch (e) { + _log('Error: $e'); + } + } + + Future _getFactors() async { + if (_myAccount == null) { + _log('Error: Not logged in. Login first.'); + return; + } + try { + _log('Calling getFactors()...'); + final factors = await _myAccount!.getFactors(); + if (factors.isEmpty) { + _log('No factors available.'); + return; + } + final output = StringBuffer('Available Factors (${factors.length}):\n'); + for (final f in factors) { + output.writeln(' - ${f.type}: usage=${f.usage?.join(", ") ?? "none"}'); + } + _log(output.toString()); + } on MyAccountException catch (e) { + _log('MyAccountException: [${e.statusCode}] ${e.code} - ${e.message}'); + } catch (e) { + _log('Error: $e'); + } + } + + Future _enrollPhone() async { + if (_myAccount == null) { + _log('Error: Not logged in. Login first.'); + return; + } + try { + final phone = _phoneController.text.trim(); + _log('Calling enrollPhone(phone: $phone, type: sms)...'); + final challenge = await _myAccount!.enrollPhone( + phoneNumber: phone, + type: PhoneType.sms, + ); + setState(() { + _lastEnrollmentId = challenge.id; + _lastAuthSession = challenge.authSession; + }); + _log('Enrollment Challenge:\n' + ' ID: ${challenge.id}\n' + ' Auth Session: ${challenge.authSession}\n' + ' Recovery Code: ${challenge.recoveryCode ?? "N/A"}\n' + '\n** Enter OTP received via SMS and tap Verify OTP **'); + } on MyAccountException catch (e) { + _log('MyAccountException: [${e.statusCode}] ${e.code} - ${e.message}'); + } catch (e) { + _log('Error: $e'); + } + } + + Future _enrollEmail() async { + if (_myAccount == null) { + _log('Error: Not logged in. Login first.'); + return; + } + try { + final email = _emailController.text.trim(); + _log('Calling enrollEmail(email: $email)...'); + final challenge = await _myAccount!.enrollEmail(email: email); + _log('Enrollment SUCCESS - ' + 'ID: ${challenge.id} | ' + 'AuthSession: ${challenge.authSession}'); + setState(() { + _lastEnrollmentId = challenge.id; + _lastAuthSession = challenge.authSession; + }); + _log('** Enter OTP received via Email and tap Verify OTP **'); + } on MyAccountException catch (e) { + _log('MyAccountException: [${e.statusCode}] ${e.code} - ${e.message}'); + } catch (e) { + _log('Error: $e'); + } + } + + Future _enrollTotp() async { + if (_myAccount == null) { + _log('Error: Not logged in. Login first.'); + return; + } + try { + _log('Calling enrollTotp()...'); + final challenge = await _myAccount!.enrollTotp(); + setState(() { + _lastEnrollmentId = challenge.id; + _lastAuthSession = challenge.authSession; + }); + _log('TOTP Enrollment Challenge:\n' + ' ID: ${challenge.id}\n' + ' Auth Session: ${challenge.authSession}\n' + ' TOTP Secret: ${challenge.totpSecret ?? "N/A"}\n' + ' TOTP URI: ${challenge.totpUri ?? "N/A"}\n' + ' Barcode URI: ${challenge.barcodeUri ?? "N/A"}\n' + ' Recovery Code: ${challenge.recoveryCode ?? "N/A"}\n' + '\n** Add secret to authenticator app, enter code, ' + 'tap Verify OTP **'); + } on MyAccountException catch (e) { + _log('MyAccountException: [${e.statusCode}] ${e.code} - ${e.message}'); + } catch (e) { + _log('Error: $e'); + } + } + + Future _enrollPush() async { + if (_myAccount == null) { + _log('Error: Not logged in. Login first.'); + return; + } + try { + _log('Calling enrollPush()...'); + final challenge = await _myAccount!.enrollPush(); + setState(() { + _lastEnrollmentId = challenge.id; + _lastAuthSession = challenge.authSession; + }); + _log('Push Enrollment Challenge:\n' + ' ID: ${challenge.id}\n' + ' Auth Session: ${challenge.authSession}\n' + ' Barcode URI: ${challenge.barcodeUri ?? "N/A"}\n' + ' Recovery Code: ${challenge.recoveryCode ?? "N/A"}'); + } on MyAccountException catch (e) { + _log('MyAccountException: [${e.statusCode}] ${e.code} - ${e.message}'); + } catch (e) { + _log('Error: $e'); + } + } + + Future _enrollRecoveryCode() async { + if (_myAccount == null) { + _log('Error: Not logged in. Login first.'); + return; + } + try { + _log('Calling enrollRecoveryCode()...'); + final challenge = await _myAccount!.enrollRecoveryCode(); + setState(() { + _lastEnrollmentId = challenge.id; + _lastAuthSession = challenge.authSession; + }); + _log('Recovery Code Enrollment:\n' + ' ID: ${challenge.id}\n' + ' Auth Session: ${challenge.authSession}\n' + ' Recovery Code: ${challenge.recoveryCode ?? "N/A"}'); + } on MyAccountException catch (e) { + _log('MyAccountException: [${e.statusCode}] ${e.code} - ${e.message}'); + } catch (e) { + _log('Error: $e'); + } + } + + Future _verifyOtp() async { + if (_myAccount == null) { + _log('Error: Not logged in. Login first.'); + return; + } + if (_lastEnrollmentId == null || _lastAuthSession == null) { + _log('Error: No pending enrollment. Enroll a factor first.'); + return; + } + final otp = _otpController.text.trim(); + if (otp.isEmpty) { + _log('Error: Enter OTP code.'); + return; + } + try { + _log('Calling verifyOtp(\n' + ' id: $_lastEnrollmentId,\n' + ' authSession: $_lastAuthSession,\n' + ' otp: $otp)...'); + final method = await _myAccount!.verifyOtp( + id: _lastEnrollmentId!, + authSession: _lastAuthSession!, + otp: otp, + ); + _log('OTP Verified Successfully! Enrollment complete.\n' + ' Method: ${method.type} (id: ${method.id})'); + setState(() { + _lastEnrollmentId = null; + _lastAuthSession = null; + _otpController.clear(); + }); + } on MyAccountException catch (e) { + _log('MyAccountException: [${e.statusCode}] ${e.code} - ${e.message}'); + } catch (e) { + _log('Error: $e'); + } + } + + Future _logout() async { + try { + _log('Logging out...'); + final webAuth = widget.auth0.webAuthentication( + scheme: widget.scheme, + ); + await webAuth.logout(); + setState(() { + _accessToken = null; + _myAccount = null; + _lastEnrollmentId = null; + _lastAuthSession = null; + _otpController.clear(); + }); + _log('Logged out successfully.'); + } catch (e) { + _log('Logout Error: $e'); + } + } + + Future _deleteLastMethod() async { + if (_myAccount == null) { + _log('Error: Not logged in. Login first.'); + return; + } + try { + _log('Fetching methods to delete the last one...'); + final methods = await _myAccount!.getAuthenticationMethods(); + if (methods.isEmpty) { + _log('No methods to delete.'); + return; + } + final target = methods.last; + _log('Deleting method: ${target.id} (${target.type})...'); + await _myAccount!.deleteAuthenticationMethod(id: target.id); + _log('Deleted successfully: ${target.id}'); + } on MyAccountException catch (e) { + _log('MyAccountException: [${e.statusCode}] ${e.code} - ${e.message}'); + } catch (e) { + _log('Error: $e'); + } + } + + @override + Widget build(final BuildContext context) { + if (kIsWeb) { + return const SizedBox.shrink(); + } + + return Card( + margin: const EdgeInsets.symmetric(vertical: 8), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InkWell( + onTap: () => setState(() => _isExpanded = !_isExpanded), + child: Row( + children: [ + Icon( + _isExpanded + ? Icons.keyboard_arrow_down + : Icons.keyboard_arrow_right, + color: Colors.indigo, + ), + const SizedBox(width: 8), + const Text( + 'My Account API (MFA)', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.indigo, + ), + ), + const Spacer(), + if (_accessToken != null) + const Icon(Icons.check_circle, + color: Colors.green, size: 20), + ], + ), + ), + if (_isExpanded) ...[ + const Divider(), + if (_accessToken == null) ...[ + const Text( + 'Login with My Account audience to get started:', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + const SizedBox(height: 8), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + foregroundColor: Colors.white, + ), + onPressed: _loginForMyAccount, + icon: const Icon(Icons.login), + label: const Text('Login for My Account'), + ), + ] else ...[ + const Text( + 'Read Operations', + style: TextStyle( + fontSize: 13, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 6), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _buildButton('Get Auth Methods', _getAuthMethods, + Icons.list), + _buildButton( + 'Get Factors', _getFactors, Icons.security), + ], + ), + const SizedBox(height: 12), + const Text( + 'Enroll', + style: TextStyle( + fontSize: 13, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 6), + Row( + children: [ + Expanded( + child: TextField( + controller: _phoneController, + decoration: const InputDecoration( + labelText: 'Phone', + isDense: true, + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 8), + _buildSmallButton('Enroll', _enrollPhone), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextField( + controller: _emailController, + decoration: const InputDecoration( + labelText: 'Email', + isDense: true, + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 8), + _buildSmallButton('Enroll', _enrollEmail), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _buildButton('Enroll TOTP', _enrollTotp, + Icons.qr_code), + _buildButton('Enroll Push', _enrollPush, + Icons.notifications), + _buildButton('Enroll Recovery', _enrollRecoveryCode, + Icons.restore), + ], + ), + if (_lastEnrollmentId != null) ...[ + const SizedBox(height: 12), + const Text( + 'Verify Enrollment', + style: TextStyle( + fontSize: 13, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 6), + Row( + children: [ + Expanded( + child: TextField( + controller: _otpController, + decoration: const InputDecoration( + labelText: 'OTP Code', + isDense: true, + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + ), + ), + const SizedBox(width: 8), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + ), + onPressed: _verifyOtp, + child: const Text('Verify OTP'), + ), + ], + ), + ], + const SizedBox(height: 12), + const Text( + 'Delete', + style: TextStyle( + fontSize: 13, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 6), + _buildButton('Delete Last Method', _deleteLastMethod, + Icons.delete, color: Colors.red), + const SizedBox(height: 12), + const Text( + 'Session', + style: TextStyle( + fontSize: 13, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 6), + _buildButton('Logout', _logout, + Icons.logout, color: Colors.orange), + ], + ], + ], + ), + ), + ); + } + + Widget _buildButton( + final String label, final VoidCallback onPressed, final IconData icon, + {final Color color = Colors.indigo}) { + return ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: color, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + onPressed: onPressed, + icon: Icon(icon, size: 16), + label: Text(label, style: const TextStyle(fontSize: 12)), + ); + } + + Widget _buildSmallButton(final String label, final VoidCallback onPressed) { + return ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + onPressed: onPressed, + child: Text(label, style: const TextStyle(fontSize: 12)), + ); + } +} diff --git a/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountDeleteAuthMethodMethodHandler.swift b/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountDeleteAuthMethodMethodHandler.swift new file mode 120000 index 00000000..2e3e8b4e --- /dev/null +++ b/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountDeleteAuthMethodMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MyAccountAPI/MyAccountDeleteAuthMethodMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountEnrollEmailMethodHandler.swift b/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountEnrollEmailMethodHandler.swift new file mode 120000 index 00000000..daf0f0fe --- /dev/null +++ b/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountEnrollEmailMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MyAccountAPI/MyAccountEnrollEmailMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountEnrollPhoneMethodHandler.swift b/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountEnrollPhoneMethodHandler.swift new file mode 120000 index 00000000..d0505ff0 --- /dev/null +++ b/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountEnrollPhoneMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MyAccountAPI/MyAccountEnrollPhoneMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountEnrollPushMethodHandler.swift b/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountEnrollPushMethodHandler.swift new file mode 120000 index 00000000..75c6bbb1 --- /dev/null +++ b/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountEnrollPushMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MyAccountAPI/MyAccountEnrollPushMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountEnrollRecoveryCodeMethodHandler.swift b/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountEnrollRecoveryCodeMethodHandler.swift new file mode 120000 index 00000000..a0823fa5 --- /dev/null +++ b/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountEnrollRecoveryCodeMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MyAccountAPI/MyAccountEnrollRecoveryCodeMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountEnrollTotpMethodHandler.swift b/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountEnrollTotpMethodHandler.swift new file mode 120000 index 00000000..4728fd48 --- /dev/null +++ b/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountEnrollTotpMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MyAccountAPI/MyAccountEnrollTotpMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountExtensions.swift b/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountExtensions.swift new file mode 120000 index 00000000..69a6d0a9 --- /dev/null +++ b/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountExtensions.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MyAccountAPI/MyAccountExtensions.swift \ No newline at end of file diff --git a/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountGetAuthMethodMethodHandler.swift b/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountGetAuthMethodMethodHandler.swift new file mode 120000 index 00000000..e1881a28 --- /dev/null +++ b/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountGetAuthMethodMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MyAccountAPI/MyAccountGetAuthMethodMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountGetAuthMethodsMethodHandler.swift b/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountGetAuthMethodsMethodHandler.swift new file mode 120000 index 00000000..3038cdde --- /dev/null +++ b/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountGetAuthMethodsMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MyAccountAPI/MyAccountGetAuthMethodsMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountGetFactorsMethodHandler.swift b/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountGetFactorsMethodHandler.swift new file mode 120000 index 00000000..57eb6990 --- /dev/null +++ b/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountGetFactorsMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MyAccountAPI/MyAccountGetFactorsMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountHandler.swift b/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountHandler.swift new file mode 120000 index 00000000..db33d385 --- /dev/null +++ b/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MyAccountAPI/MyAccountHandler.swift \ No newline at end of file diff --git a/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountVerifyOtpMethodHandler.swift b/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountVerifyOtpMethodHandler.swift new file mode 120000 index 00000000..25a45bc2 --- /dev/null +++ b/auth0_flutter/ios/Classes/MyAccountAPI/MyAccountVerifyOtpMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MyAccountAPI/MyAccountVerifyOtpMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/lib/auth0_flutter.dart b/auth0_flutter/lib/auth0_flutter.dart index 5490f6ee..8976d6aa 100644 --- a/auth0_flutter/lib/auth0_flutter.dart +++ b/auth0_flutter/lib/auth0_flutter.dart @@ -3,29 +3,36 @@ import 'package:auth0_flutter_platform_interface/auth0_flutter_platform_interfac import 'src/desktop/windows_web_authentication.dart'; import 'src/mobile/authentication_api.dart'; import 'src/mobile/credentials_manager.dart'; +import 'src/mobile/my_account_api.dart'; import 'src/mobile/web_authentication.dart'; import 'src/version.dart'; export 'package:auth0_flutter_platform_interface/auth0_flutter_platform_interface.dart' show - WebAuthenticationException, + AuthenticationMethod, ApiException, + ChallengeType, + Credentials, + CredentialsManagerException, + DatabaseUser, + EnrollmentChallenge, + Factor, IdTokenValidationConfig, + LocalAuthentication, + LocalAuthenticationLevel, + MyAccountException, + PasswordlessType, + PhoneType, SafariViewController, SafariViewControllerPresentationStyle, - Credentials, SSOCredentials, UserProfile, - DatabaseUser, - ChallengeType, - CredentialsManagerException, - PasswordlessType, - LocalAuthentication, - LocalAuthenticationLevel; + WebAuthenticationException; export 'src/desktop/windows_web_authentication.dart'; export 'src/mobile/authentication_api.dart'; export 'src/mobile/credentials_manager.dart'; +export 'src/mobile/my_account_api.dart'; export 'src/mobile/web_authentication.dart'; /// Primary interface for interacting with Auth0 using web authentication, @@ -127,6 +134,26 @@ class Auth0 { WindowsWebAuthentication windowsWebAuthentication() => WindowsWebAuthentication(_account, _userAgent); + /// Creates an instance of [MyAccountApi] for managing the user's + /// authentication methods (MFA factors) via the + /// [My Account API](https://auth0.com/docs/manage-users/my-account-api). + /// + /// Requires an [accessToken] with the appropriate `me` scopes and + /// audience `https://{domain}/me/`. + /// + /// **Note:** Only supported on mobile platforms (Android/iOS). Web is not + /// supported. + /// + /// Usage example: + /// + /// ```dart + /// final auth0 = Auth0('DOMAIN', 'CLIENT_ID'); + /// final myAccount = auth0.myAccount(accessToken: 'ACCESS_TOKEN'); + /// final methods = await myAccount.getAuthenticationMethods(); + /// ``` + MyAccountApi myAccount({required final String accessToken}) => + MyAccountApi(_account, _userAgent, accessToken); + /// Generates DPoP (Demonstrating Proof-of-Possession) headers for making /// authenticated API calls with DPoP-bound tokens. /// diff --git a/auth0_flutter/lib/src/mobile/my_account_api.dart b/auth0_flutter/lib/src/mobile/my_account_api.dart new file mode 100644 index 00000000..03d70604 --- /dev/null +++ b/auth0_flutter/lib/src/mobile/my_account_api.dart @@ -0,0 +1,233 @@ +import 'package:auth0_flutter_platform_interface/auth0_flutter_platform_interface.dart'; + +/// An interface for interacting with the +/// [Auth0 My Account API](https://auth0.com/docs/manage-users/my-account-api). +/// +/// This class enables end-users to self-manage their authentication methods +/// (MFA factors) without requiring Management API tokens. +/// +/// It is not intended for you to instantiate this class yourself, as an +/// instance of it is available via `Auth0.myAccount(accessToken:)`. +/// +/// **Note:** This API is only supported on mobile platforms (Android/iOS). +/// Web is not supported. +/// +/// Usage example: +/// +/// ```dart +/// final auth0 = Auth0('DOMAIN', 'CLIENT_ID'); +/// final myAccount = auth0.myAccount(accessToken: 'ACCESS_TOKEN'); +/// +/// final methods = await myAccount.getAuthenticationMethods(); +/// ``` +class MyAccountApi { + final Account _account; + final UserAgent _userAgent; + final String _accessToken; + + MyAccountApi(this._account, this._userAgent, this._accessToken); + + /// Lists all authentication methods enrolled by the user. + /// + /// Requires an access token with the `read:me:authentication_methods` scope + /// and audience `https://{domain}/me/`. + /// + /// ## Usage example + /// + /// ```dart + /// final methods = await myAccount.getAuthenticationMethods(); + /// ``` + Future> getAuthenticationMethods() => + Auth0FlutterMyAccountPlatform.instance.getAuthenticationMethods( + _createApiRequest(MyAccountGetAuthMethodsOptions( + accessToken: _accessToken))); + + /// Gets a specific authentication method by [id]. + /// + /// Requires an access token with the `read:me:authentication_methods` scope + /// and audience `https://{domain}/me/`. + /// + /// ## Usage example + /// + /// ```dart + /// final method = await myAccount.getAuthenticationMethod( + /// id: 'auth_method_id', + /// ); + /// ``` + Future getAuthenticationMethod( + {required final String id}) => + Auth0FlutterMyAccountPlatform.instance.getAuthenticationMethod( + _createApiRequest(MyAccountGetAuthMethodOptions( + accessToken: _accessToken, id: id))); + + /// Deletes an authentication method by [id]. + /// + /// Requires an access token with the `delete:me:authentication_methods` + /// scope and audience `https://{domain}/me/`. + /// + /// ## Usage example + /// + /// ```dart + /// await myAccount.deleteAuthenticationMethod(id: 'auth_method_id'); + /// ``` + Future deleteAuthenticationMethod({required final String id}) => + Auth0FlutterMyAccountPlatform.instance.deleteAuthenticationMethod( + _createApiRequest(MyAccountDeleteAuthMethodOptions( + accessToken: _accessToken, id: id))); + + /// Lists the factors available for enrollment on the tenant. + /// + /// Requires an access token with the `read:me:factors` scope + /// and audience `https://{domain}/me/`. + /// + /// ## Usage example + /// + /// ```dart + /// final factors = await myAccount.getFactors(); + /// ``` + Future> getFactors() => + Auth0FlutterMyAccountPlatform.instance.getFactors(_createApiRequest( + MyAccountGetFactorsOptions(accessToken: _accessToken))); + + /// Initiates phone enrollment with the specified [phoneNumber] and [type]. + /// + /// Returns an [EnrollmentChallenge] containing the enrollment `id` and + /// `authSession` needed to complete verification via [verifyOtp]. + /// + /// Requires an access token with the `create:me:authentication_methods` + /// scope and audience `https://{domain}/me/`. + /// + /// ## Usage example + /// + /// ```dart + /// final challenge = await myAccount.enrollPhone( + /// phoneNumber: '+1234567890', + /// type: PhoneType.sms, + /// ); + /// ``` + Future enrollPhone({ + required final String phoneNumber, + required final PhoneType type, + }) => + Auth0FlutterMyAccountPlatform.instance.enrollPhone(_createApiRequest( + MyAccountEnrollPhoneOptions( + accessToken: _accessToken, + phoneNumber: phoneNumber, + type: type))); + + /// Initiates email enrollment with the specified [email] address. + /// + /// Returns an [EnrollmentChallenge] containing the enrollment `id` and + /// `authSession` needed to complete verification via [verifyOtp]. + /// + /// Requires an access token with the `create:me:authentication_methods` + /// scope and audience `https://{domain}/me/`. + /// + /// ## Usage example + /// + /// ```dart + /// final challenge = await myAccount.enrollEmail( + /// email: 'user@example.com', + /// ); + /// ``` + Future enrollEmail({required final String email}) => + Auth0FlutterMyAccountPlatform.instance.enrollEmail(_createApiRequest( + MyAccountEnrollEmailOptions( + accessToken: _accessToken, email: email))); + + /// Initiates TOTP (authenticator app) enrollment. + /// + /// Returns an [EnrollmentChallenge] containing the `totpSecret` and + /// `totpUri` for the authenticator app, along with `id` and `authSession` + /// needed to complete verification via [verifyOtp]. + /// + /// Requires an access token with the `create:me:authentication_methods` + /// scope and audience `https://{domain}/me/`. + /// + /// ## Usage example + /// + /// ```dart + /// final challenge = await myAccount.enrollTotp(); + /// // Use challenge.totpUri to display a QR code + /// ``` + Future enrollTotp() => + Auth0FlutterMyAccountPlatform.instance.enrollTotp( + _createApiRequest(MyAccountEnrollTotpOptions( + accessToken: _accessToken))); + + /// Initiates push notification enrollment. + /// + /// Returns an [EnrollmentChallenge] containing the enrollment `id` and + /// `authSession` needed to complete the enrollment. + /// + /// Requires an access token with the `create:me:authentication_methods` + /// scope and audience `https://{domain}/me/`. + /// + /// ## Usage example + /// + /// ```dart + /// final challenge = await myAccount.enrollPush(); + /// ``` + Future enrollPush() => + Auth0FlutterMyAccountPlatform.instance.enrollPush( + _createApiRequest(MyAccountEnrollPushOptions( + accessToken: _accessToken))); + + /// Initiates recovery code enrollment. + /// + /// Returns an [EnrollmentChallenge] containing the `recoveryCode`. + /// + /// Requires an access token with the `create:me:authentication_methods` + /// scope and audience `https://{domain}/me/`. + /// + /// ## Usage example + /// + /// ```dart + /// final challenge = await myAccount.enrollRecoveryCode(); + /// // Store challenge.recoveryCode securely + /// ``` + Future enrollRecoveryCode() => + Auth0FlutterMyAccountPlatform.instance.enrollRecoveryCode( + _createApiRequest(MyAccountEnrollRecoveryCodeOptions( + accessToken: _accessToken))); + + /// Verifies an enrollment using a one-time password [otp]. + /// + /// Use this after calling an enrollment method (e.g., [enrollPhone], + /// [enrollEmail], [enrollTotp]) to confirm the enrollment. + /// + /// Returns the confirmed [AuthenticationMethod] after successful + /// verification. + /// + /// [id] and [authSession] are obtained from the [EnrollmentChallenge] + /// returned by the enrollment method. + /// + /// Requires an access token with the `create:me:authentication_methods` + /// scope and audience `https://{domain}/me/`. + /// + /// ## Usage example + /// + /// ```dart + /// final method = await myAccount.verifyOtp( + /// id: challenge.id, + /// authSession: challenge.authSession, + /// otp: '123456', + /// ); + /// ``` + Future verifyOtp({ + required final String id, + required final String authSession, + required final String otp, + }) => + Auth0FlutterMyAccountPlatform.instance.verifyOtp(_createApiRequest( + MyAccountVerifyOtpOptions( + accessToken: _accessToken, + id: id, + authSession: authSession, + otp: otp))); + + ApiRequest _createApiRequest( + final TOptions options) => + ApiRequest( + account: _account, options: options, userAgent: _userAgent); +} diff --git a/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountDeleteAuthMethodMethodHandler.swift b/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountDeleteAuthMethodMethodHandler.swift new file mode 120000 index 00000000..2e3e8b4e --- /dev/null +++ b/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountDeleteAuthMethodMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MyAccountAPI/MyAccountDeleteAuthMethodMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountEnrollEmailMethodHandler.swift b/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountEnrollEmailMethodHandler.swift new file mode 120000 index 00000000..daf0f0fe --- /dev/null +++ b/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountEnrollEmailMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MyAccountAPI/MyAccountEnrollEmailMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountEnrollPhoneMethodHandler.swift b/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountEnrollPhoneMethodHandler.swift new file mode 120000 index 00000000..d0505ff0 --- /dev/null +++ b/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountEnrollPhoneMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MyAccountAPI/MyAccountEnrollPhoneMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountEnrollPushMethodHandler.swift b/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountEnrollPushMethodHandler.swift new file mode 120000 index 00000000..75c6bbb1 --- /dev/null +++ b/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountEnrollPushMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MyAccountAPI/MyAccountEnrollPushMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountEnrollRecoveryCodeMethodHandler.swift b/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountEnrollRecoveryCodeMethodHandler.swift new file mode 120000 index 00000000..a0823fa5 --- /dev/null +++ b/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountEnrollRecoveryCodeMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MyAccountAPI/MyAccountEnrollRecoveryCodeMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountEnrollTotpMethodHandler.swift b/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountEnrollTotpMethodHandler.swift new file mode 120000 index 00000000..4728fd48 --- /dev/null +++ b/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountEnrollTotpMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MyAccountAPI/MyAccountEnrollTotpMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountExtensions.swift b/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountExtensions.swift new file mode 120000 index 00000000..69a6d0a9 --- /dev/null +++ b/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountExtensions.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MyAccountAPI/MyAccountExtensions.swift \ No newline at end of file diff --git a/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountGetAuthMethodMethodHandler.swift b/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountGetAuthMethodMethodHandler.swift new file mode 120000 index 00000000..e1881a28 --- /dev/null +++ b/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountGetAuthMethodMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MyAccountAPI/MyAccountGetAuthMethodMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountGetAuthMethodsMethodHandler.swift b/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountGetAuthMethodsMethodHandler.swift new file mode 120000 index 00000000..3038cdde --- /dev/null +++ b/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountGetAuthMethodsMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MyAccountAPI/MyAccountGetAuthMethodsMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountGetFactorsMethodHandler.swift b/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountGetFactorsMethodHandler.swift new file mode 120000 index 00000000..57eb6990 --- /dev/null +++ b/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountGetFactorsMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MyAccountAPI/MyAccountGetFactorsMethodHandler.swift \ No newline at end of file diff --git a/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountHandler.swift b/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountHandler.swift new file mode 120000 index 00000000..db33d385 --- /dev/null +++ b/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MyAccountAPI/MyAccountHandler.swift \ No newline at end of file diff --git a/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountVerifyOtpMethodHandler.swift b/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountVerifyOtpMethodHandler.swift new file mode 120000 index 00000000..25a45bc2 --- /dev/null +++ b/auth0_flutter/macos/Classes/MyAccountAPI/MyAccountVerifyOtpMethodHandler.swift @@ -0,0 +1 @@ +../../../darwin/Classes/MyAccountAPI/MyAccountVerifyOtpMethodHandler.swift \ No newline at end of file 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 a378522e..049a85e5 100644 --- a/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart +++ b/auth0_flutter_platform_interface/lib/auth0_flutter_platform_interface.dart @@ -39,6 +39,23 @@ export 'src/login_options.dart'; export 'src/method_channel_auth0_flutter_auth.dart'; export 'src/method_channel_auth0_flutter_dpop.dart'; export 'src/method_channel_auth0_flutter_web_auth.dart'; +export 'src/myaccount/auth0_flutter_my_account_platform.dart'; +export 'src/myaccount/authentication_method.dart'; +export 'src/myaccount/enrollment_challenge.dart'; +export 'src/myaccount/factor.dart'; +export 'src/myaccount/method_channel_auth0_flutter_my_account.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_phone_options.dart'; +export 'src/myaccount/my_account_enroll_push_options.dart'; +export 'src/myaccount/my_account_enroll_recovery_code_options.dart'; +export 'src/myaccount/my_account_enroll_totp_options.dart'; +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_verify_otp_options.dart'; +export 'src/myaccount/phone_type.dart'; export 'src/request/dpop_request.dart'; export 'src/request/request.dart'; export 'src/request/request_options.dart'; 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 new file mode 100644 index 00000000..93238f1d --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/myaccount/auth0_flutter_my_account_platform.dart @@ -0,0 +1,84 @@ +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +import '../request/request.dart'; +import 'authentication_method.dart'; +import 'enrollment_challenge.dart'; +import 'factor.dart'; +import 'method_channel_auth0_flutter_my_account.dart'; +import 'my_account_delete_auth_method_options.dart'; +import 'my_account_enroll_email_options.dart'; +import 'my_account_enroll_phone_options.dart'; +import 'my_account_enroll_push_options.dart'; +import 'my_account_enroll_recovery_code_options.dart'; +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_verify_otp_options.dart'; + +abstract class Auth0FlutterMyAccountPlatform extends PlatformInterface { + Auth0FlutterMyAccountPlatform() : super(token: _token); + + static Auth0FlutterMyAccountPlatform get instance => _instance; + static final Object _token = Object(); + static Auth0FlutterMyAccountPlatform _instance = + MethodChannelAuth0FlutterMyAccount(); + + static set instance(final Auth0FlutterMyAccountPlatform instance) { + PlatformInterface.verifyToken(instance, _token); + _instance = instance; + } + + Future> getAuthenticationMethods( + final ApiRequest request) { + throw UnimplementedError( + 'getAuthenticationMethods() has not been implemented'); + } + + Future getAuthenticationMethod( + final ApiRequest request) { + throw UnimplementedError( + 'getAuthenticationMethod() has not been implemented'); + } + + Future deleteAuthenticationMethod( + final ApiRequest request) { + throw UnimplementedError( + 'deleteAuthenticationMethod() has not been implemented'); + } + + Future> getFactors( + final ApiRequest request) { + throw UnimplementedError('getFactors() has not been implemented'); + } + + Future enrollPhone( + final ApiRequest request) { + throw UnimplementedError('enrollPhone() has not been implemented'); + } + + Future enrollEmail( + final ApiRequest request) { + throw UnimplementedError('enrollEmail() has not been implemented'); + } + + Future enrollTotp( + final ApiRequest request) { + throw UnimplementedError('enrollTotp() has not been implemented'); + } + + Future enrollPush( + final ApiRequest request) { + throw UnimplementedError('enrollPush() has not been implemented'); + } + + Future enrollRecoveryCode( + final ApiRequest request) { + throw UnimplementedError('enrollRecoveryCode() has not been implemented'); + } + + Future verifyOtp( + final ApiRequest request) { + throw UnimplementedError('verifyOtp() has not been implemented'); + } +} diff --git a/auth0_flutter_platform_interface/lib/src/myaccount/authentication_method.dart b/auth0_flutter_platform_interface/lib/src/myaccount/authentication_method.dart new file mode 100644 index 00000000..5d39b94f --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/myaccount/authentication_method.dart @@ -0,0 +1,67 @@ +class AuthenticationMethod { + final String id; + final String type; + final String? name; + final String? phoneNumber; + final String? email; + final String? totpSecret; + final String? totpUri; + final String? preferredAuthenticationMethod; + final DateTime? createdAt; + final DateTime? lastAuthAt; + final bool? confirmed; + final List? usage; + + const AuthenticationMethod({ + required this.id, + required this.type, + this.name, + this.phoneNumber, + this.email, + this.totpSecret, + this.totpUri, + this.preferredAuthenticationMethod, + this.createdAt, + this.lastAuthAt, + this.confirmed, + this.usage, + }); + + factory AuthenticationMethod.fromMap(final Map result) => + AuthenticationMethod( + id: result['id'] as String, + type: result['type'] as String, + name: result['name'] as String?, + phoneNumber: result['phone_number'] as String?, + email: result['email'] as String?, + totpSecret: result['totp_secret'] as String?, + totpUri: result['totp_uri'] as String?, + preferredAuthenticationMethod: + result['preferred_authentication_method'] as String?, + createdAt: result['created_at'] != null + ? DateTime.parse(result['created_at'] as String) + : null, + lastAuthAt: result['last_auth_at'] != null + ? DateTime.parse(result['last_auth_at'] as String) + : null, + confirmed: result['confirmed'] as bool?, + usage: (result['usage'] as List?) + ?.map((final e) => e as String) + .toList(), + ); + + Map toMap() => { + 'id': id, + 'type': type, + 'name': name, + 'phone_number': phoneNumber, + 'email': email, + 'totp_secret': totpSecret, + 'totp_uri': totpUri, + 'preferred_authentication_method': preferredAuthenticationMethod, + 'created_at': createdAt?.toUtc().toIso8601String(), + 'last_auth_at': lastAuthAt?.toUtc().toIso8601String(), + 'confirmed': confirmed, + 'usage': usage, + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/myaccount/enrollment_challenge.dart b/auth0_flutter_platform_interface/lib/src/myaccount/enrollment_challenge.dart new file mode 100644 index 00000000..1e13237a --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/myaccount/enrollment_challenge.dart @@ -0,0 +1,36 @@ +class EnrollmentChallenge { + final String id; + final String authSession; + final String? totpSecret; + final String? totpUri; + final String? barcodeUri; + final String? recoveryCode; + + const EnrollmentChallenge({ + required this.id, + required this.authSession, + this.totpSecret, + this.totpUri, + this.barcodeUri, + this.recoveryCode, + }); + + factory EnrollmentChallenge.fromMap(final Map result) => + EnrollmentChallenge( + id: result['id'] as String, + authSession: result['auth_session'] as String, + totpSecret: result['totp_secret'] as String?, + totpUri: result['totp_uri'] as String?, + barcodeUri: result['barcode_uri'] as String?, + recoveryCode: result['recovery_code'] as String?, + ); + + Map toMap() => { + 'id': id, + 'auth_session': authSession, + 'totp_secret': totpSecret, + 'totp_uri': totpUri, + 'barcode_uri': barcodeUri, + 'recovery_code': recoveryCode, + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/myaccount/factor.dart b/auth0_flutter_platform_interface/lib/src/myaccount/factor.dart new file mode 100644 index 00000000..cabf7025 --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/myaccount/factor.dart @@ -0,0 +1,21 @@ +class Factor { + final String type; + final List? usage; + + const Factor({ + required this.type, + this.usage, + }); + + factory Factor.fromMap(final Map result) => Factor( + type: result['type'] as String, + usage: (result['usage'] as List?) + ?.map((final e) => e as String) + .toList(), + ); + + Map toMap() => { + 'type': type, + 'usage': usage, + }; +} 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 new file mode 100644 index 00000000..9855fab2 --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/myaccount/method_channel_auth0_flutter_my_account.dart @@ -0,0 +1,173 @@ +import 'package:flutter/services.dart'; + +import '../request/request.dart'; +import '../request/request_options.dart'; +import 'auth0_flutter_my_account_platform.dart'; +import 'authentication_method.dart'; +import 'enrollment_challenge.dart'; +import 'factor.dart'; +import 'my_account_delete_auth_method_options.dart'; +import 'my_account_enroll_email_options.dart'; +import 'my_account_enroll_phone_options.dart'; +import 'my_account_enroll_push_options.dart'; +import 'my_account_enroll_recovery_code_options.dart'; +import 'my_account_enroll_totp_options.dart'; +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_verify_otp_options.dart'; + +const MethodChannel _channel = + MethodChannel('auth0.com/auth0_flutter/my_account'); + +const String myAccountGetAuthMethodsMethod = + 'myAccount#getAuthenticationMethods'; +const String myAccountGetAuthMethodMethod = + 'myAccount#getAuthenticationMethod'; +const String myAccountDeleteAuthMethodMethod = + 'myAccount#deleteAuthenticationMethod'; +const String myAccountGetFactorsMethod = 'myAccount#getFactors'; +const String myAccountEnrollPhoneMethod = 'myAccount#enrollPhone'; +const String myAccountEnrollEmailMethod = 'myAccount#enrollEmail'; +const String myAccountEnrollTotpMethod = 'myAccount#enrollTotp'; +const String myAccountEnrollPushMethod = 'myAccount#enrollPush'; +const String myAccountEnrollRecoveryCodeMethod = 'myAccount#enrollRecoveryCode'; +const String myAccountVerifyOtpMethod = 'myAccount#verifyOtp'; + +class MethodChannelAuth0FlutterMyAccount + extends Auth0FlutterMyAccountPlatform { + @override + Future> getAuthenticationMethods( + final ApiRequest request) async { + final List result = await invokeListRequest( + method: myAccountGetAuthMethodsMethod, request: request); + + return result + .map((final item) => AuthenticationMethod.fromMap( + Map.from(item as Map))) + .toList(); + } + + @override + Future getAuthenticationMethod( + final ApiRequest request) async { + final Map result = await invokeMapRequest( + method: myAccountGetAuthMethodMethod, request: request); + + return AuthenticationMethod.fromMap(result); + } + + @override + Future deleteAuthenticationMethod( + final ApiRequest request) async { + await invokeMapRequest( + method: myAccountDeleteAuthMethodMethod, + request: request, + throwOnNull: false); + } + + @override + Future> getFactors( + final ApiRequest request) async { + final List result = await invokeListRequest( + method: myAccountGetFactorsMethod, request: request); + + return result + .map((final item) => + Factor.fromMap(Map.from(item as Map))) + .toList(); + } + + @override + Future enrollPhone( + final ApiRequest request) async { + final Map result = await invokeMapRequest( + method: myAccountEnrollPhoneMethod, request: request); + + return EnrollmentChallenge.fromMap(result); + } + + @override + Future enrollEmail( + final ApiRequest request) async { + final Map result = await invokeMapRequest( + method: myAccountEnrollEmailMethod, request: request); + + return EnrollmentChallenge.fromMap(result); + } + + @override + Future enrollTotp( + final ApiRequest request) async { + final Map result = await invokeMapRequest( + method: myAccountEnrollTotpMethod, request: request); + + return EnrollmentChallenge.fromMap(result); + } + + @override + Future enrollPush( + final ApiRequest request) async { + final Map result = await invokeMapRequest( + method: myAccountEnrollPushMethod, request: request); + + return EnrollmentChallenge.fromMap(result); + } + + @override + Future enrollRecoveryCode( + final ApiRequest request) async { + final Map result = await invokeMapRequest( + method: myAccountEnrollRecoveryCodeMethod, request: request); + + return EnrollmentChallenge.fromMap(result); + } + + @override + Future verifyOtp( + final ApiRequest request) async { + final Map result = await invokeMapRequest( + method: myAccountVerifyOtpMethod, request: request); + + return AuthenticationMethod.fromMap(result); + } + + Future> + invokeMapRequest({ + required final String method, + required final ApiRequest request, + final bool? throwOnNull = true, + }) async { + final Map? result; + try { + result = await _channel.invokeMapMethod(method, request.toMap()); + } on PlatformException catch (e) { + throw MyAccountException.fromPlatformException(e); + } + + if (result == null && throwOnNull == true) { + throw const MyAccountException.unknown('Channel returned null.'); + } + + return result ?? {}; + } + + Future> invokeListRequest({ + required final String method, + required final ApiRequest request, + }) async { + final List? result; + try { + result = await _channel.invokeListMethod(method, request.toMap()); + } on PlatformException catch (e) { + throw MyAccountException.fromPlatformException(e); + } + + if (result == null) { + throw const MyAccountException.unknown('Channel returned null.'); + } + + return result; + } +} diff --git a/auth0_flutter_platform_interface/lib/src/myaccount/my_account_delete_auth_method_options.dart b/auth0_flutter_platform_interface/lib/src/myaccount/my_account_delete_auth_method_options.dart new file mode 100644 index 00000000..80c1c4b7 --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/myaccount/my_account_delete_auth_method_options.dart @@ -0,0 +1,17 @@ +import '../request/request_options.dart'; + +class MyAccountDeleteAuthMethodOptions implements RequestOptions { + final String accessToken; + final String id; + + MyAccountDeleteAuthMethodOptions({ + required this.accessToken, + required this.id, + }); + + @override + Map toMap() => { + 'accessToken': accessToken, + 'id': id, + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/myaccount/my_account_enroll_email_options.dart b/auth0_flutter_platform_interface/lib/src/myaccount/my_account_enroll_email_options.dart new file mode 100644 index 00000000..3f492b08 --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/myaccount/my_account_enroll_email_options.dart @@ -0,0 +1,17 @@ +import '../request/request_options.dart'; + +class MyAccountEnrollEmailOptions implements RequestOptions { + final String accessToken; + final String email; + + MyAccountEnrollEmailOptions({ + required this.accessToken, + required this.email, + }); + + @override + Map toMap() => { + 'accessToken': accessToken, + 'email': email, + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/myaccount/my_account_enroll_phone_options.dart b/auth0_flutter_platform_interface/lib/src/myaccount/my_account_enroll_phone_options.dart new file mode 100644 index 00000000..cc6e27ff --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/myaccount/my_account_enroll_phone_options.dart @@ -0,0 +1,21 @@ +import '../request/request_options.dart'; +import 'phone_type.dart'; + +class MyAccountEnrollPhoneOptions implements RequestOptions { + final String accessToken; + final String phoneNumber; + final PhoneType type; + + MyAccountEnrollPhoneOptions({ + required this.accessToken, + required this.phoneNumber, + required this.type, + }); + + @override + Map toMap() => { + 'accessToken': accessToken, + 'phoneNumber': phoneNumber, + 'type': type.toValue(), + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/myaccount/my_account_enroll_push_options.dart b/auth0_flutter_platform_interface/lib/src/myaccount/my_account_enroll_push_options.dart new file mode 100644 index 00000000..97008947 --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/myaccount/my_account_enroll_push_options.dart @@ -0,0 +1,12 @@ +import '../request/request_options.dart'; + +class MyAccountEnrollPushOptions implements RequestOptions { + final String accessToken; + + MyAccountEnrollPushOptions({required this.accessToken}); + + @override + Map toMap() => { + 'accessToken': accessToken, + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/myaccount/my_account_enroll_recovery_code_options.dart b/auth0_flutter_platform_interface/lib/src/myaccount/my_account_enroll_recovery_code_options.dart new file mode 100644 index 00000000..7da403df --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/myaccount/my_account_enroll_recovery_code_options.dart @@ -0,0 +1,12 @@ +import '../request/request_options.dart'; + +class MyAccountEnrollRecoveryCodeOptions implements RequestOptions { + final String accessToken; + + MyAccountEnrollRecoveryCodeOptions({required this.accessToken}); + + @override + Map toMap() => { + 'accessToken': accessToken, + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/myaccount/my_account_enroll_totp_options.dart b/auth0_flutter_platform_interface/lib/src/myaccount/my_account_enroll_totp_options.dart new file mode 100644 index 00000000..5b2c5239 --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/myaccount/my_account_enroll_totp_options.dart @@ -0,0 +1,12 @@ +import '../request/request_options.dart'; + +class MyAccountEnrollTotpOptions implements RequestOptions { + final String accessToken; + + MyAccountEnrollTotpOptions({required this.accessToken}); + + @override + Map toMap() => { + 'accessToken': accessToken, + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/myaccount/my_account_exception.dart b/auth0_flutter_platform_interface/lib/src/myaccount/my_account_exception.dart new file mode 100644 index 00000000..75611459 --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/myaccount/my_account_exception.dart @@ -0,0 +1,50 @@ +import 'package:flutter/services.dart'; + +import '../auth0_exception.dart'; +import '../extensions/exception_extensions.dart'; +import '../extensions/map_extensions.dart'; + +class MyAccountException extends Auth0Exception { + static const _statusCodeKey = '_statusCode'; + static const _titleKey = '_title'; + static const _detailKey = '_detail'; + static const _errorFlagsKey = '_errorFlags'; + + final int statusCode; + final String title; + final String detail; + final Map _errorFlags; + + const MyAccountException(final String code, final String message, + final Map details, this._errorFlags, this.statusCode, + this.title, this.detail) + : super(code, message, details); + + const MyAccountException.unknown(final String message) + : _errorFlags = const {}, + statusCode = 0, + title = '', + detail = '', + super.unknown(message); + + factory MyAccountException.fromPlatformException(final PlatformException e) { + final Map errorDetails = e.detailsMap; + final statusCode = errorDetails.getOrDefault(_statusCodeKey, 0); + final title = errorDetails.getOrDefault(_titleKey, ''); + final detail = errorDetails.getOrDefault(_detailKey, ''); + final errorFlags = + errorDetails.getOrDefault(_errorFlagsKey, {}); + + errorDetails.remove(_statusCodeKey); + errorDetails.remove(_titleKey); + errorDetails.remove(_detailKey); + errorDetails.remove(_errorFlagsKey); + + return MyAccountException( + e.code, e.messageString, errorDetails, errorFlags, statusCode, + title, detail); + } + + bool get isNetworkError => _errorFlags.getBooleanOrFalse('isNetworkError'); + bool get isRetryable => isNetworkError; +} diff --git a/auth0_flutter_platform_interface/lib/src/myaccount/my_account_get_auth_method_options.dart b/auth0_flutter_platform_interface/lib/src/myaccount/my_account_get_auth_method_options.dart new file mode 100644 index 00000000..47b6af30 --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/myaccount/my_account_get_auth_method_options.dart @@ -0,0 +1,17 @@ +import '../request/request_options.dart'; + +class MyAccountGetAuthMethodOptions implements RequestOptions { + final String accessToken; + final String id; + + MyAccountGetAuthMethodOptions({ + required this.accessToken, + required this.id, + }); + + @override + Map toMap() => { + 'accessToken': accessToken, + 'id': id, + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/myaccount/my_account_get_auth_methods_options.dart b/auth0_flutter_platform_interface/lib/src/myaccount/my_account_get_auth_methods_options.dart new file mode 100644 index 00000000..a9ab2f27 --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/myaccount/my_account_get_auth_methods_options.dart @@ -0,0 +1,12 @@ +import '../request/request_options.dart'; + +class MyAccountGetAuthMethodsOptions implements RequestOptions { + final String accessToken; + + MyAccountGetAuthMethodsOptions({required this.accessToken}); + + @override + Map toMap() => { + 'accessToken': accessToken, + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/myaccount/my_account_get_factors_options.dart b/auth0_flutter_platform_interface/lib/src/myaccount/my_account_get_factors_options.dart new file mode 100644 index 00000000..167a395e --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/myaccount/my_account_get_factors_options.dart @@ -0,0 +1,12 @@ +import '../request/request_options.dart'; + +class MyAccountGetFactorsOptions implements RequestOptions { + final String accessToken; + + MyAccountGetFactorsOptions({required this.accessToken}); + + @override + Map toMap() => { + 'accessToken': accessToken, + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/myaccount/my_account_verify_otp_options.dart b/auth0_flutter_platform_interface/lib/src/myaccount/my_account_verify_otp_options.dart new file mode 100644 index 00000000..61f8272e --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/myaccount/my_account_verify_otp_options.dart @@ -0,0 +1,23 @@ +import '../request/request_options.dart'; + +class MyAccountVerifyOtpOptions implements RequestOptions { + final String accessToken; + final String id; + final String authSession; + final String otp; + + MyAccountVerifyOtpOptions({ + required this.accessToken, + required this.id, + required this.authSession, + required this.otp, + }); + + @override + Map toMap() => { + 'accessToken': accessToken, + 'id': id, + 'authSession': authSession, + 'otp': otp, + }; +} diff --git a/auth0_flutter_platform_interface/lib/src/myaccount/phone_type.dart b/auth0_flutter_platform_interface/lib/src/myaccount/phone_type.dart new file mode 100644 index 00000000..11f7d243 --- /dev/null +++ b/auth0_flutter_platform_interface/lib/src/myaccount/phone_type.dart @@ -0,0 +1,24 @@ +enum PhoneType { + sms, + voice; + + String toValue() { + switch (this) { + case PhoneType.sms: + return 'sms'; + case PhoneType.voice: + return 'voice'; + } + } + + static PhoneType fromValue(final String value) { + switch (value) { + case 'sms': + return PhoneType.sms; + case 'voice': + return PhoneType.voice; + default: + return PhoneType.sms; + } + } +}